commit ccee249618a90af1476d610e06c92c8c028dea9f Author: Nico Date: Mon Mar 30 19:35:10 2026 +0200 v0.6.42: Hermes chat UI — Vue3/TS/Vite, audio STT/TTS, sidebar rail, MCP event loop Co-Authored-By: Claude Sonnet 4.6 diff --git a/README.md b/README.md new file mode 100644 index 0000000..41e4987 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# Hermes — OpenClaw Web Interface + +**Prod:** `https://chat.jqxp.org` · `wss://chat.jqxp.org/ws` +**Dev:** `https://dev.jqxp.org` + +--- + +## Stack + +``` +hermes/ + backend/ Bun + TypeScript :3001 prod / :3003 dev + frontend/ Vue 3 + Vite :8443 dev / jqxp.org prod (static) +``` + +## Architecture + +``` +Browser(s) + │ WebSocket /ws + HTTP /api/* + ▼ +server.ts (Bun.serve) + ├── auth.ts token → user, session tokens + ├── gateway.ts upstream WS → openclaw gateway :18789 + ├── session-sm.ts per-connection state machine + ├── session-watcher.ts JSONL tail → WS events + ├── hud-builder.ts HUD event factory, result builders, path helpers + ├── message-filter.ts strip sensitive values from tool output + └── mcp/ MCP Streamable HTTP server + ├── index.ts auth, routing, key management + ├── dev.ts health, subscribe, push_state tools + ├── events.ts in-memory event queue + long-poll + ├── system.ts start/stop/restart/deploy tools + └── fs.ts remote file read/write/edit/grep +``` + +See [backend/README.md](backend/README.md) and [frontend/README.md](frontend/README.md) for details. + +## Dev + +### On openclaw VM (tmux sessions) + +```bash +# Backend (auto-restarts on file change) +cd hermes/backend +PORT=3003 ~/.bun/bin/bun --watch run server.ts # tmux: hermes-bun + +# Frontend (Vite HMR) +cd hermes/frontend +bun run dev # tmux: webchat-vite → :8443 +``` + +### From Titan host (Claude Code workspace) + +Local mirror: `D:/ClaudeCode/Titan/Openclaw/` ↔ `~/.openclaw/` on openclaw VM. + +```bash +# Pull entire project +rsync -avz --exclude='node_modules' --exclude='dist' --exclude='.git' \ + openclaw:~/.openclaw/workspace-titan/projects/hermes/ \ + /d/ClaudeCode/Titan/Openclaw/workspace-titan/projects/hermes/ + +# Edit locally with Claude Code (Read/Edit tools — no shell escaping issues) + +# Push backend (bun --watch auto-restarts) +rsync -avz --exclude='node_modules' --exclude='dist' --exclude='.git' \ + /d/ClaudeCode/Titan/Openclaw/workspace-titan/projects/hermes/backend/ \ + openclaw:~/.openclaw/workspace-titan/projects/hermes/backend/ + +# Push frontend (Vite HMR picks up instantly) +rsync -avz --exclude='node_modules' --exclude='dist' --exclude='.git' \ + /d/ClaudeCode/Titan/Openclaw/workspace-titan/projects/hermes/frontend/ \ + openclaw:~/.openclaw/workspace-titan/projects/hermes/frontend/ +``` + +## Deploy + +```bash +# Frontend → static hosting +cd hermes/frontend && npm run build +rsync -avz --delete dist/ u116526981@access1007204406.webspace-data.io:~/jqxp/ + +# Backend prod restart +sudo systemctl restart openclaw-web-gateway.service +curl https://chat.jqxp.org/health +``` + +## Ports + +``` +:3001 prod backend systemd: openclaw-web-gateway.service +:3003 dev backend tmux: hermes-bun +:8443 dev frontend tmux: webchat-vite +``` + +## Network + +``` +Browser → Cloudflare Tunnel → RouterVM → OpenClaw VM :3001 → gateway :18789 +``` + +## Features (0.6.42) + +- Multi-modal chat: text, images, PDFs, audio recording +- Audio STT via ElevenLabs Scribe v2 (mic → transcript → agent) +- TTS via ElevenLabs (speaker icon on assistant messages, player bar) +- Permanent sidebar rail with overlay expand, responsive +- Previous session history (server-fetched, load more) +- MCP server with 36+ tools (fs, system, deck, dev, takeover) +- Real-time MCP event loop (dev_subscribe + dev_push_state) +- Interactive dev tools: counter game, action picker, confetti +- Automated test runner via Nextcloud Deck + takeover bridge +- WebGL particle background (theme-aware) +- Deploy to prod via MCP (system_deploy_prod) + +## Open + +- [ ] Token management (tokens hardcoded in auth.ts) +- [ ] Room mode (shared session, multiple users) +- [ ] User message lifecycle (msgId-based updates, dedup) +- [ ] Rename project/domain diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..dfcd824 --- /dev/null +++ b/backend/.env @@ -0,0 +1,4 @@ +DECK_BASE_URL=https://nc-3727881151918660719.nextcloud-ionos.com +DECK_USER=claude +DECK_TOKEN=9DJE7-fcBEw-3PXpf-rTLsW-xCPaG +ELEVENLABS_API_KEY=sk_4eea9b8b5c5d67a4a20f29607310ea8ea4669f9b41cfde58 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..b115eba --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,5 @@ +.session-tokens.json +.session-tokens-dev.json +.session-tokens-prod.json +.takeover-tokens.json +.mcp-keys.json diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..c373850 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,214 @@ +# Hermes Backend + +WebSocket gateway between browser clients and the OpenClaw agent runtime. + +## Stack + +- **Bun** — runtime, HTTP server, WebSocket server (native, no npm deps) +- **TypeScript** — all source files, run directly by Bun (no compile step) + +## Files + +| File | Role | +|----------------------|---------------------------------------------| +| `server.ts` | Main entry — HTTP + WebSocket via Bun.serve | +| `gateway.ts` | Upstream connection to OpenClaw gateway | +| `auth.ts` | Token auth, session tokens, OTP | +| `session-sm.ts` | Per-connection state machine | +| `session-watcher.ts` | JSONL session tail + event dispatch | +| `session-watcher.ts` | JSONL session tail + prev session lookup | +| `hud-builder.ts` | HUD event factory, result builders | +| `message-filter.ts` | Filter sensitive values from tool output | +| `system-access.ts` | System access request/approve flow | +| `mcp/index.ts` | MCP Streamable HTTP server, auth, routing | +| `mcp/dev.ts` | Health, subscribe, push_state, chat tools | +| `mcp/events.ts` | In-memory event queue + long-poll | +| `mcp/system.ts` | Start/stop/restart/deploy tools | +| `mcp/fs.ts` | Remote file read/write/edit/grep/ls | +| `mcp/deck.ts` | Nextcloud Deck integration | +| `mcp/takeover.ts` | Browser takeover bridge | + +### HTTP Endpoints (added in 0.6.x) + +| Endpoint | Auth | Purpose | +|-------------------------|----------|----------------------------------| +| `POST /api/tts` | session | ElevenLabs TTS generation | +| `GET /api/session-history` | session | Previous session messages | +| `POST /api/dev/counter` | session | Push counter events to MCP | +| `POST /api/dev/broadcast` | session | Push WS state to browsers | + +## Dev Setup + +```bash +cd projects/hermes/backend +NODE_TLS_REJECT_UNAUTHORIZED=0 PORT=3003 ~/.bun/bin/bun --watch run server.ts +``` + +- **`--watch`** — auto-restarts on file change (rsync push → instant reload) +- `NODE_TLS_REJECT_UNAUTHORIZED=0` — required because the OpenClaw gateway uses a self-signed TLS cert +- Default port: `3001` (prod) / `3003` (dev) +- Requires a running OpenClaw gateway on `:18789` + +### Dev workflow (from Titan host) + +```bash +# Pull remote → local mirror +rsync -avz --exclude='node_modules' --exclude='dist' --exclude='.git' \ + openclaw:~/.openclaw/workspace-titan/projects/hermes/ \ + /d/ClaudeCode/Titan/Openclaw/workspace-titan/projects/hermes/ + +# Edit locally (Claude Code Read/Edit tools — no escaping issues) + +# Push local → remote (bun --watch restarts BE, Vite HMR updates FE) +rsync -avz --exclude='node_modules' --exclude='dist' --exclude='.git' \ + /d/ClaudeCode/Titan/Openclaw/workspace-titan/projects/hermes/backend/ \ + openclaw:~/.openclaw/workspace-titan/projects/hermes/backend/ +``` + +## Production + +Managed by systemd: + +```bash +sudo systemctl status openclaw-web-gateway.service +sudo systemctl restart openclaw-web-gateway.service +journalctl -u openclaw-web-gateway.service -f +``` + +Health check: +```bash +curl https://chat.jqxp.org/health +``` + +## Architecture + +``` +Browser (WebSocket / HTTP) + │ + ▼ +server.ts ─── Bun.serve() + │ + ├── HTTP routes + │ /health, /agents + │ /api/auth, /api/auth/verify, /api/auth/logout + │ /api/viewer/token, /api/viewer/tree, /api/viewer/file + │ + └── WebSocket (/ws) + │ + ├── auth.ts token validation, session tokens + ├── gateway.ts RPC → openclaw gateway (:18789, WSS) + ├── session-sm.ts state: IDLE / AGENT_RUNNING / HANDOVER_* / SWITCHING + └── session-watcher.ts tails .jsonl, emits events to WS client +``` + +## WebSocket Protocol + +### Client → Server + +| Type | Auth | Description | +|---------------------|------|--------------------------------------| +| `connect` | No | Identify by username | +| `auth` | No | Identify by session/static token | +| `message` | Yes | Send message to agent | +| `stop` | Yes | Request agent stop | +| `kill` | Yes | Kill agent turn | +| `switch_agent` | Yes | Switch to different agent | +| `handover_request` | Yes | Ask agent to write HANDOVER.md | +| `new` | Yes | Reset session | +| `new_with_handover` | Yes | Handover then reset | +| `cancel_handover` | Yes | Cancel pending handover | +| `ping` | No | Keepalive | +| `stats_request` | No | OpenRouter credits + model info | +| `disco_request` | Yes | Disconnect gateway (triggers reconnect) | +| `disco_chat_request`| Yes | Close WebSocket | + +### Server → Client + +| Type | Description | +|-----------------------|-----------------------------------------------| +| `ready` | Auth success — session info, agents list | +| `session_state` | State machine transition | +| `sent` | Message accepted by gateway | +| `delta` | Streaming text chunk from agent | +| `done` | Agent turn complete | +| `tool` | Tool call or result (action: call/result) | +| `finance_update` | Per-turn cost estimate | +| `handover_context` | HANDOVER.md content on connect | +| `handover_done` | Handover written, content included | +| `handover_writing` | Handover in progress | +| `new_ok` | Session reset acknowledged | +| `switch_ok` | Agent switch confirmed | +| `viewer_file_changed` | Watched file changed (viewer feature) | +| `stats` | OpenRouter credit + model data | +| `pong` | Ping reply | +| `killed` / `stopped` | Agent turn terminated | +| `error` | Error with `code` and `message` | + +## HTTP Endpoints + +| Method | Path | Auth | Description | +|--------|----------------------|---------|-----------------------------------| +| GET | `/health` | None | Status, version, gateway state | +| GET | `/agents` | None | Agent list with models | +| POST | `/api/auth` | None | Login with static token | +| POST | `/api/auth/verify` | None | Verify OTP challenge | +| POST | `/api/auth/logout` | Session | Revoke session token | +| POST | `/api/viewer/token` | Session | Issue fstoken for file viewer | +| GET | `/api/viewer/tree` | fstoken | List files/dirs in viewer root | +| GET | `/api/viewer/file` | fstoken | Serve file content or PDF | +| HEAD | `/api/viewer/file` | fstoken | Check file existence/type | + +## Viewer + +Read-only file browser exposed over HTTP. Roots: + +| Key | Path | +|-------------------|---------------------------------------------| +| `shared` | `/home/openclaw/.openclaw/shared` | +| `titan` | `/home/openclaw/.openclaw/workspace-titan` | +| `adoree` | `/home/openclaw/.openclaw/workspace-adoree` | +| `alfred` | `/home/openclaw/.openclaw/workspace-alfred` | +| `ash` | `/home/openclaw/.openclaw/workspace-ash` | +| `eras` | `/home/openclaw/.openclaw/workspace-eras` | +| `willi` | `/home/openclaw/.openclaw/workspace-willi` | + +Allowed extensions: `.md .txt .ts .js .json .sh .py .pdf .css .woff2 .ttf .otf` +Max file size: 2MB. Live reload via `fs.watch` pushed over WebSocket (`viewer_file_changed`). + +## Session State Machine + +``` +IDLE + ├─ message → AGENT_RUNNING + ├─ handover_request → HANDOVER_PENDING + └─ new / new_with_handover → SWITCHING + +AGENT_RUNNING + ├─ agent_done → IDLE + ├─ stop / kill → IDLE + └─ send_error → IDLE + +HANDOVER_PENDING + ├─ handover_written → HANDOVER_DONE + ├─ handover_error → IDLE + └─ cancel_handover → IDLE + +HANDOVER_DONE + └─ cancel_handover → IDLE + +SWITCHING + ├─ switch_ready → IDLE + └─ new_greeting → AGENT_RUNNING +``` + +## Environment Variables + +| Variable | Default | Description | +|-------------------------------|------------------|-------------------------------------| +| `PORT` | `3001` | HTTP/WS listen port | +| `GATEWAY_HOST` | `10.0.0.10` | OpenClaw gateway host | +| `GATEWAY_PORT` | `18789` | OpenClaw gateway port | +| `GATEWAY_TOKEN` | (hardcoded) | Auth token for gateway connection | +| `NODE_TLS_REJECT_UNAUTHORIZED`| `1` (verify) | Set to `0` for self-signed certs | +| `SSL_KEY` | `../web-frontend/ssl/key.pem` | TLS key for HTTPS (optional) | +| `SSL_CERT` | `../web-frontend/ssl/cert.pem` | TLS cert for HTTPS (optional)| diff --git a/backend/agents.json b/backend/agents.json new file mode 100644 index 0000000..c303173 --- /dev/null +++ b/backend/agents.json @@ -0,0 +1,18 @@ +{ + "titan": { "segment": "personal", "owner": "nico", "modes": ["private", "public"] }, + "adoree": { "segment": "personal", "owner": "tina", "modes": ["private", "public"] }, + "alfred": { "segment": "personal", "owner": "niclas", "modes": ["private", "public"] }, + "ash": { "segment": "personal", "owner": "loona", "modes": ["private", "public"] }, + "willi": { "segment": "personal", "owner": "hendrik", "modes": ["private", "public"] }, + "annette": { "segment": "personal", "owner": "hendrik", "members": ["*"], "modes": ["private", "public"] }, + "lotta": { "segment": "personal", "owner": "hendrik", "members": ["*"], "modes": ["private", "public"] }, + "scotty": { "segment": "common", "modes": ["private", "public"] }, + "lock": { "segment": "common", "modes": ["private", "public"] }, + "lyzer": { "segment": "common", "modes": ["private", "public"] }, + "clerk": { "segment": "common", "modes": ["private", "public"] }, + "coder": { "segment": "common", "modes": ["private", "public"] }, + "eras": { "segment": "private", "modes": ["private"] }, + "tester": { "segment": "public", "modes": ["public"] }, + "tested": { "segment": "public", "modes": ["public"] }, + "input": { "segment": "public", "modes": ["public"] } +} diff --git a/backend/auth.ts b/backend/auth.ts new file mode 100644 index 0000000..d3fe9bd --- /dev/null +++ b/backend/auth.ts @@ -0,0 +1,248 @@ +/** + * auth.ts — Token auth, user config, agent mappings + */ + +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; + +// --- Session tokens --- +const SESSION_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const ENV_SUFFIX = process.env.PORT === '3001' ? 'prod' : 'dev'; +const SESSION_TOKEN_FILE = path.join(import.meta.dir, `.session-tokens-${ENV_SUFFIX}.json`); +const sessionTokenMap = new Map(); + +function persistSessionTokens() { + try { + const obj: Record = {}; + for (const [tok, entry] of sessionTokenMap) obj[tok] = entry; + fs.writeFileSync(SESSION_TOKEN_FILE, JSON.stringify(obj), 'utf8'); + } catch (e: any) { console.error('Failed to persist session tokens:', e.message); } +} + +function loadSessionTokens() { + try { + if (!fs.existsSync(SESSION_TOKEN_FILE)) return; + const obj = JSON.parse(fs.readFileSync(SESSION_TOKEN_FILE, 'utf8')); + const now = Date.now(); + for (const [tok, entry] of Object.entries(obj) as [string, any][]) { + if (entry.expiresAt > now) sessionTokenMap.set(tok, entry); + } + console.log(`Loaded ${sessionTokenMap.size} session token(s) from disk.`); + } catch (e: any) { console.error('Failed to load session tokens:', e.message); } +} + +loadSessionTokens(); + +export function issueSessionToken(user: string): string { + const token = randomUUID(); + sessionTokenMap.set(token, { user, expiresAt: Date.now() + SESSION_TOKEN_TTL_MS }); + persistSessionTokens(); + return token; +} + +export function getUserForSessionToken(token: string | null | undefined): string | null { + if (!token) return null; + const entry = sessionTokenMap.get(token); + if (!entry) return null; + if (entry.expiresAt < Date.now()) { sessionTokenMap.delete(token); persistSessionTokens(); return null; } + return entry.user; +} + +export function revokeSessionToken(token: string) { + sessionTokenMap.delete(token); + persistSessionTokens(); +} + +export function revokeAllSessionTokensForUser(user: string) { + for (const [tok, entry] of sessionTokenMap) { + if (entry.user === user) sessionTokenMap.delete(tok); + } + persistSessionTokens(); +} + +// --- Auth nonces (prevent login spam) --- +const NONCE_TTL_MS = 60 * 1000; +const nonceMap = new Map(); // nonce → expiresAt + +export function issueAuthNonce(): string { + const nonce = randomUUID(); + nonceMap.set(nonce, Date.now() + NONCE_TTL_MS); + return nonce; +} + +export function consumeAuthNonce(nonce: string): boolean { + const expiresAt = nonceMap.get(nonce); + if (!expiresAt) return false; + nonceMap.delete(nonce); + if (expiresAt < Date.now()) return false; + return true; +} + +// --- OTP challenges --- +const OTP_TTL_MS = 5 * 60 * 1000; +const otpChallengeMap = new Map(); + +export function issueOtpChallenge(user: string): { challengeId: string; otp: string } { + const challengeId = randomUUID(); + const otp = String(Math.floor(10000 + Math.random() * 90000)); + otpChallengeMap.set(challengeId, { user, otp, expiresAt: Date.now() + OTP_TTL_MS }); + return { challengeId, otp }; +} + +export function verifyOtpChallenge(challengeId: string, otp: string): string | null { + const entry = otpChallengeMap.get(challengeId); + if (!entry) return null; + if (entry.expiresAt < Date.now()) { otpChallengeMap.delete(challengeId); return null; } + if (entry.otp !== otp) return null; + otpChallengeMap.delete(challengeId); + return entry.user; +} + +// --- Static token map --- +export const TOKENS: Record = { + 'nico38638': 'nico', + 'tina38638': 'tina', + 'test123': 'test', + 'niclas38638': 'niclas', + 'loona38638': 'loona', + 'hendrik38638': 'hendrik', + 'eras38638': 'eras', +}; + +export const userDefaultAgent: Record = { + 'nico': 'titan', + 'tina': 'adoree', + 'niclas': 'alfred', + 'loona': 'ash', + 'hendrik': 'willi', + 'eras': 'eras', +}; + +export const userViewerRoots: Record = { + 'nico': ['shared', 'workspace-titan'], + 'tina': ['shared', 'workspace-adoree'], + 'eras': ['shared', 'workspace-eras'], + 'niclas': ['shared', 'workspace-alfred'], + 'loona': ['shared', 'workspace-ash'], + 'hendrik': ['shared', 'workspace-willi'], +}; + +export function getTokenForUser(user: string): string | null { + const token = user + '123'; + return TOKENS[token] ? token : null; +} + +export function getUserForToken(token: string): string | null { + return TOKENS[token] || null; +} + +// --- OpenClaw config cache --- +let cachedOpenClawConfig: any = null; +let lastOpenClawRead = 0; +const OPENCLAW_CACHE_DURATION_MS = 2_000; + +function getOpenClawConfig(): any { + if (cachedOpenClawConfig && (Date.now() - lastOpenClawRead < OPENCLAW_CACHE_DURATION_MS)) { + return cachedOpenClawConfig; + } + try { + cachedOpenClawConfig = JSON.parse(fs.readFileSync('/home/openclaw/.openclaw/openclaw.json', 'utf8')); + lastOpenClawRead = Date.now(); + return cachedOpenClawConfig; + } catch (err: any) { + console.error('Error reading openclaw.json:', err.message); + return {}; + } +} + +// --- agents.json metadata --- +const AGENTS_META_PATH = path.join(import.meta.dir, 'agents.json'); + +interface AgentMeta { segment?: string; owner?: string; members?: string[]; modes?: string[]; } + +function loadAgentsMeta(): Record { + try { return JSON.parse(fs.readFileSync(AGENTS_META_PATH, 'utf8')); } + catch { return {}; } +} + +export function getAgentSegment(agentId: string): string { + const meta = loadAgentsMeta(); + return (meta[agentId]?.segment) ?? 'utility'; +} + +function computeRole(meta: AgentMeta, user: string): string { + if (meta.segment === 'private') return 'private'; + if (meta.segment === 'public') return 'public'; + if (meta.segment === 'common') return 'common'; + if (meta.segment === 'personal') { + if (meta.owner === user) return 'owner'; + if (meta.members?.includes('*') || meta.members?.includes(user)) return 'member'; + return 'guest'; + } + return 'common'; +} + +// --------------------------------------------------------------------------- +// Session context prompts — injected per mode to set privacy boundaries +// --------------------------------------------------------------------------- + +const SESSION_PROMPTS = { + owner_private: 'IMPORTANT: This is a private 1:1 session with your owner. Be direct, informal, use their preferences. You may reference all prior context freely.', + user_private: 'IMPORTANT: This is a private 1:1 session with {user} (NOT your owner). Do NOT greet your owner. Do NOT share details from your owner\'s private sessions. Do NOT rewrite your handover. Address {user} directly.', + public: 'IMPORTANT: This is a PUBLIC shared channel. Multiple users may be reading. Do NOT greet any specific person by name. Do NOT reference private conversations. Do NOT rewrite your handover. Be professional and neutral. Address the room, not an individual.', +} as const; + +/** + * Returns the session context prompt for a given session key and user. + * Selection logic: + * - public session (web:public or :main) → public prompt + * - private session, user is agent owner → owner_private prompt + * - private session, user is not owner → user_private prompt (with {user} replaced) + */ +export function getSessionPrompt(agentId: string, sessionKey: string, user: string): string { + const isPublic = sessionKey.includes(':web:public') || sessionKey.endsWith(':main'); + if (isPublic) return SESSION_PROMPTS.public; + + const meta = loadAgentsMeta(); + const m = meta[agentId]; + if (m?.owner === user) return SESSION_PROMPTS.owner_private; + return SESSION_PROMPTS.user_private.replace('{user}', user); +} + +export function getAgentList(): Array<{ id: string; name: string }> { + const cfg = getOpenClawConfig(); + return (cfg.agents?.list || []).map((a: any) => ({ + id: a.id, + name: a.id.charAt(0).toUpperCase() + a.id.slice(1), + })); +} + +export function getAgentListWithModels(user?: string): Array<{ id: string; name: string; model: string; modelFull: string; segment: string; role: string; modes: string[] }> { + const cfg = getOpenClawConfig(); + const meta = loadAgentsMeta(); + return (cfg.agents?.list || []).map((a: any) => { + const m = meta[a.id] ?? {}; + return { + id: a.id, + name: a.id.charAt(0).toUpperCase() + a.id.slice(1), + model: (a.model || '').split('/').pop() || '', + modelFull: a.model || '', + segment: m.segment ?? 'utility', + role: user ? computeRole(m, user) : (m.segment ?? 'utility'), + modes: m.modes ?? ['private', 'public'], + }; + }); +} + +function getUserAllowedAgents(user: string): string[] { + const all = getAgentList().map(a => a.id); + const filtered = all.filter(id => id !== 'tester' && id !== 'tested'); + if (user === 'nico') return all; + if (user === 'eras') return ['eras']; + return filtered; +} + +export const userAllowedAgents: Record = new Proxy({} as Record, { + get(_, user: string) { return getUserAllowedAgents(user); }, +}); diff --git a/backend/bun.lock b/backend/bun.lock new file mode 100644 index 0000000..64bdf92 --- /dev/null +++ b/backend/bun.lock @@ -0,0 +1,195 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "openclaw-web-gateway", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", + }, + }, + }, + "packages": { + "@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.12.8", "", {}, "sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + } +} diff --git a/backend/gateway-ca.crt b/backend/gateway-ca.crt new file mode 100644 index 0000000..049e08b --- /dev/null +++ b/backend/gateway-ca.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDFzCCAf+gAwIBAgIUJzhSyv/HwK5PZTpj3yab3fwABrswDQYJKoZIhvcNAQEL +BQAwGzEZMBcGA1UEAwwQb3BlbmNsYXctZ2F0ZXdheTAeFw0yNjAyMjExNDAxNTBa +Fw0zNjAyMTkxNDAxNTBaMBsxGTAXBgNVBAMMEG9wZW5jbGF3LWdhdGV3YXkwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC+PBsiqqoYjasdVe9NPUq3R6In +ULkqabZVQ/w33upjaQ26z5K0IRrYQWKa8+SPF6IsWaPFtrknOonFOCblqjN91r6X +xjPDNiOfEaKufPDV8bI77uZ7plTGu6PWRJlwkXG60IW5kc7dQKIBnXJalBW7wQh6 +tsTuwracHME5iehjs0y3PqljqaRlgvCIIVPLPlmNeDMM3ZinCAvamEVdS2l7uOu5 +f4DRq83ed7OEwl6a64MCTxrZoSiyZzRUHMYkZrt1VxIE2QIfzwZE5AvBAtmJjvBS +Mwjc3wbY9QZx6CUbyamGdUK6AEjVKgcfdy0xD5UQ1jEMqiOrDuBd7e3m6ncFAgMB +AAGjUzBRMB0GA1UdDgQWBBRO3/RUMBeGG7ri/0AkuSNMA21bVjAfBgNVHSMEGDAW +gBRO3/RUMBeGG7ri/0AkuSNMA21bVjAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4IBAQCrOVBF08/UHcPK216uK7ULmlJzRFV/BWD4LTtNg5Fpus2jqY7M +JrrnCtfq93fhVR0TF6pzvDWZgzSeiyH9+kmCLLS2ttvcF8ltysvDPRVgkKeEEL0H +74XaA8xX93Q4xZqpZwdb18JRXITuEno4cR1jBOLkNo4dZDImjT70GsjNf/TpnfXM +h0B4TmaNLl+sY6bWuFqbW/L3tRzWjLF2KzRrJn0uvnh90Ry7EVnftgf/0D7jXVJ8 +pOiWDLKfGIkuWfIO/oqgVqDBzgUw4Wppz+L4st0gB+KaJ9SlN0fgeRYX2j+hH8Ue +LX12zCaIUQSPOr0nACPwCNMzDuK29roCzaZC +-----END CERTIFICATE----- diff --git a/backend/gateway.ts b/backend/gateway.ts new file mode 100644 index 0000000..a2c174b --- /dev/null +++ b/backend/gateway.ts @@ -0,0 +1,581 @@ +/** + * gateway.ts — OpenClaw gateway WebSocket connection (Bun) + * + * Manages the single upstream connection to openclaw gateway, + * request/response tracking, and event forwarding to browser sessions. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { sign, createPrivateKey } from 'crypto'; +import { filterText, filterValue } from './message-filter'; +import { hud, buildToolArgs, resolveToolResult, buildExecResult, buildGenericResult } from './hud-builder'; + +const GATEWAY_HOST = process.env.GATEWAY_HOST || '10.0.0.10'; +const GATEWAY_PORT = process.env.GATEWAY_PORT || '18789'; +const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || 'bd162cc05dac9371b92d613307350ae4bae3858f79240654'; + +// Device identity for gateway auth (required since openclaw 2026.3.13 for operator.write scope) +interface DeviceIdentity { deviceId: string; publicKeyPem: string; privateKeyPem: string; } +const DEVICE_IDENTITY_PATH = path.join( + process.env.HOME || '/home/openclaw', '.openclaw/identity/device.json' +); +const deviceIdentity: DeviceIdentity | null = (() => { + try { return JSON.parse(fs.readFileSync(DEVICE_IDENTITY_PATH, 'utf8')); } + catch { console.warn('[auth] device.json not found — connecting without device auth'); return null; } +})(); + +// Mirrors openclaw's buildDeviceAuthPayloadV3 — pipe-delimited string for Ed25519 signing +function buildV3Payload(p: { + deviceId: string; clientId: string; clientMode: string; role: string; + scopes: string[]; signedAtMs: number; token: string; nonce: string; +}): string { + return ['v3', p.deviceId, p.clientId, p.clientMode, p.role, + p.scopes.join(','), String(p.signedAtMs), p.token, p.nonce, + 'linux', 'linux', // platform + deviceFamily (normalised to lowercase) + ].join('|'); +} + +// Load gateway self-signed CA cert for TLS verification +const GATEWAY_CA_CERT = (() => { + try { + const p = path.join(import.meta.dir, 'gateway-ca.crt'); + return fs.readFileSync(p, 'utf8'); + } catch (_) { return undefined; } +})(); + +let gatewayWS: WebSocket | null = null; +let gatewayConnected = false; +const pendingRequests = new Map void; + reject: (e: Error) => void; + timer: ReturnType; +}>(); + +// browserSessions ref — set by server.ts after init +let browserSessions: Map | null = null; +export function setBrowserSessions(map: Map) { browserSessions = map; } +export function isConnected() { return gatewayConnected; } + +// onTurnDone callback — set by server.ts to clear activeTurnId when a turn completes +let onTurnDoneCallback: ((sessionKey: string) => void) | null = null; +export function setOnTurnDone(cb: (sessionKey: string) => void) { onTurnDoneCallback = cb; } + +// onGatewayDisconnect callback — set by server.ts to abort all in-flight turns on gateway drop +let onGatewayDisconnectCallback: (() => void) | null = null; +export function setOnGatewayDisconnect(cb: () => void) { onGatewayDisconnectCallback = cb; } + +function uuid(): string { return crypto.randomUUID(); } + +const CONNECT_SCOPES = ['operator.read', 'operator.write', 'operator.admin']; + +// Sends the connect request after receiving the challenge nonce (or null for no-device-auth fallback) +function sendConnectRequest(ws: WebSocket, challengeNonce: string | null, + outerResolve: () => void, outerReject: (e: Error) => void) { + const signedAtMs = Date.now(); + let device: Record | undefined; + + if (deviceIdentity && challengeNonce) { + const payload = buildV3Payload({ + deviceId: deviceIdentity.deviceId, + clientId: 'webchat', clientMode: 'webchat', role: 'operator', + scopes: CONNECT_SCOPES, signedAtMs, + token: GATEWAY_TOKEN, nonce: challengeNonce, + }); + const privKey = createPrivateKey({ key: deviceIdentity.privateKeyPem, format: 'pem' }); + const sigBytes = sign(null, Buffer.from(payload, 'utf8'), privKey); + device = { + id: deviceIdentity.deviceId, + publicKey: deviceIdentity.publicKeyPem, + signature: sigBytes.toString('base64url'), + signedAt: signedAtMs, + nonce: challengeNonce, + }; + } + + const connectId = uuid(); + pendingRequests.set(connectId, { + resolve: (payload: unknown) => { + const granted = (payload as any)?.scopes ?? []; + console.log('[auth] Connect OK — granted scopes:', granted.join(', ') || '(none listed)'); + gatewayConnected = true; + outerResolve(); + }, + reject: (err: Error) => { + console.error('[auth] Connect rejected:', err.message); + outerReject(err); + }, + timer: setTimeout(() => { + pendingRequests.delete(connectId); + outerReject(new Error('Connect handshake timeout')); + }, 15000), + }); + + ws.send(JSON.stringify({ + type: 'req', id: connectId, method: 'connect', + params: { + minProtocol: 3, maxProtocol: 3, + client: { id: 'webchat', version: '1.0.0', platform: 'linux', mode: 'webchat', deviceFamily: 'Linux' }, + role: 'operator', scopes: CONNECT_SCOPES, + caps: ['tool-events'], commands: [], permissions: {}, + auth: { token: GATEWAY_TOKEN }, + userAgent: 'openclaw-webgateway/1.0', + ...(device ? { device } : {}), + }, + })); + const authMode = device ? `yes, nonce=${challengeNonce!.slice(0, 8)}…` : 'no (fallback)'; + console.log(`[auth] Connect sent (device auth: ${authMode})`); +} + +export function connectToGateway(): Promise { + return new Promise((resolve, reject) => { + const wsUrl = `wss://${GATEWAY_HOST}:${GATEWAY_PORT}`; + console.log(`🔌 Connecting to gateway: ${wsUrl}`); + + const ws = new WebSocket(wsUrl, { + headers: { 'Origin': 'http://localhost', 'User-Agent': 'openclaw-webgateway/1.0' }, + ...(GATEWAY_CA_CERT ? { tls: { ca: GATEWAY_CA_CERT } } : {}), + } as any); + gatewayWS = ws; + + ws.addEventListener('open', () => { + console.log('✅ Gateway WS open — waiting for connect.challenge…'); + // Fallback: if no challenge arrives within 5 s, connect without device auth + const fallbackTimer = setTimeout(() => { + console.warn('[auth] No connect.challenge in 5 s — sending without device auth'); + sendConnectRequest(ws, null, resolve, reject); + }, 5000); + (ws as any)._challengeFallbackTimer = fallbackTimer; + }); + + ws.addEventListener('message', (ev: MessageEvent) => { + try { + const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : ev.data.toString()); + handleGatewayMessage(msg, resolve, reject); + } catch (err: any) { + console.error('Gateway parse error:', err.message); + } + }); + + ws.addEventListener('close', () => { + console.log('❌ Gateway disconnected'); + gatewayConnected = false; + gatewayWS = null; + if (onGatewayDisconnectCallback) onGatewayDisconnectCallback(); + setTimeout(() => connectToGateway().catch(() => {}), 3000); + }); + + ws.addEventListener('error', (ev: Event) => { + const msg = (ev as any).message || 'unknown error'; + console.error('Gateway error:', msg); + reject(new Error(msg)); + }); + + setTimeout(() => { + if (!gatewayConnected) reject(new Error('Gateway connection timeout')); + }, 20000); + }); +} + +export function gatewayRequest(method: string, params: Record): Promise { + return new Promise((resolve, reject) => { + if (!gatewayWS || gatewayWS.readyState !== WebSocket.OPEN) { + reject(new Error('Gateway not connected')); return; + } + const id = uuid(); + const timer = setTimeout(() => { + pendingRequests.delete(id); + reject(new Error('Gateway request timeout')); + }, 60000); + pendingRequests.set(id, { resolve, reject, timer }); + gatewayWS.send(JSON.stringify({ type: 'req', id, method, params })); + }); +} + +function handleGatewayMessage(msg: any, connectResolve?: () => void, connectReject?: (e: Error) => void) { + // Handle connect.challenge — clear fallback timer and send signed connect request + if (msg.type === 'event' && msg.event === 'connect.challenge') { + const nonce: string = msg.payload?.nonce ?? ''; + clearTimeout((gatewayWS as any)?._challengeFallbackTimer); + if (gatewayWS && connectResolve && connectReject) { + sendConnectRequest(gatewayWS, nonce, connectResolve, connectReject); + } + return; + } + if (msg.type === 'event') { + const p = msg.payload || {}; + console.log(`[EV] ev=${msg.event} stream=${p.stream} state=${p.state} phase=${p.data?.phase} tool=${p.tool||p.data?.name} id=${p.id||p.data?.toolCallId} keys=${Object.keys(p).join(',')}`); + if (p.stream === 'tool' && p.data?.phase !== 'update') console.log(`[EV:tool] phase=${p.data?.phase} tool=${p.data?.name} id=${p.data?.toolCallId}`); + } + if (msg.type === 'res' && msg.id) { + const pending = pendingRequests.get(msg.id); + if (pending) { + clearTimeout(pending.timer); + pendingRequests.delete(msg.id); + if (msg.ok) pending.resolve(msg.payload); + else pending.reject(new Error(msg.error?.message || 'Gateway error')); + } + return; + } + + if (msg.type === 'event') { + const { event: eventName, payload } = msg; + const psk = payload?.sessionKey; + if (psk && browserSessions) { + // Tool/lifecycle events use the agent's main sessionKey (e.g. agent:titan:main) + // Browser sessions use web sessionKey (e.g. agent:titan:web:nico) + // Match by agent ID for tool + lifecycle stream events from agent runs + const pAgentId = extractAgentId(psk); + for (const [ws, session] of browserSessions) { + const sAgentId = extractAgentId(session.sessionKey); + const exactMatch = session.sessionKey === psk; + const agentIdMatch = pAgentId && sAgentId === pAgentId; + const agentStreamMatch = eventName === 'agent' + && (payload?.stream === 'tool' || payload?.stream === 'lifecycle') + && agentIdMatch; + if (exactMatch || agentStreamMatch) { + if (exactMatch && session._handoverHandler) session._handoverHandler(eventName, payload); + forwardToBrowser(ws, session, eventName, payload); + } + } + } + } +} + +function stripNoReply(text: string): string { + return text.replace(/\s*NO_REPLY\s*$/g, '').trim(); +} + +function extractAgentId(sessionKey: string | null): string | null { + if (!sessionKey) return null; + const parts = sessionKey.split(':'); + return parts.length >= 2 ? parts[1] : null; +} + +/** + * Retroactively emit HUD tool_start/tool_end events from chat.history. + * Called after chat.done — the gateway does not forward tool stream events + * to operator WS connections, so we reconstruct them from the stored transcript. + */ +async function emitToolHudFromHistory( + session: any, + turnId: string, + hadTurnStarted: boolean, + turnTs: number, + send: (data: unknown) => void, +): Promise { + const agentId = extractAgentId(session.sessionKey); + if (!agentId) { if (hadTurnStarted) send(hud.turnEnd(turnId, turnTs)); return; } + const agentMainKey = `agent:${agentId}:main`; + + let messages: any[] = []; + try { + const res: any = await gatewayRequest('chat.history', { sessionKey: agentMainKey, limit: 40 }); + messages = res?.messages || []; + } catch { + if (hadTurnStarted) send(hud.turnEnd(turnId, turnTs)); + return; + } + + // Find the last assistant message with tool_use blocks + let lastAssistantIdx = -1; + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m.role === 'assistant') { + const content = Array.isArray(m.content) ? m.content : []; + if (content.some((c: any) => c.type === 'tool_use')) { + lastAssistantIdx = i; + break; + } + } + } + + if (lastAssistantIdx === -1) { + // No tool calls — just close the turn + if (hadTurnStarted) send(hud.turnEnd(turnId, turnTs)); + return; + } + + // Build a map of tool_result blocks from subsequent user messages + const resultMap = new Map(); // toolCallId → tool_result block + for (let i = lastAssistantIdx + 1; i < messages.length; i++) { + const m = messages[i]; + if (m.role === 'user') { + const content = Array.isArray(m.content) ? m.content : []; + for (const c of content) { + if (c.type === 'tool_result' && c.tool_use_id) { + resultMap.set(c.tool_use_id, c); + } + } + } + } + + // Extract tool_use blocks from the assistant message + const assistantContent = Array.isArray(messages[lastAssistantIdx].content) + ? messages[lastAssistantIdx].content : []; + const toolUses = assistantContent.filter((c: any) => c.type === 'tool_use'); + + if (toolUses.length === 0) { + if (hadTurnStarted) send(hud.turnEnd(turnId, turnTs)); + return; + } + + // Ensure turn is open + if (!hadTurnStarted) { + send(hud.turnStart(turnId)); + } + + // Emit tool_start + tool_end for each tool call (replay=false so they're live-looking) + const baseTs = turnTs; + for (let i = 0; i < toolUses.length; i++) { + const tu = toolUses[i]; + const callId: string = tu.id || crypto.randomUUID(); + const tool: string = tu.name || 'unknown'; + const args = buildToolArgs(tool, tu.input || {}); + const callTs = baseTs + i * 10; // slight offset to preserve order + + send(hud.toolStart(callId, turnId, tool, args)); + + const resultBlock = resultMap.get(callId); + let result: Record; + if (resultBlock) { + // Unwrap content array if needed + let raw: string; + if (Array.isArray(resultBlock.content)) { + raw = resultBlock.content.filter((c: any) => c.type === 'text').map((c: any) => c.text || '').join(''); + } else { + raw = typeof resultBlock.content === 'string' ? resultBlock.content : JSON.stringify(resultBlock.content ?? ''); + } + result = resolveToolResult(tool, tu.input || {}, raw); + if (resultBlock.is_error === true) (result as any).ok = false; + } else { + result = { ok: true }; + } + send(hud.toolEnd(callId, turnId, tool, result, callTs)); + } + + // Close turn + send(hud.turnEnd(turnId, turnTs)); +} + +function forwardToBrowser(ws: any, session: any, eventName: string, payload: any) { + if (ws.readyState !== 1 /* OPEN */) return; + const agentId = extractAgentId(session.sessionKey); + const send = (data: unknown) => ws.send(JSON.stringify(data)); + + const turnId: string = session.turnId || 'unknown'; + + // Ensure turn_start is emitted before any child HUD events + function ensureTurnStart() { + if (!session._hudTurnStarted) { + session._hudTurnStarted = true; + session._hudTurnTs = Date.now(); + send(hud.turnStart(turnId)); + } + } + + // Close any open thinking span — repeated across multiple event handlers + function closeThinkIfOpen() { + if (session._hudThinkId) { + send(hud.thinkEnd(session._hudThinkId, turnId, session._hudThinkTs || Date.now())); + session._hudThinkId = null; + session._hudThinkTs = null; + } + } + + // Close any open turn span + function closeTurnIfOpen() { + if (session._hudTurnStarted) { + send(hud.turnEnd(turnId, session._hudTurnTs || Date.now())); + session._hudTurnStarted = false; + session._hudTurnTs = null; + } + } + + switch (eventName) { + case 'chat.thinking': { + send({ type: 'thinking', content: payload.reasoning || '' }); + // HUD: turn_start + think_start on first chunk + ensureTurnStart(); + if (!session._hudThinkId) { + session._hudThinkId = crypto.randomUUID(); + session._hudThinkTs = Date.now(); + send(hud.thinkStart(session._hudThinkId, turnId)); + } + break; + } + case 'agent': { + // Live tool events — stream:"tool" phase:"start"|"result" + // stream:"lifecycle" phase:"end"|"error" — turn done via embedded run + // stream:"compaction" phase:"start"|"end" — handled by ARCH-6 (TODO) + if (payload?.stream === 'tool') { + const phase: string = payload.data?.phase || ''; + const tool: string = payload.data?.name || 'unknown'; + const toolCallId: string = payload.data?.toolCallId || payload.data?.id || ''; + const corrId: string = toolCallId || crypto.randomUUID(); + + if (phase === 'start') { + const args = buildToolArgs(tool, payload.data?.args || {}); + ensureTurnStart(); + if (!session._hudPendingTools) session._hudPendingTools = new Map(); + session._hudPendingTools.set(corrId, { tool, args, ts: Date.now(), toolCallId }); + session._hudLiveToolSeen = true; + console.log(`[HUD] tool_start corrId=${corrId} tool=${tool} turnId=${turnId}`); + send(hud.toolStart(corrId, turnId, tool, args, false, toolCallId)); + } else if (phase === 'result') { + const raw = (() => { + const d = payload.data; + // result.content[{type:"text",text:"..."}] is the primary shape + if (d?.result?.content) { + const c = d.result.content; + return Array.isArray(c) ? c.filter((x: any) => x.type === 'text').map((x: any) => x.text || '').join('') : String(c); + } + if (typeof d?.output === 'string') return d.output; + if (d?.content) return typeof d.content === 'string' ? d.content + : Array.isArray(d.content) ? d.content.filter((c: any) => c.type === 'text').map((c: any) => c.text || '').join('') : ''; + return ''; + })(); + const pending = session._hudPendingTools?.get(corrId); + const startTs: number = pending?.ts || Date.now(); + const pendingArgs = pending?.args || {}; + const result = resolveToolResult(tool, pendingArgs, raw); + console.log(`[HUD] tool_end corrId=${corrId} tool=${tool} deferred=${!!(session._hudPendingTools?.size === 0 && session._hudDeferredTurnEnd)}`); + send(hud.toolEnd(corrId, turnId, tool, result, startTs, false, toolCallId)); + session._hudPendingTools?.delete(corrId); + // Drain deferred turn_end if all tools are now done + if ((session._hudPendingTools?.size ?? 0) === 0 && session._hudDeferredTurnEnd) { + const { turnId: dTurnId, turnTs: dTurnTs, hadTurnStarted: dHts } = session._hudDeferredTurnEnd; + session._hudDeferredTurnEnd = null; + if (dHts) send(hud.turnEnd(dTurnId, dTurnTs)); + } + } + } + break; + } + // chat.tool_call / chat.tool_result — dead code (OpenClaw never emits these event names) + // Kept as no-ops for reference; remove in future cleanup. + case 'chat.delta': { + const delta = filterText(payload.delta || '') ?? ''; + session._lastDeltaText = (session._lastDeltaText || '') + delta; + ensureTurnStart(); + closeThinkIfOpen(); + send({ type: 'delta', content: delta, agentId, turnId: session.turnId || null }); + break; + } + case 'chat.done': { + const accText = session._lastDeltaText || null; + session._lastDeltaText = ''; + closeThinkIfOpen(); + // Capture turn state before clearing, for async history path + const hadTurnStarted = session._hudTurnStarted; + const turnTs = session._hudTurnTs || Date.now(); + session._hudTurnStarted = false; + session._hudTurnTs = null; + + // Scenario H: detect NO_REPLY turns — partial deltas may have leaked to browser. + // Signal suppress=true so frontend can drop the bubble retroactively. + const isNoReply = /^\s*NO_REPLY\s*$/.test(accText || ''); + + // HUD: close the turn. + // If live stream:"tool" events were received this turn (_hudPendingTools had entries), + // skip emitToolHudFromHistory — tools already emitted live, no need to replay from history. + const hadLiveTools = session._hudLiveToolSeen === true; + session._hudLiveToolSeen = false; + if (hadLiveTools) { + // Live path: defer turn_end until all pending tool_ends have landed. + // chat.done may race ahead of the final phase:'result' event. + const pendingCount = session._hudPendingTools?.size ?? 0; + if (pendingCount > 0) { + // Stash close on session — tool_end handler will drain and emit + session._hudDeferredTurnEnd = { turnId, turnTs, hadTurnStarted }; + } else { + if (hadTurnStarted) send(hud.turnEnd(turnId, turnTs)); + } + } else { + // Fallback path: no live tool events — reconstruct from chat.history (reconnect mid-turn, etc.) + emitToolHudFromHistory(session, turnId, hadTurnStarted, turnTs, send).catch(() => { + if (hadTurnStarted) send(hud.turnEnd(turnId, turnTs)); + }); + } + + send({ type: 'done', content: isNoReply ? '' : accText, suppress: isNoReply, usage: payload.usage, agentId, turnId: session.turnId || null }); + if (session.sessionKey && onTurnDoneCallback) onTurnDoneCallback(session.sessionKey); + break; + } + case 'chat.aborted': + case 'chat.error': { + closeThinkIfOpen(); + closeTurnIfOpen(); + if (session.sessionKey && onTurnDoneCallback) onTurnDoneCallback(session.sessionKey); + break; + } + case 'chat': { + const contentArr = payload.message?.content || []; + const textBlock = contentArr.find((c: any) => c.type === 'text'); + const thinkBlock = contentArr.find((c: any) => c.type === 'thinking'); + if (payload.state === 'delta') { + // HUD: ensure turn started + ensureTurnStart(); + // Forward thinking delta + HUD think_start + if (thinkBlock?.thinking) { + const lastThink: number = session._lastThinkOffset || 0; + const thinkChunk = thinkBlock.thinking.slice(lastThink); + session._lastThinkOffset = thinkBlock.thinking.length; + if (thinkChunk) { + if (!session._hudThinkId) { + session._hudThinkId = crypto.randomUUID(); + session._hudThinkTs = Date.now(); + send(hud.thinkStart(session._hudThinkId, turnId)); + } + send({ type: 'thinking', content: thinkChunk, agentId }); + } + } + // Forward text delta + HUD think_end when text starts + const fullText = textBlock?.text || ''; + const lastOffset: number = session._lastDeltaOffset || 0; + const rawChunk = fullText.slice(lastOffset); + session._lastDeltaOffset = fullText.length; + const newChars = filterText(rawChunk) ?? ''; + if (newChars) { + closeThinkIfOpen(); + send({ type: 'delta', content: newChars, agentId }); + } + } else if (payload.state === 'final') { + session._lastDeltaOffset = 0; + session._lastThinkOffset = 0; + ensureTurnStart(); + closeThinkIfOpen(); + closeTurnIfOpen(); + const finalText = filterText(stripNoReply(textBlock?.text || '')); + send({ type: 'done', content: finalText, usage: payload.usage, agentId }); + if (session.sessionKey && onTurnDoneCallback) onTurnDoneCallback(session.sessionKey); + } else if (payload.state === 'aborted' || payload.state === 'error') { + session._lastDeltaOffset = 0; + session._lastThinkOffset = 0; + closeThinkIfOpen(); + closeTurnIfOpen(); + if (session.sessionKey && onTurnDoneCallback) onTurnDoneCallback(session.sessionKey); + } else { + send({ type: 'message', content: stripNoReply(textBlock?.text || payload.content || ''), agentId }); + } + break; + } + case 'chat.message': + send({ type: 'message', content: stripNoReply(payload.content), agentId }); + break; + // Note: 'agent' case handled above (line ~398) — duplicate removed in refactor + default: + send({ type: 'event', event: eventName, payload }); + } +} + +export function disconnectGateway() { + if (gatewayWS) { + const ws = gatewayWS; + gatewayConnected = false; + gatewayWS = null; + ws.removeEventListener('close', () => {}); + try { ws.close(); } catch (_) {} + setTimeout(() => connectToGateway().catch(err => console.error('[disco] reconnect failed:', err.message)), 500); + } +} diff --git a/backend/hud-builder.ts b/backend/hud-builder.ts new file mode 100644 index 0000000..16f1e12 --- /dev/null +++ b/backend/hud-builder.ts @@ -0,0 +1,263 @@ +/** + * hud-builder.ts — HUD event construction helpers + * + * Builds structured { type: 'hud', event: ... } messages per HUD-PROTOCOL.md. + * Used by gateway.ts (live turns) and session-watcher.ts (history replay). + */ + +const OPENCLAW_ROOT = `/home/openclaw/.openclaw/`; + +// ── Inline brand/path filter (mirrors message-filter.ts, no circular dep) ──── + +const _BRAND_EMOJIS = /🐾|🦞|🤖|🦅/gu; +const _BRAND_NAME = /\bOpenClaw\b/g; +const _PATH_ABS = /\/home\/openclaw\//g; +const _PATH_TILDE = /~\/\.openclaw\//g; +const _PATH_DOT = /\.openclaw(?:[-/]|(?=\s|$|&&|;|'|"))/g; +// Strip internal npm binary paths (e.g. .npm-global/lib/node_modules/.openclaw-XXXXX/dist/foo.js) +const _PATH_NPM_BIN = /(?:\.npm-global\/lib\/node_modules\/[^\s'"]+|node_modules\/\.openclaw-[^\s'"]+)/g; +// DB / service credentials +const _DB_PASS_INLINE = /-p\S+/g; // -pPASSWORD (mariadb/mysql/psql style) +const _DB_PASS_SPACE = /(-p)\s+\S+/g; // -p PASSWORD +const _DB_USER_SPACE = /(-u)\s+\S+/g; // -u USERNAME +const _DB_OPT_PASS = /(--password=)\S+/g; // --password=xxx +const _DB_OPT_USER = /(--user=)\S+/g; // --user=xxx +const _URI_CREDS = /([\w+.-]+:\/\/)[^:@/\s]+:[^@\s]+(@)/g; // scheme://user:pass@ + +function hudFilter(s: string): string { + return s + .replace(_BRAND_EMOJIS, '🐶') + .replace(_BRAND_NAME, 'Titan') + .replace(_PATH_ABS, '') + .replace(_PATH_TILDE, '') + .replace(_PATH_NPM_BIN, '[internal]') + .replace(_PATH_DOT, '') + .replace(_DB_PASS_INLINE, '-p***') + .replace(_DB_PASS_SPACE, '$1 ***') + .replace(_DB_USER_SPACE, '$1 ***') + .replace(_DB_OPT_PASS, '$1***') + .replace(_DB_OPT_USER, '$1***') + .replace(_URI_CREDS, '$1***:***$2'); +} + +export interface FileArea { + startLine: number; + endLine: number; +} + +export interface FileMeta { + path: string; + viewerPath: string; + area?: FileArea; +} + +// ── Path helpers ───────────────────────────────────────────────────────────── + +export function toViewerPath(raw: string): string { + if (!raw) return raw; + let p = raw.trim(); + if (p.startsWith(OPENCLAW_ROOT)) p = p.slice(OPENCLAW_ROOT.length); + if (p.startsWith('~/.openclaw/')) p = p.slice('~/.openclaw/'.length); + if (p.startsWith('.openclaw/')) p = p.slice('.openclaw/'.length); + return p; +} + +export function makeFileMeta(rawPath: string, area?: FileArea): FileMeta { + return { path: rawPath, viewerPath: toViewerPath(rawPath), ...(area ? { area } : {}) }; +} + +// ── Unified result resolver ────────────────────────────────────────────────── + +/** + * Dispatch raw tool output to the appropriate result builder. + * Single source of truth — replaces duplicated dispatch blocks in + * gateway.ts (live + history) and session-watcher.ts (JSONL replay). + */ +export function resolveToolResult( + tool: string, + args: Record, + raw: string, +): Record { + const fileTools = ['read', 'write', 'edit', 'append']; + if (fileTools.includes(tool)) return buildFileOpResult(tool, args, raw); + if (tool === 'exec') return buildExecResult(raw); + if (tool === 'web_fetch' || tool === 'web_search') + return buildWebResult(args.url || args.query || null, raw); + return buildGenericResult(raw); +} + +// ── Result builders ─────────────────────────────────────────────────────────── + +/** + * Build structured result for file-op tools. + * Called from gateway (live) — we don't have file content, just what the agent passed/got. + */ +export function buildFileOpResult( + tool: string, + args: Record, + rawResult: string | null, +): Record { + const rawPath: string = args.path || args.file_path || ''; + const text = rawResult || ''; + const bytes = Buffer.byteLength(text, 'utf8'); + const lineCount = text ? text.split('\n').length : 0; + + if (tool === 'read') { + const offset: number = Number(args.offset) || 1; + const limit: number = Number(args.limit) || lineCount; + const area: FileArea = { startLine: offset, endLine: offset + Math.max(lineCount - 1, 0) }; + return { + ok: true, + file: makeFileMeta(rawPath, area), + area, + text: hudFilter(text).slice(0, 2000), // cap stored text + bytes, + truncated: bytes > 2000, + }; + } + + if (tool === 'write') { + const area: FileArea = { startLine: 1, endLine: lineCount }; + return { ok: true, file: makeFileMeta(rawPath, area), area, bytes }; + } + + if (tool === 'edit') { + // We don't have the resulting line numbers from gateway — approximate + const area: FileArea = { startLine: 1, endLine: lineCount || 1 }; + return { ok: true, file: makeFileMeta(rawPath, area), area }; + } + + if (tool === 'append') { + const area: FileArea = { startLine: 1, endLine: lineCount }; + return { ok: true, file: makeFileMeta(rawPath, area), area, bytes }; + } + + return { ok: true, raw: text.slice(0, 500) }; +} + +export function buildExecResult(rawResult: string | null): Record { + const stdout = hudFilter(rawResult || ''); + const truncated = stdout.length > 1000; + // Extract file paths mentioned in output + const pathMatches = stdout.match(/(?:^|\s)((?:~\/|\/home\/openclaw\/|\.\/)\S+)/gm) || []; + const mentionedPaths: FileMeta[] = pathMatches + .map(m => m.trim()) + .filter(p => p.length > 3) + .slice(0, 5) + .map(p => makeFileMeta(p)); + return { + ok: true, + exitCode: 0, + stdout: stdout.slice(0, 1000), + truncated, + ...(mentionedPaths.length ? { mentionedPaths } : {}), + }; +} + +export function buildWebResult(url: string | null, rawResult: string | null): Record { + const text = hudFilter(rawResult || ''); + return { ok: true, url: url || '', text: text.slice(0, 500), truncated: text.length > 500 }; +} + +export function buildGenericResult(rawResultOrOk: string | boolean | null, meta?: string): Record { + if (typeof rawResultOrOk === 'boolean') { + // Called with (ok, meta) — result content was stripped by gateway + const summary = meta ? hudFilter(meta).slice(0, 200) : (rawResultOrOk ? 'ok' : 'error'); + return { ok: rawResultOrOk, summary, truncated: false }; + } + const text = hudFilter(rawResultOrOk || ''); + return { ok: true, summary: text.slice(0, 200), truncated: text.length > 200 }; +} + +// ── Args builders ───────────────────────────────────────────────────────────── + +export function buildToolArgs(tool: string, rawArgs: any): Record { + if (!rawArgs) return {}; + const args = typeof rawArgs === 'string' ? (() => { try { return JSON.parse(rawArgs); } catch { return { raw: rawArgs }; } })() : rawArgs; + + const fileTools = ['read', 'write', 'edit', 'append']; + if (fileTools.includes(tool)) { + const rawPath: string = args.path || args.file_path || ''; + return { + path: rawPath, + viewerPath: toViewerPath(rawPath), + operation: tool, + ...(tool === 'read' && args.offset ? { offset: args.offset } : {}), + ...(tool === 'read' && args.limit ? { limit: args.limit } : {}), + }; + } + if (tool === 'exec') return { command: hudFilter(args.command || String(args)) }; + if (tool === 'web_fetch') return { url: args.url || '', maxChars: args.maxChars }; + if (tool === 'web_search') return { query: hudFilter(args.query || '') }; + + // Generic — pass through top-level keys, capped + filtered + const out: Record = {}; + for (const [k, v] of Object.entries(args).slice(0, 8)) { + out[k] = typeof v === 'string' ? hudFilter(v).slice(0, 200) : v; + } + return out; +} + +// ── HUD event factory ───────────────────────────────────────────────────────── + +export type HudEventKind = + | 'turn_start' | 'turn_end' + | 'think_start' | 'think_end' + | 'tool_start' | 'tool_end' + | 'received'; + +export type ReceivedSubtype = + | 'new_session' | 'agent_switch' | 'stop' | 'kill' + | 'handover' | 'reconnect' | 'message'; + +export interface HudEvent { + type: 'hud'; + event: HudEventKind; + id: string; + correlationId?: string; + parentId?: string; + ts: number; + replay?: boolean; + // event-specific + tool?: string; + toolCallId?: string; // OpenClaw-assigned toolCallId — separate from correlationId + args?: Record; + result?: Record; + durationMs?: number; + subtype?: ReceivedSubtype; + label?: string; + payload?: Record; +} + +function makeHudEvent(event: HudEventKind, extra: Partial = {}): HudEvent { + return { + type: 'hud', + event, + id: crypto.randomUUID(), + ts: Date.now(), + ...extra, + }; +} + +export const hud = { + turnStart: (correlationId: string, replay = false): HudEvent => + makeHudEvent('turn_start', { correlationId, ...(replay ? { replay } : {}) }), + + turnEnd: (correlationId: string, startTs: number, replay = false): HudEvent => + makeHudEvent('turn_end', { correlationId, durationMs: Date.now() - startTs, ...(replay ? { replay } : {}) }), + + thinkStart: (correlationId: string, parentId: string, replay = false): HudEvent => + makeHudEvent('think_start', { correlationId, parentId, ...(replay ? { replay } : {}) }), + + thinkEnd: (correlationId: string, parentId: string, startTs: number, replay = false): HudEvent => + makeHudEvent('think_end', { correlationId, parentId, durationMs: Date.now() - startTs, ...(replay ? { replay } : {}) }), + + toolStart: (correlationId: string, parentId: string, tool: string, args: Record, replay = false, toolCallId?: string): HudEvent => + makeHudEvent('tool_start', { correlationId, parentId, tool, args, ...(toolCallId ? { toolCallId } : {}), ...(replay ? { replay } : {}) }), + + toolEnd: (correlationId: string, parentId: string, tool: string, result: Record, startTs: number, replay = false, toolCallId?: string): HudEvent => + makeHudEvent('tool_end', { correlationId, parentId, tool, result, durationMs: Date.now() - startTs, ...(toolCallId ? { toolCallId } : {}), ...(replay ? { replay } : {}) }), + + received: (subtype: ReceivedSubtype, label: string, payload?: Record, replay = false): HudEvent => + makeHudEvent('received', { subtype, label, ...(payload ? { payload } : {}), ...(replay ? { replay } : {}) }), +}; diff --git a/backend/mcp/deck.ts b/backend/mcp/deck.ts new file mode 100644 index 0000000..cc8aa54 --- /dev/null +++ b/backend/mcp/deck.ts @@ -0,0 +1,299 @@ +/** + * mcp/deck.ts — Nextcloud Deck board/card management tools + */ + +const DECK_BASE = (process.env.DECK_BASE_URL ?? "https://next.jqxp.org") + "/index.php/apps/deck/api/v1"; +const OCS_BASE = (process.env.DECK_BASE_URL ?? "https://next.jqxp.org") + "/ocs/v2.php/apps/deck/api/v1.0"; +const AUTH = "Basic " + btoa(`${process.env.DECK_USER ?? "claude"}:${process.env.DECK_TOKEN ?? "AH25o-cBZxD-8tMZ6-yWDKt-q9874"}`); + +const HEADERS: Record = { + Authorization: AUTH, + "OCS-APIRequest": "true", + "Content-Type": "application/json", +}; + +async function deckFetch(path: string, method = "GET", body?: any) { + const res = await fetch(`${DECK_BASE}${path}`, { + method, + headers: HEADERS, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Deck ${method} ${path}: ${res.status} ${text.slice(0, 200)}`); + } + const text = await res.text(); + if (!text) return {}; + return JSON.parse(text); +} + +async function ocsFetch(path: string, method = "GET", body?: any) { + const res = await fetch(`${OCS_BASE}${path}`, { + method, + headers: { ...HEADERS, Accept: "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`OCS ${method} ${path}: ${res.status} ${text.slice(0, 200)}`); + } + return res.json(); +} + +function sanitize(text: string): string { + return text + .replace(/\u2014/g, "--") + .replace(/\u2013/g, "-") + .replace(/[\u201C\u201D]/g, '"') + .replace(/[\u2018\u2019]/g, "'"); +} + +export const tools = [ + { + name: "deck_get_stacks", + description: "Get all stacks and cards from a Deck board.", + inputSchema: { + type: "object" as const, + properties: { + boardId: { type: "number", description: "Board ID" }, + }, + required: ["boardId"], + }, + }, + { + name: "deck_get_cards", + description: "Get cards from a board, optionally filtered by label name.", + inputSchema: { + type: "object" as const, + properties: { + boardId: { type: "number", description: "Board ID" }, + label: { type: "string", description: "Filter by label name (e.g. 'smoke')" }, + }, + required: ["boardId"], + }, + }, + { + name: "deck_create_board", + description: "Create a new Deck board. Auto-shared with nico.", + inputSchema: { + type: "object" as const, + properties: { + title: { type: "string", description: "Board title" }, + color: { type: "string", description: "Hex color without # (default: 28a745)" }, + }, + required: ["title"], + }, + }, + { + name: "deck_create_stack", + description: "Create a stack (column) on a board.", + inputSchema: { + type: "object" as const, + properties: { + boardId: { type: "number" }, + title: { type: "string" }, + order: { type: "number" }, + }, + required: ["boardId", "title", "order"], + }, + }, + { + name: "deck_create_card", + description: "Create a card in a stack.", + inputSchema: { + type: "object" as const, + properties: { + boardId: { type: "number" }, + stackId: { type: "number" }, + title: { type: "string" }, + description: { type: "string" }, + order: { type: "number" }, + }, + required: ["boardId", "stackId", "title"], + }, + }, + { + name: "deck_move_card", + description: "Move a card to a different stack.", + inputSchema: { + type: "object" as const, + properties: { + boardId: { type: "number" }, + cardId: { type: "number" }, + targetStackId: { type: "number" }, + order: { type: "number" }, + }, + required: ["boardId", "cardId", "targetStackId"], + }, + }, + { + name: "deck_add_comment", + description: "Add a comment to a card. Sanitizes special characters.", + inputSchema: { + type: "object" as const, + properties: { + cardId: { type: "number" }, + text: { type: "string" }, + }, + required: ["cardId", "text"], + }, + }, + { + name: "deck_delete_board", + description: "Delete a Deck board permanently.", + inputSchema: { + type: "object" as const, + properties: { + boardId: { type: "number", description: "Board ID to delete" }, + }, + required: ["boardId"], + }, + }, + { + name: "deck_delete_card", + description: "Delete a card from a board.", + inputSchema: { + type: "object" as const, + properties: { + boardId: { type: "number" }, + stackId: { type: "number" }, + cardId: { type: "number" }, + }, + required: ["boardId", "stackId", "cardId"], + }, + }, + { + name: "deck_create_label", + description: "Create a label on a board. Returns the label with its ID.", + inputSchema: { + type: "object" as const, + properties: { + boardId: { type: "number" }, + title: { type: "string", description: "Label name (e.g. 'smoke')" }, + color: { type: "string", description: "Hex color without # (e.g. 'e67e22')" }, + }, + required: ["boardId", "title", "color"], + }, + }, + { + name: "deck_assign_label", + description: "Assign a label to a card.", + inputSchema: { + type: "object" as const, + properties: { + boardId: { type: "number" }, + stackId: { type: "number" }, + cardId: { type: "number" }, + labelId: { type: "number" }, + }, + required: ["boardId", "stackId", "cardId", "labelId"], + }, + }, +]; + +export async function handle(name: string, args: any): Promise { + switch (name) { + case "deck_get_stacks": { + const stacks = await deckFetch(`/boards/${args.boardId}/stacks`); + const slim = stacks.map((s: any) => ({ + id: s.id, title: s.title, order: s.order, + cards: (s.cards || []).map((c: any) => ({ + id: c.id, title: c.title, stackId: c.stackId, order: c.order, + labels: (c.labels || []).map((l: any) => l.title), + description: c.description?.slice(0, 200), + })), + })); + return { content: [{ type: "text", text: JSON.stringify(slim, null, 2) }] }; + } + + case "deck_get_cards": { + const stacks = await deckFetch(`/boards/${args.boardId}/stacks`); + let cards: any[] = []; + for (const stack of stacks) { + if (stack.cards) cards.push(...stack.cards.map((c: any) => ({ + id: c.id, title: c.title, stackId: c.stackId, stackTitle: stack.title, + labels: (c.labels || []).map((l: any) => l.title), + description: c.description, + }))); + } + if (args.label) { + cards = cards.filter((c: any) => + c.labels?.some((l: string) => l.toLowerCase() === args.label.toLowerCase()) + ); + } + return { content: [{ type: "text", text: JSON.stringify(cards, null, 2) }] }; + } + + case "deck_create_board": { + const board = await deckFetch("/boards", "POST", { + title: args.title, + color: args.color || "28a745", + }); + await deckFetch(`/boards/${board.id}/acl`, "POST", { + type: 0, participant: "nico", + permissionEdit: true, permissionShare: false, permissionManage: true, + }); + return { content: [{ type: "text", text: JSON.stringify(board, null, 2) }] }; + } + + case "deck_create_stack": { + const stack = await deckFetch(`/boards/${args.boardId}/stacks`, "POST", { + title: args.title, order: args.order, + }); + return { content: [{ type: "text", text: JSON.stringify(stack, null, 2) }] }; + } + + case "deck_create_card": { + const card = await deckFetch(`/boards/${args.boardId}/stacks/${args.stackId}/cards`, "POST", { + title: args.title, type: "plain", order: args.order ?? 0, + description: args.description || "", + }); + return { content: [{ type: "text", text: JSON.stringify(card, null, 2) }] }; + } + + case "deck_move_card": { + const result = await deckFetch( + `/boards/${args.boardId}/stacks/${args.targetStackId}/cards/${args.cardId}/reorder`, + "PUT", + { order: args.order ?? 0, stackId: args.targetStackId }, + ); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + + case "deck_add_comment": { + const result = await ocsFetch(`/cards/${args.cardId}/comments`, "POST", { + message: sanitize(args.text), + }); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + + case "deck_delete_board": { + const result = await deckFetch(`/boards/${args.boardId}`, "DELETE"); + return { content: [{ type: "text", text: `Board ${args.boardId} deleted` }] }; + } + + case "deck_delete_card": { + const result = await deckFetch(`/boards/${args.boardId}/stacks/${args.stackId}/cards/${args.cardId}`, "DELETE"); + return { content: [{ type: "text", text: `Card ${args.cardId} deleted` }] }; + } + + case "deck_create_label": { + const label = await deckFetch(`/boards/${args.boardId}/labels`, "POST", { + title: args.title, color: args.color, + }); + return { content: [{ type: "text", text: JSON.stringify(label, null, 2) }] }; + } + + case "deck_assign_label": { + await deckFetch( + `/boards/${args.boardId}/stacks/${args.stackId}/cards/${args.cardId}/assignLabel`, + "PUT", + { labelId: args.labelId }, + ); + return { content: [{ type: "text", text: `Label ${args.labelId} assigned to card ${args.cardId}` }] }; + } + + default: + throw new Error(`Unknown deck tool: ${name}`); + } +} diff --git a/backend/mcp/dev.ts b/backend/mcp/dev.ts new file mode 100644 index 0000000..61c4e34 --- /dev/null +++ b/backend/mcp/dev.ts @@ -0,0 +1,141 @@ +/** + * mcp/dev.ts — Dev server health and status tools + */ + +import { gatewayRequest } from '../gateway.ts'; +import { waitForEvent, getEvents, getActiveMcpKey, broadcastToBrowsers } from './events.ts'; + + +export const tools = [ + { + name: "dev_health", + description: "Check Hermes backend health: gateway, sessions, agents.", + inputSchema: { + type: "object" as const, + properties: { + env: { type: "string", enum: ["dev", "prod"], description: "Environment (default: dev)" }, + }, + }, + }, + { + name: "dev_vite_status", + description: "Check if Vite dev server is running.", + inputSchema: { + type: "object" as const, + properties: {}, + }, + }, + { + name: "dev_chat_send", + description: "Send a chat message to an agent session via the gateway. Use for internal messaging/testing.", + inputSchema: { + type: "object" as const, + properties: { + sessionKey: { type: "string", description: "Session key (e.g. agent:titan:web:nico)" }, + message: { type: "string", description: "Message text to send" }, + }, + required: ["sessionKey", "message"], + }, + }, + { + name: "dev_subscribe", + description: "Long-poll for events from Hermes (system access approvals, deploy status, etc.). Blocks until an event arrives or timeout. Use in background tasks for push notifications.", + inputSchema: { + type: "object" as const, + properties: { + timeout: { type: "number", description: "Max wait time in ms (default 30000, max 55000)" }, + }, + }, + }, + { + name: "dev_events", + description: "Get recent queued events without blocking. Useful for catching up after a gap.", + inputSchema: { + type: "object" as const, + properties: { + count: { type: "number", description: "Max events to return (default 10)" }, + }, + }, + }, + { + name: "dev_push_state", + description: "Push state to all connected browser sessions via WebSocket. Used for real-time UI updates (counter, notifications, etc.).", + inputSchema: { + type: "object" as const, + properties: { + type: { type: "string", description: "Message type (e.g. 'counter_update')" }, + data: { type: "object", description: "Payload data to send" }, + }, + required: ["type", "data"], + }, + }, +]; + +export async function handle(name: string, args: any): Promise { + switch (name) { + case "dev_health": { + const port = args.env === "prod" ? 3001 : 3003; + try { + const res = await fetch(`http://localhost:${port}/health`); + const data = await res.json(); + return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] }; + } catch (e: any) { + return { content: [{ type: "text", text: `Backend unreachable on port ${port}: ${e.message}` }] }; + } + } + + case "dev_vite_status": { + try { + const proc = Bun.spawn(["pgrep", "-f", "vite"], { stdout: "pipe", stderr: "pipe" }); + const stdout = await new Response(proc.stdout).text(); + const pids = stdout.trim().split("\n").filter(Boolean); + return { + content: [{ + type: "text", + text: pids.length > 0 + ? JSON.stringify({ running: true, pids, count: pids.length }, null, 2) + : "Vite is NOT running", + }], + }; + } catch (e: any) { + return { content: [{ type: "text", text: `Error: ${e.message}` }] }; + } + } + + case "dev_chat_send": { + const { sessionKey, message } = args; + if (!sessionKey || !message) return { content: [{ type: "text", text: "sessionKey and message are required" }] }; + try { + const idempotencyKey = crypto.randomUUID(); + const res = await gatewayRequest("chat.send", { sessionKey, message, idempotencyKey }); + return { content: [{ type: "text", text: JSON.stringify({ ok: true, sessionKey, idempotencyKey, response: res }, null, 2) }] }; + } catch (e: any) { + return { content: [{ type: "text", text: `Error: ${e.message}` }] }; + } + } + + case "dev_subscribe": { + const mcpKey = getActiveMcpKey(); + if (!mcpKey) return { content: [{ type: "text", text: JSON.stringify({ type: "error", message: "No MCP key context" }) }] }; + const timeout = Math.min(args.timeout ?? 30000, 55000); + const event = await waitForEvent(mcpKey, timeout); + if (!event) return { content: [{ type: "text", text: JSON.stringify({ type: "timeout", message: "No events within timeout" }) }] }; + return { content: [{ type: "text", text: JSON.stringify(event, null, 2) }] }; + } + + case "dev_push_state": { + broadcastToBrowsers({ type: args.type, ...args.data }); + return { content: [{ type: "text", text: JSON.stringify({ ok: true, type: args.type }) }] }; + } + + case "dev_events": { + const mcpKey = getActiveMcpKey(); + if (!mcpKey) return { content: [{ type: "text", text: "[]" }] }; + const events = getEvents(mcpKey, args.count ?? 10); + return { content: [{ type: "text", text: JSON.stringify(events, null, 2) }] }; + } + + default: + throw new Error(`Unknown dev tool: ${name}`); + } +} diff --git a/backend/mcp/events.ts b/backend/mcp/events.ts new file mode 100644 index 0000000..1f0003e --- /dev/null +++ b/backend/mcp/events.ts @@ -0,0 +1,94 @@ +/** + * mcp/events.ts — In-memory event queue for MCP long-polling + * + * Each MCP API key gets its own queue. Tools can push events, + * and dev_subscribe can wait (long-poll) for the next event. + */ + +export interface McpEvent { + type: string; + timestamp: string; + data: any; +} + +interface Waiter { + resolve: (event: McpEvent | null) => void; + timer: ReturnType; +} + +const queues = new Map(); +const waiters = new Map(); +const knownKeys = new Set(); + +const MAX_QUEUE = 100; + +/** Push an event to a specific MCP key's queue */ +export function pushEvent(mcpKey: string, event: Omit) { + const full: McpEvent = { ...event, timestamp: new Date().toISOString() }; + const waiter = waiters.get(mcpKey); + if (waiter) { + clearTimeout(waiter.timer); + waiters.delete(mcpKey); + waiter.resolve(full); + return; + } + let q = queues.get(mcpKey); + if (!q) { q = []; queues.set(mcpKey, q); } + q.push(full); + while (q.length > MAX_QUEUE) q.shift(); +} + +/** Wait for the next event (long-poll). Returns event or null on timeout. */ +export function waitForEvent(mcpKey: string, timeoutMs: number = 30000): Promise { + knownKeys.add(mcpKey); + const q = queues.get(mcpKey); + if (q && q.length > 0) { + return Promise.resolve(q.shift()!); + } + return new Promise((resolve) => { + const existing = waiters.get(mcpKey); + if (existing) { + clearTimeout(existing.timer); + existing.resolve(null); + } + const timer = setTimeout(() => { + waiters.delete(mcpKey); + resolve(null); + }, Math.min(timeoutMs, 55000)); + waiters.set(mcpKey, { resolve, timer }); + }); +} + +/** Get recent events without blocking (for catch-up) */ +export function getEvents(mcpKey: string, count: number = 10): McpEvent[] { + const q = queues.get(mcpKey) ?? []; + return q.splice(0, count); +} + +/** Push an event to ALL known MCP key queues (broadcast) */ +export function pushEventAll(event: Omit) { + const full: McpEvent = { ...event, timestamp: new Date().toISOString() }; + for (const key of knownKeys) { + const waiter = waiters.get(key); + if (waiter) { + clearTimeout(waiter.timer); + waiters.delete(key); + waiter.resolve(full); + } else { + let q = queues.get(key); + if (!q) { q = []; queues.set(key, q); } + q.push(full); + while (q.length > MAX_QUEUE) q.shift(); + } + } +} + +// ── Active MCP key for current request (thread-local pattern) ── +let _activeMcpKey: string | null = null; +export function setActiveMcpKey(key: string | null) { _activeMcpKey = key; } +export function getActiveMcpKey(): string | null { return _activeMcpKey; } + +// ── Broadcast to browsers (set by server.ts to avoid circular imports) ── +let _broadcastFn: ((data: Record) => void) | null = null; +export function setBroadcastFn(fn: (data: Record) => void) { _broadcastFn = fn; } +export function broadcastToBrowsers(data: Record) { _broadcastFn?.(data); } diff --git a/backend/mcp/fs.ts b/backend/mcp/fs.ts new file mode 100644 index 0000000..fc6f52f --- /dev/null +++ b/backend/mcp/fs.ts @@ -0,0 +1,188 @@ +/** + * mcp/fs.ts — File system read + grep tools for openclaw VM + * + * Read-only operations scoped to /home/openclaw/. + * No auth beyond MCP key — these are read-only. + */ + +import * as path from 'path'; + +const ALLOWED_ROOT = '/home/openclaw/'; + +function assertAllowed(absPath: string): string { + const resolved = path.resolve(absPath); + // resolved won't have trailing slash, so check both /home/openclaw and /home/openclaw/... + if (resolved !== '/home/openclaw' && !resolved.startsWith(ALLOWED_ROOT)) { + throw new Error(`Access denied: path must be under ${ALLOWED_ROOT}`); + } + return resolved; +} + +const text = (s: string) => ({ content: [{ type: 'text' as const, text: s }] }); + +export const tools = [ + { + name: 'fs_read', + description: 'Read a file from the openclaw VM. Returns file contents. Supports offset/limit for large files.', + inputSchema: { + type: 'object' as const, + properties: { + path: { type: 'string', description: 'Absolute path on openclaw (must be under /home/openclaw/)' }, + offset: { type: 'number', description: 'Start line (1-based, default 1)' }, + limit: { type: 'number', description: 'Max lines to return (default 200)' }, + }, + required: ['path'], + }, + }, + { + name: 'fs_grep', + description: 'Search file contents using ripgrep on the openclaw VM. Returns matching lines with file:line: prefix.', + inputSchema: { + type: 'object' as const, + properties: { + pattern: { type: 'string', description: 'Regex pattern to search for' }, + path: { type: 'string', description: 'File or directory to search (must be under /home/openclaw/)' }, + glob: { type: 'string', description: 'File glob filter (e.g. "*.ts", "*.js")' }, + context: { type: 'number', description: 'Lines of context around matches (default 0)' }, + maxResults: { type: 'number', description: 'Max matches to return (default 50)' }, + }, + required: ['pattern', 'path'], + }, + }, + { + name: 'fs_write', + description: 'Write content to a file on the openclaw VM. Creates parent dirs if needed. Returns bytes written.', + inputSchema: { + type: 'object' as const, + properties: { + path: { type: 'string', description: 'Absolute path on openclaw (must be under /home/openclaw/)' }, + content: { type: 'string', description: 'File content to write' }, + }, + required: ['path', 'content'], + }, + }, + { + name: 'fs_edit', + description: 'Edit a file on the openclaw VM by replacing old_string with new_string (exact match). Fails if old_string not found or not unique. Use replace_all to replace every occurrence.', + inputSchema: { + type: 'object' as const, + properties: { + path: { type: 'string', description: 'Absolute path on openclaw (must be under /home/openclaw/)' }, + old_string: { type: 'string', description: 'Exact text to find and replace' }, + new_string: { type: 'string', description: 'Replacement text' }, + replace_all: { type: 'boolean', description: 'Replace all occurrences (default false)' }, + }, + required: ['path', 'old_string', 'new_string'], + }, + }, + { + name: 'fs_ls', + description: 'List directory contents on the openclaw VM.', + inputSchema: { + type: 'object' as const, + properties: { + path: { type: 'string', description: 'Absolute directory path (must be under /home/openclaw/)' }, + }, + required: ['path'], + }, + }, +]; + +export async function handle(name: string, args: any) { + switch (name) { + case 'fs_read': return fsRead(args); + case 'fs_write': return fsWrite(args); + case 'fs_edit': return fsEdit(args); + case 'fs_grep': return fsGrep(args); + case 'fs_ls': return fsLs(args); + default: throw new Error(`Unknown fs tool: ${name}`); + } +} + +async function fsRead(args: any) { + const filePath = assertAllowed(args.path); + const offset = Math.max(1, args.offset ?? 1); + const limit = Math.min(2000, Math.max(1, args.limit ?? 200)); + + const file = Bun.file(filePath); + if (!await file.exists()) return text(`File not found: ${filePath}`); + + const content = await file.text(); + const lines = content.split('\n'); + const slice = lines.slice(offset - 1, offset - 1 + limit); + + const numbered = slice.map((line, i) => `${offset + i}: ${line}`).join('\n'); + const header = `${filePath} (${lines.length} lines, showing ${offset}-${Math.min(offset + limit - 1, lines.length)})`; + return text(`${header}\n${numbered}`); +} + +async function fsWrite(args: any) { + const filePath = assertAllowed(args.path); + const dir = path.dirname(filePath); + const { mkdirSync } = await import('fs'); + mkdirSync(dir, { recursive: true }); + await Bun.write(filePath, args.content); + return text(`Written ${args.content.length} chars to ${filePath}`); +} + +async function fsEdit(args: any) { + const filePath = assertAllowed(args.path); + const file = Bun.file(filePath); + if (!await file.exists()) return text(`File not found: ${filePath}`); + + const content = await file.text(); + const { old_string, new_string, replace_all } = args; + + if (old_string === new_string) return text('old_string and new_string are identical'); + + if (replace_all) { + if (!content.includes(old_string)) return text('old_string not found in file'); + const updated = content.replaceAll(old_string, new_string); + const count = (content.split(old_string).length - 1); + await Bun.write(filePath, updated); + return text(`Replaced ${count} occurrence(s) in ${filePath}`); + } + + const idx = content.indexOf(old_string); + if (idx === -1) return text('old_string not found in file'); + if (content.indexOf(old_string, idx + 1) !== -1) return text('old_string is not unique — provide more context or use replace_all'); + + const updated = content.slice(0, idx) + new_string + content.slice(idx + old_string.length); + await Bun.write(filePath, updated); + return text(`Edited ${filePath}`); +} + +async function fsGrep(args: any) { + const searchPath = assertAllowed(args.path); + const maxResults = Math.min(200, Math.max(1, args.maxResults ?? 50)); + const context = Math.min(10, Math.max(0, args.context ?? 0)); + + const rgArgs = ['grep', '-rn', '-E', '--include=' + (args.glob || '*')]; + if (context > 0) rgArgs.push('-C', String(context)); + rgArgs.push('-m', String(maxResults)); + rgArgs.push(args.pattern, searchPath); + + const proc = Bun.spawn(rgArgs, { stdout: 'pipe', stderr: 'pipe' }); + const [out, err] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const code = await proc.exited; + + if (code === 1) return text('No matches found.'); + if (code !== 0 && err.trim()) return text(`rg error (code ${code}): ${err.trim()}`); + return text(out.trim() || 'No matches found.'); +} + +async function fsLs(args: any) { + const dirPath = assertAllowed(args.path); + + const proc = Bun.spawn(['ls', '-la', dirPath], { stdout: 'pipe', stderr: 'pipe' }); + const [out, err] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + await proc.exited; + if (err.trim()) return text(`ls error: ${err.trim()}`); + return text(out.trim()); +} diff --git a/backend/mcp/index.ts b/backend/mcp/index.ts new file mode 100644 index 0000000..2e91e94 --- /dev/null +++ b/backend/mcp/index.ts @@ -0,0 +1,147 @@ +/** + * mcp/index.ts — MCP server setup for Hermes backend + * + * Auth: every request must include Authorization: Bearer + * Each MCP key is linked to a takeover token on the backend. + * Takeover tools use the linked token implicitly — no token param needed. + */ + +import * as fs from "fs"; +import * as path from "path"; +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js"; + +import { tools as takeoverTools, handle as handleTakeover, setBridge, setActiveToken, type TakeoverBridge } from "./takeover.ts"; +import { tools as deckTools, handle as handleDeck } from "./deck.ts"; +import { tools as devTools, handle as handleDev } from "./dev.ts"; +import { tools as systemTools, handle as handleSystem } from "./system.ts"; +import { tools as fsTools, handle as handleFs } from "./fs.ts"; +import { setActiveMcpKey } from "./events.ts"; + +const allTools = [...takeoverTools, ...deckTools, ...devTools, ...systemTools, ...fsTools]; + +// ── MCP API key → takeover token mapping ── +interface McpKeyEntry { takeoverToken: string; createdAt: number; user?: string; } +const MCP_KEYS_FILE = path.join(import.meta.dir, "..", ".mcp-keys.json"); + +function loadMcpKeys(): Map { + try { + const data = JSON.parse(fs.readFileSync(MCP_KEYS_FILE, "utf8")); + return new Map(Object.entries(data)); + } catch { return new Map(); } +} + +function saveMcpKeys(keys: Map) { + const obj: Record = {}; + for (const [k, v] of keys) obj[k] = v; + fs.writeFileSync(MCP_KEYS_FILE, JSON.stringify(obj, null, 2)); +} + +export function registerMcpKey(mcpKey: string, takeoverToken: string, user?: string) { + const keys = loadMcpKeys(); + keys.set(mcpKey, { takeoverToken, createdAt: Date.now(), user }); + saveMcpKeys(keys); + console.log(`[mcp] Key registered: ${mcpKey.slice(0, 8)}... → takeover ${takeoverToken.slice(0, 8)}...`); +} + +/** Called when browser re-enables takeover — auto-updates MCP key for this user */ +export function refreshMcpKeyForUser(user: string, newTakeoverToken: string) { + const keys = loadMcpKeys(); + let updated = false; + for (const [k, v] of keys) { + // Match by user field, or update unowned keys if only one key exists (legacy/single-user) + if (v.user === user || (!v.user && keys.size === 1)) { + keys.set(k, { ...v, takeoverToken: newTakeoverToken, user }); + updated = true; + } + } + if (updated) { + saveMcpKeys(keys); + console.log(`[mcp] Auto-updated key for ${user} → takeover ${newTakeoverToken.slice(0, 8)}...`); + } +} + +function validateAuth(req: Request): { entry: McpKeyEntry; mcpKey: string } | null { + const auth = req.headers.get("authorization") || ""; + const match = auth.match(/^Bearer\s+(.+)$/i); + if (!match) return null; + const keys = loadMcpKeys(); + const entry = keys.get(match[1]); + return entry ? { entry, mcpKey: match[1] } : null; +} + +// ── MCP Server factory ── +let bridgeRef: TakeoverBridge | null = null; + +function createSessionServer(): { server: Server; transport: WebStandardStreamableHTTPServerTransport } { + const server = new Server( + { name: "hermes-mcp", version: "1.0.0" }, + { capabilities: { tools: {} } }, + ); + + server.setRequestHandler(ListToolsRequestSchema, async () => { + return { tools: allTools }; + }); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const { name, arguments: args } = request.params; + if (name.startsWith("takeover_")) return handleTakeover(name, args); + if (name.startsWith("deck_")) return handleDeck(name, args); + if (name.startsWith("dev_")) return handleDev(name, args); + if (name.startsWith("system_")) return handleSystem(name, args); + if (name.startsWith("fs_")) return handleFs(name, args); + throw new Error(`Unknown tool: ${name}`); + }); + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + + server.connect(transport); + return { server, transport }; +} + +export function setupMcp(bridge: TakeoverBridge) { + bridgeRef = bridge; + setBridge(bridge); + console.log(` MCP: /mcp (${allTools.length} tools, ${loadMcpKeys().size} key(s))`); +} + +/** Handle an incoming /mcp request — called from server.ts fetch handler */ +export async function handleMcpRequest(req: Request): Promise { + // Auth gate — require valid MCP API key + const auth = validateAuth(req); + if (!auth) { + return new Response(JSON.stringify({ error: "Unauthorized — Bearer token required" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + // Set the active takeover token for this request + setActiveToken(auth.entry.takeoverToken); + setActiveMcpKey(auth.mcpKey); + + // Ensure Accept header includes text/event-stream (Cloudflare/proxies may strip it) + const accept = req.headers.get("accept") || ""; + if (!accept.includes("text/event-stream")) { + const headers = new Headers(req.headers); + headers.set("accept", "application/json, text/event-stream"); + req = new Request(req.url, { + method: req.method, + headers, + body: req.body, + // @ts-ignore — Bun supports duplex + duplex: "half", + }); + } + + // Stateless: fresh server+transport per request, survives hot reloads + const { transport } = createSessionServer(); + return transport.handleRequest(req); +} diff --git a/backend/mcp/system.ts b/backend/mcp/system.ts new file mode 100644 index 0000000..765bdc0 --- /dev/null +++ b/backend/mcp/system.ts @@ -0,0 +1,323 @@ +/** + * mcp/system.ts — System operations via device-auth flow + * + * Destructive ops (restart) require a systemToken from system_request_access → user approves in /dev. + * Read-only ops (logs, status) require no auth. + */ + +import { createRequest, checkRequest, validateSystemToken } from '../system-access.ts'; +import { getActiveMcpKey } from "./events.ts"; + +function requireToken(args: any): string { + if (!args?.systemToken) throw new Error('systemToken required — call system_request_access first'); + const entry = validateSystemToken(args.systemToken); + if (!entry) throw new Error('Invalid or expired system token'); + return entry.user; +} + +async function tmuxCapture(pane: string, lines: number): Promise { + const proc = Bun.spawn( + ['tmux', 'capture-pane', '-t', pane, '-p', '-S', `-${lines}`], + { stdout: 'pipe', stderr: 'pipe' } + ); + const [out, err] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + await proc.exited; + if (err.trim()) throw new Error(err.trim()); + return out; +} + +const text = (s: string) => ({ content: [{ type: 'text' as const, text: s }] }); + +export const tools = [ + { + name: 'system_request_access', + description: 'Request system access for destructive operations (restart, deploy). Creates a pending approval shown in /dev. Returns requestId — tell user the userCode and poll system_check_access until approved.', + inputSchema: { + type: 'object' as const, + properties: { + description: { type: 'string', description: 'What you need access for (shown to user in /dev)' }, + }, + required: ['description'], + }, + }, + { + name: 'system_check_access', + description: 'Poll for system access approval. Returns systemToken once user approves in /dev, or status: pending/expired/denied.', + inputSchema: { + type: 'object' as const, + properties: { + requestId: { type: 'string', description: 'requestId from system_request_access' }, + }, + required: ['requestId'], + }, + }, + { + name: 'system_logs', + description: 'Read recent log output from a tmux pane. target: "bun-dev" (hermes dev backend), "vite" (webchat-vite). No auth required.', + inputSchema: { + type: 'object' as const, + properties: { + target: { type: 'string', enum: ['bun-dev', 'vite'], description: 'Which process to read logs from' }, + lines: { type: 'number', description: 'Lines to return (default 50, max 200)' }, + }, + required: ['target'], + }, + }, + { + name: 'system_process_status', + description: 'Check status of bun/vite processes on openclaw. Returns matching ps output. No auth required.', + inputSchema: { + type: 'object' as const, + properties: {}, + }, + }, + { + name: 'system_restart_dev', + description: 'Restart the dev bun process. Sends C-c to hermes-dev tmux session; bun --watch auto-restarts within ~1s. MCP will reconnect automatically. No auth required.', + inputSchema: { type: 'object' as const, properties: {} }, + }, + { + name: 'system_restart_prod', + description: 'Restart the prod bun process via systemctl (openclaw-web-gateway, port 3001). Uses sudo NOPASSWD. Requires systemToken.', + inputSchema: { + type: 'object' as const, + properties: { + systemToken: { type: 'string', description: 'Token from system_check_access' }, + }, + required: ['systemToken'], + }, + }, + { + name: 'system_deploy_prod', + description: 'Full prod deploy: bump version, build frontend, rsync to IONOS, restart prod, health check. Requires systemToken.', + inputSchema: { + type: 'object' as const, + properties: { + systemToken: { type: 'string', description: 'Token from system_check_access' }, + version: { type: 'string', description: 'New version string (e.g. "0.6.0")' }, + }, + required: ['systemToken', 'version'], + }, + }, + { + name: 'system_restart_vite', + description: 'Restart the vite dev server. Sends C-c then re-runs in webchat-vite tmux session. No auth required.', + inputSchema: { type: 'object' as const, properties: {} }, + }, + { + name: 'system_stop_dev', + description: 'Stop the dev bun process (C-c to hermes-dev). Does not restart. No auth required.', + inputSchema: { type: 'object' as const, properties: {} }, + }, + { + name: 'system_stop_vite', + description: 'Stop the vite dev server (C-c to webchat-vite). Does not restart. No auth required.', + inputSchema: { type: 'object' as const, properties: {} }, + }, + { + name: 'system_start_dev', + description: 'Start the dev bun process in hermes-dev tmux session. No auth required.', + inputSchema: { type: 'object' as const, properties: {} }, + }, + { + name: 'system_start_vite', + description: 'Start the vite dev server in webchat-vite tmux session. No auth required.', + inputSchema: { type: 'object' as const, properties: {} }, + }, +]; + +export async function handle(name: string, args: any): Promise { + switch (name) { + case 'system_request_access': { + const req = createRequest(args.description ?? 'system access', getActiveMcpKey() ?? undefined); + return text(JSON.stringify({ + requestId: req.requestId, + userCode: req.userCode, + expiresIn: 300, + instruction: `Tell the user to go to /dev — they will see a pending approval with code ${req.userCode}. Then poll system_check_access with the requestId.`, + }, null, 2)); + } + + case 'system_check_access': { + return text(JSON.stringify(checkRequest(args.requestId), null, 2)); + } + + case 'system_logs': { + const pane = args.target === 'vite' ? 'webchat-vite' : 'hermes-dev'; + const lines = Math.min(args.lines ?? 50, 200); + try { + const out = await tmuxCapture(pane, lines); + return text(out || '(empty)'); + } catch (e: any) { + return text(`Error: ${e.message}`); + } + } + + case 'system_process_status': { + try { + const proc = Bun.spawn(['ps', 'aux'], { stdout: 'pipe', stderr: 'pipe' }); + const out = await new Response(proc.stdout).text(); + const relevant = out.split('\n') + .filter(l => (l.includes('bun') || l.includes('vite')) && !l.includes('grep')); + return text(relevant.join('\n') || 'No relevant processes found'); + } catch (e: any) { + return text(`Error: ${e.message}`); + } + } + + case 'system_restart_dev': { + // Defer C-c by 200ms so the HTTP response is delivered first + setTimeout(async () => { + const proc = Bun.spawn( + ['tmux', 'send-keys', '-t', 'hermes-dev', 'C-c', ''], + { stdout: 'pipe', stderr: 'pipe' } + ); + await proc.exited; + }, 200); + return text(JSON.stringify({ ok: true, message: 'Sent C-c to hermes-dev — bun --watch will restart in ~1s' })); + } + + case 'system_restart_prod': { + requireToken(args); + try { + const proc = Bun.spawn( + ['sudo', '-n', 'systemctl', 'restart', 'openclaw-web-gateway'], + { stdout: 'pipe', stderr: 'pipe' } + ); + const [, err] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + await proc.exited; + if (proc.exitCode !== 0) return text(`Error: ${err.trim() || 'exit ' + proc.exitCode}`); + return text(JSON.stringify({ ok: true, message: 'openclaw-web-gateway restarted' })); + } catch (e: any) { + return text(`Error: ${e.message}`); + } + } + + case 'system_deploy_prod': { + requireToken(args); + if (!args.version?.match(/^\d+\.\d+\.\d+$/)) { + return text('Error: version must be semver (e.g. "0.6.0")'); + } + const BASE = '/home/openclaw/.openclaw/workspace-titan/projects/hermes'; + const steps: string[] = []; + + try { + // 1. Read old version + const oldPkg = JSON.parse(await Bun.file(`${BASE}/backend/package.json`).text()); + const oldVersion = oldPkg.version; + steps.push(`Version: ${oldVersion} -> ${args.version}`); + + // 2. Bump backend package.json + oldPkg.version = args.version; + await Bun.write(`${BASE}/backend/package.json`, JSON.stringify(oldPkg, null, 2) + '\n'); + + // 3. Bump frontend package.json + const fePkg = JSON.parse(await Bun.file(`${BASE}/frontend/package.json`).text()); + fePkg.version = args.version; + await Bun.write(`${BASE}/frontend/package.json`, JSON.stringify(fePkg, null, 2) + '\n'); + steps.push('Version bumped in both package.json files'); + + // 4. Build frontend + const build = Bun.spawn(['npm', 'run', 'build'], { + cwd: `${BASE}/frontend`, + stdout: 'pipe', stderr: 'pipe', + env: { ...process.env, PATH: process.env.PATH }, + }); + const buildErr = await new Response(build.stderr).text(); + await build.exited; + if (build.exitCode !== 0) return text(`Build failed: ${buildErr.trim()}`); + steps.push('Frontend built'); + + // 5. Rsync frontend to IONOS + const rsync = Bun.spawn([ + 'rsync', '-avz', '--delete', + `${BASE}/frontend/dist/`, + 'u116526981@access1007204406.webspace-data.io:~/jqxp/', + ], { stdout: 'pipe', stderr: 'pipe' }); + const rsyncErr = await new Response(rsync.stderr).text(); + await rsync.exited; + if (rsync.exitCode !== 0) return text(`Rsync failed: ${rsyncErr.trim()}`); + steps.push('Frontend deployed to IONOS'); + + // 6. Restart prod + const restart = Bun.spawn( + ['sudo', '-n', 'systemctl', 'restart', 'openclaw-web-gateway'], + { stdout: 'pipe', stderr: 'pipe' }, + ); + const restartErr = await new Response(restart.stderr).text(); + await restart.exited; + if (restart.exitCode !== 0) return text(`Restart failed: ${restartErr.trim()}`); + steps.push('Prod restarted'); + + // 7. Wait + health check + await new Promise(r => setTimeout(r, 3000)); + const health = Bun.spawn( + ['curl', '-s', '--max-time', '5', 'http://localhost:3001/health'], + { stdout: 'pipe', stderr: 'pipe' }, + ); + const healthOut = await new Response(health.stdout).text(); + await health.exited; + steps.push(`Health: ${healthOut.trim()}`); + + return text(JSON.stringify({ ok: true, steps }, null, 2)); + } catch (e: any) { + return text(`Deploy error at step "${steps[steps.length - 1] || 'init'}": ${e.message}`); + } + } + + case 'system_restart_vite': { + setTimeout(async () => { + // Send C-c then restart + const stop = Bun.spawn(['tmux', 'send-keys', '-t', 'webchat-vite', 'C-c', ''], { stdout: 'pipe', stderr: 'pipe' }); + await stop.exited; + await new Promise(r => setTimeout(r, 500)); + const start = Bun.spawn(['tmux', 'send-keys', '-t', 'webchat-vite', 'npx vite --host --port 8444 Enter', ''], { stdout: 'pipe', stderr: 'pipe' }); + await start.exited; + }, 200); + return text(JSON.stringify({ ok: true, message: 'Restarting vite in webchat-vite tmux session' })); + } + + case 'system_stop_dev': { + setTimeout(async () => { + const proc = Bun.spawn(['tmux', 'send-keys', '-t', 'hermes-dev', 'C-c', ''], { stdout: 'pipe', stderr: 'pipe' }); + await proc.exited; + }, 200); + return text(JSON.stringify({ ok: true, message: 'Sent C-c to hermes-dev (stopped, no restart)' })); + } + + case 'system_stop_vite': { + setTimeout(async () => { + const proc = Bun.spawn(['tmux', 'send-keys', '-t', 'webchat-vite', 'C-c', ''], { stdout: 'pipe', stderr: 'pipe' }); + await proc.exited; + }, 200); + return text(JSON.stringify({ ok: true, message: 'Sent C-c to webchat-vite (stopped, no restart)' })); + } + + case 'system_start_dev': { + const cmd = 'cd ~/.openclaw/workspace-titan/projects/hermes/backend && ~/.bun/bin/bun --watch server.ts'; + setTimeout(async () => { + const proc = Bun.spawn(['tmux', 'send-keys', '-t', 'hermes-dev', cmd, 'Enter'], { stdout: 'pipe', stderr: 'pipe' }); + await proc.exited; + }, 200); + return text(JSON.stringify({ ok: true, message: 'Starting bun --watch in hermes-dev' })); + } + + case 'system_start_vite': { + const cmd = 'cd ~/.openclaw/workspace-titan/projects/hermes/frontend && npx vite --host --port 8444'; + setTimeout(async () => { + const proc = Bun.spawn(['tmux', 'send-keys', '-t', 'webchat-vite', cmd, 'Enter'], { stdout: 'pipe', stderr: 'pipe' }); + await proc.exited; + }, 200); + return text(JSON.stringify({ ok: true, message: 'Starting vite in webchat-vite' })); + } + + default: + throw new Error(`Unknown system tool: ${name}`); + } +} diff --git a/backend/mcp/takeover.ts b/backend/mcp/takeover.ts new file mode 100644 index 0000000..687e45d --- /dev/null +++ b/backend/mcp/takeover.ts @@ -0,0 +1,167 @@ +/** + * mcp/takeover.ts — Browser control tools via direct takeover token map access + * + * Token is set implicitly per-request from the MCP API key mapping. + * No token param needed in tool calls. + */ + +export interface TakeoverBridge { + sendCmd(token: string, cmd: string, args: any, timeoutMs?: number): Promise; +} + +let bridge: TakeoverBridge | null = null; +let activeToken: string = ""; + +export function setBridge(b: TakeoverBridge) { bridge = b; } +export function setActiveToken(token: string) { activeToken = token; } + +function getToken(): string { + if (!activeToken) throw new Error("No takeover token — MCP key not linked"); + return activeToken; +} + +function effectiveToken(breakout?: string): string { + const t = getToken(); + return breakout ? `${t}-breakout-${breakout}` : t; +} + +async function cmd(command: string, args: any = {}, opts: { breakout?: string; timeout?: number } = {}) { + if (!bridge) throw new Error("Takeover bridge not initialized"); + return bridge.sendCmd( + effectiveToken(opts.breakout), + command, + args, + opts.timeout ?? 10000, + ); +} + +export const tools = [ + { + name: "takeover_cmd", + description: "Execute a takeover command on the browser. Commands: boxChain, getStyles, viewport, navigate, reload, resize, querySelector, click, screenshot, getValue, setValue, typeText, listBreakouts, closeBreakout, captureScreen, enableCapture, openBreakout, eval, getConsole.", + inputSchema: { + type: "object" as const, + properties: { + cmd: { type: "string", description: "Command name" }, + args: { type: "object", description: "Command arguments" }, + breakout: { type: "string", description: "Breakout name (derives token automatically)" }, + timeout: { type: "number", description: "Timeout in ms (default 10000, max 60000)" }, + }, + required: ["cmd"], + }, + }, + { + name: "takeover_screenshot", + description: "Capture a WebRTC screenshot from the browser. Returns base64 JPEG image. Requires enableCapture first.", + inputSchema: { + type: "object" as const, + properties: { + breakout: { type: "string", description: "Breakout name" }, + quality: { type: "number", description: "JPEG quality 0-1 (default 0.5)" }, + }, + }, + }, + { + name: "takeover_health", + description: "Check browser health: viewport + capture stream status in one call.", + inputSchema: { + type: "object" as const, + properties: { + breakout: { type: "string", description: "Breakout name" }, + }, + }, + }, + { + name: "takeover_open_breakout", + description: "Open a breakout test window. User must confirm in browser. 30s timeout.", + inputSchema: { + type: "object" as const, + properties: { + name: { type: "string", description: "Breakout name" }, + preset: { type: "string", enum: ["mobile", "tablet", "tablet-landscape", "desktop"], description: "Size preset (default: mobile)" }, + }, + required: ["name"], + }, + }, + { + name: "takeover_enable_capture", + description: "Enable WebRTC capture on a browser window. User must pick tab. 30s timeout.", + inputSchema: { + type: "object" as const, + properties: { + breakout: { type: "string", description: "Breakout name" }, + }, + }, + }, +]; + +export async function handle(name: string, args: any): Promise { + switch (name) { + case "takeover_cmd": { + const result = await cmd(args.cmd, args.args || {}, { + breakout: args.breakout, + timeout: args.timeout, + }); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + + case "takeover_screenshot": { + const result = await cmd("captureScreen", { quality: args.quality ?? 0.5 }, { + breakout: args.breakout, + timeout: 15000, + }); + if (result.error) { + return { content: [{ type: "text", text: `Screenshot failed: ${result.error}` }] }; + } + if (result.result?.dataUrl) { + const [header, data] = result.result.dataUrl.split(",", 2); + const mimeMatch = header.match(/data:([^;]+)/); + return { + content: [{ + type: "image", + data, + mimeType: mimeMatch ? mimeMatch[1] : "image/jpeg", + }], + }; + } + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + + case "takeover_health": { + const opts = { breakout: args.breakout, timeout: 5000 }; + const [vp, cap] = await Promise.allSettled([ + cmd("viewport", {}, opts), + cmd("captureScreen", { quality: 0.1 }, opts), + ]); + const viewport = vp.status === "fulfilled" ? vp.value : { error: "unreachable" }; + const capture = cap.status === "fulfilled" + ? (cap.value.result?.dataUrl ? { active: true } : { active: false, reason: cap.value.result?.error || cap.value.error }) + : { active: false, reason: "unreachable" }; + return { + content: [{ + type: "text", + text: JSON.stringify({ viewport: viewport.result || viewport, capture }, null, 2), + }], + }; + } + + case "takeover_open_breakout": { + const result = await cmd("openBreakout", { + name: args.name, + preset: args.preset || "mobile", + }, { timeout: 30000 }); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + + case "takeover_enable_capture": { + const result = await cmd("enableCapture", {}, { + breakout: args.breakout, + timeout: 30000, + }); + return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] }; + } + + default: + throw new Error(`Unknown takeover tool: ${name}`); + } +} diff --git a/backend/message-filter.ts b/backend/message-filter.ts new file mode 100644 index 0000000..c953c6a --- /dev/null +++ b/backend/message-filter.ts @@ -0,0 +1,93 @@ +/** + * message-filter.ts — outgoing message sanitization + * + * 1. Brand replace: OpenClaw → Titan, openclaw emoji → 🐶 + * 2. Path scrub: strip /home/openclaw/ prefix from tool content + * 3. Smart truncation: single truncator + * - SQL results: row count summary + first N lines + * - Other: first 400 chars + …[N more chars] + */ + +// ── 1. Brand replace ──────────────────────────────────────────────────────── + +const OPENCLAW_EMOJIS = /🐾|🦞|🤖|🦅/gu; +const OPENCLAW_NAME = /\bOpenClaw\b/g; + +function brandReplace(str: string): string { + return str.replace(OPENCLAW_EMOJIS, '🐶').replace(OPENCLAW_NAME, 'Titan'); +} + +// ── 2. Path scrub ──────────────────────────────────────────────────────────── + +const PATH_ABS = /\/home\/openclaw\//g; +const PATH_TILDE = /~\/\.openclaw\//g; +const PATH_DOT_CLAW = /\.openclaw\//g; + +function scrubPaths(str: string): string { + return str.replace(PATH_ABS, '').replace(PATH_TILDE, '').replace(PATH_DOT_CLAW, ''); +} + +// ── 3. Smart truncation ────────────────────────────────────────────────────── + +const SOFT_LIMIT = 80; +const SQL_ROWS = 8; + +function looksLikeSqlResult(str: string): boolean { + const lines = str.split('\n').filter(l => l.trim()); + if (lines.length < 2) return false; + const tabLines = lines.filter(l => l.includes('\t') || (l.match(/\|/g) || []).length >= 2); + return tabLines.length > lines.length * 0.5; +} + +function truncateSql(str: string): string { + const lines = str.split('\n').filter(l => l.trim()); + const total = lines.length; + if (total <= SQL_ROWS + 1) return str; + return lines.slice(0, SQL_ROWS).join('\n') + `\n… [${total - SQL_ROWS} more rows]`; +} + +function smartTruncate(str: unknown): string { + if (typeof str !== 'string') { + try { str = JSON.stringify(str); } catch (_) { return String(str); } + } + const s = str as string; + if (s.length <= SOFT_LIMIT) return s; + if (looksLikeSqlResult(s)) return truncateSql(s); + return s.slice(0, 40) + '…' + s.slice(-40); +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +export function filterText(str: string | null | undefined): string | null | undefined { + if (str == null) return str; + return scrubPaths(brandReplace(String(str))); +} + +function extractSql(cmd: string): string | null { + const m = cmd.match(/(?:mysql|mariadb)\b[^"']*-e\s+["']([^"']+)["']/s); + if (m) return m[1].trim().replace(/\\`/g, '').replace(/`/g, ''); + return null; +} + +function prettifyArgs(val: unknown): string { + if (typeof val === 'string') { + try { val = JSON.parse(val); } catch (_) { return val; } + } + if (val && typeof val === 'object') { + const obj = val as Record; + if (typeof obj.command === 'string') { + return extractSql(obj.command) || obj.command; + } + const keys = Object.keys(obj); + if (keys.length === 1 && typeof obj[keys[0]] === 'string') return obj[keys[0]] as string; + try { return JSON.stringify(val); } catch (_) { return '[non-serializable]'; } + } + return String(val); +} + +export function filterValue(val: unknown): string | null | undefined { + if (val == null) return val as null | undefined; + return smartTruncate(scrubPaths(brandReplace(prettifyArgs(val)))); +} + +export { brandReplace, scrubPaths, smartTruncate }; diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..4a702a8 --- /dev/null +++ b/backend/package.json @@ -0,0 +1,13 @@ +{ + "name": "openclaw-web-gateway", + "version": "0.7.2", + "description": "WebSocket proxy that spawns per-user agent sessions", + "module": "server.ts", + "scripts": { + "start": "bun run server.ts", + "dev": "bun --watch server.ts" + }, + "engines": { + "bun": ">=1.0.0" + } +} diff --git a/backend/server.ts b/backend/server.ts new file mode 100644 index 0000000..068a96e --- /dev/null +++ b/backend/server.ts @@ -0,0 +1,1463 @@ +/** + * server.ts — Hermes Web Gateway (Bun) + * Port: 3003 | Env: PORT, GATEWAY_HOST, GATEWAY_PORT, GATEWAY_TOKEN + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +import { pushEventAll, setBroadcastFn } from './mcp/events.ts'; +import { createSessionWatcher, checkSessionOnDisk, findPreviousSessionFiles, parseSessionFile } from './session-watcher'; +import { createChannelSM, createConnectionSM, type ChannelState, type ConnectionState, type StateMachine } from './session-sm'; +import { + userDefaultAgent, userAllowedAgents, userViewerRoots, + getTokenForUser, getUserForToken, + getAgentList, getAgentListWithModels, + issueSessionToken, getUserForSessionToken, revokeSessionToken, + issueAuthNonce, consumeAuthNonce, + verifyOtpChallenge, getAgentSegment, getSessionPrompt, +} from './auth'; +import { + connectToGateway, gatewayRequest, isConnected, setBrowserSessions, disconnectGateway, setOnTurnDone, setOnGatewayDisconnect, +} from './gateway'; +import { filterValue } from './message-filter'; +import { hud } from './hud-builder'; +import { setupMcp, handleMcpRequest, refreshMcpKeyForUser } from './mcp/index.ts'; +import { setNotifyFn, getPendingRequests, approveRequest, denyRequest, type PendingRequest } from './system-access'; + +const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url).pathname, 'utf8')); +const BUILD_ID = Date.now().toString(36); +const VERSION = `${pkg.version as string}-${BUILD_ID}`; +const PORT = parseInt(process.env.PORT || '3003', 10); +const BRAND_NAME = process.env.BRAND_NAME || 'Titan'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface ViewerEntry { watcher: ReturnType | null; users: Set; } +interface FstokenEntry { user: string; expiresAt: number; } +interface SessionStateEntry { + handoverActive: boolean; + lastIdleAt: number; + activeTurnId: string | null; + activeTurnTs: number; + lastDoneTurnId: string | null; + lastDoneTurnTs: number; + promptInjected: boolean; +} +interface SharedSM { + sm: StateMachine; + connections: number; // ref count of active WS connections using this SM +} +interface BrowserSession { + user: string | null; + sessionKey: string | null; + clientId: string; + sm: StateMachine; // channel SM (shared) + connSm: StateMachine; // connection SM (per-WS) + watcher: ReturnType | null; + turnId?: string; + _handoverResolve?: (() => void) | null; + _handoverHandler?: ((eventName: string, payload: any) => void) | null; + _lastDeltaText?: string; + _lastDeltaOffset?: number; + _lastThinkOffset?: number; + _hudTurnStarted?: boolean; + _hudTurnTs?: number | null; + _hudThinkId?: string | null; + _hudThinkTs?: number | null; + _hudPendingTools?: Map; + _hudLiveToolSeen?: boolean; + _hudDeferredTurnEnd?: { turnId: string; turnTs: number; hadTurnStarted: boolean } | null; +} +interface PathError extends Error { status?: number; } + +// --------------------------------------------------------------------------- +// Viewer config +// --------------------------------------------------------------------------- + +const VIEWER_ROOTS: Record = { + shared: '/home/openclaw/.openclaw/shared', + titan: '/home/openclaw/.openclaw/workspace-titan', + 'workspace-titan': '/home/openclaw/.openclaw/workspace-titan', + adoree: '/home/openclaw/.openclaw/workspace-adoree', + 'workspace-adoree': '/home/openclaw/.openclaw/workspace-adoree', + alfred: '/home/openclaw/.openclaw/workspace-alfred', + 'workspace-alfred': '/home/openclaw/.openclaw/workspace-alfred', + ash: '/home/openclaw/.openclaw/workspace-ash', + 'workspace-ash': '/home/openclaw/.openclaw/workspace-ash', + eras: '/home/openclaw/.openclaw/workspace-eras', + 'workspace-eras': '/home/openclaw/.openclaw/workspace-eras', + willi: '/home/openclaw/.openclaw/workspace-willi', + 'workspace-willi': '/home/openclaw/.openclaw/workspace-willi', +}; +const VIEWER_SKIP_EXT = new Set(['.png','.jpg','.jpeg','.gif','.bmp','.ico','.svg','.webp','.mp3','.mp4','.wav','.ogg','.zip','.gz','.tar','.7z','.rar','.exe','.dll','.so','.dylib','.woff','.woff2','.ttf','.otf','.eot']); +const VIEWER_MAX_BYTES = 2 * 1024 * 1024; + +function resolveViewerPath(rel: string): string { + const [prefix, ...rest] = rel.split('/'); + const root = VIEWER_ROOTS[prefix]; + if (!root) { const e: PathError = new Error('Unknown root'); e.status = 400; throw e; } + if (!rest.length || rest.join('/') === '') return root; + const abs = path.resolve(root, rest.join('/')); + if (!abs.startsWith(root + path.sep) && abs !== root) { + const e: PathError = new Error('Path traversal denied'); e.status = 400; throw e; + } + return abs; +} + +function absToViewerPath(absPath: string): string | null { + for (const [prefix, root] of Object.entries(VIEWER_ROOTS)) { + if (absPath.startsWith(root + '/') || absPath === root) + return prefix + (absPath === root ? '' : '/' + absPath.slice(root.length + 1)); + } + return null; +} + +// --------------------------------------------------------------------------- +// fstoken +// --------------------------------------------------------------------------- + +const fstokenMap = new Map(); +const FSTOKEN_TTL_MS = 60 * 60 * 1000; + +function issueFstoken(user: string): string { + for (const [tok, entry] of fstokenMap) + if (entry.user === user && entry.expiresAt > Date.now()) return tok; + const tok = crypto.randomUUID(); + fstokenMap.set(tok, { user, expiresAt: Date.now() + FSTOKEN_TTL_MS }); + return tok; +} + +function getUserForFstoken(fstoken: string): string | null { + const entry = fstokenMap.get(fstoken); + if (!entry) return null; + if (entry.expiresAt < Date.now()) { fstokenMap.delete(fstoken); return null; } + return entry.user; +} + +function viewerTokenFromReq(req: Request): string | null { + const u = new URL(req.url); + return u.searchParams.get('token') + || (req.headers.get('authorization') || '').replace(/^Bearer /, '') + || null; +} + +function getUserForViewerToken(token: string): string | null { + return getUserForFstoken(token) || getUserForSessionToken(token) || getUserForToken(token); +} + +// --------------------------------------------------------------------------- +// Viewer watchers — file content (fs.watchFile) + directory changes (fs.watch) +// --------------------------------------------------------------------------- + +const viewerWatchers = new Map(); + +// Directory watchers: detect new/deleted files in open directories +interface DirWatcherEntry { watcher: ReturnType | null; users: Set; } +const viewerDirWatchers = new Map(); + +function attachFsWatcher(absPath: string): void { + const entry = viewerWatchers.get(absPath); + if (!entry) return; + try { + // fs.watchFile (stat-based polling) for content changes on existing files. + // Bun's fs.watch misses rename events from atomic writes (sed -i, editors). + fs.watchFile(absPath, { persistent: false, interval: 2000 }, (curr, prev) => { + if (curr.mtimeMs !== prev.mtimeMs || curr.ino !== prev.ino) { + broadcastViewerChange(absPath); + } + }); + entry.watcher = absPath as any; // store path for unwatchFile cleanup + } catch (e: any) { console.warn('[viewer] fs.watchFile failed for', absPath, e.message); } +} + +function watchViewerDir(absDir: string, user: string): void { + if (!viewerDirWatchers.has(absDir)) { + try { + const w = fs.watch(absDir, { persistent: false }, (_event, _filename) => { + broadcastDirChange(absDir); + }); + viewerDirWatchers.set(absDir, { watcher: w, users: new Set() }); + } catch (e: any) { + console.warn('[viewer] fs.watch dir failed for', absDir, e.message); + viewerDirWatchers.set(absDir, { watcher: null, users: new Set() }); + } + } + viewerDirWatchers.get(absDir)!.users.add(user); +} + +function unwatchDirUser(user: string): void { + for (const [absDir, entry] of viewerDirWatchers) { + entry.users.delete(user); + if (entry.users.size === 0) { + if (entry.watcher) { try { entry.watcher.close(); } catch (_) {} } + viewerDirWatchers.delete(absDir); + } + } +} + +function broadcastDirChange(absDir: string): void { + const viewerPath = absToViewerPath(absDir); + if (!viewerPath) return; + const entry = viewerDirWatchers.get(absDir); + if (!entry) return; + for (const [ws, s] of browserSessions) { + if (s.user && entry.users.has(s.user) && ws.readyState === 1) + ws.send(JSON.stringify({ type: 'viewer_tree_changed', path: viewerPath })); + } +} + +function watchViewerPath(absPath: string, user: string): void { + if (!viewerWatchers.has(absPath)) { + viewerWatchers.set(absPath, { watcher: null, users: new Set() }); + attachFsWatcher(absPath); + } + viewerWatchers.get(absPath)!.users.add(user); +} + +function unwatchViewerUser(user: string): void { + for (const [absPath, entry] of viewerWatchers) { + entry.users.delete(user); + if (entry.users.size === 0) { + try { fs.unwatchFile(absPath); } catch (_) {} + viewerWatchers.delete(absPath); + } + } + unwatchDirUser(user); +} + +function broadcastViewerChange(absPath: string): void { + const viewerPath = absToViewerPath(absPath); + if (!viewerPath) return; + const entry = viewerWatchers.get(absPath); + if (!entry) return; + for (const [ws, s] of browserSessions) { + if (s.user && entry.users.has(s.user) && ws.readyState === 1) + ws.send(JSON.stringify({ type: 'viewer_file_changed', path: viewerPath })); + } +} + +// --------------------------------------------------------------------------- +// Session state + helpers +// --------------------------------------------------------------------------- + +const browserSessions = new Map(); + +/** Broadcast a JSON message to all authenticated browser WS sessions */ +export function broadcastToBrowsers(data: Record) { + const payload = JSON.stringify(data); + for (const [ws, s] of browserSessions) + if (s.user && ws.readyState === 1) ws.send(payload); +} +setBroadcastFn(broadcastToBrowsers); + +// Register system-access WS notifier +setNotifyFn((req: PendingRequest) => { + broadcastToBrowsers({ type: 'system_access_request', ...req }); +}); + +// Shared SMs — one SM per sessionKey, shared across all WS connections for same user+agent +const sharedSMs = new Map(); +// Shared watchers — one watcher per sessionKey, broadcasts to all connections +const sharedWatchers = new Map; connections: number }>(); + +// Dev takeover — maps token → { ws, pending eval resolvers } +const TAKEOVER_TOKEN_FILE = path.join(import.meta.dir, '.takeover-tokens.json'); +const takeoverTokens = new Map void; timer: ReturnType }> }>(); + +function persistTakeoverTokens() { + try { + const obj: Record = {}; + for (const [tok] of takeoverTokens) obj[tok] = { createdAt: Date.now() }; + fs.writeFileSync(TAKEOVER_TOKEN_FILE, JSON.stringify(obj)); + } catch (e: any) { console.error('Failed to persist takeover tokens:', e.message); } +} + +// Load persisted tokens (ws=null until browser reconnects) +try { + const raw = JSON.parse(fs.readFileSync(TAKEOVER_TOKEN_FILE, 'utf8')); + for (const tok of Object.keys(raw)) { + takeoverTokens.set(tok, { ws: null as any, pending: new Map() }); + } + if (takeoverTokens.size > 0) console.log(`Loaded ${takeoverTokens.size} takeover token(s) from disk.`); +} catch { /* no file yet */ } + +function getOrCreateSharedSM(sessionKey: string, initial?: ChannelState): StateMachine { + if (!sharedSMs.has(sessionKey)) { + const sm = createChannelSM(sessionKey, (event: Record) => { + // SM timeout from AGENT_RUNNING — clear activeTurnId so next /new isn't blocked + if (event.reason === 'timeout' && event.prev === 'AGENT_RUNNING') { + handleTurnDone(sessionKey); + } + // Broadcast channel state to all users on this session + const st = getSessionState(sessionKey); + broadcastToSession(sessionKey, { ...event, handoverActive: st.handoverActive }); + }, initial ?? 'NO_SESSION'); + sharedSMs.set(sessionKey, { sm, connections: 0 }); + } + const entry = sharedSMs.get(sessionKey)!; + entry.connections++; + return entry.sm; +} + +function releaseSharedSM(sessionKey: string): void { + const entry = sharedSMs.get(sessionKey); + if (!entry) return; + entry.connections--; + if (entry.connections <= 0) sharedSMs.delete(sessionKey); +} +setBrowserSessions(browserSessions); + +// Clear activeTurnId immediately when gateway reports turn done (prevents reconnect → stuck AGENT_RUNNING) +function handleTurnDone(sessionKey: string) { + const st = sessionState.get(sessionKey); + if (!st) return; + st.lastDoneTurnId = st.activeTurnId; + st.lastDoneTurnTs = Date.now(); + st.activeTurnId = null; + st.lastIdleAt = Date.now(); + const sharedSm = sharedSMs.get(sessionKey); + if (sharedSm?.sm.get() === 'AGENT_RUNNING') { + transitionChannel(sessionKey, 'READY', { reason: 'agent_done' }); + } +} +setOnTurnDone(handleTurnDone); + +// On gateway disconnect, abort all sessions with an active turn — onTurnDone will never arrive +setOnGatewayDisconnect(() => { + for (const [sessionKey, st] of sessionState) { + if (st.activeTurnId) { + console.warn(`[gateway-disconnect] aborting in-flight turn for ${sessionKey}`); + handleTurnDone(sessionKey); + } + } +}); + +const sessionState = new Map(); + +function getSessionState(sessionKey: string): SessionStateEntry { + if (!sessionState.has(sessionKey)) + sessionState.set(sessionKey, { handoverActive: false, lastIdleAt: 0, activeTurnId: null, activeTurnTs: 0, lastDoneTurnId: null, lastDoneTurnTs: 0, promptInjected: false }); + return sessionState.get(sessionKey)!; +} + +function broadcastToSession(sessionKey: string, data: object): void { + const payload = JSON.stringify(data); + for (const [ws, s] of browserSessions) + if (s.sessionKey === sessionKey && ws.readyState === 1) ws.send(payload); +} + +// Channel SM transition — broadcasts to all users on the session. +// The channel SM's onStateChange callback handles broadcasting automatically. +function transitionChannel(sessionKey: string, newState: ChannelState, extra: Record = {}): void { + const shared = sharedSMs.get(sessionKey); + if (shared) shared.sm.transition(newState, { reason: extra.reason ?? newState, ...extra }); +} + +// Connection SM transition — sends to one user only. +function transitionConn(ws: any, session: BrowserSession, newState: ConnectionState, extra: Record = {}): void { + session.connSm.transition(newState, { reason: extra.reason ?? newState, ...extra }); + // Send connection state directly to this user + send(ws, { type: 'connection_state', state: newState, ...extra }); +} + +// Legacy compatibility — used during migration, maps old IDLE calls to new states +function transitionSM(sessionKey: string, newState: string, extra: Record = {}): void { + transitionChannel(sessionKey, newState as ChannelState, extra); +} + +function send(ws: any, data: object): void { + if (ws.readyState === 1) ws.send(JSON.stringify(data)); +} + +function readHandoverMd(agentId: string): string | null { + const p = path.join('/home/openclaw/.openclaw', `workspace-${agentId}`, 'HANDOVER.md'); + try { return fs.readFileSync(p, 'utf8').trim() || null; } catch (_) { return null; } +} + +// --------------------------------------------------------------------------- +// Pending messages (dedup across tabs) +// --------------------------------------------------------------------------- + +const pendingMsgs = new Map>(); + +function trackPendingMsg(sessionKey: string, msgId: string, ws: any, content: string): void { + if (!pendingMsgs.has(sessionKey)) pendingMsgs.set(sessionKey, new Map()); + pendingMsgs.get(sessionKey)!.set(msgId, { ws, content, ts: Date.now() }); +} + +function resolvePendingMsg(sessionKey: string, contentOrMsgId: string): { msgId: string; originWs: any } | null { + const map = pendingMsgs.get(sessionKey); + if (!map) return null; + const now = Date.now(); + // Try exact msgId match first + const byId = map.get(contentOrMsgId); + if (byId && now - byId.ts < 30000) { map.delete(contentOrMsgId); return { msgId: contentOrMsgId, originWs: byId.ws }; } + // Fall back to content match (backward compat, 2s window) + for (const [msgId, entry] of map) { + if (entry.content === contentOrMsgId && now - entry.ts < 2000) { map.delete(msgId); return { msgId, originWs: entry.ws }; } + } + return null; +} + +// --------------------------------------------------------------------------- +// OpenRouter stats cache +// --------------------------------------------------------------------------- + +let cachedStats: any = null; +let lastStatsFetch = 0; +const STATS_TTL = 60_000; + +async function fetchOpenRouterStats(): Promise { + if (cachedStats && Date.now() - lastStatsFetch < STATS_TTL) return cachedStats; + let apiKey: string | null = null; + try { + const cfg = JSON.parse(fs.readFileSync('/home/openclaw/.openclaw/openclaw.json', 'utf8')); + apiKey = cfg.env?.OPENROUTER_API_KEY || null; + } catch (_) {} + if (!apiKey) return { error: 'No OpenRouter API key configured' }; + + const orGet = async (p: string) => { + const res = await fetch(`https://openrouter.ai${p}`, { + headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, + }); + return res.json(); + }; + + const [creditsData, modelsData] = await Promise.all([orGet('/api/v1/credits'), orGet('/api/v1/models')]); + const modelMap: Record = {}; + for (const m of (modelsData.data || [])) + modelMap[m.id] = { name: m.name, contextLength: m.context_length, promptPrice: parseFloat(m.pricing?.prompt || 0) * 1_000_000, completionPrice: parseFloat(m.pricing?.completion || 0) * 1_000_000 }; + + const agents = getAgentListWithModels().map((a: any) => { + const modelId = a.modelFull?.replace(/^openrouter\//, '') ?? null; + const info = modelId ? modelMap[modelId] : null; + return { ...a, modelId, modelName: info?.name || a.model || '?', contextLength: info?.contextLength ?? null, promptPrice: info?.promptPrice ?? null, completionPrice: info?.completionPrice ?? null }; + }); + const total = creditsData.data?.total_credits ?? creditsData.total_credits ?? null; + const used = creditsData.data?.total_usage ?? creditsData.total_usage ?? null; + cachedStats = { credits: { total, used, remaining: total != null && used != null ? +(total - used).toFixed(4) : null }, agents }; + lastStatsFetch = Date.now(); + return cachedStats; +} + +async function makeReady(user: string, sessionKey: string): Promise { + let agents = getAgentListWithModels(user); + try { + const stats = await fetchOpenRouterStats(); + if (stats && !stats.error && Array.isArray(stats.agents)) { + // Merge pricing from stats into agents (preserve segment/role from getAgentListWithModels) + agents = agents.map(a => { + const s = stats.agents.find((sa: any) => sa.id === a.id); + return s ? { ...a, promptPrice: s.promptPrice, completionPrice: s.completionPrice, contextLength: s.contextLength } : a; + }); + } + } catch (e: any) { console.error('[makeReady]', e.message); } + return { + type: 'ready', sessionId: sessionKey, user, version: VERSION, + brandName: BRAND_NAME, + defaultAgent: (userDefaultAgent as any)[user] ?? 'titan', + allowedAgents: (userAllowedAgents as any)[user] ?? [], + agents, + }; +} + +// --------------------------------------------------------------------------- +// Session watcher attachment +// --------------------------------------------------------------------------- + +function attachWatcher(ws: any, session: BrowserSession, sessionKey: string): void { + // Detach previous watcher if switching sessions + detachWatcher(session); + + // Reuse existing shared watcher for this sessionKey if one exists + if (sharedWatchers.has(sessionKey)) { + const existing = sharedWatchers.get(sessionKey)!; + existing.connections++; + session.watcher = existing.watcher; + // Re-send history directly to this new connection (watcher won't replay it) + const historyCount = existing.watcher.sendHistoryTo((data: object) => send(ws, data)); + // Set channel state based on history (if not already set by active turn) + const channelNow = session.sm.get(); + if (channelNow === 'NO_SESSION' || channelNow === 'RESETTING') { + session.sm.transition(historyCount > 0 ? 'READY' : 'FRESH', { reason: 'history_loaded' }); + } + // Connection: history done, synced + transitionConn(ws, session, 'SYNCED', { reason: 'history_complete', historyCount }); + return; + } + + const agentId = sessionKey.split(':')[1]; + let lastFloorProjection = 0; + + const watcher = createSessionWatcher(sessionKey, agentId, async (entry: any) => { + // Handover resolve — find the session that owns it + if (entry.entry_type === 'assistant_text') { + for (const [, s] of browserSessions) + if (s.sessionKey === sessionKey && s._handoverResolve) { s._handoverResolve(); break; } + } + + // SM: agent_done transition (shared SM — only need to do once) + if (entry.entry_type === 'assistant_text') { + const sharedSm = sharedSMs.get(sessionKey); + if (sharedSm?.sm.get() === 'AGENT_RUNNING') { + const st = getSessionState(sessionKey); + const entryTs = entry.ts ? new Date(entry.ts).getTime() : 0; + if (!(entryTs > 0 && entryTs < st.activeTurnTs - 1000)) { + st.lastDoneTurnId = st.activeTurnId; + st.lastDoneTurnTs = Date.now(); + st.activeTurnId = null; + sharedSm.sm.transition('READY', { reason: 'agent_done' }); + st.lastIdleAt = Date.now(); + } + } + } + + if (entry.entry_type === 'usage') { + try { + const stats = await fetchOpenRouterStats(); + const agent = stats.agents?.find((a: any) => a.id === agentId); + if (agent?.promptPrice != null && agent?.completionPrice != null) { + const inn = entry.input_tokens || 0; + const out = entry.output_tokens || 0; + const cost = (inn / 1_000_000) * agent.promptPrice + (out / 1_000_000) * agent.completionPrice; + const nextFloor = (entry.total_tokens / 1_000_000) * agent.promptPrice * 0.1; + const projectionDelta = lastFloorProjection > 0 ? cost - lastFloorProjection : 0; + lastFloorProjection = nextFloor; + broadcastToSession(sessionKey, { type: 'finance_update', sessionKey, nextTurnFloor: nextFloor, projectionDelta, currentContextTokens: entry.total_tokens, lastTurnCost: cost, pricing: { prompt: agent.promptPrice, completion: agent.completionPrice } }); + } + } catch (e: any) { console.warn(`[${sessionKey}] Finance:`, e.message); } + } + + if (entry.type === 'session_entry' && entry.entry_type === 'tool_call') { + broadcastToSession(sessionKey, { type: 'tool', action: 'call', tool: entry.tool, args: filterValue(entry.args) }); return; + } + if (entry.type === 'session_entry' && entry.entry_type === 'tool_result') { + const raw = filterValue(entry.result) || ''; + const first = raw.split('\n')[0].trim(); + broadcastToSession(sessionKey, { type: 'tool', action: 'result', tool: entry.tool, result: first.length > 80 ? first.slice(0, 77) + '…' : first }); return; + } + if (entry.type === 'session_entry' && entry.entry_type === 'assistant_text') { + const st = getSessionState(sessionKey); + const entryTs = entry.ts ? new Date(entry.ts).getTime() : 0; + if (st.lastDoneTurnTs && entryTs <= st.lastDoneTurnTs + 500) return; + } + if (entry.type === 'session_entry' && entry.entry_type === 'user_message') { + const resolved = resolvePendingMsg(sessionKey, entry.content); + if (resolved) { + const payload = JSON.stringify({ ...entry, msgId: resolved.msgId }); + for (const [otherWs, s] of browserSessions) + if (s.sessionKey === sessionKey && otherWs !== resolved.originWs && otherWs.readyState === 1) otherWs.send(payload); + } else { broadcastToSession(sessionKey, entry); } + return; + } + // Intercept session_status to drive SM transitions + if ((entry as any).type === 'session_status') { + if ((entry as any).status === 'no_session') { + const sm = sharedSMs.get(sessionKey); + const cur = sm?.sm.get(); + // Don't override RESETTING — that's a deliberate new session in progress + if (cur && cur !== 'NO_SESSION' && cur !== 'RESETTING') sm?.sm.transition('NO_SESSION', { reason: (entry as any).reason || 'no_session' }); + // Also transition connections to SYNCED — no history to load + for (const [otherWs, s] of browserSessions) { + if (s.sessionKey === sessionKey && s.connSm.get() === 'LOADING_HISTORY') { + transitionConn(otherWs, s, 'SYNCED', { reason: 'no_session' }); + } + } + } else if ((entry as any).status === 'watching') { + const sm = sharedSMs.get(sessionKey); + const watchCur = sm?.sm.get(); + if (watchCur === 'NO_SESSION' || watchCur === 'RESETTING') { + const count = (entry as any).entries || 0; + sm?.sm.transition(count > 0 ? 'READY' : 'FRESH', { reason: 'session_found' }); + } + // Transition all connections on this channel to SYNCED + for (const [otherWs, s] of browserSessions) { + if (s.sessionKey === sessionKey && s.connSm.get() === 'LOADING_HISTORY') { + transitionConn(otherWs, s, 'SYNCED', { reason: 'history_complete' }); + } + } + } + } + broadcastToSession(sessionKey, entry); + }, (data: object) => broadcastToSession(sessionKey, data), gatewayRequest); + + sharedWatchers.set(sessionKey, { watcher, connections: 1 }); + session.watcher = watcher; + watcher.start(); +} + +function detachWatcher(session: BrowserSession): void { + if (!session.watcher || !session.sessionKey) return; + const entry = sharedWatchers.get(session.sessionKey); + if (!entry) return; + entry.connections--; + if (entry.connections <= 0) { + entry.watcher.stop(); + sharedWatchers.delete(session.sessionKey); + } + session.watcher = null; +} + +// --------------------------------------------------------------------------- +// WebSocket message handlers +// --------------------------------------------------------------------------- + +async function handleConnect(ws: any, session: BrowserSession, msg: any): Promise { + const user = msg.user; + if (!getTokenForUser(user)) { ws.close(4001, 'Invalid user'); return; } + const agentId = msg.agent; + if (!agentId) { + Object.assign(session, { user }); + send(ws, await makeReady(user, '')); + return; + } + const mode = msg.mode ?? 'private'; + const publicKey = `agent:${agentId}:web:public`; + const sessionKey = mode === 'public' ? publicKey : `agent:${agentId}:web:${user}`; + const sharedSm = getOrCreateSharedSM(sessionKey); + Object.assign(session, { user, sessionKey, sm: sharedSm }); + send(ws, await makeReady(user, sessionKey)); + const st = getSessionState(sessionKey); + send(ws, hud.received('reconnect', 'reconnected -- replaying history', { sessionKey, agent: agentId })); + // Channel state: if agent is mid-turn, channel is AGENT_RUNNING + if (st.activeTurnId && sharedSm.get() !== 'AGENT_RUNNING') { + session.turnId = st.activeTurnId; + sharedSm.transition('AGENT_RUNNING', { reason: 'reconnect' }); + } + // Connection: start loading history + transitionConn(ws, session, 'LOADING_HISTORY', { reason: 'connect' }); + // Send current channel state to this client + send(ws, { type: 'channel_state', state: sharedSm.get(), handoverActive: st.handoverActive, turnId: st.activeTurnId || undefined }); + const hc = readHandoverMd(agentId); + if (hc) send(ws, { type: 'handover_context', content: hc }); + attachWatcher(ws, session, sessionKey); + // History sent by attachWatcher → sendHistoryTo; after that we transition to SYNCED +} + +async function handleAuth(ws: any, session: BrowserSession, msg: any): Promise { + const user = getUserForSessionToken(msg.token) || getUserForToken(msg.token); + if (!user) { ws.close(4001, 'Invalid token'); return; } + const agentId = msg.agent; + if (!agentId) { + // No agent — send ready with agent list, user will pick from sidebar + Object.assign(session, { user }); + send(ws, await makeReady(user, '')); + return; + } + const mode = msg.mode ?? 'private'; + const publicKey = `agent:${agentId}:web:public`; + const sessionKey = mode === 'public' ? publicKey : `agent:${agentId}:web:${user}`; + const sharedSm = getOrCreateSharedSM(sessionKey); + Object.assign(session, { user, sessionKey, sm: sharedSm }); + send(ws, await makeReady(user, sessionKey)); + const st = getSessionState(sessionKey); + // Channel state: if agent is mid-turn, channel is AGENT_RUNNING + if (st.activeTurnId && sharedSm.get() !== 'AGENT_RUNNING') { + session.turnId = st.activeTurnId; + sharedSm.transition('AGENT_RUNNING', { reason: 'reconnect' }); + } + // Connection: start loading history + transitionConn(ws, session, 'LOADING_HISTORY', { reason: 'auth' }); + // Send current channel state to this client + send(ws, { type: 'channel_state', state: sharedSm.get(), handoverActive: st.handoverActive, turnId: st.activeTurnId || undefined }); + const hc = readHandoverMd(agentId); + if (hc) send(ws, { type: 'handover_context', content: hc }); + attachWatcher(ws, session, sessionKey); + // History sent by attachWatcher → sendHistoryTo; after that we transition to SYNCED +} + +async function handleMessage(ws: any, session: BrowserSession, msg: any): Promise { + if (!session.sessionKey) { send(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Send auth first' }); return; } + const state = session.sm.get(); + if (state !== 'READY' && state !== 'FRESH') { + const code = (state === 'HANDOVER_DONE') ? 'SESSION_TERMINATED' : 'DISCARDED_NOT_READY'; + console.warn(`[handleMessage] DISCARDED code=${code} state=${state} sessionKey=${session.sessionKey} clientId=${session.clientId}`); + send(ws, { type: 'error', code, message: `Message discarded: agent state is ${state}` }); return; + } + const msgId = msg.msgId || crypto.randomUUID(); + trackPendingMsg(session.sessionKey, msgId, ws, msg.content); + const turnId = crypto.randomUUID(); + const st = getSessionState(session.sessionKey); + st.activeTurnId = turnId; st.activeTurnTs = Date.now(); session.turnId = turnId; + session.sm.transition('AGENT_RUNNING', { reason: 'message' }); + + // Inject session context prompt on first message if not yet done + let messageToSend = msg.content; + if (!st.promptInjected) { + const agentId = session.sessionKey.split(':')[1]; + const sessionPrompt = getSessionPrompt(agentId, session.sessionKey, session.user!); + messageToSend = `${sessionPrompt}\n\n${msg.content}`; + st.promptInjected = true; + } + + // Pass attachments through to gateway if present + const rawAttachments = Array.isArray(msg.attachments) ? msg.attachments.filter( + (a: any) => typeof a?.content === 'string' && typeof a?.mimeType === 'string' + ) : []; + + // Split attachments by type — all go as native content blocks to Sonnet + const imageAttachments = rawAttachments.filter((a: any) => a.mimeType?.startsWith('image/')); + const audioAttachments = rawAttachments.filter((a: any) => a.mimeType?.startsWith('audio/')); + const pdfAttachments = rawAttachments.filter((a: any) => a.mimeType === 'application/pdf'); + const otherAttachments = rawAttachments.filter((a: any) => + !a.mimeType?.startsWith('image/') && !a.mimeType?.startsWith('audio/') && a.mimeType !== 'application/pdf'); + + // Save audio + PDFs + other files to disk (for playback/download in chat) + const savedFilePaths: string[] = []; + const filesToSave = [ + ...audioAttachments.map((a: any) => ({ ...a, label: 'audio' })), + ...pdfAttachments.map((a: any) => ({ ...a, label: 'document' })), + ...otherAttachments.map((a: any) => ({ ...a, label: 'file' })), + ]; + for (const doc of filesToSave) { + try { + const AUDIO_EXT: Record = { 'audio/webm': 'webm', 'audio/mp4': 'm4a', 'audio/ogg': 'ogg', 'audio/mpeg': 'mp3', 'audio/wav': 'wav' }; + const ext = AUDIO_EXT[doc.mimeType] || (doc.mimeType === 'application/pdf' ? 'pdf' : 'bin'); + const safeName = (doc.fileName || `${doc.label}.${ext}`).replace(/[^a-zA-Z0-9._-]/g, '_'); + const dir = '/home/openclaw/.openclaw/workspace-titan/media/inbound'; + const { mkdirSync } = await import('fs'); + mkdirSync(dir, { recursive: true }); + const filePath = `${dir}/${Date.now()}-${safeName}`; + const buffer = Buffer.from(doc.content, 'base64'); + await Bun.write(filePath, buffer); + savedFilePaths.push(filePath); + console.log(`[handleMessage] saved ${doc.label}: ${filePath} (${buffer.length} bytes)`); + } catch (err: any) { + console.error(`[handleMessage] failed to save ${doc.label}: ${err.message}`); + } + } + + // Transcribe audio via ElevenLabs STT — append transcript to message + const audioPaths = savedFilePaths.slice(0, audioAttachments.length); + for (let ai = 0; ai < audioAttachments.length; ai++) { + const audio = audioAttachments[ai]; + const audioPath = audioPaths[ai]; + try { + const apiKey = process.env.ELEVENLABS_API_KEY; + if (!apiKey) { console.warn('[stt] ELEVENLABS_API_KEY not set, skipping transcription'); continue; } + + send(ws, { type: 'hud', event: 'think_start', content: 'Transcribing audio...' }); + console.log(`[stt] transcribing ${audio.fileName} (${audio.content.length} b64 chars)`); + + const buffer = Buffer.from(audio.content, 'base64'); + const mimeBase = audio.mimeType.split(';')[0] || 'audio/webm'; + const blob = new Blob([buffer], { type: mimeBase }); + const form = new FormData(); + form.append('model_id', 'scribe_v2'); + form.append('file', blob, audio.fileName || 'recording.webm'); + + const sttRes = await fetch('https://api.elevenlabs.io/v1/speech-to-text', { + method: 'POST', + headers: { 'xi-api-key': apiKey }, + body: form, + }); + + if (!sttRes.ok) { + const errText = await sttRes.text(); + console.error(`[stt] ElevenLabs error ${sttRes.status}: ${errText}`); + send(ws, { type: 'hud', event: 'think_end' }); + continue; + } + + const sttData = await sttRes.json() as { text?: string; language_code?: string }; + const transcript = sttData.text?.trim(); + console.log(`[stt] transcript (${sttData.language_code}): ${transcript?.slice(0, 200)}`); + send(ws, { type: 'hud', event: 'think_end' }); + + // Always patch — clear pending state even if transcript is empty + broadcastToSession(session.sessionKey, { + type: 'message_update', + msgId, + patch: { + content: transcript || '(no speech detected)', + voiceAudioUrl: audioPath ? `/api/files${audioPath}` : null, + pending: false, + transcript: transcript || '', + }, + }); + if (transcript) { + const userText = messageToSend.trim(); + messageToSend = userText + ? `${userText}\n\n[voice transcript]: ${transcript}` + : `[voice transcript]: ${transcript}`; + } + } catch (err: any) { + console.error(`[stt] transcription failed: ${err.message}`); + send(ws, { type: 'hud', event: 'think_end' }); + } + } + + // Append disk paths for PDFs + other files so the agent can access them via tools + const nonAudioPaths = savedFilePaths.slice(audioAttachments.length); + if (nonAudioPaths.length > 0) { + const pathList = nonAudioPaths.map(p => `[attached file: ${p}]`).join('\n'); + messageToSend = `${messageToSend}\n\n${pathList}`; + } + + // Send images as native content blocks (gateway supports images only for now) + const attachments = [...imageAttachments]; + if (attachments.length) { + console.log(`[handleMessage] ${attachments.length} native attachment(s): ${attachments.map((a: any) => `type=${a.type} mime=${a.mimeType} file=${a.fileName} b64len=${a.content.length}`).join(', ')}`); + } else { + console.log(`[handleMessage] no attachments`); + } + + try { + const params: Record = { sessionKey: session.sessionKey, message: messageToSend, idempotencyKey: msgId }; + if (attachments?.length) params.attachments = attachments; + const gwRes = await gatewayRequest('chat.send', params); + console.log(`[handleMessage] chat.send response: ${JSON.stringify(gwRes)}`); + send(ws, { type: 'sent', msgId, turnId }); + } catch (err: any) { + console.error('[handleMessage] chat.send failed:', err.message, err); + session.sm.transition('READY', { reason: 'send_error' }); + send(ws, { type: 'error', code: 'SEND_ERROR', message: err.message }); + } +} + +async function handleStopKill(ws: any, session: BrowserSession, type: 'stop' | 'kill'): Promise { + if (!session.sessionKey) { send(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Send auth first' }); return; } + try { + send(ws, hud.received(type === 'kill' ? 'kill' : 'stop', + type === 'kill' ? 'kill received — terminating turn' : 'stop received — aborting turn', + { state: session.sm.get() })); + await gatewayRequest(type === 'kill' ? 'sessions.kill' : 'sessions.stop', { sessionKey: session.sessionKey }); + transitionChannel(session.sessionKey, 'READY', { reason: type }); + broadcastToSession(session.sessionKey, { type: type === 'kill' ? 'killed' : 'stopped' }); + } catch (err: any) { send(ws, { type: 'error', code: 'STOP_FAILED', message: err.message }); } +} + +async function handleSwitchAgent(ws: any, session: BrowserSession, msg: any): Promise { + if (!session.user) { send(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Send auth first' }); return; } + // Use per-WS flag to guard against double-switch (avoid touching shared SM) + if ((session as any)._switching) { send(ws, { type: 'error', code: 'SESSION_TERMINATED', message: 'Already switching' }); return; } + const newAgentId = msg.agent; + if (!newAgentId) { send(ws, { type: 'error', code: 'BAD_REQUEST', message: 'agent required' }); return; } + const allowed = (userAllowedAgents as any)[session.user] ?? []; + if (!allowed.includes(newAgentId)) { send(ws, { type: 'error', code: 'FORBIDDEN', message: `Agent ${newAgentId} not allowed` }); return; } + const switchMode = msg.mode ?? 'private'; + const publicKey = `agent:${newAgentId}:web:public`; + const newKey = switchMode === 'public' ? publicKey : `agent:${newAgentId}:web:${session.user}`; + if (newKey === session.sessionKey) { send(ws, { type: 'switch_ok', agent: newAgentId, sessionKey: newKey }); return; } + const prevAgentId = session.sessionKey?.split(':')[1] || null; + (session as any)._switching = true; + // Connection SM: SYNCED → SWITCHING + transitionConn(ws, session, 'SWITCHING', { reason: 'agent_switch' }); + send(ws, hud.received('agent_switch', `switch -> ${newAgentId}`, { from: prevAgentId, to: newAgentId })); + // Release old SM without broadcasting — switch is per-WS, not shared state + releaseSharedSM(session.sessionKey!); + session.sessionKey = newKey; + // Attach to new agent's shared SM (unicast state to this WS only) + const newSharedSm = getOrCreateSharedSM(newKey); + session.sm = newSharedSm; + (session as any)._switching = false; + // Connection: SWITCHING → LOADING_HISTORY (attachWatcher will send SYNCED when done) + transitionConn(ws, session, 'LOADING_HISTORY', { reason: 'switch' }); + // Send switch_ok + current channel state BEFORE attachWatcher + send(ws, { type: 'switch_ok', agent: newAgentId, sessionKey: newKey }); + const newSt = getSessionState(newKey); + send(ws, { type: 'channel_state', state: newSharedSm.get(), clear_history: true, handoverActive: newSt.handoverActive }); + const hc = readHandoverMd(newAgentId); + if (hc) send(ws, { type: 'handover_context', content: hc }); + attachWatcher(ws, session, newKey); +} + +async function handleHandoverRequest(ws: any, session: BrowserSession): Promise { + if (!session.sessionKey) { send(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Send auth first' }); return; } + if (session.sessionKey.includes(':web:public')) { + send(ws, { type: 'error', code: 'HANDOVER_NOT_ALLOWED', message: 'Handover not available in public mode' }); return; + } + const cs = session.sm.get(); + if (cs === 'SWITCHING' || cs === 'HANDOVER_DONE') { send(ws, { type: 'error', code: 'SESSION_TERMINATED', message: 'Handover request discarded' }); return; } + const sk = session.sessionKey; + const agentId = sk.split(':')[1]; + const st = getSessionState(sk); + if (st.handoverActive) { send(ws, { type: 'error', code: 'HANDOVER_IN_PROGRESS', message: 'Handover already in progress' }); return; } + st.handoverActive = true; + transitionChannel(sk, 'HANDOVER_PENDING', { reason: 'handover_request' }); + try { + await gatewayRequest('chat.send', { sessionKey: sk, message: 'Write HANDOVER.md to your workspace. Capture whatever a fresh version of you would need to pick up without losing context. Keep it brief. Reply when done.', idempotencyKey: crypto.randomUUID() }); + await new Promise((resolve) => { + const timer = setTimeout(() => { session._handoverResolve = null; resolve(); }, 45000); + session._handoverResolve = () => { clearTimeout(timer); session._handoverResolve = null; resolve(); }; + }); + const content = readHandoverMd(agentId); + st.handoverActive = false; + transitionChannel(sk, 'HANDOVER_DONE', { reason: 'handover_written' }); + broadcastToSession(sk, { type: 'handover_done', content }); + } catch (err: any) { + st.handoverActive = false; + transitionChannel(sk, 'READY', { reason: 'handover_error' }); + send(ws, { type: 'error', code: 'HANDOVER_ERROR', message: err.message }); + } +} + +async function handleNew(ws: any, session: BrowserSession, withHandover: boolean): Promise { + if (!session.sessionKey) { send(ws, { type: 'error', code: 'NOT_AUTHENTICATED', message: 'Send auth first' }); return; } + const cs = session.sm.get(); + if (cs === 'SWITCHING') { send(ws, { type: 'error', code: 'SESSION_TERMINATED', message: 'Reset already in progress' }); return; } + if (cs === 'HANDOVER_PENDING') { send(ws, { type: 'error', code: 'SESSION_TERMINATED', message: 'Handover in progress, please wait...' }); return; } + const st = getSessionState(session.sessionKey); + if ((cs === 'READY' || cs === 'FRESH') && Date.now() - st.lastIdleAt < 2000) { send(ws, { type: 'error', code: 'SESSION_TERMINATED', message: 'Session reset ready, wait a moment...' }); return; } + + const agentId = session.sessionKey.split(':')[1]; + const sessionPrompt = getSessionPrompt(agentId, session.sessionKey, session.user!); + const GREETING = `${sessionPrompt}\n\nYou're starting fresh. Read HANDOVER.md in your workspace if it exists for your own context, then greet appropriately based on the session context above. Briefly mention what you were last working on and ask what to do next.`; + + const doReset = async () => { + send(ws, hud.received('new_session', '/new received — resetting session', + { previousAgent: session.sessionKey?.split(':')[1] || null, previousSessionKey: session.sessionKey })); + transitionChannel(session.sessionKey!, 'RESETTING', { reason: 'new' }); + await gatewayRequest('chat.send', { sessionKey: session.sessionKey!, message: `/new\n\n${GREETING}`, idempotencyKey: crypto.randomUUID() }); + st.promptInjected = true; + detachWatcher(session); + setTimeout(() => { + if (ws.readyState !== 1) return; + attachWatcher(ws, session, session.sessionKey!); + session.sm.transition('AGENT_RUNNING', { reason: 'new_greeting' }); + setTimeout(() => { + if (session.sm.get() === 'AGENT_RUNNING') { session.sm.transition('READY', { reason: 'new_greeting_done' }); getSessionState(session.sessionKey!).lastIdleAt = Date.now(); } + }, 30000); + }, 1000); + broadcastToSession(session.sessionKey!, { type: 'new_ok' }); + }; + + try { + if (withHandover) { + st.handoverActive = true; + broadcastToSession(session.sessionKey, { type: 'handover_writing' }); + await gatewayRequest('chat.send', { sessionKey: session.sessionKey, message: 'Write HANDOVER.md to your workspace. Capture whatever a fresh version of you would need to pick up without losing context. Keep it brief. Reply when done.', idempotencyKey: crypto.randomUUID() }); + await new Promise((resolve) => { + const timer = setTimeout(() => resolve(), 30000); + session._handoverHandler = (ev: string, payload: any) => { + if (ev === 'chat' && payload.state === 'final') { clearTimeout(timer); session._handoverHandler = null; resolve(); } + }; + }); + const agentId = session.sessionKey.split(':')[1]; + let handoverContent: string | null = null; + try { handoverContent = fs.readFileSync(`/home/openclaw/.openclaw/workspace-${agentId}/HANDOVER.md`, 'utf8').trim(); } catch (_) {} + st.handoverActive = false; + broadcastToSession(session.sessionKey, { type: 'handover_done', content: handoverContent }); + } + await doReset(); + } catch (err: any) { send(ws, { type: 'error', code: 'RESET_ERROR', message: err.message }); } +} + +// --------------------------------------------------------------------------- +// HTTP route handlers +// --------------------------------------------------------------------------- + +const CORS: Record = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization', +}; + +function corsHeaders() { return new Headers(CORS); } +function jsonResp(data: unknown, status = 200): Response { + const h = corsHeaders(); h.set('Content-Type', 'application/json'); + return new Response(JSON.stringify(data), { status, headers: h }); +} +function textResp(text: string, status = 200): Response { + return new Response(text, { status, headers: corsHeaders() }); +} + +// Takeover command sender — used by MCP bridge +async function executeTakeoverCmd(token: string, cmd: string, args: any, timeoutMs = 10000): Promise { + const entry = takeoverTokens.get(token); + if (!entry) throw new Error('invalid or expired token'); + if (!entry.ws) throw new Error('token registered but browser not connected — reload /dev in browser'); + const cmdId = Math.random().toString(36).slice(2, 10); + const clampedTimeout = Math.min(Math.max(timeoutMs, 1000), 60000); + return new Promise((resolve) => { + const timer = setTimeout(() => { entry.pending.delete(cmdId); resolve({ error: `timeout (${clampedTimeout / 1000}s)` }); }, clampedTimeout); + entry.pending.set(cmdId, { resolve, timer }); + send(entry.ws, { type: 'dev_cmd', cmdId, cmd, args: args || {} }); + }); +} + +// MCP server setup — direct bridge to takeoverTokens (no HTTP self-call) +setupMcp({ sendCmd: executeTakeoverCmd }); + +async function handleHttp(req: Request): Promise { + const url = new URL(req.url); + const method = req.method; + + if (method === 'OPTIONS') return new Response(null, { status: 204, headers: corsHeaders() }); + + // MCP endpoint — streamable HTTP transport (auth via MCP API key in handleMcpRequest) + if (url.pathname === '/mcp') { + return handleMcpRequest(req); + } + + // File download — serve files from agent workspace + if (url.pathname.startsWith('/api/files/') && method === 'GET') { + const token = url.searchParams.get('token') || req.headers.get('authorization')?.replace('Bearer ', '') || ''; + const user = getUserForSessionToken(token); + if (!user) return jsonResp({ error: 'Unauthorized' }, 401); + const filePath = decodeURIComponent(url.pathname.slice('/api/files'.length)); + const WORKSPACE_ROOT = '/home/openclaw/.openclaw/workspace-titan/'; + const resolved = require('path').resolve(filePath); + if (!resolved.startsWith(WORKSPACE_ROOT)) return jsonResp({ error: 'Access denied' }, 403); + const file = Bun.file(resolved); + if (!await file.exists()) return jsonResp({ error: 'Not found' }, 404); + const name = require('path').basename(resolved); + return new Response(file, { + headers: { + 'Content-Type': file.type || 'application/octet-stream', + 'Content-Disposition': `attachment; filename="${name}"`, + }, + }); + } + + // TTS — text-to-speech via ElevenLabs + if (url.pathname === '/api/tts' && method === 'POST') { + const token = req.headers.get('authorization')?.replace('Bearer ', '') ?? ''; + const user = getUserForSessionToken(token); + if (!user) return jsonResp({ error: 'Unauthorized' }, 401); + try { + const body = await req.json() as { text?: string }; + const text = (body.text || '').trim(); + if (!text) return jsonResp({ error: 'text is required' }, 400); + if (text.length > 4096) return jsonResp({ error: 'text too long (max 4096 chars)' }, 400); + + const apiKey = process.env.ELEVENLABS_API_KEY; + if (!apiKey) return jsonResp({ error: 'TTS not configured' }, 503); + + const VOICE_ID = 'pMsXgVXv3BLzUgSXRplE'; // Daniel — warm, clear, multilingual + const MODEL_ID = 'eleven_multilingual_v2'; + + // Cache: hash text to reuse existing files + const { createHash } = await import('crypto'); + const hash = createHash('sha256').update(text).digest('hex').slice(0, 16); + const ttsDir = '/home/openclaw/.openclaw/workspace-titan/media/tts'; + const { mkdirSync } = await import('fs'); + mkdirSync(ttsDir, { recursive: true }); + const audioPath = `${ttsDir}/${hash}.mp3`; + + const file = Bun.file(audioPath); + if (await file.exists()) { + console.log(`[tts] cache hit: ${audioPath}`); + return jsonResp({ url: `/api/files${audioPath}` }); + } + + console.log(`[tts] generating: ${text.length} chars, voice=${VOICE_ID}`); + const ttsRes = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${VOICE_ID}`, { + method: 'POST', + headers: { + 'xi-api-key': apiKey, + 'Content-Type': 'application/json', + 'Accept': 'audio/mpeg', + }, + body: JSON.stringify({ + text, + model_id: MODEL_ID, + voice_settings: { stability: 0.5, similarity_boost: 0.75 }, + }), + }); + + if (!ttsRes.ok) { + const errText = await ttsRes.text(); + console.error(`[tts] ElevenLabs error ${ttsRes.status}: ${errText}`); + return jsonResp({ error: `TTS failed: ${ttsRes.status}` }, 502); + } + + const audioBuffer = Buffer.from(await ttsRes.arrayBuffer()); + await Bun.write(audioPath, audioBuffer); + console.log(`[tts] saved: ${audioPath} (${audioBuffer.length} bytes)`); + return jsonResp({ url: `/api/files${audioPath}` }); + } catch (err: any) { + console.error('[tts] error:', err.message); + return jsonResp({ error: err.message }, 500); + } + } + + // Health + if (url.pathname === '/health' && method === 'GET') + return jsonResp({ status: 'ok', version: VERSION }); + + // Agents + if (url.pathname === '/agents' && method === 'GET') { + const user = getUserForSessionToken(req.headers.get('authorization')?.replace('Bearer ', '') ?? ''); + if (!user) return jsonResp({ error: 'Unauthorized' }, 401); + try { return jsonResp(getAgentListWithModels()); } + catch (err: any) { return jsonResp({ error: err.message }, 500); } + } + + // Channel state for a single agent — private + public + if (url.pathname.startsWith('/api/channels/') && method === 'GET') { + const user = getUserForSessionToken(req.headers.get('authorization')?.replace('Bearer ', '') ?? ''); + if (!user) return jsonResp({ error: 'Unauthorized' }, 401); + const agentId = url.pathname.split('/')[3]; + if (!agentId) return jsonResp({ error: 'Missing agent ID' }, 400); + + function channelInfo(sessionKey: string): any { + // 1. Active SM — authoritative + const entry = sharedSMs.get(sessionKey); + if (entry) { + const st = sessionState.get(sessionKey); + return { + state: entry.sm.get(), + connections: entry.connections, + handoverActive: st?.handoverActive ?? false, + activeTurnId: st?.activeTurnId ?? null, + }; + } + // 2. Disk check — same logic as session-watcher.start() + return { state: checkSessionOnDisk(agentId, sessionKey), connections: 0 }; + } + + const privateKey = `agent:${agentId}:web:${user}`; + // Public web channel — always web:public, never :main (main = internal/heartbeats) + const publicKey = `agent:${agentId}:web:public`; + + return jsonResp({ + agent: agentId, + private: channelInfo(privateKey), + public: channelInfo(publicKey), + }); + } + + // Auth — login with static token + if (url.pathname === '/api/auth/nonce' && method === 'GET') { + return jsonResp({ nonce: issueAuthNonce() }); + } + + if (url.pathname === '/api/auth' && method === 'POST') { + try { + const { token: loginToken, nonce } = await req.json() as any; + if (!nonce || !consumeAuthNonce(nonce)) return jsonResp({ error: 'Missing or expired nonce' }, 400); + const user = getUserForToken(loginToken); + if (!user) return jsonResp({ error: 'Invalid token' }, 401); + return jsonResp({ sessionToken: issueSessionToken(user), user }); + } catch { return jsonResp({ error: 'Bad request' }, 400); } + } + + // Auth — verify OTP + if (url.pathname === '/api/auth/verify' && method === 'POST') { + try { + const { challengeId, otp } = await req.json() as any; + const user = verifyOtpChallenge(challengeId, String(otp)); + if (!user) return jsonResp({ error: 'Invalid or expired OTP' }, 401); + return jsonResp({ sessionToken: issueSessionToken(user), user }); + } catch { return jsonResp({ error: 'Bad request' }, 400); } + } + + // Auth — logout + if (url.pathname === '/api/auth/logout' && method === 'POST') { + try { + const { sessionToken } = await req.json() as any; + if (sessionToken) revokeSessionToken(sessionToken); + return jsonResp({ ok: true }); + } catch { return jsonResp({ error: 'Bad request' }, 400); } + } + + // System access — pending requests (for /dev UI) + if (url.pathname === '/api/system/pending' && method === 'GET') { + const user = getUserForSessionToken(req.headers.get('authorization')?.replace('Bearer ', '') ?? ''); + if (!user) return jsonResp({ error: 'Unauthorized' }, 401); + return jsonResp({ pending: getPendingRequests() }); + } + + // System access — approve + if (url.pathname === '/api/system/approve' && method === 'POST') { + try { + const { sessionToken, requestId } = await req.json() as any; + const user = getUserForSessionToken(sessionToken); + if (!user) return jsonResp({ error: 'Unauthorized' }, 401); + const systemToken = approveRequest(requestId, user); + if (!systemToken) return jsonResp({ error: 'Request not found or expired' }, 404); + return jsonResp({ ok: true }); + } catch { return jsonResp({ error: 'Bad request' }, 400); } + } + + // System access — deny + if (url.pathname === '/api/system/deny' && method === 'POST') { + try { + const { sessionToken, requestId } = await req.json() as any; + const user = getUserForSessionToken(sessionToken); + if (!user) return jsonResp({ error: 'Unauthorized' }, 401); + denyRequest(requestId); + return jsonResp({ ok: true }); + } catch { return jsonResp({ error: 'Bad request' }, 400); } + } + + // Dev broadcast — push state to browser via WS (used by MCP and direct HTTP) + if (url.pathname === '/api/dev/broadcast' && method === 'POST') { + const user = getUserForSessionToken(req.headers.get('authorization')?.replace('Bearer ', '') ?? ''); + if (!user) return jsonResp({ error: 'Unauthorized' }, 401); + try { + const payload = await req.json() as any; + if (!payload.type) return jsonResp({ error: 'type required' }, 400); + broadcastToBrowsers(payload); + return jsonResp({ ok: true }); + } catch { return jsonResp({ error: 'Bad request' }, 400); } + } + + // Dev counter — push event to MCP subscriber (requires authenticated session) + if (url.pathname === '/api/dev/counter' && method === 'POST') { + const user = getUserForSessionToken(req.headers.get('authorization')?.replace('Bearer ', '') ?? ''); + if (!user) return jsonResp({ error: 'Unauthorized' }, 401); + try { + const { action, pick } = await req.json() as any; + if (!['increment', 'decrement', 'timeout', 'pick'].includes(action)) return jsonResp({ error: 'Invalid action' }, 400); + // Push to ALL active MCP keys (broadcast) + pushEventAll({ type: 'counter', data: { action, ...(pick ? { pick } : {}) } }); + return jsonResp({ ok: true }); + } catch { return jsonResp({ error: 'Bad request' }, 400); } + } + + // DB query proxy — execute SQL on local MariaDB (read-only) + if (url.pathname === '/api/db/query' && method === 'POST') { + try { + const { query, database } = await req.json() as any; + if (!query || !database) return jsonResp({ error: 'query and database required' }, 400); + // Safety: only allow SELECT and DESCRIBE/SHOW + const trimmed = query.trim().toUpperCase(); + if (!trimmed.startsWith('SELECT') && !trimmed.startsWith('DESCRIBE') && !trimmed.startsWith('SHOW')) { + return jsonResp({ error: 'Only SELECT/DESCRIBE/SHOW queries allowed' }, 400); + } + const proc = Bun.spawn(['mariadb', '-u', 'root', '-proot', database, '-e', query, '--batch'], { + stdout: 'pipe', stderr: 'pipe', + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + await proc.exited; + if (proc.exitCode !== 0) return jsonResp({ error: stderr.trim() || 'Query failed' }); + return jsonResp({ result: stdout.trim() }); + } catch (e: any) { return jsonResp({ error: e.message || 'DB error' }, 500); } + } + + // Viewer — issue fstoken + if (url.pathname === '/api/viewer/token' && method === 'POST') { + const tok = viewerTokenFromReq(req); + const user = tok && getUserForViewerToken(tok); + if (!user) return textResp('Unauthorized', 401); + return jsonResp({ fstoken: issueFstoken(user) }); + } + + // Viewer — tree + if (url.pathname === '/api/viewer/tree' && method === 'GET') { + const tok = viewerTokenFromReq(req); + if (!tok || !getUserForViewerToken(tok)) return textResp('Unauthorized', 401); + const root = url.searchParams.get('root') || ''; + try { + if (!root) { + const user = getUserForViewerToken(tok)!; + const roots = (userViewerRoots as any)[user] || ['shared', 'titan']; + return jsonResp({ dirs: roots, files: [] }); + } + const absDir = resolveViewerPath(root); + const entries = fs.readdirSync(absDir, { withFileTypes: true }); + const SKIP = new Set(['node_modules', '.git']); + const dirs: string[] = []; const files: any[] = []; + for (const e of entries) { + if (e.name.startsWith('.') || SKIP.has(e.name)) continue; + if (e.isDirectory()) { dirs.push(e.name); } + else if (e.isFile()) { + const ext = path.extname(e.name).toLowerCase(); + if (!VIEWER_SKIP_EXT.has(ext)) { + const abs = path.join(absDir, e.name); + let mtime = 0; try { mtime = fs.statSync(abs).mtimeMs; } catch (_) {} + files.push({ name: e.name, path: absToViewerPath(abs), mtime }); + } + } + } + dirs.sort(); files.sort((a, b) => a.name.localeCompare(b.name)); + // Watch this directory for new/deleted files + const treeUser = getUserForViewerToken(tok); + if (treeUser) watchViewerDir(absDir, treeUser); + return jsonResp({ dirs, files }); + } catch (err: any) { return textResp(err.message, err.status || 500); } + } + + // Viewer — file + if (url.pathname === '/api/viewer/file' && (method === 'GET' || method === 'HEAD')) { + const tok = viewerTokenFromReq(req); + if (!tok || !getUserForViewerToken(tok)) return textResp('Unauthorized', 401); + const rel = url.searchParams.get('path'); + if (!rel) return textResp('Missing path param', 400); + try { + const abs = resolveViewerPath(rel); + const ext = path.extname(abs).toLowerCase(); + if (VIEWER_SKIP_EXT.has(ext)) return textResp('Binary file type not supported', 415); + const stat = fs.statSync(abs); + if (stat.size > VIEWER_MAX_BYTES) return textResp('File too large', 413); + watchViewerPath(abs, getUserForViewerToken(tok)!); + const ct = ext === '.pdf' ? 'application/pdf' : 'text/plain; charset=utf-8'; + const h = corsHeaders(); h.set('Content-Type', ct); + if (url.searchParams.has('dl')) { + const fname = path.basename(abs); + h.set('Content-Disposition', `attachment; filename="${fname}"`); + } + if (method === 'HEAD') return new Response(null, { status: 200, headers: h }); + return new Response(Bun.file(abs), { headers: h }); + } catch (err: any) { return textResp(err.message, err.status || 500); } + } + + // ── Previous session history (REST) ── + if (url.pathname === '/api/session-history' && method === 'GET') { + const user = getUserForSessionToken(req.headers.get('authorization')?.replace('Bearer ', '') ?? ''); + if (!user) return jsonResp({ error: 'Unauthorized' }, 401); + const agent = url.searchParams.get('agent'); + const mode = url.searchParams.get('mode') || 'private'; + if (!agent) return jsonResp({ error: 'missing agent param' }, 400); + const skip = parseInt(url.searchParams.get('skip') || '0') || 0; + const count = Math.min(parseInt(url.searchParams.get('count') || '1') || 1, 10); + const files = findPreviousSessionFiles(agent, count, skip); + if (!files.length) return jsonResp({ sessions: [], hasMore: false }); + const sessions = files.map(f => ({ + entries: parseSessionFile(f.path), + resetTimestamp: f.resetTimestamp, + })); + // Check if there are more sessions beyond this batch + const moreFiles = findPreviousSessionFiles(agent, 1, skip + count); + return jsonResp({ sessions, hasMore: moreFiles.length > 0 }); + } + + return textResp('Not Found', 404); +} + +// --------------------------------------------------------------------------- +// Bun.serve — HTTP + WebSocket +// --------------------------------------------------------------------------- + +Bun.serve({ + port: PORT, + tls: (() => { + const keyPath = process.env.SSL_KEY || '../web-frontend/ssl/key.pem'; + const certPath = process.env.SSL_CERT || '../web-frontend/ssl/cert.pem'; + if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { + console.log('🔒 TLS enabled'); + return { key: Bun.file(keyPath), cert: Bun.file(certPath) }; + } + return undefined; + })(), + + fetch(req: Request, server: any): Response | Promise { + const url = new URL(req.url); + if (url.pathname === '/ws') { + const ok = server.upgrade(req); + if (ok) return undefined as any; + return textResp('WebSocket upgrade failed', 400); + } + return handleHttp(req); + }, + + websocket: { + open(ws: any) { + const clientId = Math.random().toString(36).slice(2, 10); + // Connection SM is per-WS; channel SM is assigned on auth/connect + const connSm = createConnectionSM(clientId, (event: object) => send(ws, event)); + // Placeholder channel SM — replaced when joining a channel + const sm = createChannelSM(`pending-${clientId}`, (event: object) => send(ws, event)); + browserSessions.set(ws, { user: null, sessionKey: null, clientId, sm, connSm, watcher: null }); + }, + + async message(ws: any, data: string | Buffer) { + const session = browserSessions.get(ws); + if (!session) return; + let msg: any; + try { msg = JSON.parse(typeof data === 'string' ? data : data.toString()); } + catch (err: any) { send(ws, { type: 'error', code: 'PARSE_ERROR', message: err.message }); return; } + + + try { + switch (msg.type) { + case 'connect': await handleConnect(ws, session, msg); break; + case 'auth': await handleAuth(ws, session, msg); break; + case 'message': await handleMessage(ws, session, msg); break; + case 'stop': await handleStopKill(ws, session, 'stop'); break; + case 'kill': await handleStopKill(ws, session, 'kill'); break; + case 'switch_agent': await handleSwitchAgent(ws, session, msg); break; + case 'handover_request': await handleHandoverRequest(ws, session); break; + case 'new': await handleNew(ws, session, false); break; + case 'new_with_handover': await handleNew(ws, session, true); break; + case 'cancel_handover': { + const cs = session.sm.get(); + if (cs === 'HANDOVER_DONE' || cs === 'HANDOVER_PENDING') { + const st = getSessionState(session.sessionKey!); + st.handoverActive = false; + transitionChannel(session.sessionKey!, 'READY', { reason: 'handover_cancelled' }); + } + break; + } + case 'dev_takeover': { + const token = msg.token; + if (!token) { send(ws, { type: 'error', code: 'MISSING_TOKEN' }); break; } + // Re-attach WS to existing token or create new + const existing = takeoverTokens.get(token); + if (existing) { + existing.ws = ws; + // Resolve any pending commands that were waiting + for (const [, p] of existing.pending) { clearTimeout(p.timer); p.resolve({ error: 'reconnected' }); } + existing.pending.clear(); + } else { + takeoverTokens.set(token, { ws, pending: new Map() }); + } + persistTakeoverTokens(); + if (session.user) refreshMcpKeyForUser(session.user, token); + send(ws, { type: 'dev_takeover_ok', token }); + break; + } + case 'dev_cmd_result': { + const { cmdId, result, error } = msg; + for (const [, entry] of takeoverTokens) { + const p = entry.pending.get(cmdId); + if (p) { + clearTimeout(p.timer); + entry.pending.delete(cmdId); + p.resolve(error ? { error } : { result }); + break; + } + } + break; + } + case 'disco_request': disconnectGateway(); send(ws, { type: 'disco_ok' }); break; + case 'disco_chat_request': send(ws, { type: 'disco_chat_ok' }); setTimeout(() => ws.close(1001, 'disco_chat'), 50); break; + case 'ping': send(ws, { type: 'pong' }); break; + case 'stats_request': + fetchOpenRouterStats().then(stats => send(ws, { type: 'stats', ...stats })).catch(err => send(ws, { type: 'stats', error: err.message })); + break; + default: + send(ws, { type: 'error', code: 'UNKNOWN_MESSAGE', message: `Unknown type: ${msg.type}` }); + } + } catch (err: any) { send(ws, { type: 'error', code: 'HANDLER_ERROR', message: err.message }); } + }, + + close(ws: any) { + const session = browserSessions.get(ws); + if (session) detachWatcher(session); + if (session?.sessionKey) releaseSharedSM(session.sessionKey); + else if (session?.sm) session.sm.destroy(); // pre-auth placeholder SM + if (session?.user) unwatchViewerUser(session.user); + browserSessions.delete(ws); + // Detach WS from takeover tokens but keep tokens persisted + for (const [, entry] of takeoverTokens) { + if (entry.ws === ws) { + for (const [, p] of entry.pending) { clearTimeout(p.timer); p.resolve({ error: 'disconnected' }); } + entry.pending.clear(); + entry.ws = null; + } + } + }, + }, +}); + +// --------------------------------------------------------------------------- +// Gateway connect with retry +// --------------------------------------------------------------------------- + +(async function connectWithRetry() { + while (true) { + try { await connectToGateway(); console.log('🤝 Gateway connected'); break; } + catch (err: any) { console.error('Gateway connection failed:', err.message, '— retrying in 5s...'); await Bun.sleep(5000); } + } +})(); + +console.log(`🌐 Hermes Gateway listening on port ${PORT}`); +console.log(` Health: http://localhost:${PORT}/health`); +console.log(` WS: ws://localhost:${PORT}/ws`); diff --git a/backend/session-sm.ts b/backend/session-sm.ts new file mode 100644 index 0000000..7f2124d --- /dev/null +++ b/backend/session-sm.ts @@ -0,0 +1,140 @@ +/** + * session-sm.ts — Two-SM architecture for Hermes sessions + * + * CHANNEL SM (shared, per session key — all users see same state) + * FRESH → session exists, 0 messages + * READY → has messages, idle, user can type + * AGENT_RUNNING → agent processing a user message + * HANDOVER_PENDING → handover write in progress + * HANDOVER_DONE → handover written, waiting for action + * RESETTING → deliberate new session in progress + * NO_SESSION → no session file found + * + * CONNECTION SM (per WebSocket — only this user sees it) + * CONNECTING → WS open, waiting for auth + * LOADING_HISTORY → receiving history replay + * SYNCED → connected to channel, seeing live updates + * SWITCHING → leaving one channel, joining another + */ + +// ── Channel SM ────────────────────────────────────────────── + +export type ChannelState = + | 'FRESH' + | 'READY' + | 'AGENT_RUNNING' + | 'HANDOVER_PENDING' + | 'HANDOVER_DONE' + | 'RESETTING' + | 'NO_SESSION'; + +const CHANNEL_TRANSITIONS: Record = { + FRESH: ['AGENT_RUNNING', 'RESETTING', 'NO_SESSION'], + READY: ['AGENT_RUNNING', 'HANDOVER_PENDING', 'RESETTING', 'NO_SESSION'], + AGENT_RUNNING: ['READY', 'RESETTING'], + HANDOVER_PENDING: ['HANDOVER_DONE', 'READY'], + HANDOVER_DONE: ['RESETTING', 'READY'], + RESETTING: ['AGENT_RUNNING', 'FRESH', 'NO_SESSION'], + NO_SESSION: ['RESETTING', 'FRESH', 'READY'], +}; + +const CHANNEL_TIMEOUTS_MS: Partial> = { + AGENT_RUNNING: 300_000, + HANDOVER_PENDING: 120_000, + RESETTING: 30_000, +}; + +// ── Connection SM ─────────────────────────────────────────── + +export type ConnectionState = + | 'CONNECTING' + | 'LOADING_HISTORY' + | 'SYNCED' + | 'SWITCHING'; + +const CONNECTION_TRANSITIONS: Record = { + CONNECTING: ['LOADING_HISTORY', 'SYNCED'], + LOADING_HISTORY: ['SYNCED'], + SYNCED: ['SWITCHING'], + SWITCHING: ['LOADING_HISTORY', 'SYNCED'], +}; + +const CONNECTION_TIMEOUTS_MS: Partial> = { + SWITCHING: 10_000, +}; + +// ── Generic SM factory ────────────────────────────────────── + +export interface StateMachine { + transition(next: S, payload?: Record): boolean; + get(): S; + destroy(): void; +} + +function createSM( + id: string, + label: string, + initial: S, + transitions: Record, + timeouts: Partial>, + onStateChange: (event: Record) => void, + timeoutTarget?: S, +): StateMachine { + let state: S = initial; + let timeoutHandle: ReturnType | null = null; + + function clearPendingTimeout() { + if (timeoutHandle !== null) { clearTimeout(timeoutHandle); timeoutHandle = null; } + } + + function transition(next: S, payload: Record = {}): boolean { + const allowed = transitions[state]; + if (!allowed?.includes(next)) { + console.warn(`[${label}:${id}] invalid transition: ${state} -> ${next} (ignored)`); + return false; + } + clearPendingTimeout(); + const prev = state; + state = next; + console.log(`[${label}:${id}] ${prev} -> ${state}${payload.reason ? ' (' + payload.reason + ')' : ''}`); + + const ms = timeouts[state]; + if (ms && timeoutTarget) { + timeoutHandle = setTimeout(() => { + console.warn(`[${label}:${id}] timeout in ${state}, forcing -> ${timeoutTarget}`); + transition(timeoutTarget, { reason: 'timeout' }); + }, ms); + } + + onStateChange({ type: `${label}_state`, state, prev, ...payload }); + return true; + } + + return { + transition, + get: () => state, + destroy: clearPendingTimeout, + }; +} + +// ── Factory functions ─────────────────────────────────────── + +export function createChannelSM( + sessionKey: string, + onStateChange: (event: Record) => void, + initial: ChannelState = 'NO_SESSION', +): StateMachine { + return createSM(sessionKey, 'channel', initial, CHANNEL_TRANSITIONS, CHANNEL_TIMEOUTS_MS, onStateChange, 'READY'); +} + +export function createConnectionSM( + clientId: string, + onStateChange: (event: Record) => void, +): StateMachine { + return createSM(clientId, 'connection', 'CONNECTING', CONNECTION_TRANSITIONS, CONNECTION_TIMEOUTS_MS, onStateChange, 'SYNCED'); +} + +// ── Backward compat (temporary, remove after migration) ───── + +export type SmState = ChannelState | ConnectionState; +export type SessionSM = StateMachine; diff --git a/backend/session-watcher.ts b/backend/session-watcher.ts new file mode 100644 index 0000000..6f43804 --- /dev/null +++ b/backend/session-watcher.ts @@ -0,0 +1,573 @@ +/** + * session-watcher.ts — JSONL session file watcher + * + * Reads openclaw session JSONL, classifies entries, and streams + * them to the browser via onEntry callbacks. + */ + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { filterValue, filterText } from './message-filter'; +import { hud, buildToolArgs, resolveToolResult } from './hud-builder'; + +export type WatcherEntry = Record; +export type OnEntry = (entry: WatcherEntry) => void; +export type WsSend = (data: WatcherEntry) => void; +export type GatewayRequestFn = (method: string, params: Record) => Promise; + +export interface SessionWatcher { + start(): void; + stop(): void; + sendHistoryTo(send: WsSend): number; +} + +/** + * Check if a session exists on disk for a given session key. + * Returns 'READY' if the .jsonl file exists, 'NO_SESSION' otherwise. + * Same logic as createSessionWatcher.start() — single source of truth. + */ +export function checkSessionOnDisk(agentId: string, sessionKey: string): 'READY' | 'NO_SESSION' { + try { + const sessionsPath = path.join(os.homedir(), '.openclaw', 'agents', agentId, 'sessions', 'sessions.json'); + const sessions = JSON.parse(fs.readFileSync(sessionsPath, 'utf-8')); + const session = sessions[sessionKey]; + if (session?.sessionFile && fs.existsSync(session.sessionFile)) { + return 'READY'; + } + return 'NO_SESSION'; + } catch { + return 'NO_SESSION'; + } +} + +export function createSessionWatcher( + sessionKey: string, + agentId: string, + onEntry: OnEntry, + wsSend: WsSend, + gatewayRequestFn: GatewayRequestFn, +): SessionWatcher { + const sessionsPath = path.join(os.homedir(), '.openclaw', 'agents', agentId, 'sessions', 'sessions.json'); + let fileWatcher: fs.FSWatcher | null = null; + let directoryWatcher: fs.FSWatcher | null = null; + let sessionsJsonWatcher: fs.FSWatcher | null = null; + let sessionsChangeTimer: ReturnType | null = null; + let filePosition = 0; + let currentJsonlPath: string | null = null; + let cumulativeTokens = { input: 0, output: 0, cacheRead: 0, total: 0 }; + const suppressedIds = new Set(); + let lastHistorySnapshot: { entries: WatcherEntry[]; file: string; entryCount: number } | null = null; + // HUD tool-call pairing state — keyed by toolCallId (OpenClaw-assigned, unique per call) + // Name-based fallback removed: unreliable when same tool is called multiple times in one turn. + const hudPendingByCallId: Map = new Map(); + + function isSystemMessage(text: string): boolean { + return ( + !text || + text.startsWith('Pre-compaction memory flush') || + text.startsWith('HEARTBEAT') || + !!text.match(/^Read HEARTBEAT\.md/) || + text.startsWith("You're starting fresh. Read HANDOVER.md") || + text.startsWith("Write HANDOVER.md to your workspace") || + text.startsWith("/new") + ); + } + + function classifyEntry(parsed: any, isHistory = false): WatcherEntry | WatcherEntry[] | null { + const msg = parsed.message; + const ts = parsed.timestamp; + const id = parsed.id; + const parentId = parsed.parentId; + if (!msg) return null; + + if (parentId && suppressedIds.has(parentId) && msg.role === 'assistant') { + // If this assistant message contains a tool call, still emit HUD tool_start + // but suppress the text content. Add its id to suppressedIds so toolResults + // for non-tool-call responses (greeting text) are also suppressed. + const toolCall = msg.content?.find((c: any) => c.type === 'toolCall'); + if (!toolCall) { if (id) suppressedIds.add(id); return null; } + // Has a tool call — fall through to emit tool_start; add to suppressedIds for + // any sibling assistant-text that might follow + if (id) suppressedIds.add(id); + } + + if (msg.role === 'user') { + let text: string = msg.content?.[0]?.text || ''; + text = text.replace(/^Sender \(untrusted metadata\):[\s\S]*?```\s*\n\n?/, '').trim(); + text = text.replace(/^\[\w{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2} [^\]]+\]\s*/, '').trim(); + if (isSystemMessage(text)) { if (id) suppressedIds.add(id); return null; } + // Detect injected session context prompt and split it out + const promptMatch = text.match(/^(IMPORTANT: This is a (?:private|PUBLIC|private 1:1)[^\n]*(?:\n[^\n]*)*?(?:Address [^\n]*\.))\n\n([\s\S]*)$/); + if (promptMatch) { + const entries: WatcherEntry[] = [ + { type: 'session_entry', entry_type: 'session_context', content: promptMatch[1], ts }, + ]; + if (promptMatch[2].trim()) { + entries.push({ type: 'session_entry', entry_type: 'user_message', content: promptMatch[2].trim(), ts }); + } + return entries; + } + return { type: 'session_entry', entry_type: 'user_message', content: text, ts }; + } + + if (msg.role === 'toolResult') { + const tool: string = msg.toolName || 'unknown'; + const raw: string = msg.content?.[0]?.text || ''; + // Use toolCallId exclusively — name fallback removed (breaks on repeated same-tool calls) + const toolCallId: string = msg.toolCallId || ''; + const callId: string = toolCallId || crypto.randomUUID(); + const pending = toolCallId ? hudPendingByCallId.get(toolCallId) : undefined; + const pendingArgs = pending?.args || {}; + if (toolCallId) hudPendingByCallId.delete(toolCallId); + const result = resolveToolResult(tool, pendingArgs, raw); + const startTs: number = pending?.ts || (ts ? new Date(ts).getTime() : Date.now()); + const endTs: number = ts ? new Date(ts).getTime() : Date.now(); + const durationMs: number = Math.max(0, endTs - startTs); + const ev = hud.toolEnd(callId, 'history', tool, result, startTs, isHistory, toolCallId || undefined); + return { ...ev, durationMs, ts } as any; + } + + if (msg.role === 'assistant') { + const toolCalls = msg.content?.filter((c: any) => c.type === 'toolCall') ?? []; + if (toolCalls.length > 0) { + const startTs = ts ? new Date(ts).getTime() : Date.now(); + const events: WatcherEntry[] = []; + for (const toolCall of toolCalls) { + const tool: string = toolCall.name || 'unknown'; + const toolCallId: string = toolCall.id || ''; + const callId: string = toolCallId || crypto.randomUUID(); + const args = buildToolArgs(tool, toolCall.arguments); + // Stash for pairing with toolResult — keyed by toolCallId only + if (toolCallId) hudPendingByCallId.set(toolCallId, { tool, args, ts: startTs }); + events.push({ + ...hud.toolStart(callId, 'history', tool, args, isHistory, toolCallId || undefined), + ts, + } as any); + } + return events; + } + if (msg.stopReason === 'stop' || msg.stopReason === 'length') { + const truncated = msg.stopReason === 'length'; + const text = filterText( + (msg.content?.filter((c: any) => c.type === 'text') ?? []) + .map((c: any) => c.text || '').join('').trim() + ) ?? ''; + const result: WatcherEntry = { type: 'session_entry', entry_type: 'assistant_text', content: text, ts, ...(truncated ? { truncated: true } : {}) }; + if (truncated && !isHistory && wsSend) { + wsSend({ type: 'truncated_warning' }); + } + if (msg.usage) { + cumulativeTokens.input += msg.usage.input || 0; + cumulativeTokens.output += msg.usage.output || 0; + cumulativeTokens.cacheRead += msg.usage.cacheRead || 0; + cumulativeTokens.total += msg.usage.totalTokens || 0; + if (wsSend) { + wsSend({ + type: 'session_total_tokens', + input_tokens: cumulativeTokens.input, + cache_read_tokens: cumulativeTokens.cacheRead, + output_tokens: cumulativeTokens.output, + }); + } + return [ + result, + { + type: 'session_entry', + entry_type: 'usage', + input_tokens: msg.usage.input || 0, + output_tokens: msg.usage.output || 0, + cache_read_tokens: msg.usage.cacheRead || 0, + cache_write_tokens: msg.usage.cacheWrite || 0, + total_tokens: msg.usage.totalTokens || 0, + cost: msg.usage.cost?.total ?? msg.usage.cost ?? 0, + ts, + }, + ]; + } + return result; + } + } + + return null; + } + + function processLines(lines: string[]) { + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed); + const result = classifyEntry(parsed); + if (!result) continue; + const entries = Array.isArray(result) ? result : [result]; + for (const entry of entries) { + // Keep snapshot up to date so late-joining tabs get full history + if (lastHistorySnapshot) { + lastHistorySnapshot.entries.push(entry); + lastHistorySnapshot.entryCount++; + } + onEntry(entry); + } + } catch (e: any) { + wsSend({ type: 'diagnostic', level: 'warn', message: `Backend: Skipped malformed line: ${e.message}` }); + } + } + } + + /** + * Group flat history entries into turn-structured HUD events. + * Each turn = one user message → N tool calls/results → one assistant text. + * Injects synthetic turn_start/turn_end HUD nodes wrapping tool nodes. + */ + function groupIntoTurns(entries: WatcherEntry[]): WatcherEntry[] { + const result: WatcherEntry[] = []; + // Collect tool HUD events (tool_start/tool_end) since last user message + let pendingTools: WatcherEntry[] = []; + let turnStartTs: number | null = null; + let turnEndTs: number | null = null; + let inTurn = false; + + function flushTurn() { + if (!inTurn || pendingTools.length === 0) { + // No tools in this turn — emit nothing for the turn shell + for (const t of pendingTools) result.push(t); + pendingTools = []; + inTurn = false; + turnStartTs = null; + turnEndTs = null; + return; + } + const turnId = crypto.randomUUID(); + const startTs = turnStartTs ?? Date.now(); + const endTs = turnEndTs ?? Date.now(); + const durationMs = Math.max(0, endTs - startTs); + // Rewrite parentId on all pending tools to this turn's id + const rewritten = pendingTools.map(t => ({ ...t, parentId: turnId })); + result.push({ type: 'hud', event: 'turn_start', correlationId: turnId, ts: startTs, replay: true } as WatcherEntry); + for (const t of rewritten) result.push(t); + result.push({ type: 'hud', event: 'turn_end', correlationId: turnId, ts: endTs, durationMs, replay: true } as WatcherEntry); + pendingTools = []; + inTurn = false; + turnStartTs = null; + turnEndTs = null; + } + + for (const entry of entries) { + const e = entry as any; + + // User message → turn boundary: flush previous turn, start new + if (e.type === 'session_entry' && e.entry_type === 'user_message') { + flushTurn(); + result.push(entry); + inTurn = true; + turnStartTs = e.ts ? new Date(e.ts).getTime() : Date.now(); + continue; + } + + // HUD tool events — collect instead of emitting directly + if (e.type === 'hud' && (e.event === 'tool_start' || e.event === 'tool_end')) { + if (inTurn) { + if (e.event === 'tool_end') { + // Update turn end ts to track last tool completion + const ets = e.ts ? new Date(e.ts).getTime() : Date.now(); + turnEndTs = Math.max(turnEndTs ?? 0, ets); + } + pendingTools.push(entry); + } else { + // Tool with no preceding user message (shouldn't happen, emit at root) + result.push(entry); + } + continue; + } + + // Assistant text → turn end marker + if (e.type === 'session_entry' && e.entry_type === 'assistant_text') { + if (inTurn && e.ts) { + const ets = new Date(e.ts).getTime(); + turnEndTs = Math.max(turnEndTs ?? 0, ets); + } + flushTurn(); + result.push(entry); + continue; + } + + // Everything else (usage, diagnostics, etc.) — emit as-is + result.push(entry); + } + + // Flush any open turn at end of file + flushTurn(); + return result; + } + + function startFileWatcher(filePath: string) { + currentJsonlPath = filePath; + suppressedIds.clear(); + const content = fs.readFileSync(filePath, 'utf8'); + const lines = content.split('\n'); + const rawHistoryEntries: WatcherEntry[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed); + if (parsed.message?.role === 'assistant' && parsed.message.usage) { + cumulativeTokens.input += parsed.message.usage.input || 0; + cumulativeTokens.output += parsed.message.usage.output || 0; + cumulativeTokens.cacheRead += parsed.message.usage.cacheRead || 0; + cumulativeTokens.total += parsed.message.usage.totalTokens || 0; + } + const result = classifyEntry(parsed, true); + if (!result) continue; + const entries = Array.isArray(result) ? result : [result]; + for (const entry of entries) rawHistoryEntries.push(entry); + } catch (e: any) { + wsSend({ type: 'diagnostic', level: 'warn', message: `Backend: Skipped malformed line during history parse: ${e.message}` }); + } + } + + const historyEntries = groupIntoTurns(rawHistoryEntries); + + filePosition = Buffer.byteLength(content, 'utf8'); + lastHistorySnapshot = { entries: historyEntries, file: filePath, entryCount: lines.filter(l => l.trim()).length }; + onEntry({ type: 'session_history', entries: historyEntries }); + onEntry({ type: 'session_status', status: 'watching', file: filePath, entries: lines.filter(l => l.trim()).length }); + + if (wsSend && (cumulativeTokens.input > 0 || cumulativeTokens.output > 0 || cumulativeTokens.cacheRead > 0)) { + wsSend({ + type: 'session_total_tokens', + input_tokens: cumulativeTokens.input, + cache_read_tokens: cumulativeTokens.cacheRead, + output_tokens: cumulativeTokens.output, + }); + } + + fileWatcher = fs.watch(filePath, () => { + try { + const stat = fs.statSync(filePath); + if (stat.size <= filePosition) return; + const fd = fs.openSync(filePath, 'r'); + const buf = Buffer.alloc(stat.size - filePosition); + fs.readSync(fd, buf, 0, buf.length, filePosition); + fs.closeSync(fd); + filePosition = stat.size; + processLines(buf.toString('utf8').split('\n')); + } catch (e: any) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + // Session file was rotated — stop watching old file, check for new one + wsSend({ type: 'diagnostic', level: 'warn', message: `Backend: Session file rotated, re-attaching...` }); + if (fileWatcher) { fileWatcher.close(); fileWatcher = null; } + // Re-read sessions.json to find new file + try { + const sessions = JSON.parse(fs.readFileSync(sessionsPath, 'utf8')); + const session = sessions[sessionKey]; + if (session?.sessionFile && fs.existsSync(session.sessionFile)) { + filePosition = 0; + cumulativeTokens = { input: 0, output: 0, cacheRead: 0, total: 0 }; + suppressedIds.clear(); + lastHistorySnapshot = null; + startFileWatcher(session.sessionFile); + } + } catch (_) { + wsSend({ type: 'diagnostic', level: 'error', message: `Backend: Failed to re-attach after rotation` }); + } + } else { + wsSend({ type: 'diagnostic', level: 'error', message: `Backend: Error reading session file: ${e.message}` }); + } + } + }); + } + + function startSessionsWatcher() { + if (sessionsJsonWatcher) return; // already watching + try { + sessionsJsonWatcher = fs.watch(sessionsPath, () => { + // Debounce: sessions.json may fire multiple events per write + if (sessionsChangeTimer) clearTimeout(sessionsChangeTimer); + sessionsChangeTimer = setTimeout(() => { + sessionsChangeTimer = null; + try { + const sessions = JSON.parse(fs.readFileSync(sessionsPath, 'utf8')); + const session = sessions[sessionKey]; + const newPath: string | undefined = session?.sessionFile; + if (newPath && newPath !== currentJsonlPath && fs.existsSync(newPath)) { + wsSend({ type: 'diagnostic', level: 'info', message: `Backend: Session rotated for ${sessionKey}, re-attaching to ${newPath}` }); + if (fileWatcher) { fileWatcher.close(); fileWatcher = null; } + if (directoryWatcher) { directoryWatcher.close(); directoryWatcher = null; } + filePosition = 0; + cumulativeTokens = { input: 0, output: 0, cacheRead: 0, total: 0 }; + suppressedIds.clear(); + lastHistorySnapshot = null; + startFileWatcher(newPath); + } + } catch (_) { + // sessions.json temporarily unreadable during write — ignore + } + }, 200); + }); + } catch (_) { + // sessions.json doesn't exist — nothing to watch + } + } + + function start() { + startSessionsWatcher(); + let sessions: any; + try { sessions = JSON.parse(fs.readFileSync(sessionsPath, 'utf8')); } + catch (_) { onEntry({ type: 'session_status', status: 'no_session', reason: 'sessions.json unreadable' }); return; } + + const session = sessions[sessionKey]; + if (!session?.sessionFile) { + onEntry({ type: 'session_status', status: 'no_session', reason: `no entry for ${sessionKey}` }); + return; + } + + const rawJsonlPath: string = session.sessionFile; + const jsonlDir = path.dirname(rawJsonlPath); + const jsonlFilename = path.basename(rawJsonlPath); + + if (fs.existsSync(rawJsonlPath)) { + startFileWatcher(rawJsonlPath); + } else { + onEntry({ type: 'session_status', status: 'no_session', reason: 'jsonl file not found, watching directory', directory: jsonlDir, expectedFile: jsonlFilename }); + directoryWatcher = fs.watch(jsonlDir, (eventType, filename) => { + if (eventType === 'rename' && filename === jsonlFilename) { + const fullPath = path.join(jsonlDir, filename as string); + setTimeout(() => { + if (fs.existsSync(fullPath)) { + if (directoryWatcher) { directoryWatcher.close(); directoryWatcher = null; } + startFileWatcher(fullPath); + } + }, 100); + } + }); + } + } + + function stop() { + if (sessionsChangeTimer) { clearTimeout(sessionsChangeTimer); sessionsChangeTimer = null; } + if (fileWatcher) { fileWatcher.close(); fileWatcher = null; } + if (directoryWatcher) { directoryWatcher.close(); directoryWatcher = null; } + if (sessionsJsonWatcher) { sessionsJsonWatcher.close(); sessionsJsonWatcher = null; } + } + + function sendHistoryTo(send: WsSend): number { + if (!lastHistorySnapshot) return 0; + send({ type: 'session_history', entries: lastHistorySnapshot.entries }); + send({ type: 'session_status', status: 'watching', file: lastHistorySnapshot.file, entries: lastHistorySnapshot.entryCount }); + if (cumulativeTokens.input > 0 || cumulativeTokens.output > 0 || cumulativeTokens.cacheRead > 0) { + send({ type: 'session_total_tokens', input_tokens: cumulativeTokens.input, cache_read_tokens: cumulativeTokens.cacheRead, output_tokens: cumulativeTokens.output }); + } + return lastHistorySnapshot.entryCount; + } + + return { start, stop, sendHistoryTo }; +} + +// ── Previous session helpers ────────────────────────────────── + +export interface PreviousSessionInfo { + path: string; + resetTimestamp: string; +} + +/** + * Find previous session .jsonl.reset.* files for an agent. + * Returns files sorted newest-first. Use `skip` to paginate. + */ +export function findPreviousSessionFiles(agentId: string, count = 1, skip = 0): PreviousSessionInfo[] { + try { + const sessionsDir = path.join(os.homedir(), '.openclaw', 'agents', agentId, 'sessions'); + const files = fs.readdirSync(sessionsDir) + .filter(f => f.includes('.jsonl.reset.')) + .sort((a, b) => { + const tsA = a.split('.jsonl.reset.')[1] || ''; + const tsB = b.split('.jsonl.reset.')[1] || ''; + return tsB.localeCompare(tsA); // newest first + }); + return files.slice(skip, skip + count).map(f => { + const tsRaw = f.split('.jsonl.reset.')[1] || ''; + const resetTimestamp = tsRaw.replace(/(\d{2})-(\d{2})-(\d{2})\./, '$1:$2:$3.'); + return { path: path.join(sessionsDir, f), resetTimestamp }; + }); + } catch { + return []; + } +} + +/** Convenience: find just the most recent one */ +export function findPreviousSessionFile(agentId: string): PreviousSessionInfo | null { + const files = findPreviousSessionFiles(agentId, 1, 0); + return files.length > 0 ? files[0] : null; +} + +export interface PreviousMessage { + role: 'user' | 'assistant'; + content: string; + timestamp?: string; +} + +/** + * Parse a session JSONL file and extract user + assistant messages. + * Skips tools, usage, system messages, session headers. + * Size guard: skips files > 5MB. + */ +export function parseSessionFile(filePath: string): PreviousMessage[] { + try { + const stat = fs.statSync(filePath); + if (stat.size > 5 * 1024 * 1024) return []; // too large + + const lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(l => l.trim()); + const messages: PreviousMessage[] = []; + const suppressedIds = new Set(); + + for (const line of lines) { + let parsed: any; + try { parsed = JSON.parse(line); } catch { continue; } + const msg = parsed.message; + const ts = parsed.timestamp; + const id = parsed.id; + const parentId = parsed.parentId; + if (!msg) continue; + + // Suppress children of system messages + if (parentId && suppressedIds.has(parentId)) { + if (id) suppressedIds.add(id); + continue; + } + + if (msg.role === 'user') { + let text: string = msg.content?.[0]?.text || ''; + // Strip sender metadata (may be entire message or prefix) + text = text.replace(/^Sender \(untrusted metadata\):[\s\S]*?```\s*\n?\n?/, '').trim(); + text = text.replace(/^\[\w{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2} [^\]]+\]\s*/, '').trim(); + // Skip system messages + if (!text || text.startsWith('Pre-compaction memory flush') || text.startsWith('HEARTBEAT') || + text.startsWith("/new") || text.startsWith("You're starting fresh") || + text.startsWith("Write HANDOVER.md")) { + if (id) suppressedIds.add(id); + continue; + } + // Strip injected session prompt + // Skip or strip session context prompt + if (/^(?:IMPORTANT: )?This is a (?:private|PUBLIC|private 1:1)/.test(text)) { + // Try to extract user content after the prompt + const afterPrompt = text.match(/\n\n([\s\S]+)$/); + if (afterPrompt) { text = afterPrompt[1].trim(); } + else { continue; } // entire message is just the prompt + } + if (!text) continue; + messages.push({ role: 'user', content: text, timestamp: ts }); + } else if (msg.role === 'assistant' && (msg.stopReason === 'stop' || msg.stopReason === 'length')) { + const text = (msg.content?.filter((c: any) => c.type === 'text') ?? []) + .map((c: any) => c.text || '').join('').trim(); + if (text) messages.push({ role: 'assistant', content: text, timestamp: ts }); + } + } + return messages; + } catch { + return []; + } +} diff --git a/backend/system-access.ts b/backend/system-access.ts new file mode 100644 index 0000000..4262c8b --- /dev/null +++ b/backend/system-access.ts @@ -0,0 +1,110 @@ +/** + * system-access.ts — Device authorization flow for system MCP tools + * + * Flow: Claude calls system_request_access → backend notifies browser via WS → + * user approves in /dev → Claude polls system_check_access → gets systemToken + */ + +import { randomUUID } from 'crypto'; +import { pushEvent } from './mcp/events.ts'; + +const REQUEST_TTL_MS = 5 * 60 * 1000; // 5 min to approve +const TOKEN_TTL_MS = 4 * 60 * 60 * 1000; // 4 hour token lifetime + +export interface PendingRequest { + requestId: string; + userCode: string; + description: string; + createdAt: number; + expiresAt: number; +} + +interface RequestEntry extends PendingRequest { + systemToken: string | null; + mcpKey?: string; +} + +interface SystemTokenEntry { + requestId: string; + user: string; + createdAt: number; + expiresAt: number; +} + +const pendingMap = new Map(); +const tokenMap = new Map(); + +let notifyFn: ((req: PendingRequest) => void) | null = null; + +export function setNotifyFn(fn: (req: PendingRequest) => void) { + notifyFn = fn; +} + +function genUserCode(): string { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + const part = (n: number) => Array.from({ length: n }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); + return `${part(4)}-${part(4)}`; +} + +export function createRequest(description: string, mcpKey?: string): PendingRequest { + const now = Date.now(); + // Clean expired + for (const [id, req] of pendingMap) + if (req.expiresAt < now) pendingMap.delete(id); + + const entry: RequestEntry = { + requestId: randomUUID(), + userCode: genUserCode(), + description, + createdAt: now, + expiresAt: now + REQUEST_TTL_MS, + systemToken: null, + mcpKey, + }; + pendingMap.set(entry.requestId, entry); + + const pub: PendingRequest = { requestId: entry.requestId, userCode: entry.userCode, description, createdAt: entry.createdAt, expiresAt: entry.expiresAt }; + notifyFn?.(pub); + return pub; +} + +export function getPendingRequests(): PendingRequest[] { + const now = Date.now(); + const out: PendingRequest[] = []; + for (const req of pendingMap.values()) + if (req.expiresAt > now && !req.systemToken) + out.push({ requestId: req.requestId, userCode: req.userCode, description: req.description, createdAt: req.createdAt, expiresAt: req.expiresAt }); + return out; +} + +export function approveRequest(requestId: string, user: string): string | null { + const req = pendingMap.get(requestId); + if (!req) return null; + if (req.expiresAt < Date.now()) { pendingMap.delete(requestId); return null; } + if (req.systemToken) return req.systemToken; // idempotent + const token = randomUUID(); + // Push event to MCP subscriber + if (req.mcpKey) pushEvent(req.mcpKey, { type: "system_access_approved", data: { requestId, systemToken: token } }); + req.systemToken = token; + tokenMap.set(token, { requestId, user, createdAt: Date.now(), expiresAt: Date.now() + TOKEN_TTL_MS }); + return token; +} + +export function denyRequest(requestId: string) { + pendingMap.delete(requestId); +} + +export function checkRequest(requestId: string): { status: 'pending' | 'approved' | 'expired' | 'denied'; systemToken?: string } { + const req = pendingMap.get(requestId); + if (!req) return { status: 'denied' }; + if (req.expiresAt < Date.now()) { pendingMap.delete(requestId); return { status: 'expired' }; } + if (req.systemToken) return { status: 'approved', systemToken: req.systemToken }; + return { status: 'pending' }; +} + +export function validateSystemToken(token: string): SystemTokenEntry | null { + const entry = tokenMap.get(token); + if (!entry) return null; + if (entry.expiresAt < Date.now()) { tokenMap.delete(token); return null; } + return entry; +} diff --git a/docs/HUD-PROTOCOL.md b/docs/HUD-PROTOCOL.md new file mode 100644 index 0000000..77affd3 --- /dev/null +++ b/docs/HUD-PROTOCOL.md @@ -0,0 +1,311 @@ +# HUD Protocol — Structured Activity Feed + +**Status:** Design — not yet implemented +**Author:** Titan +**Created:** 2026-03-15 + +--- + +## Overview + +The HUD (Heads-Up Display) is a real-time activity feed in the webchat UI showing what the agent is doing — tool calls, reasoning, session events. It replaces the previous flat `string[]` log with a structured, hierarchical, machine-readable event stream. + +--- + +## Goals + +- **Structured** — args and results are objects, not truncated strings +- **Hierarchical** — tools nest inside turns; thinking nests inside turns +- **Incremental** — events stream as they happen (`_start` / `_end` pairs) +- **Machine-readable** — Titan can inspect full tool output from HUD panel +- **Replay-aware** — history replay emits same events, flagged `replay: true` +- **Extensible** — new event types add without breaking existing consumers + +--- + +## Wire Format + +All HUD events are sent as WebSocket messages with `type: "hud"`. + +### Base shape + +```ts +interface HudEvent { + type: 'hud' + event: HudEventKind + id: string // always crypto.randomUUID() — globally unique + correlationId?: string // provider call_* id (tools) or turnId (turns) — used for _start/_end pairing + parentId?: string // correlationId of containing turn + ts: number // Unix ms timestamp + replay?: boolean // true when emitted from history replay +} +``` + +### Event kinds + +``` +tool_start tool_end +think_start think_end +turn_start turn_end +received // instantaneous — no _end counterpart +``` + +--- + +## Event Shapes + +### turn_start / turn_end + +```json +{ "type": "hud", "event": "turn_start", "id": "", "correlationId": "", "ts": 1741995000000 } +{ "type": "hud", "event": "turn_end", "id": "", "correlationId": "", "ts": 1741995004500, "durationMs": 4500 } +``` + +`correlationId` = `session.turnId`. Frontend pairs `turn_start` ↔ `turn_end` by matching `correlationId`. + +--- + +### think_start / think_end + +```json +{ "type": "hud", "event": "think_start", "id": "", "correlationId": "", "parentId": "", "ts": 1741995000050 } +{ "type": "hud", "event": "think_end", "id": "", "correlationId": "", "parentId": "", "ts": 1741995000820, "durationMs": 770 } +``` + +`correlationId` = `crypto.randomUUID()` generated at `think_start`, held in session state until `think_end`. `parentId` = containing turn's `correlationId`. + +--- + +### tool_start / tool_end + +```json +{ "type": "hud", "event": "tool_start", + "id": "", + "correlationId": "call_123f898fc88346afaec098e0", + "parentId": "", + "tool": "read", + "args": { "path": "workspace-titan/SOUL.md" }, + "ts": 1741995001000 } + +{ "type": "hud", "event": "tool_end", + "id": "", + "correlationId": "call_123f898fc88346afaec098e0", + "parentId": "", + "tool": "read", + "result": { "ok": true, "text": "# SOUL\n…", "bytes": 2048 }, + "ts": 1741995001210, "durationMs": 210 } +``` + +`id` = always `crypto.randomUUID()`. `correlationId` = provider tool call id (`call_*`) — frontend pairs `tool_start` ↔ `tool_end` by matching `correlationId`. + +**Result shapes by tool:** + +```ts +// Shared — viewer-navigable file reference +interface FileArea { + startLine: number // 1-indexed, in resulting file + endLine: number +} + +interface FileMeta { + path: string // raw as passed to tool + viewerPath: string // normalized: /home/openclaw/.openclaw/ stripped + area?: FileArea +} + +// read +args: { path: string, offset?: number, limit?: number } +result: { ok: boolean, file: FileMeta, area: FileArea, text: string, bytes: number, truncated: boolean } +// area inferred from offset/limit → { startLine: offset, endLine: offset+limit } + +// write +args: { path: string, operation: 'write' } +result: { ok: boolean, file: FileMeta, area: FileArea, bytes: number } +// area: { startLine: 1, endLine: lineCount(written content) } + +// edit +args: { path: string, operation: 'edit' } +result: { ok: boolean, file: FileMeta, area: FileArea } +// area: line range of replaced block in resulting file + +// append +args: { path: string, operation: 'append' } +result: { ok: boolean, file: FileMeta, area: FileArea, bytes: number } +// area: { startLine: prevLineCount+1, endLine: newLineCount } + +// exec +args: { command: string } +result: { ok: boolean, exitCode: number, stdout: string, truncated: boolean, mentionedPaths?: FileMeta[], error?: string } +// mentionedPaths: file paths parsed from command string via regex + +// web_search / web_fetch +args: { query?: string, url?: string } +result: { ok: boolean, text: string, url?: string, truncated: boolean } + +// browser / canvas / nodes / message / sessions_* +args: { action: string, [key: string]: any } +result: { ok: boolean, summary: string, raw?: any } + +// fallback (unknown tool) +result: { ok: boolean, raw: any } +``` + +**UI labels derived from operation:** + +| Tool + operation | Label | +|---|---| +| `read` | `👁 path:L10–L50` | +| `write` | `✏️ path (overwrite)` | +| `edit` | `✏️ path:L22–L28` | +| `append` | `✏️ path:L180–L195` | +| `exec` | `⚡ command` | +| `web_fetch` | `🌐 url` | +| `web_search` | `🔍 query` | + +--- + +### received (instantaneous) + +No `_end` counterpart. Emitted when the backend acknowledges a control action. + +```ts +interface ReceivedEvent extends HudEvent { + event: 'received' + subtype: ReceivedSubtype + label: string // human readable description + payload?: Record +} + +type ReceivedSubtype = + | 'new_session' + | 'agent_switch' + | 'stop' + | 'kill' + | 'handover' + | 'reconnect' + | 'message' +``` + +**Examples:** + +```json +{ "type": "hud", "event": "received", "id": "", "subtype": "new_session", + "label": "/new received — resetting session", + "payload": { "previousAgent": "tester" }, "ts": 1741995010000 } + +{ "type": "hud", "event": "received", "id": "", "subtype": "agent_switch", + "label": "switch → titan", + "payload": { "from": "tester", "to": "titan" }, "ts": 1741995020000 } + +{ "type": "hud", "event": "received", "id": "", "subtype": "stop", + "label": "stop received — aborting turn", + "payload": { "state": "AGENT_RUNNING" }, "ts": 1741995030000 } + +{ "type": "hud", "event": "received", "id": "", "subtype": "reconnect", + "label": "reconnected — replaying history", + "payload": { "sessionKey": "agent:titan:web:nico" }, "ts": 1741995040000 } + +{ "type": "hud", "event": "received", "id": "", "subtype": "message", + "label": "message received", + "payload": { "preview": "hello world" }, "ts": 1741995050000 } +``` + +--- + +## ID Policy + +All `id` fields are always `crypto.randomUUID()` — globally unique, no exceptions. + +`correlationId` carries the external or domain identifier used for `_start`/`_end` pairing: + +| Event | `correlationId` source | +|---|---| +| `tool_start` / `tool_end` | Provider tool call id (`call_*`) | +| `turn_start` / `turn_end` | `session.turnId` | +| `think_start` / `think_end` | `crypto.randomUUID()` generated at `think_start`, reused at `think_end` | +| `received` | — (no pairing needed) | + +Frontend pairing logic: +- `*_start` → create node, index by `correlationId` (or `id` if no `correlationId`) +- `*_end` → look up by `correlationId` → merge result, set `state: 'done'`, set `durationMs` +- FIFO fallback if `correlationId` is missing or unmatched — match oldest running node of same tool/type + +--- + +## Frontend Data Model + +```ts +interface HudNode { + id: string + type: 'turn' | 'tool' | 'think' | 'received' + subtype?: string + state: 'running' | 'done' | 'error' + label: string // human readable + tool?: string + args?: Record // full, structured + result?: Record // full, structured + startedAt: number + endedAt?: number + durationMs?: number + children: HudNode[] // tools/thinks nest inside turns + replay: boolean +} +``` + +**Pairing logic:** +- Maintain `Map` (pending nodes) +- `*_start` → create node with `state: 'running'`, insert into map + tree +- `*_end` → look up by id, merge result, set `state: 'done'`, set `durationMs`, remove from map +- `received` → create complete node immediately (`state: 'done'`) +- If `*_end` arrives with unknown id → FIFO fallback (match oldest running node of same tool) + +--- + +## Emission Points + +| Source | Events emitted | +|---|---| +| `gateway.ts` `chat.tool_call` | `tool_start` | +| `gateway.ts` `chat.tool_result` | `tool_end` | +| `gateway.ts` `chat.thinking` | `think_start` (on first chunk) | +| `gateway.ts` `chat.done` / `chat.final` | `think_end` (if thinking was open), `turn_end` | +| `gateway.ts` `chat.delta` / `turn_start` | `turn_start` (on first delta) | +| `server.ts` `handleNew` | `received` subtype=`new_session` | +| `server.ts` `handleSwitchAgent` | `received` subtype=`agent_switch` | +| `server.ts` `handleStopKill` | `received` subtype=`stop` or `kill` | +| `server.ts` `handleHandoverRequest` | `received` subtype=`handover` | +| `server.ts` reconnect path | `received` subtype=`reconnect` | +| `session-watcher.ts` history | all of the above with `replay: true` | + +--- + +## Rendering (HudActions.vue) + +- Tree view: turns at root, tools/thinks as children +- Each row: `[state-dot] [icon] [label] [duration-badge]` +- Expandable: click row → show args/result as formatted JSON +- `replay: true` nodes rendered at reduced opacity +- Running nodes animate (pulse dot) +- Max visible: last 50 nodes (configurable) +- History replay nodes collapsible as a group + +--- + +## Migration + +Replaces: +- `hudActionsLog: ref` in `sessionHistory.ts` +- String-building in `handleSessionEntry` / `handleSessionHistory` +- Raw string push in `useAgentSocket.ts` lines 111–114 + +Preserved: +- `chatStore.pushSystem()` — chat bubble system messages (errors, stop confirm) — different concern +- `lastSystemMsgRef` — status text in HudRow + +--- + +## Open Questions + +- Should `received.message` events be emitted for every user message? (could be noisy) +- Should thinking content be stored in the node (for expand) or discarded? +- Cap on `result.text` size stored in node? (full fidelity vs memory) diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..a5bf5bf --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1 @@ +VITE_WS_URL=wss://chat.jqxp.org/ws diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..b2d59d1 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,2 @@ +/node_modules +/dist \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1bf753e --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,146 @@ +# Hermes Frontend + +**Status:** Active development | **Version:** 0.6.42 +**Production:** `https://jqxp.org` (Cloudflare static) + +--- + +## What Is This + +Vue 3 + TypeScript + Vite chat UI for OpenClaw agents. +Part of the **Hermes project** — the plugin-based successor to the original webchat. + +Connects to `hermes/backend` (Bun) via WebSocket. Supports streaming, markdown rendering, +session history, handover, token cost tracking, file tree view, multi-modal input (images, PDFs, audio), +TTS playback, and real-time MCP-driven UI (counter, action picker, confetti). + +--- + +## Stack + +- Vue 3, TypeScript, Vite, Pinia, Vue Router +- marked (markdown rendering) +- WebSocket composable (`composables/ws.ts`) +- Session history from JSONL via backend (`composables/sessionHistory.ts`) +- Agent socket protocol (`composables/useAgentSocket.ts`) + +--- + +## Key Files + +``` +src/ + views/ + AgentsView.vue main chat view + DevView.vue dev tools, counter game, action picker + ViewerView.vue file browser + HomeView.vue landing page + store/chat.ts Pinia store — sm state, messages, queue + composables/ + ws.ts WS connect/reconnect/send + useAgentSocket.ts maps WS events → store actions + sessionHistory.ts history load + prev session fetch + useMessages.ts message rendering (markdown, ANSI, code) + useAttachments.ts file/image/audio/PDF attach + upload + useAudioRecorder.ts mic recording with level visualization + useTtsPlayer.ts ElevenLabs TTS playback + useTheme.ts theme switching (titan/eras/loop42) + agents.ts agent list, selection, display names + auth.ts login / token handling + components/ + AppSidebar.vue permanent rail sidebar, responsive + TtsPlayerBar.vue fixed TTS player bar + AssistantMessage.vue assistant bubble + speak button + UserMessage.vue user bubble + audio/image/PDF display + HudControls.vue NEW / HANDOVER / STOP buttons + WebGLBackground.vue particle background (theme-aware) + utils/apiBase.ts API base URL (dev proxy / prod absolute) +``` + +--- + +## Current Port Situation (2026-03-13) + +``` +Port What Status +─────────────────────────────────────────────────────── +8443 webchat/frontend Vite RUNNING (tmux webchat-vite) — wrong FE for hermes-dev +8444 hermes/frontend Vite RUNNING (separate tmux) — correct FE for hermes-dev +3001 webchat/backend PROD (systemd) +3002 webchat/backend dev RUNNING (tmux webchat-be) +3003 hermes/backend dev NOT RUNNING +``` + +⚠️ hermes/fe currently serves on :8444. `dev.jqxp.org` nginx points to :8443 (wrong). +To use hermes-dev properly: reach it via `hermes.dev.jqxp.org` (→ :8444) until ports are aligned. + +## Dev Setup + +```bash +# Terminal 1 — Frontend (hermes/fe) +cd projects/hermes/frontend +npm run dev # Vite on :8444 (configured in vite.config.ts) + +# Terminal 2 — Backend (hermes/be) +cd projects/hermes/backend +PORT=3003 node --watch server.ts +``` + +Vite proxies `/ws` → `ws://localhost:3003`. +RouterVM: `hermes.dev.jqxp.org` → :8444 (FE only, BE not exposed externally yet). + +--- + +## Deploy to Production + +Build here, rsync to webspace: + +```bash +cd projects/hermes/frontend +npm run build +sshpass -p '*KuHTW_9E.dvvWw' rsync -avz --delete \ + -e "ssh -o StrictHostKeyChecking=no -p 22" \ + dist/ u116526981@access1007204406.webspace-data.io:~/jqxp/ +``` + +--- + +## Hermes vs Webchat (Legacy) + +| Feature | webchat/frontend (legacy) | hermes/frontend (this) | +|---|---|---| +| Source of truth | ❌ archived | ✅ yes | +| Backend | Node.js server.js | Bun index.ts | +| Plugin channel | ❌ | ✅ openclaw hermes channel | +| File tree / workspace | ❌ | ✅ via Hermes backend APIs | +| Room mode (multi-user) | ❌ planned | 🔜 next milestone | + +--- + +## Planned: Room Mode + +``` +DIRECT MODE (current) ROOM MODE (next) +────────────────────────────── ────────────────────────────────── +[Titan ▼] [New] [Handover] [🏠 support-123] Titan + [● nico] [● tina] participant bar +messages: no sender label messages: sender label per bubble +agent dropdown: visible agent dropdown: hidden (room-scoped) +``` + +--- + +## Session Key Pattern + +``` +CURRENT: agent:{agent}:web:{user} e.g. agent:titan:web:nico +TARGET: agent:{agent}:web:direct:{user} private + agent:{agent}:web:room:{roomId} shared room (with Hermes plugin) +``` + +--- + +## Version + +Defined in `package.json` → displayed in nav bar via `composables/ui.ts`. +Current: **0.3.19** diff --git a/frontend/bun.lock b/frontend/bun.lock new file mode 100644 index 0000000..e81ede1 --- /dev/null +++ b/frontend/bun.lock @@ -0,0 +1,386 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "frontend", + "dependencies": { + "marked": "^17.0.4", + "pinia": "^3.0.4", + "vue": "^3.5.29", + "vue-router": "^5.0.3", + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@vitejs/plugin-vue": "^6.0.4", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vue-tsc": "^3.2.5", + }, + }, + }, + "packages": { + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ=="], + + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], + + "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], + + "@vue-macros/common": ["@vue-macros/common@3.1.2", "", { "dependencies": { "@vue/compiler-sfc": "^3.5.22", "ast-kit": "^2.1.2", "local-pkg": "^1.1.2", "magic-string-ast": "^1.0.2", "unplugin-utils": "^0.3.0" }, "peerDependencies": { "vue": "^2.7.0 || ^3.2.25" } }, "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng=="], + + "@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.29", "", { "dependencies": { "@vue/compiler-core": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.29", "@vue/compiler-dom": "3.5.29", "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw=="], + + "@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="], + + "@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="], + + "@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="], + + "@vue/language-core": ["@vue/language-core@3.2.5", "", { "dependencies": { "@volar/language-core": "2.4.28", "@vue/compiler-dom": "^3.5.0", "@vue/shared": "^3.5.0", "alien-signals": "^3.0.0", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1", "picomatch": "^4.0.2" } }, "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.29", "", { "dependencies": { "@vue/shared": "3.5.29" } }, "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.29", "", { "dependencies": { "@vue/reactivity": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.29", "", { "dependencies": { "@vue/reactivity": "3.5.29", "@vue/runtime-core": "3.5.29", "@vue/shared": "3.5.29", "csstype": "^3.2.3" } }, "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.29", "", { "dependencies": { "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29" }, "peerDependencies": { "vue": "3.5.29" } }, "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g=="], + + "@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="], + + "acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "alien-signals": ["alien-signals@3.1.2", "", {}, "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw=="], + + "ast-kit": ["ast-kit@2.2.0", "", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="], + + "ast-walker-scope": ["ast-walker-scope@0.8.3", "", { "dependencies": { "@babel/parser": "^7.28.4", "ast-kit": "^2.1.3" } }, "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg=="], + + "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + + "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": "bin/esbuild" }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + + "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "magic-string-ast": ["magic-string-ast@1.0.3", "", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="], + + "marked": ["marked@17.0.4", "", { "bin": "bin/marked.js" }, "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "mlly": ["mlly@1.8.1", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ=="], + + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": "bin/nanoid.cjs" }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pinia": ["pinia@3.0.4", "", { "dependencies": { "@vue/devtools-api": "^7.7.7" }, "peerDependencies": { "typescript": ">=4.5.0", "vue": "^3.5.11" } }, "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw=="], + + "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], + + "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="], + + "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], + + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + + "unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="], + + "unplugin-utils": ["unplugin-utils@0.3.1", "", { "dependencies": { "pathe": "^2.0.3", "picomatch": "^4.0.3" } }, "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + + "vue": ["vue@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", "@vue/runtime-dom": "3.5.29", "@vue/server-renderer": "3.5.29", "@vue/shared": "3.5.29" }, "peerDependencies": { "typescript": "*" } }, "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA=="], + + "vue-router": ["vue-router@5.0.3", "", { "dependencies": { "@babel/generator": "^7.28.6", "@vue-macros/common": "^3.1.1", "@vue/devtools-api": "^8.0.6", "ast-walker-scope": "^0.8.3", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.0", "muggle-string": "^0.4.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "scule": "^1.3.0", "tinyglobby": "^0.2.15", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", "yaml": "^2.8.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.17", "pinia": "^3.0.4", "vue": "^3.5.0" }, "optionalPeers": ["@pinia/colada"] }, "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw=="], + + "vue-tsc": ["vue-tsc@3.2.5", "", { "dependencies": { "@volar/typescript": "2.4.28", "@vue/language-core": "3.2.5" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": "bin/vue-tsc.js" }, "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + + "yaml": ["yaml@2.8.2", "", { "bin": "bin.mjs" }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "vue-router/@vue/devtools-api": ["@vue/devtools-api@8.0.7", "", { "dependencies": { "@vue/devtools-kit": "^8.0.7" } }, "sha512-tc1TXAxclsn55JblLkFVcIRG7MeSJC4fWsPjfM7qu/IcmPUYnQ5Q8vzWwBpyDY24ZjmZTUCCwjRSNbx58IhlAA=="], + + "mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.7", "", { "dependencies": { "@vue/devtools-shared": "^8.0.7", "birpc": "^2.6.1", "hookable": "^5.5.3", "perfect-debounce": "^2.0.0" } }, "sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw=="], + + "vue-router/@vue/devtools-api/@vue/devtools-kit/@vue/devtools-shared": ["@vue/devtools-shared@8.0.7", "", {}, "sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA=="], + + "vue-router/@vue/devtools-api/@vue/devtools-kit/perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + } +} diff --git a/frontend/css/base.css b/frontend/css/base.css new file mode 100644 index 0000000..5e23271 --- /dev/null +++ b/frontend/css/base.css @@ -0,0 +1,232 @@ +@import url('https://fonts.googleapis.com/css2?family=Raleway:wght@400;500;600;700&display=swap'); + +@font-face { + font-family: 'Ubuntu Sans'; + src: url('/fonts/ubuntu-sans/UbuntuSans[wght].woff2') format('woff2'); + font-weight: 100 900; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: 'Ubuntu Sans'; + src: url('/fonts/ubuntu-sans/UbuntuSans-Italic[wght].woff2') format('woff2'); + font-weight: 100 900; + font-style: italic; + font-display: swap; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } +[v-cloak] { display: none; } + +html, body { background: transparent; } + +:root { + /* ── Typography ── */ + --font-sans: 'Ubuntu Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'Ubuntu Sans Mono', 'Consolas', 'Monaco', monospace; + --text-base: 0.875rem; /* 14px — single base size for all UI text */ + + /* Surface colors - slate blue */ + --bg: #0f172a; + --surface: #1e293b; + --border: #334155; + --chat-bg: #0f172a; + --agent: #1e293b; + --agent-border:#475569; + --muted: #334155; + --muted-text: #94a3b8; + + /* Text */ + --text: #eee; + --text-dim: #888; + + /* Semantic colors */ + --accent: #818cf8; /* indigo — primary action / UI chrome */ + --user-bubble: #2d5a3d; /* muted green — user messages */ + --primary: #3b82f6; /* blue — info, links */ + --secondary: #6366f1; /* indigo — secondary badges */ + --success: #22c55e; /* green — yes, wrap up, positive */ + --success-dim: #4ade80; /* light green — connected lamp */ + --warn: #fbbf24; /* yellow — connecting, caution */ + --error: #f87171; /* red-light — errors, destructive */ + --focus: #4ade80; /* green — input focus rings */ + + /* Dim surface — used by system groups, subtle containers */ + --bg-dim: #1a2133; + + /* Disabled */ + --disabled-opacity: 0.4; + + /* Layout tokens */ + --space-page: 32px; /* horizontal page gutter */ + --space-gap: 8px; /* gap between stacked sections */ + --space-inset: 12px; /* padding inside components */ + --radius: 8px; /* cards, inputs, large buttons */ + --radius-sm: 6px; /* toolbar buttons, badges */ + --radius-panel: 12px; /* floating panels (sidebar, toolbar groups) */ + --height-nav: 40px; /* top nav bar */ + --height-btn: 32px; /* toolbar buttons */ + --text-sm: var(--text-base); /* legacy alias — same as base now */ + --text-xs: var(--text-base); /* legacy alias — same as base now */ + + /* Panel system — elevated floating containers */ + --panel-bg: #1a2540; + --panel-shadow: 0 2px 12px rgba(0,0,0,0.3), 0 0 1px rgba(255,255,255,0.05); + --panel-gap: 6px; /* gap between panels and edges */ +} + +/* Mobile overrides: applied per-element in view CSS files, not via global var changes. + CSS vars stay constant across all breakpoints to prevent resize flicker. */ + +/* ── ERAS theme (bright) ────────────────────────────────────── */ +[data-theme="eras"] { + --bg: #ffffff; + --surface: #f5f5f5; + --border: #e2e2e2; + --chat-bg: #fafafa; + --agent: #f0f0f0; + --agent-border:#d0d0d0; + --muted: #e8e8e8; + --muted-text: #8f8f8f; + + --text: #212121; + --text-dim: #646464; + + --accent: #005e83; /* eras teal-blue — nav, active, UI chrome */ + --user-bubble: #fef3e5; /* warm orange tint for user messages */ + --primary: #e25303; /* eras orange — links, actions */ + --secondary: #3567fd; + --success: #009490; /* eras teal */ + --success-dim: #00b8b3; + --warn: #ff8044; + --error: #df3131; + --focus: #e25303; + + --bg-dim: #f0ece8; + --disabled-opacity: 0.4; + + /* Markdown code blocks */ + --code-bg: #f0f0f0; + --text-bright: #212121; + + /* Nav links */ + --nav-link: #005e83; + --nav-link-active: #005e83; + + /* Send button */ + --send-btn-bg: #e25303; + --send-btn-color: #ffffff; + + /* Panel system */ + --panel-bg: #ffffff; + --panel-shadow: 0 2px 12px rgba(0,0,0,0.08), 0 0 1px rgba(0,0,0,0.12); +} + +html, body { + height: 100%; + margin: 0; + overflow: hidden; + /* iOS safe areas (notch, home indicator) */ + padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left); +} + +/* Prevent iOS auto-zoom on input focus — set base to 16px globally + (iOS zooms when font-size < 16px). No breakpoint needed. */ +input, select, textarea { font-size: 1rem; } + +body { + font-family: var(--font-sans); + font-size: var(--text-base); + background: var(--bg); + color: var(--text); + display: flex; + flex-direction: column; +} + +[data-theme="eras"] { + --font-sans: 'Raleway', 'Segoe UI', sans-serif; +} + +/* ── Workhorse theme (neutral dark + 6 happy kids colors) ─── */ +/* Palette: #73B96E #46915C #F25E44 #489FB2 #FFD96C #C895C0 */ +/* Backgrounds: neutral grays, not from palette */ +[data-theme="loop42"] { + --bg: #1A212C; + --surface: #222a36; + --border: #1D7872; + --chat-bg: #1e2630; + --agent: #222a36; + --agent-border:#1D7872; + --muted: #2a3340; + --muted-text: #71B095; + + --text: #e8e6e0; + --text-dim: #71B095; + + --accent: #1D7872; /* deep teal — grounded */ + --user-bubble: #2a3340; + --primary: #71B095; /* sage green — links */ + --btn-bg: #1D7872; + --btn-text: #e8e6e0; + --btn-hover: #71B095; + + --input-bg: #222a36; + --input-border:#1D7872; + --input-text: #e8e6e0; + + /* Panel system */ + --panel-bg: #1e2630; + --panel-shadow: 0 2px 12px rgba(0,0,0,0.35), 0 0 1px rgba(29,120,114,0.15); +} + +/* Scrollbars — OverlayScrollbars handles main containers. + Fallback for any native scrollbar we missed. */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--accent); } +/* Hide native scrollbar inside OverlayScrollbars viewports */ +[data-overlayscrollbars-viewport] { scrollbar-width: none; } +[data-overlayscrollbars-viewport]::-webkit-scrollbar { display: none; } + +/* Animations */ +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: var(--disabled-opacity); } +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0; } +} + +/* Emoji: use system font so sizing is consistent across platforms */ +.emoji, [role="img"] { + font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', sans-serif; + font-style: normal; +} + +/* Touch: prevent tap highlight on mobile */ +* { -webkit-tap-highlight-color: transparent; } + +/* Links */ +a { + color: var(--primary); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* ── Mobile: tighter spacing ── */ +@media (max-width: 480px) { + :root { + --space-page: 12px; + --space-inset: 8px; + } +} diff --git a/frontend/css/components.css b/frontend/css/components.css new file mode 100644 index 0000000..c3c2cf9 --- /dev/null +++ b/frontend/css/components.css @@ -0,0 +1,327 @@ +/* Select */ +select { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + padding: 8px 12px; + border-radius: var(--radius-sm); + font-size: inherit; + cursor: pointer; +} + + +/* Status lamp */ +.status-lamp { + width: 10px; + height: 10px; + border-radius: 50%; + background: #666; + transition: background 0.3s; +} +.status-lamp.connected { background: var(--success-dim); box-shadow: 0 0 8px var(--success-dim); } +.status-lamp.connecting { background: var(--warn); animation: pulse 1s infinite; } +.status-lamp.error { background: var(--error); box-shadow: 0 0 8px var(--error); } + +/* Nav controls */ +.nav-status { + display: flex; + align-items: center; + gap: 6px; + margin-right: 12px; +} + +.nav-status-text { color: var(--text-dim); } + +.nav-user { + color: var(--text-dim); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 12px; + padding: 2px 10px; + margin-right: 6px; +} + +.nav-logout-btn { + background: none; + border: none; + color: var(--text-dim); + padding: 3px 10px; + border-radius: 4px; + cursor: pointer; + margin-right: 0; + padding-right: 0; + transition: color 0.15s; + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-color: transparent; +} +.nav-logout-btn:hover { color: var(--error); text-decoration-color: var(--error); } + +.nav-login-btn { + color: white !important; + text-decoration: none !important; + padding: 3px 12px !important; + border: none !important; + background: var(--secondary); + border-radius: 12px; + margin-right: 12px; + transition: opacity 0.15s; + line-height: 1; + display: inline-flex; + align-items: center; +} +.nav-login-btn:hover { opacity: 0.85; } +.nav-login-btn.active, .nav-login-btn.router-link-active { + position: relative; +} +.nav-login-btn.active::after, .nav-login-btn.router-link-active::after { + content: ''; + position: absolute; + bottom: -8px; + left: 50%; + transform: translateX(-50%); + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--accent); +} + +/* Badges */ +.usage-badge { + color: var(--text-dim); + opacity: 0.7; + white-space: nowrap; +} + +.user-badge { + padding: 4px 8px; + background: var(--primary); + border-radius: var(--radius-sm); + color: white; + font-weight: 500; +} + +.session-badge { + padding: 4px 8px; + background: var(--secondary); + border-radius: var(--radius-sm); + color: white; + font-weight: 500; +} + +/* Nav SM state pill */ +.nav-sm-state { + position: absolute; + left: 50%; + transform: translateX(-50%); + padding: 3px 10px; + border-radius: 99px; + background: var(--muted); + color: var(--muted-text); + white-space: nowrap; + font-weight: 500; + transition: background 0.3s, color 0.3s; + pointer-events: none; +} +.nav-sm-state.IDLE { background: var(--muted); color: var(--muted-text); } +.nav-sm-state.AGENT_RUNNING { background: #1a3a5c; color: var(--warn); animation: pulse 1.5s infinite; } +.nav-sm-state.HANDOVER_PENDING { background: #2a2a1a; color: var(--warn); } +.nav-sm-state.HANDOVER_DONE { background: #1a3a1a; color: var(--success); } +.nav-sm-state.SWITCHING { background: #1e3a5f; color: #60a5fa; } +.nav-sm-state.CONNECTING { background: var(--muted); color: var(--warn); animation: pulse 1.5s infinite; } + +.toolbar-sm-state { + padding: 2px 8px; + border-radius: 99px; + background: var(--muted); + color: var(--muted-text); + white-space: nowrap; + font-weight: 500; + transition: background 0.3s, color 0.3s; + pointer-events: none; +} +.toolbar-sm-state.IDLE { background: var(--muted); color: var(--muted-text); } +.toolbar-sm-state.AGENT_RUNNING { background: #1a3a5c; color: var(--warn); animation: pulse 1.5s infinite; } +.toolbar-sm-state.HANDOVER_PENDING { background: #2a2a1a; color: var(--warn); } +.toolbar-sm-state.HANDOVER_DONE { background: #1a3a1a; color: var(--success); } +.toolbar-sm-state.SWITCHING { background: #1e3a5f; color: #60a5fa; } +.toolbar-sm-state.CONNECTING { background: var(--muted); color: var(--warn); animation: pulse 1.5s infinite; } +.toolbar-sm-state.STOP_PENDING { background: #3a1a1a; color: var(--error); } + +.version-badge { + cursor: pointer; + user-select: none; + padding: 4px 8px; + background: var(--muted); + border-radius: var(--radius-sm); + color: var(--muted-text); + font-weight: 500; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 5px; + transition: color 0.15s; +} +.version-badge:hover { color: var(--text); } +.version-copy-icon { opacity: 0.6; } +.version-badge:hover .version-copy-icon { opacity: 1; } + +.ws-status { + color: var(--text-dim); + padding: 4px 8px; + border-radius: var(--radius-sm); + background: var(--bg); + min-width: 0; + text-align: center; +} + +/* Agent selector */ +.agents-header { + display: flex; + align-items: center; + gap: var(--space-gap); + padding: 0; + flex-shrink: 0; +} + +.agent-selection-group { + display: flex; + align-items: center; + gap: var(--space-gap); +} + +.or-separator { color: var(--text-dim); } + +.default-agent-btn { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + padding: 6px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.2s, border-color 0.2s; + white-space: nowrap; +} +.default-agent-btn:hover { background: var(--accent); border-color: var(--accent); } +.default-agent-btn:disabled { + opacity: var(--disabled-opacity); + cursor: default; + background: var(--surface); + border-color: var(--border); + color: var(--text-dim); +} + + +.footer-buttons { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 8px; +} + +.handover-btn { + background: var(--user-bubble); + color: #a8d5b5; + border: none; + padding: 7px 16px; + border-radius: var(--radius-sm); + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} +.handover-btn:hover { opacity: 0.85; } +.handover-btn:disabled { opacity: var(--disabled-opacity); cursor: default; } + +/* HUD confirm buttons — enabled only when handover is pending */ +.confirm-new-btn { + background: var(--accent); + color: #fff; + border: none; + padding: 7px 16px; + border-radius: var(--radius-sm); + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} +.confirm-new-btn:hover:not(:disabled) { opacity: 0.85; } +.confirm-new-btn:disabled { opacity: var(--disabled-opacity); cursor: default; } + +.stay-btn { + background: var(--bg-dim); + color: var(--text-dim); + border: 1px solid var(--border); + padding: 7px 16px; + border-radius: var(--radius-sm); + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; +} +.stay-btn:hover:not(:disabled) { opacity: 0.85; } +.stay-btn:disabled { opacity: var(--disabled-opacity); cursor: default; } + +.handover-preview { + margin: 8px 0; + padding: 10px 12px; + background: var(--bg); + border-radius: var(--radius-sm); + color: var(--text); + white-space: pre-wrap; + max-height: 320px; + overflow-y: auto; + text-align: left; +} + +.handover-context-header { + cursor: pointer; + user-select: none; + display: flex; + align-items: center; + gap: 6px; +} +.handover-context-header:hover { opacity: 0.8; } +.handover-toggle { + opacity: 0.6; + font-weight: normal; +} + +.confirm-new-btn { + display: inline-block; + margin-top: 6px; + background: var(--user-bubble); + color: #a8d5b5; + border: none; + padding: 4px 12px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: opacity 0.15s; +} +.confirm-new-btn:hover { opacity: 0.85; } + +/* Logout / misc */ +.logout-btn { + padding: 4px 10px; + background: transparent; + border: 1px solid var(--border); + color: var(--text-dim); + border-radius: 4px; + cursor: pointer; +} +.logout-btn:hover { border-color: var(--accent); color: var(--accent); } + +/* Not logged in */ +.not-logged-in { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 100%; + gap: var(--space-page); + color: var(--text-dim); +} +.not-logged-in a { color: var(--accent); text-decoration: none; font-weight: 600; } +.not-logged-in a:hover { text-decoration: underline; } + +/* ── Mobile — visibility + touch targets only, no spacing changes ── */ +@media (max-width: 639px) { + .nav-user { display: none; } +} diff --git a/frontend/css/layout.css b/frontend/css/layout.css new file mode 100644 index 0000000..6f9c61b --- /dev/null +++ b/frontend/css/layout.css @@ -0,0 +1,184 @@ +.app-container { + display: flex; + flex-direction: column; + height: 100vh; +} + +/* legacy alias kept for any views that still reference it */ + + +.page { + padding-left: var(--space-page); + padding-right: var(--space-page); +} + +.content { + padding-left: var(--space-inset); + padding-right: var(--space-inset); +} +/* Agents: scroll container extends to viewport edge for scrollbar alignment */ +.agents-view .content { + padding-right: 0; + margin-right: calc(-1 * var(--space-page)); +} + +.view-header { + display: flex; + align-items: center; + gap: var(--space-gap); + padding-top: var(--space-gap); + padding-bottom: var(--space-gap); + flex-shrink: 0; +} + +.content-area { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; +} + +.main-footer { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + position: relative; + height: 44px; + background: transparent; + border-top: none; + color: var(--text-dim); + flex-shrink: 0; +} + +.main-footer .version-label { + color: var(--text-dim); + font-family: var(--font-mono); + cursor: default; + opacity: 0.4; +} + +.main-nav { + display: flex; + align-items: center; + gap: 0; + padding: 0 var(--space-page); + background: var(--panel-bg); + border-radius: var(--radius-panel); + box-shadow: var(--panel-shadow); + height: var(--height-nav); + position: relative; + margin: var(--panel-gap); +} + +.main-nav a { + color: var(--nav-link, var(--text-dim)); + padding: 8px var(--space-page); + text-decoration: none; + position: relative; + transition: color 0.15s; +} +.main-nav a:first-of-type { + padding-left: 0; +} + +.main-nav a.active { + color: var(--nav-link-active, var(--text)); +} + +.main-nav a::after { display: none; } + +/* Sliding dot — single element on the nav itself */ +.main-nav { + --dot-left: 50%; +} + +.main-nav::after { + content: ''; + position: absolute; + bottom: 0; + left: var(--dot-left); + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--accent); + transition: left 0.25s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: none; +} + +.main-nav a:hover:not(.active) { color: var(--nav-link-active, var(--text)); } + +.main-nav .spacer { margin-left: auto; } + +.nav-home-logo { + display: flex; + align-items: center; + justify-content: center; + padding: 8px 12px 8px 0 !important; +} + +.nav-theme-icon { + width: 22px; + height: 22px; + stroke-width: 1.5; +} + +.nav-home-logo .nav-agent-logo { + width: 28px; + height: 22px; + object-fit: contain; +} + +.app-layout { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; +} + +/* Agents view — full-height flex column */ +.agents-view { + flex: 1; + align-self: stretch; + display: flex; + flex-direction: column; + overflow: hidden; + background: transparent; +} + +.agent-column { + width: auto; + background: var(--panel-bg); + border-radius: var(--radius-panel); + box-shadow: var(--panel-shadow); + padding: var(--space-page); + flex-shrink: 0; + overflow-y: auto; + min-height: 0; +} + +.chat-column { + flex-grow: 1; + position: relative; + overflow: hidden; +} + +.chat-frame { + flex: 1 1 auto; + min-height: 0; + display: flex; + flex-direction: column; + gap: var(--space-gap); + margin: 0; +} + +/* ── Mobile — visibility only, no spacing changes ── */ +@media (max-width: 639px) { + .nav-agent-logo { + width: 22px; + margin-right: 2px; + } +} diff --git a/frontend/css/markdown.css b/frontend/css/markdown.css new file mode 100644 index 0000000..d42dc56 --- /dev/null +++ b/frontend/css/markdown.css @@ -0,0 +1,53 @@ +/* Markdown body — global (not scoped) because v-html bypasses scoped styles. + box-shadow trick: never serialized into clipboard HTML, so dark code blocks + render correctly in the viewer but paste clean into Google Docs / Word. */ + +.md-body { + color: var(--text); + font-family: var(--font-sans); + line-height: 1.7; + max-width: 100%; + width: 100%; + overflow-wrap: break-word; + word-break: break-word; +} +.md-body p, .md-body li, .md-body td, .md-body th { color: inherit; } +.md-body h1, .md-body h2, .md-body h3, .md-body h4 { + color: var(--text-bright, #fff); + margin: 1.4em 0 0.4em; + font-weight: 600; +} +.md-body h1 { font-size: 1.4em; border-bottom: 1px solid var(--border); padding-bottom: 4px; } +.md-body h2 { font-size: 1.15em; } +.md-body h3 { font-size: 1em; } +.md-body p { margin: 0.5em 0; } +.md-body a { color: var(--accent); } +.md-body code { + background: transparent; + box-shadow: inset 0 0 0 999px var(--code-bg, #1e2227); + padding: 1px 5px; + border-radius: 3px; + font-family: var(--font-mono); +} +.md-body pre { + background: transparent; + box-shadow: inset 0 0 0 999px var(--code-bg, #1e2227); + padding: 12px 16px; + border-radius: 6px; + overflow-x: auto; + max-width: 100%; + margin: 0.8em 0; +} +.md-body pre code { background: none; padding: 0; } +.md-body blockquote { + border-left: 3px solid var(--accent); + margin: 0.5em 0; + padding: 2px 12px; + color: var(--text-dim); +} +.md-body ul, .md-body ol { padding-left: 1.5em; margin: 0.4em 0; } +.md-body li { margin: 0.2em 0; } +.md-body table { border-collapse: collapse; width: 100%; margin: 0.8em 0; display: block; overflow-x: auto; } +.md-body th, .md-body td { border: 1px solid var(--border); padding: 4px 10px; } +.md-body th { background: var(--code-bg, #1e2227); color: var(--text-bright, #fff); } +.md-body hr { border: none; border-top: 1px solid var(--border); margin: 1.2em 0; } diff --git a/frontend/css/scrollbar.css b/frontend/css/scrollbar.css new file mode 100644 index 0000000..3ad1229 --- /dev/null +++ b/frontend/css/scrollbar.css @@ -0,0 +1,29 @@ +/* OverlayScrollbars — 3 states: not visible, visible (dim), visible+hovered (bright) */ +.os-scrollbar .os-scrollbar-track { + background: transparent; +} +.os-scrollbar .os-scrollbar-handle { + background: var(--text-muted, rgba(255, 255, 255, 0.15)); + border-radius: 3px; + opacity: 0.4; + transition: opacity 0.2s, background 0.2s; +} + +/* Hover anywhere inside the scrollable host → brighter */ +[data-overlayscrollbars-initialize]:hover .os-scrollbar-handle { + opacity: 0.7; + background: var(--text-dim); +} + +/* Hover directly on scrollbar → full brightness */ +.os-scrollbar:hover .os-scrollbar-handle { + opacity: 1; + background: var(--text-dim); +} + +/* Actively dragging the handle */ +.os-scrollbar .os-scrollbar-handle.active, +.os-scrollbar .os-scrollbar-handle:active { + opacity: 1; + background: var(--text); +} diff --git a/frontend/css/sidebar.css b/frontend/css/sidebar.css new file mode 100644 index 0000000..771ebd5 --- /dev/null +++ b/frontend/css/sidebar.css @@ -0,0 +1,785 @@ +/* ── Sidebar layout ───────────────────────────────────────────── */ + +:root { + --sidebar-width: 240px; + --sidebar-collapsed-width: 48px; + --sidebar-header-height: 40px; /* aligns with content-top in views */ +} + +/* ── App body: sidebar + content column ── */ +.app-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: row; + overflow: hidden; +} + +.main-column { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* ── Sidebar ── */ +.app-sidebar { + position: fixed; + top: var(--panel-gap); + left: var(--panel-gap); + height: calc(100% - var(--panel-gap) * 2); + width: var(--sidebar-width); + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--panel-bg); + border-radius: var(--radius-panel); + box-shadow: var(--panel-shadow); + padding: 0 6px; + transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1); + overflow: visible; + z-index: 100; +} + +/* Fade text/indicators on expand/collapse */ +.sidebar-logo-label, +.sidebar-room-name, +.sidebar-segment-label, +.sidebar-channel-indicators, +.sidebar-room-mode-btn, +.sidebar-chevron-btn, +.sidebar-capture-btn, +.sidebar-link span, +.sidebar-user-name { + opacity: 1; + transition: opacity 0.15s ease 0.1s; /* 0.1s delay so width expands first */ +} + +.app-sidebar.is-collapsed .sidebar-logo-label, +.app-sidebar.is-collapsed .sidebar-room-name, +.app-sidebar.is-collapsed .sidebar-segment-label, +.app-sidebar.is-collapsed .sidebar-channel-indicators, +.app-sidebar.is-collapsed .sidebar-room-mode-btn, +.app-sidebar.is-collapsed .sidebar-chevron-btn, +.app-sidebar.is-collapsed .sidebar-capture-btn, +.app-sidebar.is-collapsed .sidebar-link span, +.app-sidebar.is-collapsed .sidebar-user-name { + opacity: 0; + transition: opacity 0.05s ease; /* fade out fast */ + pointer-events: none; + position: relative; + z-index: 20; +} + +.app-sidebar.is-collapsed { + width: var(--sidebar-collapsed-width); +} + +/* When expanded: enable click target */ +.app-sidebar:not(.is-collapsed) .sidebar-close-target { + pointer-events: auto; +} + +/* Legacy gradient shadow — replaced by panel box-shadow */ +.sidebar-shadow { + display: none; +} + +/* Invisible click target to close sidebar — behind sidebar content */ +.sidebar-close-target { + position: fixed; + inset: 0; + z-index: -1; + opacity: 0; + pointer-events: none; +} + +/* ── Sidebar header (logo + collapse) ── */ +.sidebar-header { + height: var(--sidebar-header-height); + display: flex; + align-items: center; + flex-shrink: 0; + gap: 0; +} + +.sidebar-header { overflow: hidden; } +.app-sidebar.is-collapsed .sidebar-header { cursor: pointer; } + +/* Brand: logo + name centered (flex:1 fills remaining space) */ +.sidebar-brand { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 100%; + color: var(--text); + text-decoration: none; + cursor: pointer; + transition: opacity 0.15s; + overflow: hidden; + white-space: nowrap; + /* Offset for chevron width so brand is visually centered in sidebar */ + padding-right: var(--sidebar-collapsed-width); +} +.sidebar-brand:hover { opacity: 0.8; text-decoration: none; } + +/* Chevron rotation animation */ +.sidebar-chevron-anim { + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.sidebar-brand-logo, +.sidebar-brand-icon { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.sidebar-brand-name { + font-weight: 600; + font-size: var(--text-base); + color: var(--text); + white-space: nowrap; +} + +/* Toggle button: right side when expanded, centered when collapsed */ +.sidebar-toggle-btn { + width: var(--sidebar-collapsed-width); + height: 100%; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + color: var(--text-dim); + transition: color 0.15s, background 0.15s; +} +.sidebar-toggle-btn:hover { color: var(--text); background: color-mix(in srgb, var(--accent) 8%, transparent); } + +/* Icon slot: fixed 48px centered area — never moves on collapse */ +.sidebar-logo-img, +.sidebar-theme-icon { + flex-shrink: 0; + width: var(--sidebar-collapsed-width); + display: flex; + align-items: center; + justify-content: center; +} +.sidebar-logo-img { + height: 18px; + object-fit: contain; + padding: 0 14px; + box-sizing: border-box; +} +.sidebar-theme-icon { + height: 20px; + stroke-width: 1.5; + color: var(--accent); + padding: 0 14px; + box-sizing: border-box; +} +.sidebar-logo-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); + letter-spacing: 0.02em; +} + +.sidebar-chevron-icon { + margin-left: auto; + opacity: 0.5; + flex-shrink: 0; +} + +.sidebar-chevron-btn { + width: var(--sidebar-collapsed-width); + height: 100%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: none; + border: none; + cursor: pointer; + color: var(--text-dim); + transition: color 0.15s, background 0.15s; +} +.sidebar-chevron-btn:hover { color: var(--text); background: color-mix(in srgb, var(--accent) 8%, transparent); } + +/* ── Rooms (agents) ── */ +.sidebar-rooms { + flex: 1 1 0; + min-height: 0; + padding: 4px 0; + display: flex; + flex-direction: column; + gap: 1px; + overflow-x: hidden; + overflow-y: auto; +} + +/* Segment label */ +.sidebar-segment-label { + padding: 8px 14px 2px; + height: 27px; + flex-shrink: 0; + font-size: var(--text-base); + font-weight: 600; + letter-spacing: 0.08em; + text-transform: capitalize; + color: var(--text-dim); + opacity: 0.5; + user-select: none; +} + +/* Agent row: icon slot is always 48px wide, centered */ +.sidebar-room { + display: flex; + align-items: center; + gap: 0; + padding: 0 10px 0 0; + height: 30px; + min-height: 30px; + flex-shrink: 0; + color: var(--text-dim); + text-decoration: none; + font-size: var(--text-base); + white-space: nowrap; + overflow: hidden; + transition: background 0.12s, color 0.12s; + border-radius: var(--radius-sm); + cursor: pointer; +} +.sidebar-room:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--text); } +.sidebar-room.active { color: var(--text); background: color-mix(in srgb, var(--accent) 12%, transparent); } + +/* ── Role dots ── */ +.sidebar-room-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + position: relative; + /* Center dot in 48px icon slot (minus 6px panel padding when expanded) */ + margin-left: 14px; + margin-right: 12px; +} + + +/* owner: solid filled dot */ +.dot-owner { + background: var(--accent); + box-shadow: 0 0 0 0px transparent; +} + +/* member: outlined ring (dot + same-color ring) */ +.dot-member { + background: transparent; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 60%, transparent); +} + +/* common: small dot + outer ring gap */ +.dot-common { + background: #34d399; + box-shadow: 0 0 0 2px color-mix(in srgb, #34d399 40%, transparent); +} + +/* guest: dashed ring (simulate with border) */ +.dot-guest { + background: transparent; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--text-dim) 40%, transparent); +} + +/* utility: dim solid */ +.dot-utility { + background: color-mix(in srgb, var(--text-dim) 35%, transparent); +} + +.sidebar-room-placeholder { pointer-events: none; opacity: 0; } +.sidebar-room-name { + overflow: hidden; + text-overflow: ellipsis; +} + +.sidebar-room-mode-btn { + margin-left: auto; + color: var(--text-dim); + opacity: 0.55; + white-space: nowrap; + flex-shrink: 0; + text-decoration: none; + padding: 1px 6px; + border-radius: 4px; + cursor: pointer; +} +.sidebar-room-mode-btn:hover { opacity: 1; color: var(--text); background: color-mix(in srgb, var(--accent) 12%, transparent); } +.sidebar-room-mode-btn.active { opacity: 1; color: var(--accent); } + +/* Collapsed: keep segment labels same height for Y alignment, just hide text */ +.sidebar-segment-label.is-collapsed { + visibility: hidden; +} + +/* ── Spacer ── */ +.sidebar-spacer { + flex: 1; +} + +/* ── Home section (above agents) ── */ +.sidebar-home-section { +} + +/* ── Nav links (viewer, dev) ── */ +.sidebar-nav-links { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 0; + overflow: hidden; +} + +/* Shared row style: icon in fixed 48px slot, text after */ +.sidebar-link { + display: flex; + align-items: center; + gap: 0; + padding: 0; + height: 32px; + color: var(--text-dim); + text-decoration: none; + font-size: var(--text-base); + white-space: nowrap; + overflow: hidden; + transition: background 0.12s, color 0.12s; + cursor: pointer; + background: none; + border: none; + width: 100%; + text-align: left; +} +.sidebar-link:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--text); } +.sidebar-link.active { color: var(--accent); } +.sidebar-link svg { + flex-shrink: 0; + /* Center 16px icon in 48px slot (minus 6px panel padding) */ + width: 16px; + height: 16px; + margin-left: 10px; + margin-right: 10px; +} + + +/* ── Channel state indicators (dual: private + public) ── */ +.sidebar-room { position: relative; } +.sidebar-channel-indicators { + position: absolute; + right: 44px; + top: 0; + display: flex; + gap: 3px; + align-items: center; + height: 30px; + pointer-events: none; + font-size: var(--text-base); +} +.sidebar-ch-dot { opacity: 0.7; } +.sidebar-ch-dot.ch-running { animation: pulse 2s infinite; } +.sidebar-ch-dot.ch-ready { opacity: 0.3; } +.sidebar-ch-dot.ch-fresh { opacity: 0.6; } +.sidebar-ch-dot.ch-nosession { opacity: 0.15; } +.sidebar-ch-dot.ch-none { opacity: 0.15; } + +/* ── Connection status ── */ +.sidebar-connection { + padding: 0; +} +.sidebar-connection .sidebar-link { color: var(--text-dim); opacity: 0.4; } +.sidebar-connection.active .sidebar-link { color: var(--success, #22c55e); opacity: 0.7; } + +/* ── Connection status panel ── */ +.sidebar-conn-wrap { + position: relative; +} + +/* ── Takeover status ── */ +.sidebar-takeover-wrap { + padding: 0; + position: relative; +} +.sidebar-takeover-row { + display: flex; + align-items: center; +} +.sidebar-takeover-row .sidebar-link { flex: 1; } +.sidebar-takeover-wrap .sidebar-link { color: var(--text-dim); opacity: 0.4; } +.sidebar-takeover-wrap.active .sidebar-link { color: var(--success, #22c55e); opacity: 0.7; } +.sidebar-takeover-wrap.active .sidebar-link svg { animation: pulse 2s infinite; } + +.sidebar-capture-btn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 10px; + border-radius: var(--radius-sm); + background: none; + border: none; + cursor: pointer; + color: var(--text-dim); + opacity: 0.5; + transition: opacity 0.15s, color 0.15s, background 0.15s; +} +.sidebar-capture-btn:hover { opacity: 1; background: color-mix(in srgb, var(--accent) 8%, transparent); } +.sidebar-capture-btn.active { color: var(--success, #22c55e); opacity: 1; } +.sidebar-capture-btn.active svg { animation: pulse 2s infinite; } + +/* ── Bottom (user) ── */ +.sidebar-bottom { + padding: 4px 0; + position: relative; +} + +.sidebar-user-wrap { + position: relative; +} + +.sidebar-user-btn { + display: flex; + align-items: center; + gap: 0; + padding: 0; + height: 32px; + width: 100%; + background: none; + border: none; + cursor: pointer; + color: var(--text-dim); + font-size: var(--text-base); + white-space: nowrap; + overflow: hidden; + transition: background 0.12s, color 0.12s; + text-align: left; +} +.sidebar-user-btn:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--text); } +.sidebar-user-btn svg { + flex-shrink: 0; + width: 16px; + height: 16px; + margin-left: 10px; + margin-right: 10px; +} + +.sidebar-user-name { overflow: hidden; text-overflow: ellipsis; } + +/* ── Panel backdrop: closes any open panel on click ── */ +.sidebar-panel-backdrop { + position: fixed; + inset: 0; + z-index: 150; +} + +/* ── Shared popup panel style ── */ +.sidebar-panel, +.sidebar-user-menu { + position: absolute; + bottom: 0; + left: 100%; + margin-left: 4px; + width: 220px; + background: var(--panel-bg); + border-radius: var(--radius-panel); + box-shadow: var(--panel-shadow); + z-index: 200; /* above .sidebar-panel-backdrop (150) */ + overflow: hidden; +} +.sidebar-panel-header { + padding: 8px 12px 4px; + font-size: var(--text-base); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: capitalize; + color: var(--text-dim); +} +.sidebar-panel-token { + padding: 8px 12px; + cursor: pointer; + transition: background 0.12s; + position: relative; +} +.sidebar-panel-token:hover { background: color-mix(in srgb, var(--accent) 8%, transparent); } +.sidebar-panel-token code { + font-size: var(--text-base); + color: var(--accent); + word-break: break-all; +} +.sidebar-panel-copied { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + font-size: var(--text-base); + color: var(--success, #22c55e); +} +.sidebar-panel-row { + display: flex; + justify-content: space-between; + padding: 6px 12px; + font-size: var(--text-base); + color: var(--text-dim); +} +.sidebar-panel-item { + display: block; + width: 100%; + padding: 7px 12px; + background: none; + border: none; + cursor: pointer; + color: var(--text); + font-size: var(--text-base); + text-align: left; + transition: background 0.12s; +} +.sidebar-panel-item:hover { background: color-mix(in srgb, var(--error) 12%, transparent); color: var(--error); } +.sidebar-user-menu-header { + padding: 8px 12px 4px; + font-size: var(--text-base); + color: var(--text-dim); +} +.sidebar-user-menu-item { + display: block; + width: 100%; + padding: 7px 12px; + background: none; + border: none; + cursor: pointer; + color: var(--text); + font-size: var(--text-base); + text-align: left; + transition: background 0.12s; +} +.sidebar-user-menu-item:hover { background: color-mix(in srgb, var(--error) 12%, transparent); color: var(--error); } + +/* Collapsed: smaller menu width */ +.app-sidebar.is-collapsed .sidebar-user-menu { + width: 160px; +} + +/* ── Sidebar spacer (reserves rail width on all screens) ── */ +.sidebar-spacer { + display: block; + width: var(--sidebar-collapsed-width); + min-width: var(--sidebar-collapsed-width); + max-width: var(--sidebar-collapsed-width); + flex: 0 0 var(--sidebar-collapsed-width); +} + +/* ── Version ── */ +.sidebar-version-wrap { + position: relative; + flex-shrink: 0; +} +.sidebar-version-wrap.is-hidden { + visibility: hidden; + pointer-events: none; +} +.sidebar-version { + display: block; + width: 100%; + padding: 4px 12px; + text-align: left; + font-size: var(--text-base); + color: var(--text-dim); + opacity: 0.4; + cursor: pointer; + user-select: none; + background: none; + border: none; + transition: opacity 0.15s; +} +.sidebar-version:hover { opacity: 0.8; } +.sidebar-version-panel { + bottom: 100%; + margin-bottom: 4px; +} + +/* ── Large screens: sidebar in flow, pushes content ── */ +@media (min-width: 1024px) { + .app-sidebar { + position: relative; + top: auto; + left: auto; + height: auto; + z-index: auto; + margin: var(--panel-gap); + margin-right: 0; + } + .app-sidebar:not(.is-collapsed) .sidebar-close-target { + pointer-events: none; + } + /* Spacer matches sidebar width (not fixed 48px) */ + .sidebar-spacer { + display: none; + } +} + +/* ── Mobile tweaks ── */ +@media (max-width: 480px) { + /* Panels: constrain to viewport */ + .sidebar-panel, + .sidebar-user-menu { + width: min(220px, calc(100vw - var(--sidebar-collapsed-width) - 16px)) !important; + } + /* Hide takeover panel on mobile — use /dev on desktop */ + .sidebar-takeover-wrap .sidebar-panel { display: none !important; } +} + +/* Top section: default agent + files — shrink-to-fit, shrink when needed */ +.sidebar-top-section { + flex-shrink: 0; + display: flex; + flex-direction: column; + min-height: 0; +} +.sidebar-top-section.has-tree { + flex-shrink: 1; + overflow: hidden; +} +.sidebar-top-section .sidebar-home { + flex-shrink: 0; + padding: 4px 0; +} +.sidebar-file-scroll { + min-height: 0; + font-size: var(--text-base); + overflow: hidden; +} + +/* File sections: toggle row + collapsible tree */ +.sidebar-file-section { + display: flex; + flex-direction: column; + min-height: 0; + flex-shrink: 0; +} +.sidebar-file-section.is-open { + flex-shrink: 1; + min-height: 0; + overflow: hidden; +} +.sidebar-file-toggle { + flex-shrink: 0; +} +.sidebar-file-chev { + margin-left: auto; + color: var(--text-dim); + opacity: 0.3; + flex-shrink: 0; + margin-right: 10px; +} +.sidebar-file-toggle:hover .sidebar-file-chev { opacity: 1; } + +/* Agents nav link */ +.sidebar-nav-agents { + flex-shrink: 0; + padding: 4px 0; +} + +/* File tree in sidebar — inherit sidebar font */ +.sidebar-panel-section .file-tree { + font-size: var(--text-base); + font-family: inherit; +} + +/* Segment divider inside agents panel */ +.sidebar-segment-divider { + font-size: var(--text-base); + text-transform: capitalize; + letter-spacing: 0.05em; + color: var(--text-dim); + opacity: 0.5; + padding: 6px 14px 2px; +} + +/* ── Collapsed top ── */ +.sidebar-collapsed-top { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 0; +} + +/* ── Flex spacer (both states) ── */ +.sidebar-flex-spacer { + flex: 1; +} + +/* ── Unified bottom section ── */ +.sidebar-bottom-section { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 0; + position: relative; + z-index: 160; /* above backdrop (150) so buttons remain clickable */ +} +.sidebar-bottom { + position: relative; + z-index: 160; +} +.sidebar-bottom-section .sidebar-conn-link.active { color: var(--success, #22c55e); } +.sidebar-bottom-section .sidebar-takeover-wrap.active .sidebar-link { color: var(--success, #22c55e); } +.sidebar-bottom-section .sidebar-takeover-wrap.active .sidebar-link svg { animation: pulse 2s infinite; } + +.sidebar-version-link { + color: var(--text-dim) !important; + opacity: 0.5; +} +.sidebar-version-link:hover { opacity: 0.8 !important; } +.sidebar-version-text { + font-size: 0.65rem; + white-space: nowrap; + width: var(--sidebar-collapsed-width); + text-align: center; + /* Don't fade on collapse — it's the only content */ + opacity: 1 !important; + pointer-events: auto; +} + +/* SidebarPanel and system section above backdrop */ +.sidebar-panel-section { position: relative; z-index: 160; } +.sidebar-system-section { position: relative; z-index: 160; overflow: visible; } +.sidebar-system-content { overflow: visible; } +.sidebar-system-toggle { opacity: 0.5; } +.sidebar-system-toggle:hover { opacity: 0.8; } + +/* Clean up inside SidebarPanel */ +.sidebar-panel-section .sidebar-nav-links { padding: 0; } + +/* Compact bottom items: same size expanded and collapsed */ +.sidebar-bottom-section .sidebar-link, +.sidebar-panel-content .sidebar-conn-link, +.sidebar-panel-content .sidebar-takeover-wrap .sidebar-link, +.sidebar-panel-content .sidebar-version-link { + height: 30px; +} +.sidebar-panel-content .sidebar-conn-link svg, +.sidebar-panel-content .sidebar-takeover-wrap .sidebar-link svg { + width: 14px; + height: 14px; + margin-left: 11px; + margin-right: 10px; +} +.sidebar-bottom-section .sidebar-link svg { + width: 14px; + height: 14px; + margin-left: 11px; + margin-right: 10px; +} + diff --git a/frontend/css/styles.css b/frontend/css/styles.css new file mode 100644 index 0000000..463ad71 --- /dev/null +++ b/frontend/css/styles.css @@ -0,0 +1,10 @@ +@import './base.css'; +@import './scrollbar.css'; +@import './layout.css'; +@import './sidebar.css'; +@import './components.css'; +@import './markdown.css'; +@import './views/agents.css'; +@import './views/home.css'; +@import './views/login.css'; +@import './views/dev.css'; diff --git a/frontend/css/tailwind.css b/frontend/css/tailwind.css new file mode 100644 index 0000000..db35fe7 --- /dev/null +++ b/frontend/css/tailwind.css @@ -0,0 +1,92 @@ +@import "tailwindcss"; + +/* ── Map existing CSS vars into Tailwind theme ── + This lets you write bg-surface, text-accent, border-border etc. + Values reference your :root vars, so theme switching (Titan/ERAS) works automatically. */ + +@theme { + /* Colors — mapped from :root CSS vars */ + --color-bg: var(--bg); + --color-bg-dim: var(--bg-dim); + --color-surface: var(--surface); + --color-border: var(--border); + --color-chat-bg: var(--chat-bg); + --color-agent: var(--agent); + --color-agent-border:var(--agent-border); + --color-muted: var(--muted); + --color-muted-text: var(--muted-text); + + --color-text: var(--text); + --color-text-dim: var(--text-dim); + + --color-accent: var(--accent); + --color-user-bubble: var(--user-bubble); + --color-primary: var(--primary); + --color-secondary: var(--secondary); + --color-success: var(--success); + --color-success-dim: var(--success-dim); + --color-warn: var(--warn); + --color-error: var(--error); + --color-focus: var(--focus); + + /* Typography */ + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + + /* Spacing tokens */ + --spacing-page: var(--space-page); + --spacing-gap: var(--space-gap); + --spacing-inset: var(--space-inset); + + /* Border radius */ + --radius-lg: var(--radius); + --radius-sm: var(--radius-sm); +} + +/* ── Component classes ── + Reusable semantic classes built from Tailwind utilities. + Use these in templates for common patterns. */ + +@utility card { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: var(--space-inset); +} + +@utility badge { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.5rem; + border-radius: var(--radius-sm); + font-weight: 500; + white-space: nowrap; +} + +@utility btn { + display: inline-flex; + align-items: center; + padding: 0.375rem 1rem; + border-radius: var(--radius-sm); + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s, background-color 0.15s, color 0.15s; +} + +@utility btn-primary { + background-color: var(--accent); + color: white; + border: none; +} + +@utility btn-outline { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); +} + +@utility btn-danger { + background: none; + border: 1px solid var(--error); + color: var(--error); +} diff --git a/frontend/css/views/agents.css b/frontend/css/views/agents.css new file mode 100644 index 0000000..b0a20db --- /dev/null +++ b/frontend/css/views/agents.css @@ -0,0 +1,433 @@ +/* Messages list — padding from .page wrapper, viewport is flex column */ +.messages { + flex: 1 1 auto; + min-height: 0; + background: transparent; +} +.messages [data-overlayscrollbars-viewport] { + display: flex !important; + flex-direction: column; + gap: var(--space-gap); + padding: var(--space-inset) 0; +} +.messages [data-overlayscrollbars-viewport] > * { + margin-right: calc(var(--space-page) + var(--space-inset)); +} + +/* Load more */ +.load-more-btn { + align-self: center; + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + padding: 4px 14px; + border-radius: var(--radius-sm); + cursor: pointer; + margin-bottom: var(--space-gap); + transition: color 0.15s, border-color 0.15s; +} +.load-more-btn:hover { color: var(--text); border-color: var(--text-dim); } + +/* Message bubbles */ +.message { + max-width: 80%; + padding: 12px 18px; + border-radius: var(--radius); + line-height: 1.5; + white-space: normal; + text-indent: 0; + position: relative; + overflow-wrap: break-word; + word-break: break-word; + min-width: 0; +} + +.message.user { + align-self: flex-end; + background: var(--user-bubble); + white-space: pre-wrap; + margin-top: var(--space-page); +} + +.message.assistant { + align-self: flex-start; + background: var(--surface); +} + +.message.thinking { + align-self: flex-start; + background: rgba(139, 92, 246, 0.08); + border: 1px solid rgba(139, 92, 246, 0.25); + color: #a78bfa; + padding: 6px 12px; + border-radius: var(--radius); + max-width: 80%; +} +.message.thinking summary { + cursor: pointer; + font-style: italic; + opacity: 0.8; + user-select: none; + list-style: none; +} +.message.thinking summary::-webkit-details-marker { display: none; } +.message.thinking .thinking-content { + margin-top: 8px; + white-space: pre-wrap; + word-break: break-word; + font-family: var(--font-mono); + color: #c4b5fd; + opacity: 0.85; + max-height: 300px; + overflow-y: auto; +} + +.message.system { + align-self: flex-start; + background: transparent; + border: none; + color: var(--text-dim); + padding: 1px 8px; + margin: -2px 0; + max-width: 100%; + opacity: 0.7; +} + + +.message .bubble-footer:not(:empty) { + color: var(--text-dim); + margin-top: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: none; +} + +.message hr { border: none; border-top: 1px solid var(--border); margin: 1.2rem 0.5rem; opacity: 0.4; } + +.message table { border-collapse: collapse; margin: 6px 0; display: block; overflow-x: auto; } +.message th, .message td { padding: 0 10px; } +.message th { font-weight: 600; } +.message ul, .message ol { padding-left: 20px; margin: 4px 0; white-space: normal; } +.message li { margin: 2px 0; white-space: normal; } +.message li > p { margin: 0; } +.message p { margin: 0 0 6px 0; white-space: normal; } +.message p:last-child { margin-bottom: 0; } + +.message pre { + overflow-x: auto; + white-space: pre; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 12px; + margin: 6px 0; + max-width: 100%; +} + +.message code { font-family: var(--font-mono); } +.message pre code { background: none; padding: 0; border: none; } + +/* Copy button on messages */ +.copy-btn { + position: absolute; + top: 6px; + right: 8px; + background: none; + border: none; + color: #fff; + cursor: pointer; + padding: 0; + line-height: 1; + opacity: 0; + transition: opacity 0.15s; +} +.message:hover .copy-btn { opacity: 0.6; } +.copy-btn:hover { opacity: 1 !important; } + +/* Copy button on code blocks */ +.pre-copy-btn { + position: absolute; + top: 6px; + right: 6px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-dim); + padding: 2px 7px; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s; +} +.message pre:hover .pre-copy-btn { opacity: 1; } + +/* Status indicators */ +.inline-hud { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.5rem 0 0.25rem; +} + +.messages-sm-state { + display: flex; + justify-content: center; + font-weight: 500; + color: var(--text-dim); + padding: 4px 0 0; + margin: 0; + line-height: 1.2; + opacity: 0.6; + pointer-events: none; +} +.messages-sm-state.AGENT_RUNNING { color: var(--warn); opacity: 1; animation: pulse 1.5s infinite; } +.messages-sm-state.STOP_PENDING { color: var(--error); opacity: 1; } +.messages-sm-state.CONNECTING { color: var(--warn); opacity: 0.7; } +.messages-sm-state.SWITCHING { color: #60a5fa; opacity: 1; } + +.agent-status-indicator { + padding: 2px var(--space-page); + color: var(--text-dim); + opacity: 0.7; + flex-shrink: 0; +} +.agent-status-indicator:not(.done) span { animation: pulse 1s infinite; } +.agent-status-indicator.done { opacity: var(--disabled-opacity); } + +.typing-dots { animation: blink 1s infinite; } + +/* Agent action bar — always visible above input */ +.agent-action-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 4px var(--space-page); + flex-shrink: 0; +} +.action-bar-btn { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + padding: 3px 12px; + height: 26px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.action-bar-btn:hover:not(:disabled) { color: var(--text); border-color: var(--text-dim); } +.action-bar-btn:disabled { opacity: var(--disabled-opacity); cursor: default; } + +/* Input area */ +.input-area { + display: flex; + flex-direction: column; + padding: var(--panel-gap) var(--panel-gap) var(--panel-gap); + background: transparent; + flex-shrink: 0; +} + +.input-box { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + gap: 0.3rem 0.5rem; + overflow: hidden; + background: var(--panel-bg); + border: none; + border-radius: var(--radius-panel); + box-shadow: var(--panel-shadow); + padding: 0.5rem 0.5rem 0.5rem 0.85rem; + transition: box-shadow 0.15s; + width: 100%; + box-sizing: border-box; +} + +.input-box:focus-within { + box-shadow: var(--panel-shadow), 0 0 0 2px rgba(74, 222, 128, 0.15); +} + +.input-area .chat-input { + flex: 1 1 100%; + order: -1; + min-width: 0 !important; + max-width: 100% !important; + background: transparent; + color: var(--text); + border: none; + padding: 0; + font-size: 1rem; + font-family: inherit; + resize: none; + line-height: 1.5; + min-height: 36px; + max-height: 160px; + overflow-y: auto; + box-sizing: border-box; + outline: none; +} +.input-area .chat-input:focus { outline: none; } + +.input-area .chat-input.shake { + animation: shake 0.4s cubic-bezier(.36,.07,.19,.97) both; + border: 1px solid var(--error) !important; +} + +@keyframes shake { + 10%, 90% { transform: translate3d(-1px, 0, 0); } + 20%, 80% { transform: translate3d(2px, 0, 0); } + 30%, 50%, 70% { transform: translate3d(-4px, 0, 0); } + 40%, 60% { transform: translate3d(4px, 0, 0); } +} + +/* Input toolbar */ +.input-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--space-page); +} + +.input-toolbar-left, +.input-toolbar-center { + display: flex; + align-items: center; + gap: 6px; +} + +.input-toolbar-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.input-toolbar-center { + flex: 1; + justify-content: center; +} + +.v-stack { + flex-direction: column; + gap: 4px; + align-items: center; +} + +.toolbar-btn { + background: none; + border: 1px solid var(--primary); + color: var(--primary); + border-radius: var(--radius-sm); + padding: 0 10px; + height: var(--height-btn); + line-height: 1; + cursor: pointer; + transition: color .15s, border-color .15s; + display: inline-flex; + align-items: center; +} +.toolbar-btn:hover:not(:disabled) { color: var(--text); border-color: var(--text-dim); } +.toolbar-btn:disabled { opacity: var(--disabled-opacity); cursor: default; } + +.yes-btn { + background: none; + border: 1px solid var(--success); + color: var(--success); + padding: 0 16px; + height: var(--height-btn); + border-radius: var(--radius-sm); + font-weight: 500; + cursor: pointer; + transition: background .15s, color .15s; + display: inline-flex; + align-items: center; +} +.yes-btn:hover:not(:disabled) { background: var(--success); color: #fff; } +.yes-btn:disabled { opacity: var(--disabled-opacity); cursor: default; } + +.send-btn { + width: 32px; + height: 32px; + min-width: 32px; + min-height: 32px; + flex-shrink: 0; + margin-left: auto; + border-radius: 50%; + border: none; + background: var(--send-btn-bg, var(--accent)); + color: var(--send-btn-color, white); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: filter 0.15s, opacity 0.15s; + padding: 0; + margin-bottom: 1px; +} +.send-btn:hover:not(:disabled) { filter: brightness(1.1); } +.send-btn:disabled { opacity: 0.25; cursor: not-allowed; background: var(--muted); } + +.stop-btn { + background: none; + border: 1px solid var(--error); + color: var(--error); + padding: 0 12px; + height: var(--height-btn); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} +.stop-btn:hover:not(:disabled) { background: var(--error); color: #fff; } +.stop-btn:disabled { cursor: default; } +.stop-btn.stop-muted { opacity: 0.25; } +.footer-stop-btn { margin-left: 4px; } + +.kill-btn { + background: none; + border: 1px solid var(--error); + color: var(--error); + padding: 0 12px; + height: var(--height-btn); + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} +.kill-btn:hover:not(:disabled) { background: var(--error); color: #fff; } + +.finance-badge { + color: var(--text-dim); + border: 1px solid var(--border); + padding: 2px 8px; + height: 20px; + border-radius: 12px; + background: var(--bg); + cursor: pointer; + display: inline-flex; + align-items: center; + white-space: nowrap; +} +.finance-badge:hover { border-color: var(--accent); color: var(--text); } + +/* ── File download buttons ── */ +.file-download-link { + display: inline-block; + padding: 0.3rem 0.7rem; + margin: 0.2rem 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + color: var(--accent); + font-size: 0.85rem; + font-family: inherit; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} +.file-download-link:hover { + border-color: var(--accent); + background: rgba(74, 222, 128, 0.08); +} + +/* ── Mobile — touch targets ── */ +@media (max-width: 639px) { + .send-btn { width: 44px; height: 44px; min-width: 44px; min-height: 44px; } +} diff --git a/frontend/css/views/dev.css b/frontend/css/views/dev.css new file mode 100644 index 0000000..f491649 --- /dev/null +++ b/frontend/css/views/dev.css @@ -0,0 +1,275 @@ +.dev-view { + padding: var(--space-inset) 0; +} + +.dev-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-page); +} +.dev-header h2 { margin-bottom: 0; } + +.dev-view h2 { + font-size: 1.25rem; + font-weight: 700; + margin-bottom: var(--space-page); + color: var(--text); +} + +.dev-section { margin-bottom: var(--space-page); } + +.dev-section h3 { + color: var(--text-dim); + margin-bottom: var(--space-gap); +} + +.dev-actions { display: flex; gap: var(--space-gap); flex-wrap: wrap; } + +/* Credits widget */ +.credits-widget { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius, 6px); + padding: 16px 20px; +} +.credits-bar-track { + height: 6px; + background: var(--border); + border-radius: 3px; + overflow: hidden; + margin-bottom: 14px; +} +.credits-bar-fill { + height: 100%; + background: var(--accent); + border-radius: 3px; + transition: width 0.4s ease; +} +.credits-row { + display: flex; + gap: 32px; +} +.credits-stat { + display: flex; + flex-direction: column; + gap: 2px; +} +.credits-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted, var(--text-dim)); +} +.credits-amount { + font-size: 1.1rem; + font-weight: 600; + color: var(--text); + font-variant-numeric: tabular-nums; +} +.credits-used { color: var(--error); } +.credits-remaining { color: var(--success-dim, var(--accent)); } + +/* Table */ +.dev-table { + width: 100%; + border-collapse: collapse; +} +.dev-table th { + text-align: left; + padding: 8px 12px; + color: var(--text-dim); + font-weight: 500; + border-bottom: 1px solid var(--border); +} +.dev-table td { padding: 8px 12px; border-bottom: 1px solid var(--border); color: var(--text); } +.dev-table tr:last-child td { border-bottom: none; } +.dev-table .agent-id { font-weight: 600; } +.dev-table code { + background: var(--border); + padding: 2px 6px; + border-radius: 3px; + font-family: var(--font-mono); +} + +/* Dev flags */ +.dev-flags { display: flex; gap: 16px; flex-wrap: wrap; } +.dev-flag { + display: flex; + align-items: center; + gap: 6px; + color: var(--text); + cursor: pointer; + user-select: none; +} +.dev-flag input[type="checkbox"] { + accent-color: var(--accent); + width: 16px; + height: 16px; + cursor: pointer; +} +.dev-flag span { font-family: var(--font-mono); } + +.takeover-token { + font-family: var(--font-mono); + background: var(--surface); + border: 1px solid var(--border); + padding: 4px 10px; + border-radius: var(--radius-sm); + color: var(--accent); + user-select: all; +} + +.dev-loading { color: var(--text-dim); } +.dev-error { color: var(--error); } + +.dev-refresh-btn { + background: none; + border: 1px solid var(--border); + color: var(--text-dim); + padding: 6px 14px; + border-radius: 4px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} +.dev-refresh-btn:hover { color: var(--text); border-color: var(--text-dim); } +.dev-refresh-btn:disabled { opacity: var(--disabled-opacity); cursor: not-allowed; } + +.dev-disco-btn { + background: none; + border: 1px solid var(--error); + color: var(--error); + padding: 6px 14px; + border-radius: 4px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} +.dev-disco-btn:hover { background: var(--error)22; } +.dev-disco-btn:disabled { opacity: var(--disabled-opacity); cursor: not-allowed; } + +/* Theme buttons */ +.dev-theme-btn { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-dim); + padding: 6px 18px; + border-radius: 4px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} +.dev-theme-btn:hover { color: var(--text); border-color: var(--text-dim); } +.dev-theme-btn.active { + border-color: var(--accent); + color: var(--bg); + background: var(--accent); +} + +/* Table horizontal scroll wrapper */ +.dev-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +/* Breakout confirmation modal */ +.breakout-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} +.breakout-modal { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 24px 32px; + min-width: 300px; + text-align: center; +} +.breakout-modal h3 { color: var(--text); margin-bottom: 12px; } +.breakout-modal p { color: var(--text-dim); margin: 4px 0; } +.breakout-nonce { + font-family: var(--font-mono); + font-size: 2rem; + font-weight: 700; + color: var(--accent); + letter-spacing: 0.15em; + margin: 16px 0; +} +.breakout-modal-actions { + display: flex; + gap: 12px; + justify-content: center; + margin-top: 16px; +} + +/* ── MCP Counter ── */ +.counter-widget { transition: opacity 0.3s; } +.counter-widget.muted { opacity: 0.35; } +.counter-controls { display: flex; align-items: center; gap: 16px; } +.counter-btn { + width: 48px; height: 48px; border-radius: 50%; + border: 1px solid var(--text-dim); background: transparent; + color: var(--text); font-size: 1.5rem; cursor: pointer; + transition: all 0.2s; +} +.counter-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); } +.counter-btn:disabled { cursor: not-allowed; opacity: 0.3; } +.counter-value { + font-size: 2.5rem; font-variant-numeric: tabular-nums; + min-width: 3ch; text-align: center; color: var(--text); +} +.counter-challenge { + margin-top: 12px; display: flex; align-items: center; gap: 12px; + animation: counter-pulse 1s ease-in-out infinite alternate; +} +.counter-message { color: var(--accent); font-weight: 600; font-size: 0.9rem; } +.counter-timer { + font-variant-numeric: tabular-nums; color: var(--text-dim); + font-size: 0.85rem; min-width: 3ch; +} +.counter-hint { margin-top: 8px; font-size: 0.8rem; opacity: 0.4; } +@keyframes counter-pulse { + from { opacity: 0.7; } + to { opacity: 1; } +} +.counter-widget.flash { + animation: counter-flash 0.5s ease-out 3; +} +@keyframes counter-flash { + 0% { box-shadow: 0 0 0 0 var(--accent); } + 50% { box-shadow: 0 0 20px 4px var(--accent); } + 100% { box-shadow: 0 0 0 0 var(--accent); } +} + +/* ── Confetti ── */ +.confetti-container { + position: fixed; inset: 0; pointer-events: none; z-index: 9999; overflow: hidden; +} +.confetti-piece { + position: absolute; width: 10px; height: 10px; top: -20px; + animation: confetti-fall 3s ease-in forwards; +} +@keyframes confetti-fall { + 0% { transform: translateY(0) rotate(0deg); opacity: 1; } + 100% { transform: translateY(100vh) rotate(720deg); opacity: 0; } +} + +/* ── Action Picker ── */ +.action-picker { display: flex; flex-wrap: wrap; gap: 8px; } +.action-pick-btn { + padding: 8px 16px; border-radius: 6px; + border: 1px solid var(--accent); background: transparent; + color: var(--accent); font-size: 0.85rem; cursor: pointer; + transition: all 0.2s; +} +.action-pick-btn:hover:not(:disabled) { background: var(--accent); color: var(--bg); } +.action-pick-btn:disabled { opacity: 0.3; cursor: not-allowed; } + +/* ── Mobile ── */ +@media (max-width: 639px) { + .dev-theme-btn, .dev-disco-btn { min-height: 44px; } + .dev-flag { min-height: 44px; } +} diff --git a/frontend/css/views/home.css b/frontend/css/views/home.css new file mode 100644 index 0000000..c57d0dd --- /dev/null +++ b/frontend/css/views/home.css @@ -0,0 +1,45 @@ +.home-view { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-height: 100%; + background: transparent; +} + +.home-card { + text-align: center; + padding: 48px 40px; +} + +.home-logo { + font-size: 3rem; + margin-bottom: var(--space-page); + display: flex; + justify-content: center; +} + +.home-card h1 { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: var(--space-gap); + color: var(--text); +} + +.home-sub { + color: var(--text-dim); + margin-bottom: 32px; +} + +.home-btn { + display: inline-block; + background: var(--accent); + color: #fff; + padding: 12px 28px; + border-radius: var(--radius); + font-weight: 600; + text-decoration: none; + transition: opacity 0.15s; +} +.home-btn:hover { opacity: 0.85; } + diff --git a/frontend/css/views/login.css b/frontend/css/views/login.css new file mode 100644 index 0000000..f2a3a9c --- /dev/null +++ b/frontend/css/views/login.css @@ -0,0 +1,87 @@ +.login-view { + display: flex; + align-items: center; + justify-content: center; + min-height: 100%; + padding: 16px; + background: transparent; +} + +.login-card { + background: rgba(30, 30, 38, 0.85); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px; + width: 100%; + max-width: 360px; + text-align: center; +} + +.login-card h2 { + font-size: 1.25rem; + margin-bottom: 24px; + color: var(--text); +} + +.login-card input { + width: 100%; + padding: 12px var(--space-page); + font-size: inherit; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + margin-bottom: var(--space-page); + color-scheme: dark; +} +.login-card input:focus { outline: none; border-color: var(--focus); } +.login-card input::placeholder { color: var(--text-dim); } + +.login-card button { + width: 100%; + padding: 12px; + font-size: inherit; + font-weight: 600; + background: var(--accent); + border: none; + border-radius: var(--radius); + color: white; + cursor: pointer; + transition: opacity 0.2s; +} +.login-card button:hover { opacity: 0.9; } +.login-card button:disabled { opacity: var(--disabled-opacity); cursor: not-allowed; } + +.login-error { + color: var(--error); + margin-top: 12px; +} + +.login-info { + color: var(--text-dim); + margin-bottom: 20px; +} + +.login-card button + button { + margin-top: 10px; +} + +.logout-btn { + background: transparent !important; + border: 1px solid var(--border) !important; + color: var(--text-dim) !important; +} +.logout-btn:hover { border-color: var(--error) !important; color: var(--error) !important; opacity: 1 !important; } + +.version-login { + color: var(--text-dim); + margin-bottom: var(--space-page); +} + +.login-label { + display: block; + font-weight: 600; + color: var(--text-dim); + margin-bottom: 4px; +} + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e3700b0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,31 @@ + + + + + + Hermes + + + + +
+ + + + + diff --git a/frontend/openclaw-web-frontend.service b/frontend/openclaw-web-frontend.service new file mode 100644 index 0000000..40ee717 --- /dev/null +++ b/frontend/openclaw-web-frontend.service @@ -0,0 +1,14 @@ +[Unit] +Description=OpenClaw Web Frontend (Vue Chat UI - Vite Dev) +After=network.target + +[Service] +Type=simple +WorkingDirectory=/home/openclaw/.openclaw/workspace-titan/projects/webchat/frontend +ExecStart=/usr/bin/npm run dev +Restart=always +RestartSec=5 +Environment=NODE_ENV=development + +[Install] +WantedBy=multi-user.target diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..e5b4c06 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2012 @@ +{ + "name": "frontend", + "version": "2.14.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "2.14.0", + "license": "ISC", + "dependencies": { + "marked": "^17.0.4", + "pinia": "^3.0.4", + "vue": "^3.5.29", + "vue-router": "^5.0.3" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.4", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vue-tsc": "^3.2.5" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", + "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", + "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.29", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", + "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", + "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.29", + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", + "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/devtools-api": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.7.tgz", + "integrity": "sha512-tc1TXAxclsn55JblLkFVcIRG7MeSJC4fWsPjfM7qu/IcmPUYnQ5Q8vzWwBpyDY24ZjmZTUCCwjRSNbx58IhlAA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.0.7" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.7.tgz", + "integrity": "sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.0.7", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.7.tgz", + "integrity": "sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.5.tgz", + "integrity": "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", + "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", + "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", + "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/runtime-core": "3.5.29", + "@vue/shared": "3.5.29", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", + "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "vue": "3.5.29" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", + "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/marked": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/marked/-/marked-17.0.4.tgz", + "integrity": "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.1.tgz", + "integrity": "sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/pinia/node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/pinia/node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/pinia/node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", + "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-sfc": "3.5.29", + "@vue/runtime-dom": "3.5.29", + "@vue/server-renderer": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz", + "integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.5.tgz", + "integrity": "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.5" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..c0336cd --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,32 @@ +{ + "name": "frontend", + "version": "0.7.2", + "description": "**Status:** Phase 1 complete | **Live:** `http://192.168.56.102:8443/#chat` (local only)", + "main": "index.html", + "directories": { + "doc": "docs" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "node server.js", + "dev": "vite", + "build": "vite build" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@tailwindcss/vite": "^4.2.2", + "@vitejs/plugin-vue": "^6.0.4", + "tailwindcss": "^4.2.2", + "typescript": "^5.9.3", + "vite": "^7.3.1", + "vue-tsc": "^3.2.5" + }, + "dependencies": { + "marked": "^17.0.4", + "pinia": "^3.0.4", + "vue": "^3.5.29", + "vue-router": "^5.0.3" + } +} diff --git a/frontend/public/favicon-eras.svg b/frontend/public/favicon-eras.svg new file mode 100644 index 0000000..39b02f3 --- /dev/null +++ b/frontend/public/favicon-eras.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/public/favicon-loop42.svg b/frontend/public/favicon-loop42.svg new file mode 100644 index 0000000..9aeb27c --- /dev/null +++ b/frontend/public/favicon-loop42.svg @@ -0,0 +1,9 @@ + + + + + + + + 42 + diff --git a/frontend/public/favicon-titan.svg b/frontend/public/favicon-titan.svg new file mode 100644 index 0000000..22ec087 --- /dev/null +++ b/frontend/public/favicon-titan.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..22ec087 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/public/fonts/ubuntu-sans/UbuntuSans-Italic[wght].woff2 b/frontend/public/fonts/ubuntu-sans/UbuntuSans-Italic[wght].woff2 new file mode 100644 index 0000000..6b71154 Binary files /dev/null and b/frontend/public/fonts/ubuntu-sans/UbuntuSans-Italic[wght].woff2 differ diff --git a/frontend/public/fonts/ubuntu-sans/UbuntuSans[wght].woff2 b/frontend/public/fonts/ubuntu-sans/UbuntuSans[wght].woff2 new file mode 100644 index 0000000..6dd2f36 Binary files /dev/null and b/frontend/public/fonts/ubuntu-sans/UbuntuSans[wght].woff2 differ diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..d0ced29 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue new file mode 100644 index 0000000..6d6226f --- /dev/null +++ b/frontend/src/components/AppSidebar.vue @@ -0,0 +1,546 @@ + + + diff --git a/frontend/src/components/AssistantMessage.vue b/frontend/src/components/AssistantMessage.vue new file mode 100644 index 0000000..84752ea --- /dev/null +++ b/frontend/src/components/AssistantMessage.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/frontend/src/components/BreakpointBadge.vue b/frontend/src/components/BreakpointBadge.vue new file mode 100644 index 0000000..e59567d --- /dev/null +++ b/frontend/src/components/BreakpointBadge.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/frontend/src/components/FileTree.vue b/frontend/src/components/FileTree.vue new file mode 100644 index 0000000..722add1 --- /dev/null +++ b/frontend/src/components/FileTree.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/frontend/src/components/GridOverlay.vue b/frontend/src/components/GridOverlay.vue new file mode 100644 index 0000000..f85a316 --- /dev/null +++ b/frontend/src/components/GridOverlay.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/frontend/src/components/HandoverCard.vue b/frontend/src/components/HandoverCard.vue new file mode 100644 index 0000000..bcd3345 --- /dev/null +++ b/frontend/src/components/HandoverCard.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/frontend/src/components/HermesStatus.vue b/frontend/src/components/HermesStatus.vue new file mode 100644 index 0000000..71e33ba --- /dev/null +++ b/frontend/src/components/HermesStatus.vue @@ -0,0 +1,50 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/components/HudActions.vue b/frontend/src/components/HudActions.vue new file mode 100644 index 0000000..6e513b1 --- /dev/null +++ b/frontend/src/components/HudActions.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/frontend/src/components/HudControls.vue b/frontend/src/components/HudControls.vue new file mode 100644 index 0000000..1036d88 --- /dev/null +++ b/frontend/src/components/HudControls.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/frontend/src/components/HudMetrics.vue b/frontend/src/components/HudMetrics.vue new file mode 100644 index 0000000..20590f0 --- /dev/null +++ b/frontend/src/components/HudMetrics.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/frontend/src/components/HudRow.vue b/frontend/src/components/HudRow.vue new file mode 100644 index 0000000..a302246 --- /dev/null +++ b/frontend/src/components/HudRow.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/frontend/src/components/MessageFrame.vue b/frontend/src/components/MessageFrame.vue new file mode 100644 index 0000000..a7dc50b --- /dev/null +++ b/frontend/src/components/MessageFrame.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/components/SidebarPanel.vue b/frontend/src/components/SidebarPanel.vue new file mode 100644 index 0000000..c812917 --- /dev/null +++ b/frontend/src/components/SidebarPanel.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/frontend/src/components/SystemMessage.vue b/frontend/src/components/SystemMessage.vue new file mode 100644 index 0000000..9109335 --- /dev/null +++ b/frontend/src/components/SystemMessage.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/frontend/src/components/ToolIcon.vue b/frontend/src/components/ToolIcon.vue new file mode 100644 index 0000000..b808b27 --- /dev/null +++ b/frontend/src/components/ToolIcon.vue @@ -0,0 +1,44 @@ + + + diff --git a/frontend/src/components/TreeNode.vue b/frontend/src/components/TreeNode.vue new file mode 100644 index 0000000..988ba0b --- /dev/null +++ b/frontend/src/components/TreeNode.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/frontend/src/components/TtsPlayerBar.vue b/frontend/src/components/TtsPlayerBar.vue new file mode 100644 index 0000000..1b8d816 --- /dev/null +++ b/frontend/src/components/TtsPlayerBar.vue @@ -0,0 +1,135 @@ + + + + + diff --git a/frontend/src/components/UsageDisplay.vue b/frontend/src/components/UsageDisplay.vue new file mode 100644 index 0000000..5a3693d --- /dev/null +++ b/frontend/src/components/UsageDisplay.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/frontend/src/components/UserMessage.vue b/frontend/src/components/UserMessage.vue new file mode 100644 index 0000000..b625c60 --- /dev/null +++ b/frontend/src/components/UserMessage.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/frontend/src/components/WebGLBackground.vue b/frontend/src/components/WebGLBackground.vue new file mode 100644 index 0000000..e3de5f9 --- /dev/null +++ b/frontend/src/components/WebGLBackground.vue @@ -0,0 +1,172 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/composables/agents.ts b/frontend/src/composables/agents.ts new file mode 100644 index 0000000..dd2e97f --- /dev/null +++ b/frontend/src/composables/agents.ts @@ -0,0 +1,218 @@ +import { ref, computed, watch, type Ref } from 'vue'; +import type { ChannelState } from '../store/chat'; +import { getApiBase } from '../utils/apiBase'; + +interface ServerConfig { + defaultAgent?: string; + allowedAgents?: string[]; + agents?: Agent[]; +} + +interface ChannelInfo { + state: ChannelState; + connections: number; + handoverActive: boolean; + activeTurnId: string | null; +} + +export interface Agent { + id: string; + name: string; + model?: string; + modelFull?: string; + modelName?: string; + promptPrice: number | null; + completionPrice: number | null; + segment?: string; // 'personal' | 'common' | 'utility' + role?: string; // 'owner' | 'member' | 'common' | 'guest' | 'utility' + modes?: string[]; // ['private', 'public'] | ['private'] | ['public'] +} + +// These are refs so they can be watched and reacted to by Vue components +const _allAgents: Ref = ref([]); +// Restore agent from URL or sessionStorage at init (before WS auth fires) +const _initAgent = (() => { + const url = new URLSearchParams(window.location.hash.split('?')[1] || '').get('agent'); + if (url) return url; + const saved = sessionStorage.getItem('agent'); + if (saved) return saved; + return ''; // truly first visit — server will provide default via ready message +})(); +const _selectedAgent: Ref = ref(_initAgent); +// Restore mode from URL or sessionStorage at init (before WS auth fires) +const _initMode = (() => { + const url = new URLSearchParams(window.location.hash.split('?')[1] || '').get('mode'); + if (url === 'public' || url === 'private') return url; + const saved = sessionStorage.getItem('agent_mode'); + if (saved === 'public' || saved === 'private') return saved; + return 'private'; +})(); +const _selectedMode: Ref<'private' | 'public'> = ref(_initMode as 'private' | 'public'); +const _defaultAgent: Ref = ref('titan'); // Default fallback +const _allowedAgentIds: Ref = ref([]); // List of agent IDs allowed for the current user + +export function useAgents(connected: Ref) { + // --- State --- // + const allAgents = _allAgents; + const selectedAgent = _selectedAgent; + const selectedMode = _selectedMode; + const defaultAgent = _defaultAgent; + const allowedAgentIds = _allowedAgentIds; + + const agentModels: Ref = ref([]); // Assuming agent models can be of any type for now + + // --- Computed --- // + const filteredAgents = computed(() => { + if (allowedAgentIds.value.length === 0) return allAgents.value; + return allAgents.value.filter(a => allowedAgentIds.value.includes(a.id)); + }); + + // --- Actions --- // + + /** + * Called when the server sends new config (e.g., on auth_ok or ready message). + * Updates agent list, allowed agents, and attempts to set a default selected agent. + */ + function updateFromServer(data: ServerConfig): void { + if (data.agents) { + allAgents.value = data.agents; + } + if (data.defaultAgent) { + _defaultAgent.value = data.defaultAgent; + } + if (data.allowedAgents) { + _allowedAgentIds.value = data.allowedAgents; + } + + // Restore mode: URL ?mode= > sessionStorage > default 'private' + const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || ''); + const urlMode = urlParams.get('mode'); + const savedMode = sessionStorage.getItem('agent_mode'); + if (urlMode === 'public' || urlMode === 'private') { + _selectedMode.value = urlMode; + } else if (savedMode === 'public' || savedMode === 'private') { + _selectedMode.value = savedMode; + } + + // Set selected agent: + // 1. From ?agent= URL param if valid and allowed. + // 2. From sessionStorage if valid and allowed. + // 3. Leave empty — NO_AGENT_SELECTED state, user must pick. + const urlAgent = urlParams.get('agent'); + const savedAgent = sessionStorage.getItem('agent'); + const isUrlAgentAllowed = urlAgent && _allowedAgentIds.value.includes(urlAgent); + const isSavedAgentAllowed = savedAgent && _allowedAgentIds.value.includes(savedAgent); + + if (isUrlAgentAllowed) { + selectedAgent.value = urlAgent as string; + } else if (isSavedAgentAllowed) { + selectedAgent.value = savedAgent as string; + } + // else: leave selectedAgent empty — AgentsView shows agent picker + } + + async function fetchAgentModels(): Promise { + try { + const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'; + const host = window.location.hostname; + // Use the HTTP /agents endpoint for full models list if needed, but current /dev uses WS for stats. + // This function might be redundant if all agent info comes from WS 'config' or 'stats' messages. + const sessionToken = localStorage.getItem('openclaw_session') ?? ''; + const res = await fetch(`${protocol}//${host}/agents`, { + headers: { Authorization: `Bearer ${sessionToken}` }, + }); + agentModels.value = await res.json(); + } catch (e) { + console.error('Failed to fetch agent models:', e); + } + } + + // Attempt to set a default agent initially, and whenever allowed agents change. + // Note: this might trigger before updateFromServer if localStorage has an old agent value. + // updateFromServer should be the primary setter. + watch([connected, _allowedAgentIds], () => { + if (connected.value && !_selectedAgent.value) { + // Only run if connected and no agent is currently selected + updateFromServer({}); // Pass empty to trigger default agent logic + } + }); + + + // --- Channel state polling (round-robin, one agent per tick) --- // + // Map: agentId → { private: ChannelInfo, public: ChannelInfo } + const channelStates = ref>({}); + let _pollTimer: ReturnType | null = null; + let _pollIdx = 0; + + async function fetchOneAgent(agentId: string): Promise { + const sessionToken = localStorage.getItem('openclaw_session') ?? ''; + if (!sessionToken) return; + try { + const apiBase = getApiBase(); + const res = await fetch(`${apiBase}/api/channels/${agentId}`, { + headers: { Authorization: `Bearer ${sessionToken}` }, + }); + if (!res.ok) return; + const data = await res.json(); + channelStates.value = { + ...channelStates.value, + [agentId]: { + private: data.private ?? null, + public: data.public ?? null, + }, + }; + } catch { + // Silent + } + } + + function pollNextAgent() { + const agents = allAgents.value; + if (!agents.length) return; + _pollIdx = _pollIdx % agents.length; + fetchOneAgent(agents[_pollIdx].id); + _pollIdx++; + } + + function startChannelPolling() { + if (_pollTimer) return; + _pollIdx = 0; + pollNextAgent(); // immediate first + _pollTimer = setInterval(pollNextAgent, 1000); // 1 agent per second + } + + function stopChannelPolling() { + if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; } + } + + // Helper: get channel state for an agent by id + mode + function getChannelState(agentId: string, mode: 'private' | 'public'): ChannelInfo | null { + const entry = channelStates.value[agentId]; + if (!entry) return null; + return mode === 'private' ? entry.private : entry.public; + } + + let currentUserRef: Ref | null = null; + function setCurrentUser(userRef: Ref) { currentUserRef = userRef; } + + // Channel state polling disabled — indicators removed from sidebar in 0.6.45 + // Keeping the functions for future use if needed. + // watch(connected, (val) => { + // if (val) startChannelPolling(); + // else stopChannelPolling(); + // }, { immediate: true }); + + // Keep sessionStorage in sync with agent + mode (URL managed by router) + function syncStorage() { + if (_selectedAgent.value) sessionStorage.setItem('agent', _selectedAgent.value); + sessionStorage.setItem('agent_mode', _selectedMode.value); + } + watch([_selectedAgent, _selectedMode], syncStorage); + + return { + allAgents, selectedAgent, selectedMode, defaultAgent, allowedAgentIds, + agentModels, filteredAgents, channelStates, + fetchAgentModels, updateFromServer, getChannelState, setCurrentUser, + startChannelPolling, stopChannelPolling, + }; +} diff --git a/frontend/src/composables/auth.ts b/frontend/src/composables/auth.ts new file mode 100644 index 0000000..5ff39c8 --- /dev/null +++ b/frontend/src/composables/auth.ts @@ -0,0 +1,73 @@ +import { ref, type Ref } from 'vue'; +import router from '../router'; + +const SESSION_TOKEN_KEY = 'openclaw_session'; + +import { getApiBase } from '../utils/apiBase'; + +export function useAuth(connectFn: () => void) { + const isLoggedIn: Ref = ref(!!localStorage.getItem(SESSION_TOKEN_KEY)); + const loginToken: Ref = ref(''); + const loginError: Ref = ref(''); + const loggingIn: Ref = ref(false); + + async function doLogin(): Promise { + const token = loginToken.value.trim(); + if (!token) return; + loggingIn.value = true; + loginError.value = ''; + + try { + const nonceRes = await fetch(`${getApiBase()}/api/auth/nonce`); + if (!nonceRes.ok) { loginError.value = 'Auth unavailable'; loggingIn.value = false; return; } + const { nonce } = await nonceRes.json(); + const res = await fetch(`${getApiBase()}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, nonce }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Login failed' })); + loginError.value = err.error || 'Invalid token'; + loggingIn.value = false; + return; + } + const { sessionToken } = await res.json(); + localStorage.removeItem('titan_token'); + localStorage.removeItem('openclaw_token'); + localStorage.setItem(SESSION_TOKEN_KEY, sessionToken); + sessionStorage.removeItem('agent'); + isLoggedIn.value = true; + connectFn(); + router.push('/chat'); + setTimeout(() => { loggingIn.value = false; }, 500); + } catch { + loginError.value = 'Network error'; + loggingIn.value = false; + } + } + + async function doLogout(disconnectFn?: () => void): Promise { + const sessionToken = localStorage.getItem(SESSION_TOKEN_KEY); + if (sessionToken) { + // Fire-and-forget revoke + fetch(`${getApiBase()}/api/auth/logout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionToken }), + }).catch(() => {}); + } + if (disconnectFn) disconnectFn(); + localStorage.removeItem(SESSION_TOKEN_KEY); + localStorage.removeItem('titan_token'); + localStorage.removeItem('openclaw_token'); + sessionStorage.removeItem('agent'); + sessionStorage.removeItem('viewer_auth'); // clear cached fstoken + isLoggedIn.value = false; + loginToken.value = ''; + loggingIn.value = false; + router.push('/'); + } + + return { isLoggedIn, loginToken, loginError, loggingIn, doLogin, doLogout }; +} diff --git a/frontend/src/composables/sessionHistory.ts b/frontend/src/composables/sessionHistory.ts new file mode 100644 index 0000000..d1a17a1 --- /dev/null +++ b/frontend/src/composables/sessionHistory.ts @@ -0,0 +1,450 @@ +import { ref, triggerRef, nextTick } from 'vue'; +import { useChatStore } from '../store/chat'; + +// ── HUD node types ──────────────────────────────────────────────────────────── + +export interface HudNode { + id: string + correlationId?: string + type: 'turn' | 'tool' | 'think' | 'received' + subtype?: string + state: 'running' | 'done' | 'error' + label: string + tool?: string + args?: Record + result?: Record + payload?: Record + startedAt: number + endedAt?: number + durationMs?: number + children: HudNode[] + replay: boolean +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +const VISIBLE_PAGE = 50; +const MAX_HUD_NODES = 100; + +// ── Composable ──────────────────────────────────────────────────────────────── + +export function useSessionHistory( + isAgentRunning: () => boolean, + visibleCount: { value: number }, + agentIdFn: () => string, +) { + const store = useChatStore(); + const sessionHistoryComplete = ref(false); + const lastUsage = ref(null); + let loadStartTime: number | null = null; + let pendingMessages: any[] = []; + let pendingUsageTotals: any | null = null; + + const lastSystemMsgRef = ref(null); + + // ── HUD tree state ──────────────────────────────────────────────────────── + const hudTree = ref([]); + const hudVersion = ref(0); // increments on every tree mutation — use as reactive dep in components + // Map correlationId → running HudNode (for pairing _start/_end) + const hudPending = new Map(); + // Map correlationId → HudNode (turns, for parenting tools into turns) + const hudTurns = new Map(); + // Secondary index: toolCallId → WeakRef (OpenClaw-assigned ID, separate from corrId) + // WeakRef: GC collects node when tree drops it; lazy eviction on access; clear on session reset. + const toolCallMap = new Map>(); + + function lookupByToolCallId(toolCallId?: string): HudNode | null { + if (!toolCallId) return null; + const ref = toolCallMap.get(toolCallId); + if (!ref) return null; + const node = ref.deref(); + if (!node) { toolCallMap.delete(toolCallId); return null; } // lazy eviction + return node; + } + // Currently active (running) turn correlationId — stamped onto assistant messages + const activeTurnCorrId = ref(null); + + function makeNode(partial: Partial): HudNode { + return { + id: partial.id || crypto.randomUUID(), + type: partial.type || 'received', + state: partial.state || 'running', + label: partial.label || '', + children: [], + replay: partial.replay ?? false, + startedAt: partial.startedAt || Date.now(), + ...partial, + } as HudNode; + } + + function findNode(nodes: HudNode[], corrId: string): HudNode | null { + for (const n of nodes) { + if (n.correlationId === corrId) return n; + if (n.children) { + const found = findNode(n.children, corrId); + if (found) return found; + } + } + return null; + } + + function addHudNode(node: HudNode, parentCorrelationId?: string) { + // 'history' is a sentinel parentId used by session-watcher for replay events with no real turn parent + if (parentCorrelationId && parentCorrelationId !== 'history') { + const parent = hudTurns.get(parentCorrelationId); + if (parent) { + parent.children.push(node); + triggerRef(hudTree); hudVersion.value++; // force Vue reactivity on nested mutation + return; + } + } + hudTree.value.unshift(node); + if (hudTree.value.length > MAX_HUD_NODES) hudTree.value.splice(MAX_HUD_NODES); + triggerRef(hudTree); hudVersion.value++; + } + + function pushHudEvent(event: any) { + const replay = !!event.replay; + const ts: number = event.ts || Date.now(); + const corrId: string | undefined = event.correlationId; + const parentId: string | undefined = event.parentId; + + switch (event.event) { + case 'turn_start': { + // Deduplicate: skip if a turn with this corrId already exists + if (corrId && (hudPending.has(corrId) || hudTurns.has(corrId))) break; + const node = makeNode({ + type: 'turn', state: 'running', + label: '🔄 Turn', + correlationId: corrId, + startedAt: ts, replay, + }); + if (corrId) { hudPending.set(corrId, node); hudTurns.set(corrId, node); } + if (!replay && corrId) activeTurnCorrId.value = corrId; + addHudNode(node); + break; + } + case 'turn_end': { + const node = corrId ? hudPending.get(corrId) : null; + if (node) { + node.state = 'done'; + node.endedAt = ts; + node.durationMs = event.durationMs; + if (corrId) { hudPending.delete(corrId); hudTurns.delete(corrId); } + triggerRef(hudTree); hudVersion.value++; + } + // else: no matching open turn — drop silently (no orphan node) + if (!replay && corrId && activeTurnCorrId.value === corrId) activeTurnCorrId.value = null; + break; + } + case 'think_start': { + const node = makeNode({ + type: 'think', state: 'running', + label: 'Thinking', + correlationId: corrId, + startedAt: ts, replay, + }); + if (corrId) hudPending.set(corrId, node); + addHudNode(node, parentId); + break; + } + case 'think_end': { + const node = corrId ? hudPending.get(corrId) : null; + if (node) { + node.state = 'done'; + node.endedAt = ts; + node.durationMs = event.durationMs; + if (corrId) hudPending.delete(corrId); + triggerRef(hudTree); hudVersion.value++; + } else { + addHudNode(makeNode({ type: 'think', state: 'done', label: 'Thinking', correlationId: corrId, startedAt: ts, endedAt: ts, durationMs: event.durationMs, replay }), parentId); + } + break; + } + case 'tool_start': { + const tool = event.tool || 'unknown'; + const args = event.args || {}; + const label = buildToolLabel(tool, args); + const node = makeNode({ + type: 'tool', state: 'running', + label, tool, args, + correlationId: corrId, + startedAt: ts, replay, + }); + if (corrId) hudPending.set(corrId, node); + // Register in toolCallMap for reliable tool_end pairing + if (event.toolCallId) toolCallMap.set(event.toolCallId, new WeakRef(node)); + addHudNode(node, parentId); + lastSystemMsgRef.value = label; + break; + } + case 'tool_end': { + const tool = event.tool || 'unknown'; + const result = event.result || {}; + // Lookup order: toolCallId (most reliable) → correlationId → tree scan fallback + let node = lookupByToolCallId(event.toolCallId) + ?? (corrId ? hudPending.get(corrId) : null); + // Last resort: find oldest running tool node under same turn + if (!node && parentId) { + const turnNode = findNode(hudTree.value, parentId); + if (turnNode?.children) { + const match = turnNode.children.find( + n => n.type === 'tool' && n.state === 'running' && (!tool || n.tool === tool || n.tool === 'unknown') + ); + if (match) { + node = match; + if (match.correlationId) hudPending.delete(match.correlationId); + } + } + } + if (node) { + node.state = result.ok === false ? 'error' : 'done'; + node.result = result; + node.endedAt = ts; + node.durationMs = event.durationMs; + node.label = buildToolLabel(tool, node.args || {}, result); + if (corrId) hudPending.delete(corrId); + if (event.toolCallId) toolCallMap.delete(event.toolCallId); + triggerRef(hudTree); hudVersion.value++; + } else { + // No matching start — create completed node (true orphan) + const label = buildToolLabel(tool, event.args || {}, result); + addHudNode(makeNode({ type: 'tool', state: 'done', label, tool, result, correlationId: corrId, startedAt: ts, endedAt: ts, durationMs: event.durationMs, replay }), parentId); + } + break; + } + case 'received': { + const node = makeNode({ + type: 'received', state: 'done', + subtype: event.subtype, + label: event.label || event.subtype || 'received', + startedAt: ts, endedAt: ts, replay, + }); + addHudNode(node); + break; + } + } + } + + function buildToolLabel(tool: string, args: Record, result?: Record): string { + const fileTools = ['read', 'write', 'edit', 'append']; + if (fileTools.includes(tool)) { + const vp: string = args.viewerPath || args.path || ''; + const filename = vp.split('/').pop() || vp; + const area = result?.area || args.area; + const areaStr = area ? `:L${area.startLine}–${area.endLine}` : ''; + return `${filename}${areaStr}`; + } + if (tool === 'exec') { + const cmd: string = args.command || ''; + return `${cmd.slice(0, 60)}${cmd.length > 60 ? '…' : ''}`; + } + if (tool === 'web_fetch') return (args.url || '').slice(0, 60); + if (tool === 'web_search') return (args.query || '').slice(0, 60); + return tool; + } + + // ── Turn → tools lookup ─────────────────────────────────────────────────── + + function getToolsForTurn(corrId: string | null | undefined): HudNode[] { + if (!corrId) return []; + const turn = hudTree.value.find(n => n.correlationId === corrId) + ?? [...hudTurns.values()].find(n => n.correlationId === corrId); + return turn ? turn.children.filter(c => c.type === 'tool') : []; + } + + // ── Debug snapshot ──────────────────────────────────────────────────────── + + function hudSnapshot(): string { + const lines: string[] = [`HUD tree — ${hudTree.value.length} root node(s)\n`]; + for (const node of hudTree.value) { + const dur = node.durationMs != null ? ` [${node.durationMs}ms]` : ''; + const repl = node.replay ? ' (replay)' : ''; + lines.push(` ${node.state === 'running' ? '⏳' : node.state === 'error' ? '❌' : '✅'} [${node.type}] ${node.label}${dur}${repl}`); + lines.push(` id=${node.id.slice(0,8)} corrId=${(node.correlationId || '—').slice(0,8)} children=${node.children.length}`); + for (const child of node.children) { + const cdur = child.durationMs != null ? ` [${child.durationMs}ms]` : ''; + lines.push(` ${child.state === 'running' ? '⏳' : child.state === 'error' ? '❌' : '✅'} [${child.type}] ${child.label}${cdur}`); + if (child.args) lines.push(` args: ${JSON.stringify(child.args).slice(0, 80)}`); + if (child.result) lines.push(` result: ${JSON.stringify(child.result).slice(0, 80)}`); + } + } + return lines.join('\n'); + } + + // ── Legacy pushSystem (kept for non-tool system messages) ──────────────── + + function pushSystem(text: string) { + lastSystemMsgRef.value = text; + } + + // ── Pending clear ───────────────────────────────────────────────────────── + + function flushPendingClear(pendingClearRef: { value: boolean }) { + if (!pendingClearRef.value) return; + pendingClearRef.value = false; + store.clearMessages(); + visibleCount.value = VISIBLE_PAGE; + sessionHistoryComplete.value = false; + loadStartTime = performance.now(); + pendingMessages = []; + pendingUsageTotals = null; + } + + // ── History reveal ──────────────────────────────────────────────────────── + + function revealMessages() { + loadStartTime = null; + + const _pending = pendingMessages; + const _usage = pendingUsageTotals; + pendingMessages = []; + pendingUsageTotals = null; + + const idx = store.messages.findIndex(m => m.role === 'system' && m.content.includes('Loading session history...')); + if (idx !== -1) { + store.messages.splice(idx, 1, ..._pending); + } else { + store.messages.unshift(..._pending); + } + + // Set session context hint based on current agent's messages only (_pending) + const revealedCount = _pending.filter((m: any) => m.role !== 'system').length; + store.sessionContextHint = revealedCount > 0 ? `${revealedCount} msgs in context` : 'fresh context'; + + if (_usage) lastUsage.value = _usage; + } + + // ── Bulk history handler ────────────────────────────────────────────────── + + function handleSessionHistory(entries: any[]) { + if (!entries?.length) return; + if (loadStartTime === null) loadStartTime = performance.now(); + + if (!store.messages.some(m => m.content?.includes('Loading session history...'))) { + store.pushSystem('⏳ Loading session history...', agentIdFn()); + } + + const newMsgs: any[] = []; + const currentAgentId = agentIdFn(); + const currentSessionId = store.localSessionId; + let pendingUsage: any = null; + + for (const data of entries) { + // HUD events (hud protocol) — route to pushHudEvent + if (data.type === 'hud') { + pushHudEvent({ ...data, replay: true }); + continue; + } + if (data.event === 'tool_start' || data.event === 'tool_end' || + data.event === 'think_start' || data.event === 'think_end' || + data.event === 'turn_start' || data.event === 'turn_end' || + data.event === 'received') { + pushHudEvent({ ...data, replay: true }); + continue; + } + + if (data.entry_type === 'session_context') { + newMsgs.push({ role: 'session_context', content: data.content || '', agentId: currentAgentId, sessionId: currentSessionId }); + } else if (data.entry_type === 'user_message') { + newMsgs.push({ role: 'user', content: data.content || '', agentId: currentAgentId, sessionId: currentSessionId }); + } else if (data.entry_type === 'assistant_text') { + const content = (data.content || '').replace(/^\[\[reply_to[^\]]*\]\]\s*/i, '').trim(); + if (!content) continue; + const msg: any = { role: 'assistant', content, streaming: false, agentId: currentAgentId, sessionId: currentSessionId, timestamp: data.ts || null }; + if (data.truncated) msg.truncated = true; + if (pendingUsage) { msg.usage = pendingUsage; pendingUsage = null; } + newMsgs.push(msg); + } else if (data.entry_type === 'usage') { + pendingUsage = { + input_tokens: data.input_tokens || 0, + output_tokens: data.output_tokens || 0, + total_tokens: data.total_tokens || 0, + cost: Number(data.cost || 0), + }; + const last = newMsgs[newMsgs.length - 1]; + if (last?.role === 'assistant') { last.usage = pendingUsage; pendingUsage = null; } + } + } + pendingMessages = newMsgs; + + const totalUsage = entries + .filter(e => e.entry_type === 'usage') + .reduce((acc, e) => ({ + input_tokens: acc.input_tokens + (e.input_tokens || 0), + output_tokens: acc.output_tokens + (e.output_tokens || 0), + total_tokens: acc.total_tokens + (e.total_tokens || 0), + cost: acc.cost + Number(e.cost || 0), + }), { input_tokens: 0, output_tokens: 0, total_tokens: 0, cost: 0 }); + pendingUsageTotals = totalUsage.total_tokens > 0 ? totalUsage : null; + } + + // ── Incremental entry handler (live + late-join) ────────────────────────── + + function handleSessionEntry( + data: any, + sentMessages: Set, + pushSystemFn: (text: string) => void, + ) { + // HUD events — route directly + if (data.type === 'hud') { pushHudEvent(data); return; } + + const isReplay = !sessionHistoryComplete.value; + const currentAgentId = agentIdFn(); + const currentSessionId = store.localSessionId; + switch (data.entry_type) { + case 'user_message': { + const raw = data.content || ''; + if (raw.startsWith('A new session was started')) break; + // Voice transcripts are already shown via message_update — skip echo + if (!isReplay && raw.includes('[voice transcript]:')) break; + // Dedup: check if we already have this message by msgId or content + const hasByMsgId = data.msgId && store.messages.some((m: any) => m.msgId === data.msgId); + if (hasByMsgId) break; // already shown via optimistic push + if (!isReplay && !sentMessages.has(raw.trim())) { + store.messages.push({ role: 'user', content: raw, agentId: currentAgentId, sessionId: currentSessionId, msgId: data.msgId }); + } else { + sentMessages.delete(raw.trim()); + } + break; + } + case 'assistant_text': break; + case 'usage': break; + } + } + + function resetHudMaps() { + hudPending.clear(); + hudTurns.clear(); + toolCallMap.clear(); + activeTurnCorrId.value = null; + hudTree.value = []; + hudVersion.value++; + } + + function toolCallMapSnapshot(): Array<{ toolCallId: string; label: string | null; state: string | null; stale: boolean }> { + return [...toolCallMap.entries()].map(([k, ref]) => { + const node = ref.deref(); + return { toolCallId: k, label: node?.label ?? null, state: node?.state ?? null, stale: !node }; + }); + } + + return { + sessionHistoryComplete, + lastUsage, + lastSystemMsgRef, + hudTree, + hudVersion, + activeTurnCorrId, + getToolsForTurn, + pushHudEvent, + hudSnapshot, + toolCallMapSnapshot, + resetHudMaps, + flushPendingClear, + revealMessages, + handleSessionHistory, + handleSessionEntry, + pushSystem, + }; +} diff --git a/frontend/src/composables/ui.ts b/frontend/src/composables/ui.ts new file mode 100644 index 0000000..a1e7e79 --- /dev/null +++ b/frontend/src/composables/ui.ts @@ -0,0 +1,64 @@ +import { computed, type Ref } from 'vue'; + +import type { Agent } from './agents'; // Import Agent interface + +export function formatUsage(u: any, agentId: string | null = null, allAgents: Agent[] | null = null): string { + if (!u) return ''; + const inn = u.input_tokens ?? u.in ?? null; + const out = u.output_tokens ?? u.out ?? null; + + let inCost = 0, outCost = 0, totalCost = 0; + if (agentId && allAgents && inn !== null && out !== null) { + const agent = allAgents.find(a => a.id === agentId); + if (agent && agent.promptPrice !== null && agent.completionPrice !== null) { + inCost = (inn / 1_000_000) * agent.promptPrice; + outCost = (out / 1_000_000) * agent.completionPrice; + totalCost = inCost + outCost; + } + } + + // Fallback to existing cost from backend if no agent pricing + if (totalCost === 0) { + totalCost = typeof u.cost === 'object' ? (u.cost?.total ?? 0) : Number(u.cost || 0); + } + + const showCosts = totalCost > 0.0000001; + const inCostStr = showCosts && inCost > 0 ? `$${inCost.toFixed(4)}` : ''; + const outCostStr = showCosts && outCost > 0 ? `$${outCost.toFixed(4)}` : ''; + + if (inn !== null && out !== null) { + const inFmt = inn >= 1000 ? (inn / 1000).toFixed(1) + 'k' : String(inn); + const outFmt = out >= 1000 ? (out / 1000).toFixed(1) + 'k' : String(out); + + let pricingStr = ''; + if (agentId && allAgents) { + const agent = allAgents.find(a => a.id === agentId); + if (agent && agent.promptPrice !== null && agent.completionPrice !== null) { + pricingStr = ` (${agent.promptPrice.toFixed(2)}/${agent.completionPrice.toFixed(2)})`; + } + } + + const parts = [`${inFmt} in${inCostStr ? ` (${inCostStr})` : ''}`, `${outFmt} out${outCostStr ? ` (${outCostStr})` : ''}`]; + if (showCosts) parts.push(`$${totalCost.toFixed(4)}${pricingStr}`); + return parts.join(' · '); + } + const total = u.total_tokens ?? u.total ?? 0; + return `${total} tokens${showCosts ? ` · $${totalCost.toFixed(4)}` : ''}`; +} + +import pkg from '../../package.json'; + +declare const __BUILD__: string; + +export function useUI(status: Ref) { + const version: string = `${pkg.version}-${__BUILD__}`; + + const statusClass = computed(() => { + if (status.value.includes('Connected')) return 'connected'; + if (status.value.includes('Connecting')) return 'connecting'; + if (status.value.includes('Error')) return 'error'; + return ''; + }); + + return { version, statusClass }; +} diff --git a/frontend/src/composables/useAgentDisplay.ts b/frontend/src/composables/useAgentDisplay.ts new file mode 100644 index 0000000..d2b1315 --- /dev/null +++ b/frontend/src/composables/useAgentDisplay.ts @@ -0,0 +1,37 @@ +import { computed, type Ref } from 'vue'; +import { useChatStore } from '../store/chat'; +import type { Agent } from './agents'; + +export function useAgentDisplay( + selectedAgent: Ref, + defaultAgent: Ref, + allAgents: Ref, +) { + const chatStore = useChatStore(); + + const defaultAgentName = computed(() => { + const agent = allAgents.value.find(a => a.id === defaultAgent.value); + return agent ? agent.name : defaultAgent.value; + }); + + const agentDisplayName = computed(() => { + const agent = allAgents.value.find(a => a.id === selectedAgent.value); + return (agent ? agent.name : selectedAgent.value).toUpperCase(); + }); + + const isAgentRunning = computed(() => chatStore.smState === 'AGENT_RUNNING'); + const agentStatusDone = computed(() => chatStore.channelState === 'READY' || chatStore.channelState === 'FRESH'); + + const agentStatus = computed(() => { + switch (chatStore.smState) { + case 'CONNECTING': return '⚙️ Connecting…'; + case 'AGENT_RUNNING': return '⚙️ Working…'; + case 'HANDOVER_PENDING': return '📝 Writing handover…'; + case 'HANDOVER_DONE': return '✅ Handover ready'; + case 'SWITCHING': return '🔀 Switching…'; + default: return null; + } + }); + + return { defaultAgentName, agentDisplayName, isAgentRunning, agentStatusDone, agentStatus }; +} diff --git a/frontend/src/composables/useAgentSocket.ts b/frontend/src/composables/useAgentSocket.ts new file mode 100644 index 0000000..48cd80a --- /dev/null +++ b/frontend/src/composables/useAgentSocket.ts @@ -0,0 +1,340 @@ +import { ref, nextTick, computed, watch } from 'vue'; +import { useChatStore } from '../store/chat'; +import { ws, agents } from '../store'; +import { useSessionHistory } from './sessionHistory'; +import { useMessages } from './useMessages'; + +export function useAgentSocket( + visibleCount: { value: number }, + lastUsage: { value: any }, + pendingClearRef: { value: boolean }, + sentMessages: Set, + restoreLastSent?: () => void, +) { + const chatStore = useChatStore(); + const { connected, send: wsSend, onMessage: onWsMessage, replayBuffer } = ws; + const { updateFromServer, selectedAgent } = agents; + + // We strictly use chatStore actions for everything now + const handoverInProgress = () => chatStore.smState === 'HANDOVER_PENDING' || chatStore.smState === 'HANDOVER_DONE'; + const isAgentRunning = () => chatStore.smState === 'AGENT_RUNNING'; + + const history = useSessionHistory(isAgentRunning, visibleCount, () => selectedAgent.value); + + // Sync lastUsage back to caller + history.lastUsage = lastUsage as any; + + // Keep chatStore.activeTurnCorrId in sync with the HUD-tracked active turn + watch(history.activeTurnCorrId, (id) => { chatStore.activeTurnCorrId = id; }); + + function pushSystem(text: string) { + history.pushSystem(text); + } + + function mount() { + let wasReconnected = false; + let wasJustSwitched = false; + + // ── Message handlers keyed by data.type ────────────────────────── + // Each handler receives the raw WS message. Handlers close over + // mount-scoped state (wasReconnected, wasJustSwitched) and module- + // scoped refs (chatStore, history, etc.). + + const handlers: Record void> = { + auth_ok(data) { updateFromServer(data); }, + ready(data) { + updateFromServer(data); + if (data.sessionId) chatStore.sessionKey = data.sessionId; + }, + + thinking(data) { + if (!handoverInProgress()) chatStore.appendThinking(data.content); + }, + + delta(data) { + if (!handoverInProgress()) { + history.flushPendingClear(pendingClearRef); + chatStore.collapseThinking(); + chatStore.appendAssistantDelta(data.content, data.agentId); + } + }, + + message(data) { + if (handoverInProgress()) return; + history.flushPendingClear(pendingClearRef); + if (data.streaming === false) { + chatStore.createCompleteAssistantMessage(data.content, data.agentId, data.usage); + } else if (data.final) { + chatStore.finalizeAssistantMessage(null, data.usage); + } + // streaming === true && !final handled by 'delta' to avoid double-text + }, + + truncated_warning(_data) { + chatStore.collapseThinking(); + if (chatStore.hasActiveStreamingMessage()) chatStore.finalizeAssistantMessage(null, undefined, true); + chatStore.truncatedWarning = true; + }, + + done(data) { + if (handoverInProgress()) return; + chatStore.collapseThinking(); + if (data.suppress) { + chatStore.suppressAssistantMessage(); + } else { + const doneContent: string | null = data.content || null; + if (chatStore.hasActiveStreamingMessage()) { + const deltaLen = chatStore.streamingMessageLength(); + const useContent = (doneContent && deltaLen < doneContent.length) ? doneContent : null; + chatStore.finalizeAssistantMessage(useContent, data.usage); + } else if (doneContent) { + chatStore.createCompleteAssistantMessage(doneContent, undefined, data.usage); + } + } + history.lastSystemMsgRef.value = null; + }, + + session_history(data) { + if (chatStore.hasActiveStreamingMessage()) chatStore.finalizeAssistantMessage(null); + chatStore.collapseThinking(); + history.flushPendingClear(pendingClearRef); + history.handleSessionHistory(data.entries); + }, + + hud(data) { + history.pushHudEvent(data); + if (data.event === 'turn_start' && !data.replay) { + chatStore.activeTurnCorrId = data.correlationId ?? null; + chatStore.startNewAssistantMessage(selectedAgent.value); + } + }, + + event(data) { + // Gateway agent stream events (tool, lifecycle, assistant) + if (data.event === 'agent') { + const stream = data.payload?.stream; + if (stream === 'tool') { + console.log('[HUD agent/tool]', JSON.stringify(data.payload).slice(0, 400)); + } + } + }, + + tool(data) { + // Legacy tool events — backward compat with older BE versions + if (data.action === 'call') { + pushSystem(`${data.tool} ${data.args || ''}`); + } else if (data.action === 'result') { + pushSystem(`→ ${data.result || ''}`); + } + }, + + session_entry(data) { + history.handleSessionEntry(data, sentMessages, pushSystem); + }, + + message_update(data) { + // Patch an existing message by msgId (e.g. voice transcript, audio URL) + if (!data.msgId || !data.patch) return; + const patch = { ...data.patch }; + // Add auth token to voice audio URL + if (patch.voiceAudioUrl) { + const token = localStorage.getItem('openclaw_session') || localStorage.getItem('titan_token') || ''; + patch.voiceAudioUrl = `${patch.voiceAudioUrl}?token=${encodeURIComponent(token)}`; + } + // Pre-add the gateway echo text to sentMessages so it gets deduped + if (patch.transcript) { + sentMessages.add(`[voice transcript]: ${patch.transcript}`.trim()); + } + const patched = chatStore.patchMessage(data.msgId, patch); + if (!patched) { + console.warn('[message_update] no message found for msgId:', data.msgId); + } + }, + + handover_done(data) { + chatStore.messages.push({ + role: 'assistant', + content: data.content || '📋 Handover written.', + agentId: selectedAgent.value, + sessionId: chatStore.localSessionId, + }); + chatStore.messages.push({ + role: 'system', + content: 'Start a new session with this handover context?', + confirmNew: true, + confirmed: false, + agentId: selectedAgent.value, + sessionId: chatStore.localSessionId, + }); + }, + + handover_context(_data) { + // Silently discard — handover content already shown in chat history + }, + + // New two-SM protocol: channel_state + connection_state + channel_state(data) { + if (!data.state) return; + if (data.clear_history) { + console.log('[clear] channel switch', { state: data.state, msgCount: chatStore.messages.length }); + history.resetHudMaps(); + chatStore.messages.length = 0; + pendingClearRef.value = false; + } + const prevChannel = chatStore.channelState; + chatStore.applyChannelState(data.state); + // On READY/FRESH: flush queued thought or clear after stop + if (data.state === 'READY' || data.state === 'FRESH') { + history.lastSystemMsgRef.value = null; + if (chatStore.queuedThought !== null && prevChannel === 'AGENT_RUNNING') { + const thought = chatStore.queuedThought as string; + chatStore.queuedThought = null; + wsSend({ type: 'message', content: thought }); + } + } + }, + + connection_state(data) { + if (!data.state) return; + chatStore.applyConnectionState(data.state); + if (data.state === 'LOADING_HISTORY') { + // Only stash on agent switch (clear_history comes via channel_state) + // Plain F5 reloads same session — no need to stash + history.resetHudMaps(); + chatStore.messages.length = 0; + pendingClearRef.value = false; + wasReconnected = true; + } + if (data.state === 'SYNCED') { + history.flushPendingClear(pendingClearRef); + history.revealMessages(); + wasReconnected = false; + wasJustSwitched = false; + } + }, + + // Legacy: still handle session_state for backward compat + session_state(data) { + if (!data.state) return; + if (data.reconnected) wasReconnected = true; + if (data.reconnected || data.clear_history) { + console.log('[clear] immediate flush', { reconnected: data.reconnected, clear_history: data.clear_history, state: data.state, msgCount: chatStore.messages.length }); + chatStore.stashMessages(); + history.resetHudMaps(); + chatStore.messages.length = 0; + pendingClearRef.value = false; + } + chatStore.applySessionState(data.state); + if (data.state === 'READY' || data.state === 'FRESH' || data.state === 'IDLE') { + history.lastSystemMsgRef.value = null; + if (chatStore.queuedThought !== null) { + const thought = chatStore.queuedThought as string; + chatStore.queuedThought = null; + wsSend({ type: 'message', content: thought }); + } + } + }, + + session_total_tokens(data) { + chatStore.sessionTotalTokens = data; + }, + + finance_update(data) { + chatStore.finance = data; + }, + + usage(data) { + if (!handoverInProgress()) { + chatStore.sessionTotalTokens = { + input_tokens: data.input_tokens || (chatStore.sessionTotalTokens?.input_tokens || 0), + cache_read_tokens: data.cache_read_tokens || (chatStore.sessionTotalTokens?.cache_read_tokens || 0), + output_tokens: data.output_tokens || (chatStore.sessionTotalTokens?.output_tokens || 0), + }; + } + }, + + session_status(data) { + if (data.status === 'no_session') { + pendingClearRef.value = true; + history.flushPendingClear(pendingClearRef); + chatStore.messages.push({ + role: 'system', + type: 'no_session', + content: '-- NO SESSION --', + agentId: selectedAgent.value, + sessionId: chatStore.localSessionId, + }); + chatStore.sessionContextHint = ''; + wasJustSwitched = false; + wasReconnected = false; + } else if (data.status === 'watching') { + history.flushPendingClear(pendingClearRef); + history.sessionHistoryComplete.value = true; + history.revealMessages(); + wasReconnected = false; + wasJustSwitched = false; + } + }, + + sent(_data) { + // User sent — transition handled by chatStore.send() in AgentsView + }, + + switch_ok(data) { + wasJustSwitched = true; + history.sessionHistoryComplete.value = false; + if (data.sessionKey) chatStore.sessionKey = data.sessionKey; + }, + + new_ok(_data) { + history.sessionHistoryComplete.value = false; + }, + + error(data) { + if (data.code === 'SESSION_TERMINATED') { + chatStore.pushSystem('⚠️ Message not delivered — session was resetting. Please try again.', selectedAgent.value); + restoreLastSent?.(); + } else if (data.code === 'DISCARDED_NOT_IDLE' || data.code === 'DISCARDED_NOT_READY') { + chatStore.pushSystem('⚠️ Message not delivered — agent was busy. Please try again.', selectedAgent.value); + restoreLastSent?.(); + } + }, + + stopped(_data) { + chatStore.pushSystem('✅ Agent stopped', selectedAgent.value); + }, + + killed(_data) { + chatStore.pushSystem('☠️ Agent killed', selectedAgent.value); + }, + }; + + // ── Subscribe ──────────────────────────────────────────────────── + + const unsubscribe = onWsMessage((data: any) => { + const handler = handlers[data.type]; + if (handler) handler(data); + }); + + // Replay buffered messages through the same handler map + replayBuffer((data: any) => { + const handler = handlers[data.type]; + if (handler) handler(data); + }); + + return unsubscribe; + } + + return { + mount, + lastSystemMsg: history.lastSystemMsgRef, + hudTree: history.hudTree, + hudVersion: history.hudVersion, + getToolsForTurn: history.getToolsForTurn, + hudSnapshot: history.hudSnapshot, + toolCallMapSnapshot: history.toolCallMapSnapshot, + sessionHistoryComplete: history.sessionHistoryComplete, + pushSystem, + hasActiveStreamingMessage: chatStore.hasActiveStreamingMessage, + }; +} diff --git a/frontend/src/composables/useAttachments.ts b/frontend/src/composables/useAttachments.ts new file mode 100644 index 0000000..bc5c37a --- /dev/null +++ b/frontend/src/composables/useAttachments.ts @@ -0,0 +1,87 @@ +import { ref } from 'vue'; + +export interface Attachment { + file: File; + preview: string; + base64: string; + mimeType: string; + fileName: string; +} + +export interface AttachmentPayload { + type: string; + mimeType: string; + content: string; + fileName: string; +} + +const ACCEPTED_TYPES = [ + 'image/jpeg', 'image/png', 'image/gif', 'image/webp', + 'application/pdf', + 'audio/webm', 'audio/mp4', 'audio/ogg', 'audio/mpeg', 'audio/wav', 'audio/x-m4a', +]; +const MAX_BYTES = 10 * 1024 * 1024; // 10MB + +function readAsBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = reader.result as string; + // Strip data URL prefix: "data:image/png;base64,..." + const idx = result.indexOf(','); + resolve(idx >= 0 ? result.slice(idx + 1) : result); + }; + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); +} + +export function useAttachments() { + const attachments = ref([]); + + async function addFiles(files: FileList | File[]) { + for (const file of Array.from(files)) { + // Exact match for non-audio, prefix match for audio (Chrome adds codecs like "audio/webm;codecs=opus") + const baseType = file.type.split(';')[0]; + if (!ACCEPTED_TYPES.includes(baseType) && !ACCEPTED_TYPES.includes(file.type)) { + console.warn(`[attachments] skipped ${file.name}: unsupported type ${file.type}`); + continue; + } + if (file.size > MAX_BYTES) { + console.warn(`[attachments] skipped ${file.name}: exceeds 5MB (${(file.size / 1024 / 1024).toFixed(1)}MB)`); + continue; + } + const base64 = await readAsBase64(file); + const preview = URL.createObjectURL(file); + attachments.value.push({ file, preview, base64, mimeType: file.type, fileName: file.name }); + } + } + + function removeAttachment(index: number) { + const att = attachments.value[index]; + if (att) URL.revokeObjectURL(att.preview); + attachments.value.splice(index, 1); + } + + function clearAttachments() { + for (const att of attachments.value) URL.revokeObjectURL(att.preview); + attachments.value = []; + } + + function toPayload(): AttachmentPayload[] { + return attachments.value.map(a => ({ + type: a.mimeType.startsWith('image/') ? 'image' + : a.mimeType.startsWith('audio/') ? 'audio' + : 'document', + mimeType: a.mimeType, + content: a.base64, + fileName: a.fileName, + })); + } + + function hasAttachments(): boolean { + return attachments.value.length > 0; + } + + return { attachments, addFiles, removeAttachment, clearAttachments, toPayload, hasAttachments }; +} diff --git a/frontend/src/composables/useAudioRecorder.ts b/frontend/src/composables/useAudioRecorder.ts new file mode 100644 index 0000000..b5db854 --- /dev/null +++ b/frontend/src/composables/useAudioRecorder.ts @@ -0,0 +1,117 @@ +import { ref, onUnmounted } from 'vue'; + +export function useAudioRecorder() { + const isRecording = ref(false); + const duration = ref(0); + const audioLevel = ref(0); // 0-1 normalized audio level + const micDenied = ref(false); + + let mediaRecorder: MediaRecorder | null = null; + let stream: MediaStream | null = null; + let audioCtx: AudioContext | null = null; + let analyser: AnalyserNode | null = null; + let levelBuf: Uint8Array | null = null; + let chunks: Blob[] = []; + let timer: ReturnType | null = null; + let startTime = 0; + + function cleanup() { + if (timer) { clearInterval(timer); timer = null; } + if (audioCtx) { audioCtx.close().catch(() => {}); audioCtx = null; analyser = null; levelBuf = null; } + if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; } + mediaRecorder = null; + chunks = []; + duration.value = 0; + audioLevel.value = 0; + isRecording.value = false; + } + + function updateLevel() { + if (!analyser || !levelBuf) return; + analyser.getByteTimeDomainData(levelBuf); + // Compute RMS + let sum = 0; + for (let i = 0; i < levelBuf.length; i++) { + const v = (levelBuf[i] - 128) / 128; + sum += v * v; + } + audioLevel.value = Math.min(1, Math.sqrt(sum / levelBuf.length) * 3); + } + + async function startRecording(): Promise { + if (isRecording.value) return; + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } catch (err) { + console.warn('[audio] mic access denied:', err); + micDenied.value = true; + setTimeout(() => { micDenied.value = false; }, 5000); + return; + } + + // Audio level analyser + try { + audioCtx = new AudioContext(); + const source = audioCtx.createMediaStreamSource(stream); + analyser = audioCtx.createAnalyser(); + analyser.fftSize = 256; + source.connect(analyser); + levelBuf = new Uint8Array(analyser.fftSize); + } catch (err) { + console.warn('[audio] analyser setup failed:', err); + } + + chunks = []; + const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') + ? 'audio/webm;codecs=opus' + : MediaRecorder.isTypeSupported('audio/webm') + ? 'audio/webm' + : ''; + mediaRecorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); + mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) chunks.push(e.data); }; + mediaRecorder.start(250); + isRecording.value = true; + startTime = Date.now(); + timer = setInterval(() => { + duration.value = Math.floor((Date.now() - startTime) / 1000); + updateLevel(); + }, 80); + } + + function stopRecording(): Promise { + return new Promise((resolve) => { + if (!mediaRecorder || mediaRecorder.state === 'inactive') { + cleanup(); + resolve(null); + return; + } + mediaRecorder.onstop = () => { + const mimeType = mediaRecorder?.mimeType || 'audio/webm'; + const ext = mimeType.includes('mp4') ? 'mp4' : mimeType.includes('ogg') ? 'ogg' : 'webm'; + const blob = new Blob(chunks, { type: mimeType }); + const file = new File([blob], `recording-${Date.now()}.${ext}`, { type: mimeType }); + cleanup(); + resolve(file); + }; + mediaRecorder.stop(); + }); + } + + function cancelRecording() { + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + mediaRecorder.onstop = () => {}; + mediaRecorder.stop(); + } + cleanup(); + } + + function formatDuration(secs: number): string { + const m = Math.floor(secs / 60); + const s = secs % 60; + return `${m}:${s.toString().padStart(2, '0')}`; + } + + onUnmounted(cleanup); + + return { isRecording, duration, audioLevel, micDenied, startRecording, stopRecording, cancelRecording, formatDuration }; +} diff --git a/frontend/src/composables/useBreakout.ts b/frontend/src/composables/useBreakout.ts new file mode 100644 index 0000000..659cb10 --- /dev/null +++ b/frontend/src/composables/useBreakout.ts @@ -0,0 +1,97 @@ +/** + * useBreakout — Breakout window management + * + * Module-level Map survives HMR so we never lose window references. + * Single source of truth for presets and token derivation. + */ + +import { ref, type Ref } from 'vue'; + +export interface BreakoutRequest { + name: string; + preset: string; + nonce: string; + resolve: (confirmed: boolean) => void; +} + +// ── Module-level (survives HMR) ── +const windows = new Map(); + +export const PRESETS: Record = { + mobile: [375, 812], + tablet: [768, 1024], + 'tablet-landscape': [1024, 768], + desktop: [1280, 800], +}; + +export function deriveToken(parentToken: string, name: string): string { + return `${parentToken}-breakout-${name}`; +} + +export function useBreakout() { + const pendingRequest: Ref = ref(null); + + /** Open a breakout — shows confirmation modal, resolves when user confirms/denies */ + async function open(args: { name: string; preset?: string; w?: number; h?: number }): Promise { + const parentToken = sessionStorage.getItem('hermes_takeover_token'); + if (!parentToken) return { error: 'No takeover token active' }; + + const name = args.name || 'mobile'; + const preset = args.preset || 'desktop'; + + // Close existing with same name + const existing = windows.get(name); + if (existing && !existing.closed) existing.close(); + + const nonce = Math.random().toString(36).slice(2, 6); + const [pw, ph] = args.w && args.h ? [args.w, args.h] : (PRESETS[preset] || PRESETS.desktop); + const presetLabel = `${pw}x${ph}`; + + return new Promise((resolve) => { + pendingRequest.value = { + name, + preset: presetLabel, + nonce, + resolve: (confirmed: boolean) => { + pendingRequest.value = null; + if (!confirmed) { resolve({ error: 'rejected by user' }); return; } + const token = deriveToken(parentToken, name); + const url = `${window.location.origin}${window.location.pathname}?breakout_token=${token}#/agents`; + const popup = window.open(url, `hermes_breakout_${name}`, `width=${pw},height=${ph},resizable=yes,scrollbars=yes`); + if (!popup) { resolve({ error: 'popup blocked' }); return; } + windows.set(name, popup); + resolve({ opened: name, token, size: presetLabel }); + }, + }; + }); + } + + /** Open breakout directly from DevView UI (no modal — user is clicking the button) */ + function openDirect(name: string, presetStr: string, parentToken: string) { + const [w, h] = presetStr.split('x').map(Number); + const token = deriveToken(parentToken, name); + const url = `${window.location.origin}${window.location.pathname}?breakout_token=${token}#/agents`; + const popup = window.open(url, `hermes_breakout_${name}`, `width=${w},height=${h},resizable=yes,scrollbars=yes`); + if (!popup) { alert('Popup blocked -- allow popups for this site'); return; } + windows.set(name, popup); + } + + function list(): Record { + const result: Record = {}; + for (const [name, win] of windows) { + result[name] = { alive: !win.closed }; + if (win.closed) windows.delete(name); + } + return result; + } + + function close(args: { name: string }): { closed: string } | { error: string } { + const win = windows.get(args.name); + if (!win) return { error: `No breakout: ${args.name}` }; + if (!win.closed) win.close(); + windows.delete(args.name); + return { closed: args.name }; + } + + return { windows, pendingRequest, open, openDirect, list, close }; +} diff --git a/frontend/src/composables/useCapture.ts b/frontend/src/composables/useCapture.ts new file mode 100644 index 0000000..996f9b8 --- /dev/null +++ b/frontend/src/composables/useCapture.ts @@ -0,0 +1,117 @@ +/** + * useCapture — WebRTC screen capture lifecycle + * + * Stream stored on window so it survives Vite HMR module re-evaluation. + * Single implementation of getDisplayMedia + canvas capture. + */ + +import { ref } from 'vue'; +import { useHermes } from './useHermes'; + +// ── Stored on window.__hermes (survives HMR module re-evaluation) ── +const H = useHermes(); + +function getStream(): MediaStream | null { return H.captureStream || null; } +function setStream(s: MediaStream | null) { H.captureStream = s; } + +const isActive = ref(false); + +// Restore state on HMR re-evaluation +syncState(); + +function syncState() { + const stream = getStream(); + isActive.value = !!(stream && stream.active && stream.getVideoTracks().some(t => t.readyState === 'live')); + if (!isActive.value && stream) { + stream.getTracks().forEach(t => t.stop()); + setStream(null); + } +} + +export function useCapture() { + async function enable(): Promise<{ enabled: boolean; error?: string }> { + const stream = getStream(); + if (stream && stream.active) { + syncState(); + if (isActive.value) return { enabled: true }; + } + try { + const newStream = await navigator.mediaDevices.getDisplayMedia({ + video: { displaySurface: 'browser' } as any, + preferCurrentTab: true, + } as any); + newStream.getVideoTracks()[0].onended = () => { + setStream(null); + syncState(); + }; + setStream(newStream); + syncState(); + return { enabled: true }; + } catch (e: any) { + setStream(null); + syncState(); + return { enabled: false, error: `Capture denied: ${e.message}` }; + } + } + + function disable() { + const stream = getStream(); + if (stream) { + stream.getTracks().forEach(t => t.stop()); + setStream(null); + } + syncState(); + } + + async function capture(quality = 0.7): Promise<{ dataUrl: string; length: number } | { error: string }> { + syncState(); + const stream = getStream(); + if (!stream) return { error: 'Capture not enabled -- send enableCapture first' }; + + const track = stream.getVideoTracks()[0]; + if (!track || track.readyState !== 'live') { + setStream(null); + syncState(); + return { error: 'Capture stream ended -- re-enable with enableCapture' }; + } + + const video = document.createElement('video'); + video.srcObject = stream; + video.muted = true; + await video.play(); + + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + canvas.getContext('2d')!.drawImage(video, 0, 0); + video.pause(); + video.srcObject = null; + + return new Promise(resolve => { + canvas.toBlob(blob => { + if (!blob) { resolve({ error: 'Canvas toBlob failed' }); return; } + const reader = new FileReader(); + reader.onloadend = () => { + const dataUrl = reader.result as string; + resolve({ dataUrl, length: dataUrl.length }); + }; + reader.readAsDataURL(blob); + }, 'image/jpeg', quality); + }); + } + + function healthCheck(): { active: boolean; tracks: number; reason?: string } { + const stream = getStream(); + if (!stream) return { active: false, tracks: 0, reason: 'no stream' }; + const tracks = stream.getVideoTracks(); + const live = tracks.filter(t => t.readyState === 'live'); + if (!stream.active || live.length === 0) { + setStream(null); + syncState(); + return { active: false, tracks: 0, reason: 'stream ended' }; + } + return { active: true, tracks: live.length }; + } + + return { isActive, enable, disable, capture, healthCheck }; +} diff --git a/frontend/src/composables/useDevFlags.ts b/frontend/src/composables/useDevFlags.ts new file mode 100644 index 0000000..4c8c541 --- /dev/null +++ b/frontend/src/composables/useDevFlags.ts @@ -0,0 +1,35 @@ +import { reactive, watch } from 'vue'; + +const STORAGE_KEY = 'hermes_dev_flags'; + +interface DevFlags { + showGrid: boolean; + showDebugInfo: boolean; + showHud: boolean; +} + +const defaults: DevFlags = { + showGrid: false, + showDebugInfo: false, + showHud: false, +}; + +function load(): DevFlags { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return { ...defaults }; + return { ...defaults, ...JSON.parse(raw) }; + } catch { + return { ...defaults }; + } +} + +const flags = reactive(load()); + +watch(flags, (v) => { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(v)); +}, { deep: true }); + +export function useDevFlags() { + return flags; +} diff --git a/frontend/src/composables/useHandover.ts b/frontend/src/composables/useHandover.ts new file mode 100644 index 0000000..92c27d1 --- /dev/null +++ b/frontend/src/composables/useHandover.ts @@ -0,0 +1,47 @@ +import { type Ref } from 'vue'; +import { useChatStore } from '../store/chat'; + +export function useHandover( + wsSend: (payload: any) => void, + pendingClearRef: Ref, + lastUsage: Ref, +) { + const chatStore = useChatStore(); + + function confirmStartNew() { + const confirmBubble = [...chatStore.messages].reverse().find(m => m.confirmNew); + if (confirmBubble) confirmBubble.confirmed = true; + // Keep messages on new session (only agent switch clears) + chatStore.resetLocalSession(); + pendingClearRef.value = false; + lastUsage.value = null; + wsSend({ type: 'new' }); + } + + function staySession() { + const confirmBubble = [...chatStore.messages].reverse().find(m => m.confirmNew); + if (confirmBubble) { + // Mark as confirmed but don't start new session + confirmBubble.confirmed = true; + // Optionally, remove the handover prompt after a delay + setTimeout(() => { + const idx = chatStore.messages.findIndex(m => m === confirmBubble); + if (idx !== -1) chatStore.messages.splice(idx, 1); + }, 1000); + } + } + + function startNew() { + // Keep messages on new session (only agent switch clears) + chatStore.resetLocalSession(); + pendingClearRef.value = false; + lastUsage.value = null; + wsSend({ type: 'new' }); + } + + function startHandover() { + wsSend({ type: 'handover_request' }); + } + + return { confirmStartNew, staySession, startNew, startHandover }; +} diff --git a/frontend/src/composables/useHermes.ts b/frontend/src/composables/useHermes.ts new file mode 100644 index 0000000..4bfeb79 --- /dev/null +++ b/frontend/src/composables/useHermes.ts @@ -0,0 +1,30 @@ +/** + * useHermes — accessor for the HMR-safe runtime store on window.__hermes + * + * All state that must survive Vite HMR module re-evaluation lives here. + * Initialized in main.ts (console hook), lazily created if accessed before main.ts runs. + */ + +export interface HermesRuntime { + // Console + console: { t: number; l: string; m: string }[]; + _origConsole: Record void> | null; + // WebSocket + ws: WebSocket | null; + wsPing: ReturnType | null; + wsCbs: ((data: any) => void)[]; + wsBuf: any[]; + wsConnected: any; // Ref + wsStatus: any; // Ref + wsUser: any; // Ref + wsSid: any; // Ref + wsInit: any; // Ref + // Capture + captureStream: MediaStream | null; +} + +export function useHermes(): HermesRuntime { + const w = window as any; + if (!w.__hermes) w.__hermes = {}; + return w.__hermes; +} diff --git a/frontend/src/composables/useInputAutogrow.ts b/frontend/src/composables/useInputAutogrow.ts new file mode 100644 index 0000000..1410d61 --- /dev/null +++ b/frontend/src/composables/useInputAutogrow.ts @@ -0,0 +1,26 @@ +import { ref, watch, nextTick } from 'vue'; + +export function useInputAutogrow(input: ReturnType>) { + const inputEl = ref(null); + const isShaking = ref(false); + + function autoGrow() { + const el = inputEl.value; + if (!el) return; + el.style.height = 'auto'; + + el.style.height = el.scrollHeight + 'px'; + el.style.overflowY = el.scrollHeight > 160 ? 'auto' : 'hidden'; + } + + function triggerShake() { + isShaking.value = true; + setTimeout(() => { isShaking.value = false; }, 400); + } + + watch(input, (val) => { + if (!val) nextTick(() => autoGrow()); + }); + + return { inputEl, isShaking, autoGrow, triggerShake }; +} diff --git a/frontend/src/composables/useMessageGrouping.ts b/frontend/src/composables/useMessageGrouping.ts new file mode 100644 index 0000000..a2042ac --- /dev/null +++ b/frontend/src/composables/useMessageGrouping.ts @@ -0,0 +1,121 @@ +import { computed, type Ref } from 'vue'; +import type { Agent } from './agents'; + +export function useMessageGrouping( + messages: Ref, + visibleCount: Ref, + selectedAgent: Ref, + allAgents: Ref, + sessionKey?: Ref, +) { + const VISIBLE_PAGE = 50; + + const visibleMsgs = computed(() => { + const all = messages.value; + const start = Math.max(0, all.length - visibleCount.value); + return all.slice(start).map((m, i) => ({ ...m, _sourceIndex: start + i })); + }); + const hasMore = computed(() => messages.value.length > visibleCount.value); + + function loadMore() { + visibleCount.value += VISIBLE_PAGE; + } + + function getFormattedAgentName(agentId: string | null): string { + if (!agentId) return 'Unknown'; + const agent = allAgents.value.find(a => a.id === agentId); + return agent ? agent.name : agentId; + } + + function shouldShowHeadline(index: number, msgsArr: any[]): boolean { + if (index === 0) return true; + const current = msgsArr[index]; + const prev = msgsArr[index - 1]; + + // Ignore transitions to/from null agentId (user messages) + // Only show headlines for actual agent switches or session changes + if (!current.agentId || !prev.agentId) { + return current.sessionId !== prev.sessionId; + } + + return current.agentId !== prev.agentId || current.sessionId !== prev.sessionId; + } + + function formatHeadlineText(agentName: string): string { + const key = sessionKey?.value; + return key ? `${agentName} · ${key}` : agentName; + } + + function getHeadline(index: number, msgsArr: any[]): { text: string; kind: 'agent' | 'new-session' } { + const current = msgsArr[index]; + const targetAgentId = current.agentId || selectedAgent.value; + const agentName = getFormattedAgentName(targetAgentId); + + if (index === 0) return { text: formatHeadlineText(agentName), kind: 'agent' }; + const prev = msgsArr[index - 1]; + if (current.agentId !== prev.agentId) return { text: formatHeadlineText(agentName), kind: 'agent' }; + if (current.sessionId !== prev.sessionId) return { text: 'New Session', kind: 'new-session' }; + return { text: formatHeadlineText(agentName), kind: 'agent' }; + } + + const groupedVisibleMsgs = computed(() => { + const raw = visibleMsgs.value; + const result: any[] = []; + let currentGroup: any = null; + + for (let i = 0; i < raw.length; i++) { + const msg = raw[i]; + + if (shouldShowHeadline(i, raw)) { + if (currentGroup) { result.push(currentGroup); currentGroup = null; } + const { text, kind } = getHeadline(i, raw); + result.push({ + role: 'system', + type: 'headline', + content: text, + headlineKind: kind, + agentId: msg.agentId, + sessionId: msg.sessionId, + position: 'header', // Header appears before agent block + }); + } + + if (msg.role === 'system' && msg.type !== 'no_session') { + if (!currentGroup) { + currentGroup = { role: 'system_group', messages: [msg], agentId: msg.agentId, sessionId: msg.sessionId }; + } else { + currentGroup.messages.push(msg); + } + } else { + if (currentGroup) { result.push(currentGroup); currentGroup = null; } + result.push(msg); + + // Footer headline: only once, at the very end of the list + const effectiveAgentId = msg.agentId || selectedAgent.value; + if (effectiveAgentId && i === raw.length - 1) { + const agentName = getFormattedAgentName(effectiveAgentId); + result.push({ + role: 'system', + type: 'headline', + content: formatHeadlineText(agentName), + headlineKind: 'agent', + agentId: effectiveAgentId, + sessionId: msg.sessionId, + position: 'footer', + }); + } + } + } + + if (currentGroup) result.push(currentGroup); + return result; + }); + + return { + visibleMsgs, + groupedVisibleMsgs, + hasMore, + loadMore, + getFormattedAgentName, + }; +} diff --git a/frontend/src/composables/useMessages.ts b/frontend/src/composables/useMessages.ts new file mode 100644 index 0000000..dd36e2e --- /dev/null +++ b/frontend/src/composables/useMessages.ts @@ -0,0 +1,256 @@ +import { ref, nextTick, computed } from 'vue'; +import { marked } from 'marked'; +import { useChatStore } from '../store/chat'; +import { getApiBase } from '../utils/apiBase'; + +interface MessagePayload { + type: 'message'; + content: string; + msgId?: string; + attachments?: { type: string; mimeType: string; content: string; fileName: string }[]; + [key: string]: any; +} + +function generateMsgId(): string { + return crypto.randomUUID(); +} + +const renderer = new marked.Renderer(); +renderer.link = ({ href, title, text }) => { + const titleAttr = title ? ` title="${title}"` : ''; + return `${text}`; +}; + +function ansiToHtml(text: string): string { + const colorMap: Record = { + 30: '#555', 31: '#e06c75', 32: '#98c379', 33: '#e5c07b', + 34: '#61afef', 35: '#c678dd', 36: '#56b6c2', 37: '#abb2bf', + }; + let open = false, bold = false, dim = false; + const result = text.replace(/\x1b\[([0-9;]*)m/g, (_match, codes: string) => { + const parts = codes.split(';').map(Number); + let out = ''; + for (const code of parts) { + if (code === 0) { + if (open) { out += ''; open = false; } + if (bold) { out += ''; bold = false; } + if (dim) { out += ''; dim = false; } + } else if (code === 1) { + if (!bold) { out += ''; bold = true; } + } else if (code === 2) { + if (!dim) { out += ''; dim = true; } + } else if (colorMap[code]) { + if (open) { out += ''; } + out += ``; + open = true; + } + } + return out; + }); + let tail = ''; + if (open) tail += ''; + if (bold) tail += ''; + if (dim) tail += ''; + return result + tail; +} + +const WORKSPACE_PATH_RE = /((?:\/home\/openclaw\/\.openclaw\/)?workspace-titan\/[^\s"'<>)]+\.(?:pdf|png|jpg|jpeg|gif|csv|json|txt|md|html|zip|mp3|wav|ogg|webm|m4a))/g; +const WORKSPACE_PREFIX = '/home/openclaw/.openclaw/'; +const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'webm', 'm4a']); + +function linkifyWorkspaceFiles(html: string): string { + return html.replace(WORKSPACE_PATH_RE, (match) => { + const name = match.split('/').pop() || match; + const absPath = match.startsWith('/') ? match : WORKSPACE_PREFIX + match; + const ext = name.split('.').pop()?.toLowerCase() || ''; + if (AUDIO_EXTENSIONS.has(ext)) { + return ``; + } + return ``; + }); +} + +// Global audio source handler — sets src on first play via authenticated fetch +if (typeof window !== 'undefined' && !(window as any).__hermesAudioSrc) { + (window as any).__hermesAudioSrc = (el: HTMLAudioElement) => { + if (el.src) return; // already loaded + const filepath = el.dataset.filepath; + if (!filepath) return; + const token = localStorage.getItem('openclaw_session') || localStorage.getItem('titan_token') || ''; + const apiBase = getApiBase(); + el.src = `${apiBase}/api/files${filepath}?token=${encodeURIComponent(token)}`; + }; +} + +// Global download handler — fetches file as blob and triggers download +if (typeof window !== 'undefined' && !(window as any).__hermesDownload) { + (window as any).__hermesDownload = async (el: HTMLElement) => { + const filepath = el.dataset.filepath; + const filename = el.dataset.filename || 'download'; + if (!filepath) return; + el.textContent = '⏳ ' + filename; + try { + const token = localStorage.getItem('openclaw_session') || localStorage.getItem('titan_token') || ''; + const apiBase2 = getApiBase(); + const res = await fetch(`${apiBase2}/api/files${filepath}?token=${encodeURIComponent(token)}`); + if (!res.ok) throw new Error(`${res.status}`); + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + el.textContent = '✅ ' + filename; + } catch (err) { + el.textContent = '❌ ' + filename; + console.error('[download]', err); + } + }; +} + +export function parseMd(content: string | undefined): string { + const raw = content || ''; + if (/\x1b\[/.test(raw)) { + const escaped = raw.replace(/&/g, '&').replace(//g, '>'); + return `
${ansiToHtml(escaped)}
`; + } + let html = marked.parse(raw, { renderer, async: false, gfm: true, breaks: true }) as string; + html = linkifyWorkspaceFiles(html); + return html; +} + +const DRAFT_KEY = 'chat_draft'; +const HISTORY_KEY = 'chat_input_history'; +const HISTORY_MAX = 50; + +function loadDraft(): string { + try { return sessionStorage.getItem(DRAFT_KEY) || ''; } catch { return ''; } +} +function saveDraft(v: string) { + try { if (v) sessionStorage.setItem(DRAFT_KEY, v); else sessionStorage.removeItem(DRAFT_KEY); } catch {} +} +function loadHistory(): string[] { + try { return JSON.parse(sessionStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; } +} +function pushHistory(v: string) { + try { + const h = loadHistory().filter(x => x !== v); + h.unshift(v); + sessionStorage.setItem(HISTORY_KEY, JSON.stringify(h.slice(0, HISTORY_MAX))); + } catch {} +} + +export function useMessages(wsSendFn: (payload: MessagePayload) => void) { + const store = useChatStore(); + const sending = ref(false); + const input = ref(loadDraft()); + const messagesEl = ref(null); + let historyIdx = -1; + + function getViewport(): HTMLElement | null { + const el = messagesEl.value; + if (!el) return null; + const root = (el as any).$el || el; + return root.querySelector?.('[data-overlayscrollbars-viewport]') as HTMLElement || root; + } + + function scrollToBottom(): void { + // Double nextTick + rAF to ensure DOM has rendered after bulk message inserts + nextTick(() => { + nextTick(() => { + requestAnimationFrame(() => { + const vp = getViewport(); + if (vp) vp.scrollTop = vp.scrollHeight; + }); + }); + }); + } + + function scrollIfAtBottom(): void { + const vp = getViewport(); + if (!vp) return; + if (vp.scrollHeight - vp.scrollTop - vp.clientHeight < 80) scrollToBottom(); + } + + // Persist draft on every keystroke (throttled via watch) + let draftTimer: ReturnType | null = null; + function onInputChange() { + if (draftTimer) clearTimeout(draftTimer); + draftTimer = setTimeout(() => saveDraft(input.value), 1000); + } + + // Arrow-up/down history navigation — call from keydown handler + function navigateHistory(dir: 'up' | 'down') { + const history = loadHistory(); + if (!history.length) return; + if (dir === 'up') { + historyIdx = Math.min(historyIdx + 1, history.length - 1); + } else { + historyIdx = Math.max(historyIdx - 1, -1); + } + input.value = historyIdx === -1 ? '' : history[historyIdx]; + } + + let lastSentContent = ''; + function restoreLastSent() { input.value = lastSentContent; } + + async function send(attachmentPayload?: { type: string; mimeType: string; content: string; fileName: string }[]): Promise { + const hasText = input.value.trim().length > 0; + const hasAttachments = attachmentPayload && attachmentPayload.length > 0; + if ((!hasText && !hasAttachments) || sending.value) return; + const content = input.value.trim(); + if (hasText) { + lastSentContent = content; + pushHistory(content); + } + historyIdx = -1; + input.value = ''; + saveDraft(''); + sending.value = true; + + const msgId = generateMsgId(); + + // Build local preview URLs for display + const localAttachments = hasAttachments ? attachmentPayload!.map(a => { + const mime = a.mimeType.split(';')[0]; + let dataUrl: string; + if (a.mimeType.startsWith('audio/')) { + const bytes = Uint8Array.from(atob(a.content), c => c.charCodeAt(0)); + dataUrl = URL.createObjectURL(new Blob([bytes], { type: mime })); + } else { + dataUrl = `data:${mime};base64,${a.content}`; + } + return { mimeType: a.mimeType, fileName: a.fileName, dataUrl }; + }) : undefined; + + const hasAudio = hasAttachments && attachmentPayload!.some(a => a.mimeType.startsWith('audio/')); + + // Always push optimistic local message with msgId (even for audio — will be patched with transcript) + store.pushMessage({ + role: 'user' as const, + content, + agentId: null, + msgId, + attachments: localAttachments, + pending: hasAudio, // audio messages are pending until transcript arrives + }); + // Scroll handled by AgentsView.send() — don't scrollToBottom here + + const payload: MessagePayload = { type: 'message', content, msgId }; + if (hasAttachments) payload.attachments = attachmentPayload; + wsSendFn(payload); + sending.value = false; + } + + return { + sending, input, messagesEl, + parseMd, scrollToBottom, scrollIfAtBottom, send, onInputChange, navigateHistory, restoreLastSent, + startNewAssistantMessage: store.startNewAssistantMessage, + appendAssistantMessage: store.appendAssistantDelta, + finalizeAssistantMessage: store.finalizeAssistantMessage, + resetAssistantMessageState: store.resetLocalSession, + hasActiveStreamingMessage: store.hasActiveStreamingMessage, + streamingMessageVisibleContent: computed(() => store.streamingMessageVisibleContent) + }; +} diff --git a/frontend/src/composables/useScrollbar.ts b/frontend/src/composables/useScrollbar.ts new file mode 100644 index 0000000..483ecbb --- /dev/null +++ b/frontend/src/composables/useScrollbar.ts @@ -0,0 +1,14 @@ +import { OverlayScrollbars, ClickScrollPlugin } from 'overlayscrollbars'; + +// Register once +OverlayScrollbars.plugin(ClickScrollPlugin); + +export const scrollbarOptions = { + overflow: { + x: 'hidden' as const, + }, + scrollbars: { + clickScroll: true, + autoHide: 'never' as const, + }, +}; diff --git a/frontend/src/composables/useTakeover.ts b/frontend/src/composables/useTakeover.ts new file mode 100644 index 0000000..f58c1fa --- /dev/null +++ b/frontend/src/composables/useTakeover.ts @@ -0,0 +1,268 @@ +/** + * useTakeover — Token management + dev command dispatch + * + * Single owner of takeover token lifecycle and command registry. + * Delegates capture/breakout commands to their composables. + */ + +import { ref } from 'vue'; +import { useCapture } from './useCapture'; +import { useBreakout } from './useBreakout'; +import { useHermes } from './useHermes'; + +const TAKEOVER_KEY = 'hermes_takeover_token'; + +// ── Module-level (survives HMR) ── +let currentToken = sessionStorage.getItem(TAKEOVER_KEY) || ''; + +export function useTakeover(wsSend: (msg: any) => void) { + const token = ref(currentToken); + const capture = useCapture(); + const breakout = useBreakout(); + + // ── Token lifecycle (single place) ── + + function init(): string { + const t = crypto.randomUUID(); + token.value = t; + currentToken = t; + sessionStorage.setItem(TAKEOVER_KEY, t); + wsSend({ type: 'dev_takeover', token: t }); + return t; + } + + function revoke() { + token.value = ''; + currentToken = ''; + sessionStorage.removeItem(TAKEOVER_KEY); + } + + /** Called once from ws.ts onopen — single re-registration point */ + function reregister() { + // Also handle breakout token from URL (popup window startup) + const urlParams = new URLSearchParams(window.location.search); + const breakoutToken = urlParams.get('breakout_token'); + if (breakoutToken) { + sessionStorage.setItem(TAKEOVER_KEY, breakoutToken); + urlParams.delete('breakout_token'); + const clean = urlParams.toString(); + const newUrl = window.location.pathname + (clean ? '?' + clean : '') + window.location.hash; + window.history.replaceState(null, '', newUrl); + } + const t = sessionStorage.getItem(TAKEOVER_KEY); + if (t) { + token.value = t; + currentToken = t; + wsSend({ type: 'dev_takeover', token: t }); + } + } + + // ── DOM command implementations ── + + function boxChain(args: { selector: string }) { + const el = document.querySelector(args.selector); + if (!el) return { error: `No element: ${args.selector}` }; + const chain: any[] = []; + let node: HTMLElement | null = el as HTMLElement; + while (node && node !== document.documentElement) { + const cs = getComputedStyle(node); + const rect = node.getBoundingClientRect(); + chain.push({ + tag: node.tagName.toLowerCase(), + cls: (node.className?.toString() || '').split(' ').filter(Boolean).slice(0, 5).join(' '), + w: Math.round(rect.width), h: Math.round(rect.height), + l: Math.round(rect.left), r: Math.round(rect.right), + pl: cs.paddingLeft, pr: cs.paddingRight, + ml: cs.marginLeft, mr: cs.marginRight, + }); + node = node.parentElement; + } + return chain; + } + + function getStyles(args: { selector: string; props?: string[] }) { + const el = document.querySelector(args.selector) as HTMLElement | null; + if (!el) return { error: `No element: ${args.selector}` }; + const cs = getComputedStyle(el); + const rect = el.getBoundingClientRect(); + const props = args.props || ['padding', 'margin', 'width', 'height', 'display', 'flexDirection', 'overflow', 'gap']; + const styles: Record = {}; + for (const p of props) styles[p] = cs.getPropertyValue(p.replace(/[A-Z]/g, m => '-' + m.toLowerCase())); + return { ...styles, boundingRect: { w: Math.round(rect.width), h: Math.round(rect.height), l: Math.round(rect.left), r: Math.round(rect.right), t: Math.round(rect.top), b: Math.round(rect.bottom) } }; + } + + function viewport() { + return { w: window.innerWidth, h: window.innerHeight, dpr: window.devicePixelRatio, hash: window.location.hash }; + } + + function navigate(args: { hash: string }) { + window.location.hash = args.hash; + return { navigated: args.hash }; + } + + function reload() { + setTimeout(() => window.location.reload(), 100); + return { reloading: true }; + } + + function resize(args: { w: number; h: number }) { + window.resizeTo(args.w, args.h); + return { resized: `${args.w}x${args.h}` }; + } + + function querySelector(args: { selector: string; limit?: number }) { + const els = document.querySelectorAll(args.selector); + const limit = args.limit || 10; + return Array.from(els).slice(0, limit).map((el, i) => { + const rect = (el as HTMLElement).getBoundingClientRect(); + return { + i, tag: el.tagName.toLowerCase(), + cls: (el.className?.toString() || '').split(' ').filter(Boolean).slice(0, 5).join(' '), + text: (el.textContent || '').slice(0, 60), + w: Math.round(rect.width), h: Math.round(rect.height), + l: Math.round(rect.left), t: Math.round(rect.top), + }; + }); + } + + function click(args: { selector: string; index?: number }) { + const els = document.querySelectorAll(args.selector); + const idx = args.index || 0; + const el = els[idx] as HTMLElement | null; + if (!el) return { error: `No element: ${args.selector}[${idx}]` }; + el.click(); + return { clicked: args.selector, index: idx }; + } + + function getValue(args: { selector: string }) { + const el = document.querySelector(args.selector) as HTMLInputElement | HTMLSelectElement | null; + if (!el) return { error: `No element: ${args.selector}` }; + return { value: el.value, tag: el.tagName.toLowerCase() }; + } + + function setValue(args: { selector: string; value: string }) { + const el = document.querySelector(args.selector) as HTMLInputElement | HTMLSelectElement | null; + if (!el) return { error: `No element: ${args.selector}` }; + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + el.tagName === 'SELECT' ? HTMLSelectElement.prototype : HTMLInputElement.prototype, 'value' + )?.set; + nativeInputValueSetter?.call(el, args.value); + el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event('change', { bubbles: true })); + return { set: args.value, selector: args.selector }; + } + + function typeText(args: { selector: string; text: string }) { + const el = document.querySelector(args.selector) as HTMLInputElement | HTMLTextAreaElement | null; + if (!el) return { error: `No element: ${args.selector}` }; + el.focus(); + el.value = args.text; + el.dispatchEvent(new Event('input', { bubbles: true })); + return { typed: args.text, selector: args.selector }; + } + + function screenshot() { + return { + url: window.location.hash, + title: document.title, + viewport: { w: window.innerWidth, h: window.innerHeight }, + body: document.body.innerText.slice(0, 2000), + }; + } + + function scroll(args: { selector: string; to?: number | 'top' | 'bottom' | 'middle' }) { + const el = document.querySelector(args.selector) as HTMLElement | null; + if (!el) return { error: `No element: ${args.selector}` }; + const before = el.scrollTop; + if (args.to !== undefined) { + if (args.to === 'top') el.scrollTop = 0; + else if (args.to === 'bottom') el.scrollTop = el.scrollHeight; + else if (args.to === 'middle') el.scrollTop = (el.scrollHeight - el.clientHeight) / 2; + else el.scrollTop = args.to; + } + return { + scrollTop: el.scrollTop, + scrollHeight: el.scrollHeight, + clientHeight: el.clientHeight, + before, + }; + } + + // ── Command registry ── + + function getConsole(args: { last?: number; level?: string; pattern?: string; clear?: boolean }) { + const h = useHermes(); + if (!h.console?.length && !h._origConsole) return { error: 'Console hook not initialized' }; + let entries = h.console as any[]; + if (args.level) entries = entries.filter((e: any) => e.l === args.level); + if (args.pattern) { + const re = new RegExp(args.pattern, 'i'); + entries = entries.filter((e: any) => re.test(e.m)); + } + const result = entries.slice(-(args.last || 50)); + if (args.clear) h.console.length = 0; + return result; + } + + const autoCommands: Record any> = { + boxChain, getStyles, viewport, navigate, reload, resize, querySelector, click, screenshot, + getValue, setValue, typeText, scroll, getConsole, + listBreakouts: () => breakout.list(), + closeBreakout: (args: any) => breakout.close(args), + }; + + const asyncCommands: Record Promise> = { + captureScreen: (args: any) => capture.capture(args.quality), + enableCapture: () => capture.enable(), + openBreakout: (args: any) => breakout.open(args), + }; + + /** Dispatch a dev command — called by ws.ts on dev_cmd message */ + function dispatch(cmdId: string, cmd: string, args: any, sendResult: (msg: any) => void) { + const fn = autoCommands[cmd]; + if (fn) { + try { + const result = fn(args); + sendResult({ type: 'dev_cmd_result', cmdId, result: JSON.parse(JSON.stringify(result ?? null)) }); + } catch (err: any) { + sendResult({ type: 'dev_cmd_result', cmdId, error: err.message }); + } + return; + } + + const asyncFn = asyncCommands[cmd]; + if (asyncFn) { + asyncFn(args).then(result => { + sendResult({ type: 'dev_cmd_result', cmdId, result: JSON.parse(JSON.stringify(result ?? null)) }); + }).catch((err: any) => { + sendResult({ type: 'dev_cmd_result', cmdId, error: err.message }); + }); + return; + } + + // eval — requires user confirmation + if (cmd === 'eval') { + const js = args.js; + if (!js) { sendResult({ type: 'dev_cmd_result', cmdId, error: 'js required' }); return; } + const ok = window.confirm(`Dev takeover eval request:\n\n${js.slice(0, 500)}\n\nAllow?`); + if (!ok) { sendResult({ type: 'dev_cmd_result', cmdId, error: 'rejected by user' }); return; } + try { + const result = new Function('return (' + js + ')')(); + const serialized = result instanceof Element + ? result.outerHTML.slice(0, 500) + : JSON.parse(JSON.stringify(result ?? null)); + sendResult({ type: 'dev_cmd_result', cmdId, result: serialized }); + } catch (err: any) { + sendResult({ type: 'dev_cmd_result', cmdId, error: err.message }); + } + return; + } + + sendResult({ type: 'dev_cmd_result', cmdId, error: `Unknown command: ${cmd}` }); + } + + return { + token, capture, breakout, + init, revoke, reregister, dispatch, + }; +} diff --git a/frontend/src/composables/useTheme.ts b/frontend/src/composables/useTheme.ts new file mode 100644 index 0000000..c25ee0d --- /dev/null +++ b/frontend/src/composables/useTheme.ts @@ -0,0 +1,73 @@ +import { ref, watch, type Component } from 'vue'; +import { CommandLineIcon, SunIcon, CubeTransparentIcon } from '@heroicons/vue/24/outline'; + +export type Theme = 'titan' | 'eras' | 'loop42'; + +const STORAGE_KEY = 'hermes_theme'; + +/** Heroicon component per theme — used in nav, home page, and theme buttons */ +export const THEME_ICONS: Record = { + titan: CommandLineIcon, + eras: SunIcon, + loop42: CubeTransparentIcon, +}; + +/** Display name per theme */ +export const THEME_NAMES: Record = { + titan: 'Titan', + eras: 'ERAS', + loop42: 'loop42', +}; + +/** Optional external logo per theme (e.g. customer branding). Null = use THEME_ICONS. */ +export const THEME_LOGOS: Record = { + titan: null, + eras: null, + loop42: null, +}; + +// Map agent id → theme (unlisted agents default to 'titan') +export const AGENT_THEME_MAP: Record = { + eras: 'eras', +}; + +export function agentLogo(agentId: string): string | null { + const t = AGENT_THEME_MAP[agentId] ?? 'titan'; + return THEME_LOGOS[t]; +} + +const stored = localStorage.getItem(STORAGE_KEY); +const theme = ref( + stored === 'workhorse' ? 'loop42' : // migrate legacy name + (stored as Theme) || 'loop42' +); + +const THEME_FAVICONS: Record = { + titan: '/favicon-titan.svg', + eras: '/favicon-eras.svg', + loop42: '/favicon-loop42.svg', +}; + +function applyTheme(t: Theme) { + if (t === 'titan') { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.setAttribute('data-theme', t); + } + // Update favicon + const link = document.querySelector('link[rel="icon"]') as HTMLLinkElement | null; + if (link) link.href = THEME_FAVICONS[t] || '/favicon.svg'; +} + +// Apply on init +applyTheme(theme.value); + +watch(theme, (t) => { + applyTheme(t); + localStorage.setItem(STORAGE_KEY, t); +}); + +export function useTheme() { + function setTheme(t: Theme) { theme.value = t; } + return { theme, setTheme }; +} diff --git a/frontend/src/composables/useTtsPlayer.ts b/frontend/src/composables/useTtsPlayer.ts new file mode 100644 index 0000000..112b90a --- /dev/null +++ b/frontend/src/composables/useTtsPlayer.ts @@ -0,0 +1,180 @@ +import { ref, computed, watch } from 'vue'; +import { useChatStore } from '../store/chat'; +import { getApiBase } from '../utils/apiBase'; + +export interface TtsTrack { + msgRef: any; + sourceIndex: number; + snippet: string; + audioUrl: string | null; +} + +type TtsState = 'idle' | 'loading' | 'playing' | 'paused'; + +function stripMdForTts(text: string): string { + return text + .replace(/```[\s\S]*?```/g, '') // code blocks + .replace(/`[^`]+`/g, '') // inline code + .replace(/!\[.*?\]\(.*?\)/g, '') // images + .replace(/\[([^\]]+)\]\(.*?\)/g, '$1') // links → text + .replace(/#{1,6}\s*/g, '') // headings + .replace(/[*_~]+/g, '') // bold/italic/strikethrough + .replace(/\n{2,}/g, '. ') // paragraph breaks → pause + .replace(/\n/g, ' ') + .trim(); +} + +function createTtsPlayer() { + const store = useChatStore(); + + const state = ref('idle'); + const currentTrack = ref(null); + const currentTime = ref(0); + const duration = ref(0); + const progress = computed(() => duration.value > 0 ? currentTime.value / duration.value : 0); + + let audio: HTMLAudioElement | null = null; + const urlCache = new Map(); + + function getAudio(): HTMLAudioElement { + if (!audio) { + audio = new Audio(); + audio.addEventListener('timeupdate', () => { currentTime.value = audio!.currentTime; }); + audio.addEventListener('durationchange', () => { duration.value = audio!.duration || 0; }); + audio.addEventListener('ended', () => { state.value = 'idle'; currentTrack.value = null; }); + audio.addEventListener('error', () => { + console.error('[tts] playback error'); + state.value = 'idle'; currentTrack.value = null; + }); + } + return audio; + } + + const speakableMessages = computed(() => { + const result: { msg: any; index: number }[] = []; + for (let i = 0; i < store.messages.length; i++) { + const m = store.messages[i]; + if (m.role === 'assistant' && !m.streaming && m.content) { + result.push({ msg: m, index: i }); + } + } + return result; + }); + + function contentKey(text: string): string { + // Simple hash for cache key + let h = 0; + for (let i = 0; i < text.length; i++) { h = ((h << 5) - h + text.charCodeAt(i)) | 0; } + return String(h); + } + + async function play(msg: any, sourceIndex: number): Promise { + const el = getAudio(); // must be in user gesture callstack + el.pause(); + + const rawText = stripMdForTts(msg.content || ''); + if (!rawText) return; + const text = rawText.slice(0, 4096); + const snippet = rawText.slice(0, 60) + (rawText.length > 60 ? '...' : ''); + + currentTrack.value = { msgRef: msg, sourceIndex, snippet, audioUrl: null }; + currentTime.value = 0; + duration.value = 0; + state.value = 'loading'; + + const key = contentKey(text); + let url = urlCache.get(key); + + if (!url) { + try { + const token = localStorage.getItem('openclaw_session') || ''; + const apiBase = getApiBase(); + const res = await fetch(`${apiBase}/api/tts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ text }), + }); + if (!res.ok) throw new Error(`TTS ${res.status}`); + const data = await res.json(); + url = `${apiBase}${data.url}?token=${encodeURIComponent(token)}`; + urlCache.set(key, url); + } catch (err) { + console.error('[tts]', err); + state.value = 'idle'; + currentTrack.value = null; + return; + } + } + + if (currentTrack.value) currentTrack.value.audioUrl = url; + el.src = url; + try { + await el.play(); + state.value = 'playing'; + } catch (err) { + console.error('[tts] play failed:', err); + state.value = 'idle'; + currentTrack.value = null; + } + } + + function pause() { + if (audio && state.value === 'playing') { + audio.pause(); + state.value = 'paused'; + } + } + + function resume() { + if (audio && state.value === 'paused') { + audio.play(); + state.value = 'playing'; + } + } + + function stop() { + if (audio) { audio.pause(); audio.removeAttribute('src'); audio.load(); } + state.value = 'idle'; + currentTrack.value = null; + currentTime.value = 0; + duration.value = 0; + } + + function seek(fraction: number) { + if (audio && duration.value > 0) { + audio.currentTime = fraction * duration.value; + } + } + + function nav(delta: number) { + if (!currentTrack.value) return; + const msgs = speakableMessages.value; + const idx = msgs.findIndex(m => m.msg === currentTrack.value!.msgRef); + if (idx < 0) return; + const next = msgs[idx + delta]; + if (next) play(next.msg, next.index); + } + + function prev() { nav(-1); } + function next() { nav(1); } + + function isPlayingMsg(msg: any): boolean { + return currentTrack.value?.msgRef === msg; + } + + // Auto-stop on session reset + watch(() => store.localSessionId, () => { stop(); urlCache.clear(); }); + + return { + state, currentTrack, currentTime, duration, progress, + speakableMessages, + play, pause, resume, stop, seek, prev, next, + isPlayingMsg, + }; +} + +let _instance: ReturnType | null = null; +export function useTtsPlayer() { + if (!_instance) _instance = createTtsPlayer(); + return _instance; +} diff --git a/frontend/src/composables/useViewer.ts b/frontend/src/composables/useViewer.ts new file mode 100644 index 0000000..037b63e --- /dev/null +++ b/frontend/src/composables/useViewer.ts @@ -0,0 +1,232 @@ +import { ref, computed, watch, onMounted, onUnmounted } from 'vue'; +import { getApiBase } from '../utils/apiBase'; +import { useRoute, useRouter } from 'vue-router'; +import { marked } from 'marked'; +import { ws, lastViewerPath, useViewerStore } from '../store'; + +export function useViewer() { + const route = useRoute(); + const router = useRouter(); + const { onMessage } = ws; + + const viewerStore = useViewerStore(); + const fstoken = computed(() => viewerStore.fstoken); + const viewerRoots = computed(() => viewerStore.roots); + const token = fstoken; + + function normalizePath(path: string): string { + if (!path) return path; + const roots = viewerStore.roots; + if (!roots.length) return path; + const [prefix, ...rest] = path.split('/'); + if (roots.includes(prefix)) return path; + const canonical = roots.find(r => r === `workspace-${prefix}` || r.endsWith(`-${prefix}`)); + return canonical ? [canonical, ...rest].join('/') : path; + } + + const currentPath = ref(normalizePath( + (route.query.path as string) || localStorage.getItem('viewer_last_path') || '' + )); + const sidebarCollapsed = ref(window.innerWidth < 768); + const content = ref(''); + const fileType = ref<'md' | 'pdf' | 'text' | 'dir' | ''>(''); + const dirFiles = ref<{ name: string; path: string; mtime: number }[]>([]); + const dirDirs = ref([]); + const loading = ref(false); + const showLoading = ref(false); + let loadingTimer: ReturnType | null = null; + const fetchError = ref(''); + const pdfCacheBust = ref(Date.now()); + const mdRaw = ref(false); + + const mdLineCount = computed(() => { + if (!content.value) return 0; + return content.value.split('\n').length; + }); + + const renderedMd = computed(() => { + if (fileType.value !== 'md' || !content.value) return ''; + return marked.parse(content.value) as string; + }); + + const pdfSrc = computed(() => { + if (fileType.value !== 'pdf' || !currentPath.value || !token.value) return ''; + const base = getBaseUrl(); + return `${base}/api/viewer/file?path=${encodeURIComponent(currentPath.value)}&token=${encodeURIComponent(token.value)}&t=${pdfCacheBust.value}`; + }); + + function getBaseUrl(): string { + return getApiBase(); + } + + function extOf(p: string): string { + const m = p.match(/\.([^./]+)$/); + return m ? m[1].toLowerCase() : ''; + } + + function startLoading(silent: boolean) { + if (silent) return; + loading.value = true; + showLoading.value = false; + if (loadingTimer) clearTimeout(loadingTimer); + loadingTimer = setTimeout(() => { showLoading.value = true; }, 3000); + } + function stopLoading() { + loading.value = false; + showLoading.value = false; + if (loadingTimer) { clearTimeout(loadingTimer); loadingTimer = null; } + } + + async function fetchContent(path: string, silent = false, _retry = false) { + startLoading(silent); + try { + const base = getBaseUrl(); + const url = `${base}/api/viewer/file?path=${encodeURIComponent(path)}&token=${encodeURIComponent(token.value)}`; + const res = await fetch(url); + if (res.status === 401 && !_retry) { + viewerStore.invalidate(); + await viewerStore.acquire(true); + return fetchContent(path, silent, true); + } + if (!res.ok) { + fetchError.value = `${res.status}: ${await res.text()}`; + return; + } + fetchError.value = ''; + content.value = await res.text(); + } catch (e: any) { + fetchError.value = e.message || 'Fetch failed'; + } finally { + stopLoading(); + } + } + + async function openFile(path: string) { + path = normalizePath(path); + currentPath.value = path; + localStorage.setItem('viewer_last_path', path); + lastViewerPath.value = path; + if (route.query.path !== path) { + router.push({ name: 'viewer', query: { path } }); + } + const ext = extOf(path); + + // Empty path = root listing (show viewer roots) + if (!path) { + fileType.value = 'dir'; + content.value = ''; + fetchError.value = ''; + dirDirs.value = viewerRoots.value.length ? viewerRoots.value : ['shared', 'workspace-titan']; + dirFiles.value = []; + return; + } + + // No extension = directory + if (!ext) { + fileType.value = 'dir'; + content.value = ''; + fetchError.value = ''; + // Keep old dir content visible while fetching new + startLoading(false); + try { + const base = getBaseUrl(); + const url = `${base}/api/viewer/tree?root=${encodeURIComponent(path)}&token=${encodeURIComponent(token.value)}`; + const res = await fetch(url); + if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + const data = await res.json(); + dirDirs.value = data.dirs || []; + dirFiles.value = data.files || []; + } catch (e: any) { + dirDirs.value = []; + dirFiles.value = []; + fetchError.value = e.message || 'Failed to load directory'; + } finally { + stopLoading(); + } + return; + } + + if (ext === 'pdf') { + fileType.value = 'pdf'; + content.value = ''; + fetchError.value = ''; + pdfCacheBust.value = Date.now(); + fetch(`${getBaseUrl()}/api/viewer/file?path=${encodeURIComponent(path)}&token=${encodeURIComponent(token.value)}`, { method: 'HEAD' }).catch(() => {}); + return; + } + + fileType.value = ext === 'md' ? 'md' : 'text'; + fetchError.value = ''; + await fetchContent(path, false); + } + + function onCopy(e: ClipboardEvent) { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed) return; + const html = (() => { + const div = document.createElement('div'); + div.appendChild(sel.getRangeAt(0).cloneContents()); + return div.innerHTML; + })(); + const clean = html.replace(/background(-color)?:[^;"]*(;|(?="))/gi, ''); + const plain = sel.toString(); + e.clipboardData!.setData('text/html', clean); + e.clipboardData!.setData('text/plain', plain); + e.preventDefault(); + } + + async function refreshFile(path: string) { + const ext = extOf(path); + if (ext === 'pdf') { + pdfCacheBust.value = Date.now(); + return; + } + await fetchContent(path, true); + } + + // Lifecycle + let unsubscribe: (() => void) | null = null; + + onMounted(() => { + unsubscribe = onMessage((data: any) => { + if (data.type === 'viewer_file_changed' && data.path === currentPath.value) { + refreshFile(currentPath.value); + } + if (data.type === 'viewer_tree_changed' && data.path === currentPath.value && fileType.value === 'dir') { + openFile(currentPath.value); + } + }); + viewerStore.acquire(); + if (currentPath.value) openFile(currentPath.value); + }); + + watch(() => route.query.path, (newPath) => { + if (newPath && newPath !== currentPath.value) { + openFile(newPath as string); + } + }); + + onUnmounted(() => { + if (unsubscribe) unsubscribe(); + }); + + return { + fstoken, + viewerRoots, + currentPath, + sidebarCollapsed, + content, + fileType, + loading, + fetchError, + mdRaw, + mdLineCount, + renderedMd, + pdfSrc, + openFile, + onCopy, + dirFiles, + dirDirs, + showLoading, + }; +} diff --git a/frontend/src/composables/ws.ts b/frontend/src/composables/ws.ts new file mode 100644 index 0000000..393b76c --- /dev/null +++ b/frontend/src/composables/ws.ts @@ -0,0 +1,238 @@ +/** + * ws.ts — WebSocket transport layer + * + * Pure connection management: connect, disconnect, reconnect, send, onMessage. + * Delegates takeover commands to useTakeover. + * + * All mutable state is module-level so it survives Vite HMR. + * useWebSocket() returns a stable API over that shared state. + */ + +import { ref, type Ref } from 'vue'; +import { useTakeover } from './useTakeover'; +import { useHermes } from './useHermes'; + +interface MessagePayload { + type: string; + agent?: string; + token?: string; + user?: string; + [key: string]: any; +} + +// ── Module-level state — stored on window.__hermes to survive Vite HMR ── +const H = useHermes(); +// WebSocket + ping timer +let _ws: WebSocket | null = H.ws ?? null; +let _pingInterval: ReturnType | null = H.wsPing ?? null; +// Callbacks + buffer +const _onMessageCallbacks: ((data: any) => void)[] = H.wsCbs ?? []; +const _messageBuffer: any[] = H.wsBuf ?? []; +// Reconnect +let _reconnectTimer: ReturnType | null = null; +let _reconnectDelay = 1000; +let _pendingAuth = false; // WS open but agent was empty — send auth when agent is set +// Refs (re-bound on connect) +let _selectedAgentRef: Ref | null = null; +let _selectedModeRef: Ref | null = null; +let _isLoggedInRef: Ref | null = null; +let _loginErrorRef: Ref | null = null; +let _takeover: ReturnType | null = null; + +const connected = H.wsConnected ?? ref(false); +const status = H.wsStatus ?? ref('Disconnected'); +const currentUser = H.wsUser ?? ref(''); +const sessionId = H.wsSid ?? ref(null); +const isInitialLoad = H.wsInit ?? ref(true); + +// Persist on __hermes for HMR survival +H.wsConnected = connected; +H.wsStatus = status; +H.wsUser = currentUser; +H.wsSid = sessionId; +H.wsInit = isInitialLoad; +H.wsCbs = _onMessageCallbacks; +H.wsBuf = _messageBuffer; + +function send(payload: MessagePayload): void { + if (_ws && _ws.readyState === WebSocket.OPEN) _ws.send(JSON.stringify(payload)); +} + +function getTakeover() { + if (!_takeover) _takeover = useTakeover(send); + return _takeover; +} + +function getWsUrl(): string { + if (import.meta.env.VITE_WS_URL) return import.meta.env.VITE_WS_URL as string; + const params = new URLSearchParams(window.location.search); + const wsHost = params.get('ws') || window.location.hostname; + const wsPort = params.get('port') || window.location.port; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${wsHost}:${wsPort}/ws`; +} + +function scheduleReconnect() { + if (_reconnectTimer) return; + if (!_isLoggedInRef?.value) return; + status.value = `Reconnecting in ${Math.round(_reconnectDelay / 1000)}s…`; + _reconnectTimer = setTimeout(() => { + _reconnectTimer = null; + if (_isLoggedInRef?.value) connect(_selectedAgentRef!, _isLoggedInRef!, _loginErrorRef!); + }, _reconnectDelay); + _reconnectDelay = Math.min(_reconnectDelay * 2, 16000); +} + +function connect( + selectedAgent: Ref, + isLoggedInRef: Ref, + loginErrorRef: Ref, + selectedMode?: Ref +): void { + if (_ws && _ws.readyState <= WebSocket.OPEN) return; + _selectedAgentRef = selectedAgent; + _selectedModeRef = selectedMode ?? null; + _isLoggedInRef = isLoggedInRef; + _loginErrorRef = loginErrorRef; + _reconnectDelay = 1000; + // Poll setup happens in onopen if agent is empty + console.log('WS CONNECT attempt, ws state:', _ws?.readyState, 'url:', getWsUrl()); + const wsUrl = getWsUrl(); + if (isInitialLoad.value) { + status.value = 'Connecting...'; + isInitialLoad.value = false; + } + + _ws = new WebSocket(wsUrl); + H.ws = _ws; + + _ws.onopen = () => { + _reconnectDelay = 1000; + const agent = selectedAgent.value; + const token = localStorage.getItem('openclaw_session') || localStorage.getItem('titan_token'); + const mode = _selectedModeRef?.value ?? 'private'; + // Send auth even without agent — BE returns ready with agent list + _ws?.send(JSON.stringify( + token + ? { type: 'auth', agent: agent || '', token, mode } + : { type: 'connect', agent: agent || '', user: 'nico', mode } + )); + getTakeover().reregister(); + if (_pingInterval) clearInterval(_pingInterval); + _pingInterval = setInterval(() => { + if (_ws?.readyState === WebSocket.OPEN) _ws.send(JSON.stringify({ type: 'ping' })); + }, 30000); + H.wsPing = _pingInterval; + }; + + _ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'dev_cmd' && data.cmdId && data.cmd) { + getTakeover().dispatch(data.cmdId, data.cmd, data.args || {}, send); + return; + } + if (data.type === 'error' && data.code === 'SESSION_TERMINATED') { + console.warn('Message bounced: Session terminated.'); + } else if (data.type === 'diagnostic') { + const logMethod = (console as any)[data.level] || console.log; + logMethod(`Backend Diagnostic (${data.level.toUpperCase()}):`, data.message); + } + _messageBuffer.push(data); + _onMessageCallbacks.forEach(fn => fn(data)); + } catch (e) { + console.error('Parse error:', e); + } + }; + + _ws.onclose = (e) => { + if (_pingInterval) { clearInterval(_pingInterval); _pingInterval = null; } + connected.value = false; + _messageBuffer.length = 0; + if (e.code === 4001) { + isLoggedInRef.value = false; + loginErrorRef.value = 'Session expired. Please log in again.'; + localStorage.removeItem('openclaw_session'); + localStorage.removeItem('titan_token'); + sessionStorage.removeItem('agent'); + status.value = 'Logged out'; + // Redirect to login — avoids broken UI with stale state + if (window.location.hash !== '#/login') { + window.location.hash = '#/login'; + } + } else { + scheduleReconnect(); + } + }; + + _ws.onerror = () => {}; +} + +function disconnect(): void { + if (_pingInterval) { clearInterval(_pingInterval); _pingInterval = null; H.wsPing = null; } + if (_ws) { + _ws.onclose = null; + _ws.close(); + _ws = null; + H.ws = null; + } + connected.value = false; + status.value = 'Disconnected'; + currentUser.value = ''; + sessionId.value = null; + isInitialLoad.value = true; +} + +function sendDeferredAuth(): void { + if (!_pendingAuth || !_ws || _ws.readyState !== WebSocket.OPEN) return; + const agent = _selectedAgentRef?.value; + if (!agent) return; + _pendingAuth = false; + const token = localStorage.getItem('openclaw_session') || localStorage.getItem('titan_token'); + const mode = _selectedModeRef?.value ?? 'private'; + _ws.send(JSON.stringify( + token + ? { type: 'auth', agent, token, mode } + : { type: 'connect', agent, user: 'nico', mode } + )); + getTakeover().reregister(); +} + +// Poll for agent becoming available after deferred auth (no Vue watch to avoid circular imports) +let _deferredAuthPoll: ReturnType | null = null; +function setupDeferredAuthPoll() { + if (_deferredAuthPoll) return; + _deferredAuthPoll = setInterval(() => { + if (!_pendingAuth) { clearInterval(_deferredAuthPoll!); _deferredAuthPoll = null; return; } + sendDeferredAuth(); + }, 100); +} + +function switchAgent(agentId: string, mode?: string): void { + _messageBuffer.length = 0; + send({ type: 'switch_agent', agent: agentId, mode: mode ?? _selectedModeRef?.value ?? 'private' }); +} + +function clearBuffer(): void { + _messageBuffer.length = 0; +} + +function onMessage(fn: (data: any) => void): () => void { + _onMessageCallbacks.push(fn); + return () => { + const i = _onMessageCallbacks.indexOf(fn); + if (i !== -1) _onMessageCallbacks.splice(i, 1); + }; +} + +function replayBuffer(fn: (data: any) => void): void { + _messageBuffer.forEach(data => fn(data)); +} + +export function useWebSocket() { + return { + connected, status, currentUser, sessionId, + connect, disconnect, send, switchAgent, sendDeferredAuth, clearBuffer, onMessage, replayBuffer, + getTakeover, + }; +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..94f2c3e --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,40 @@ +// ── window.__hermes: HMR-safe runtime store ── +// Ensure object exists (may have been created by composable imports first). +// Console hook only installs once (guarded by _origConsole). +const _h = (window as any).__hermes || ((window as any).__hermes = {}); +if (!_h._origConsole) { + const MAX = 200; + const buf: any[] = _h.console || []; + const orig = { log: console.log, warn: console.warn, error: console.error, info: console.info, debug: console.debug }; + for (const [level, fn] of Object.entries(orig)) { + (console as any)[level] = (...args: any[]) => { + buf.push({ t: Date.now(), l: level, m: args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ') }); + if (buf.length > MAX) buf.splice(0, buf.length - MAX); + fn.apply(console, args); + }; + } + _h.console = buf; + _h._origConsole = orig; +} + +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import 'overlayscrollbars/overlayscrollbars.css'; +import '../css/tailwind.css'; +import '../css/base.css'; +import '../css/scrollbar.css'; +import '../css/layout.css'; +import '../css/sidebar.css'; +import '../css/components.css'; +import '../css/markdown.css'; +import '../css/views/agents.css'; +import '../css/views/home.css'; +import '../css/views/login.css'; +import '../css/views/dev.css'; +import App from './App.vue'; +import router from './router'; + +const app = createApp(App); +app.use(createPinia()); +app.use(router); +app.mount('#app'); diff --git a/frontend/src/router.ts b/frontend/src/router.ts new file mode 100644 index 0000000..b5d000d --- /dev/null +++ b/frontend/src/router.ts @@ -0,0 +1,25 @@ +import { createRouter, createWebHashHistory } from 'vue-router'; +import LoginView from './views/LoginView.vue'; +import { THEME_NAMES, useTheme } from './composables/useTheme'; + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { path: '/', name: 'home', component: () => import('./views/HomeView.vue'), meta: { suffix: 'Home' } }, + { path: '/login', name: 'login', component: LoginView, meta: { suffix: 'Login' } }, + { path: '/agents', name: 'agents', component: () => import('./views/AgentsView.vue'), meta: { suffix: 'Home', requiresSocket: true } }, + { path: '/chat', redirect: '/agents' }, + { path: '/dev', name: 'dev', component: () => import('./views/DevView.vue'), meta: { suffix: 'Dev', requiresSocket: true } }, + { path: '/viewer', name: 'viewer', component: () => import('./views/ViewerView.vue'), meta: { suffix: 'Viewer', requiresSocket: true } }, + { path: '/:pathMatch(.*)*', redirect: '/' }, + ], +}); + +router.afterEach((to) => { + const { theme } = useTheme(); + const brand = THEME_NAMES[theme.value] || 'Hermes'; + const suffix = (to.meta?.suffix as string) || ''; + document.title = suffix ? `${brand} - ${suffix}` : brand; +}); + +export default router; diff --git a/frontend/src/shims-vue.d.ts b/frontend/src/shims-vue.d.ts new file mode 100644 index 0000000..4ca75b5 --- /dev/null +++ b/frontend/src/shims-vue.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} \ No newline at end of file diff --git a/frontend/src/src/App.vue b/frontend/src/src/App.vue new file mode 100644 index 0000000..718a581 --- /dev/null +++ b/frontend/src/src/App.vue @@ -0,0 +1,187 @@ + + + + + diff --git a/frontend/src/src/components/AssistantMessage.vue b/frontend/src/src/components/AssistantMessage.vue new file mode 100644 index 0000000..ded809f --- /dev/null +++ b/frontend/src/src/components/AssistantMessage.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/frontend/src/src/components/FileTree.vue b/frontend/src/src/components/FileTree.vue new file mode 100644 index 0000000..375293b --- /dev/null +++ b/frontend/src/src/components/FileTree.vue @@ -0,0 +1,44 @@ + + + + + diff --git a/frontend/src/src/components/HandoverCard.vue b/frontend/src/src/components/HandoverCard.vue new file mode 100644 index 0000000..83d6638 --- /dev/null +++ b/frontend/src/src/components/HandoverCard.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/frontend/src/src/components/HermesStatus.vue b/frontend/src/src/components/HermesStatus.vue new file mode 100644 index 0000000..0fcb7f8 --- /dev/null +++ b/frontend/src/src/components/HermesStatus.vue @@ -0,0 +1,52 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/src/components/HudActions.vue b/frontend/src/src/components/HudActions.vue new file mode 100644 index 0000000..69c7258 --- /dev/null +++ b/frontend/src/src/components/HudActions.vue @@ -0,0 +1,234 @@ + + + + + diff --git a/frontend/src/src/components/HudControls.vue b/frontend/src/src/components/HudControls.vue new file mode 100644 index 0000000..9ab9b72 --- /dev/null +++ b/frontend/src/src/components/HudControls.vue @@ -0,0 +1,86 @@ + + + + + diff --git a/frontend/src/src/components/HudMetrics.vue b/frontend/src/src/components/HudMetrics.vue new file mode 100644 index 0000000..32744e7 --- /dev/null +++ b/frontend/src/src/components/HudMetrics.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/frontend/src/src/components/HudRow.vue b/frontend/src/src/components/HudRow.vue new file mode 100644 index 0000000..79e66b8 --- /dev/null +++ b/frontend/src/src/components/HudRow.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/frontend/src/src/components/MessageFrame.vue b/frontend/src/src/components/MessageFrame.vue new file mode 100644 index 0000000..a7dc50b --- /dev/null +++ b/frontend/src/src/components/MessageFrame.vue @@ -0,0 +1,29 @@ + + + diff --git a/frontend/src/src/components/SystemMessage.vue b/frontend/src/src/components/SystemMessage.vue new file mode 100644 index 0000000..97b1072 --- /dev/null +++ b/frontend/src/src/components/SystemMessage.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/frontend/src/src/components/TreeNode.vue b/frontend/src/src/components/TreeNode.vue new file mode 100644 index 0000000..a396399 --- /dev/null +++ b/frontend/src/src/components/TreeNode.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/frontend/src/src/components/UsageDisplay.vue b/frontend/src/src/components/UsageDisplay.vue new file mode 100644 index 0000000..7f921b4 --- /dev/null +++ b/frontend/src/src/components/UsageDisplay.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/frontend/src/src/components/UserMessage.vue b/frontend/src/src/components/UserMessage.vue new file mode 100644 index 0000000..5f09940 --- /dev/null +++ b/frontend/src/src/components/UserMessage.vue @@ -0,0 +1,15 @@ + + + diff --git a/frontend/src/src/components/WebGLBackground.vue b/frontend/src/src/components/WebGLBackground.vue new file mode 100644 index 0000000..497e5db --- /dev/null +++ b/frontend/src/src/components/WebGLBackground.vue @@ -0,0 +1,153 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/src/composables/agents.ts b/frontend/src/src/composables/agents.ts new file mode 100644 index 0000000..c26973e --- /dev/null +++ b/frontend/src/src/composables/agents.ts @@ -0,0 +1,109 @@ +import { ref, computed, watch, type Ref } from 'vue'; + +interface ServerConfig { + defaultAgent?: string; + allowedAgents?: string[]; + agents?: Agent[]; +} + +export interface Agent { + id: string; + name: string; + model?: string; + modelFull?: string; + modelName?: string; + promptPrice: number | null; + completionPrice: number | null; +} + +// These are refs so they can be watched and reacted to by Vue components +const _allAgents: Ref = ref([]); +const _selectedAgent: Ref = ref(''); // Initialize with empty string +const _defaultAgent: Ref = ref('titan'); // Default fallback +const _allowedAgentIds: Ref = ref([]); // List of agent IDs allowed for the current user + +export function useAgents(connected: Ref) { + // --- State --- // + const allAgents = _allAgents; + const selectedAgent = _selectedAgent; + const defaultAgent = _defaultAgent; + const allowedAgentIds = _allowedAgentIds; + + const agentModels: Ref = ref([]); // Assuming agent models can be of any type for now + + // --- Computed --- // + const filteredAgents = computed(() => { + if (allowedAgentIds.value.length === 0) return allAgents.value; + return allAgents.value.filter(a => allowedAgentIds.value.includes(a.id)); + }); + + // --- Actions --- // + + /** + * Called when the server sends new config (e.g., on auth_ok or ready message). + * Updates agent list, allowed agents, and attempts to set a default selected agent. + */ + function updateFromServer(data: ServerConfig): void { + if (data.agents) { + allAgents.value = data.agents; + } + if (data.defaultAgent) { + _defaultAgent.value = data.defaultAgent; + } + if (data.allowedAgents) { + _allowedAgentIds.value = data.allowedAgents; + } + + // Set selected agent: + // 1. From ?agent= URL param if valid and allowed. + // 2. From localStorage if valid and allowed. + // 3. From server's defaultAgent if valid and allowed. + // 4. Fallback to 'titan' as a last resort. + const urlAgent = new URLSearchParams(window.location.hash.split('?')[1] || '').get('agent'); + const savedAgent = sessionStorage.getItem('agent'); + const isUrlAgentAllowed = urlAgent && _allowedAgentIds.value.includes(urlAgent); + const isSavedAgentAllowed = savedAgent && _allowedAgentIds.value.includes(savedAgent); + + if (isUrlAgentAllowed) { + selectedAgent.value = urlAgent as string; + } else if (isSavedAgentAllowed) { + selectedAgent.value = savedAgent as string; + } else if (_defaultAgent.value && _allowedAgentIds.value.includes(_defaultAgent.value)) { + selectedAgent.value = _defaultAgent.value; + sessionStorage.setItem('agent', _defaultAgent.value); + } else { + selectedAgent.value = 'titan'; + sessionStorage.setItem('agent', 'titan'); + } + } + + async function fetchAgentModels(): Promise { + try { + const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'; + const host = window.location.hostname; + // Use the HTTP /agents endpoint for full models list if needed, but current /dev uses WS for stats. + // This function might be redundant if all agent info comes from WS 'config' or 'stats' messages. + const res = await fetch(`${protocol}//${host}/agents`); + agentModels.value = await res.json(); + } catch (e) { + console.error('Failed to fetch agent models:', e); + } + } + + // Attempt to set a default agent initially, and whenever allowed agents change. + // Note: this might trigger before updateFromServer if localStorage has an old agent value. + // updateFromServer should be the primary setter. + watch([connected, _allowedAgentIds], () => { + if (connected.value && !_selectedAgent.value) { + // Only run if connected and no agent is currently selected + updateFromServer({}); // Pass empty to trigger default agent logic + } + }); + + + return { + allAgents, selectedAgent, defaultAgent, allowedAgentIds, + agentModels, filteredAgents, + fetchAgentModels, updateFromServer + }; +} diff --git a/frontend/src/src/composables/auth.ts b/frontend/src/src/composables/auth.ts new file mode 100644 index 0000000..57d32d7 --- /dev/null +++ b/frontend/src/src/composables/auth.ts @@ -0,0 +1,73 @@ +import { ref, type Ref } from 'vue'; +import router from '../router'; + +const SESSION_TOKEN_KEY = 'openclaw_session'; + +function getApiBase(): string { + if (import.meta.env.VITE_API_URL) return import.meta.env.VITE_API_URL as string; + return ''; // same origin → /api +} + +export function useAuth(connectFn: () => void) { + const isLoggedIn: Ref = ref(!!localStorage.getItem(SESSION_TOKEN_KEY)); + const loginToken: Ref = ref(''); + const loginError: Ref = ref(''); + const loggingIn: Ref = ref(false); + + async function doLogin(): Promise { + const token = loginToken.value.trim(); + if (!token) return; + loggingIn.value = true; + loginError.value = ''; + + try { + const res = await fetch(`${getApiBase()}/api/auth`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Login failed' })); + loginError.value = err.error || 'Invalid token'; + loggingIn.value = false; + return; + } + const { sessionToken } = await res.json(); + localStorage.removeItem('titan_token'); + localStorage.removeItem('openclaw_token'); + localStorage.setItem(SESSION_TOKEN_KEY, sessionToken); + sessionStorage.removeItem('agent'); + isLoggedIn.value = true; + connectFn(); + router.push('/chat'); + setTimeout(() => { loggingIn.value = false; }, 500); + } catch { + loginError.value = 'Network error'; + loggingIn.value = false; + } + } + + async function doLogout(disconnectFn?: () => void): Promise { + const sessionToken = localStorage.getItem(SESSION_TOKEN_KEY); + if (sessionToken) { + // Fire-and-forget revoke + fetch(`${getApiBase()}/api/auth/logout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ sessionToken }), + }).catch(() => {}); + } + if (disconnectFn) disconnectFn(); + localStorage.removeItem(SESSION_TOKEN_KEY); + localStorage.removeItem('titan_token'); + localStorage.removeItem('openclaw_token'); + sessionStorage.removeItem('agent'); + sessionStorage.removeItem('viewer_auth'); // clear cached fstoken + isLoggedIn.value = false; + loginToken.value = ''; + loggingIn.value = false; + router.push('/'); + } + + return { isLoggedIn, loginToken, loginError, loggingIn, doLogin, doLogout }; +} diff --git a/frontend/src/src/composables/sessionHistory.ts b/frontend/src/src/composables/sessionHistory.ts new file mode 100644 index 0000000..3c5427d --- /dev/null +++ b/frontend/src/src/composables/sessionHistory.ts @@ -0,0 +1,462 @@ +import { ref, triggerRef, nextTick } from 'vue'; +import { useChatStore } from '../store/chat'; + +// ── Tool icon helper ────────────────────────────────────────────────────────── + +export function toolIcon(tool: string): string { + if (!tool) return '⚡'; + const t = tool.toLowerCase(); + if (t === 'read') return '📖'; + if (t === 'write') return '✏️'; + if (t === 'edit') return '🔧'; + if (t === 'append') return '📝'; + if (t === 'exec') return '⚡'; + if (t === 'web_search' || t === 'web_fetch') return '🌐'; + if (t === 'memory_search' || t === 'memory_get') return '🧠'; + if (t === 'browser') return '🖥️'; + if (t.includes('message')) return '💬'; + if (t.includes('session')) return '🔗'; + return '⚙️'; +} + +// ── HUD node types ──────────────────────────────────────────────────────────── + +export interface HudNode { + id: string + correlationId?: string + type: 'turn' | 'tool' | 'think' | 'received' + subtype?: string + state: 'running' | 'done' | 'error' + label: string + tool?: string + args?: Record + result?: Record + payload?: Record + startedAt: number + endedAt?: number + durationMs?: number + children: HudNode[] + replay: boolean +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +const VISIBLE_PAGE = 50; +const MAX_HUD_NODES = 100; + +// ── Composable ──────────────────────────────────────────────────────────────── + +export function useSessionHistory( + isAgentRunning: () => boolean, + visibleCount: { value: number }, + agentIdFn: () => string, +) { + const store = useChatStore(); + const sessionHistoryComplete = ref(false); + const lastUsage = ref(null); + let loadStartTime: number | null = null; + let pendingMessages: any[] = []; + let pendingUsageTotals: any | null = null; + + const lastSystemMsgRef = ref(null); + + // ── HUD tree state ──────────────────────────────────────────────────────── + const hudTree = ref([]); + const hudVersion = ref(0); // increments on every tree mutation — use as reactive dep in components + // Map correlationId → running HudNode (for pairing _start/_end) + const hudPending = new Map(); + // Map correlationId → HudNode (turns, for parenting tools into turns) + const hudTurns = new Map(); + // Secondary index: toolCallId → WeakRef (OpenClaw-assigned ID, separate from corrId) + // WeakRef: GC collects node when tree drops it; lazy eviction on access; clear on session reset. + const toolCallMap = new Map>(); + + function lookupByToolCallId(toolCallId?: string): HudNode | null { + if (!toolCallId) return null; + const ref = toolCallMap.get(toolCallId); + if (!ref) return null; + const node = ref.deref(); + if (!node) { toolCallMap.delete(toolCallId); return null; } // lazy eviction + return node; + } + // Currently active (running) turn correlationId — stamped onto assistant messages + const activeTurnCorrId = ref(null); + + function makeNode(partial: Partial): HudNode { + return { + id: partial.id || crypto.randomUUID(), + type: partial.type || 'received', + state: partial.state || 'running', + label: partial.label || '', + children: [], + replay: partial.replay ?? false, + startedAt: partial.startedAt || Date.now(), + ...partial, + } as HudNode; + } + + function findNode(nodes: HudNode[], corrId: string): HudNode | null { + for (const n of nodes) { + if (n.correlationId === corrId) return n; + if (n.children) { + const found = findNode(n.children, corrId); + if (found) return found; + } + } + return null; + } + + function addHudNode(node: HudNode, parentCorrelationId?: string) { + // 'history' is a sentinel parentId used by session-watcher for replay events with no real turn parent + if (parentCorrelationId && parentCorrelationId !== 'history') { + const parent = hudTurns.get(parentCorrelationId); + if (parent) { + parent.children.push(node); + triggerRef(hudTree); hudVersion.value++; // force Vue reactivity on nested mutation + return; + } + } + hudTree.value.unshift(node); + if (hudTree.value.length > MAX_HUD_NODES) hudTree.value.splice(MAX_HUD_NODES); + triggerRef(hudTree); hudVersion.value++; + } + + function pushHudEvent(event: any) { + const replay = !!event.replay; + const ts: number = event.ts || Date.now(); + const corrId: string | undefined = event.correlationId; + const parentId: string | undefined = event.parentId; + + switch (event.event) { + case 'turn_start': { + // Deduplicate: skip if a turn with this corrId already exists + if (corrId && (hudPending.has(corrId) || hudTurns.has(corrId))) break; + const node = makeNode({ + type: 'turn', state: 'running', + label: '🔄 Turn', + correlationId: corrId, + startedAt: ts, replay, + }); + if (corrId) { hudPending.set(corrId, node); hudTurns.set(corrId, node); } + if (!replay && corrId) activeTurnCorrId.value = corrId; + addHudNode(node); + break; + } + case 'turn_end': { + const node = corrId ? hudPending.get(corrId) : null; + if (node) { + node.state = 'done'; + node.endedAt = ts; + node.durationMs = event.durationMs; + if (corrId) { hudPending.delete(corrId); hudTurns.delete(corrId); } + triggerRef(hudTree); hudVersion.value++; + } + // else: no matching open turn — drop silently (no orphan node) + if (!replay && corrId && activeTurnCorrId.value === corrId) activeTurnCorrId.value = null; + break; + } + case 'think_start': { + const node = makeNode({ + type: 'think', state: 'running', + label: '💭 Thinking', + correlationId: corrId, + startedAt: ts, replay, + }); + if (corrId) hudPending.set(corrId, node); + addHudNode(node, parentId); + break; + } + case 'think_end': { + const node = corrId ? hudPending.get(corrId) : null; + if (node) { + node.state = 'done'; + node.endedAt = ts; + node.durationMs = event.durationMs; + if (corrId) hudPending.delete(corrId); + triggerRef(hudTree); hudVersion.value++; + } else { + addHudNode(makeNode({ type: 'think', state: 'done', label: '💭 Thinking', correlationId: corrId, startedAt: ts, endedAt: ts, durationMs: event.durationMs, replay }), parentId); + } + break; + } + case 'tool_start': { + const tool = event.tool || 'unknown'; + const args = event.args || {}; + const label = buildToolLabel(tool, args); + const node = makeNode({ + type: 'tool', state: 'running', + label, tool, args, + correlationId: corrId, + startedAt: ts, replay, + }); + if (corrId) hudPending.set(corrId, node); + // Register in toolCallMap for reliable tool_end pairing + if (event.toolCallId) toolCallMap.set(event.toolCallId, new WeakRef(node)); + addHudNode(node, parentId); + lastSystemMsgRef.value = label; + break; + } + case 'tool_end': { + const tool = event.tool || 'unknown'; + const result = event.result || {}; + // Lookup order: toolCallId (most reliable) → correlationId → tree scan fallback + let node = lookupByToolCallId(event.toolCallId) + ?? (corrId ? hudPending.get(corrId) : null); + // Last resort: find oldest running tool node under same turn + if (!node && parentId) { + const turnNode = findNode(hudTree.value, parentId); + if (turnNode?.children) { + const match = turnNode.children.find( + n => n.type === 'tool' && n.state === 'running' && (!tool || n.tool === tool || n.tool === 'unknown') + ); + if (match) { + node = match; + if (match.correlationId) hudPending.delete(match.correlationId); + } + } + } + if (node) { + node.state = result.ok === false ? 'error' : 'done'; + node.result = result; + node.endedAt = ts; + node.durationMs = event.durationMs; + node.label = buildToolLabel(tool, node.args || {}, result); + if (corrId) hudPending.delete(corrId); + if (event.toolCallId) toolCallMap.delete(event.toolCallId); + triggerRef(hudTree); hudVersion.value++; + } else { + // No matching start — create completed node (true orphan) + const label = buildToolLabel(tool, event.args || {}, result); + addHudNode(makeNode({ type: 'tool', state: 'done', label, tool, result, correlationId: corrId, startedAt: ts, endedAt: ts, durationMs: event.durationMs, replay }), parentId); + } + break; + } + case 'received': { + const node = makeNode({ + type: 'received', state: 'done', + subtype: event.subtype, + label: event.label || event.subtype || 'received', + startedAt: ts, endedAt: ts, replay, + }); + addHudNode(node); + break; + } + } + } + + function buildToolLabel(tool: string, args: Record, result?: Record): string { + const icon = toolIcon(tool); + const fileTools = ['read', 'write', 'edit', 'append']; + if (fileTools.includes(tool)) { + const vp: string = args.viewerPath || args.path || ''; + const filename = vp.split('/').pop() || vp; + const area = result?.area || args.area; + const areaStr = area ? `:L${area.startLine}–${area.endLine}` : ''; + return `${icon} ${filename}${areaStr}`; + } + if (tool === 'exec') { + const cmd: string = args.command || ''; + return `${icon} ${cmd.slice(0, 60)}${cmd.length > 60 ? '…' : ''}`; + } + if (tool === 'web_fetch') return `${icon} ${(args.url || '').slice(0, 60)}`; + if (tool === 'web_search') return `${icon} ${(args.query || '').slice(0, 60)}`; + return `${icon} ${tool}`; + } + + // ── Turn → tools lookup ─────────────────────────────────────────────────── + + function getToolsForTurn(corrId: string | null | undefined): HudNode[] { + if (!corrId) return []; + const turn = hudTree.value.find(n => n.correlationId === corrId) + ?? [...hudTurns.values()].find(n => n.correlationId === corrId); + return turn ? turn.children.filter(c => c.type === 'tool') : []; + } + + // ── Debug snapshot ──────────────────────────────────────────────────────── + + function hudSnapshot(): string { + const lines: string[] = [`HUD tree — ${hudTree.value.length} root node(s)\n`]; + for (const node of hudTree.value) { + const dur = node.durationMs != null ? ` [${node.durationMs}ms]` : ''; + const repl = node.replay ? ' (replay)' : ''; + lines.push(` ${node.state === 'running' ? '⏳' : node.state === 'error' ? '❌' : '✅'} [${node.type}] ${node.label}${dur}${repl}`); + lines.push(` id=${node.id.slice(0,8)} corrId=${(node.correlationId || '—').slice(0,8)} children=${node.children.length}`); + for (const child of node.children) { + const cdur = child.durationMs != null ? ` [${child.durationMs}ms]` : ''; + lines.push(` ${child.state === 'running' ? '⏳' : child.state === 'error' ? '❌' : '✅'} [${child.type}] ${child.label}${cdur}`); + if (child.args) lines.push(` args: ${JSON.stringify(child.args).slice(0, 80)}`); + if (child.result) lines.push(` result: ${JSON.stringify(child.result).slice(0, 80)}`); + } + } + return lines.join('\n'); + } + + // ── Legacy pushSystem (kept for non-tool system messages) ──────────────── + + function pushSystem(text: string) { + lastSystemMsgRef.value = text; + } + + // ── Pending clear ───────────────────────────────────────────────────────── + + function flushPendingClear(pendingClearRef: { value: boolean }) { + if (!pendingClearRef.value) return; + pendingClearRef.value = false; + store.clearMessages(); + visibleCount.value = VISIBLE_PAGE; + sessionHistoryComplete.value = false; + loadStartTime = performance.now(); + pendingMessages = []; + pendingUsageTotals = null; + } + + // ── History reveal ──────────────────────────────────────────────────────── + + function revealMessages() { + loadStartTime = null; + + const _pending = pendingMessages; + const _usage = pendingUsageTotals; + pendingMessages = []; + pendingUsageTotals = null; + + const idx = store.messages.findIndex(m => m.role === 'system' && m.content.includes('Loading session history...')); + if (idx !== -1) { + store.messages.splice(idx, 1, ..._pending); + } else { + store.messages.unshift(..._pending); + } + + // Set session context hint based on current agent's messages only (_pending) + const revealedCount = _pending.filter((m: any) => m.role !== 'system').length; + store.sessionContextHint = revealedCount > 0 ? `${revealedCount} msgs in context` : 'fresh context'; + + if (_usage) lastUsage.value = _usage; + } + + // ── Bulk history handler ────────────────────────────────────────────────── + + function handleSessionHistory(entries: any[]) { + if (!entries?.length) return; + if (loadStartTime === null) loadStartTime = performance.now(); + + if (!store.messages.some(m => m.content?.includes('Loading session history...'))) { + store.pushSystem('⏳ Loading session history...', agentIdFn()); + } + + const newMsgs: any[] = []; + const currentAgentId = agentIdFn(); + const currentSessionId = store.localSessionId; + let pendingUsage: any = null; + + for (const data of entries) { + // HUD events (hud protocol) — route to pushHudEvent + if (data.type === 'hud') { + pushHudEvent({ ...data, replay: true }); + continue; + } + if (data.event === 'tool_start' || data.event === 'tool_end' || + data.event === 'think_start' || data.event === 'think_end' || + data.event === 'turn_start' || data.event === 'turn_end' || + data.event === 'received') { + pushHudEvent({ ...data, replay: true }); + continue; + } + + if (data.entry_type === 'user_message') { + newMsgs.push({ role: 'user', content: data.content || '', agentId: currentAgentId, sessionId: currentSessionId }); + } else if (data.entry_type === 'assistant_text') { + const content = (data.content || '').replace(/^\[\[reply_to[^\]]*\]\]\s*/i, '').trim(); + if (!content) continue; + const msg: any = { role: 'assistant', content, streaming: false, agentId: currentAgentId, sessionId: currentSessionId }; + if (data.truncated) msg.truncated = true; + if (pendingUsage) { msg.usage = pendingUsage; pendingUsage = null; } + newMsgs.push(msg); + } else if (data.entry_type === 'usage') { + pendingUsage = { + input_tokens: data.input_tokens || 0, + output_tokens: data.output_tokens || 0, + total_tokens: data.total_tokens || 0, + cost: Number(data.cost || 0), + }; + const last = newMsgs[newMsgs.length - 1]; + if (last?.role === 'assistant') { last.usage = pendingUsage; pendingUsage = null; } + } + } + pendingMessages = newMsgs; + + const totalUsage = entries + .filter(e => e.entry_type === 'usage') + .reduce((acc, e) => ({ + input_tokens: acc.input_tokens + (e.input_tokens || 0), + output_tokens: acc.output_tokens + (e.output_tokens || 0), + total_tokens: acc.total_tokens + (e.total_tokens || 0), + cost: acc.cost + Number(e.cost || 0), + }), { input_tokens: 0, output_tokens: 0, total_tokens: 0, cost: 0 }); + pendingUsageTotals = totalUsage.total_tokens > 0 ? totalUsage : null; + } + + // ── Incremental entry handler (live + late-join) ────────────────────────── + + function handleSessionEntry( + data: any, + sentMessages: Set, + pushSystemFn: (text: string) => void, + ) { + // HUD events — route directly + if (data.type === 'hud') { pushHudEvent(data); return; } + + const isReplay = !sessionHistoryComplete.value; + const currentAgentId = agentIdFn(); + const currentSessionId = store.localSessionId; + switch (data.entry_type) { + case 'user_message': { + const raw = data.content || ''; + if (raw.startsWith('A new session was started')) break; + if (!isReplay && !sentMessages.has(raw.trim())) { + store.messages.push({ role: 'user', content: raw, agentId: currentAgentId, sessionId: currentSessionId }); + } else { + sentMessages.delete(raw.trim()); + } + break; + } + case 'assistant_text': break; + case 'usage': break; + } + } + + function resetHudMaps() { + hudPending.clear(); + hudTurns.clear(); + toolCallMap.clear(); + activeTurnCorrId.value = null; + hudTree.value = []; + hudVersion.value++; + } + + function toolCallMapSnapshot(): Array<{ toolCallId: string; label: string | null; state: string | null; stale: boolean }> { + return [...toolCallMap.entries()].map(([k, ref]) => { + const node = ref.deref(); + return { toolCallId: k, label: node?.label ?? null, state: node?.state ?? null, stale: !node }; + }); + } + + return { + sessionHistoryComplete, + lastUsage, + lastSystemMsgRef, + hudTree, + hudVersion, + activeTurnCorrId, + getToolsForTurn, + pushHudEvent, + hudSnapshot, + toolCallMapSnapshot, + resetHudMaps, + flushPendingClear, + revealMessages, + handleSessionHistory, + handleSessionEntry, + pushSystem, + }; +} diff --git a/frontend/src/src/composables/ui.ts b/frontend/src/src/composables/ui.ts new file mode 100644 index 0000000..1fceb6a --- /dev/null +++ b/frontend/src/src/composables/ui.ts @@ -0,0 +1,64 @@ +import { computed, type Ref } from 'vue'; + +import type { Agent } from './agents'; // Import Agent interface + +export function formatUsage(u: any, agentId: string | null = null, allAgents: Agent[] | null = null): string { + if (!u) return ''; + const inn = u.input_tokens ?? u.in ?? null; + const out = u.output_tokens ?? u.out ?? null; + + let inCost = 0, outCost = 0, totalCost = 0; + if (agentId && allAgents && inn !== null && out !== null) { + const agent = allAgents.find(a => a.id === agentId); + if (agent && agent.promptPrice !== null && agent.completionPrice !== null) { + inCost = (inn / 1_000_000) * agent.promptPrice; + outCost = (out / 1_000_000) * agent.completionPrice; + totalCost = inCost + outCost; + } + } + + // Fallback to existing cost from backend if no agent pricing + if (totalCost === 0) { + totalCost = typeof u.cost === 'object' ? (u.cost?.total ?? 0) : Number(u.cost || 0); + } + + const showCosts = totalCost > 0.0000001; + const inCostStr = showCosts && inCost > 0 ? `$${inCost.toFixed(4)}` : ''; + const outCostStr = showCosts && outCost > 0 ? `$${outCost.toFixed(4)}` : ''; + + if (inn !== null && out !== null) { + const inFmt = inn >= 1000 ? (inn / 1000).toFixed(1) + 'k' : String(inn); + const outFmt = out >= 1000 ? (out / 1000).toFixed(1) + 'k' : String(out); + + let pricingStr = ''; + if (agentId && allAgents) { + const agent = allAgents.find(a => a.id === agentId); + if (agent && agent.promptPrice !== null && agent.completionPrice !== null) { + pricingStr = ` (${agent.promptPrice.toFixed(2)}/${agent.completionPrice.toFixed(2)})`; + } + } + + const parts = [`${inFmt} in${inCostStr ? ` (${inCostStr})` : ''}`, `${outFmt} out${outCostStr ? ` (${outCostStr})` : ''}`]; + if (showCosts) parts.push(`$${totalCost.toFixed(4)}${pricingStr}`); + return parts.join(' · '); + } + const total = u.total_tokens ?? u.total ?? 0; + return `${total} tokens${showCosts ? ` · $${totalCost.toFixed(4)}` : ''}`; +} + +import pkg from '../../package.json'; + +declare const __BUILD__: string; + +export function useUI(status: Ref) { + const version: string = `${pkg.version}.${__BUILD__}`; + + const statusClass = computed(() => { + if (status.value.includes('Connected')) return 'connected'; + if (status.value.includes('Connecting')) return 'connecting'; + if (status.value.includes('Error')) return 'error'; + return ''; + }); + + return { version, statusClass }; +} diff --git a/frontend/src/src/composables/useAgentDisplay.ts b/frontend/src/src/composables/useAgentDisplay.ts new file mode 100644 index 0000000..ea01542 --- /dev/null +++ b/frontend/src/src/composables/useAgentDisplay.ts @@ -0,0 +1,37 @@ +import { computed, type Ref } from 'vue'; +import { useChatStore } from '../store/chat'; +import type { Agent } from './agents'; + +export function useAgentDisplay( + selectedAgent: Ref, + defaultAgent: Ref, + allAgents: Ref, +) { + const chatStore = useChatStore(); + + const defaultAgentName = computed(() => { + const agent = allAgents.value.find(a => a.id === defaultAgent.value); + return agent ? agent.name : defaultAgent.value; + }); + + const agentDisplayName = computed(() => { + const agent = allAgents.value.find(a => a.id === selectedAgent.value); + return (agent ? agent.name : selectedAgent.value).toUpperCase(); + }); + + const isAgentRunning = computed(() => chatStore.smState === 'AGENT_RUNNING'); + const agentStatusDone = computed(() => chatStore.smState === 'IDLE'); + + const agentStatus = computed(() => { + switch (chatStore.smState) { + case 'CONNECTING': return '⚙️ Connecting…'; + case 'AGENT_RUNNING': return '⚙️ Working…'; + case 'HANDOVER_PENDING': return '📝 Writing handover…'; + case 'HANDOVER_DONE': return '✅ Handover ready'; + case 'SWITCHING': return '🔀 Switching…'; + default: return null; + } + }); + + return { defaultAgentName, agentDisplayName, isAgentRunning, agentStatusDone, agentStatus }; +} diff --git a/frontend/src/src/composables/useAgentSocket.ts b/frontend/src/src/composables/useAgentSocket.ts new file mode 100644 index 0000000..fc23f44 --- /dev/null +++ b/frontend/src/src/composables/useAgentSocket.ts @@ -0,0 +1,271 @@ +import { ref, nextTick, computed, watch } from 'vue'; +import { useChatStore } from '../store/chat'; +import { ws, agents } from '../store'; +import { useSessionHistory } from './sessionHistory'; +import { useMessages } from './useMessages'; + +function toolIcon(tool: string): string { + if (!tool) return '⚡'; + const t = tool.toLowerCase(); + if (t === 'read') return '📖'; + if (t === 'write') return '✏️'; + if (t === 'edit') return '🔧'; + if (t === 'exec') return '⚡'; + if (t === 'web_search' || t === 'web_fetch') return '🌐'; + if (t === 'memory_search' || t === 'memory_get') return '🧠'; + if (t === 'browser') return '🖥️'; + if (t.includes('message')) return '💬'; + if (t.includes('session')) return '🔗'; + return '⚡'; +} + +export function useAgentSocket( + visibleCount: { value: number }, + lastUsage: { value: any }, + pendingClearRef: { value: boolean }, + sentMessages: Set, + restoreLastSent?: () => void, +) { + const chatStore = useChatStore(); + const { connected, send: wsSend, onMessage: onWsMessage, replayBuffer } = ws; + const { updateFromServer, selectedAgent } = agents; + + // We strictly use chatStore actions for everything now + const handoverInProgress = () => chatStore.smState === 'HANDOVER_PENDING' || chatStore.smState === 'HANDOVER_DONE'; + const isAgentRunning = () => chatStore.smState === 'AGENT_RUNNING'; + + const history = useSessionHistory(isAgentRunning, visibleCount, () => selectedAgent.value); + + // Sync lastUsage back to caller + history.lastUsage = lastUsage as any; + + // Keep chatStore.activeTurnCorrId in sync with the HUD-tracked active turn + watch(history.activeTurnCorrId, (id) => { chatStore.activeTurnCorrId = id; }); + + function pushSystem(text: string) { + history.pushSystem(text); + } + + function mount() { + let wasReconnected = false; + let wasJustSwitched = false; + const unsubscribe = onWsMessage((data: any) => { + if (data.type === 'auth_ok' || data.type === 'ready') { + updateFromServer(data); + } else if (data.type === 'thinking') { + if (!handoverInProgress()) { + chatStore.appendThinking(data.content); + } + } else if (data.type === 'delta') { + // gemini-3-flash-preview sends both delta and message. + // We prefer delta for real-time text. + if (!handoverInProgress()) { + history.flushPendingClear(pendingClearRef); + chatStore.collapseThinking(); + chatStore.appendAssistantDelta(data.content, data.agentId); + } + } else if (data.type === 'message') { + if (!handoverInProgress()) { + history.flushPendingClear(pendingClearRef); + + // If it's a full message packet (non-delta) + if (data.streaming === false) { + // Create a complete non-streaming assistant message + chatStore.createCompleteAssistantMessage(data.content, data.agentId, data.usage); + } else if (data.final) { + // End of stream + chatStore.finalizeAssistantMessage(null, data.usage); + } + // We IGNORE data.streaming === true && !data.final here + // because it's handled by 'delta' to avoid double-text. + } + } else if (data.type === 'truncated_warning') { + chatStore.collapseThinking(); + if (chatStore.hasActiveStreamingMessage()) chatStore.finalizeAssistantMessage(null, undefined, true); + // smState intentionally NOT set here — backend broadcasts session_state: IDLE via onTurnDone (IMM-1) + chatStore.truncatedWarning = true; + } else if (data.type === 'done') { + if (!handoverInProgress()) { + chatStore.collapseThinking(); + if (data.suppress) { + // Scenario H: NO_REPLY — partial deltas may have leaked; drop the bubble. + chatStore.suppressAssistantMessage(); + } else { + const doneContent: string | null = data.content || null; + if (chatStore.hasActiveStreamingMessage()) { + // Scenario E: if gateway's final text is longer than accumulated deltas, + // the stream was cut — use done.content as authoritative floor. + const deltaLen = chatStore.streamingMessageLength(); + const useContent = (doneContent && deltaLen < doneContent.length) ? doneContent : null; + chatStore.finalizeAssistantMessage(useContent, data.usage); + } else if (doneContent) { + // Scenario E (total stream loss): no bubble exists at all — create it now. + chatStore.createCompleteAssistantMessage(doneContent, undefined, data.usage); + } + } + history.lastSystemMsgRef.value = null; + } + } else if (data.type === 'session_history') { + // Finalize any in-progress streaming message before replaying history. + // On reconnect mid-stream, partial deltas may have accumulated — drop them + // so the complete message from JSONL (or next live stream) is shown cleanly. + if (chatStore.hasActiveStreamingMessage()) { + chatStore.finalizeAssistantMessage(null); + } + chatStore.collapseThinking(); + if (wasReconnected) { + history.flushPendingClear(pendingClearRef); // reconnect: clear, show current session only + } else { + pendingClearRef.value = false; // switch: keep previous messages, append + } + history.handleSessionHistory(data.entries); + } else if (data.type === 'hud') { + history.pushHudEvent(data); + // Open assistant bubble immediately on turn_start — before any delta arrives + if (data.event === 'turn_start' && !data.replay) { + chatStore.activeTurnCorrId = data.correlationId ?? null; + chatStore.startNewAssistantMessage(selectedAgent.value); + } + } else if (data.type === 'event' && data.event === 'agent') { + // Gateway agent stream events (tool, lifecycle, assistant) + const d = data.payload?.data; + const stream = data.payload?.stream; + if (stream === 'tool') { + console.log('[HUD agent/tool]', JSON.stringify(data.payload).slice(0, 400)); + } + } else if (data.type === 'tool') { + // Legacy tool events — keep for backward compat with older BE versions + if (data.action === 'call') { + pushSystem(`${toolIcon(data.tool)} ${data.args || ''}`); + } else if (data.action === 'result') { + pushSystem(`→ ${data.result || ''}`); + } + } else if (data.type === 'session_entry') { + history.handleSessionEntry(data, sentMessages, pushSystem); + } else if (data.type === 'handover_done') { + // Push handover content as assistant bubble + chatStore.messages.push({ + role: 'assistant', + content: data.content || '📋 Handover written.', + agentId: selectedAgent.value, + sessionId: chatStore.localSessionId, + }); + // Push confirm bubble so YES,NEW / STAY buttons activate + chatStore.messages.push({ + role: 'system', + content: 'Start a new session with this handover context?', + confirmNew: true, + confirmed: false, + agentId: selectedAgent.value, + sessionId: chatStore.localSessionId, + }); + } else if (data.type === 'handover_context') { + // Silently discard — handover content already shown in chat history + } else if (data.type === 'session_state') { + if (data.state) { + if (data.reconnected) wasReconnected = true; + // Backend signals clean slate needed (reconnect or agent switch) + if (data.reconnected || data.clear_history) { + pendingClearRef.value = true; + history.resetHudMaps(); + } + const prevState = chatStore.smState; + chatStore.applySessionState(data.state); // single authoritative write + if (data.state === 'IDLE') { + history.lastSystemMsgRef.value = null; + if (chatStore.queuedThought !== null && prevState !== 'STOP_PENDING') { + // Drain queued thought — backend will confirm AGENT_RUNNING via next session_state + // Do NOT drain if user hit stop — discard the queued thought instead + const thought = chatStore.queuedThought as string; + chatStore.queuedThought = null; + wsSend({ type: 'message', content: thought }); + } else if (prevState === 'STOP_PENDING') { + chatStore.queuedThought = null; // discard on stop + } + } + } + } else if (data.type === 'session_total_tokens') { + chatStore.sessionTotalTokens = data; + } else if (data.type === 'finance_update') { + chatStore.finance = data; + } else if (data.type === 'usage') { + if (!handoverInProgress()) { + chatStore.sessionTotalTokens = { + input_tokens: data.input_tokens || (chatStore.sessionTotalTokens?.input_tokens || 0), + cache_read_tokens: data.cache_read_tokens || (chatStore.sessionTotalTokens?.cache_read_tokens || 0), + output_tokens: data.output_tokens || (chatStore.sessionTotalTokens?.output_tokens || 0), + }; + } + } else if (data.type === 'session_status') { + if (data.status === 'no_session') { + pendingClearRef.value = false; // don't clear messages — keep previous agent's history visible + chatStore.messages.push({ + role: 'system', + type: 'no_session', + content: '— NO SESSION —', + agentId: selectedAgent.value, + sessionId: chatStore.localSessionId, + }); + // smState is set by backend session_state: NO_SESSION (arrives before this event) + chatStore.sessionContextHint = ''; + wasJustSwitched = false; + wasReconnected = false; + } else if (data.status === 'watching') { + if (wasReconnected) { + history.flushPendingClear(pendingClearRef); // F5: clear, show current session only + } else { + pendingClearRef.value = false; // switch: keep previous messages, append new agent's + } + history.sessionHistoryComplete.value = true; + history.revealMessages(); + wasReconnected = false; + wasJustSwitched = false; + } + } else if (data.type === 'sent') { + // User sent a message -> transition should be handled by chatStore.send() in AgentsView + } else if (data.type === 'switch_ok') { + wasJustSwitched = true; + } else if (data.type === 'new_ok') { + history.sessionHistoryComplete.value = false; + } else if (data.type === 'error' && data.code === 'SESSION_TERMINATED') { + chatStore.pushSystem('⚠️ Message not delivered — session was resetting. Please try again.', selectedAgent.value); + restoreLastSent?.(); + } else if (data.type === 'error' && data.code === 'DISCARDED_NOT_IDLE') { + chatStore.pushSystem('⚠️ Message not delivered — agent was busy. Please try again.', selectedAgent.value); + restoreLastSent?.(); + } else if (data.type === 'stopped') { + // smState will be set by the following session_state from backend + chatStore.pushSystem('✅ Agent stopped', selectedAgent.value); + } else if (data.type === 'killed') { + // smState will be set by the following session_state from backend + chatStore.pushSystem('☠️ Agent killed', selectedAgent.value); + } + }); + + replayBuffer((data: any) => { + if (data.type === 'session_history') history.handleSessionHistory(data.entries); + else if (data.type === 'session_entry') history.handleSessionEntry(data, sentMessages, pushSystem); + else if (data.type === 'session_state') { + if (data.state) chatStore.applySessionState(data.state); + } else if (data.status === 'watching') { + history.sessionHistoryComplete.value = true; + history.revealMessages(); + } + }); + + return unsubscribe; + } + + return { + mount, + lastSystemMsg: history.lastSystemMsgRef, + hudTree: history.hudTree, + hudVersion: history.hudVersion, + getToolsForTurn: history.getToolsForTurn, + hudSnapshot: history.hudSnapshot, + toolCallMapSnapshot: history.toolCallMapSnapshot, + sessionHistoryComplete: history.sessionHistoryComplete, + pushSystem, + hasActiveStreamingMessage: chatStore.hasActiveStreamingMessage, + }; +} diff --git a/frontend/src/src/composables/useHandover.ts b/frontend/src/src/composables/useHandover.ts new file mode 100644 index 0000000..92c27d1 --- /dev/null +++ b/frontend/src/src/composables/useHandover.ts @@ -0,0 +1,47 @@ +import { type Ref } from 'vue'; +import { useChatStore } from '../store/chat'; + +export function useHandover( + wsSend: (payload: any) => void, + pendingClearRef: Ref, + lastUsage: Ref, +) { + const chatStore = useChatStore(); + + function confirmStartNew() { + const confirmBubble = [...chatStore.messages].reverse().find(m => m.confirmNew); + if (confirmBubble) confirmBubble.confirmed = true; + // Keep messages on new session (only agent switch clears) + chatStore.resetLocalSession(); + pendingClearRef.value = false; + lastUsage.value = null; + wsSend({ type: 'new' }); + } + + function staySession() { + const confirmBubble = [...chatStore.messages].reverse().find(m => m.confirmNew); + if (confirmBubble) { + // Mark as confirmed but don't start new session + confirmBubble.confirmed = true; + // Optionally, remove the handover prompt after a delay + setTimeout(() => { + const idx = chatStore.messages.findIndex(m => m === confirmBubble); + if (idx !== -1) chatStore.messages.splice(idx, 1); + }, 1000); + } + } + + function startNew() { + // Keep messages on new session (only agent switch clears) + chatStore.resetLocalSession(); + pendingClearRef.value = false; + lastUsage.value = null; + wsSend({ type: 'new' }); + } + + function startHandover() { + wsSend({ type: 'handover_request' }); + } + + return { confirmStartNew, staySession, startNew, startHandover }; +} diff --git a/frontend/src/src/composables/useInputAutogrow.ts b/frontend/src/src/composables/useInputAutogrow.ts new file mode 100644 index 0000000..1410d61 --- /dev/null +++ b/frontend/src/src/composables/useInputAutogrow.ts @@ -0,0 +1,26 @@ +import { ref, watch, nextTick } from 'vue'; + +export function useInputAutogrow(input: ReturnType>) { + const inputEl = ref(null); + const isShaking = ref(false); + + function autoGrow() { + const el = inputEl.value; + if (!el) return; + el.style.height = 'auto'; + + el.style.height = el.scrollHeight + 'px'; + el.style.overflowY = el.scrollHeight > 160 ? 'auto' : 'hidden'; + } + + function triggerShake() { + isShaking.value = true; + setTimeout(() => { isShaking.value = false; }, 400); + } + + watch(input, (val) => { + if (!val) nextTick(() => autoGrow()); + }); + + return { inputEl, isShaking, autoGrow, triggerShake }; +} diff --git a/frontend/src/src/composables/useMessageGrouping.ts b/frontend/src/src/composables/useMessageGrouping.ts new file mode 100644 index 0000000..ce5969f --- /dev/null +++ b/frontend/src/src/composables/useMessageGrouping.ts @@ -0,0 +1,112 @@ +import { computed, type Ref } from 'vue'; +import type { Agent } from './agents'; + +export function useMessageGrouping( + messages: Ref, + visibleCount: Ref, + selectedAgent: Ref, + allAgents: Ref, +) { + const VISIBLE_PAGE = 50; + + const visibleMsgs = computed(() => messages.value.slice(-visibleCount.value)); + const hasMore = computed(() => messages.value.length > visibleCount.value); + + function loadMore() { + visibleCount.value += VISIBLE_PAGE; + } + + function getFormattedAgentName(agentId: string | null): string { + if (!agentId) return 'Unknown'; + const agent = allAgents.value.find(a => a.id === agentId); + return agent ? agent.name : agentId; + } + + function shouldShowHeadline(index: number, msgsArr: any[]): boolean { + if (index === 0) return true; + const current = msgsArr[index]; + const prev = msgsArr[index - 1]; + + // Ignore transitions to/from null agentId (user messages) + // Only show headlines for actual agent switches or session changes + if (!current.agentId || !prev.agentId) { + return current.sessionId !== prev.sessionId; + } + + return current.agentId !== prev.agentId || current.sessionId !== prev.sessionId; + } + + function getHeadline(index: number, msgsArr: any[]): { text: string; kind: 'agent' | 'new-session' } { + const current = msgsArr[index]; + const targetAgentId = current.agentId || selectedAgent.value; + const agentName = getFormattedAgentName(targetAgentId); + + if (index === 0) return { text: agentName, kind: 'agent' }; + const prev = msgsArr[index - 1]; + if (current.agentId !== prev.agentId) return { text: agentName, kind: 'agent' }; + if (current.sessionId !== prev.sessionId) return { text: 'New Session', kind: 'new-session' }; + return { text: agentName, kind: 'agent' }; + } + + const groupedVisibleMsgs = computed(() => { + const raw = visibleMsgs.value; + const result: any[] = []; + let currentGroup: any = null; + + for (let i = 0; i < raw.length; i++) { + const msg = raw[i]; + + if (shouldShowHeadline(i, raw)) { + if (currentGroup) { result.push(currentGroup); currentGroup = null; } + const { text, kind } = getHeadline(i, raw); + result.push({ + role: 'system', + type: 'headline', + content: text, + headlineKind: kind, + agentId: msg.agentId, + sessionId: msg.sessionId, + position: 'header', // Header appears before agent block + }); + } + + if (msg.role === 'system' && msg.type !== 'no_session') { + if (!currentGroup) { + currentGroup = { role: 'system_group', messages: [msg], agentId: msg.agentId, sessionId: msg.sessionId }; + } else { + currentGroup.messages.push(msg); + } + } else { + if (currentGroup) { result.push(currentGroup); currentGroup = null; } + result.push(msg); + + // Check if this is the last message from this agent before a switch or end + const effectiveAgentId = msg.agentId || selectedAgent.value; + // Footer headline: only once, at the very end of the list + if (effectiveAgentId && i === raw.length - 1) { + const agentName = getFormattedAgentName(effectiveAgentId); + result.push({ + role: 'system', + type: 'headline', + content: agentName, + headlineKind: 'agent', + agentId: effectiveAgentId, + sessionId: msg.sessionId, + position: 'footer', + }); + } + } + } + + if (currentGroup) result.push(currentGroup); + return result; + }); + + return { + visibleMsgs, + groupedVisibleMsgs, + hasMore, + loadMore, + getFormattedAgentName, + }; +} diff --git a/frontend/src/src/composables/useMessages.ts b/frontend/src/src/composables/useMessages.ts new file mode 100644 index 0000000..db375a3 --- /dev/null +++ b/frontend/src/src/composables/useMessages.ts @@ -0,0 +1,158 @@ +import { ref, nextTick, computed } from 'vue'; +import { marked } from 'marked'; +import { useChatStore } from '../store/chat'; + +interface MessagePayload { + type: 'message'; + content: string; + [key: string]: any; +} + +const renderer = new marked.Renderer(); +renderer.link = ({ href, title, text }) => { + const titleAttr = title ? ` title="${title}"` : ''; + return `${text}`; +}; + +function ansiToHtml(text: string): string { + const colorMap: Record = { + 30: '#555', 31: '#e06c75', 32: '#98c379', 33: '#e5c07b', + 34: '#61afef', 35: '#c678dd', 36: '#56b6c2', 37: '#abb2bf', + }; + let open = false, bold = false, dim = false; + const result = text.replace(/\x1b\[([0-9;]*)m/g, (_match, codes: string) => { + const parts = codes.split(';').map(Number); + let out = ''; + for (const code of parts) { + if (code === 0) { + if (open) { out += ''; open = false; } + if (bold) { out += ''; bold = false; } + if (dim) { out += ''; dim = false; } + } else if (code === 1) { + if (!bold) { out += ''; bold = true; } + } else if (code === 2) { + if (!dim) { out += ''; dim = true; } + } else if (colorMap[code]) { + if (open) { out += ''; } + out += ``; + open = true; + } + } + return out; + }); + let tail = ''; + if (open) tail += ''; + if (bold) tail += ''; + if (dim) tail += ''; + return result + tail; +} + +export function parseMd(content: string | undefined): string { + const raw = content || ''; + if (/\x1b\[/.test(raw)) { + const escaped = raw.replace(/&/g, '&').replace(//g, '>'); + return `
${ansiToHtml(escaped)}
`; + } + return marked.parse(raw, { renderer, async: false, gfm: true, breaks: true }) as string; +} + +const DRAFT_KEY = 'chat_draft'; +const HISTORY_KEY = 'chat_input_history'; +const HISTORY_MAX = 50; + +function loadDraft(): string { + try { return sessionStorage.getItem(DRAFT_KEY) || ''; } catch { return ''; } +} +function saveDraft(v: string) { + try { if (v) sessionStorage.setItem(DRAFT_KEY, v); else sessionStorage.removeItem(DRAFT_KEY); } catch {} +} +function loadHistory(): string[] { + try { return JSON.parse(sessionStorage.getItem(HISTORY_KEY) || '[]'); } catch { return []; } +} +function pushHistory(v: string) { + try { + const h = loadHistory().filter(x => x !== v); + h.unshift(v); + sessionStorage.setItem(HISTORY_KEY, JSON.stringify(h.slice(0, HISTORY_MAX))); + } catch {} +} + +export function useMessages(wsSendFn: (payload: MessagePayload) => void) { + const store = useChatStore(); + const sending = ref(false); + const input = ref(loadDraft()); + const messagesEl = ref(null); + let historyIdx = -1; + + function isNearBottom(): boolean { + const el = messagesEl.value; + if (!el) return true; + return el.scrollHeight - el.scrollTop - el.clientHeight < 80; + } + + function scrollToBottom(): void { + nextTick(() => { + if (messagesEl.value) { + messagesEl.value.scrollTop = messagesEl.value.scrollHeight; + } + }); + } + + function scrollIfAtBottom(): void { + if (isNearBottom()) scrollToBottom(); + } + + // Persist draft on every keystroke (throttled via watch) + let draftTimer: ReturnType | null = null; + function onInputChange() { + if (draftTimer) clearTimeout(draftTimer); + draftTimer = setTimeout(() => saveDraft(input.value), 1000); + } + + // Arrow-up/down history navigation — call from keydown handler + function navigateHistory(dir: 'up' | 'down') { + const history = loadHistory(); + if (!history.length) return; + if (dir === 'up') { + historyIdx = Math.min(historyIdx + 1, history.length - 1); + } else { + historyIdx = Math.max(historyIdx - 1, -1); + } + input.value = historyIdx === -1 ? '' : history[historyIdx]; + } + + let lastSentContent = ''; + function restoreLastSent() { input.value = lastSentContent; } + + async function send(): Promise { + if (!input.value.trim() || sending.value) return; + const content = input.value.trim(); + lastSentContent = content; + pushHistory(content); + historyIdx = -1; + input.value = ''; + saveDraft(''); + sending.value = true; + + store.pushMessage({ + role: 'user' as const, + content, + agentId: null + }); + scrollToBottom(); + + wsSendFn({ type: 'message', content }); + sending.value = false; + } + + return { + sending, input, messagesEl, + parseMd, scrollToBottom, scrollIfAtBottom, send, onInputChange, navigateHistory, restoreLastSent, + startNewAssistantMessage: store.startNewAssistantMessage, + appendAssistantMessage: store.appendAssistantDelta, + finalizeAssistantMessage: store.finalizeAssistantMessage, + resetAssistantMessageState: store.resetLocalSession, + hasActiveStreamingMessage: store.hasActiveStreamingMessage, + streamingMessageVisibleContent: computed(() => store.streamingMessageVisibleContent) + }; +} diff --git a/frontend/src/src/composables/useTheme.ts b/frontend/src/src/composables/useTheme.ts new file mode 100644 index 0000000..646dae1 --- /dev/null +++ b/frontend/src/src/composables/useTheme.ts @@ -0,0 +1,43 @@ +import { ref, watch } from 'vue'; + +export type Theme = 'titan' | 'eras'; + +const STORAGE_KEY = 'webchat_theme'; + +export const THEME_LOGOS: Record = { + titan: null, + eras: 'https://develop.eras-online.de/images/logos/eras2_customer_logo.png?v=2.4.7.36', +}; + +// Map agent id → theme (unlisted agents default to 'titan') +export const AGENT_THEME_MAP: Record = { + eras: 'eras', +}; + +export function agentLogo(agentId: string): string | null { + const t = AGENT_THEME_MAP[agentId] ?? 'titan'; + return THEME_LOGOS[t]; +} + +const theme = ref((localStorage.getItem(STORAGE_KEY) as Theme) || 'titan'); + +function applyTheme(t: Theme) { + if (t === 'titan') { + document.documentElement.removeAttribute('data-theme'); + } else { + document.documentElement.setAttribute('data-theme', t); + } +} + +// Apply on init +applyTheme(theme.value); + +watch(theme, (t) => { + applyTheme(t); + localStorage.setItem(STORAGE_KEY, t); +}); + +export function useTheme() { + function setTheme(t: Theme) { theme.value = t; } + return { theme, setTheme }; +} diff --git a/frontend/src/src/composables/ws.ts b/frontend/src/src/composables/ws.ts new file mode 100644 index 0000000..dff49ee --- /dev/null +++ b/frontend/src/src/composables/ws.ts @@ -0,0 +1,170 @@ +import { ref, type Ref } from 'vue'; + +interface MessagePayload { + type: string; + agent?: string; + token?: string; + user?: string; + [key: string]: any; // Allow other properties +} + +export function useWebSocket() { + const connected: Ref = ref(false); + const status: Ref = ref('Disconnected'); + const currentUser: Ref = ref(''); + const sessionId: Ref = ref(null); + const isInitialLoad: Ref = ref(true); + + let ws: WebSocket | null = null; + const onMessageCallbacks: ((data: any) => void)[] = []; + const messageBuffer: any[] = []; + let reconnectTimer: ReturnType | null = null; + let reconnectDelay = 1000; + let pingInterval: ReturnType | null = null; + let selectedAgentRef: Ref | null = null; + let isLoggedInRef_: Ref | null = null; + let loginErrorRef_: Ref | null = null; + + function onMessage(fn: (data: any) => void): () => void { + onMessageCallbacks.push(fn); + return () => { + const i = onMessageCallbacks.indexOf(fn); + if (i !== -1) onMessageCallbacks.splice(i, 1); + }; + } + + // Called by late-mounting views to replay buffered messages they missed + function replayBuffer(fn: (data: any) => void): void { + messageBuffer.forEach(data => fn(data)); + } + + function getWsUrl(): string { + // In production builds, VITE_WS_URL is set to wss://chat.jqxp.org + if (import.meta.env.VITE_WS_URL) { + return import.meta.env.VITE_WS_URL as string; + } + // Dev: derive from current page URL (supports ?ws= and ?port= overrides) + const params = new URLSearchParams(window.location.search); + const wsHost = params.get('ws') || window.location.hostname; + const wsPort = params.get('port') || window.location.port; + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${protocol}//${wsHost}:${wsPort}/ws`; + } + + function scheduleReconnect() { + if (reconnectTimer) return; + if (!isLoggedInRef_?.value) return; + status.value = `Reconnecting in ${Math.round(reconnectDelay / 1000)}s…`; + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (isLoggedInRef_?.value) connect(selectedAgentRef!, isLoggedInRef_!, loginErrorRef_!); + }, reconnectDelay); + reconnectDelay = Math.min(reconnectDelay * 2, 16000); + } + + function connect( + selectedAgent: Ref, + isLoggedInRef: Ref, + loginErrorRef: Ref + ): void { + if (ws && ws.readyState <= WebSocket.OPEN) return; + // Store refs for auto-reconnect + selectedAgentRef = selectedAgent; + isLoggedInRef_ = isLoggedInRef; + loginErrorRef_ = loginErrorRef; + reconnectDelay = 1000; // reset on explicit connect + console.log("WS CONNECT attempt, ws state:", ws?.readyState, "url:", getWsUrl()); + const wsUrl = getWsUrl(); + if (isInitialLoad.value) { + status.value = 'Connecting...'; + isInitialLoad.value = false; + } + + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + reconnectDelay = 1000; // reset backoff on successful open + const token = localStorage.getItem('openclaw_session') || localStorage.getItem('titan_token'); + ws?.send(JSON.stringify( + token + ? { type: 'auth', agent: selectedAgent.value, token } + : { type: 'connect', agent: selectedAgent.value, user: 'nico' } + )); + // Keepalive: ping every 30s to prevent Cloudflare idle disconnect + if (pingInterval) clearInterval(pingInterval); + pingInterval = setInterval(() => { + if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' })); + }, 30000); + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + // console.log("WS Received:", data); // Log all incoming messages + if (data.type === 'error' && data.code === 'SESSION_TERMINATED') { + console.warn("Message bounced: Session terminated."); + } else if (data.type === 'diagnostic') { // Handle diagnostic messages + const logMethod = console[data.level] || console.log; + logMethod(`Backend Diagnostic (${data.level.toUpperCase()}):`, data.message); + } + messageBuffer.push(data); + onMessageCallbacks.forEach(fn => fn(data)); + } catch (e) { + console.error('Parse error:', e); + } + }; + + ws.onclose = (e) => { + if (pingInterval) { clearInterval(pingInterval); pingInterval = null; } + connected.value = false; + messageBuffer.length = 0; // clear stale events — new session will replay fresh history + if (e.code === 4001) { + // Explicit auth rejection — log out + isLoggedInRef.value = false; + loginErrorRef.value = 'Invalid token. Please log in again.'; + localStorage.removeItem('openclaw_session'); + localStorage.removeItem('titan_token'); + status.value = 'Logged out'; + } else { + // Transient disconnect — auto-reconnect + scheduleReconnect(); + } + }; + + ws.onerror = () => { + // onerror is always followed by onclose — reconnect handled there + }; + } + + function disconnect(): void { + if (pingInterval) { clearInterval(pingInterval); pingInterval = null; } + if (ws) { + ws.onclose = null; // Prevent onclose from being called again + ws.close(); + ws = null; + } + connected.value = false; + status.value = 'Disconnected'; + currentUser.value = ''; + sessionId.value = null; + isInitialLoad.value = true; + } + + function send(payload: MessagePayload): void { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(payload)); + } + + function switchAgent(agentId: string): void { + messageBuffer.length = 0; // clear stale messages from previous agent + send({ type: 'switch_agent', agent: agentId }); + } + + function clearBuffer(): void { + messageBuffer.length = 0; + } + + return { + connected, status, currentUser, sessionId, + connect, disconnect, send, switchAgent, clearBuffer, onMessage, replayBuffer + }; +} diff --git a/frontend/src/src/main.ts b/frontend/src/src/main.ts new file mode 100644 index 0000000..f77dbb3 --- /dev/null +++ b/frontend/src/src/main.ts @@ -0,0 +1,9 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; +import router from './router'; + +const app = createApp(App); +app.use(createPinia()); +app.use(router); +app.mount('#app'); diff --git a/frontend/src/src/router.ts b/frontend/src/src/router.ts new file mode 100644 index 0000000..cc4db06 --- /dev/null +++ b/frontend/src/src/router.ts @@ -0,0 +1,25 @@ +import { createRouter, createWebHashHistory } from 'vue-router'; +import HomeView from './views/HomeView.vue'; +import LoginView from './views/LoginView.vue'; +import AgentsView from './views/AgentsView.vue'; +import DevView from './views/DevView.vue'; +import ViewerView from './views/ViewerView.vue'; + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { path: '/', name: 'home', component: HomeView, meta: { title: 'Titan - Home' } }, + { path: '/login', name: 'login', component: LoginView, meta: { title: 'Titan - Login' } }, + { path: '/agents', name: 'agents', component: AgentsView, meta: { title: 'Titan - Home', requiresSocket: true } }, + { path: '/chat', redirect: '/agents' }, + { path: '/dev', name: 'dev', component: DevView, meta: { title: 'Titan - Dev', requiresSocket: true } }, + { path: '/viewer', name: 'viewer', component: ViewerView, meta: { title: 'Titan - Viewer', requiresSocket: true } }, + { path: '/:pathMatch(.*)*', redirect: '/' }, + ], +}); + +router.afterEach((to) => { + document.title = (to.meta?.title as string) || 'Titan'; +}); + +export default router; diff --git a/frontend/src/src/shims-vue.d.ts b/frontend/src/src/shims-vue.d.ts new file mode 100644 index 0000000..4ca75b5 --- /dev/null +++ b/frontend/src/src/shims-vue.d.ts @@ -0,0 +1,5 @@ +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} \ No newline at end of file diff --git a/frontend/src/src/store.ts b/frontend/src/src/store.ts new file mode 100644 index 0000000..5360e94 --- /dev/null +++ b/frontend/src/src/store.ts @@ -0,0 +1,35 @@ +/** + * store.ts — Shared singleton state + * + * Singleton composables that need a single instance across all views. + * All chat/session state lives in store/chat.ts (Pinia). + */ + +import { ref, reactive } from 'vue'; +import { useWebSocket } from './composables/ws'; +import { useAgents } from './composables/agents'; +import { useAuth } from './composables/auth'; +import { useViewerStore } from './store/viewer'; + +// Viewer last-selected path — reactive singleton so App.vue nav link stays current +export const lastViewerPath = ref(localStorage.getItem('viewer_last_path') || ''); + +// Viewer tree open-state — Set of paths that are currently expanded +// In-memory only; survives tab switches, resets on full page reload +export const viewerOpenDirs = reactive(new Set()); + +// WebSocket — single connection shared across all views +export const ws = useWebSocket(); + +// Agents — depends on ws.connected +export const agents = useAgents(ws.connected); + +// Auth — on login, connect WS + pre-warm viewer fstoken +export const auth = useAuth(() => { + ws.connect(agents.selectedAgent, auth.isLoggedIn, auth.loginError); + // Pre-warm viewer token in background so agent→viewer is instant + useViewerStore().acquire(); +}); + +// Re-export viewer store accessor for use in views +export { useViewerStore }; diff --git a/frontend/src/src/store/chat.ts b/frontend/src/src/store/chat.ts new file mode 100644 index 0000000..a1bad4a --- /dev/null +++ b/frontend/src/src/store/chat.ts @@ -0,0 +1,368 @@ +import { defineStore } from 'pinia'; +import { ref, computed, nextTick } from 'vue'; + +export type SmState = 'CONNECTING' | 'IDLE' | 'AGENT_RUNNING' | 'HANDOVER_PENDING' | 'HANDOVER_DONE' | 'SWITCHING' | 'STOP_PENDING' | 'NO_SESSION'; + +export interface FinanceData { + nextTurnFloor: number; + projectionDelta: number; + currentContextTokens: number; + lastTurnCost: number; + pricing: { prompt: number, completion: number }; +} + +export const useChatStore = defineStore('chat', () => { + // --- State --- + const messages = ref([]); + // smState is private — write only via applySessionState() or setConnecting() + const smState = ref('CONNECTING'); + const truncatedWarning = ref(false); + const localSessionId = ref(Math.random().toString(36).slice(2, 10)); + const sessionTotalTokens = ref<{ input_tokens: number; cache_read_tokens: number; output_tokens: number } | null>(null); + const sessionUsage = ref(null); + const finance = ref(null); + + const sessionCost = computed(() => { + const p = finance.value?.pricing; + const u = sessionTotalTokens.value; + if (!p || !u) return 0; + return ((u.input_tokens || 0) * p.prompt + (u.output_tokens || 0) * p.completion) / 1_000_000; + }); + + // Thought queue — 1 slot, replaced on each new queued send + const queuedThought = ref(null); + + // Active turn correlation id — set by sessionHistory, stamped onto assistant messages + const activeTurnCorrId = ref(null); + + // True when a handover confirm bubble is waiting for user action + const handoverPending = computed(() => + messages.value.some((m: any) => m.confirmNew && !m.confirmed) + ); + + const isRunning = computed(() => + smState.value === 'AGENT_RUNNING' || + smState.value === 'STOP_PENDING' || + smState.value === 'HANDOVER_PENDING' + ); + + // WS send fn — injected at app init by useAgentSocket/App.vue + let _wsSend: ((payload: any) => void) | null = null; + function setWsSend(fn: (payload: any) => void) { _wsSend = fn; } + + // Session actions (called from HudControls / HudRow) + function newSession() { + resetLocalSession(); + _wsSend?.({ type: 'new' }); + } + + function handover() { + _wsSend?.({ type: 'handover_request' }); + } + + function stop() { + if (smState.value !== 'AGENT_RUNNING' && smState.value !== 'STOP_PENDING') return; + queuedThought.value = null; + smState.value = 'STOP_PENDING'; + pushSystem('Stopping after current turn…'); + _wsSend?.({ type: 'stop' }); + } + + function confirmNew() { + const bubble = [...messages.value].reverse().find((m: any) => m.confirmNew && !m.confirmed); + if (bubble) bubble.confirmed = true; + resetLocalSession(); + _wsSend?.({ type: 'new' }); + } + + function stay() { + const bubble = [...messages.value].reverse().find((m: any) => m.confirmNew && !m.confirmed); + if (bubble) { + bubble.confirmed = true; + setTimeout(() => { + const idx = messages.value.findIndex((m: any) => m === bubble); + if (idx !== -1) messages.value.splice(idx, 1); + }, 1000); + } + _wsSend?.({ type: 'cancel_handover' }); + // smState will be set by the backend's session_state:IDLE after cancel_handover + } + + // --- SM write gatekeepers (only these may write smState) --- + // Called by useAgentSocket session_state handler (authoritative path) + function applySessionState(state: SmState) { + smState.value = state; + if (state === 'AGENT_RUNNING') sessionContextHint.value = ''; + } + // Called by App.vue on WS disconnect (pre-auth state) + function setConnecting() { + smState.value = 'CONNECTING'; + } + + // Streaming state + let currentAssistantMessageIndex = -1; + const streamingMessageVisibleContent = ref(''); + let streamingMessageFullContent = ''; + let streamingInterval: ReturnType | null = null; + let charIndex = 0; + let pendingVisibleClear = false; // guards deferred nextTick clear + + // Thinking state + let thinkingMessageIndex = -1; + const thinkingContent = ref(''); + const sessionContextHint = ref(''); + const TYPING_TICK_MS = 10; + + // --- Getters --- + const smLabel = computed(() => { + switch (smState.value) { + case 'AGENT_RUNNING': return '⚙️ Working…'; + case 'HANDOVER_PENDING': return '📝 Handover…'; + case 'HANDOVER_DONE': return '✅ Handover ready'; + case 'SWITCHING': return '🔀 Switching…'; + case 'CONNECTING': return '⏳ Connecting…'; + case 'STOP_PENDING': return '⛔ Stopping…'; + case 'IDLE': return sessionContextHint.value ? `✓ Ready · ${sessionContextHint.value}` : '✓ Ready'; + case 'NO_SESSION': return '⚠️ No session'; + default: return '✓ Ready'; + } + }); + + const visibleMsgs = (visibleCount: number) => { + return messages.value.slice(-visibleCount); + }; + + // --- Actions --- + function resetLocalSession() { + localSessionId.value = Math.random().toString(36).slice(2, 10); + console.log('[ChatStore] Local Session Reset:', localSessionId.value); + resetStreamingState(); + } + + function clearMessages() { + messages.value = []; + sessionTotalTokens.value = null; + finance.value = null; + resetStreamingState(); + } + + function pushMessage(msg: any) { + const index = hasActiveStreamingMessage() ? currentAssistantMessageIndex : messages.value.length; + const msgWithId = { + ...msg, + sessionId: localSessionId.value + }; + + if (hasActiveStreamingMessage() && msg.role === 'user') { + messages.value.splice(currentAssistantMessageIndex, 0, msgWithId); + currentAssistantMessageIndex++; + } else { + messages.value.push(msgWithId); + } + } + + function pushSystem(text: string, agentId?: string) { + const msg = { + role: 'system', + content: text, + agentId: agentId || null, + sessionId: localSessionId.value + }; + if (hasActiveStreamingMessage()) { + messages.value.splice(currentAssistantMessageIndex, 0, msg); + currentAssistantMessageIndex++; + } else { + messages.value.push(msg); + } + } + + // --- Streaming Actions --- + function resetStreamingState() { + if (streamingInterval !== null) clearInterval(streamingInterval); + streamingInterval = null; + currentAssistantMessageIndex = -1; + streamingMessageFullContent = ''; + charIndex = 0; + // Defer clearing visible content until after Vue renders msg.content, + // preventing a blank frame. The flag lets a new stream cancel the clear. + pendingVisibleClear = true; + nextTick(() => { if (pendingVisibleClear) { streamingMessageVisibleContent.value = ''; pendingVisibleClear = false; } }); + } + + function startNewAssistantMessage(agentId?: string) { + if (currentAssistantMessageIndex === -1 || !messages.value[currentAssistantMessageIndex]?.streaming) { + pendingVisibleClear = false; // cancel any deferred clear from previous message + resetStreamingState(); + messages.value.push({ + role: 'assistant', + content: '', + fullContent: '', + usage: null, + streaming: true, + agentId: agentId ?? null, + sessionId: localSessionId.value, + turnCorrId: activeTurnCorrId.value ?? null, + }); + currentAssistantMessageIndex = messages.value.length - 1; + } + } + + function appendAssistantDelta(delta: string, agentId?: string) { + if (currentAssistantMessageIndex !== -1 && messages.value[currentAssistantMessageIndex]?.streaming) { + streamingMessageFullContent += delta; + messages.value[currentAssistantMessageIndex].fullContent = streamingMessageFullContent; + if (!streamingInterval) startTypewriter(); + } else { + startNewAssistantMessage(agentId); + appendAssistantDelta(delta, agentId); + } + } + + function startTypewriter() { + if (streamingInterval !== null) clearInterval(streamingInterval); + streamingInterval = setInterval(() => { + const backlog = streamingMessageFullContent.length - charIndex; + if (backlog <= 0) { + console.log('[hermes] typewriter done: charIndex=%d fullLen=%d visibleLen=%d streaming=%s', + charIndex, streamingMessageFullContent.length, + streamingMessageVisibleContent.value.length, + messages.value[currentAssistantMessageIndex]?.streaming); + if (streamingInterval !== null) clearInterval(streamingInterval); + streamingInterval = null; + return; + } + const charsThisTick = backlog >= 200 ? 10 : backlog >= 50 ? 4 : 1; + const end = Math.min(charIndex + charsThisTick, streamingMessageFullContent.length); + streamingMessageVisibleContent.value += streamingMessageFullContent.slice(charIndex, end); + charIndex = end; + }, TYPING_TICK_MS); + } + + function finalizeAssistantMessage(content?: string | null, usage?: any, truncated = false) { + if (currentAssistantMessageIndex !== -1 && messages.value[currentAssistantMessageIndex]?.streaming) { + const msg = messages.value[currentAssistantMessageIndex]; + const finalContent = ((content !== null && content !== undefined) ? content : streamingMessageFullContent) + .replace(/\s*NO_REPLY\s*$/g, '').trim(); + console.log('[hermes] finalizeAssistantMessage: charIndex=%d fullLen=%d visibleLen=%d finalLen=%d', + charIndex, streamingMessageFullContent.length, + streamingMessageVisibleContent.value.length, finalContent.length); + // Flush typewriter to end synchronously — prevents visible truncation + // when done arrives while the typewriter interval is still mid-run + if (streamingInterval !== null) { + clearInterval(streamingInterval); + streamingInterval = null; + } + streamingMessageVisibleContent.value = finalContent; + charIndex = finalContent.length; + msg.content = finalContent; + msg.fullContent = finalContent; + if (usage) msg.usage = usage; + if (truncated) msg.truncated = true; + msg.streaming = false; + console.log('[hermes] post-finalize: msg.content.length=%d msg.streaming=%s idx=%d', + msg.content.length, msg.streaming, currentAssistantMessageIndex); + resetStreamingState(); + } + } + + function appendThinking(content: string) { + if (!content) return; + thinkingContent.value += content; + if (thinkingMessageIndex === -1) { + messages.value.push({ role: 'thinking', content: thinkingContent, collapsed: false }); + thinkingMessageIndex = messages.value.length - 1; + } + } + + function collapseThinking() { + if (thinkingMessageIndex !== -1 && messages.value[thinkingMessageIndex]) { + messages.value[thinkingMessageIndex].collapsed = true; + } + thinkingMessageIndex = -1; + thinkingContent.value = ''; + } + + function createCompleteAssistantMessage(content: string, agentId?: string, usage?: any) { + // If there's an active streaming message, finalize it instead of creating a new one + if (hasActiveStreamingMessage()) { + finalizeAssistantMessage(content, usage); + return; + } + + const msg = { + role: 'assistant', + content: content.replace(/\s*NO_REPLY\s*$/g, '').trim(), + fullContent: content.replace(/\s*NO_REPLY\s*$/g, '').trim(), + usage: usage || null, + streaming: false, + agentId: agentId ?? null, + sessionId: localSessionId.value, + turnCorrId: activeTurnCorrId.value ?? null, + }; + messages.value.push(msg); + } + + function hasActiveStreamingMessage() { + return currentAssistantMessageIndex !== -1 && messages.value[currentAssistantMessageIndex]?.streaming; + } + + function streamingMessageLength() { + return streamingMessageFullContent.length; + } + + /** + * Suppress (remove) the active streaming assistant message entirely. + * Used for NO_REPLY turns: the agent emits NO_REPLY but partial deltas + * already leaked to the browser. We drop the bubble retroactively on done. + */ + function suppressAssistantMessage() { + if (currentAssistantMessageIndex !== -1 && messages.value[currentAssistantMessageIndex]?.streaming) { + if (streamingInterval !== null) { + clearInterval(streamingInterval); + streamingInterval = null; + } + messages.value.splice(currentAssistantMessageIndex, 1); + } + resetStreamingState(); + } + + return { + messages, + activeTurnCorrId, + smState, // read-only by convention — write via applySessionState() / setConnecting() / stop() + applySessionState, + setConnecting, + truncatedWarning, + localSessionId, + sessionTotalTokens, + sessionUsage, + sessionCost, + finance, + smLabel, + sessionContextHint, + streamingMessageVisibleContent, + visibleMsgs, + resetLocalSession, + clearMessages, + pushMessage, + pushSystem, + startNewAssistantMessage, + appendAssistantDelta, + finalizeAssistantMessage, + createCompleteAssistantMessage, + appendThinking, + collapseThinking, + hasActiveStreamingMessage, + streamingMessageLength, + suppressAssistantMessage, + queuedThought, + handoverPending, + isRunning, + setWsSend, + newSession, + handover, + stop, + confirmNew, + stay, + }; +}); diff --git a/frontend/src/src/store/viewer.ts b/frontend/src/src/store/viewer.ts new file mode 100644 index 0000000..91c7a54 --- /dev/null +++ b/frontend/src/src/store/viewer.ts @@ -0,0 +1,119 @@ +/** + * store/viewer.ts — Viewer singleton state (fstoken + roots) + * + * fstoken has a 1h TTL on the backend. We cache it in sessionStorage so + * F5 and agent→viewer transitions are instant (stale-while-revalidate). + * + * acquire() is idempotent: no-op if token is fresh, re-acquires if within + * REFRESH_BEFORE_EXPIRY of expiry. Safe to call from multiple places. + */ + +import { defineStore } from 'pinia'; +import { ref, computed } from 'vue'; + +const STORAGE_KEY = 'viewer_auth'; +const REFRESH_BEFORE_EXPIRY_MS = 5 * 60 * 1000; // refresh if <5min left + +interface ViewerAuth { + fstoken: string; + roots: string[]; + expiresAt: number; // ms epoch, client-side estimate (TTL - 1h) +} + +function getApiBase(): string { + return (import.meta.env.VITE_API_URL as string) || ''; +} + +function loadStored(): ViewerAuth | null { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return null; + return JSON.parse(raw) as ViewerAuth; + } catch { + return null; + } +} + +function saveStored(auth: ViewerAuth) { + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(auth)); + } catch {} +} + +function clearStored() { + sessionStorage.removeItem(STORAGE_KEY); +} + +export const useViewerStore = defineStore('viewer', () => { + const stored = loadStored(); + + const fstoken = ref(stored?.fstoken ?? ''); + const roots = ref(stored?.roots ?? []); + const expiresAt = ref(stored?.expiresAt ?? 0); + + // true once we have a valid (non-expired) token + const ready = computed(() => !!fstoken.value && Date.now() < expiresAt.value); + + let acquiring: Promise | null = null; + + async function acquire(force = false): Promise { + // Skip if token is fresh (more than REFRESH_BEFORE_EXPIRY_MS left) + if (!force && fstoken.value && Date.now() < expiresAt.value - REFRESH_BEFORE_EXPIRY_MS) return; + + // Deduplicate concurrent calls + if (acquiring) return acquiring; + + acquiring = _acquire().finally(() => { acquiring = null; }); + return acquiring; + } + + async function _acquire(): Promise { + const sessionToken = localStorage.getItem('openclaw_session') || localStorage.getItem('titan_token') || ''; + if (!sessionToken) return; + + try { + const res = await fetch(`${getApiBase()}/api/viewer/token`, { + method: 'POST', + headers: { Authorization: `Bearer ${sessionToken}` }, + }); + if (!res.ok) { + // token rejected — clear stale state + fstoken.value = ''; + roots.value = []; + expiresAt.value = 0; + clearStored(); + return; + } + const data = await res.json(); + const newToken: string = data.fstoken; + + // Fetch roots while we have the token + let newRoots: string[] = roots.value.length ? roots.value : ['shared', 'workspace-titan']; + try { + const tr = await fetch(`${getApiBase()}/api/viewer/tree?root=&token=${encodeURIComponent(newToken)}`); + if (tr.ok) { + const td = await tr.json(); + if (Array.isArray(td.dirs) && td.dirs.length) newRoots = td.dirs; + } + } catch {} + + // Client-side expiry estimate: backend TTL is 1h + const newExpiresAt = Date.now() + 60 * 60 * 1000; + + fstoken.value = newToken; + roots.value = newRoots; + expiresAt.value = newExpiresAt; + + saveStored({ fstoken: newToken, roots: newRoots, expiresAt: newExpiresAt }); + } catch {} + } + + function invalidate() { + fstoken.value = ''; + roots.value = []; + expiresAt.value = 0; + clearStored(); + } + + return { fstoken, roots, ready, acquire, invalidate }; +}); diff --git a/frontend/src/src/views/AgentsView.vue b/frontend/src/src/views/AgentsView.vue new file mode 100644 index 0000000..2b7dd8b --- /dev/null +++ b/frontend/src/src/views/AgentsView.vue @@ -0,0 +1,334 @@ + + + + + diff --git a/frontend/src/src/views/DevView.vue b/frontend/src/src/views/DevView.vue new file mode 100644 index 0000000..131c97d --- /dev/null +++ b/frontend/src/src/views/DevView.vue @@ -0,0 +1,179 @@ + + + + + diff --git a/frontend/src/src/views/HomeView.vue b/frontend/src/src/views/HomeView.vue new file mode 100644 index 0000000..f9eef81 --- /dev/null +++ b/frontend/src/src/views/HomeView.vue @@ -0,0 +1,19 @@ + + + diff --git a/frontend/src/src/views/LoginView.vue b/frontend/src/src/views/LoginView.vue new file mode 100644 index 0000000..e0199db --- /dev/null +++ b/frontend/src/src/views/LoginView.vue @@ -0,0 +1,37 @@ + + + diff --git a/frontend/src/src/views/ViewerView.vue b/frontend/src/src/views/ViewerView.vue new file mode 100644 index 0000000..75e1ce0 --- /dev/null +++ b/frontend/src/src/views/ViewerView.vue @@ -0,0 +1,470 @@ +