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)
|
||||
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'
|
||||
|
|
|
|||
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",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -2,3 +2,5 @@ flask
|
|||
flask-cors
|
||||
PyJWT
|
||||
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/scan-visitor.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.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">
|
||||
|
|
|
|||
312
js/chat.js
312
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 = `
|
||||
<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() {
|
||||
const indicator = document.createElement('div');
|
||||
indicator.className = 'chat-msg chat-msg-assistant chat-typing-indicator';
|
||||
|
|
@ -50,23 +88,25 @@
|
|||
indicator.innerHTML = `
|
||||
<span class="chat-msg-label">JAE-AI</span>
|
||||
<div class="chat-msg-body">
|
||||
<span class="typing-dot"></span>
|
||||
<span class="typing-dot"></span>
|
||||
<span class="typing-dot"></span>
|
||||
</div>
|
||||
`;
|
||||
<span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>
|
||||
</div>`;
|
||||
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 = '<span>— RESTORED FROM MEMORY —</span>';
|
||||
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 @@
|
|||
<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>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
</div>`;
|
||||
|
||||
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; },
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue