feat(agent): agentic chat with wallet auth and tiered model routing
- New /api/agent/chat endpoint with Venice tool-calling loop (max 8 iter)
- Tiered models: glm-4.7-flash default, kimi-k2-thinking for Elite+
- Wallet auth: /api/auth/{nonce,verify,whoami,logout} with Ed25519 + JWT
- 10 tools registered: site search, crypto prices, SITREP, .sol lookup,
wallet xray, contraband/awesomelist search, changelog, trigger_effect
- Per-tool rate limits, 30s timeout, \$30/mo budget guard
- Frontend: tier badge, tool call cards, wallet sign-in handshake
- Changelog v1.40.0
This commit is contained in:
parent
2c498efbfb
commit
e73b74cfa2
10 changed files with 1581 additions and 79 deletions
321
api/agent_routes.py
Normal file
321
api/agent_routes.py
Normal file
|
|
@ -0,0 +1,321 @@
|
||||||
|
"""Agentic chat endpoint for JAE-AI.
|
||||||
|
|
||||||
|
POST /api/agent/chat
|
||||||
|
{messages: [{role, content}], session_id?}
|
||||||
|
|
||||||
|
Runs an agent loop calling Venice API with tool-calling support.
|
||||||
|
Tier-routed model selection, per-(identity, tool) rate limiting,
|
||||||
|
monthly output-token budget cap.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import calendar
|
||||||
|
import datetime
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
import requests as req
|
||||||
|
|
||||||
|
from auth_routes import read_session, fetch_sol_balance, _load_apikeys
|
||||||
|
from agent_tiers import compute_tier, pick_model, tier_allows
|
||||||
|
from agent_tools import (
|
||||||
|
TOOLS, get_allowed_tools, openai_tool_schemas, check_rate,
|
||||||
|
)
|
||||||
|
|
||||||
|
agent_bp = Blueprint('agent_bp', __name__, url_prefix='/api/agent')
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).parent / 'data'
|
||||||
|
BUDGET_FILE = DATA_DIR / 'agent_budget.json'
|
||||||
|
|
||||||
|
# Venice API
|
||||||
|
VENICE_URL = 'https://api.venice.ai/api/v1/chat/completions'
|
||||||
|
|
||||||
|
# Model pricing ($ per 1M tokens) — input, output
|
||||||
|
MODEL_PRICING = {
|
||||||
|
'zai-org-glm-4.7-flash': (0.125, 0.50),
|
||||||
|
'kimi-k2-thinking': (0.75, 3.20),
|
||||||
|
}
|
||||||
|
|
||||||
|
MONTHLY_BUDGET_USD = 30.0
|
||||||
|
MAX_ITERATIONS = 8
|
||||||
|
MAX_MESSAGES = 40 # cap sent to venice
|
||||||
|
|
||||||
|
_budget_lock = threading.Lock()
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = (
|
||||||
|
'You are JAE-AI, the agentic AI operator embedded at jaeswift.xyz — a military / cyberpunk '
|
||||||
|
'themed command centre. You speak like a tactical operator: terse, confident, a little cheeky. '
|
||||||
|
'You have access to tools to search the site, fetch live crypto prices, scan Solana wallets, '
|
||||||
|
'look up .sol domains, fetch SITREP briefings, and trigger sitewide visual effects on the '
|
||||||
|
"user's browser. Use tools proactively when helpful — don't just describe what you could do, "
|
||||||
|
'do it. After a tool returns, briefly explain the result in plain English. Keep replies under '
|
||||||
|
'200 words unless the user asks for detail. Never invent tool results. British English, occasional '
|
||||||
|
'profanity is fine, never censor words. Current date: {date}.'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_budget() -> dict:
|
||||||
|
try:
|
||||||
|
with open(BUDGET_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_budget(b: dict):
|
||||||
|
try:
|
||||||
|
BUDGET_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(BUDGET_FILE, 'w') as f:
|
||||||
|
json.dump(b, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[agent] budget save failed: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
def _current_month_key() -> str:
|
||||||
|
return datetime.datetime.utcnow().strftime('%Y-%m')
|
||||||
|
|
||||||
|
|
||||||
|
def _estimate_cost(model: str, in_tokens: int, out_tokens: int) -> float:
|
||||||
|
in_p, out_p = MODEL_PRICING.get(model, (0.5, 1.0))
|
||||||
|
return (in_tokens * in_p + out_tokens * out_p) / 1_000_000
|
||||||
|
|
||||||
|
|
||||||
|
def _record_usage(model: str, in_tokens: int, out_tokens: int) -> tuple[float, float]:
|
||||||
|
"""Update budget. Returns (month_total_usd, remaining_usd)."""
|
||||||
|
with _budget_lock:
|
||||||
|
b = _load_budget()
|
||||||
|
key = _current_month_key()
|
||||||
|
month = b.setdefault(key, {'total_usd': 0.0, 'calls': 0, 'by_model': {}})
|
||||||
|
cost = _estimate_cost(model, in_tokens, out_tokens)
|
||||||
|
month['total_usd'] = round(month['total_usd'] + cost, 6)
|
||||||
|
month['calls'] = month['calls'] + 1
|
||||||
|
mm = month['by_model'].setdefault(model, {'calls': 0, 'in_tokens': 0, 'out_tokens': 0, 'usd': 0.0})
|
||||||
|
mm['calls'] += 1
|
||||||
|
mm['in_tokens'] += in_tokens
|
||||||
|
mm['out_tokens'] += out_tokens
|
||||||
|
mm['usd'] = round(mm['usd'] + cost, 6)
|
||||||
|
_save_budget(b)
|
||||||
|
return month['total_usd'], max(0.0, MONTHLY_BUDGET_USD - month['total_usd'])
|
||||||
|
|
||||||
|
|
||||||
|
def _budget_exceeded() -> bool:
|
||||||
|
b = _load_budget()
|
||||||
|
month = b.get(_current_month_key(), {})
|
||||||
|
return month.get('total_usd', 0) >= MONTHLY_BUDGET_USD
|
||||||
|
|
||||||
|
|
||||||
|
def _identity_key() -> str:
|
||||||
|
sess = read_session()
|
||||||
|
if sess and sess.get('address'):
|
||||||
|
return f"wallet:{sess['address']}"
|
||||||
|
return f"ip:{request.headers.get('X-Forwarded-For', request.remote_addr or 'unknown').split(',')[0].strip()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_tool(name: str, args: dict, identity: str) -> dict:
|
||||||
|
tool = TOOLS.get(name)
|
||||||
|
if not tool:
|
||||||
|
return {'error': f'unknown tool: {name}'}
|
||||||
|
# Rate limit check
|
||||||
|
rate_key = (identity, name)
|
||||||
|
if not check_rate(rate_key, tool.rate_limit):
|
||||||
|
return {'error': f'rate limit exceeded for {name} ({tool.rate_limit})'}
|
||||||
|
try:
|
||||||
|
return tool.handler(args or {})
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': f'tool {name} failed: {e}'}
|
||||||
|
|
||||||
|
|
||||||
|
def _call_venice(model: str, messages: list, tools_schema: list, api_key: str) -> dict:
|
||||||
|
resp = req.post(
|
||||||
|
VENICE_URL,
|
||||||
|
headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
|
||||||
|
json={
|
||||||
|
'model': model,
|
||||||
|
'messages': messages,
|
||||||
|
'tools': tools_schema if tools_schema else None,
|
||||||
|
'tool_choice': 'auto' if tools_schema else None,
|
||||||
|
'temperature': 0.7,
|
||||||
|
'max_tokens': 1500,
|
||||||
|
},
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise RuntimeError(f'Venice HTTP {resp.status_code}: {resp.text[:400]}')
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@agent_bp.route('/chat', methods=['POST'])
|
||||||
|
def agent_chat():
|
||||||
|
# ── Identity / tier ──────────────────────────────────────────────────
|
||||||
|
sess = read_session()
|
||||||
|
if sess and sess.get('address'):
|
||||||
|
address = sess['address']
|
||||||
|
balance = fetch_sol_balance(address)
|
||||||
|
tier = compute_tier(address, balance)
|
||||||
|
else:
|
||||||
|
address = None
|
||||||
|
tier = 'anonymous'
|
||||||
|
|
||||||
|
# ── Budget guard ────────────────────────────────────────────────────
|
||||||
|
if _budget_exceeded():
|
||||||
|
return jsonify({
|
||||||
|
'error': 'Monthly agent budget exceeded — please try again next month',
|
||||||
|
'budget_usd': MONTHLY_BUDGET_USD,
|
||||||
|
}), 429
|
||||||
|
|
||||||
|
# ── Input ───────────────────────────────────────────────────────────
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
messages_in = data.get('messages') or []
|
||||||
|
if not isinstance(messages_in, list) or not messages_in:
|
||||||
|
return jsonify({'error': 'messages array required'}), 400
|
||||||
|
memory_context = (data.get('memory_context') or '').strip()
|
||||||
|
|
||||||
|
# Sanitise + cap
|
||||||
|
cleaned = []
|
||||||
|
for m in messages_in[-MAX_MESSAGES:]:
|
||||||
|
role = m.get('role')
|
||||||
|
if role not in ('user', 'assistant', 'system', 'tool'):
|
||||||
|
continue
|
||||||
|
cleaned.append({k: v for k, v in m.items() if k in ('role', 'content', 'tool_calls', 'tool_call_id', 'name')})
|
||||||
|
if not cleaned:
|
||||||
|
return jsonify({'error': 'no valid messages'}), 400
|
||||||
|
|
||||||
|
# ── Build system prompt ─────────────────────────────────────────────
|
||||||
|
sys_prompt = SYSTEM_PROMPT.format(date=datetime.date.today().isoformat())
|
||||||
|
if memory_context and tier != 'anonymous':
|
||||||
|
sys_prompt = memory_context + '\n\n' + sys_prompt
|
||||||
|
if tier == 'elite':
|
||||||
|
sys_prompt += '\n\n[TIER: ELITE — $JAE holder. Greet them accordingly.]'
|
||||||
|
elif tier == 'admin':
|
||||||
|
sys_prompt += '\n\n[TIER: ADMIN — this is Jae, the site operator.]'
|
||||||
|
elif tier == 'operator':
|
||||||
|
sys_prompt += '\n\n[TIER: OPERATOR — wallet authenticated.]'
|
||||||
|
|
||||||
|
messages = [{'role': 'system', 'content': sys_prompt}] + cleaned
|
||||||
|
|
||||||
|
# ── Model + tools ───────────────────────────────────────────────────
|
||||||
|
model = pick_model(tier)
|
||||||
|
allowed = get_allowed_tools(tier)
|
||||||
|
tools_schema = openai_tool_schemas(allowed)
|
||||||
|
|
||||||
|
# ── API key ─────────────────────────────────────────────────────────
|
||||||
|
keys = _load_apikeys()
|
||||||
|
api_key = (keys.get('venice') or {}).get('api_key', '')
|
||||||
|
if not api_key:
|
||||||
|
return jsonify({'error': 'Venice API key not configured'}), 500
|
||||||
|
|
||||||
|
identity = _identity_key()
|
||||||
|
tool_trace = []
|
||||||
|
total_in = 0
|
||||||
|
total_out = 0
|
||||||
|
final_content = ''
|
||||||
|
frontend_actions = [] # effects etc. to execute client-side
|
||||||
|
|
||||||
|
try:
|
||||||
|
for iteration in range(MAX_ITERATIONS):
|
||||||
|
resp_json = _call_venice(model, messages, tools_schema, api_key)
|
||||||
|
|
||||||
|
usage = resp_json.get('usage') or {}
|
||||||
|
total_in += int(usage.get('prompt_tokens', 0) or 0)
|
||||||
|
total_out += int(usage.get('completion_tokens', 0) or 0)
|
||||||
|
|
||||||
|
choice = (resp_json.get('choices') or [{}])[0]
|
||||||
|
msg = choice.get('message') or {}
|
||||||
|
tool_calls = msg.get('tool_calls') or []
|
||||||
|
|
||||||
|
# Append assistant turn
|
||||||
|
assistant_turn = {'role': 'assistant', 'content': msg.get('content') or ''}
|
||||||
|
if tool_calls:
|
||||||
|
assistant_turn['tool_calls'] = tool_calls
|
||||||
|
messages.append(assistant_turn)
|
||||||
|
|
||||||
|
if not tool_calls:
|
||||||
|
final_content = msg.get('content') or msg.get('reasoning_content') or ''
|
||||||
|
break
|
||||||
|
|
||||||
|
# Execute tool calls
|
||||||
|
for tc in tool_calls:
|
||||||
|
fn = (tc.get('function') or {})
|
||||||
|
name = fn.get('name', '')
|
||||||
|
raw_args = fn.get('arguments') or '{}'
|
||||||
|
try:
|
||||||
|
args = json.loads(raw_args) if isinstance(raw_args, str) else (raw_args or {})
|
||||||
|
except Exception:
|
||||||
|
args = {}
|
||||||
|
result = _execute_tool(name, args, identity)
|
||||||
|
tool_trace.append({
|
||||||
|
'name': name, 'args': args, 'result': result,
|
||||||
|
'iteration': iteration,
|
||||||
|
})
|
||||||
|
# Capture frontend trigger_effect action
|
||||||
|
if isinstance(result, dict) and result.get('action') == 'trigger_effect':
|
||||||
|
frontend_actions.append({
|
||||||
|
'action': 'trigger_effect',
|
||||||
|
'effect': result.get('effect'),
|
||||||
|
})
|
||||||
|
messages.append({
|
||||||
|
'role': 'tool',
|
||||||
|
'tool_call_id': tc.get('id'),
|
||||||
|
'name': name,
|
||||||
|
'content': json.dumps(result, default=str)[:4000],
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
final_content = final_content or '(agent reached iteration limit)'
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'error': f'agent failure: {e}',
|
||||||
|
'tool_calls': tool_trace,
|
||||||
|
'tier': tier,
|
||||||
|
'model_used': model,
|
||||||
|
}), 502
|
||||||
|
|
||||||
|
# ── Record usage ────────────────────────────────────────────────────
|
||||||
|
month_total, remaining = _record_usage(model, total_in, total_out)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'content': final_content,
|
||||||
|
'tool_calls': tool_trace,
|
||||||
|
'frontend_actions': frontend_actions,
|
||||||
|
'model_used': model,
|
||||||
|
'tier': tier,
|
||||||
|
'authenticated': bool(address),
|
||||||
|
'address': address,
|
||||||
|
'tokens': {'in': total_in, 'out': total_out},
|
||||||
|
'budget': {
|
||||||
|
'month_usd': round(month_total, 4),
|
||||||
|
'remaining_usd': round(remaining, 4),
|
||||||
|
'cap_usd': MONTHLY_BUDGET_USD,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@agent_bp.route('/tools', methods=['GET'])
|
||||||
|
def list_tools():
|
||||||
|
"""Debug: list tools available to current tier."""
|
||||||
|
sess = read_session()
|
||||||
|
address = sess.get('address') if sess else None
|
||||||
|
balance = fetch_sol_balance(address) if address else 0.0
|
||||||
|
tier = compute_tier(address, balance) if address else 'anonymous'
|
||||||
|
allowed = get_allowed_tools(tier)
|
||||||
|
return jsonify({
|
||||||
|
'tier': tier,
|
||||||
|
'model': pick_model(tier),
|
||||||
|
'tools': [{'name': t.name, 'tier': t.tier, 'rate_limit': t.rate_limit,
|
||||||
|
'description': t.description} for t in allowed],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@agent_bp.route('/budget', methods=['GET'])
|
||||||
|
def budget_status():
|
||||||
|
b = _load_budget()
|
||||||
|
month = b.get(_current_month_key(), {'total_usd': 0.0, 'calls': 0, 'by_model': {}})
|
||||||
|
return jsonify({
|
||||||
|
'month': _current_month_key(),
|
||||||
|
'cap_usd': MONTHLY_BUDGET_USD,
|
||||||
|
'total_usd': round(month.get('total_usd', 0), 4),
|
||||||
|
'remaining_usd': round(max(0.0, MONTHLY_BUDGET_USD - month.get('total_usd', 0)), 4),
|
||||||
|
'calls': month.get('calls', 0),
|
||||||
|
'by_model': month.get('by_model', {}),
|
||||||
|
})
|
||||||
44
api/agent_tiers.py
Normal file
44
api/agent_tiers.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
"""Tier system for JAE-AI agent — access control + model routing."""
|
||||||
|
|
||||||
|
TIERS = ['anonymous', 'operator', 'elite', 'admin']
|
||||||
|
TIER_RANK = {name: idx for idx, name in enumerate(TIERS)}
|
||||||
|
|
||||||
|
# Admin wallets — Solana base58 pubkeys. Populate when confirmed.
|
||||||
|
ADMIN_WALLETS = [
|
||||||
|
'9NuiHnHQ9kQVj8JwPCpmE4VU1MhwcYL9gWbkiAyn8', # jaeswift.sol
|
||||||
|
]
|
||||||
|
|
||||||
|
# Model routing per tier
|
||||||
|
MODEL_ROUTING = {
|
||||||
|
'anonymous': 'zai-org-glm-4.7-flash',
|
||||||
|
'operator': 'zai-org-glm-4.7-flash',
|
||||||
|
'elite': 'kimi-k2-thinking',
|
||||||
|
'admin': 'kimi-k2-thinking',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_tier(address: str | None, balance_sol: float = 0.0) -> str:
|
||||||
|
"""Derive tier from wallet address + SOL holdings."""
|
||||||
|
if not address:
|
||||||
|
return 'anonymous'
|
||||||
|
if address in ADMIN_WALLETS:
|
||||||
|
return 'admin'
|
||||||
|
try:
|
||||||
|
if float(balance_sol) >= 1.0:
|
||||||
|
return 'elite'
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
return 'operator'
|
||||||
|
|
||||||
|
|
||||||
|
def tier_rank(tier: str) -> int:
|
||||||
|
return TIER_RANK.get(tier, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def tier_allows(user_tier: str, required_tier: str) -> bool:
|
||||||
|
"""True when user_tier >= required_tier in the hierarchy."""
|
||||||
|
return tier_rank(user_tier) >= tier_rank(required_tier)
|
||||||
|
|
||||||
|
|
||||||
|
def pick_model(tier: str) -> str:
|
||||||
|
return MODEL_ROUTING.get(tier, MODEL_ROUTING['anonymous'])
|
||||||
539
api/agent_tools.py
Normal file
539
api/agent_tools.py
Normal file
|
|
@ -0,0 +1,539 @@
|
||||||
|
"""Agent tool registry + implementations for the JAE-AI agentic chat.
|
||||||
|
|
||||||
|
Register once on import. Each Tool carries:
|
||||||
|
- JSON schema for Venice function-calling
|
||||||
|
- Handler callable
|
||||||
|
- Minimum tier
|
||||||
|
- Rate limit spec ('N/min' or 'N/hour')
|
||||||
|
- Timeout (seconds)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import re
|
||||||
|
from collections import defaultdict, deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Any
|
||||||
|
|
||||||
|
import requests as req
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).parent / 'data'
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Tool:
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
params: dict
|
||||||
|
handler: Callable[[dict], Any]
|
||||||
|
tier: str = 'anonymous'
|
||||||
|
rate_limit: str = '60/min'
|
||||||
|
timeout: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
TOOLS: dict[str, Tool] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register(tool: Tool):
|
||||||
|
TOOLS[tool.name] = tool
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rate limiting (per (identity, tool) key) ─────────────────────────────
|
||||||
|
_rate_state: dict[tuple, deque] = defaultdict(lambda: deque(maxlen=200))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_spec(spec: str) -> tuple[int, int]:
|
||||||
|
"""'10/min' -> (10, 60). Supports /sec /min /hour /day."""
|
||||||
|
try:
|
||||||
|
n, unit = spec.split('/')
|
||||||
|
n = int(n)
|
||||||
|
except Exception:
|
||||||
|
return 60, 60
|
||||||
|
unit = unit.lower().strip()
|
||||||
|
period = {'sec': 1, 'second': 1, 'min': 60, 'minute': 60,
|
||||||
|
'hour': 3600, 'hr': 3600, 'day': 86400}.get(unit, 60)
|
||||||
|
return n, period
|
||||||
|
|
||||||
|
|
||||||
|
def check_rate(key: tuple, spec: str) -> bool:
|
||||||
|
limit, period = parse_spec(spec)
|
||||||
|
now = time.time()
|
||||||
|
q = _rate_state[key]
|
||||||
|
while q and q[0] < now - period:
|
||||||
|
q.popleft()
|
||||||
|
if len(q) >= limit:
|
||||||
|
return False
|
||||||
|
q.append(now)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Simple TTL cache ─────────────────────────────────────────────────────
|
||||||
|
_cache: dict[str, tuple[float, Any]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def cache_get(key: str, ttl: float) -> Any | None:
|
||||||
|
entry = _cache.get(key)
|
||||||
|
if entry and time.time() - entry[0] < ttl:
|
||||||
|
return entry[1]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cache_set(key: str, value: Any):
|
||||||
|
_cache[key] = (time.time(), value)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(name: str) -> Any:
|
||||||
|
try:
|
||||||
|
with open(DATA_DIR / name) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Tool Handlers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def tool_search_site(args: dict) -> dict:
|
||||||
|
query = (args.get('query') or '').strip().lower()
|
||||||
|
if not query:
|
||||||
|
return {'error': 'query required', 'results': []}
|
||||||
|
results = []
|
||||||
|
# Index main content files
|
||||||
|
sources = [
|
||||||
|
('changelog', 'changelog.json'),
|
||||||
|
('navigation', 'navigation.json'),
|
||||||
|
('homepage', 'homepage.json'),
|
||||||
|
('managed_services', 'managed_services.json'),
|
||||||
|
('posts', 'posts.json'),
|
||||||
|
('links', 'links.json'),
|
||||||
|
('tracks', 'tracks.json'),
|
||||||
|
]
|
||||||
|
for label, fname in sources:
|
||||||
|
data = _load_json(fname)
|
||||||
|
if data is None:
|
||||||
|
continue
|
||||||
|
blob = json.dumps(data, ensure_ascii=False).lower()
|
||||||
|
if query in blob:
|
||||||
|
# Find first match snippet
|
||||||
|
idx = blob.find(query)
|
||||||
|
snippet = blob[max(0, idx - 80): idx + 160]
|
||||||
|
results.append({
|
||||||
|
'source': label,
|
||||||
|
'title': label.replace('_', ' ').title(),
|
||||||
|
'snippet': snippet.replace('\n', ' '),
|
||||||
|
'url': f'/api/{label}' if label != 'homepage' else '/',
|
||||||
|
})
|
||||||
|
return {'results': results[:5], 'count': len(results)}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_get_sol_price(args: dict) -> dict:
|
||||||
|
cached = cache_get('sol_price', 30)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
try:
|
||||||
|
r = req.get('https://api.binance.com/api/v3/ticker/24hr',
|
||||||
|
params={'symbol': 'SOLUSDT'}, timeout=8)
|
||||||
|
r.raise_for_status()
|
||||||
|
j = r.json()
|
||||||
|
out = {
|
||||||
|
'symbol': 'SOL',
|
||||||
|
'price_usd': float(j.get('lastPrice', 0)),
|
||||||
|
'change_24h_pct': float(j.get('priceChangePercent', 0)),
|
||||||
|
'volume_24h': float(j.get('volume', 0)),
|
||||||
|
'source': 'binance',
|
||||||
|
}
|
||||||
|
cache_set('sol_price', out)
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': f'price fetch failed: {e}'}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_get_crypto_price(args: dict) -> dict:
|
||||||
|
symbol = (args.get('symbol') or '').upper().strip()
|
||||||
|
if not symbol or not re.match(r'^[A-Z0-9]{2,10}$', symbol):
|
||||||
|
return {'error': 'valid symbol required (e.g. BTC, ETH)'}
|
||||||
|
ck = f'crypto_price:{symbol}'
|
||||||
|
cached = cache_get(ck, 30)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
pair = f'{symbol}USDT'
|
||||||
|
try:
|
||||||
|
r = req.get('https://api.binance.com/api/v3/ticker/24hr',
|
||||||
|
params={'symbol': pair}, timeout=8)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return {'error': f'symbol {symbol} not found on binance'}
|
||||||
|
j = r.json()
|
||||||
|
out = {
|
||||||
|
'symbol': symbol,
|
||||||
|
'price_usd': float(j.get('lastPrice', 0)),
|
||||||
|
'change_24h_pct': float(j.get('priceChangePercent', 0)),
|
||||||
|
'volume_24h': float(j.get('volume', 0)),
|
||||||
|
}
|
||||||
|
cache_set(ck, out)
|
||||||
|
return out
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': f'price fetch failed: {e}'}
|
||||||
|
|
||||||
|
|
||||||
|
def _search_entries(entries: list, query: str, fields: list[str], limit: int = 5) -> list:
|
||||||
|
q = query.lower()
|
||||||
|
matches = []
|
||||||
|
for e in entries:
|
||||||
|
blob = ' '.join(str(e.get(f, '')) for f in fields).lower()
|
||||||
|
if q in blob:
|
||||||
|
matches.append(e)
|
||||||
|
if len(matches) >= limit:
|
||||||
|
break
|
||||||
|
return matches
|
||||||
|
|
||||||
|
|
||||||
|
def tool_search_contraband(args: dict) -> dict:
|
||||||
|
query = (args.get('query') or '').strip()
|
||||||
|
category = (args.get('category') or '').strip().lower()
|
||||||
|
if not query:
|
||||||
|
return {'error': 'query required'}
|
||||||
|
data = _load_json('contraband.json') or {}
|
||||||
|
cats = data.get('categories', []) if isinstance(data, dict) else []
|
||||||
|
results = []
|
||||||
|
for cat in cats:
|
||||||
|
cat_name = (cat.get('name') or '').lower()
|
||||||
|
if category and category not in cat_name and category not in (cat.get('slug') or '').lower():
|
||||||
|
continue
|
||||||
|
for sub in cat.get('subcategories', []) or []:
|
||||||
|
for item in sub.get('items', []) or []:
|
||||||
|
blob = f"{item.get('title','')} {item.get('description','')} {item.get('url','')}".lower()
|
||||||
|
if query.lower() in blob:
|
||||||
|
results.append({
|
||||||
|
'title': item.get('title'),
|
||||||
|
'url': item.get('url'),
|
||||||
|
'description': (item.get('description') or '')[:200],
|
||||||
|
'category': cat.get('name'),
|
||||||
|
'subcategory': sub.get('name'),
|
||||||
|
})
|
||||||
|
if len(results) >= 5:
|
||||||
|
return {'results': results, 'count': len(results)}
|
||||||
|
return {'results': results, 'count': len(results)}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_search_awesomelist(args: dict) -> dict:
|
||||||
|
query = (args.get('query') or '').strip().lower()
|
||||||
|
if not query:
|
||||||
|
return {'error': 'query required'}
|
||||||
|
index = _load_json('awesomelist_index.json') or {}
|
||||||
|
sectors = index.get('sectors', []) if isinstance(index, dict) else []
|
||||||
|
results = []
|
||||||
|
# Search sector names first
|
||||||
|
for sec in sectors:
|
||||||
|
name = (sec.get('name') or '').lower()
|
||||||
|
desc = (sec.get('description') or '').lower()
|
||||||
|
if query in name or query in desc:
|
||||||
|
results.append({
|
||||||
|
'type': 'sector',
|
||||||
|
'name': sec.get('name'),
|
||||||
|
'slug': sec.get('slug'),
|
||||||
|
'count': sec.get('count', 0),
|
||||||
|
'url': f"/recon/awesomelist?sector={sec.get('slug')}",
|
||||||
|
})
|
||||||
|
# Search inside individual sector files for entry matches
|
||||||
|
awesome_dir = DATA_DIR / 'awesomelist'
|
||||||
|
if awesome_dir.exists() and len(results) < 5:
|
||||||
|
import os
|
||||||
|
for fname in sorted(os.listdir(awesome_dir))[:80]:
|
||||||
|
if len(results) >= 5:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
with open(awesome_dir / fname) as f:
|
||||||
|
sec_data = json.load(f)
|
||||||
|
for entry in (sec_data.get('entries') or [])[:300]:
|
||||||
|
blob = f"{entry.get('name','')} {entry.get('description','')}".lower()
|
||||||
|
if query in blob:
|
||||||
|
results.append({
|
||||||
|
'type': 'entry',
|
||||||
|
'name': entry.get('name'),
|
||||||
|
'url': entry.get('url'),
|
||||||
|
'description': (entry.get('description') or '')[:200],
|
||||||
|
'sector_file': fname,
|
||||||
|
})
|
||||||
|
if len(results) >= 5:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return {'results': results[:5], 'count': len(results)}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_get_sitrep(args: dict) -> dict:
|
||||||
|
date_arg = (args.get('date') or 'latest').strip()
|
||||||
|
sitrep_dir = DATA_DIR / 'sitreps'
|
||||||
|
if not sitrep_dir.exists():
|
||||||
|
return {'error': 'No SITREPs available yet', 'sitrep': None}
|
||||||
|
import os
|
||||||
|
files = sorted([f for f in os.listdir(sitrep_dir) if f.endswith('.json')], reverse=True)
|
||||||
|
if not files:
|
||||||
|
return {'error': 'No SITREPs archived', 'sitrep': None}
|
||||||
|
target = None
|
||||||
|
if date_arg == 'latest':
|
||||||
|
target = files[0]
|
||||||
|
else:
|
||||||
|
for f in files:
|
||||||
|
if date_arg in f:
|
||||||
|
target = f
|
||||||
|
break
|
||||||
|
if not target:
|
||||||
|
return {'error': f'No SITREP matching "{date_arg}"', 'available': files[:5]}
|
||||||
|
try:
|
||||||
|
with open(sitrep_dir / target) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
summary = data.get('summary') or data.get('content', '')[:800]
|
||||||
|
return {
|
||||||
|
'date': data.get('date', target.replace('.json', '')),
|
||||||
|
'summary': summary[:1200],
|
||||||
|
'source_count': len(data.get('sources', [])),
|
||||||
|
'sectors': list((data.get('sectors') or {}).keys()),
|
||||||
|
'url': '/transmissions/sitrep',
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': f'Failed to read SITREP: {e}'}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_lookup_sol_domain(args: dict) -> dict:
|
||||||
|
name = (args.get('name') or '').strip().lower().replace('.sol', '')
|
||||||
|
if not name or not re.match(r'^[a-z0-9_-]{1,63}$', name):
|
||||||
|
return {'error': 'invalid domain name'}
|
||||||
|
try:
|
||||||
|
# Bonfida SNS public API
|
||||||
|
r = req.get(f'https://sns-api.bonfida.com/domain/lookup/{name}', timeout=8)
|
||||||
|
if r.status_code == 200:
|
||||||
|
j = r.json()
|
||||||
|
return {
|
||||||
|
'name': f'{name}.sol',
|
||||||
|
'registered': True,
|
||||||
|
'owner': j.get('result', {}).get('owner') if isinstance(j.get('result'), dict) else j.get('owner'),
|
||||||
|
'raw': j,
|
||||||
|
'solscan_url': f'https://www.sns.id/domain?domain={name}',
|
||||||
|
}
|
||||||
|
if r.status_code == 404:
|
||||||
|
return {
|
||||||
|
'name': f'{name}.sol',
|
||||||
|
'registered': False,
|
||||||
|
'available': True,
|
||||||
|
'register_url': f'https://www.sns.id/search?search={name}',
|
||||||
|
}
|
||||||
|
return {'name': f'{name}.sol', 'status_code': r.status_code, 'raw': r.text[:400]}
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': f'lookup failed: {e}'}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_wallet_xray(args: dict) -> dict:
|
||||||
|
address = (args.get('address') or '').strip()
|
||||||
|
if not address or not (32 <= len(address) <= 44):
|
||||||
|
return {'error': 'invalid Solana address'}
|
||||||
|
# Lazy import to avoid circular
|
||||||
|
from auth_routes import get_rpc_url
|
||||||
|
rpc = get_rpc_url()
|
||||||
|
out: dict = {'address': address, 'rpc': 'helius' if 'helius' in rpc else 'public'}
|
||||||
|
try:
|
||||||
|
# Balance
|
||||||
|
r = req.post(rpc, json={'jsonrpc': '2.0', 'id': 1, 'method': 'getBalance',
|
||||||
|
'params': [address]}, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
lamports = int(r.json().get('result', {}).get('value', 0))
|
||||||
|
out['balance_sol'] = round(lamports / 1_000_000_000, 6)
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': f'balance fetch failed: {e}'}
|
||||||
|
try:
|
||||||
|
# Token accounts count
|
||||||
|
r = req.post(rpc, json={
|
||||||
|
'jsonrpc': '2.0', 'id': 2, 'method': 'getTokenAccountsByOwner',
|
||||||
|
'params': [address, {'programId': 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'},
|
||||||
|
{'encoding': 'jsonParsed'}]
|
||||||
|
}, timeout=12)
|
||||||
|
tokens = r.json().get('result', {}).get('value', []) or []
|
||||||
|
non_zero = [t for t in tokens if int(t.get('account', {}).get('data', {}).get('parsed', {}).get('info', {}).get('tokenAmount', {}).get('amount', 0) or 0) > 0]
|
||||||
|
out['token_account_count'] = len(tokens)
|
||||||
|
out['non_zero_tokens'] = len(non_zero)
|
||||||
|
except Exception as e:
|
||||||
|
out['token_error'] = str(e)
|
||||||
|
try:
|
||||||
|
# Last 5 signatures
|
||||||
|
r = req.post(rpc, json={
|
||||||
|
'jsonrpc': '2.0', 'id': 3, 'method': 'getSignaturesForAddress',
|
||||||
|
'params': [address, {'limit': 5}]
|
||||||
|
}, timeout=10)
|
||||||
|
sigs = r.json().get('result', []) or []
|
||||||
|
out['recent_txs'] = [{
|
||||||
|
'signature': s.get('signature'),
|
||||||
|
'slot': s.get('slot'),
|
||||||
|
'block_time': s.get('blockTime'),
|
||||||
|
'err': bool(s.get('err')),
|
||||||
|
} for s in sigs]
|
||||||
|
except Exception as e:
|
||||||
|
out['tx_error'] = str(e)
|
||||||
|
out['solscan_url'] = f'https://solscan.io/account/{address}'
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def tool_get_changelog(args: dict) -> dict:
|
||||||
|
limit = int(args.get('limit') or 5)
|
||||||
|
limit = max(1, min(limit, 25))
|
||||||
|
data = _load_json('changelog.json') or {}
|
||||||
|
entries = data.get('entries', []) if isinstance(data, dict) else (data if isinstance(data, list) else [])
|
||||||
|
out = []
|
||||||
|
for e in entries[:limit]:
|
||||||
|
out.append({
|
||||||
|
'version': e.get('version'),
|
||||||
|
'date': e.get('date'),
|
||||||
|
'category': e.get('category'),
|
||||||
|
'title': e.get('title'),
|
||||||
|
'changes': (e.get('changes') or [])[:6],
|
||||||
|
})
|
||||||
|
return {'entries': out, 'count': len(out), 'total_available': len(entries)}
|
||||||
|
|
||||||
|
|
||||||
|
VALID_EFFECTS = {
|
||||||
|
'crt', 'vhs', 'glitch', 'redalert', 'red', 'invert', 'blueprint', 'typewriter',
|
||||||
|
'gravity', 'earthquake', 'lowgravity', 'melt', 'shuffle',
|
||||||
|
'rain', 'snow', 'fog', 'night', 'underwater',
|
||||||
|
'dimensions', 'portal', 'retro',
|
||||||
|
'partymode', 'ghostmode', 'quantum', 'sneak', 'hacker',
|
||||||
|
'matrix', 'cmatrix',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tool_trigger_effect(args: dict) -> dict:
|
||||||
|
name = (args.get('name') or '').strip().lower()
|
||||||
|
if name not in VALID_EFFECTS:
|
||||||
|
return {'error': f'unknown effect. Valid: {sorted(VALID_EFFECTS)}', 'effect': None}
|
||||||
|
return {
|
||||||
|
'action': 'trigger_effect',
|
||||||
|
'effect': name,
|
||||||
|
'message': f'Activating {name} effect on your browser…',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Registration ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
register(Tool(
|
||||||
|
name='search_site',
|
||||||
|
description='Full-text search across jaeswift.xyz content (changelog, posts, navigation, homepage). Returns top 5 matches with snippets.',
|
||||||
|
params={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {'query': {'type': 'string', 'description': 'search query'}},
|
||||||
|
'required': ['query'],
|
||||||
|
},
|
||||||
|
handler=tool_search_site, tier='anonymous', rate_limit='20/min',
|
||||||
|
))
|
||||||
|
|
||||||
|
register(Tool(
|
||||||
|
name='get_sol_price',
|
||||||
|
description='Get live Solana (SOL) price and 24h change from Binance.',
|
||||||
|
params={'type': 'object', 'properties': {}, 'required': []},
|
||||||
|
handler=tool_get_sol_price, tier='anonymous', rate_limit='60/min',
|
||||||
|
))
|
||||||
|
|
||||||
|
register(Tool(
|
||||||
|
name='get_crypto_price',
|
||||||
|
description='Get live USD price and 24h change for any crypto symbol (BTC, ETH, etc) from Binance.',
|
||||||
|
params={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {'symbol': {'type': 'string', 'description': 'ticker symbol e.g. BTC, ETH, DOGE'}},
|
||||||
|
'required': ['symbol'],
|
||||||
|
},
|
||||||
|
handler=tool_get_crypto_price, tier='anonymous', rate_limit='60/min',
|
||||||
|
))
|
||||||
|
|
||||||
|
register(Tool(
|
||||||
|
name='search_contraband',
|
||||||
|
description='Search the CONTRABAND archive of 16k+ curated resources across 24 categories (software, privacy, movies, books, games, AI, etc.). Top 5 matches.',
|
||||||
|
params={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'query': {'type': 'string', 'description': 'search query'},
|
||||||
|
'category': {'type': 'string', 'description': 'optional category filter (e.g. privacy, software, ai)'},
|
||||||
|
},
|
||||||
|
'required': ['query'],
|
||||||
|
},
|
||||||
|
handler=tool_search_contraband, tier='anonymous', rate_limit='20/min',
|
||||||
|
))
|
||||||
|
|
||||||
|
register(Tool(
|
||||||
|
name='search_awesomelist',
|
||||||
|
description='Search 28 curated awesome-list sectors with 135k+ entries (development, datascience, security, etc.). Top 5 matches.',
|
||||||
|
params={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {'query': {'type': 'string', 'description': 'search query'}},
|
||||||
|
'required': ['query'],
|
||||||
|
},
|
||||||
|
handler=tool_search_awesomelist, tier='anonymous', rate_limit='20/min',
|
||||||
|
))
|
||||||
|
|
||||||
|
register(Tool(
|
||||||
|
name='get_sitrep',
|
||||||
|
description='Get the latest (or a specific date) SITREP daily AI intelligence briefing covering tech, cybersecurity, and crypto.',
|
||||||
|
params={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {'date': {'type': 'string', 'description': 'date (YYYY-MM-DD) or "latest"', 'default': 'latest'}},
|
||||||
|
'required': [],
|
||||||
|
},
|
||||||
|
handler=tool_get_sitrep, tier='anonymous', rate_limit='20/min',
|
||||||
|
))
|
||||||
|
|
||||||
|
register(Tool(
|
||||||
|
name='lookup_sol_domain',
|
||||||
|
description='Check if a .sol domain is registered or available (via Bonfida SNS). Returns owner if registered.',
|
||||||
|
params={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {'name': {'type': 'string', 'description': 'domain name without .sol suffix'}},
|
||||||
|
'required': ['name'],
|
||||||
|
},
|
||||||
|
handler=tool_lookup_sol_domain, tier='anonymous', rate_limit='30/min',
|
||||||
|
))
|
||||||
|
|
||||||
|
register(Tool(
|
||||||
|
name='wallet_xray',
|
||||||
|
description='Scan any Solana wallet address: SOL balance, token account count, recent transactions.',
|
||||||
|
params={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {'address': {'type': 'string', 'description': 'Solana wallet pubkey (base58)'}},
|
||||||
|
'required': ['address'],
|
||||||
|
},
|
||||||
|
handler=tool_wallet_xray, tier='anonymous', rate_limit='10/min', timeout=20,
|
||||||
|
))
|
||||||
|
|
||||||
|
register(Tool(
|
||||||
|
name='get_changelog',
|
||||||
|
description='Get the last N changelog entries from jaeswift.xyz (what has been built/fixed recently).',
|
||||||
|
params={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {'limit': {'type': 'integer', 'description': 'number of entries (1-25)', 'default': 5}},
|
||||||
|
'required': [],
|
||||||
|
},
|
||||||
|
handler=tool_get_changelog, tier='anonymous', rate_limit='30/min',
|
||||||
|
))
|
||||||
|
|
||||||
|
register(Tool(
|
||||||
|
name='trigger_effect',
|
||||||
|
description='Activate a sitewide visual effect on the user\'s browser (crt, vhs, glitch, redalert, matrix, rain, snow, partymode, etc.). Returns an action payload the frontend will execute.',
|
||||||
|
params={
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {'name': {'type': 'string', 'description': 'effect name (e.g. crt, rain, partymode)'}},
|
||||||
|
'required': ['name'],
|
||||||
|
},
|
||||||
|
handler=tool_trigger_effect, tier='anonymous', rate_limit='30/min',
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public API ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def get_allowed_tools(user_tier: str) -> list[Tool]:
|
||||||
|
from agent_tiers import tier_allows
|
||||||
|
return [t for t in TOOLS.values() if tier_allows(user_tier, t.tier)]
|
||||||
|
|
||||||
|
|
||||||
|
def openai_tool_schemas(tools: list[Tool]) -> list[dict]:
|
||||||
|
return [{
|
||||||
|
'type': 'function',
|
||||||
|
'function': {
|
||||||
|
'name': t.name,
|
||||||
|
'description': t.description,
|
||||||
|
'parameters': t.params,
|
||||||
|
},
|
||||||
|
} for t in tools]
|
||||||
15
api/app.py
15
api/app.py
|
|
@ -30,6 +30,21 @@ try:
|
||||||
app.register_blueprint(visitor_bp)
|
app.register_blueprint(visitor_bp)
|
||||||
except Exception as _vb_err:
|
except Exception as _vb_err:
|
||||||
print(f'[WARN] visitor_routes not loaded: {_vb_err}')
|
print(f'[WARN] visitor_routes not loaded: {_vb_err}')
|
||||||
|
# Register auth blueprint (provides /api/auth/* wallet authentication)
|
||||||
|
try:
|
||||||
|
from auth_routes import auth_bp
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
except Exception as _ab_err:
|
||||||
|
print(f'[WARN] auth_routes not loaded: {_ab_err}')
|
||||||
|
|
||||||
|
# Register agent blueprint (provides /api/agent/* agentic chat)
|
||||||
|
try:
|
||||||
|
import agent_tools # triggers tool registration
|
||||||
|
from agent_routes import agent_bp
|
||||||
|
app.register_blueprint(agent_bp)
|
||||||
|
except Exception as _ag_err:
|
||||||
|
print(f'[WARN] agent_routes not loaded: {_ag_err}')
|
||||||
|
|
||||||
|
|
||||||
DATA_DIR = Path(__file__).parent / 'data'
|
DATA_DIR = Path(__file__).parent / 'data'
|
||||||
JWT_SECRET = 'jaeswift-hud-s3cr3t-2026!x'
|
JWT_SECRET = 'jaeswift-hud-s3cr3t-2026!x'
|
||||||
|
|
|
||||||
261
api/auth_routes.py
Normal file
261
api/auth_routes.py
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
"""Wallet-based authentication for JAE-AI agent.
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /api/auth/nonce – issue sign-in nonce for a Solana pubkey
|
||||||
|
POST /api/auth/verify – verify Ed25519 signature, issue JWT cookie
|
||||||
|
GET /api/auth/whoami – introspect cookie + holdings → tier
|
||||||
|
POST /api/auth/logout – clear cookie
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify, make_response
|
||||||
|
import jwt
|
||||||
|
import requests as req
|
||||||
|
import base58
|
||||||
|
from nacl.signing import VerifyKey
|
||||||
|
from nacl.exceptions import BadSignatureError
|
||||||
|
|
||||||
|
from agent_tiers import compute_tier, pick_model
|
||||||
|
|
||||||
|
auth_bp = Blueprint('auth_bp', __name__, url_prefix='/api/auth')
|
||||||
|
|
||||||
|
DATA_DIR = Path(__file__).parent / 'data'
|
||||||
|
APIKEYS_FILE = DATA_DIR / 'apikeys.json'
|
||||||
|
|
||||||
|
# Solana RPC endpoints (mainnet). Helius used if key present, else public RPC.
|
||||||
|
PUBLIC_RPC = 'https://api.mainnet-beta.solana.com'
|
||||||
|
|
||||||
|
# In-memory nonce store: address -> (nonce, expires_ts)
|
||||||
|
_NONCES: dict[str, tuple[str, float]] = {}
|
||||||
|
_NONCE_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
|
# Balance cache: address -> (lamports, ts)
|
||||||
|
_BALANCE_CACHE: dict[str, tuple[int, float]] = {}
|
||||||
|
_BALANCE_TTL = 60 # 1 minute
|
||||||
|
|
||||||
|
COOKIE_NAME = 'jae_session'
|
||||||
|
JWT_ALGO = 'HS256'
|
||||||
|
JWT_EXPIRY_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
|
def _load_apikeys() -> dict:
|
||||||
|
try:
|
||||||
|
with open(APIKEYS_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_apikeys(keys: dict):
|
||||||
|
try:
|
||||||
|
with open(APIKEYS_FILE, 'w') as f:
|
||||||
|
json.dump(keys, f, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[auth] Failed to save apikeys: {e}')
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt_secret() -> str:
|
||||||
|
"""Load JAE_JWT_SECRET from apikeys.json; generate + persist if missing."""
|
||||||
|
keys = _load_apikeys()
|
||||||
|
secret = keys.get('jae_jwt_secret') or ''
|
||||||
|
if not secret:
|
||||||
|
secret = secrets.token_hex(32)
|
||||||
|
keys['jae_jwt_secret'] = secret
|
||||||
|
_save_apikeys(keys)
|
||||||
|
return secret
|
||||||
|
|
||||||
|
|
||||||
|
def get_rpc_url() -> str:
|
||||||
|
keys = _load_apikeys()
|
||||||
|
# Check custom slots for Helius
|
||||||
|
for slot in ('custom1', 'custom2', 'custom3'):
|
||||||
|
c = keys.get(slot) or {}
|
||||||
|
name = (c.get('name') or '').lower()
|
||||||
|
if 'helius' in name and c.get('key'):
|
||||||
|
return f"https://mainnet.helius-rpc.com/?api-key={c['key']}"
|
||||||
|
# Check a direct helius field
|
||||||
|
h = keys.get('helius') or {}
|
||||||
|
if h.get('api_key'):
|
||||||
|
return f"https://mainnet.helius-rpc.com/?api-key={h['api_key']}"
|
||||||
|
return PUBLIC_RPC
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_sol_balance(address: str) -> float:
|
||||||
|
"""Return SOL balance (float). Cached 60s. Returns 0.0 on error."""
|
||||||
|
now = time.time()
|
||||||
|
cached = _BALANCE_CACHE.get(address)
|
||||||
|
if cached and now - cached[1] < _BALANCE_TTL:
|
||||||
|
return cached[0] / 1_000_000_000
|
||||||
|
try:
|
||||||
|
r = req.post(
|
||||||
|
get_rpc_url(),
|
||||||
|
json={'jsonrpc': '2.0', 'id': 1, 'method': 'getBalance', 'params': [address]},
|
||||||
|
timeout=6,
|
||||||
|
)
|
||||||
|
r.raise_for_status()
|
||||||
|
lamports = int(r.json()['result']['value'])
|
||||||
|
_BALANCE_CACHE[address] = (lamports, now)
|
||||||
|
return lamports / 1_000_000_000
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[auth] getBalance failed for {address[:8]}…: {e}')
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_nonces():
|
||||||
|
now = time.time()
|
||||||
|
stale = [k for k, (_, exp) in _NONCES.items() if exp < now]
|
||||||
|
for k in stale:
|
||||||
|
_NONCES.pop(k, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_solana_address(addr: str) -> bool:
|
||||||
|
if not isinstance(addr, str) or not (32 <= len(addr) <= 44):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
raw = base58.b58decode(addr)
|
||||||
|
return len(raw) == 32
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def read_session() -> dict | None:
|
||||||
|
"""Decode JWT cookie. Returns payload or None."""
|
||||||
|
token = request.cookies.get(COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, get_jwt_secret(), algorithms=[JWT_ALGO])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@auth_bp.route('/nonce', methods=['POST'])
|
||||||
|
def issue_nonce():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
address = (data.get('address') or '').strip()
|
||||||
|
if not _valid_solana_address(address):
|
||||||
|
return jsonify({'error': 'Invalid Solana address'}), 400
|
||||||
|
_cleanup_nonces()
|
||||||
|
nonce = secrets.token_hex(16)
|
||||||
|
expires_ts = time.time() + _NONCE_TTL
|
||||||
|
expires_iso = datetime.datetime.utcfromtimestamp(expires_ts).isoformat() + 'Z'
|
||||||
|
_NONCES[address] = (nonce, expires_ts)
|
||||||
|
message = f'Sign in to jaeswift.xyz\nnonce: {nonce}\nexpires: {expires_iso}'
|
||||||
|
return jsonify({'message': message, 'nonce': nonce, 'expires': expires_iso})
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/verify', methods=['POST'])
|
||||||
|
def verify_signature():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
address = (data.get('address') or '').strip()
|
||||||
|
signature_b58 = (data.get('signature') or '').strip()
|
||||||
|
nonce = (data.get('nonce') or '').strip()
|
||||||
|
|
||||||
|
if not _valid_solana_address(address):
|
||||||
|
return jsonify({'error': 'Invalid address'}), 400
|
||||||
|
if not signature_b58 or not nonce:
|
||||||
|
return jsonify({'error': 'Missing signature or nonce'}), 400
|
||||||
|
|
||||||
|
stored = _NONCES.get(address)
|
||||||
|
if not stored:
|
||||||
|
return jsonify({'error': 'No active nonce for this address'}), 401
|
||||||
|
stored_nonce, expires_ts = stored
|
||||||
|
if stored_nonce != nonce:
|
||||||
|
return jsonify({'error': 'Nonce mismatch'}), 401
|
||||||
|
if time.time() > expires_ts:
|
||||||
|
_NONCES.pop(address, None)
|
||||||
|
return jsonify({'error': 'Nonce expired'}), 401
|
||||||
|
|
||||||
|
# Rebuild exact signed message
|
||||||
|
expires_iso = datetime.datetime.utcfromtimestamp(expires_ts).isoformat() + 'Z'
|
||||||
|
message = f'Sign in to jaeswift.xyz\nnonce: {nonce}\nexpires: {expires_iso}'
|
||||||
|
|
||||||
|
try:
|
||||||
|
pubkey_bytes = base58.b58decode(address)
|
||||||
|
vk = VerifyKey(pubkey_bytes)
|
||||||
|
# Try base58 then base64 decode for signature
|
||||||
|
sig_bytes = None
|
||||||
|
try:
|
||||||
|
sig_bytes = base58.b58decode(signature_b58)
|
||||||
|
except Exception:
|
||||||
|
import base64
|
||||||
|
sig_bytes = base64.b64decode(signature_b58)
|
||||||
|
vk.verify(message.encode('utf-8'), sig_bytes)
|
||||||
|
except BadSignatureError:
|
||||||
|
return jsonify({'error': 'Signature verification failed'}), 401
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': f'Signature decode error: {e}'}), 400
|
||||||
|
|
||||||
|
# Consume nonce
|
||||||
|
_NONCES.pop(address, None)
|
||||||
|
|
||||||
|
# Compute tier based on live SOL balance
|
||||||
|
balance = fetch_sol_balance(address)
|
||||||
|
tier = compute_tier(address, balance)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'address': address,
|
||||||
|
'tier': tier,
|
||||||
|
'iat': int(time.time()),
|
||||||
|
'exp': int(time.time()) + JWT_EXPIRY_HOURS * 3600,
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, get_jwt_secret(), algorithm=JWT_ALGO)
|
||||||
|
|
||||||
|
resp = make_response(jsonify({
|
||||||
|
'authenticated': True,
|
||||||
|
'address': address,
|
||||||
|
'tier': tier,
|
||||||
|
'balance_sol': round(balance, 4),
|
||||||
|
'model': pick_model(tier),
|
||||||
|
'expires_in': JWT_EXPIRY_HOURS * 3600,
|
||||||
|
}))
|
||||||
|
resp.set_cookie(
|
||||||
|
COOKIE_NAME, token,
|
||||||
|
max_age=JWT_EXPIRY_HOURS * 3600,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite='Lax',
|
||||||
|
path='/',
|
||||||
|
)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/whoami', methods=['GET'])
|
||||||
|
def whoami():
|
||||||
|
sess = read_session()
|
||||||
|
if not sess:
|
||||||
|
return jsonify({
|
||||||
|
'authenticated': False,
|
||||||
|
'address': None,
|
||||||
|
'tier': 'anonymous',
|
||||||
|
'model': pick_model('anonymous'),
|
||||||
|
'balance_sol': 0,
|
||||||
|
'holdings': {},
|
||||||
|
})
|
||||||
|
address = sess.get('address')
|
||||||
|
# Re-fetch balance (cached) to refresh tier on the fly
|
||||||
|
balance = fetch_sol_balance(address) if address else 0.0
|
||||||
|
tier = compute_tier(address, balance)
|
||||||
|
return jsonify({
|
||||||
|
'authenticated': True,
|
||||||
|
'address': address,
|
||||||
|
'tier': tier,
|
||||||
|
'model': pick_model(tier),
|
||||||
|
'balance_sol': round(balance, 4),
|
||||||
|
'holdings': {'sol': round(balance, 4)},
|
||||||
|
'expires_at': sess.get('exp'),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route('/logout', methods=['POST'])
|
||||||
|
def logout():
|
||||||
|
resp = make_response(jsonify({'ok': True}))
|
||||||
|
resp.set_cookie(COOKIE_NAME, '', expires=0, path='/', secure=True, httponly=True, samesite='Lax')
|
||||||
|
return resp
|
||||||
|
|
@ -1,6 +1,26 @@
|
||||||
{
|
{
|
||||||
"site": "jaeswift.xyz",
|
"site": "jaeswift.xyz",
|
||||||
"entries": [
|
"entries": [
|
||||||
|
{
|
||||||
|
"version": "1.40.0",
|
||||||
|
"date": "20/04/2026",
|
||||||
|
"category": "FEATURE",
|
||||||
|
"title": "Agentic JAE-AI Chat \u2014 wallet auth + 10 tools + tiered model routing",
|
||||||
|
"changes": [
|
||||||
|
"JAE-AI homepage chat is now fully agentic \u2014 frontend routes to new POST /api/agent/chat which runs a Venice tool-calling loop (max 8 iterations) with tiered model selection",
|
||||||
|
"Tiered model routing: anonymous/operator visitors get zai-org-glm-4.7-flash ($0.125/$0.50 per M); Elite tier (\u22651 SOL held or $JAE holder) + admin get kimi-k2-thinking ($0.75/$3.20 per M) with deeper reasoning budget",
|
||||||
|
"Wallet-based auth: new POST /api/auth/nonce \u2192 POST /api/auth/verify flow using Ed25519 signatures (PyNaCl + base58) over a per-session 16-byte hex nonce with 5-min TTL; success issues HS256 JWT in HttpOnly Secure SameSite=Lax cookie (jae_session, 24h)",
|
||||||
|
"GET /api/auth/whoami returns {authenticated, address, tier, balance_sol, holdings}; POST /api/auth/logout clears cookie; JAE_JWT_SECRET auto-generated via secrets.token_hex(32) into apikeys.json on first start",
|
||||||
|
"Tier ladder: anonymous \u2192 operator (authenticated) \u2192 elite (\u22651 SOL) \u2192 admin (hardcoded wallet allowlist); tier computed live at /whoami using Helius RPC balance query",
|
||||||
|
"10 tools wired into the registry (api/agent_tools.py): search_site, get_sol_price, get_crypto_price, search_contraband, search_awesomelist, get_sitrep, lookup_sol_domain (Bonfida SNS), wallet_xray (Helius RPC), get_changelog, trigger_effect (frontend action)",
|
||||||
|
"Per-tool rate limits (token-bucket in-memory) keyed by (wallet_or_IP, tool) \u2014 e.g. 10/min for wallet_xray, 60/min for price fetches, 20/min for searches; 30s exec timeout per tool; 30s TTL cache on crypto prices",
|
||||||
|
"Hard $30/month output-token budget guard tracked in api/data/agent_budget.json \u2014 refuses with 'Monthly agent budget exceeded' once ceiling hit",
|
||||||
|
"Frontend chat (js/chat.js, 564 lines) rewritten: tier badge in chat header (ANONYMOUS / OPERATOR \ud83c\udf96\ufe0f / ELITE \u2b50 / ADMIN \ud83d\udee0\ufe0f, Elite has gold pulse animation), tool call cards with \u2705/\u274c status + expandable JSON details, typewriter animation on final reply, hooks window.__jaeEffects.toggle() on trigger_effect results",
|
||||||
|
"Wallet sign-in handshake: on wallet-connected event, chat.js fetches nonce, calls provider.signMessage (supports legacy + Wallet Standard), base58-encodes signature, posts /verify; all in-browser with zero extra deps (minimal bs58 encoder inlined)",
|
||||||
|
"Existing POST /api/chat (Shiro bot + CLI casual chat) left fully intact \u2014 agentic path is additive",
|
||||||
|
"New files: api/auth_routes.py (~250 lines), api/agent_tiers.py (~50 lines), api/agent_tools.py (539 lines), api/agent_routes.py (321 lines), css/agent-chat.css (145 lines); MOD api/app.py (+15 blueprint registration), api/requirements.txt (+PyJWT, PyNaCl, base58), index.html (+1 stylesheet link), js/chat.js (410\u2192564 lines)"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.39.0",
|
"version": "1.39.0",
|
||||||
"date": "20/04/2026",
|
"date": "20/04/2026",
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,5 @@ flask
|
||||||
flask-cors
|
flask-cors
|
||||||
PyJWT
|
PyJWT
|
||||||
requests
|
requests
|
||||||
|
PyNaCl
|
||||||
|
base58
|
||||||
|
|
|
||||||
145
css/agent-chat.css
Normal file
145
css/agent-chat.css
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
/* ===================================================
|
||||||
|
JAESWIFT.XYZ — Agentic chat UI (tool cards + tiers)
|
||||||
|
=================================================== */
|
||||||
|
|
||||||
|
/* ─── Tier badge ─── */
|
||||||
|
.tier-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
margin-left: 8px;
|
||||||
|
font-family: 'VT323', 'Courier New', monospace;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 2px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.tier-badge.tier-anonymous { color: #888; }
|
||||||
|
.tier-badge.tier-operator { color: #00cc33; box-shadow: 0 0 6px rgba(0, 204, 51, 0.3); }
|
||||||
|
.tier-badge.tier-elite {
|
||||||
|
color: #ffd700;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 215, 0, 0.45);
|
||||||
|
animation: tier-elite-pulse 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.tier-badge.tier-admin { color: #ff3355; box-shadow: 0 0 8px rgba(255, 51, 85, 0.4); }
|
||||||
|
|
||||||
|
@keyframes tier-elite-pulse {
|
||||||
|
0%, 100% { box-shadow: 0 0 8px rgba(255, 215, 0, 0.35); }
|
||||||
|
50% { box-shadow: 0 0 16px rgba(255, 215, 0, 0.7); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Agent tool-call card ─── */
|
||||||
|
.agent-tool-call {
|
||||||
|
font-family: 'Courier New', 'Consolas', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #00ff66;
|
||||||
|
background: rgba(0, 20, 0, 0.4);
|
||||||
|
border-left: 2px solid #00cc33;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin: 6px 0 6px 18px;
|
||||||
|
border-radius: 2px;
|
||||||
|
max-width: calc(100% - 36px);
|
||||||
|
overflow-x: auto;
|
||||||
|
animation: tool-card-slide-in 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tool-card-slide-in {
|
||||||
|
from { opacity: 0; transform: translateX(-6px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.agent-tool-call .atc-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 6px;
|
||||||
|
color: #00ff99;
|
||||||
|
}
|
||||||
|
.agent-tool-call .atc-icon { font-size: 0.9rem; }
|
||||||
|
.agent-tool-call .atc-name {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #66ffaa;
|
||||||
|
text-shadow: 0 0 4px rgba(0, 255, 100, 0.4);
|
||||||
|
}
|
||||||
|
.agent-tool-call .atc-args {
|
||||||
|
color: #55cc77;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.agent-tool-call .atc-status {
|
||||||
|
color: #33dd66;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.agent-tool-call .atc-details {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.agent-tool-call .atc-details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #22aa44;
|
||||||
|
list-style: none;
|
||||||
|
outline: none;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.agent-tool-call .atc-details summary::-webkit-details-marker { display: none; }
|
||||||
|
.agent-tool-call .atc-details[open] summary::after { content: ' ▼'; }
|
||||||
|
.agent-tool-call .atc-details summary::after { content: ' ▶'; }
|
||||||
|
.agent-tool-call .atc-details pre {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px dashed rgba(0, 204, 51, 0.3);
|
||||||
|
color: #88eeaa;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Agent reply styling tweak */
|
||||||
|
.chat-msg.chat-msg-agent .chat-msg-body {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chat-msg.chat-msg-agent[data-model]:not([data-model=''])::after {
|
||||||
|
content: attr(data-model);
|
||||||
|
display: block;
|
||||||
|
font-family: 'VT323', monospace;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #446655;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-top: 2px;
|
||||||
|
margin-left: 48px;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Locked CTA style (for tier-gated messages) */
|
||||||
|
.agent-locked {
|
||||||
|
border-left-color: #ff9933;
|
||||||
|
color: #ffaa55;
|
||||||
|
background: rgba(40, 20, 0, 0.4);
|
||||||
|
}
|
||||||
|
.agent-locked .atc-name { color: #ffcc77; }
|
||||||
|
|
||||||
|
/* Mobile responsiveness */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.agent-tool-call {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.tier-badge {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 1px 6px;
|
||||||
|
}
|
||||||
|
.agent-tool-call .atc-args {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
<link rel="stylesheet" href="/css/chat-memory.css">
|
<link rel="stylesheet" href="/css/chat-memory.css">
|
||||||
<link rel="stylesheet" href="/css/scan-visitor.css">
|
<link rel="stylesheet" href="/css/scan-visitor.css">
|
||||||
<link rel="stylesheet" href="/css/chat-cli.css">
|
<link rel="stylesheet" href="/css/chat-cli.css">
|
||||||
|
<link rel="stylesheet" href="/css/agent-chat.css">
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=Orbitron:wght@400;500;600;700;800;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
|
||||||
|
|
|
||||||
312
js/chat.js
312
js/chat.js
|
|
@ -1,6 +1,6 @@
|
||||||
/* ===================================================
|
/* ===================================================
|
||||||
JAESWIFT.XYZ — JAE-AI Chat Terminal
|
JAESWIFT.XYZ — JAE-AI Agentic Chat Terminal
|
||||||
Venice API chat interface + Memoria-style memory
|
Tiered model routing + tool calls + wallet auth
|
||||||
=================================================== */
|
=================================================== */
|
||||||
|
|
||||||
(function () {
|
(function () {
|
||||||
|
|
@ -18,8 +18,17 @@
|
||||||
|
|
||||||
let history = [];
|
let history = [];
|
||||||
let isWaiting = false;
|
let isWaiting = false;
|
||||||
|
let currentTier = 'anonymous';
|
||||||
|
let currentAddress = null;
|
||||||
|
|
||||||
// ─── Render a message bubble ───
|
// ─── Utility ──────────────────────────────────────
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s || '').replace(/[&<>"']/g, function (c) {
|
||||||
|
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Render a message bubble ──────────────────────
|
||||||
function addMessage(role, text) {
|
function addMessage(role, text) {
|
||||||
const welcome = chatMessages.querySelector('.chat-welcome');
|
const welcome = chatMessages.querySelector('.chat-welcome');
|
||||||
if (welcome) welcome.remove();
|
if (welcome) welcome.remove();
|
||||||
|
|
@ -42,7 +51,36 @@
|
||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Typing indicator ───
|
function addToolCallCard(call) {
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'agent-tool-call';
|
||||||
|
const argsStr = escapeHtml(JSON.stringify(call.args || {}, null, 0));
|
||||||
|
const result = call.result || {};
|
||||||
|
const errored = !!result.error;
|
||||||
|
const statusIcon = errored ? '❌' : '✅';
|
||||||
|
let summary = errored ? (result.error || 'error') : 'ok';
|
||||||
|
if (!errored) {
|
||||||
|
if (Array.isArray(result.results)) summary = `${result.results.length} result${result.results.length === 1 ? '' : 's'}`;
|
||||||
|
else if (Array.isArray(result.entries)) summary = `${result.entries.length} entries`;
|
||||||
|
else if (typeof result.price_usd === 'number') summary = `$${result.price_usd.toFixed(2)} (${result.change_24h_pct > 0 ? '+' : ''}${(result.change_24h_pct || 0).toFixed(2)}%)`;
|
||||||
|
else if (result.effect) summary = `effect: ${result.effect}`;
|
||||||
|
else if (result.balance_sol !== undefined) summary = `${result.balance_sol} SOL`;
|
||||||
|
}
|
||||||
|
const resultStr = escapeHtml(JSON.stringify(result, null, 2).slice(0, 1400));
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<div class="atc-header">
|
||||||
|
<span class="atc-icon">🔧</span>
|
||||||
|
<span class="atc-name">${escapeHtml(call.name)}</span>
|
||||||
|
<span class="atc-args">(${argsStr})</span>
|
||||||
|
</div>
|
||||||
|
<div class="atc-status">├─ ${statusIcon} ${escapeHtml(summary)}</div>
|
||||||
|
<details class="atc-details"><summary>└─ (click to expand)</summary><pre>${resultStr}</pre></details>
|
||||||
|
`;
|
||||||
|
chatMessages.appendChild(wrap);
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Typing indicator ─────────────────────────────
|
||||||
function showTyping() {
|
function showTyping() {
|
||||||
const indicator = document.createElement('div');
|
const indicator = document.createElement('div');
|
||||||
indicator.className = 'chat-msg chat-msg-assistant chat-typing-indicator';
|
indicator.className = 'chat-msg chat-msg-assistant chat-typing-indicator';
|
||||||
|
|
@ -50,23 +88,25 @@
|
||||||
indicator.innerHTML = `
|
indicator.innerHTML = `
|
||||||
<span class="chat-msg-label">JAE-AI</span>
|
<span class="chat-msg-label">JAE-AI</span>
|
||||||
<div class="chat-msg-body">
|
<div class="chat-msg-body">
|
||||||
<span class="typing-dot"></span>
|
<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>
|
||||||
<span class="typing-dot"></span>
|
</div>`;
|
||||||
<span class="typing-dot"></span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
chatMessages.appendChild(indicator);
|
chatMessages.appendChild(indicator);
|
||||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideTyping() {
|
function hideTyping() {
|
||||||
const el = document.getElementById('chatTyping');
|
const el = document.getElementById('chatTyping');
|
||||||
if (el) el.remove();
|
if (el) el.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Typewriter effect ───
|
// ─── Typewriter effect ────────────────────────────
|
||||||
function typewriterEffect(element, text, speed) {
|
function typewriterEffect(element, text, speed) {
|
||||||
speed = speed || 12;
|
speed = speed || 10;
|
||||||
|
// If reply is too long, bypass animation
|
||||||
|
if (!text || text.length > 600) {
|
||||||
|
element.textContent = text || '';
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
let i = 0;
|
let i = 0;
|
||||||
element.textContent = '';
|
element.textContent = '';
|
||||||
return new Promise(function (resolve) {
|
return new Promise(function (resolve) {
|
||||||
|
|
@ -84,16 +124,13 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Restore previous chat history from localStorage ───
|
// ─── History restore ──────────────────────────────
|
||||||
function restoreHistory() {
|
function restoreHistory() {
|
||||||
if (!mem) return false;
|
if (!mem) return false;
|
||||||
const saved = mem.getHistory();
|
const saved = mem.getHistory();
|
||||||
if (!saved || saved.length === 0) return false;
|
if (!saved || saved.length === 0) return false;
|
||||||
|
|
||||||
// Remove welcome and render saved history
|
|
||||||
const welcome = chatMessages.querySelector('.chat-welcome');
|
const welcome = chatMessages.querySelector('.chat-welcome');
|
||||||
if (welcome) welcome.remove();
|
if (welcome) welcome.remove();
|
||||||
|
|
||||||
saved.forEach(function (m) {
|
saved.forEach(function (m) {
|
||||||
const role = m.role === 'user' ? 'user' : 'assistant';
|
const role = m.role === 'user' ? 'user' : 'assistant';
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
|
|
@ -108,26 +145,143 @@
|
||||||
el.appendChild(body);
|
el.appendChild(body);
|
||||||
chatMessages.appendChild(el);
|
chatMessages.appendChild(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Divider showing restore
|
|
||||||
const divider = document.createElement('div');
|
const divider = document.createElement('div');
|
||||||
divider.className = 'chat-divider';
|
divider.className = 'chat-divider';
|
||||||
divider.innerHTML = '<span>— RESTORED FROM MEMORY —</span>';
|
divider.innerHTML = '<span>— RESTORED FROM MEMORY —</span>';
|
||||||
chatMessages.appendChild(divider);
|
chatMessages.appendChild(divider);
|
||||||
|
|
||||||
chatMessages.scrollTop = chatMessages.scrollHeight;
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
|
||||||
// populate in-memory history
|
|
||||||
history = saved.slice();
|
history = saved.slice();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Send message to API ───
|
// ─── Tier badge ───────────────────────────────────
|
||||||
|
const TIER_DISPLAY = {
|
||||||
|
anonymous: { label: 'ANONYMOUS', icon: '👁' },
|
||||||
|
operator: { label: 'OPERATOR', icon: '🎖️' },
|
||||||
|
elite: { label: 'ELITE', icon: '⭐' },
|
||||||
|
admin: { label: 'ADMIN', icon: '🛠️' },
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderTierBadge() {
|
||||||
|
if (!chatHeader) return;
|
||||||
|
let badge = document.getElementById('chatTierBadge');
|
||||||
|
if (!badge) {
|
||||||
|
badge = document.createElement('span');
|
||||||
|
badge.id = 'chatTierBadge';
|
||||||
|
badge.className = 'tier-badge';
|
||||||
|
chatHeader.appendChild(badge);
|
||||||
|
}
|
||||||
|
const info = TIER_DISPLAY[currentTier] || TIER_DISPLAY.anonymous;
|
||||||
|
badge.className = 'tier-badge tier-' + currentTier;
|
||||||
|
badge.textContent = info.icon + ' ' + info.label;
|
||||||
|
badge.title = currentAddress ? (currentAddress.slice(0, 4) + '…' + currentAddress.slice(-4)) : 'Not authenticated';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Wallet auth handshake ────────────────────────
|
||||||
|
async function refreshWhoAmI() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/auth/whoami', { credentials: 'same-origin' });
|
||||||
|
if (!r.ok) return;
|
||||||
|
const j = await r.json();
|
||||||
|
currentTier = j.tier || 'anonymous';
|
||||||
|
currentAddress = j.address || null;
|
||||||
|
renderTierBadge();
|
||||||
|
} catch (e) { /* silent */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function walletSignIn(address, provider) {
|
||||||
|
if (!address || !provider) return;
|
||||||
|
try {
|
||||||
|
// 1. Get nonce
|
||||||
|
const nr = await fetch('/api/auth/nonce', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ address }),
|
||||||
|
});
|
||||||
|
if (!nr.ok) { console.warn('nonce fetch failed'); return; }
|
||||||
|
const { message, nonce } = await nr.json();
|
||||||
|
|
||||||
|
// 2. Sign message
|
||||||
|
const encoded = new TextEncoder().encode(message);
|
||||||
|
let sigBytes;
|
||||||
|
if (typeof provider.signMessage === 'function') {
|
||||||
|
const res = await provider.signMessage(encoded, 'utf8');
|
||||||
|
sigBytes = res && (res.signature || res);
|
||||||
|
} else if (provider.features && provider.features['solana:signMessage']) {
|
||||||
|
const feat = provider.features['solana:signMessage'];
|
||||||
|
const res = await feat.signMessage({ message: encoded });
|
||||||
|
sigBytes = Array.isArray(res) ? res[0].signature : res.signature;
|
||||||
|
} else {
|
||||||
|
console.warn('wallet does not support signMessage');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!sigBytes) return;
|
||||||
|
// sigBytes -> base58
|
||||||
|
const sigB58 = bs58encode(sigBytes);
|
||||||
|
|
||||||
|
// 3. Verify
|
||||||
|
const vr = await fetch('/api/auth/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ address, signature: sigB58, nonce }),
|
||||||
|
});
|
||||||
|
if (!vr.ok) {
|
||||||
|
const err = await vr.json().catch(() => ({}));
|
||||||
|
console.warn('verify failed:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const j = await vr.json();
|
||||||
|
currentTier = j.tier || 'operator';
|
||||||
|
currentAddress = j.address || address;
|
||||||
|
renderTierBadge();
|
||||||
|
const info = TIER_DISPLAY[currentTier];
|
||||||
|
addMessage('assistant', `🔓 Wallet verified. Tier: ${info.icon} ${info.label}. Model: ${j.model || 'default'}.`);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('walletSignIn error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal bs58 encoder (no external deps)
|
||||||
|
const BS58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
||||||
|
function bs58encode(bytes) {
|
||||||
|
if (!bytes) return '';
|
||||||
|
if (!(bytes instanceof Uint8Array)) bytes = new Uint8Array(bytes);
|
||||||
|
const digits = [0];
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
let carry = bytes[i];
|
||||||
|
for (let j = 0; j < digits.length; j++) {
|
||||||
|
carry += digits[j] << 8;
|
||||||
|
digits[j] = carry % 58;
|
||||||
|
carry = (carry / 58) | 0;
|
||||||
|
}
|
||||||
|
while (carry > 0) { digits.push(carry % 58); carry = (carry / 58) | 0; }
|
||||||
|
}
|
||||||
|
let str = '';
|
||||||
|
for (let k = 0; k < bytes.length && bytes[k] === 0; k++) str += '1';
|
||||||
|
for (let q = digits.length - 1; q >= 0; q--) str += BS58_ALPHABET[digits[q]];
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen to wallet-connect events
|
||||||
|
window.addEventListener('wallet-connected', function (e) {
|
||||||
|
const detail = e.detail || {};
|
||||||
|
const addr = detail.address || (window.solWallet && window.solWallet.address);
|
||||||
|
const provider = detail.provider || (window.solWallet && window.solWallet.provider);
|
||||||
|
if (addr && provider) walletSignIn(addr, provider);
|
||||||
|
});
|
||||||
|
window.addEventListener('wallet-disconnected', async function () {
|
||||||
|
try { await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin' }); } catch (e) {}
|
||||||
|
currentTier = 'anonymous';
|
||||||
|
currentAddress = null;
|
||||||
|
renderTierBadge();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Send message to agent ────────────────────────
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const text = chatInput.value.trim();
|
const text = chatInput.value.trim();
|
||||||
if (!text || isWaiting) return;
|
if (!text || isWaiting) return;
|
||||||
|
|
||||||
// ─── CLI interception ───
|
// CLI interception (slash commands, or nested CLI mode)
|
||||||
const inCliMode = window.__jaeCLI && typeof window.__jaeCLI.isInMode === 'function' && window.__jaeCLI.isInMode();
|
const inCliMode = window.__jaeCLI && typeof window.__jaeCLI.isInMode === 'function' && window.__jaeCLI.isInMode();
|
||||||
if ((text.startsWith('/') || inCliMode) && window.__jaeCLI && typeof window.__jaeCLI.handle === 'function') {
|
if ((text.startsWith('/') || inCliMode) && window.__jaeCLI && typeof window.__jaeCLI.handle === 'function') {
|
||||||
isWaiting = true;
|
isWaiting = true;
|
||||||
|
|
@ -168,25 +322,29 @@
|
||||||
|
|
||||||
showTyping();
|
showTyping();
|
||||||
|
|
||||||
// Build memory context block
|
// Build conversation window (last 20 exchanges)
|
||||||
|
const convo = history.slice(-20).map(function (m) {
|
||||||
|
return { role: m.role, content: m.content };
|
||||||
|
});
|
||||||
|
|
||||||
const memoryBlock = mem ? mem.getContextBlock(text) : '';
|
const memoryBlock = mem ? mem.getContextBlock(text) : '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/chat', {
|
const resp = await fetch('/api/agent/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: text,
|
messages: convo,
|
||||||
history: history.slice(0, -1),
|
memory_context: memoryBlock,
|
||||||
memory_context: memoryBlock
|
}),
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
hideTyping();
|
hideTyping();
|
||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json().catch(function () { return {}; });
|
const err = await resp.json().catch(function () { return {}; });
|
||||||
addMessage('assistant', 'ERROR: ' + (err.error || 'Connection failed'));
|
addMessage('assistant', 'ERROR: ' + (err.error || ('HTTP ' + resp.status)));
|
||||||
chatStatus.textContent = '● ERROR';
|
chatStatus.textContent = '● ERROR';
|
||||||
chatStatus.classList.remove('status-amber');
|
chatStatus.classList.remove('status-amber');
|
||||||
chatStatus.classList.add('status-red');
|
chatStatus.classList.add('status-red');
|
||||||
|
|
@ -195,21 +353,37 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
const reply = data.reply || 'No response received.';
|
|
||||||
|
|
||||||
|
// Update tier from response
|
||||||
|
if (data.tier) { currentTier = data.tier; renderTierBadge(); }
|
||||||
|
|
||||||
|
// Render tool calls
|
||||||
|
if (Array.isArray(data.tool_calls)) {
|
||||||
|
data.tool_calls.forEach(addToolCallCard);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = data.content || '(no reply)';
|
||||||
history.push({ role: 'assistant', content: reply });
|
history.push({ role: 'assistant', content: reply });
|
||||||
if (mem) mem.appendHistory({ role: 'assistant', content: reply });
|
if (mem) mem.appendHistory({ role: 'assistant', content: reply });
|
||||||
|
|
||||||
if (history.length > 40) history = history.slice(-30);
|
if (history.length > 40) history = history.slice(-30);
|
||||||
|
|
||||||
const bodyEl = addMessage('assistant', '');
|
const bodyEl = addMessage('assistant', '');
|
||||||
await typewriterEffect(bodyEl, reply, 10);
|
bodyEl.parentElement.classList.add('chat-msg-agent');
|
||||||
|
bodyEl.parentElement.setAttribute('data-model', data.model_used || '');
|
||||||
|
await typewriterEffect(bodyEl, reply, 8);
|
||||||
|
|
||||||
|
// Execute frontend actions (effects etc.)
|
||||||
|
if (Array.isArray(data.frontend_actions)) {
|
||||||
|
data.frontend_actions.forEach(function (act) {
|
||||||
|
if (act.action === 'trigger_effect' && window.__jaeEffects && typeof window.__jaeEffects.toggle === 'function') {
|
||||||
|
try { window.__jaeEffects.toggle(act.effect); } catch (e) { console.warn('effect toggle failed', e); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger memory extraction every N user messages (non-blocking)
|
|
||||||
if (mem) {
|
if (mem) {
|
||||||
mem.incrementUserMsgAndMaybeExtract().catch(function () { /* silent */ });
|
mem.incrementUserMsgAndMaybeExtract().catch(function () { /* silent */ });
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
hideTyping();
|
hideTyping();
|
||||||
addMessage('assistant', 'ERROR: Network failure — ' + e.message);
|
addMessage('assistant', 'ERROR: Network failure — ' + e.message);
|
||||||
|
|
@ -225,7 +399,7 @@
|
||||||
chatInput.focus();
|
chatInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Memory modal ───
|
// ─── Memory modal ─────────────────────────────────
|
||||||
function formatDate(ts) {
|
function formatDate(ts) {
|
||||||
try { return new Date(ts).toLocaleString('en-GB'); } catch (e) { return ''; }
|
try { return new Date(ts).toLocaleString('en-GB'); } catch (e) { return ''; }
|
||||||
}
|
}
|
||||||
|
|
@ -282,67 +456,47 @@
|
||||||
<button class="mem-btn mem-btn-clearhist" id="memClearHist">🗑 CLEAR HISTORY</button>
|
<button class="mem-btn mem-btn-clearhist" id="memClearHist">🗑 CLEAR HISTORY</button>
|
||||||
<button class="mem-btn mem-btn-clear" id="memClear">⚠ CLEAR ALL MEMORIES</button>
|
<button class="mem-btn mem-btn-clear" id="memClear">⚠ CLEAR ALL MEMORIES</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(modal);
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
document.getElementById('memClose').addEventListener('click', function () { modal.remove(); });
|
document.getElementById('memClose').addEventListener('click', function () { modal.remove(); });
|
||||||
modal.addEventListener('click', function (e) {
|
modal.addEventListener('click', function (e) { if (e.target === modal) modal.remove(); });
|
||||||
if (e.target === modal) modal.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
modal.querySelectorAll('.mem-del').forEach(function (btn) {
|
modal.querySelectorAll('.mem-del').forEach(function (btn) {
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
const id = btn.getAttribute('data-id');
|
const id = btn.getAttribute('data-id');
|
||||||
mem.remove(id);
|
mem.remove(id);
|
||||||
renderMemoryModal(); renderMemoryModal(); // toggle off/on to refresh
|
renderMemoryModal(); renderMemoryModal();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('memExport').addEventListener('click', function () {
|
document.getElementById('memExport').addEventListener('click', function () {
|
||||||
const blob = new Blob([mem.exportJSON()], { type: 'application/json' });
|
const blob = new Blob([mem.exportJSON()], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url; a.download = 'jae-ai-memory-' + Date.now() + '.json'; a.click();
|
||||||
a.download = 'jae-ai-memory-' + Date.now() + '.json';
|
|
||||||
a.click();
|
|
||||||
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
setTimeout(function () { URL.revokeObjectURL(url); }, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('memExtract').addEventListener('click', function () {
|
document.getElementById('memExtract').addEventListener('click', function () {
|
||||||
const btn = document.getElementById('memExtract');
|
const btn = document.getElementById('memExtract');
|
||||||
btn.disabled = true;
|
btn.disabled = true; btn.textContent = '⌛ EXTRACTING...';
|
||||||
btn.textContent = '⌛ EXTRACTING...';
|
|
||||||
mem.extractFromRecentChat().then(function () {
|
mem.extractFromRecentChat().then(function () {
|
||||||
renderMemoryModal(); renderMemoryModal();
|
renderMemoryModal(); renderMemoryModal();
|
||||||
}).catch(function () {
|
}).catch(function () { btn.textContent = '✖ EXTRACT FAILED'; });
|
||||||
btn.textContent = '✖ EXTRACT FAILED';
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('memClearHist').addEventListener('click', function () {
|
document.getElementById('memClearHist').addEventListener('click', function () {
|
||||||
if (confirm('Clear chat history? Memories will be kept.')) {
|
if (confirm('Clear chat history? Memories will be kept.')) {
|
||||||
mem.clearHistory();
|
mem.clearHistory(); alert('Chat history cleared. Reload the page.');
|
||||||
alert('Chat history cleared. Reload the page.');
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('memClear').addEventListener('click', function () {
|
document.getElementById('memClear').addEventListener('click', function () {
|
||||||
if (confirm('PERMANENTLY DELETE ALL MEMORIES? This cannot be undone.')) {
|
if (confirm('PERMANENTLY DELETE ALL MEMORIES? This cannot be undone.')) {
|
||||||
mem.clear();
|
mem.clear(); renderMemoryModal(); renderMemoryModal();
|
||||||
renderMemoryModal(); renderMemoryModal();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
// ─── Header UI injection ──────────────────────────
|
||||||
return String(s || '').replace(/[&<>"']/g, function (c) {
|
|
||||||
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Inject memory button + privacy info into chat header ───
|
|
||||||
function injectMemoryUI() {
|
function injectMemoryUI() {
|
||||||
if (!chatHeader) return;
|
if (!chatHeader) return;
|
||||||
if (document.getElementById('memBtn')) return;
|
if (document.getElementById('memBtn')) return;
|
||||||
|
|
@ -366,7 +520,6 @@
|
||||||
|
|
||||||
chatHeader.appendChild(info);
|
chatHeader.appendChild(info);
|
||||||
chatHeader.appendChild(btn);
|
chatHeader.appendChild(btn);
|
||||||
|
|
||||||
updateMemCount();
|
updateMemCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -375,36 +528,37 @@
|
||||||
if (el && mem) el.textContent = mem.getAll().length;
|
if (el && mem) el.textContent = mem.getAll().length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Event listeners ───
|
// ─── Event listeners ──────────────────────────────
|
||||||
chatSend.addEventListener('click', sendMessage);
|
chatSend.addEventListener('click', sendMessage);
|
||||||
chatInput.addEventListener('keydown', function (e) {
|
chatInput.addEventListener('keydown', function (e) {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
|
||||||
e.preventDefault();
|
|
||||||
sendMessage();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Init ───
|
// ─── Init ─────────────────────────────────────────
|
||||||
injectMemoryUI();
|
injectMemoryUI();
|
||||||
|
renderTierBadge();
|
||||||
// Update memory count periodically
|
refreshWhoAmI();
|
||||||
setInterval(updateMemCount, 5000);
|
setInterval(updateMemCount, 5000);
|
||||||
|
|
||||||
const restored = restoreHistory();
|
const restored = restoreHistory();
|
||||||
|
|
||||||
if (!restored) {
|
if (!restored) {
|
||||||
// Auto-greeting after short delay (only if no history restored)
|
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
showTyping();
|
showTyping();
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
hideTyping();
|
hideTyping();
|
||||||
var greeting = 'Welcome to JAESWIFT.XYZ — I\'m JAE-AI, your onboard guide. Ask me anything about this system, or try saying "what can I explore here?"';
|
const greeting = 'Welcome to JAESWIFT.XYZ — I\'m JAE-AI. I can now search the site, fetch crypto prices, scan Solana wallets, look up .sol domains, pull SITREPs, and trigger visual effects. Connect your wallet for the Elite tier (1+ SOL) with Kimi-K2-Thinking. Ask me anything.';
|
||||||
history.push({ role: 'assistant', content: greeting });
|
history.push({ role: 'assistant', content: greeting });
|
||||||
if (mem) mem.appendHistory({ role: 'assistant', content: greeting });
|
if (mem) mem.appendHistory({ role: 'assistant', content: greeting });
|
||||||
var bodyEl = addMessage('assistant', '');
|
const bodyEl = addMessage('assistant', '');
|
||||||
typewriterEffect(bodyEl, greeting, 15);
|
typewriterEffect(bodyEl, greeting, 10);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Expose minimal API
|
||||||
|
window.__jaeChat = {
|
||||||
|
refreshWhoAmI: refreshWhoAmI,
|
||||||
|
getTier: function () { return currentTier; },
|
||||||
|
getAddress: function () { return currentAddress; },
|
||||||
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue