From e73b74cfa20d77492753d926b6ee3d91c91bff1e Mon Sep 17 00:00:00 2001 From: jae Date: Mon, 20 Apr 2026 10:40:27 +0000 Subject: [PATCH] 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 --- api/agent_routes.py | 321 ++++++++++++++++++++++++ api/agent_tiers.py | 44 ++++ api/agent_tools.py | 539 ++++++++++++++++++++++++++++++++++++++++ api/app.py | 15 ++ api/auth_routes.py | 261 +++++++++++++++++++ api/data/changelog.json | 20 ++ api/requirements.txt | 2 + css/agent-chat.css | 145 +++++++++++ index.html | 1 + js/chat.js | 312 +++++++++++++++++------ 10 files changed, 1581 insertions(+), 79 deletions(-) create mode 100644 api/agent_routes.py create mode 100644 api/agent_tiers.py create mode 100644 api/agent_tools.py create mode 100644 api/auth_routes.py create mode 100644 css/agent-chat.css diff --git a/api/agent_routes.py b/api/agent_routes.py new file mode 100644 index 0000000..5332394 --- /dev/null +++ b/api/agent_routes.py @@ -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', {}), + }) diff --git a/api/agent_tiers.py b/api/agent_tiers.py new file mode 100644 index 0000000..836d006 --- /dev/null +++ b/api/agent_tiers.py @@ -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']) diff --git a/api/agent_tools.py b/api/agent_tools.py new file mode 100644 index 0000000..1708172 --- /dev/null +++ b/api/agent_tools.py @@ -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] diff --git a/api/app.py b/api/app.py index 79cf1db..0cf2beb 100644 --- a/api/app.py +++ b/api/app.py @@ -30,6 +30,21 @@ try: app.register_blueprint(visitor_bp) except Exception as _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' JWT_SECRET = 'jaeswift-hud-s3cr3t-2026!x' diff --git a/api/auth_routes.py b/api/auth_routes.py new file mode 100644 index 0000000..224f2ee --- /dev/null +++ b/api/auth_routes.py @@ -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 diff --git a/api/data/changelog.json b/api/data/changelog.json index b2f9216..9aab52a 100644 --- a/api/data/changelog.json +++ b/api/data/changelog.json @@ -1,6 +1,26 @@ { "site": "jaeswift.xyz", "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", "date": "20/04/2026", diff --git a/api/requirements.txt b/api/requirements.txt index 4e00b20..0ca8157 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -2,3 +2,5 @@ flask flask-cors PyJWT requests +PyNaCl +base58 diff --git a/css/agent-chat.css b/css/agent-chat.css new file mode 100644 index 0000000..17ca372 --- /dev/null +++ b/css/agent-chat.css @@ -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; + } +} diff --git a/index.html b/index.html index d4fb8fb..c1fa0e2 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,7 @@ + diff --git a/js/chat.js b/js/chat.js index 554b509..a7b0a09 100644 --- a/js/chat.js +++ b/js/chat.js @@ -1,6 +1,6 @@ /* =================================================== - JAESWIFT.XYZ — JAE-AI Chat Terminal - Venice API chat interface + Memoria-style memory + JAESWIFT.XYZ — JAE-AI Agentic Chat Terminal + Tiered model routing + tool calls + wallet auth =================================================== */ (function () { @@ -18,8 +18,17 @@ let history = []; 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) { const welcome = chatMessages.querySelector('.chat-welcome'); if (welcome) welcome.remove(); @@ -42,7 +51,36 @@ 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 = ` +
+ 🔧 + ${escapeHtml(call.name)} + (${argsStr}) +
+
├─ ${statusIcon} ${escapeHtml(summary)}
+
└─ (click to expand)
${resultStr}
+ `; + chatMessages.appendChild(wrap); + chatMessages.scrollTop = chatMessages.scrollHeight; + } + + // ─── Typing indicator ───────────────────────────── function showTyping() { const indicator = document.createElement('div'); indicator.className = 'chat-msg chat-msg-assistant chat-typing-indicator'; @@ -50,23 +88,25 @@ indicator.innerHTML = ` JAE-AI
- - - -
- `; + + `; chatMessages.appendChild(indicator); chatMessages.scrollTop = chatMessages.scrollHeight; } - function hideTyping() { const el = document.getElementById('chatTyping'); if (el) el.remove(); } - // ─── Typewriter effect ─── + // ─── Typewriter effect ──────────────────────────── 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; element.textContent = ''; return new Promise(function (resolve) { @@ -84,16 +124,13 @@ }); } - // ─── Restore previous chat history from localStorage ─── + // ─── History restore ────────────────────────────── function restoreHistory() { if (!mem) return false; const saved = mem.getHistory(); if (!saved || saved.length === 0) return false; - - // Remove welcome and render saved history const welcome = chatMessages.querySelector('.chat-welcome'); if (welcome) welcome.remove(); - saved.forEach(function (m) { const role = m.role === 'user' ? 'user' : 'assistant'; const label = document.createElement('span'); @@ -108,26 +145,143 @@ el.appendChild(body); chatMessages.appendChild(el); }); - - // Divider showing restore const divider = document.createElement('div'); divider.className = 'chat-divider'; divider.innerHTML = '— RESTORED FROM MEMORY —'; chatMessages.appendChild(divider); - chatMessages.scrollTop = chatMessages.scrollHeight; - - // populate in-memory history history = saved.slice(); 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() { const text = chatInput.value.trim(); 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(); if ((text.startsWith('/') || inCliMode) && window.__jaeCLI && typeof window.__jaeCLI.handle === 'function') { isWaiting = true; @@ -168,25 +322,29 @@ 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) : ''; try { - const resp = await fetch('/api/chat', { + const resp = await fetch('/api/agent/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', body: JSON.stringify({ - message: text, - history: history.slice(0, -1), - memory_context: memoryBlock - }) + messages: convo, + memory_context: memoryBlock, + }), }); hideTyping(); if (!resp.ok) { 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.classList.remove('status-amber'); chatStatus.classList.add('status-red'); @@ -195,21 +353,37 @@ } 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 }); if (mem) mem.appendHistory({ role: 'assistant', content: reply }); - if (history.length > 40) history = history.slice(-30); 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) { mem.incrementUserMsgAndMaybeExtract().catch(function () { /* silent */ }); } - } catch (e) { hideTyping(); addMessage('assistant', 'ERROR: Network failure — ' + e.message); @@ -225,7 +399,7 @@ chatInput.focus(); } - // ─── Memory modal ─── + // ─── Memory modal ───────────────────────────────── function formatDate(ts) { try { return new Date(ts).toLocaleString('en-GB'); } catch (e) { return ''; } } @@ -282,67 +456,47 @@ - - `; + `; document.body.appendChild(modal); document.getElementById('memClose').addEventListener('click', function () { modal.remove(); }); - modal.addEventListener('click', function (e) { - if (e.target === modal) modal.remove(); - }); + modal.addEventListener('click', function (e) { if (e.target === modal) modal.remove(); }); modal.querySelectorAll('.mem-del').forEach(function (btn) { btn.addEventListener('click', function () { const id = btn.getAttribute('data-id'); mem.remove(id); - renderMemoryModal(); renderMemoryModal(); // toggle off/on to refresh + renderMemoryModal(); renderMemoryModal(); }); }); - document.getElementById('memExport').addEventListener('click', function () { const blob = new Blob([mem.exportJSON()], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); - a.href = url; - a.download = 'jae-ai-memory-' + Date.now() + '.json'; - a.click(); + a.href = url; a.download = 'jae-ai-memory-' + Date.now() + '.json'; a.click(); setTimeout(function () { URL.revokeObjectURL(url); }, 1000); }); - document.getElementById('memExtract').addEventListener('click', function () { const btn = document.getElementById('memExtract'); - btn.disabled = true; - btn.textContent = '⌛ EXTRACTING...'; + btn.disabled = true; btn.textContent = '⌛ EXTRACTING...'; mem.extractFromRecentChat().then(function () { renderMemoryModal(); renderMemoryModal(); - }).catch(function () { - btn.textContent = '✖ EXTRACT FAILED'; - }); + }).catch(function () { btn.textContent = '✖ EXTRACT FAILED'; }); }); - document.getElementById('memClearHist').addEventListener('click', function () { if (confirm('Clear chat history? Memories will be kept.')) { - mem.clearHistory(); - alert('Chat history cleared. Reload the page.'); + mem.clearHistory(); alert('Chat history cleared. Reload the page.'); } }); - document.getElementById('memClear').addEventListener('click', function () { if (confirm('PERMANENTLY DELETE ALL MEMORIES? This cannot be undone.')) { - mem.clear(); - renderMemoryModal(); renderMemoryModal(); + mem.clear(); renderMemoryModal(); renderMemoryModal(); } }); } - function escapeHtml(s) { - return String(s || '').replace(/[&<>"']/g, function (c) { - return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; - }); - } - - // ─── Inject memory button + privacy info into chat header ─── + // ─── Header UI injection ────────────────────────── function injectMemoryUI() { if (!chatHeader) return; if (document.getElementById('memBtn')) return; @@ -366,7 +520,6 @@ chatHeader.appendChild(info); chatHeader.appendChild(btn); - updateMemCount(); } @@ -375,36 +528,37 @@ if (el && mem) el.textContent = mem.getAll().length; } - // ─── Event listeners ─── + // ─── Event listeners ────────────────────────────── chatSend.addEventListener('click', sendMessage); chatInput.addEventListener('keydown', function (e) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); - // ─── Init ─── + // ─── Init ───────────────────────────────────────── injectMemoryUI(); - - // Update memory count periodically + renderTierBadge(); + refreshWhoAmI(); setInterval(updateMemCount, 5000); const restored = restoreHistory(); - if (!restored) { - // Auto-greeting after short delay (only if no history restored) setTimeout(function () { showTyping(); setTimeout(function () { 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 }); if (mem) mem.appendHistory({ role: 'assistant', content: greeting }); - var bodyEl = addMessage('assistant', ''); - typewriterEffect(bodyEl, greeting, 15); + const bodyEl = addMessage('assistant', ''); + typewriterEffect(bodyEl, greeting, 10); }, 1500); }, 2000); } + // Expose minimal API + window.__jaeChat = { + refreshWhoAmI: refreshWhoAmI, + getTier: function () { return currentTier; }, + getAddress: function () { return currentAddress; }, + }; })();