v0.6.42: Hermes chat UI — Vue3/TS/Vite, audio STT/TTS, sidebar rail, MCP event loop
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
ccee249618
121
README.md
Normal file
121
README.md
Normal file
@ -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
|
||||||
4
backend/.env
Normal file
4
backend/.env
Normal file
@ -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
|
||||||
5
backend/.gitignore
vendored
Normal file
5
backend/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.session-tokens.json
|
||||||
|
.session-tokens-dev.json
|
||||||
|
.session-tokens-prod.json
|
||||||
|
.takeover-tokens.json
|
||||||
|
.mcp-keys.json
|
||||||
214
backend/README.md
Normal file
214
backend/README.md
Normal file
@ -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)|
|
||||||
18
backend/agents.json
Normal file
18
backend/agents.json
Normal file
@ -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"] }
|
||||||
|
}
|
||||||
248
backend/auth.ts
Normal file
248
backend/auth.ts
Normal file
@ -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<string, { user: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
function persistSessionTokens() {
|
||||||
|
try {
|
||||||
|
const obj: Record<string, { user: string; expiresAt: number }> = {};
|
||||||
|
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<string, number>(); // 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<string, { user: string; otp: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
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<string, string> = {
|
||||||
|
'nico38638': 'nico',
|
||||||
|
'tina38638': 'tina',
|
||||||
|
'test123': 'test',
|
||||||
|
'niclas38638': 'niclas',
|
||||||
|
'loona38638': 'loona',
|
||||||
|
'hendrik38638': 'hendrik',
|
||||||
|
'eras38638': 'eras',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userDefaultAgent: Record<string, string> = {
|
||||||
|
'nico': 'titan',
|
||||||
|
'tina': 'adoree',
|
||||||
|
'niclas': 'alfred',
|
||||||
|
'loona': 'ash',
|
||||||
|
'hendrik': 'willi',
|
||||||
|
'eras': 'eras',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const userViewerRoots: Record<string, string[]> = {
|
||||||
|
'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<string, AgentMeta> {
|
||||||
|
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<string, string[]> = new Proxy({} as Record<string, string[]>, {
|
||||||
|
get(_, user: string) { return getUserAllowedAgents(user); },
|
||||||
|
});
|
||||||
195
backend/bun.lock
Normal file
195
backend/bun.lock
Normal file
@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/gateway-ca.crt
Normal file
19
backend/gateway-ca.crt
Normal file
@ -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-----
|
||||||
581
backend/gateway.ts
Normal file
581
backend/gateway.ts
Normal file
@ -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<string, {
|
||||||
|
resolve: (v: unknown) => void;
|
||||||
|
reject: (e: Error) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// browserSessions ref — set by server.ts after init
|
||||||
|
let browserSessions: Map<any, any> | null = null;
|
||||||
|
export function setBrowserSessions(map: Map<any, any>) { 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<string, unknown> | 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<void> {
|
||||||
|
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<string, unknown>): Promise<unknown> {
|
||||||
|
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<void> {
|
||||||
|
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<string, any>(); // 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<string, any>;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
263
backend/hud-builder.ts
Normal file
263
backend/hud-builder.ts
Normal file
@ -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<string, any>,
|
||||||
|
raw: string,
|
||||||
|
): Record<string, any> {
|
||||||
|
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<string, any>,
|
||||||
|
rawResult: string | null,
|
||||||
|
): Record<string, any> {
|
||||||
|
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<string, any> {
|
||||||
|
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<string, any> {
|
||||||
|
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<string, any> {
|
||||||
|
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<string, any> {
|
||||||
|
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<string, any> = {};
|
||||||
|
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<string, any>;
|
||||||
|
result?: Record<string, any>;
|
||||||
|
durationMs?: number;
|
||||||
|
subtype?: ReceivedSubtype;
|
||||||
|
label?: string;
|
||||||
|
payload?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeHudEvent(event: HudEventKind, extra: Partial<HudEvent> = {}): 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<string, any>, 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<string, any>, 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<string, any>, replay = false): HudEvent =>
|
||||||
|
makeHudEvent('received', { subtype, label, ...(payload ? { payload } : {}), ...(replay ? { replay } : {}) }),
|
||||||
|
};
|
||||||
299
backend/mcp/deck.ts
Normal file
299
backend/mcp/deck.ts
Normal file
@ -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<string, string> = {
|
||||||
|
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<any> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
backend/mcp/dev.ts
Normal file
141
backend/mcp/dev.ts
Normal file
@ -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<any> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
backend/mcp/events.ts
Normal file
94
backend/mcp/events.ts
Normal file
@ -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<typeof setTimeout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const queues = new Map<string, McpEvent[]>();
|
||||||
|
const waiters = new Map<string, Waiter>();
|
||||||
|
const knownKeys = new Set<string>();
|
||||||
|
|
||||||
|
const MAX_QUEUE = 100;
|
||||||
|
|
||||||
|
/** Push an event to a specific MCP key's queue */
|
||||||
|
export function pushEvent(mcpKey: string, event: Omit<McpEvent, 'timestamp'>) {
|
||||||
|
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<McpEvent | null> {
|
||||||
|
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<McpEvent, 'timestamp'>) {
|
||||||
|
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<string, any>) => void) | null = null;
|
||||||
|
export function setBroadcastFn(fn: (data: Record<string, any>) => void) { _broadcastFn = fn; }
|
||||||
|
export function broadcastToBrowsers(data: Record<string, any>) { _broadcastFn?.(data); }
|
||||||
188
backend/mcp/fs.ts
Normal file
188
backend/mcp/fs.ts
Normal file
@ -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());
|
||||||
|
}
|
||||||
147
backend/mcp/index.ts
Normal file
147
backend/mcp/index.ts
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* mcp/index.ts — MCP server setup for Hermes backend
|
||||||
|
*
|
||||||
|
* Auth: every request must include Authorization: Bearer <mcp-key>
|
||||||
|
* 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<string, McpKeyEntry> {
|
||||||
|
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<string, McpKeyEntry>) {
|
||||||
|
const obj: Record<string, McpKeyEntry> = {};
|
||||||
|
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<Response> {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
323
backend/mcp/system.ts
Normal file
323
backend/mcp/system.ts
Normal file
@ -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<string> {
|
||||||
|
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<any> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
167
backend/mcp/takeover.ts
Normal file
167
backend/mcp/takeover.ts
Normal file
@ -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<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<any> {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
backend/message-filter.ts
Normal file
93
backend/message-filter.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
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 };
|
||||||
13
backend/package.json
Normal file
13
backend/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
1463
backend/server.ts
Normal file
1463
backend/server.ts
Normal file
File diff suppressed because it is too large
Load Diff
140
backend/session-sm.ts
Normal file
140
backend/session-sm.ts
Normal file
@ -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<ChannelState, ChannelState[]> = {
|
||||||
|
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<Record<ChannelState, number>> = {
|
||||||
|
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<ConnectionState, ConnectionState[]> = {
|
||||||
|
CONNECTING: ['LOADING_HISTORY', 'SYNCED'],
|
||||||
|
LOADING_HISTORY: ['SYNCED'],
|
||||||
|
SYNCED: ['SWITCHING'],
|
||||||
|
SWITCHING: ['LOADING_HISTORY', 'SYNCED'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONNECTION_TIMEOUTS_MS: Partial<Record<ConnectionState, number>> = {
|
||||||
|
SWITCHING: 10_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Generic SM factory ──────────────────────────────────────
|
||||||
|
|
||||||
|
export interface StateMachine<S extends string> {
|
||||||
|
transition(next: S, payload?: Record<string, unknown>): boolean;
|
||||||
|
get(): S;
|
||||||
|
destroy(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSM<S extends string>(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
initial: S,
|
||||||
|
transitions: Record<S, S[]>,
|
||||||
|
timeouts: Partial<Record<S, number>>,
|
||||||
|
onStateChange: (event: Record<string, unknown>) => void,
|
||||||
|
timeoutTarget?: S,
|
||||||
|
): StateMachine<S> {
|
||||||
|
let state: S = initial;
|
||||||
|
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
function clearPendingTimeout() {
|
||||||
|
if (timeoutHandle !== null) { clearTimeout(timeoutHandle); timeoutHandle = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function transition(next: S, payload: Record<string, unknown> = {}): 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<string, unknown>) => void,
|
||||||
|
initial: ChannelState = 'NO_SESSION',
|
||||||
|
): StateMachine<ChannelState> {
|
||||||
|
return createSM(sessionKey, 'channel', initial, CHANNEL_TRANSITIONS, CHANNEL_TIMEOUTS_MS, onStateChange, 'READY');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConnectionSM(
|
||||||
|
clientId: string,
|
||||||
|
onStateChange: (event: Record<string, unknown>) => void,
|
||||||
|
): StateMachine<ConnectionState> {
|
||||||
|
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<ChannelState>;
|
||||||
573
backend/session-watcher.ts
Normal file
573
backend/session-watcher.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
export type OnEntry = (entry: WatcherEntry) => void;
|
||||||
|
export type WsSend = (data: WatcherEntry) => void;
|
||||||
|
export type GatewayRequestFn = (method: string, params: Record<string, unknown>) => Promise<unknown>;
|
||||||
|
|
||||||
|
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<typeof setTimeout> | null = null;
|
||||||
|
let filePosition = 0;
|
||||||
|
let currentJsonlPath: string | null = null;
|
||||||
|
let cumulativeTokens = { input: 0, output: 0, cacheRead: 0, total: 0 };
|
||||||
|
const suppressedIds = new Set<string>();
|
||||||
|
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<string, { tool: string; args: any; ts: number }> = 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<string>();
|
||||||
|
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
110
backend/system-access.ts
Normal file
110
backend/system-access.ts
Normal file
@ -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<string, RequestEntry>();
|
||||||
|
const tokenMap = new Map<string, SystemTokenEntry>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
311
docs/HUD-PROTOCOL.md
Normal file
311
docs/HUD-PROTOCOL.md
Normal file
@ -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": "<uuid>", "correlationId": "<turnId>", "ts": 1741995000000 }
|
||||||
|
{ "type": "hud", "event": "turn_end", "id": "<uuid>", "correlationId": "<turnId>", "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": "<uuid>", "correlationId": "<uuid-think>", "parentId": "<turnId>", "ts": 1741995000050 }
|
||||||
|
{ "type": "hud", "event": "think_end", "id": "<uuid>", "correlationId": "<uuid-think>", "parentId": "<turnId>", "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": "<uuid>",
|
||||||
|
"correlationId": "call_123f898fc88346afaec098e0",
|
||||||
|
"parentId": "<turnId>",
|
||||||
|
"tool": "read",
|
||||||
|
"args": { "path": "workspace-titan/SOUL.md" },
|
||||||
|
"ts": 1741995001000 }
|
||||||
|
|
||||||
|
{ "type": "hud", "event": "tool_end",
|
||||||
|
"id": "<uuid>",
|
||||||
|
"correlationId": "call_123f898fc88346afaec098e0",
|
||||||
|
"parentId": "<turnId>",
|
||||||
|
"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<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReceivedSubtype =
|
||||||
|
| 'new_session'
|
||||||
|
| 'agent_switch'
|
||||||
|
| 'stop'
|
||||||
|
| 'kill'
|
||||||
|
| 'handover'
|
||||||
|
| 'reconnect'
|
||||||
|
| 'message'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "hud", "event": "received", "id": "<uuid>", "subtype": "new_session",
|
||||||
|
"label": "/new received — resetting session",
|
||||||
|
"payload": { "previousAgent": "tester" }, "ts": 1741995010000 }
|
||||||
|
|
||||||
|
{ "type": "hud", "event": "received", "id": "<uuid>", "subtype": "agent_switch",
|
||||||
|
"label": "switch → titan",
|
||||||
|
"payload": { "from": "tester", "to": "titan" }, "ts": 1741995020000 }
|
||||||
|
|
||||||
|
{ "type": "hud", "event": "received", "id": "<uuid>", "subtype": "stop",
|
||||||
|
"label": "stop received — aborting turn",
|
||||||
|
"payload": { "state": "AGENT_RUNNING" }, "ts": 1741995030000 }
|
||||||
|
|
||||||
|
{ "type": "hud", "event": "received", "id": "<uuid>", "subtype": "reconnect",
|
||||||
|
"label": "reconnected — replaying history",
|
||||||
|
"payload": { "sessionKey": "agent:titan:web:nico" }, "ts": 1741995040000 }
|
||||||
|
|
||||||
|
{ "type": "hud", "event": "received", "id": "<uuid>", "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<string, any> // full, structured
|
||||||
|
result?: Record<string, any> // full, structured
|
||||||
|
startedAt: number
|
||||||
|
endedAt?: number
|
||||||
|
durationMs?: number
|
||||||
|
children: HudNode[] // tools/thinks nest inside turns
|
||||||
|
replay: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pairing logic:**
|
||||||
|
- Maintain `Map<id, HudNode>` (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<string[]>` 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)
|
||||||
1
frontend/.env.production
Normal file
1
frontend/.env.production
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_WS_URL=wss://chat.jqxp.org/ws
|
||||||
2
frontend/.gitignore
vendored
Normal file
2
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/node_modules
|
||||||
|
/dist
|
||||||
146
frontend/README.md
Normal file
146
frontend/README.md
Normal file
@ -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**
|
||||||
386
frontend/bun.lock
Normal file
386
frontend/bun.lock
Normal file
@ -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=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
232
frontend/css/base.css
Normal file
232
frontend/css/base.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
327
frontend/css/components.css
Normal file
327
frontend/css/components.css
Normal file
@ -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; }
|
||||||
|
}
|
||||||
184
frontend/css/layout.css
Normal file
184
frontend/css/layout.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
frontend/css/markdown.css
Normal file
53
frontend/css/markdown.css
Normal file
@ -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; }
|
||||||
29
frontend/css/scrollbar.css
Normal file
29
frontend/css/scrollbar.css
Normal file
@ -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);
|
||||||
|
}
|
||||||
785
frontend/css/sidebar.css
Normal file
785
frontend/css/sidebar.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
10
frontend/css/styles.css
Normal file
10
frontend/css/styles.css
Normal file
@ -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';
|
||||||
92
frontend/css/tailwind.css
Normal file
92
frontend/css/tailwind.css
Normal file
@ -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);
|
||||||
|
}
|
||||||
433
frontend/css/views/agents.css
Normal file
433
frontend/css/views/agents.css
Normal file
@ -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; }
|
||||||
|
}
|
||||||
275
frontend/css/views/dev.css
Normal file
275
frontend/css/views/dev.css
Normal file
@ -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; }
|
||||||
|
}
|
||||||
45
frontend/css/views/home.css
Normal file
45
frontend/css/views/home.css
Normal file
@ -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; }
|
||||||
|
|
||||||
87
frontend/css/views/login.css
Normal file
87
frontend/css/views/login.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
31
frontend/index.html
Normal file
31
frontend/index.html
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
|
<title>Hermes</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<style>
|
||||||
|
html, body { background: #1A212C; }
|
||||||
|
canvas:not(.ready) { opacity: 0; }
|
||||||
|
[v-cloak] { display: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" v-cloak></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.addEventListener('pagehide',function(){
|
||||||
|
document.querySelectorAll('canvas').forEach(function(c){
|
||||||
|
c.style.display='none';
|
||||||
|
var gl=c.getContext('webgl');
|
||||||
|
if(gl){gl.clearColor(0,0,0,0);gl.clear(gl.COLOR_BUFFER_BIT);}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
window.addEventListener('beforeunload',function(){
|
||||||
|
document.querySelectorAll('canvas').forEach(function(c){c.style.display='none'});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
frontend/openclaw-web-frontend.service
Normal file
14
frontend/openclaw-web-frontend.service
Normal file
@ -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
|
||||||
2012
frontend/package-lock.json
generated
Normal file
2012
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
frontend/package.json
Normal file
32
frontend/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
frontend/public/favicon-eras.svg
Normal file
11
frontend/public/favicon-eras.svg
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<!-- Sun circle -->
|
||||||
|
<circle cx="16" cy="16" r="10" fill="none" stroke="#005e83" stroke-width="2"/>
|
||||||
|
<!-- Sun rays -->
|
||||||
|
<line x1="16" y1="2" x2="16" y2="6" stroke="#e25303" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="16" y1="26" x2="16" y2="30" stroke="#e25303" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="2" y1="16" x2="6" y2="16" stroke="#e25303" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="26" y1="16" x2="30" y2="16" stroke="#e25303" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<!-- Inner dot -->
|
||||||
|
<circle cx="16" cy="16" r="4" fill="#005e83"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 653 B |
9
frontend/public/favicon-loop42.svg
Normal file
9
frontend/public/favicon-loop42.svg
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<!-- Rounded square background -->
|
||||||
|
<rect x="2" y="2" width="28" height="28" rx="6" fill="#1A212C" stroke="#1D7872" stroke-width="1.5"/>
|
||||||
|
<!-- Loop symbol: two interlinked arcs -->
|
||||||
|
<path d="M10,16 a5,5 0 1,1 6,0 a5,5 0 1,1 -6,0" fill="none" stroke="#71B095" stroke-width="2"/>
|
||||||
|
<path d="M16,16 a5,5 0 1,1 6,0 a5,5 0 1,1 -6,0" fill="none" stroke="#1D7872" stroke-width="2"/>
|
||||||
|
<!-- 42 text -->
|
||||||
|
<text x="16" y="27" font-family="monospace" font-size="6" font-weight="bold" fill="#71B095" text-anchor="middle">42</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 593 B |
12
frontend/public/favicon-titan.svg
Normal file
12
frontend/public/favicon-titan.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<!-- Monitor body -->
|
||||||
|
<rect x="2" y="4" width="28" height="18" rx="2.5" ry="2.5" fill="#1a1a2e" stroke="#7c6ff7" stroke-width="1.5"/>
|
||||||
|
<!-- Screen inner -->
|
||||||
|
<rect x="4.5" y="6.5" width="23" height="13" rx="1" fill="#0d0d1a"/>
|
||||||
|
<!-- Stand neck -->
|
||||||
|
<rect x="14" y="22" width="4" height="4" fill="#7c6ff7"/>
|
||||||
|
<!-- Stand base -->
|
||||||
|
<rect x="10" y="26" width="12" height="2.5" rx="1.2" fill="#7c6ff7"/>
|
||||||
|
<!-- Lightning bolt on screen -->
|
||||||
|
<polygon points="18,9 14,16 16.5,16 14,23 20,15 17,15" fill="#f0c040"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 587 B |
12
frontend/public/favicon.svg
Normal file
12
frontend/public/favicon.svg
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<!-- Monitor body -->
|
||||||
|
<rect x="2" y="4" width="28" height="18" rx="2.5" ry="2.5" fill="#1a1a2e" stroke="#7c6ff7" stroke-width="1.5"/>
|
||||||
|
<!-- Screen inner -->
|
||||||
|
<rect x="4.5" y="6.5" width="23" height="13" rx="1" fill="#0d0d1a"/>
|
||||||
|
<!-- Stand neck -->
|
||||||
|
<rect x="14" y="22" width="4" height="4" fill="#7c6ff7"/>
|
||||||
|
<!-- Stand base -->
|
||||||
|
<rect x="10" y="26" width="12" height="2.5" rx="1.2" fill="#7c6ff7"/>
|
||||||
|
<!-- Lightning bolt on screen -->
|
||||||
|
<polygon points="18,9 14,16 16.5,16 14,23 20,15 17,15" fill="#f0c040"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 587 B |
BIN
frontend/public/fonts/ubuntu-sans/UbuntuSans-Italic[wght].woff2
Normal file
BIN
frontend/public/fonts/ubuntu-sans/UbuntuSans-Italic[wght].woff2
Normal file
Binary file not shown.
BIN
frontend/public/fonts/ubuntu-sans/UbuntuSans[wght].woff2
Normal file
BIN
frontend/public/fonts/ubuntu-sans/UbuntuSans[wght].woff2
Normal file
Binary file not shown.
204
frontend/src/App.vue
Normal file
204
frontend/src/App.vue
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app" class="app-container" v-cloak>
|
||||||
|
|
||||||
|
<div class="app-body">
|
||||||
|
<AppSidebar
|
||||||
|
@logout="handleLogout"
|
||||||
|
/>
|
||||||
|
<!-- Mobile: spacer reserves rail width when sidebar is absolute/overlay -->
|
||||||
|
<div class="sidebar-spacer" />
|
||||||
|
|
||||||
|
<div class="main-column">
|
||||||
|
<div class="content-area">
|
||||||
|
<TtsPlayerBar />
|
||||||
|
<!-- Socket views: v-if=visited (lazy first mount), class-based hide (preserve scroll) -->
|
||||||
|
<AgentsView v-if="visited.agents" :class="{ 'view-hidden': route.name !== 'agents' }" />
|
||||||
|
<ViewerView v-if="visited.viewer" :class="{ 'view-hidden': route.name !== 'viewer' }" />
|
||||||
|
<DevView v-if="visited.dev" :class="{ 'view-hidden': route.name !== 'dev' }" />
|
||||||
|
<!-- Non-socket views: normal router -->
|
||||||
|
<RouterView v-if="routerReady && !isSocketRoute" />
|
||||||
|
</div>
|
||||||
|
<GridOverlay />
|
||||||
|
<BreakpointBadge />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, computed, watch } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useUI } from './composables/ui';
|
||||||
|
import { ws, auth, agents } from './store';
|
||||||
|
import { THEME_LOGOS, useTheme } from './composables/useTheme';
|
||||||
|
import { useChatStore } from './store/chat';
|
||||||
|
|
||||||
|
import { defineAsyncComponent } from 'vue';
|
||||||
|
|
||||||
|
import GridOverlay from './components/GridOverlay.vue';
|
||||||
|
import BreakpointBadge from './components/BreakpointBadge.vue';
|
||||||
|
import AppSidebar from './components/AppSidebar.vue';
|
||||||
|
import router from './router';
|
||||||
|
|
||||||
|
import TtsPlayerBar from './components/TtsPlayerBar.vue';
|
||||||
|
const AgentsView = defineAsyncComponent(() => import('./views/AgentsView.vue'));
|
||||||
|
const ViewerView = defineAsyncComponent(() => import('./views/ViewerView.vue'));
|
||||||
|
const DevView = defineAsyncComponent(() => import('./views/DevView.vue'));
|
||||||
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const visited = reactive({ agents: false, viewer: false, dev: false });
|
||||||
|
const isSocketRoute = computed(() => ['agents', 'viewer', 'dev'].includes(route.name as string));
|
||||||
|
|
||||||
|
const routerReady = ref(false);
|
||||||
|
router.isReady().then(() => { routerReady.value = true; });
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
|
||||||
|
const { version } = useUI(ws.status);
|
||||||
|
const { isLoggedIn, doLogout } = auth;
|
||||||
|
const { currentUser, connected, status, sessionId, disconnect, onMessage: onWsMessage, replayBuffer } = ws;
|
||||||
|
const { selectedAgent, updateFromServer } = agents;
|
||||||
|
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
doLogout(disconnect);
|
||||||
|
visited.agents = visited.viewer = visited.dev = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeConnect() {
|
||||||
|
if (auth.isLoggedIn.value && !ws.connected.value) {
|
||||||
|
ws.connect(agents.selectedAgent, auth.isLoggedIn, auth.loginError, agents.selectedMode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.meta?.requiresSocket) {
|
||||||
|
if (!auth.isLoggedIn.value) return { name: 'login' };
|
||||||
|
if (!selectedAgent.value) {
|
||||||
|
const urlAgent = to.query?.agent as string | undefined;
|
||||||
|
const saved = sessionStorage.getItem('agent');
|
||||||
|
if (urlAgent) selectedAgent.value = urlAgent;
|
||||||
|
else if (saved) selectedAgent.value = saved;
|
||||||
|
}
|
||||||
|
maybeConnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mark socket views as visited (triggers v-if → first mount)
|
||||||
|
router.afterEach((to) => {
|
||||||
|
const name = to.name as string;
|
||||||
|
if (name in visited) (visited as any)[name] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset SM state to CONNECTING on disconnect so UI doesn't show stale state
|
||||||
|
watch(connected, (isConnected) => {
|
||||||
|
if (!isConnected) chatStore.setConnecting();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global handler: connection_state + channel_state apply regardless of active view
|
||||||
|
onWsMessage((data: any) => {
|
||||||
|
if (data.type === 'connection_state' && data.state) {
|
||||||
|
chatStore.applyConnectionState(data.state);
|
||||||
|
}
|
||||||
|
if (data.type === 'channel_state' && data.state) {
|
||||||
|
chatStore.applyChannelState(data.state);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Replay buffered connection/channel states (may have arrived before this handler)
|
||||||
|
replayBuffer((data: any) => {
|
||||||
|
if (data.type === 'connection_state' && data.state) chatStore.applyConnectionState(data.state);
|
||||||
|
if (data.type === 'channel_state' && data.state) chatStore.applyChannelState(data.state);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update favicon when theme changes
|
||||||
|
watch(theme, (t) => {
|
||||||
|
const logo = THEME_LOGOS[t];
|
||||||
|
const el = document.querySelector<HTMLLinkElement>('link[rel~="icon"]');
|
||||||
|
if (el) el.href = logo ?? '/favicon.ico';
|
||||||
|
});
|
||||||
|
|
||||||
|
const beVersion = ref('');
|
||||||
|
const envLabel = import.meta.env.PROD ? 'prod' : 'dev';
|
||||||
|
const versionState = ref<'short' | 'full' | 'copied'>('short');
|
||||||
|
const fullVersionText = computed(() => `[${envLabel}] fe: ${version} | be: ${beVersion.value || '…'}`);
|
||||||
|
const versionDisplay = computed(() => {
|
||||||
|
if (versionState.value === 'short') return version.split('-')[0];
|
||||||
|
if (versionState.value === 'copied') return '✓ copied';
|
||||||
|
return fullVersionText.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
function cycleVersion() {
|
||||||
|
if (versionState.value === 'short') {
|
||||||
|
versionState.value = 'full';
|
||||||
|
} else if (versionState.value === 'full') {
|
||||||
|
navigator.clipboard.writeText(fullVersionText.value).then(() => {
|
||||||
|
versionState.value = 'copied';
|
||||||
|
setTimeout(() => versionState.value = 'short', 2000);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
versionState.value = 'short';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
onWsMessage((data: any) => {
|
||||||
|
if (data.type === 'ready' || data.type === 'auth_ok') {
|
||||||
|
connected.value = true;
|
||||||
|
currentUser.value = data.user;
|
||||||
|
sessionId.value = data.sessionId;
|
||||||
|
status.value = 'Connected';
|
||||||
|
if (data.version) { beVersion.value = data.version; chatStore.beVersion = data.version; }
|
||||||
|
updateFromServer(data);
|
||||||
|
if (route.path === '/login') router.push('/agents');
|
||||||
|
} else if (data.type === 'cost_update') {
|
||||||
|
chatStore.sessionUsage = data.usage;
|
||||||
|
chatStore.sessionCost = data.cost;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* styles.css moved to main.ts for independent HMR */
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-footer {
|
||||||
|
height: 28px;
|
||||||
|
background: transparent;
|
||||||
|
border-top: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.main-footer:hover { opacity: 0.85; }
|
||||||
|
.main-footer p { margin: 0; }
|
||||||
|
|
||||||
|
[v-cloak] { display: none; }
|
||||||
|
|
||||||
|
/* Hide inactive socket views without display:none — preserves OverlayScrollbars scroll position */
|
||||||
|
.view-hidden {
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.content-area { position: relative; }
|
||||||
|
</style>
|
||||||
546
frontend/src/components/AppSidebar.vue
Normal file
546
frontend/src/components/AppSidebar.vue
Normal file
@ -0,0 +1,546 @@
|
|||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
class="app-sidebar"
|
||||||
|
:class="{ 'is-collapsed': !isOpen }"
|
||||||
|
>
|
||||||
|
<!-- Gradient shadow next to expanded sidebar -->
|
||||||
|
<div class="sidebar-shadow" />
|
||||||
|
<!-- Invisible full-screen click target to close -->
|
||||||
|
<div class="sidebar-close-target" @click="collapse" />
|
||||||
|
|
||||||
|
<!-- Top: [chevron] [logo + brand] -->
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<button class="sidebar-toggle-btn" @click="toggle" :title="isOpen ? 'Collapse' : 'Expand'">
|
||||||
|
<ChevronLeftIcon v-if="isOpen" class="sidebar-chevron-anim w-4 h-4" />
|
||||||
|
<ChevronRightIcon v-else class="sidebar-chevron-anim w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<RouterLink v-if="isOpen" to="/" class="sidebar-brand" title="Home" @click="collapse">
|
||||||
|
<img v-if="navLogo" :src="navLogo" class="sidebar-brand-logo" alt="Home" />
|
||||||
|
<component v-else :is="THEME_ICONS[theme]" class="sidebar-brand-icon" />
|
||||||
|
<span class="sidebar-brand-name">{{ THEME_NAMES[theme] }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top section: default agent + agents link + files (grows to fill space) -->
|
||||||
|
<div v-if="isLoggedIn && isOpen" class="sidebar-top-section" :class="{ 'has-tree': sharedOpen || workspaceOpen }">
|
||||||
|
<!-- Default agent (placeholder keeps layout stable before WS config) -->
|
||||||
|
<div class="sidebar-home">
|
||||||
|
<div
|
||||||
|
v-if="homeAgent"
|
||||||
|
class="sidebar-room"
|
||||||
|
:class="[`role-${homeAgent.role}`, { active: selectedAgent === homeAgent.id }]"
|
||||||
|
@click="handleModeClick(homeAgent.id, defaultMode(homeAgent))"
|
||||||
|
:title="'Chat with ' + homeAgent.name"
|
||||||
|
>
|
||||||
|
<span class="sidebar-room-dot" :class="`dot-${homeAgent.role}`"></span>
|
||||||
|
<span class="sidebar-room-name">{{ homeAgent.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="sidebar-room sidebar-room-placeholder">
|
||||||
|
<span class="sidebar-room-dot"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agents nav link -->
|
||||||
|
<button class="sidebar-link" :class="{ active: route.name === 'agents' && !route.query.agent }" @click="goAgentsOverview">
|
||||||
|
<ChatBubbleLeftRightIcon class="w-4 h-4" />
|
||||||
|
<span>Agents</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Shared files -->
|
||||||
|
<div v-if="viewerToken" class="sidebar-file-section" :class="{ 'is-open': sharedOpen }">
|
||||||
|
<button class="sidebar-link sidebar-file-toggle" :class="{ active: sharedOpen }" @click="toggleFileSection('shared')">
|
||||||
|
<FolderIcon class="w-4 h-4" />
|
||||||
|
<span>Shared</span>
|
||||||
|
</button>
|
||||||
|
<OverlayScrollbarsComponent v-if="sharedOpen" class="sidebar-file-scroll" :options="scrollbarOptions" element="div">
|
||||||
|
<FileTree
|
||||||
|
:token="viewerToken"
|
||||||
|
:active-path="lastViewerPath"
|
||||||
|
:expand-to="lastViewerPath"
|
||||||
|
:roots="['shared']"
|
||||||
|
:hide-root="true"
|
||||||
|
:folders-only="true"
|
||||||
|
@select="openInViewer"
|
||||||
|
/>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Workspace files -->
|
||||||
|
<div v-if="viewerToken" class="sidebar-file-section" :class="{ 'is-open': workspaceOpen }">
|
||||||
|
<button class="sidebar-link sidebar-file-toggle" :class="{ active: workspaceOpen }" @click="toggleFileSection('workspace')">
|
||||||
|
<FolderOpenIcon class="w-4 h-4" />
|
||||||
|
<span>Workspace</span>
|
||||||
|
</button>
|
||||||
|
<OverlayScrollbarsComponent v-if="workspaceOpen" class="sidebar-file-scroll" :options="scrollbarOptions" element="div">
|
||||||
|
<FileTree
|
||||||
|
:token="viewerToken"
|
||||||
|
:active-path="lastViewerPath"
|
||||||
|
:expand-to="lastViewerPath"
|
||||||
|
:roots="[viewerWorkspaceRoot]"
|
||||||
|
:hide-root="true"
|
||||||
|
:folders-only="true"
|
||||||
|
@select="openInViewer"
|
||||||
|
/>
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Collapsed: top icons -->
|
||||||
|
<div v-if="isLoggedIn && !isOpen" class="sidebar-collapsed-top">
|
||||||
|
<div
|
||||||
|
v-if="homeAgent"
|
||||||
|
class="sidebar-room"
|
||||||
|
:class="[`role-${homeAgent.role}`, { active: selectedAgent === homeAgent.id }]"
|
||||||
|
@click="handleModeClick(homeAgent.id, defaultMode(homeAgent))"
|
||||||
|
:title="homeAgent.name"
|
||||||
|
>
|
||||||
|
<span class="sidebar-room-dot" :class="`dot-${homeAgent.role}`"></span>
|
||||||
|
</div>
|
||||||
|
<button class="sidebar-link" :class="{ active: route.name === 'agents' && !route.query.agent }" title="Agents" @click="goAgentsOverview">
|
||||||
|
<ChatBubbleLeftRightIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<RouterLink :to="{ name: 'viewer', query: { path: 'shared' } }" class="sidebar-link" :class="{ active: route.name === 'viewer' && lastViewerPath.startsWith('shared') }" title="Shared files">
|
||||||
|
<FolderIcon class="w-4 h-4" />
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'viewer', query: { path: viewerWorkspaceRoot } }" class="sidebar-link" :class="{ active: route.name === 'viewer' && lastViewerPath.startsWith('workspace') }" title="Workspace files">
|
||||||
|
<FolderOpenIcon class="w-4 h-4" />
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Spacer: push bottom section down -->
|
||||||
|
<div v-if="isLoggedIn" class="sidebar-flex-spacer" />
|
||||||
|
|
||||||
|
<!-- Panel backdrop: click outside to close any open panel -->
|
||||||
|
<div v-if="anyPanelOpen" class="sidebar-panel-backdrop" @click="closeAllPanels" />
|
||||||
|
|
||||||
|
<!-- Expanded: collapsible System section (no OverlayScrollbars — panels must escape overflow) -->
|
||||||
|
<div v-if="isLoggedIn && isOpen" class="sidebar-system-section" :class="{ collapsed: !systemOpen }">
|
||||||
|
<button class="sidebar-link sidebar-system-toggle" @click="systemOpen = !systemOpen; sessionStorage.setItem('sidebar_panel_system', String(systemOpen))">
|
||||||
|
<ChevronDownIcon v-if="systemOpen" class="w-4 h-4" />
|
||||||
|
<ChevronRightIcon v-else class="w-4 h-4" />
|
||||||
|
<span>System</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="systemOpen" class="sidebar-system-content">
|
||||||
|
<!-- Connection -->
|
||||||
|
<div class="sidebar-conn-wrap">
|
||||||
|
<button class="sidebar-link sidebar-conn-link" :class="{ active: connActive }" @click="toggleConnPanel">
|
||||||
|
<WifiIcon class="w-4 h-4" />
|
||||||
|
<span>{{ connLabel }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="connPanelOpen" class="sidebar-panel">
|
||||||
|
<div class="sidebar-panel-header">Connection</div>
|
||||||
|
<div class="sidebar-panel-row"><span>WebSocket</span><span>{{ chatStore.connectionState }}</span></div>
|
||||||
|
<div class="sidebar-panel-row"><span>Channel</span><span>{{ chatStore.channelState }}</span></div>
|
||||||
|
<div class="sidebar-panel-row"><span>Agent</span><span>{{ selectedAgent || 'none' }}</span></div>
|
||||||
|
<div class="sidebar-panel-row"><span>Mode</span><span>{{ selectedMode }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Takeover -->
|
||||||
|
<div class="sidebar-takeover-wrap" :class="{ active: !!takeoverToken }">
|
||||||
|
<button class="sidebar-link" :class="{ active: !!takeoverToken }" @click="toggleTakeoverPanel">
|
||||||
|
<SignalIcon class="w-4 h-4" />
|
||||||
|
<span>Takeover</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="takeoverPanelOpen && takeoverToken" class="sidebar-panel">
|
||||||
|
<div class="sidebar-panel-header">Takeover Token</div>
|
||||||
|
<div class="sidebar-panel-token" @click="copyToken" :title="tokenCopied ? 'Copied!' : 'Click to copy'">
|
||||||
|
<code>{{ takeoverToken }}</code>
|
||||||
|
<span v-if="tokenCopied" class="sidebar-panel-copied">Copied!</span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-panel-row">
|
||||||
|
<span>Capture</span>
|
||||||
|
<span :style="{ color: captureActive ? 'var(--success, #22c55e)' : 'var(--text-dim)' }">{{ captureActive ? 'ON' : 'OFF' }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="sidebar-panel-item" @click="toggleCapture">
|
||||||
|
{{ captureActive ? 'Disable Capture' : 'Enable Capture' }}
|
||||||
|
</button>
|
||||||
|
<button class="sidebar-panel-item" @click="revokeAndClose">Revoke</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dev -->
|
||||||
|
<RouterLink to="/dev" class="sidebar-link" :class="{ active: route.name === 'dev' }" @click="collapse">
|
||||||
|
<CodeBracketIcon class="w-4 h-4" />
|
||||||
|
<span>dev</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<!-- Version -->
|
||||||
|
<div class="sidebar-version-wrap">
|
||||||
|
<button class="sidebar-link sidebar-version-link" @click="toggleVersionPanel">
|
||||||
|
<span class="sidebar-version-text">{{ versionShort }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="versionPanelOpen" class="sidebar-panel sidebar-version-panel">
|
||||||
|
<div class="sidebar-panel-header">Version</div>
|
||||||
|
<div class="sidebar-panel-row"><span>Frontend</span><span>{{ version }}</span></div>
|
||||||
|
<div class="sidebar-panel-row"><span>Backend</span><span>{{ chatStore.beVersion || '...' }}</span></div>
|
||||||
|
<div class="sidebar-panel-row"><span>Env</span><span>{{ envLabel }}</span></div>
|
||||||
|
<button class="sidebar-panel-item" @click="copyVersionDetails">
|
||||||
|
{{ versionCopied ? '✓ Copied' : 'Copy details' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Collapsed: bottom icons -->
|
||||||
|
<div v-if="isLoggedIn && !isOpen" class="sidebar-bottom-section">
|
||||||
|
<div class="sidebar-conn-wrap">
|
||||||
|
<button class="sidebar-link sidebar-conn-link" :class="{ active: connActive }" @click="toggleConnPanel" :title="connLabel">
|
||||||
|
<WifiIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<div v-if="connPanelOpen" class="sidebar-panel">
|
||||||
|
<div class="sidebar-panel-header">Connection</div>
|
||||||
|
<div class="sidebar-panel-row"><span>WebSocket</span><span>{{ chatStore.connectionState }}</span></div>
|
||||||
|
<div class="sidebar-panel-row"><span>Channel</span><span>{{ chatStore.channelState }}</span></div>
|
||||||
|
<div class="sidebar-panel-row"><span>Agent</span><span>{{ selectedAgent || 'none' }}</span></div>
|
||||||
|
<div class="sidebar-panel-row"><span>Mode</span><span>{{ selectedMode }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-takeover-wrap" :class="{ active: !!takeoverToken }">
|
||||||
|
<button class="sidebar-link" :class="{ active: !!takeoverToken }" @click="toggleTakeoverPanel" title="Takeover">
|
||||||
|
<SignalIcon class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<div v-if="takeoverPanelOpen && takeoverToken" class="sidebar-panel">
|
||||||
|
<div class="sidebar-panel-header">Takeover Token</div>
|
||||||
|
<div class="sidebar-panel-token" @click="copyToken" :title="tokenCopied ? 'Copied!' : 'Click to copy'">
|
||||||
|
<code>{{ takeoverToken }}</code>
|
||||||
|
<span v-if="tokenCopied" class="sidebar-panel-copied">Copied!</span>
|
||||||
|
</div>
|
||||||
|
<div class="sidebar-panel-row">
|
||||||
|
<span>Capture</span>
|
||||||
|
<span :style="{ color: captureActive ? 'var(--success, #22c55e)' : 'var(--text-dim)' }">{{ captureActive ? 'ON' : 'OFF' }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="sidebar-panel-item" @click="toggleCapture">
|
||||||
|
{{ captureActive ? 'Disable Capture' : 'Enable Capture' }}
|
||||||
|
</button>
|
||||||
|
<button class="sidebar-panel-item" @click="revokeAndClose">Revoke</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<RouterLink to="/dev" class="sidebar-link" :class="{ active: route.name === 'dev' }" title="Dev" @click="collapse">
|
||||||
|
<CodeBracketIcon class="w-4 h-4" />
|
||||||
|
</RouterLink>
|
||||||
|
<div class="sidebar-version-wrap">
|
||||||
|
<button class="sidebar-link sidebar-version-link" @click="toggleVersionPanel" :title="versionShort">
|
||||||
|
<span class="sidebar-version-text">{{ versionShort }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="versionPanelOpen" class="sidebar-panel sidebar-version-panel">
|
||||||
|
<div class="sidebar-panel-header">Version</div>
|
||||||
|
<div class="sidebar-panel-row"><span>Frontend</span><span>{{ version }}</span></div>
|
||||||
|
<div class="sidebar-panel-row"><span>Backend</span><span>{{ chatStore.beVersion || '...' }}</span></div>
|
||||||
|
<div class="sidebar-panel-row"><span>Env</span><span>{{ envLabel }}</span></div>
|
||||||
|
<button class="sidebar-panel-item" @click="copyVersionDetails">
|
||||||
|
{{ versionCopied ? '✓ Copied' : 'Copy details' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User (always unified) -->
|
||||||
|
<div class="sidebar-bottom">
|
||||||
|
<template v-if="isLoggedIn">
|
||||||
|
<div class="sidebar-user-wrap" :class="{ open: userMenuOpen }">
|
||||||
|
<button
|
||||||
|
class="sidebar-user-btn"
|
||||||
|
@click="toggleUserMenu"
|
||||||
|
:title="isOpen ? '' : currentUser"
|
||||||
|
>
|
||||||
|
<UserCircleIcon class="w-4 h-4" />
|
||||||
|
<span v-if="isOpen" class="sidebar-user-name">{{ currentUser }}</span>
|
||||||
|
</button>
|
||||||
|
<div v-if="userMenuOpen" class="sidebar-user-menu">
|
||||||
|
<div class="sidebar-user-menu-header">{{ currentUser }}</div>
|
||||||
|
<button class="sidebar-user-menu-item" @click="handleLogout">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<RouterLink v-else to="/login" class="sidebar-link" :title="isOpen ? '' : 'Sign in'">
|
||||||
|
<ArrowRightEndOnRectangleIcon class="w-4 h-4" />
|
||||||
|
<span v-if="isOpen">Sign in</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ name: 'AppSidebar' });
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
FolderIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
CodeBracketIcon,
|
||||||
|
|
||||||
|
UserCircleIcon,
|
||||||
|
ArrowRightEndOnRectangleIcon,
|
||||||
|
LockClosedIcon,
|
||||||
|
UserGroupIcon,
|
||||||
|
SignalIcon,
|
||||||
|
WifiIcon,
|
||||||
|
ChatBubbleLeftRightIcon,
|
||||||
|
} from '@heroicons/vue/20/solid';
|
||||||
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
|
||||||
|
import { scrollbarOptions } from '../composables/useScrollbar';
|
||||||
|
import { THEME_ICONS, THEME_LOGOS, THEME_NAMES, useTheme } from '../composables/useTheme';
|
||||||
|
import { ws, auth, agents, takeover, lastViewerPath, useViewerStore } from '../store';
|
||||||
|
import { useChatStore } from '../store/chat';
|
||||||
|
import FileTree from './FileTree.vue';
|
||||||
|
|
||||||
|
const takeoverToken = takeover.token;
|
||||||
|
const captureActive = takeover.capture.isActive;
|
||||||
|
const takeoverPanelOpen = ref(false);
|
||||||
|
const tokenCopied = ref(false);
|
||||||
|
|
||||||
|
async function toggleCapture() {
|
||||||
|
if (captureActive.value) {
|
||||||
|
takeover.capture.disable();
|
||||||
|
} else {
|
||||||
|
await takeover.capture.enable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToken() {
|
||||||
|
if (!takeoverToken.value) return;
|
||||||
|
navigator.clipboard.writeText(takeoverToken.value);
|
||||||
|
tokenCopied.value = true;
|
||||||
|
setTimeout(() => { tokenCopied.value = false; }, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemOpen = ref(sessionStorage.getItem('sidebar_panel_system') === 'true');
|
||||||
|
const connPanelOpen = ref(false);
|
||||||
|
const anyPanelOpen = computed(() => takeoverPanelOpen.value || userMenuOpen.value || versionPanelOpen.value || connPanelOpen.value);
|
||||||
|
|
||||||
|
function closeAllPanels() {
|
||||||
|
takeoverPanelOpen.value = false;
|
||||||
|
userMenuOpen.value = false;
|
||||||
|
versionPanelOpen.value = false;
|
||||||
|
connPanelOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleConnPanel() {
|
||||||
|
const opening = !connPanelOpen.value;
|
||||||
|
closeAllPanels();
|
||||||
|
if (opening) connPanelOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTakeoverPanel() {
|
||||||
|
if (!takeoverToken.value) { router.push('/dev'); return; }
|
||||||
|
const opening = !takeoverPanelOpen.value;
|
||||||
|
closeAllPanels();
|
||||||
|
if (opening) takeoverPanelOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleUserMenu() {
|
||||||
|
const opening = !userMenuOpen.value;
|
||||||
|
closeAllPanels();
|
||||||
|
if (opening) userMenuOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeAndClose() {
|
||||||
|
takeover.revoke();
|
||||||
|
closeAllPanels();
|
||||||
|
}
|
||||||
|
const homeAgent = computed(() => allAgents.value.find(a => a.id === defaultAgent.value));
|
||||||
|
const allFilteredSorted = computed(() =>
|
||||||
|
filteredAgents.value
|
||||||
|
.filter(a => a.id !== defaultAgent.value)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
);
|
||||||
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
|
// Viewer file tree in sidebar
|
||||||
|
const viewerStore = useViewerStore();
|
||||||
|
const viewerToken = computed(() => viewerStore.fstoken);
|
||||||
|
const viewerRoots = computed(() => viewerStore.roots);
|
||||||
|
|
||||||
|
const sharedOpen = ref(lastViewerPath.value.startsWith('shared'));
|
||||||
|
const workspaceOpen = ref(lastViewerPath.value.startsWith('workspace'));
|
||||||
|
|
||||||
|
function toggleFileSection(section: 'shared' | 'workspace') {
|
||||||
|
const root = section === 'shared' ? 'shared' : viewerWorkspaceRoot.value;
|
||||||
|
if (section === 'shared') {
|
||||||
|
sharedOpen.value = !sharedOpen.value;
|
||||||
|
if (sharedOpen.value) { workspaceOpen.value = false; openInViewer(root); }
|
||||||
|
} else {
|
||||||
|
workspaceOpen.value = !workspaceOpen.value;
|
||||||
|
if (workspaceOpen.value) { sharedOpen.value = false; openInViewer(root); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const viewerWorkspaceRoot = computed(() => {
|
||||||
|
const ws = viewerRoots.value.find(r => r.startsWith('workspace'));
|
||||||
|
return ws || 'workspace-titan';
|
||||||
|
});
|
||||||
|
|
||||||
|
function openInViewer(path: string) {
|
||||||
|
lastViewerPath.value = path;
|
||||||
|
localStorage.setItem('viewer_last_path', path);
|
||||||
|
router.push({ name: 'viewer', query: { path } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version display
|
||||||
|
import { useUI } from '../composables/ui';
|
||||||
|
const { version } = useUI(ws.status);
|
||||||
|
const versionShort = version.split('-')[0];
|
||||||
|
const envLabel = import.meta.env.PROD ? 'prod' : 'dev';
|
||||||
|
const versionPanelOpen = ref(false);
|
||||||
|
const versionCopied = ref(false);
|
||||||
|
|
||||||
|
function toggleVersionPanel() {
|
||||||
|
const opening = !versionPanelOpen.value;
|
||||||
|
closeAllPanels();
|
||||||
|
if (opening) versionPanelOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyVersionDetails() {
|
||||||
|
const details = `env: ${envLabel}\nfe: ${version}\nbe: ${chatStore.beVersion || 'unknown'}\nua: ${navigator.userAgent}`;
|
||||||
|
navigator.clipboard.writeText(details);
|
||||||
|
versionCopied.value = true;
|
||||||
|
setTimeout(() => { versionCopied.value = false; }, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateToIndicator(state: string | null) {
|
||||||
|
switch (state) {
|
||||||
|
case 'AGENT_RUNNING': return { icon: '⚙️', cls: 'ch-running' };
|
||||||
|
case 'FRESH': return { icon: '✨', cls: 'ch-fresh' };
|
||||||
|
case 'READY': return { icon: '●', cls: 'ch-ready' };
|
||||||
|
case 'HANDOVER_PENDING': return { icon: '📝', cls: 'ch-handover' };
|
||||||
|
case 'HANDOVER_DONE': return { icon: '✅', cls: 'ch-handover' };
|
||||||
|
case 'RESETTING': return { icon: '🔄', cls: 'ch-resetting' };
|
||||||
|
case 'NO_SESSION': return { icon: '○', cls: 'ch-nosession' };
|
||||||
|
default: return { icon: '·', cls: 'ch-none' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For selected agent: use live WS state from chat store
|
||||||
|
const selectedIndicator = computed(() => stateToIndicator(chatStore.channelState));
|
||||||
|
|
||||||
|
// For any agent: get from HTTP-polled channel states
|
||||||
|
function agentIndicator(agentId: string, mode: 'private' | 'public' = 'private') {
|
||||||
|
// For selected agent + mode: use live WS state only when SYNCED
|
||||||
|
if (agentId === selectedAgent.value && mode === selectedMode.value && chatStore.connectionState === 'SYNCED') {
|
||||||
|
return selectedIndicator.value;
|
||||||
|
}
|
||||||
|
const info = agents.getChannelState(agentId, mode);
|
||||||
|
return info ? stateToIndicator(info.state) : stateToIndicator(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connLabel = computed(() => {
|
||||||
|
switch (chatStore.connectionState) {
|
||||||
|
case 'CONNECTING': return 'Connecting...';
|
||||||
|
case 'LOADING_HISTORY': return 'Loading...';
|
||||||
|
case 'SWITCHING': return 'Switching...';
|
||||||
|
case 'SYNCED': return 'Connected';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const connActive = computed(() => chatStore.connectionState === 'SYNCED');
|
||||||
|
|
||||||
|
const emit = defineEmits<{ logout: [] }>();
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const navLogo = computed(() => THEME_LOGOS[theme.value]);
|
||||||
|
|
||||||
|
const { isLoggedIn } = auth;
|
||||||
|
const { currentUser, send } = ws;
|
||||||
|
const { filteredAgents, selectedAgent, selectedMode, defaultAgent, allAgents } = agents;
|
||||||
|
|
||||||
|
function defaultMode(agent: any): string {
|
||||||
|
if (agent.role === 'owner') return 'private';
|
||||||
|
if (agent.modes?.includes('public')) return 'public';
|
||||||
|
return 'private';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extraMode(agent: any): string | null {
|
||||||
|
const def = defaultMode(agent);
|
||||||
|
const other = def === 'private' ? 'public' : 'private';
|
||||||
|
return agent.modes?.includes(other) ? other : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeMode(agent: any): string | null {
|
||||||
|
if (selectedAgent.value !== agent.id) return null;
|
||||||
|
return selectedMode.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEGMENTS = ['personal', 'common', 'private', 'public'] as const;
|
||||||
|
|
||||||
|
const visibleSegments = computed(() =>
|
||||||
|
SEGMENTS
|
||||||
|
.map(key => ({
|
||||||
|
key,
|
||||||
|
agents: filteredAgents.value
|
||||||
|
.filter(a => (a.segment ?? 'utility') === key && a.id !== defaultAgent.value)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
}))
|
||||||
|
.filter(s => s.agents.length > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const isMobile = window.innerWidth <= 480;
|
||||||
|
const isLarge = window.innerWidth >= 1024;
|
||||||
|
const isOpen = ref(isMobile ? false : isLarge ? true : localStorage.getItem('sidebar_open') !== 'false');
|
||||||
|
const userMenuOpen = ref(false);
|
||||||
|
|
||||||
|
// Auto-collapse when crossing from lg → md
|
||||||
|
const lgQuery = window.matchMedia('(min-width: 1024px)');
|
||||||
|
lgQuery.addEventListener('change', (e) => {
|
||||||
|
if (e.matches && !isOpen.value) {
|
||||||
|
isOpen.value = true;
|
||||||
|
localStorage.setItem('sidebar_open', 'true');
|
||||||
|
} else if (!e.matches && isOpen.value) {
|
||||||
|
isOpen.value = false;
|
||||||
|
localStorage.setItem('sidebar_open', 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
isOpen.value = !isOpen.value;
|
||||||
|
localStorage.setItem('sidebar_open', String(isOpen.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapse() {
|
||||||
|
if (window.innerWidth >= 1024) return; // large screens: stay open
|
||||||
|
isOpen.value = false;
|
||||||
|
localStorage.setItem('sidebar_open', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function goAgentsOverview() {
|
||||||
|
collapse();
|
||||||
|
router.push({ name: 'agents', query: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
function handleModeClick(agentId: string, mode: string) {
|
||||||
|
collapse();
|
||||||
|
const sameAgent = selectedAgent.value === agentId && selectedMode.value === mode;
|
||||||
|
// Already on this agent+mode on agents view — nothing to do
|
||||||
|
if (sameAgent && route.name === 'agents') return;
|
||||||
|
// Same agent but on a different view — just navigate back, don't re-switch
|
||||||
|
if (sameAgent) {
|
||||||
|
router.push({ name: 'agents', query: { agent: agentId, mode } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Different agent — full switch
|
||||||
|
selectedAgent.value = agentId;
|
||||||
|
selectedMode.value = mode as 'private' | 'public';
|
||||||
|
sessionStorage.setItem('agent', agentId);
|
||||||
|
sessionStorage.setItem('agent_mode', mode);
|
||||||
|
if (ws.connected.value) {
|
||||||
|
ws.switchAgent(agentId, mode);
|
||||||
|
} else {
|
||||||
|
ws.connect(selectedAgent, auth.isLoggedIn, auth.loginError, selectedMode);
|
||||||
|
}
|
||||||
|
router.push({ name: 'agents', query: { agent: agentId, mode } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
userMenuOpen.value = false;
|
||||||
|
emit('logout');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
155
frontend/src/components/AssistantMessage.vue
Normal file
155
frontend/src/components/AssistantMessage.vue
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
<template>
|
||||||
|
<MessageFrame role="assistant" :copyContent="msg.content">
|
||||||
|
<div v-html="parseMd(content)"></div>
|
||||||
|
<template #footer>
|
||||||
|
<span class="footer-name">{{ displayName }}</span>
|
||||||
|
<button v-if="!msg.streaming && msg.content" class="tts-btn" :class="{ active: ttsPlayer.isPlayingMsg(msg) }" @click.stop="ttsPlayer.play(msg, msg._sourceIndex ?? 0)" title="Listen">
|
||||||
|
<span v-if="ttsPlayer.isPlayingMsg(msg) && ttsPlayer.state.value === 'loading'" class="tts-spinner"></span>
|
||||||
|
<SpeakerWaveIcon v-else class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<span v-if="msg.streaming && tools.length === 0" class="footer-status"> ...</span>
|
||||||
|
<span v-else-if="!msg.streaming && tools.length === 0" class="footer-status"> | {{ msgTimeLabel }}</span>
|
||||||
|
<span v-if="tools.length > 0" class="footer-tools">
|
||||||
|
<span
|
||||||
|
v-for="tool in tools"
|
||||||
|
:key="tool.id"
|
||||||
|
class="footer-tool-icon"
|
||||||
|
:class="tool.state"
|
||||||
|
:title="`${tool.label} [${tool.state}]`"
|
||||||
|
><ToolIcon :tool="tool.tool || ''" /></span>
|
||||||
|
</span>
|
||||||
|
<span v-if="msg.truncated" class="truncated-notice"><ExclamationTriangleIcon class="w-4 h-4 inline" /> Output limit reached — response was cut off</span>
|
||||||
|
</template>
|
||||||
|
</MessageFrame>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { ExclamationTriangleIcon, SpeakerWaveIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import MessageFrame from './MessageFrame.vue';
|
||||||
|
import { useChatStore } from '../store/chat';
|
||||||
|
import { parseMd } from '../composables/useMessages';
|
||||||
|
import type { Agent } from '../composables/agents';
|
||||||
|
import ToolIcon from './ToolIcon.vue';
|
||||||
|
import { useTtsPlayer } from '../composables/useTtsPlayer';
|
||||||
|
import { relativeTime } from '../utils/relativeTime';
|
||||||
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const ttsPlayer = useTtsPlayer();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
msg: any;
|
||||||
|
agentDisplayName: string;
|
||||||
|
isAgentRunning: boolean;
|
||||||
|
allAgents: Agent[];
|
||||||
|
getToolsForTurn: (corrId: string | null | undefined) => any[];
|
||||||
|
hudVersion: number; // increments on every HUD tree mutation — reactive dep
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const displayName = computed(() => {
|
||||||
|
const id = props.msg.agentId;
|
||||||
|
if (id) {
|
||||||
|
const agent = props.allAgents?.find((a: any) => a.id === id);
|
||||||
|
return (agent ? agent.name : id).toUpperCase();
|
||||||
|
}
|
||||||
|
return props.agentDisplayName;
|
||||||
|
});
|
||||||
|
|
||||||
|
const msgTimeLabel = computed(() => {
|
||||||
|
if (props.msg.timestamp) {
|
||||||
|
return relativeTime(props.msg.timestamp);
|
||||||
|
}
|
||||||
|
return 'Done';
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = computed(() => {
|
||||||
|
if (props.msg.streaming) {
|
||||||
|
return chatStore.streamingMessageVisibleContent + '<span class="typing-dots">...</span>';
|
||||||
|
}
|
||||||
|
return props.msg.content;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tool list for this message's turn — live during streaming, frozen after
|
||||||
|
// hudVersion is a primitive counter that increments on every HUD mutation,
|
||||||
|
// so Vue properly tracks it as a reactive dep (array identity doesn't change)
|
||||||
|
const tools = computed(() => {
|
||||||
|
void props.hudVersion;
|
||||||
|
return props.getToolsForTurn(props.msg.turnCorrId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// TTS handled by global useTtsPlayer composable
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.footer-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.footer-status {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.footer-tools {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.footer-tool-icon {
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.footer-tool-icon.running {
|
||||||
|
opacity: 1;
|
||||||
|
animation: pulse-icon 1.2s infinite;
|
||||||
|
}
|
||||||
|
.footer-tool-icon.error {
|
||||||
|
opacity: 1;
|
||||||
|
filter: sepia(1) saturate(5) hue-rotate(300deg);
|
||||||
|
}
|
||||||
|
.footer-tool-icon.done {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
@keyframes pulse-icon {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
.truncated-notice {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #e5a950;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TTS */
|
||||||
|
.tts-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.15s, color 0.15s;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.tts-btn:hover { opacity: 1; color: var(--text); }
|
||||||
|
.tts-btn.active { opacity: 1; color: var(--accent); }
|
||||||
|
.tts-btn.loading { opacity: 0.7; cursor: wait; }
|
||||||
|
.tts-spinner {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid var(--text-dim);
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin-tts 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin-tts { to { transform: rotate(360deg); } }
|
||||||
|
</style>
|
||||||
31
frontend/src/components/BreakpointBadge.vue
Normal file
31
frontend/src/components/BreakpointBadge.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="devFlags.showDebugInfo" class="bp-badge" aria-hidden="true">
|
||||||
|
<span class="sm:hidden">xs</span>
|
||||||
|
<span class="hidden sm:inline md:hidden">sm</span>
|
||||||
|
<span class="hidden md:inline lg:hidden">md</span>
|
||||||
|
<span class="hidden lg:inline">lg</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDevFlags } from '../composables/useDevFlags';
|
||||||
|
const devFlags = useDevFlags();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.bp-badge {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 8px;
|
||||||
|
right: var(--space-page);
|
||||||
|
z-index: 9999;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
44
frontend/src/components/FileTree.vue
Normal file
44
frontend/src/components/FileTree.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div class="file-tree">
|
||||||
|
<TreeNode
|
||||||
|
v-for="root in roots"
|
||||||
|
:key="root.prefix"
|
||||||
|
:label="root.label"
|
||||||
|
:path="root.prefix"
|
||||||
|
:token="token"
|
||||||
|
:active-path="activePath"
|
||||||
|
:expand-to="expandTo"
|
||||||
|
:depth="0"
|
||||||
|
:hide-self="hideRoot"
|
||||||
|
:folders-only="foldersOnly"
|
||||||
|
@select="$emit('select', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import TreeNode from './TreeNode.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
token: string;
|
||||||
|
activePath: string;
|
||||||
|
expandTo: string;
|
||||||
|
roots?: string[];
|
||||||
|
hideRoot?: boolean;
|
||||||
|
foldersOnly?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{ (e: 'select', path: string): void }>();
|
||||||
|
|
||||||
|
const roots = computed(() =>
|
||||||
|
(props.roots && props.roots.length > 0 ? props.roots : ['shared', 'workspace-titan'])
|
||||||
|
.map(r => ({ label: r, prefix: r }))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.file-tree {
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
31
frontend/src/components/GridOverlay.vue
Normal file
31
frontend/src/components/GridOverlay.vue
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="devFlags.showGrid" class="grid-overlay" aria-hidden="true">
|
||||||
|
<div class="grid-col" v-for="n in columns" :key="n"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDevFlags } from '../composables/useDevFlags';
|
||||||
|
|
||||||
|
const devFlags = useDevFlags();
|
||||||
|
const columns = 12;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.grid-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(12, 1fr);
|
||||||
|
gap: var(--space-gap, 8px);
|
||||||
|
padding: 0 var(--space-page, 16px);
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.grid-col {
|
||||||
|
background: rgba(129, 140, 248, 0.06);
|
||||||
|
border-left: 1px solid rgba(129, 140, 248, 0.12);
|
||||||
|
border-right: 1px solid rgba(129, 140, 248, 0.12);
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
159
frontend/src/components/HandoverCard.vue
Normal file
159
frontend/src/components/HandoverCard.vue
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
<template>
|
||||||
|
<div class="handover-card" :class="{ collapsed: isCollapsed }">
|
||||||
|
<div class="handover-card-header" @click="isCollapsed = !isCollapsed">
|
||||||
|
<DocumentTextIcon class="handover-icon" />
|
||||||
|
<span class="handover-label">{{ label }}</span>
|
||||||
|
<div class="handover-actions" @click.stop>
|
||||||
|
<button class="handover-copy-btn" @click="copy" :title="copied ? 'Copied!' : 'Copy'">
|
||||||
|
<CheckIcon v-if="copied" class="w-3.5 h-3.5" />
|
||||||
|
<ClipboardDocumentIcon v-else class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ChevronDownIcon class="chevron" :class="{ open: !isCollapsed }" />
|
||||||
|
</div>
|
||||||
|
<div v-if="!isCollapsed" class="handover-card-body markdown-body" v-html="parseMd(content)"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { DocumentTextIcon, ClipboardDocumentIcon, CheckIcon, ChevronDownIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import { parseMd } from '../composables/useMessages';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
content: string;
|
||||||
|
label?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isCollapsed = ref(true);
|
||||||
|
const copied = ref(false);
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
navigator.clipboard.writeText(props.content).then(() => {
|
||||||
|
copied.value = true;
|
||||||
|
setTimeout(() => { copied.value = false; }, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.handover-card {
|
||||||
|
align-self: flex-start;
|
||||||
|
max-width: min(520px, 90%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
/* subtle paper-like top highlight */
|
||||||
|
box-shadow: 0 1px 0 rgba(255,255,255,0.04) inset, 0 1px 4px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handover-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.handover-card-header:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
|
||||||
|
.handover-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handover-label {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handover-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handover-copy-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.handover-copy-btn:hover { opacity: 1; background: rgba(255,255,255,0.07); }
|
||||||
|
.handover-copy-btn svg { width: 13px; height: 13px; }
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
.chevron.open { transform: rotate(0deg); }
|
||||||
|
|
||||||
|
.handover-card-body {
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255,255,255,0.015);
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown styles */
|
||||||
|
.handover-card-body :deep(p) { margin: 0.35rem 0; }
|
||||||
|
.handover-card-body :deep(h1),
|
||||||
|
.handover-card-body :deep(h2),
|
||||||
|
.handover-card-body :deep(h3) {
|
||||||
|
margin: 0.7rem 0 0.3rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.handover-card-body :deep(h1) { }
|
||||||
|
.handover-card-body :deep(ul),
|
||||||
|
.handover-card-body :deep(ol) {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
list-style-position: outside;
|
||||||
|
}
|
||||||
|
.handover-card-body :deep(li) { margin: 0.25rem 0; line-height: 1.5; }
|
||||||
|
.handover-card-body :deep(code) {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.handover-card-body :deep(pre) {
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
.handover-card-body :deep(pre code) { background: none; padding: 0; }
|
||||||
|
.handover-card-body :deep(strong) { color: var(--text); font-weight: 600; }
|
||||||
|
.handover-card-body :deep(a) { color: var(--accent); text-decoration: underline; }
|
||||||
|
.handover-card-body :deep(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
50
frontend/src/components/HermesStatus.vue
Normal file
50
frontend/src/components/HermesStatus.vue
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div class="hermes-status" :class="{ connected: isConnected }" :title="statusText">
|
||||||
|
<span class="hermes-dot"></span>
|
||||||
|
<span class="hermes-label">{{ isConnected ? 'Hermes' : 'Local' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isConnected?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const statusText = computed(() =>
|
||||||
|
props.isConnected
|
||||||
|
? 'Hermes: Connected (multi-user rooms available)'
|
||||||
|
: 'Hermes: Not connected (single-user mode)'
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hermes-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: var(--bg-dim);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.hermes-status.connected {
|
||||||
|
color: var(--accent-fg);
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.hermes-dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim);
|
||||||
|
}
|
||||||
|
.hermes-status.connected .hermes-dot {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
.hermes-label {
|
||||||
|
}
|
||||||
|
</style>
|
||||||
234
frontend/src/components/HudActions.vue
Normal file
234
frontend/src/components/HudActions.vue
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
<template>
|
||||||
|
<div class="hud-actions" :class="smState">
|
||||||
|
<div class="log-feed" ref="logEl">
|
||||||
|
<template v-if="hudTree?.length">
|
||||||
|
<div
|
||||||
|
v-for="node in hudTree"
|
||||||
|
:key="node.id"
|
||||||
|
class="hud-node"
|
||||||
|
:class="[node.type, node.state, { replay: node.replay, expanded: expandedIds.has(node.id) }]"
|
||||||
|
>
|
||||||
|
<!-- Root node row -->
|
||||||
|
<div class="node-row" @click="toggle(node)">
|
||||||
|
<span class="node-dot" :class="node.state" />
|
||||||
|
<span class="node-label">{{ node.label }}</span>
|
||||||
|
<span v-if="node.durationMs != null" class="node-dur">{{ fmtDur(node.durationMs) }}</span>
|
||||||
|
<ChevronDownIcon v-if="(node.children?.length || node.args || node.result || node.payload) && expandedIds.has(node.id)" class="node-chevron w-3 h-3" />
|
||||||
|
<ChevronRightIcon v-else-if="node.children?.length || node.args || node.result || node.payload" class="node-chevron w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
<!-- Children (tools/thinks nested in turn) -->
|
||||||
|
<div v-if="expandedIds.has(node.id) && node.children?.length" class="node-children">
|
||||||
|
<div
|
||||||
|
v-for="child in node.children"
|
||||||
|
:key="child.id"
|
||||||
|
class="hud-node child"
|
||||||
|
:class="[child.type, child.state, { replay: child.replay, expanded: expandedIds.has(child.id) }]"
|
||||||
|
>
|
||||||
|
<div class="node-row" @click="toggle(child)">
|
||||||
|
<ToolIcon v-if="child.type === 'tool' && child.tool" :tool="child.tool" />
|
||||||
|
<span v-else class="node-dot" :class="child.state" />
|
||||||
|
<span class="node-label">{{ child.label }}</span>
|
||||||
|
<span v-if="child.durationMs != null" class="node-dur">{{ fmtDur(child.durationMs) }}</span>
|
||||||
|
<ChevronDownIcon v-if="(child.args || child.result) && expandedIds.has(child.id)" class="node-chevron w-3 h-3" />
|
||||||
|
<ChevronRightIcon v-else-if="child.args || child.result" class="node-chevron w-3 h-3" />
|
||||||
|
</div>
|
||||||
|
<!-- Expanded args/result -->
|
||||||
|
<div v-if="expandedIds.has(child.id)" class="node-detail">
|
||||||
|
<pre v-if="child.args" class="detail-block">{{ fmt(child.args) }}</pre>
|
||||||
|
<pre v-if="child.result" class="detail-block result">{{ fmt(child.result) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Root node detail (args / result / payload) -->
|
||||||
|
<div v-if="expandedIds.has(node.id) && !node.children?.length" class="node-detail">
|
||||||
|
<pre v-if="node.args" class="detail-block">{{ fmt(node.args) }}</pre>
|
||||||
|
<pre v-if="node.result" class="detail-block result">{{ fmt(node.result) }}</pre>
|
||||||
|
<pre v-if="node.payload && !node.args && !node.result" class="detail-block">{{ fmt(node.payload) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="log-empty">No actions yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/vue/20/solid'
|
||||||
|
import ToolIcon from './ToolIcon.vue'
|
||||||
|
import type { HudNode } from '../composables/sessionHistory'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
smState: String,
|
||||||
|
smLabel: String,
|
||||||
|
statusText: String,
|
||||||
|
hudTree: Array as () => HudNode[],
|
||||||
|
})
|
||||||
|
|
||||||
|
const expandedIds = ref(new Set<string>())
|
||||||
|
const logEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
function toggle(node: HudNode) {
|
||||||
|
if (expandedIds.value.has(node.id)) {
|
||||||
|
expandedIds.value.delete(node.id)
|
||||||
|
} else {
|
||||||
|
expandedIds.value.add(node.id)
|
||||||
|
}
|
||||||
|
// Force reactivity
|
||||||
|
expandedIds.value = new Set(expandedIds.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDur(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(obj: any): string {
|
||||||
|
try { return JSON.stringify(obj, null, 2) } catch { return String(obj) }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hud-actions {
|
||||||
|
display: block;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
height: 90px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(59, 130, 246, 0.05);
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-feed {
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-empty {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.3;
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Node ── */
|
||||||
|
.hud-node {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-node.child {
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-node.replay { opacity: 0.45; }
|
||||||
|
|
||||||
|
.node-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.08rem 0.1rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.node-row:hover { background: rgba(255,255,255,0.05); }
|
||||||
|
|
||||||
|
.node-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
.node-dot.running {
|
||||||
|
background: #3b82f6;
|
||||||
|
box-shadow: 0 0 4px #3b82f6;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
.node-dot.done { background: #22c55e; }
|
||||||
|
.node-dot.error { background: #ef4444; }
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.3; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-label {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Newest (first) root node gets full brightness */
|
||||||
|
.hud-node:first-child > .node-row > .node-label {
|
||||||
|
color: var(--text);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-dur {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-chevron {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.5;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Detail panel ── */
|
||||||
|
.node-children {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
padding: 0.2rem 0 0.2rem 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-block {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
background: rgba(0,0,0,0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.detail-block.result {
|
||||||
|
border-left: 2px solid #22c55e44;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Type-specific colours ── */
|
||||||
|
.hud-node.received > .node-row > .node-label { color: #f59e0b; }
|
||||||
|
.hud-node.think > .node-row > .node-label { color: #a78bfa; }
|
||||||
|
.hud-node.turn > .node-row > .node-label { color: var(--text); }
|
||||||
|
</style>
|
||||||
84
frontend/src/components/HudControls.vue
Normal file
84
frontend/src/components/HudControls.vue
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<div class="hud-controls">
|
||||||
|
<div class="btn-group primary">
|
||||||
|
<button class="control-btn" @click="$emit('new')" :disabled="!connected || isAgentRunning || handoverPending || smState === 'NO_SESSION' || smState === 'RESETTING'">NEW</button>
|
||||||
|
<button v-if="!isPublic" class="control-btn" @click="$emit('handover')" :disabled="!connected || isAgentRunning || handoverPending || smState === 'NO_SESSION' || smState === 'RESETTING'">HANDOVER</button>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group secondary">
|
||||||
|
<button class="control-btn confirm-btn" @click="$emit('confirm-new')" :disabled="!handoverPending">YES, NEW</button>
|
||||||
|
<button class="control-btn stay-btn" @click="$emit('stay')" :disabled="!handoverPending">STAY</button>
|
||||||
|
<button class="control-btn ns-btn" @click="$emit('new')" :disabled="smState !== 'NO_SESSION'">NEW SESSION</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps({
|
||||||
|
smState: String,
|
||||||
|
connected: Boolean,
|
||||||
|
isAgentRunning: Boolean,
|
||||||
|
handoverPending: Boolean,
|
||||||
|
isPublic: Boolean,
|
||||||
|
})
|
||||||
|
defineEmits(['new', 'handover', 'confirm-new', 'stay'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hud-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.btn-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 0.35rem;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
.control-btn {
|
||||||
|
height: 32px;
|
||||||
|
min-width: 90px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--bg-dim);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.control-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg);
|
||||||
|
border-color: var(--text-dim);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.control-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.stop-btn.stop-active {
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
}
|
||||||
|
.confirm-btn:not(:disabled) {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border-color: #22c55e;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
.stay-btn:not(:disabled) {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
border-color: #3b82f6;
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
.ns-btn {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
border-color: #22c55e;
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
.ns-btn:hover {
|
||||||
|
background: rgba(34, 197, 94, 0.2) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
226
frontend/src/components/HudMetrics.vue
Normal file
226
frontend/src/components/HudMetrics.vue
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
<template>
|
||||||
|
<div class="hud-metrics">
|
||||||
|
<button v-if="groups.length" class="metrics-copy-btn" @click="copyAll">
|
||||||
|
<ClipboardDocumentIcon v-if="!copied" class="w-3 h-3" />
|
||||||
|
<CheckIcon v-else class="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<div v-for="group in groups" :key="group.label" class="metric-group">
|
||||||
|
<span
|
||||||
|
v-for="item in group.items" :key="item.key"
|
||||||
|
class="m"
|
||||||
|
@click="copyToClipboard(item.title)"
|
||||||
|
@mouseenter="(e) => showTooltip(item.key, e)"
|
||||||
|
@mouseleave="activeTooltip = null"
|
||||||
|
>{{ item.key }}: <b>{{ item.value }}</b>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Tooltip rendered via Teleport → body, uses fixed positioning to avoid overflow -->
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="activeTooltip"
|
||||||
|
class="metric-tooltip"
|
||||||
|
:style="{ top: tooltipY + 'px', left: tooltipX + 'px' }"
|
||||||
|
>{{ groups.flatMap(g => g.items).find(i => i.key === activeTooltip)?.title }}</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, watch, ref } from 'vue'
|
||||||
|
import { ClipboardDocumentIcon, CheckIcon } from '@heroicons/vue/20/solid'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
usage: Object,
|
||||||
|
finance: Object,
|
||||||
|
selectedAgent: String,
|
||||||
|
allAgents: Array as () => any[],
|
||||||
|
})
|
||||||
|
|
||||||
|
const agent = computed(() => props.allAgents?.find((a: any) => a.id === props.selectedAgent))
|
||||||
|
const inn = computed(() => Number(props.usage?.input_tokens ?? props.usage?.in ?? 0))
|
||||||
|
const out = computed(() => Number(props.usage?.output_tokens ?? props.usage?.out ?? 0))
|
||||||
|
|
||||||
|
const sessionCost = computed(() => {
|
||||||
|
const a = agent.value
|
||||||
|
if (!a || a.promptPrice == null || a.completionPrice == null) return 0
|
||||||
|
return (inn.value * a.promptPrice + out.value * a.completionPrice) / 1_000_000
|
||||||
|
})
|
||||||
|
|
||||||
|
const lastTurnCost = ref(0)
|
||||||
|
const lstHistory = ref<number[]>([]) // ring buffer, last 5 LST values
|
||||||
|
|
||||||
|
watch(sessionCost, (newVal, oldVal) => {
|
||||||
|
const delta = newVal - oldVal
|
||||||
|
if (delta > 0.000001) {
|
||||||
|
lastTurnCost.value = delta
|
||||||
|
lstHistory.value = [...lstHistory.value.slice(-4), delta] // keep last 5
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const trend = computed(() => {
|
||||||
|
const h = lstHistory.value
|
||||||
|
if (h.length < 2) return null
|
||||||
|
// slope = average delta between consecutive values
|
||||||
|
let sum = 0
|
||||||
|
for (let i = 1; i < h.length; i++) sum += h[i] - h[i - 1]
|
||||||
|
return sum / (h.length - 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
const trendTitle = computed(() => {
|
||||||
|
const h = lstHistory.value
|
||||||
|
if (h.length < 2) {
|
||||||
|
const needed = 2 - h.length
|
||||||
|
return `Trend not available yet.\nNeeds at least 2 completed turns to compute slope.\nCurrently have: ${h.length} turn${h.length === 1 ? '' : 's'}.\nWill appear after ${needed} more turn${needed === 1 ? '' : 's'}.`
|
||||||
|
}
|
||||||
|
const vals = h.map((v, i) => {
|
||||||
|
const delta = i === 0 ? '' : ` (${h[i] - h[i-1] >= 0 ? '+' : ''}$${(h[i] - h[i-1]).toFixed(6)})`
|
||||||
|
return ` turn -${h.length - 1 - i}: $${v.toFixed(6)}${delta}`
|
||||||
|
}).join('\n')
|
||||||
|
const t = trend.value!
|
||||||
|
return `Source: rolling slope of last ${h.length} LST values\nFormula: avg(LST[i] - LST[i-1]) over last ${h.length} turns\nMeaning: ${t >= 0 ? 'Cost per turn is growing' : 'Cost per turn is shrinking'} (context ${t >= 0 ? 'expanding' : 'stabilising'})\nHistory:\n${vals}\nSlope: ${t >= 0 ? '+' : ''}$${t.toFixed(6)}/turn`
|
||||||
|
})
|
||||||
|
|
||||||
|
const groups = computed(() => {
|
||||||
|
const result = []
|
||||||
|
const a = agent.value
|
||||||
|
const f = props.finance
|
||||||
|
|
||||||
|
if (props.usage) {
|
||||||
|
result.push({
|
||||||
|
label: 'tokens',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'IN',
|
||||||
|
value: formatTokens(inn.value),
|
||||||
|
title: `Source: session_total_tokens (session-watcher.js, cumulative)\nMeaning: Total input tokens sent to the model this session (context window grows each turn)\nRaw: ${inn.value.toLocaleString()} tokens`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'OUT',
|
||||||
|
value: formatTokens(out.value),
|
||||||
|
title: `Source: session_total_tokens (session-watcher.js, cumulative)\nMeaning: Total output tokens generated by the model this session\nRaw: ${out.value.toLocaleString()} tokens`
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionCost.value > 0.00001) {
|
||||||
|
const costItems = []
|
||||||
|
if (sessionCost.value > 0.00001) {
|
||||||
|
costItems.push({
|
||||||
|
key: 'TOT',
|
||||||
|
value: `$${sessionCost.value.toFixed(4)}`,
|
||||||
|
title: a
|
||||||
|
? `Source: IN/OUT tokens × agent pricing from allAgents (OpenRouter model list)\nFormula: (IN × $${(a.promptPrice ?? 0).toFixed(2)}/1M) + (OUT × $${(a.completionPrice ?? 0).toFixed(2)}/1M)\nModel: ${a.modelName || a.model}\nUsage: ${inn.value.toLocaleString()} in + ${out.value.toLocaleString()} out = ${(inn.value + out.value).toLocaleString()} tokens\nTotal: $${sessionCost.value.toFixed(6)}`
|
||||||
|
: `Source: IN/OUT tokens (pricing unavailable)\nTotal: $${sessionCost.value.toFixed(6)}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastTurnCost.value > 0) {
|
||||||
|
costItems.push({
|
||||||
|
key: 'LST',
|
||||||
|
value: `$${lastTurnCost.value.toFixed(4)}`,
|
||||||
|
title: `Source: TOT delta (frontend-computed, updates when sessionCost changes)\nFormula: TOT_after - TOT_before for the last completed turn\nMeaning: True cost of the last turn including full context window\nDelta: $${lastTurnCost.value.toFixed(6)}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const t = trend.value
|
||||||
|
costItems.push({
|
||||||
|
key: 'TRD',
|
||||||
|
value: lstHistory.value.length < 2 ? 'n/a' : `${t! >= 0 ? '+' : ''}$${t!.toFixed(4)}`,
|
||||||
|
title: trendTitle.value
|
||||||
|
})
|
||||||
|
if (costItems.length) result.push({ label: 'cost', items: costItems })
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const allText = computed(() =>
|
||||||
|
groups.value.flatMap(g => g.items.map(i => `${i.key}: ${i.value}\n${i.title}`)).join('\n\n')
|
||||||
|
)
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
const activeTooltip = ref<string | null>(null)
|
||||||
|
const tooltipX = ref(0)
|
||||||
|
const tooltipY = ref(0)
|
||||||
|
|
||||||
|
function showTooltip(key: string, e: MouseEvent) {
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
tooltipX.value = Math.min(rect.left, window.innerWidth - 360);
|
||||||
|
tooltipY.value = rect.top - 8; // will be pushed up by transform in CSS
|
||||||
|
activeTooltip.value = key;
|
||||||
|
}
|
||||||
|
function copyAll() {
|
||||||
|
const text = allText.value
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => copied.value = false, 2000)
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard(val: string) {
|
||||||
|
navigator.clipboard.writeText(val).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(t: number) {
|
||||||
|
return t >= 1000 ? (t / 1000).toFixed(1) + 'k' : String(t)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hud-metrics {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 200px;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: stretch;
|
||||||
|
background: rgba(34, 197, 94, 0.05);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.metric-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.m { cursor: copy; position: relative; }
|
||||||
|
.m b { color: var(--text); font-weight: 700; margin-left: 2px; }
|
||||||
|
.metric-tooltip {
|
||||||
|
position: fixed;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.5rem 0.65rem;
|
||||||
|
font-size: var(--text-base);
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
|
min-width: 220px;
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
.metrics-copy-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 6px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: var(--text-base);
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.4;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.metrics-copy-btn:hover { opacity: 1; }
|
||||||
|
</style>
|
||||||
75
frontend/src/components/HudRow.vue
Normal file
75
frontend/src/components/HudRow.vue
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="hud-row" :class="{ 'justify-center': !devFlags.showHud }">
|
||||||
|
<div class="hud-left">
|
||||||
|
<HudControls
|
||||||
|
:smState="smState"
|
||||||
|
:connected="connected"
|
||||||
|
:isAgentRunning="isAgentRunning"
|
||||||
|
:handoverPending="handoverPending"
|
||||||
|
:isPublic="isPublic"
|
||||||
|
@new="$emit('new')"
|
||||||
|
@handover="$emit('handover')"
|
||||||
|
@stop="$emit('stop')"
|
||||||
|
@confirm-new="$emit('confirm-new')"
|
||||||
|
@stay="$emit('stay')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="devFlags.showHud" class="hud-center hidden md:flex">
|
||||||
|
<HudActions :smState="smState" :smLabel="smLabel" :statusText="statusText" :hudTree="hudTree" />
|
||||||
|
</div>
|
||||||
|
<div v-if="devFlags.showHud" class="hud-right hidden lg:flex">
|
||||||
|
<HudMetrics
|
||||||
|
:usage="sessionTotalTokens"
|
||||||
|
:finance="finance"
|
||||||
|
:selectedAgent="selectedAgent"
|
||||||
|
:allAgents="allAgents"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import HudActions from './HudActions.vue'
|
||||||
|
import HudControls from './HudControls.vue'
|
||||||
|
import HudMetrics from './HudMetrics.vue'
|
||||||
|
import { useDevFlags } from '../composables/useDevFlags'
|
||||||
|
|
||||||
|
const devFlags = useDevFlags()
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
smState: String,
|
||||||
|
smLabel: String,
|
||||||
|
statusText: String,
|
||||||
|
hudTree: Array as () => import('../composables/sessionHistory').HudNode[],
|
||||||
|
sessionTotalTokens: Object,
|
||||||
|
finance: Object,
|
||||||
|
selectedAgent: String,
|
||||||
|
allAgents: Array,
|
||||||
|
connected: Boolean,
|
||||||
|
isAgentRunning: Boolean,
|
||||||
|
handoverPending: Boolean,
|
||||||
|
isPublic: Boolean,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['new', 'handover', 'stop', 'confirm-new', 'stay'])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hud-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: 100%;
|
||||||
|
height: 90px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-left { flex: 0 0 auto; display: flex; align-items: center; height: 100%; }
|
||||||
|
.hud-center { flex: 2; min-width: 0; align-items: stretch; height: 100%; overflow: hidden; }
|
||||||
|
.hud-right { flex: 1; min-width: 200px; align-items: stretch; height: 100%; }
|
||||||
|
|
||||||
|
/* hud-center / hud-right gated by showHud dev flag */
|
||||||
|
</style>
|
||||||
29
frontend/src/components/MessageFrame.vue
Normal file
29
frontend/src/components/MessageFrame.vue
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<div class="message" :class="role">
|
||||||
|
<button v-if="role !== 'system'" class="copy-btn" @click="doCopy" title="Copy">⎘</button>
|
||||||
|
<slot />
|
||||||
|
<div v-if="$slots.footer" class="bubble-footer">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
role: 'user' | 'assistant' | 'system';
|
||||||
|
copyContent?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function doCopy() {
|
||||||
|
if (props.copyContent !== undefined) {
|
||||||
|
navigator.clipboard.writeText(props.copyContent).catch(() => {
|
||||||
|
const el = document.createElement('textarea');
|
||||||
|
el.value = props.copyContent!;
|
||||||
|
document.body.appendChild(el);
|
||||||
|
el.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
133
frontend/src/components/SidebarPanel.vue
Normal file
133
frontend/src/components/SidebarPanel.vue
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<div class="sidebar-panel-section" :class="{ collapsed: !isOpen, 'is-grow': grow && isOpen }">
|
||||||
|
<div v-if="label" class="sidebar-panel-header-row" @click="toggle">
|
||||||
|
<span class="sidebar-panel-line" />
|
||||||
|
<span class="sidebar-panel-label">{{ label }}</span>
|
||||||
|
<span class="sidebar-panel-line" />
|
||||||
|
<template v-if="collapsible">
|
||||||
|
<ChevronDownIcon v-if="isOpen" class="sidebar-panel-chevron w-3 h-3" />
|
||||||
|
<ChevronRightIcon v-else class="sidebar-panel-chevron w-3 h-3" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<OverlayScrollbarsComponent
|
||||||
|
v-if="isOpen"
|
||||||
|
class="sidebar-panel-content"
|
||||||
|
:style="maxHeight ? { maxHeight } : {}"
|
||||||
|
:options="scrollbarOptions"
|
||||||
|
element="div"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</OverlayScrollbarsComponent>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-vue';
|
||||||
|
import { scrollbarOptions } from '../composables/useScrollbar';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label?: string;
|
||||||
|
storageKey: string;
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
maxHeight?: string;
|
||||||
|
grow?: boolean;
|
||||||
|
radioGroup?: string;
|
||||||
|
collapsible?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'sidebar_panel_';
|
||||||
|
const stored = sessionStorage.getItem(STORAGE_PREFIX + props.storageKey);
|
||||||
|
const isOpen = ref(stored !== null ? stored === 'true' : (props.defaultOpen ?? true));
|
||||||
|
|
||||||
|
// Radio group: only one panel open at a time within a group
|
||||||
|
const radioRegistry = new Map<string, Set<() => void>>();
|
||||||
|
|
||||||
|
function getGroup(name: string): Set<() => void> {
|
||||||
|
if (!radioRegistry.has(name)) radioRegistry.set(name, new Set());
|
||||||
|
return radioRegistry.get(name)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
let collapseMe: (() => void) | null = null;
|
||||||
|
|
||||||
|
if (props.radioGroup) {
|
||||||
|
collapseMe = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
sessionStorage.setItem(STORAGE_PREFIX + props.storageKey, 'false');
|
||||||
|
};
|
||||||
|
getGroup(props.radioGroup).add(collapseMe);
|
||||||
|
}
|
||||||
|
|
||||||
|
import { onUnmounted } from 'vue';
|
||||||
|
if (props.radioGroup && collapseMe) {
|
||||||
|
onUnmounted(() => {
|
||||||
|
getGroup(props.radioGroup!).delete(collapseMe!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
const opening = !isOpen.value;
|
||||||
|
if (opening && props.radioGroup) {
|
||||||
|
// Collapse all others in the group
|
||||||
|
for (const fn of getGroup(props.radioGroup)) {
|
||||||
|
if (fn !== collapseMe) fn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isOpen.value = opening;
|
||||||
|
sessionStorage.setItem(STORAGE_PREFIX + props.storageKey, String(isOpen.value));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sidebar-panel-section {
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-panel-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.sidebar-panel-header-row:hover .sidebar-panel-chevron { opacity: 1; }
|
||||||
|
|
||||||
|
.sidebar-panel-line {
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border);
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-panel-label {
|
||||||
|
font-size: var(--text-base);
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-panel-chevron {
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.3;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-panel-content {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-panel-section.is-grow {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.sidebar-panel-section.is-grow .sidebar-panel-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
154
frontend/src/components/SystemMessage.vue
Normal file
154
frontend/src/components/SystemMessage.vue
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
<template>
|
||||||
|
<div class="message-hud">
|
||||||
|
<!-- Boundary Headline: Agent name (Header) -->
|
||||||
|
<div v-if="msg.type === 'headline' && msg.headlineKind !== 'new-session' && msg.position !== 'footer'" class="headline-container headline-header">
|
||||||
|
<div class="headline-line"></div>
|
||||||
|
<div class="headline-text">{{ msg.content }}</div>
|
||||||
|
<div class="headline-line"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Boundary Headline: Agent name (Footer) -->
|
||||||
|
<div v-else-if="msg.type === 'headline' && msg.headlineKind !== 'new-session' && msg.position === 'footer'" class="headline-footer-wrapper">
|
||||||
|
<div class="headline-container headline-footer">
|
||||||
|
<div class="headline-line"></div>
|
||||||
|
<div class="headline-text">{{ msg.content }}</div>
|
||||||
|
<div class="headline-line"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Boundary Headline: New session (subheadline below agent name) -->
|
||||||
|
<div v-else-if="msg.type === 'headline' && msg.headlineKind === 'new-session'" class="headline-new-session">
|
||||||
|
<span class="new-session-text">{{ msg.content }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Grouped System Messages -->
|
||||||
|
<div v-else-if="msg.role === 'system_group' || msg.messages" class="system-group">
|
||||||
|
<div class="system-group-header" @click="isCollapsed = !isCollapsed">
|
||||||
|
<Cog6ToothIcon class="system-group-icon-svg" />
|
||||||
|
<span class="system-group-summary">{{ getSummary() }}</span>
|
||||||
|
<ChevronDownIcon class="chevron" :class="{ open: !isCollapsed }" />
|
||||||
|
</div>
|
||||||
|
<div v-if="!isCollapsed" class="system-group-content" ref="groupContentEl">
|
||||||
|
<template v-for="(subMsg, idx) in msg.messages" :key="idx">
|
||||||
|
<div class="system-item">
|
||||||
|
<template v-if="isSqlResult(subMsg.content)">
|
||||||
|
<div class="sql-table-wrap">
|
||||||
|
<table class="sql-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th v-for="(col, ci) in parseSqlTable(subMsg.content).headers" :key="ci">{{ col }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, ri) in parseSqlTable(subMsg.content).rows" :key="ri">
|
||||||
|
<td v-for="(cell, ci) in row" :key="ci">{{ cell }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="system-content raw-text" :title="subMsg.content">{{ subMsg.content }}</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fallback Single System Message -->
|
||||||
|
<div v-else class="system-group">
|
||||||
|
<div class="system-group-header">
|
||||||
|
<InformationCircleIcon class="system-group-icon-svg" />
|
||||||
|
<span class="system-group-summary">{{ msg.content }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onUpdated, nextTick } from 'vue';
|
||||||
|
import { Cog6ToothIcon, ChevronDownIcon, InformationCircleIcon } from '@heroicons/vue/20/solid';
|
||||||
|
|
||||||
|
const props = defineProps<{ msg: any }>();
|
||||||
|
|
||||||
|
const isCollapsed = ref(false);
|
||||||
|
const groupContentEl = ref<HTMLElement | null>(null);
|
||||||
|
onUpdated(() => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (groupContentEl.value) {
|
||||||
|
groupContentEl.value.scrollTop = groupContentEl.value.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function isSqlResult(content: string): boolean {
|
||||||
|
if (!content) return false;
|
||||||
|
// Tool results start with "→ " — check after stripping that prefix
|
||||||
|
const raw = content.startsWith('→ ') ? content.slice(2) : content;
|
||||||
|
const lines = raw.split('\n').filter(l => l.trim());
|
||||||
|
if (lines.length < 2) return false;
|
||||||
|
// At least 2 lines with tabs = sql result
|
||||||
|
return lines.filter(l => l.includes('\t')).length >= 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSqlTable(content: string): { headers: string[], rows: string[][] } {
|
||||||
|
const raw = content.startsWith('→ ') ? content.slice(2) : content;
|
||||||
|
const lines = raw.split('\n').filter(l => l.trim());
|
||||||
|
const [headerLine, ...dataLines] = lines;
|
||||||
|
const headers = headerLine.split('\t');
|
||||||
|
const rows = dataLines
|
||||||
|
.filter(l => !l.startsWith('… ['))
|
||||||
|
.map(l => l.split('\t'));
|
||||||
|
return { headers, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSummary() {
|
||||||
|
const msgs = props.msg.messages;
|
||||||
|
if (!msgs?.length) return 'Event';
|
||||||
|
const toolNames: string[] = [];
|
||||||
|
for (const m of msgs) {
|
||||||
|
const raw = (m.content || '') as string;
|
||||||
|
const match = raw.match(/^[^\w]*(\w+)/u);
|
||||||
|
if (match) {
|
||||||
|
const name = match[1];
|
||||||
|
if (!['true', 'false', 'null', 'done', 'ok'].includes(name.toLowerCase())) {
|
||||||
|
if (!toolNames.includes(name)) toolNames.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const callCount = msgs.length;
|
||||||
|
const label = toolNames.length ? toolNames.join(' · ') : 'Event';
|
||||||
|
return callCount === 1 ? label : `${label} · ${callCount}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.message-hud { display: contents; }
|
||||||
|
.system-group { margin: -0.75rem 0; border-radius: 8px; background: var(--bg-dim); display: inline-block; max-width: 80%; align-self: flex-start; }
|
||||||
|
.system-group-header { padding: 0.4rem 0.8rem; display: flex; align-items: center; gap: 0.5rem; cursor: pointer; user-select: none; }
|
||||||
|
.system-group-header:hover { opacity: 0.8; }
|
||||||
|
.system-group-icon-svg { width: 13px; height: 13px; color: var(--text-dim); opacity: 0.5; flex-shrink: 0; }
|
||||||
|
.system-group-summary { flex: 1; font-weight: 500; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.system-group-content { padding: 0.25rem; display: flex; flex-direction: column; gap: 0.15rem; background: rgba(0, 0, 0, 0.1); max-height: 320px; overflow-y: auto; }
|
||||||
|
.system-item { padding: 0.15rem 0.5rem; border-radius: 4px; }
|
||||||
|
.headline-container { display: flex; align-items: center; width: 100%; }
|
||||||
|
.headline-container.headline-header { margin: 0.6rem 0 0.35rem; opacity: 1; }
|
||||||
|
.headline-container.headline-footer { margin: 0; opacity: 0.45; }
|
||||||
|
.headline-footer-wrapper { margin: 0; display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.headline-text { padding: 0 0.75rem; font-weight: 700; color: var(--text-dim); white-space: nowrap; }
|
||||||
|
.headline-line { flex: 1; height: 1px; background: var(--border); }
|
||||||
|
.headline-container.headline-header .headline-line:first-child { max-width: 12px; background: var(--accent); }
|
||||||
|
.headline-container.headline-header .headline-text { color: var(--text); opacity: 0.7; }
|
||||||
|
.headline-new-session { display: flex; justify-content: center; margin: -0.5rem 0 0.75rem; opacity: 0.45; }
|
||||||
|
.new-session-text { font-weight: 500; color: var(--text-dim); }
|
||||||
|
.system-content { color: var(--text-dim); }
|
||||||
|
.system-content.raw-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; line-height: 1.4; }
|
||||||
|
.sql-table-wrap { overflow-x: auto; max-width: 600px; }
|
||||||
|
.sql-table { border-collapse: collapse; color: var(--text); white-space: nowrap; }
|
||||||
|
.sql-table th { background: var(--surface); color: var(--accent); font-weight: 600; padding: 3px 10px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||||
|
.sql-table td { padding: 2px 10px; border-bottom: 1px solid var(--border); opacity: 0.85; }
|
||||||
|
.sql-table tr:last-child td { border-bottom: none; }
|
||||||
|
.sql-table tr:hover td { background: var(--bg-dim); opacity: 1; }
|
||||||
|
.chevron { width: 14px; height: 14px; color: var(--text-dim); opacity: 0.6; flex-shrink: 0; transition: transform 0.2s ease; transform: rotate(-90deg); }
|
||||||
|
.chevron.open { transform: rotate(0deg); }
|
||||||
|
</style>
|
||||||
44
frontend/src/components/ToolIcon.vue
Normal file
44
frontend/src/components/ToolIcon.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<component :is="icon" class="tool-icon w-3.5 h-3.5 inline shrink-0" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import {
|
||||||
|
BookOpenIcon,
|
||||||
|
PencilIcon,
|
||||||
|
WrenchIcon,
|
||||||
|
DocumentPlusIcon,
|
||||||
|
BoltIcon,
|
||||||
|
GlobeAltIcon,
|
||||||
|
CpuChipIcon,
|
||||||
|
ComputerDesktopIcon,
|
||||||
|
ChatBubbleLeftIcon,
|
||||||
|
LinkIcon,
|
||||||
|
Cog6ToothIcon,
|
||||||
|
} from '@heroicons/vue/20/solid';
|
||||||
|
|
||||||
|
const props = defineProps<{ tool: string }>();
|
||||||
|
|
||||||
|
const iconMap: Record<string, any> = {
|
||||||
|
read: BookOpenIcon,
|
||||||
|
write: PencilIcon,
|
||||||
|
edit: WrenchIcon,
|
||||||
|
append: DocumentPlusIcon,
|
||||||
|
exec: BoltIcon,
|
||||||
|
web_search: GlobeAltIcon,
|
||||||
|
web_fetch: GlobeAltIcon,
|
||||||
|
memory_search: CpuChipIcon,
|
||||||
|
memory_get: CpuChipIcon,
|
||||||
|
browser: ComputerDesktopIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
const icon = computed(() => {
|
||||||
|
if (!props.tool) return BoltIcon;
|
||||||
|
const t = props.tool.toLowerCase();
|
||||||
|
if (iconMap[t]) return iconMap[t];
|
||||||
|
if (t.includes('message')) return ChatBubbleLeftIcon;
|
||||||
|
if (t.includes('session')) return LinkIcon;
|
||||||
|
return Cog6ToothIcon;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
221
frontend/src/components/TreeNode.vue
Normal file
221
frontend/src/components/TreeNode.vue
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tree-node">
|
||||||
|
<!-- Directory row: arrow toggles tree, name selects directory -->
|
||||||
|
<div
|
||||||
|
v-if="!hideSelf"
|
||||||
|
class="tree-row dir-row"
|
||||||
|
:class="{ active: path === activePath, 'is-leaf': isLeaf }"
|
||||||
|
:style="{ paddingLeft: `${depth * 12 + 14}px` }"
|
||||||
|
@click.stop="selectDir"
|
||||||
|
>
|
||||||
|
<span v-if="!isLeaf" class="chevron" @click.stop="toggle">
|
||||||
|
<ChevronDownIcon v-if="open" class="icon w-3 h-3" />
|
||||||
|
<ChevronRightIcon v-else class="icon w-3 h-3" />
|
||||||
|
</span>
|
||||||
|
<span v-else class="chevron-spacer"></span>
|
||||||
|
<span class="label">{{ label }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Children (always visible when hideSelf) -->
|
||||||
|
<div v-if="open || hideSelf" class="tree-children">
|
||||||
|
<div v-if="showLoading" class="tree-loading" :style="{ paddingLeft: `${childDepth * 12 + 14}px` }">…</div>
|
||||||
|
<div v-else-if="error" class="tree-error" :style="{ paddingLeft: `${childDepth * 12 + 14}px` }">{{ error }}</div>
|
||||||
|
<template v-else>
|
||||||
|
<!-- Subdirectories (recursive) -->
|
||||||
|
<TreeNode
|
||||||
|
v-for="dir in dirs"
|
||||||
|
:key="path + '/' + dir"
|
||||||
|
:label="dir"
|
||||||
|
:path="path + '/' + dir"
|
||||||
|
:token="token"
|
||||||
|
:active-path="activePath"
|
||||||
|
:expand-to="expandTo"
|
||||||
|
:depth="childDepth"
|
||||||
|
:folders-only="foldersOnly"
|
||||||
|
@select="$emit('select', $event)"
|
||||||
|
/>
|
||||||
|
<!-- Files (hidden in foldersOnly mode) -->
|
||||||
|
<template v-if="!foldersOnly">
|
||||||
|
<div
|
||||||
|
v-for="file in files"
|
||||||
|
:key="file.path"
|
||||||
|
class="tree-row file-row"
|
||||||
|
:class="{ active: file.path === activePath }"
|
||||||
|
:style="{ paddingLeft: `${childDepth * 12 + 14}px` }"
|
||||||
|
@click="$emit('select', file.path)"
|
||||||
|
>
|
||||||
|
<DocumentIcon class="icon file-icon w-3 h-3" />
|
||||||
|
<span class="label">{{ file.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="!loading && !dirs.length && !foldersOnly && !files.length" class="tree-empty" :style="{ paddingLeft: `${childDepth * 12 + 14}px` }">empty</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
import { ChevronDownIcon, ChevronRightIcon, DocumentIcon } from '@heroicons/vue/20/solid';
|
||||||
|
import { viewerOpenDirs, useViewerStore } from '../store';
|
||||||
|
import { getApiBase } from '../utils/apiBase';
|
||||||
|
import { ws } from '../store';
|
||||||
|
|
||||||
|
interface FileEntry { name: string; path: string; mtime: number; }
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string;
|
||||||
|
path: string;
|
||||||
|
token: string;
|
||||||
|
activePath: string;
|
||||||
|
expandTo: string; // path to auto-expand toward (e.g. on F5 load)
|
||||||
|
depth: number;
|
||||||
|
hideSelf?: boolean; // hide own dir row, show children directly (used for root-less trees)
|
||||||
|
foldersOnly?: boolean; // only show directories, hide files
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'select', path: string): void }>();
|
||||||
|
|
||||||
|
// When hideSelf, children render at same depth (no parent row taking space)
|
||||||
|
const childDepth = computed(() => props.hideSelf ? props.depth : props.depth + 1);
|
||||||
|
|
||||||
|
// Track whether we've fetched at least once
|
||||||
|
const fetched = ref(false);
|
||||||
|
// In foldersOnly mode: show chevron only when we KNOW there are subdirs
|
||||||
|
// Before fetch: no chevron (prevent flicker). After fetch: chevron only if dirs exist.
|
||||||
|
const hasSubdirs = computed(() => dirs.value.length > 0);
|
||||||
|
const isLeaf = computed(() => props.foldersOnly ? !hasSubdirs.value : false);
|
||||||
|
|
||||||
|
const viewerStore = useViewerStore();
|
||||||
|
|
||||||
|
// open state is driven by the shared store — survives tab switches
|
||||||
|
const open = computed(() => viewerOpenDirs.has(props.path));
|
||||||
|
const loading = ref(false);
|
||||||
|
const showLoading = ref(false);
|
||||||
|
let loadingTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const error = ref('');
|
||||||
|
const dirs = ref<string[]>([]);
|
||||||
|
const files = ref<FileEntry[]>([]);
|
||||||
|
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
return getApiBase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTree(_retry = false) {
|
||||||
|
if (loading.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
showLoading.value = false;
|
||||||
|
if (loadingTimer) clearTimeout(loadingTimer);
|
||||||
|
loadingTimer = setTimeout(() => { showLoading.value = true; }, 3000);
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const base = getBaseUrl();
|
||||||
|
const url = `${base}/api/viewer/tree?root=${encodeURIComponent(props.path)}&token=${encodeURIComponent(props.token)}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.status === 401 && !_retry) {
|
||||||
|
loading.value = false;
|
||||||
|
viewerStore.invalidate();
|
||||||
|
await viewerStore.acquire(true);
|
||||||
|
return fetchTree(true);
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
||||||
|
const data = await res.json();
|
||||||
|
dirs.value = data.dirs || [];
|
||||||
|
files.value = data.files || [];
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message || 'Failed to load';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
showLoading.value = false;
|
||||||
|
fetched.value = true;
|
||||||
|
if (loadingTimer) { clearTimeout(loadingTimer); loadingTimer = null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOpen(val: boolean) {
|
||||||
|
if (val) viewerOpenDirs.add(props.path);
|
||||||
|
else viewerOpenDirs.delete(props.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
const next = !open.value;
|
||||||
|
setOpen(next);
|
||||||
|
if (next && !dirs.value.length && !files.value.length && !loading.value && !error.value) {
|
||||||
|
fetchTree();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDir() {
|
||||||
|
// Select directory (show contents in viewer) without toggling tree
|
||||||
|
emit('select', props.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-expand toward expandTo — opens this dir and fetches children if needed
|
||||||
|
function maybeExpand(expandTo: string) {
|
||||||
|
if (expandTo && expandTo.startsWith(props.path + '/') && !open.value) {
|
||||||
|
setOpen(true);
|
||||||
|
// Fetch children so subdirs mount and can expand further down the path
|
||||||
|
if (!dirs.value.length && !files.value.length) fetchTree();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Listen for directory changes (new/deleted files)
|
||||||
|
const { onMessage } = ws;
|
||||||
|
let unsubDirWatch: (() => void) | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// hideSelf: force-open this node so children always render
|
||||||
|
if (props.hideSelf && !open.value) setOpen(true);
|
||||||
|
// Restore previously-open dir (from store) — fetch children so subtree renders
|
||||||
|
if ((open.value || props.hideSelf) && !dirs.value.length && !files.value.length) fetchTree();
|
||||||
|
// foldersOnly: pre-fetch to detect leaf dirs (no subdirs → hide chevron)
|
||||||
|
else if (props.foldersOnly && !fetched.value && !loading.value) fetchTree();
|
||||||
|
maybeExpand(props.expandTo);
|
||||||
|
|
||||||
|
unsubDirWatch = onMessage((data: any) => {
|
||||||
|
if (data.type === 'viewer_tree_changed' && data.path === props.path && open.value) {
|
||||||
|
fetchTree();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
onUnmounted(() => { if (unsubDirWatch) unsubDirWatch(); });
|
||||||
|
watch(() => props.expandTo, maybeExpand);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tree-node { user-select: none; }
|
||||||
|
|
||||||
|
.tree-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
padding-right: 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text, #ccc);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.tree-row:hover { background: var(--hover-bg, rgba(255,255,255,0.05)); }
|
||||||
|
.tree-row.active { background: color-mix(in srgb, var(--accent) 12%, transparent); }
|
||||||
|
.dir-row.active { color: var(--text, #ccc); }
|
||||||
|
.file-row.active { color: var(--text, #ccc); }
|
||||||
|
|
||||||
|
.dir-row { color: var(--text-dim, #71B095); font-weight: 500; }
|
||||||
|
.chevron { display: flex; align-items: center; cursor: pointer; padding: 2px; flex-shrink: 0; opacity: 0.6; }
|
||||||
|
.chevron:hover { opacity: 1; }
|
||||||
|
.chevron-spacer { width: 16px; flex-shrink: 0; }
|
||||||
|
.icon { flex-shrink: 0; font-style: normal; width: 12px; text-align: center; }
|
||||||
|
.file-icon { }
|
||||||
|
.label { overflow: hidden; text-overflow: ellipsis; flex: 1; }
|
||||||
|
|
||||||
|
.tree-loading, .tree-error, .tree-empty {
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.tree-error { color: var(--error, #e06c75); }
|
||||||
|
</style>
|
||||||
135
frontend/src/components/TtsPlayerBar.vue
Normal file
135
frontend/src/components/TtsPlayerBar.vue
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="tts.state.value !== 'idle'" class="tts-player-bar">
|
||||||
|
<button class="tts-nav-btn" @click="tts.prev()" title="Previous">
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"><path d="M15.707 15.707a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 010 1.414zm-6 0a1 1 0 01-1.414 0l-5-5a1 1 0 010-1.414l5-5a1 1 0 011.414 1.414L5.414 10l4.293 4.293a1 1 0 010 1.414z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="tts-play-btn" @click="togglePlay" :title="tts.state.value === 'playing' ? 'Pause' : 'Play'">
|
||||||
|
<span v-if="tts.state.value === 'loading'" class="tts-bar-spinner"></span>
|
||||||
|
<svg v-else-if="tts.state.value === 'playing'" class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.75 3a.75.75 0 00-.75.75v12.5a.75.75 0 001.5 0V3.75A.75.75 0 005.75 3zm8.5 0a.75.75 0 00-.75.75v12.5a.75.75 0 001.5 0V3.75a.75.75 0 00-.75-.75z" clip-rule="evenodd"/></svg>
|
||||||
|
<svg v-else class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"><path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="tts-nav-btn" @click="tts.next()" title="Next">
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"><path d="M4.293 15.707a1 1 0 010-1.414L8.586 10 4.293 5.707a1 1 0 011.414-1.414l5 5a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0zm6 0a1 1 0 010-1.414L14.586 10l-4.293-4.293a1 1 0 011.414-1.414l5 5a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0z"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="tts-progress" @click="onSeek">
|
||||||
|
<div class="tts-progress-fill" :style="{ width: (tts.progress.value * 100) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="tts-time">{{ formatTime(tts.currentTime.value) }} / {{ formatTime(tts.duration.value) }}</span>
|
||||||
|
|
||||||
|
<span class="tts-snippet">{{ tts.currentTrack.value?.snippet || '' }}</span>
|
||||||
|
|
||||||
|
<button class="tts-close-btn" @click="tts.stop()" title="Close">
|
||||||
|
<svg class="w-4 h-4" viewBox="0 0 20 20" fill="currentColor"><path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useTtsPlayer } from '../composables/useTtsPlayer';
|
||||||
|
|
||||||
|
const tts = useTtsPlayer();
|
||||||
|
|
||||||
|
function togglePlay() {
|
||||||
|
if (tts.state.value === 'playing') tts.pause();
|
||||||
|
else if (tts.state.value === 'paused') tts.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSeek(e: MouseEvent) {
|
||||||
|
const el = e.currentTarget as HTMLElement;
|
||||||
|
const fraction = e.offsetX / el.clientWidth;
|
||||||
|
tts.seek(Math.max(0, Math.min(1, fraction)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(secs: number): string {
|
||||||
|
if (!secs || !isFinite(secs)) return '0:00';
|
||||||
|
const m = Math.floor(secs / 60);
|
||||||
|
const s = Math.floor(secs % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tts-player-bar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 15;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
animation: slideDown 0.2s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideDown { from { transform: translateY(-100%); } to { transform: translateY(0); } }
|
||||||
|
|
||||||
|
.tts-nav-btn, .tts-play-btn, .tts-close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-dim);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.tts-nav-btn:hover, .tts-play-btn:hover, .tts-close-btn:hover { color: var(--text); }
|
||||||
|
.tts-play-btn { color: var(--accent); }
|
||||||
|
.tts-play-btn:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.tts-progress {
|
||||||
|
flex: 1;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
.tts-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-time {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-snippet {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tts-bar-spinner {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.w-4 { width: 16px; height: 16px; }
|
||||||
|
|
||||||
|
@media (max-width: 639px) {
|
||||||
|
.tts-snippet { display: none; }
|
||||||
|
.tts-time { min-width: 50px; font-size: 0.68rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
86
frontend/src/components/UsageDisplay.vue
Normal file
86
frontend/src/components/UsageDisplay.vue
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="usage"
|
||||||
|
class="usage-display"
|
||||||
|
:title="computationTitle"
|
||||||
|
@click="copyMeta"
|
||||||
|
style="cursor: pointer;"
|
||||||
|
>
|
||||||
|
{{ totalCostLabel }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import type { Agent } from '../composables/agents';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
usage: any;
|
||||||
|
agentId: string | null;
|
||||||
|
allAgents: Agent[];
|
||||||
|
type?: 'turn' | 'session';
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const agent = computed(() => props.allAgents?.find(a => a.id === props.agentId));
|
||||||
|
|
||||||
|
const pricing = computed(() => {
|
||||||
|
const a = agent.value;
|
||||||
|
if (!a || a.promptPrice === null || a.completionPrice === null) return null;
|
||||||
|
return { prompt: a.promptPrice, completion: a.completionPrice };
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCostLabel = computed(() => {
|
||||||
|
const inn = props.usage?.input_tokens ?? props.usage?.in ?? 0;
|
||||||
|
const out = props.usage?.output_tokens ?? props.usage?.out ?? 0;
|
||||||
|
const pricingVal = pricing.value;
|
||||||
|
if (pricingVal) {
|
||||||
|
const total = (inn / 1_000_000) * pricingVal.prompt + (out / 1_000_000) * pricingVal.completion;
|
||||||
|
return `$${total.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
// fallback to backend cost
|
||||||
|
const cost = typeof props.usage?.cost === 'object' ? (props.usage.cost?.total ?? 0) : Number(props.usage?.cost || 0);
|
||||||
|
return cost > 0 ? `$${cost.toFixed(4)}` : `${(inn + out).toLocaleString()} tokens`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const computationTitle = computed(() => {
|
||||||
|
const inn = props.usage?.input_tokens ?? props.usage?.in ?? 0;
|
||||||
|
const out = props.usage?.output_tokens ?? props.usage?.out ?? 0;
|
||||||
|
|
||||||
|
const typeLabel = props.type === 'session' ? '[SESSION TOTAL]' : '[SINGLE TURN]';
|
||||||
|
const modelName = agent.value?.modelName || agent.value?.model || 'Unknown model';
|
||||||
|
const pricingVal = pricing.value;
|
||||||
|
const pricingInfo = pricingVal
|
||||||
|
? `promptPrice: $${pricingVal.prompt.toFixed(2)}/1M | completionPrice: $${pricingVal.completion.toFixed(2)}/1M`
|
||||||
|
: `Pricing: Not available for this model`;
|
||||||
|
|
||||||
|
if (!pricingVal) {
|
||||||
|
const costValue = typeof props.usage?.cost === 'object' ? (props.usage.cost?.total ?? 0) : Number(props.usage?.cost || 0);
|
||||||
|
return `${typeLabel}\nModel: ${modelName}\n${pricingInfo}\nTokens: ${(inn + out).toLocaleString()} | backendCost: $${costValue.toFixed(4)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inCost = (inn / 1_000_000) * pricingVal.prompt;
|
||||||
|
const outCost = (out / 1_000_000) * pricingVal.completion;
|
||||||
|
const total = inCost + outCost;
|
||||||
|
|
||||||
|
return `${typeLabel}\nModel: ${modelName}\n${pricingInfo}\nUsage: ${inn.toLocaleString()} in + ${out.toLocaleString()} out = ${(inn + out).toLocaleString()} tokens\nCalculation: (${inn.toLocaleString()} * $${pricingVal.prompt.toFixed(2)}/1M) + (${out.toLocaleString()} * $${pricingVal.completion.toFixed(2)}/1M) = $${total.toFixed(4)}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function copyMeta() {
|
||||||
|
navigator.clipboard.writeText(computationTitle.value).then(() => {
|
||||||
|
// Optional: could add a temporary success state/toast here
|
||||||
|
console.log('Copied usage meta to clipboard');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.usage-display {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
115
frontend/src/components/UserMessage.vue
Normal file
115
frontend/src/components/UserMessage.vue
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
<template>
|
||||||
|
<MessageFrame role="user" :copyContent="msg.content">
|
||||||
|
<!-- Voice audio: server URL (persists F5) or local blob (temporary) -->
|
||||||
|
<audio v-if="msg.voiceAudioUrl" controls :src="msg.voiceAudioUrl" class="user-att-audio"></audio>
|
||||||
|
<audio v-else-if="audioAttachment" controls :src="audioAttachment.dataUrl" class="user-att-audio"></audio>
|
||||||
|
<!-- Pending transcription indicator -->
|
||||||
|
<div v-if="msg.pending" class="voice-pending">transcribing...</div>
|
||||||
|
<!-- Text content -->
|
||||||
|
<div v-if="msg.content">{{ msg.content }}</div>
|
||||||
|
<!-- Non-audio attachments -->
|
||||||
|
<div v-if="nonAudioAttachments.length" class="user-attachments">
|
||||||
|
<template v-for="(att, i) in nonAudioAttachments" :key="i">
|
||||||
|
<div v-if="att.mimeType === 'application/pdf'" class="user-att-pdf" :title="att.fileName || 'PDF'">
|
||||||
|
<span class="pdf-icon">📄</span>
|
||||||
|
<span class="pdf-name">{{ att.fileName || 'document.pdf' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="user-att-thumb" @click="expandImage(att.dataUrl)">
|
||||||
|
<img :src="att.dataUrl" :alt="att.fileName || 'image'" loading="lazy" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<template v-if="username" #footer>{{ username }}</template>
|
||||||
|
</MessageFrame>
|
||||||
|
<!-- Lightbox -->
|
||||||
|
<div v-if="expandedSrc" class="lightbox-overlay" @click="expandedSrc = ''">
|
||||||
|
<img :src="expandedSrc" class="lightbox-img" @click.stop />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import MessageFrame from './MessageFrame.vue';
|
||||||
|
import { auth } from '../store';
|
||||||
|
|
||||||
|
const props = defineProps<{ msg: any }>();
|
||||||
|
|
||||||
|
const username = auth.currentUser;
|
||||||
|
const expandedSrc = ref('');
|
||||||
|
function expandImage(src: string) { expandedSrc.value = src; }
|
||||||
|
|
||||||
|
const audioAttachment = computed(() =>
|
||||||
|
(props.msg.attachments || []).find((a: any) => a.mimeType?.startsWith('audio/')));
|
||||||
|
const nonAudioAttachments = computed(() =>
|
||||||
|
(props.msg.attachments || []).filter((a: any) => !a.mimeType?.startsWith('audio/')));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.user-attachments {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.user-att-thumb {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.user-att-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.user-att-thumb:hover img { transform: scale(1.05); }
|
||||||
|
.user-att-audio {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 250px;
|
||||||
|
max-width: 300px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-att-pdf {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.pdf-icon { font-size: 1.2rem; }
|
||||||
|
.pdf-name { max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
.voice-pending {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 0.9; } }
|
||||||
|
|
||||||
|
.lightbox-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
background: rgba(0,0,0,0.85);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lightbox-img {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
172
frontend/src/components/WebGLBackground.vue
Normal file
172
frontend/src/components/WebGLBackground.vue
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<canvas ref="canvas" class="webgl-bg" :class="{ ready }"></canvas>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
const canvas = ref<HTMLCanvasElement>();
|
||||||
|
const ready = ref(false);
|
||||||
|
let ctx: WebGLRenderingContext | null = null;
|
||||||
|
let animationId: number;
|
||||||
|
let resizeHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
const vertexShader = `
|
||||||
|
attribute vec2 a_position;
|
||||||
|
uniform float u_aspect;
|
||||||
|
void main() {
|
||||||
|
vec2 pos = a_position;
|
||||||
|
pos.x /= u_aspect;
|
||||||
|
gl_Position = vec4(pos, 0, 1);
|
||||||
|
gl_PointSize = 7.0;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const fragmentShader = `
|
||||||
|
precision mediump float;
|
||||||
|
uniform vec3 u_color;
|
||||||
|
void main() {
|
||||||
|
float d = distance(gl_PointCoord, vec2(0.5));
|
||||||
|
if (d > 0.5) discard;
|
||||||
|
float alpha = (0.5 - d) * 2.0;
|
||||||
|
gl_FragColor = vec4(u_color, alpha * 0.9);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function createShader(gl: WebGLRenderingContext, type: number, source: string) {
|
||||||
|
const shader = gl.createShader(type);
|
||||||
|
if (!shader) return null;
|
||||||
|
gl.shaderSource(shader, source);
|
||||||
|
gl.compileShader(shader);
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
console.error(gl.getShaderInfoLog(shader));
|
||||||
|
gl.deleteShader(shader);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgram(gl: WebGLRenderingContext, vs: WebGLShader, fs: WebGLShader) {
|
||||||
|
const program = gl.createProgram();
|
||||||
|
if (!program) return null;
|
||||||
|
gl.attachShader(program, vs);
|
||||||
|
gl.attachShader(program, fs);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
|
console.error(gl.getProgramInfoLog(program));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const c = canvas.value;
|
||||||
|
if (!c) return;
|
||||||
|
|
||||||
|
resizeHandler = () => {
|
||||||
|
c.width = window.innerWidth;
|
||||||
|
c.height = window.innerHeight;
|
||||||
|
};
|
||||||
|
resizeHandler();
|
||||||
|
window.addEventListener('resize', resizeHandler);
|
||||||
|
|
||||||
|
ctx = c.getContext('webgl', { alpha: true, premultipliedAlpha: false });
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
const gl = ctx;
|
||||||
|
// Immediately clear to transparent — prevents white buffer flash
|
||||||
|
gl.clearColor(0, 0, 0, 0);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
const vs = createShader(gl, gl.VERTEX_SHADER, vertexShader);
|
||||||
|
const fs = createShader(gl, gl.FRAGMENT_SHADER, fragmentShader);
|
||||||
|
if (!vs || !fs) return;
|
||||||
|
|
||||||
|
const program = createProgram(gl, vs, fs);
|
||||||
|
if (!program) return;
|
||||||
|
|
||||||
|
const positionLoc = gl.getAttribLocation(program, 'a_position');
|
||||||
|
const colorLoc = gl.getUniformLocation(program, 'u_color');
|
||||||
|
const aspectLoc = gl.getUniformLocation(program, 'u_aspect');
|
||||||
|
|
||||||
|
// Particles
|
||||||
|
const particleCount = 150;
|
||||||
|
const positions = new Float32Array(particleCount * 2);
|
||||||
|
const velocities = new Float32Array(particleCount * 2);
|
||||||
|
|
||||||
|
const initAspect = c.width / c.height;
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
positions[i * 2] = (Math.random() * 2 - 1) * initAspect;
|
||||||
|
positions[i * 2 + 1] = (Math.random() * 2 - 1);
|
||||||
|
velocities[i * 2] = (Math.random() - 0.5) * 0.0006;
|
||||||
|
velocities[i * 2 + 1] = (Math.random() - 0.5) * 0.0006;
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
|
||||||
|
gl.enableVertexAttribArray(positionLoc);
|
||||||
|
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
|
||||||
|
|
||||||
|
gl.useProgram(program);
|
||||||
|
// Read particle color from theme's --secondary CSS variable
|
||||||
|
const cssColor = getComputedStyle(document.documentElement).getPropertyValue('--secondary').trim();
|
||||||
|
let pr = 0.4, pg = 0.6, pb = 1.0; // fallback
|
||||||
|
if (cssColor.startsWith('#') && cssColor.length >= 7) {
|
||||||
|
pr = parseInt(cssColor.slice(1, 3), 16) / 255;
|
||||||
|
pg = parseInt(cssColor.slice(3, 5), 16) / 255;
|
||||||
|
pb = parseInt(cssColor.slice(5, 7), 16) / 255;
|
||||||
|
}
|
||||||
|
gl.uniform3f(colorLoc, pr, pg, pb);
|
||||||
|
|
||||||
|
let frameCount = 0;
|
||||||
|
function animate() {
|
||||||
|
if (!ready.value && ++frameCount > 30) ready.value = true;
|
||||||
|
gl.viewport(0, 0, c.width, c.height);
|
||||||
|
gl.uniform1f(aspectLoc, c.width / c.height);
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||||
|
gl.clearColor(0, 0, 0, 0);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
|
||||||
|
// Update positions
|
||||||
|
const aspect = c.width / c.height;
|
||||||
|
for (let i = 0; i < particleCount; i++) {
|
||||||
|
positions[i * 2] += velocities[i * 2];
|
||||||
|
positions[i * 2 + 1] += velocities[i * 2 + 1];
|
||||||
|
|
||||||
|
// Wrap around (preserve offset to avoid stacking at edges)
|
||||||
|
if (positions[i * 2] > aspect) positions[i * 2] -= 2 * aspect;
|
||||||
|
if (positions[i * 2] < -aspect) positions[i * 2] += 2 * aspect;
|
||||||
|
if (positions[i * 2 + 1] > 1) positions[i * 2 + 1] -= 2;
|
||||||
|
if (positions[i * 2 + 1] < -1) positions[i * 2 + 1] += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, positions, gl.DYNAMIC_DRAW);
|
||||||
|
gl.drawArrays(gl.POINTS, 0, particleCount);
|
||||||
|
|
||||||
|
animationId = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
animate();
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
cancelAnimationFrame(animationId);
|
||||||
|
if (resizeHandler) window.removeEventListener('resize', resizeHandler);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.webgl-bg {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.webgl-bg.ready {
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
218
frontend/src/composables/agents.ts
Normal file
218
frontend/src/composables/agents.ts
Normal file
@ -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<Agent[]> = 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<string> = 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<string> = ref('titan'); // Default fallback
|
||||||
|
const _allowedAgentIds: Ref<string[]> = ref([]); // List of agent IDs allowed for the current user
|
||||||
|
|
||||||
|
export function useAgents(connected: Ref<boolean>) {
|
||||||
|
// --- State --- //
|
||||||
|
const allAgents = _allAgents;
|
||||||
|
const selectedAgent = _selectedAgent;
|
||||||
|
const selectedMode = _selectedMode;
|
||||||
|
const defaultAgent = _defaultAgent;
|
||||||
|
const allowedAgentIds = _allowedAgentIds;
|
||||||
|
|
||||||
|
const agentModels: Ref<any[]> = 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<void> {
|
||||||
|
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<Record<string, { private: ChannelInfo | null; public: ChannelInfo | null }>>({});
|
||||||
|
let _pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let _pollIdx = 0;
|
||||||
|
|
||||||
|
async function fetchOneAgent(agentId: string): Promise<void> {
|
||||||
|
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<string> | null = null;
|
||||||
|
function setCurrentUser(userRef: Ref<string>) { 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
73
frontend/src/composables/auth.ts
Normal file
73
frontend/src/composables/auth.ts
Normal file
@ -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<boolean> = ref(!!localStorage.getItem(SESSION_TOKEN_KEY));
|
||||||
|
const loginToken: Ref<string> = ref('');
|
||||||
|
const loginError: Ref<string> = ref('');
|
||||||
|
const loggingIn: Ref<boolean> = ref(false);
|
||||||
|
|
||||||
|
async function doLogin(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
450
frontend/src/composables/sessionHistory.ts
Normal file
450
frontend/src/composables/sessionHistory.ts
Normal file
@ -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<string, any>
|
||||||
|
result?: Record<string, any>
|
||||||
|
payload?: Record<string, any>
|
||||||
|
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 | any>(null);
|
||||||
|
let loadStartTime: number | null = null;
|
||||||
|
let pendingMessages: any[] = [];
|
||||||
|
let pendingUsageTotals: any | null = null;
|
||||||
|
|
||||||
|
const lastSystemMsgRef = ref<string | null>(null);
|
||||||
|
|
||||||
|
// ── HUD tree state ────────────────────────────────────────────────────────
|
||||||
|
const hudTree = ref<HudNode[]>([]);
|
||||||
|
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<string, HudNode>();
|
||||||
|
// Map correlationId → HudNode (turns, for parenting tools into turns)
|
||||||
|
const hudTurns = new Map<string, HudNode>();
|
||||||
|
// Secondary index: toolCallId → WeakRef<HudNode> (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<string, WeakRef<HudNode>>();
|
||||||
|
|
||||||
|
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<string | null>(null);
|
||||||
|
|
||||||
|
function makeNode(partial: Partial<HudNode>): 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<string, any>, result?: Record<string, any>): 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<string>,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
64
frontend/src/composables/ui.ts
Normal file
64
frontend/src/composables/ui.ts
Normal file
@ -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<string>) {
|
||||||
|
const version: string = `${pkg.version}-${__BUILD__}`;
|
||||||
|
|
||||||
|
const statusClass = computed<string>(() => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
37
frontend/src/composables/useAgentDisplay.ts
Normal file
37
frontend/src/composables/useAgentDisplay.ts
Normal file
@ -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<string>,
|
||||||
|
defaultAgent: Ref<string>,
|
||||||
|
allAgents: Ref<Agent[]>,
|
||||||
|
) {
|
||||||
|
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<string | null>(() => {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
340
frontend/src/composables/useAgentSocket.ts
Normal file
340
frontend/src/composables/useAgentSocket.ts
Normal file
@ -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<string>,
|
||||||
|
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<string, (data: any) => 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
87
frontend/src/composables/useAttachments.ts
Normal file
87
frontend/src/composables/useAttachments.ts
Normal file
@ -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<string> {
|
||||||
|
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<Attachment[]>([]);
|
||||||
|
|
||||||
|
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 };
|
||||||
|
}
|
||||||
117
frontend/src/composables/useAudioRecorder.ts
Normal file
117
frontend/src/composables/useAudioRecorder.ts
Normal file
@ -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<typeof setInterval> | 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<void> {
|
||||||
|
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<File | null> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
97
frontend/src/composables/useBreakout.ts
Normal file
97
frontend/src/composables/useBreakout.ts
Normal file
@ -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<string, Window>();
|
||||||
|
|
||||||
|
export const PRESETS: Record<string, [number, number]> = {
|
||||||
|
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<BreakoutRequest | null> = 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<any> {
|
||||||
|
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<string, { alive: boolean }> {
|
||||||
|
const result: Record<string, { alive: boolean }> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
117
frontend/src/composables/useCapture.ts
Normal file
117
frontend/src/composables/useCapture.ts
Normal file
@ -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 };
|
||||||
|
}
|
||||||
35
frontend/src/composables/useDevFlags.ts
Normal file
35
frontend/src/composables/useDevFlags.ts
Normal file
@ -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<DevFlags>(load());
|
||||||
|
|
||||||
|
watch(flags, (v) => {
|
||||||
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(v));
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
export function useDevFlags() {
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
47
frontend/src/composables/useHandover.ts
Normal file
47
frontend/src/composables/useHandover.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { type Ref } from 'vue';
|
||||||
|
import { useChatStore } from '../store/chat';
|
||||||
|
|
||||||
|
export function useHandover(
|
||||||
|
wsSend: (payload: any) => void,
|
||||||
|
pendingClearRef: Ref<boolean>,
|
||||||
|
lastUsage: Ref<any>,
|
||||||
|
) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
30
frontend/src/composables/useHermes.ts
Normal file
30
frontend/src/composables/useHermes.ts
Normal file
@ -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<string, (...args: any[]) => void> | null;
|
||||||
|
// WebSocket
|
||||||
|
ws: WebSocket | null;
|
||||||
|
wsPing: ReturnType<typeof setInterval> | null;
|
||||||
|
wsCbs: ((data: any) => void)[];
|
||||||
|
wsBuf: any[];
|
||||||
|
wsConnected: any; // Ref<boolean>
|
||||||
|
wsStatus: any; // Ref<string>
|
||||||
|
wsUser: any; // Ref<string>
|
||||||
|
wsSid: any; // Ref<string | null>
|
||||||
|
wsInit: any; // Ref<boolean>
|
||||||
|
// Capture
|
||||||
|
captureStream: MediaStream | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHermes(): HermesRuntime {
|
||||||
|
const w = window as any;
|
||||||
|
if (!w.__hermes) w.__hermes = {};
|
||||||
|
return w.__hermes;
|
||||||
|
}
|
||||||
26
frontend/src/composables/useInputAutogrow.ts
Normal file
26
frontend/src/composables/useInputAutogrow.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { ref, watch, nextTick } from 'vue';
|
||||||
|
|
||||||
|
export function useInputAutogrow(input: ReturnType<typeof ref<string>>) {
|
||||||
|
const inputEl = ref<HTMLTextAreaElement | null>(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 };
|
||||||
|
}
|
||||||
121
frontend/src/composables/useMessageGrouping.ts
Normal file
121
frontend/src/composables/useMessageGrouping.ts
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import { computed, type Ref } from 'vue';
|
||||||
|
import type { Agent } from './agents';
|
||||||
|
|
||||||
|
export function useMessageGrouping(
|
||||||
|
messages: Ref<any[]>,
|
||||||
|
visibleCount: Ref<number>,
|
||||||
|
selectedAgent: Ref<string>,
|
||||||
|
allAgents: Ref<Agent[]>,
|
||||||
|
sessionKey?: Ref<string>,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
256
frontend/src/composables/useMessages.ts
Normal file
256
frontend/src/composables/useMessages.ts
Normal file
@ -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 `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ansiToHtml(text: string): string {
|
||||||
|
const colorMap: Record<number, string> = {
|
||||||
|
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 += '</span>'; open = false; }
|
||||||
|
if (bold) { out += '</strong>'; bold = false; }
|
||||||
|
if (dim) { out += '</span>'; dim = false; }
|
||||||
|
} else if (code === 1) {
|
||||||
|
if (!bold) { out += '<strong>'; bold = true; }
|
||||||
|
} else if (code === 2) {
|
||||||
|
if (!dim) { out += '<span style="opacity:0.45">'; dim = true; }
|
||||||
|
} else if (colorMap[code]) {
|
||||||
|
if (open) { out += '</span>'; }
|
||||||
|
out += `<span style="color:${colorMap[code]}">`;
|
||||||
|
open = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
let tail = '';
|
||||||
|
if (open) tail += '</span>';
|
||||||
|
if (bold) tail += '</strong>';
|
||||||
|
if (dim) tail += '</span>';
|
||||||
|
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 `<audio controls class="inline-audio" data-filepath="${absPath}" onplay="window.__hermesAudioSrc(this)"></audio>`;
|
||||||
|
}
|
||||||
|
return `<button class="file-download-link" data-filepath="${absPath}" data-filename="${name}" onclick="window.__hermesDownload(this)" title="Download ${name}">📎 ${name}</button>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, '<').replace(/>/g, '>');
|
||||||
|
return `<pre style="font-family:var(--font-mono);line-height:1.5;white-space:pre-wrap">${ansiToHtml(escaped)}</pre>`;
|
||||||
|
}
|
||||||
|
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<HTMLElement | null>(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<typeof setTimeout> | 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<void> {
|
||||||
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
14
frontend/src/composables/useScrollbar.ts
Normal file
14
frontend/src/composables/useScrollbar.ts
Normal file
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
268
frontend/src/composables/useTakeover.ts
Normal file
268
frontend/src/composables/useTakeover.ts
Normal file
@ -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<string, string> = {};
|
||||||
|
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<string, (args: any) => 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<string, (args: any) => Promise<any>> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
73
frontend/src/composables/useTheme.ts
Normal file
73
frontend/src/composables/useTheme.ts
Normal file
@ -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<Theme, Component> = {
|
||||||
|
titan: CommandLineIcon,
|
||||||
|
eras: SunIcon,
|
||||||
|
loop42: CubeTransparentIcon,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Display name per theme */
|
||||||
|
export const THEME_NAMES: Record<Theme, string> = {
|
||||||
|
titan: 'Titan',
|
||||||
|
eras: 'ERAS',
|
||||||
|
loop42: 'loop42',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Optional external logo per theme (e.g. customer branding). Null = use THEME_ICONS. */
|
||||||
|
export const THEME_LOGOS: Record<Theme, string | null> = {
|
||||||
|
titan: null,
|
||||||
|
eras: null,
|
||||||
|
loop42: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map agent id → theme (unlisted agents default to 'titan')
|
||||||
|
export const AGENT_THEME_MAP: Record<string, Theme> = {
|
||||||
|
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<Theme>(
|
||||||
|
stored === 'workhorse' ? 'loop42' : // migrate legacy name
|
||||||
|
(stored as Theme) || 'loop42'
|
||||||
|
);
|
||||||
|
|
||||||
|
const THEME_FAVICONS: Record<Theme, string> = {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
180
frontend/src/composables/useTtsPlayer.ts
Normal file
180
frontend/src/composables/useTtsPlayer.ts
Normal file
@ -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<TtsState>('idle');
|
||||||
|
const currentTrack = ref<TtsTrack | null>(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<string, string>();
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<typeof createTtsPlayer> | null = null;
|
||||||
|
export function useTtsPlayer() {
|
||||||
|
if (!_instance) _instance = createTtsPlayer();
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
232
frontend/src/composables/useViewer.ts
Normal file
232
frontend/src/composables/useViewer.ts
Normal file
@ -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<string[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const showLoading = ref(false);
|
||||||
|
let loadingTimer: ReturnType<typeof setTimeout> | 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
238
frontend/src/composables/ws.ts
Normal file
238
frontend/src/composables/ws.ts
Normal file
@ -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<typeof setInterval> | null = H.wsPing ?? null;
|
||||||
|
// Callbacks + buffer
|
||||||
|
const _onMessageCallbacks: ((data: any) => void)[] = H.wsCbs ?? [];
|
||||||
|
const _messageBuffer: any[] = H.wsBuf ?? [];
|
||||||
|
// Reconnect
|
||||||
|
let _reconnectTimer: ReturnType<typeof setTimeout> | 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<string> | null = null;
|
||||||
|
let _selectedModeRef: Ref<string> | null = null;
|
||||||
|
let _isLoggedInRef: Ref<boolean> | null = null;
|
||||||
|
let _loginErrorRef: Ref<string> | null = null;
|
||||||
|
let _takeover: ReturnType<typeof useTakeover> | null = null;
|
||||||
|
|
||||||
|
const connected = H.wsConnected ?? ref(false);
|
||||||
|
const status = H.wsStatus ?? ref('Disconnected');
|
||||||
|
const currentUser = H.wsUser ?? ref('');
|
||||||
|
const sessionId = H.wsSid ?? ref<string | null>(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<string>,
|
||||||
|
isLoggedInRef: Ref<boolean>,
|
||||||
|
loginErrorRef: Ref<string>,
|
||||||
|
selectedMode?: Ref<string>
|
||||||
|
): 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<typeof setInterval> | 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
40
frontend/src/main.ts
Normal file
40
frontend/src/main.ts
Normal file
@ -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');
|
||||||
25
frontend/src/router.ts
Normal file
25
frontend/src/router.ts
Normal file
@ -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;
|
||||||
5
frontend/src/shims-vue.d.ts
vendored
Normal file
5
frontend/src/shims-vue.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
const component: DefineComponent<{}, {}, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
187
frontend/src/src/App.vue
Normal file
187
frontend/src/src/App.vue
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app" class="app-container" v-cloak>
|
||||||
|
|
||||||
|
<nav class="main-nav">
|
||||||
|
<img v-if="navLogo" :src="navLogo" class="nav-agent-logo" alt="Agent" />
|
||||||
|
<RouterLink to="/" :class="{ active: route.path === '/' }">home</RouterLink>
|
||||||
|
<RouterLink to="/agents" :class="{ active: route.path === '/agents' }">agents</RouterLink>
|
||||||
|
<RouterLink :to="viewerLink" :class="{ active: route.path === '/viewer' }">viewer</RouterLink>
|
||||||
|
<RouterLink to="/dev" :class="{ active: route.path === '/dev' }">dev</RouterLink>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<template v-if="isLoggedIn">
|
||||||
|
<span class="nav-user">{{ currentUser }}</span>
|
||||||
|
<button class="nav-logout-btn" @click="doLogout(disconnect)">Logout</button>
|
||||||
|
</template>
|
||||||
|
<RouterLink v-else to="/login" class="nav-login-btn">Sign in</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div style="flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column;">
|
||||||
|
<RouterView v-if="routerReady" />
|
||||||
|
</div>
|
||||||
|
<footer class="main-footer" @click="copyVersions">
|
||||||
|
<span class="version-label">{{ copied ? '✓ copied' : `[${envLabel}] fe: ${version} | be: ${beVersion || '…'}` }}</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, computed, watch, nextTick } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { useUI } from './composables/ui';
|
||||||
|
import { ws, auth, agents, lastViewerPath } from './store';
|
||||||
|
import { THEME_LOGOS, useTheme } from './composables/useTheme';
|
||||||
|
import { useChatStore } from './store/chat';
|
||||||
|
import type { SmState } from './store/chat';
|
||||||
|
|
||||||
|
import router from './router';
|
||||||
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
const smState = computed(() => chatStore.smState);
|
||||||
|
const smLabel = computed(() => chatStore.smLabel);
|
||||||
|
|
||||||
|
const routerReady = ref(false);
|
||||||
|
router.isReady().then(() => { routerReady.value = true; });
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
function updateNavDot() {
|
||||||
|
nextTick(() => {
|
||||||
|
const nav = document.querySelector('.main-nav') as HTMLElement | null;
|
||||||
|
const active = document.querySelector('.main-nav a.active, .main-nav a.router-link-active') as HTMLElement | null;
|
||||||
|
if (!nav || !active) return;
|
||||||
|
const navRect = nav.getBoundingClientRect();
|
||||||
|
const linkRect = active.getBoundingClientRect();
|
||||||
|
const left = linkRect.left - navRect.left + linkRect.width / 2 - 2;
|
||||||
|
nav.style.setProperty('--dot-left', left + 'px');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deferredUpdateNavDot() {
|
||||||
|
requestAnimationFrame(() => setTimeout(updateNavDot, 50));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { version } = useUI(ws.status);
|
||||||
|
const { isLoggedIn, loginToken, loginError, doLogout } = auth;
|
||||||
|
const { currentUser, connected, status, sessionId, disconnect, onMessage: onWsMessage } = ws;
|
||||||
|
const { selectedAgent, updateFromServer } = agents;
|
||||||
|
|
||||||
|
const { theme } = useTheme();
|
||||||
|
|
||||||
|
const viewerLink = computed(() =>
|
||||||
|
lastViewerPath.value ? { name: 'viewer', query: { path: lastViewerPath.value } } : '/viewer'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Nav logo follows theme
|
||||||
|
const navLogo = computed(() => THEME_LOGOS[theme.value]);
|
||||||
|
|
||||||
|
watch(() => route.path, updateNavDot);
|
||||||
|
watch(navLogo, deferredUpdateNavDot); // logo appearing/disappearing shifts nav links
|
||||||
|
onMounted(deferredUpdateNavDot);
|
||||||
|
|
||||||
|
function updateFavicon(logo: string | null) {
|
||||||
|
const el = document.querySelector<HTMLLinkElement>('link[rel~="icon"]');
|
||||||
|
if (el) el.href = logo ?? '/favicon.ico';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update favicon when theme changes (dev style buttons only)
|
||||||
|
watch(theme, (t) => updateFavicon(THEME_LOGOS[t]));
|
||||||
|
|
||||||
|
const beVersion = ref('');
|
||||||
|
const envLabel = import.meta.env.PROD ? 'prod' : 'dev';
|
||||||
|
const copied = ref(false);
|
||||||
|
|
||||||
|
function copyVersions() {
|
||||||
|
const text = `[${envLabel}] fe: ${version} | be: ${beVersion.value}`;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
copied.value = true;
|
||||||
|
setTimeout(() => copied.value = false, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeConnect() {
|
||||||
|
if (auth.isLoggedIn.value && !ws.connected.value) {
|
||||||
|
ws.connect(agents.selectedAgent, auth.isLoggedIn, auth.loginError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.beforeEach((to) => {
|
||||||
|
if (to.meta?.requiresSocket) {
|
||||||
|
if (!auth.isLoggedIn.value) return { name: 'login' };
|
||||||
|
if (!selectedAgent.value) {
|
||||||
|
const urlAgent = to.query?.agent as string | undefined;
|
||||||
|
const saved = sessionStorage.getItem('agent');
|
||||||
|
if (urlAgent) selectedAgent.value = urlAgent;
|
||||||
|
else if (saved) selectedAgent.value = saved;
|
||||||
|
}
|
||||||
|
maybeConnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset SM state to CONNECTING on disconnect so UI doesn't show stale state
|
||||||
|
watch(connected, (isConnected) => {
|
||||||
|
if (!isConnected) chatStore.setConnecting();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
onWsMessage((data: any) => {
|
||||||
|
if (data.type === 'ready' || data.type === 'auth_ok') {
|
||||||
|
connected.value = true;
|
||||||
|
currentUser.value = data.user;
|
||||||
|
sessionId.value = data.sessionId;
|
||||||
|
status.value = 'Connected';
|
||||||
|
if (data.version) beVersion.value = data.version;
|
||||||
|
// session token is now stored by doLogin() — no need to cache login token
|
||||||
|
updateFromServer(data);
|
||||||
|
if (route.path === '/login') router.push('/agents');
|
||||||
|
} else if (data.type === 'cost_update') {
|
||||||
|
chatStore.sessionUsage = data.usage;
|
||||||
|
chatStore.sessionCost = data.cost;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@import '../css/styles.css';
|
||||||
|
|
||||||
|
#app {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-footer {
|
||||||
|
height: 24px;
|
||||||
|
background: transparent;
|
||||||
|
border-top: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: var(--text-muted, #8899aa);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.main-footer:hover { opacity: 0.8; }
|
||||||
|
.main-footer p { margin: 0; opacity: 0.6; }
|
||||||
|
|
||||||
|
[v-cloak] { display: none; }
|
||||||
|
</style>
|
||||||
109
frontend/src/src/components/AssistantMessage.vue
Normal file
109
frontend/src/src/components/AssistantMessage.vue
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
<template>
|
||||||
|
<MessageFrame role="assistant" :copyContent="msg.content">
|
||||||
|
<div v-html="parseMd(content)"></div>
|
||||||
|
<template #footer>
|
||||||
|
<span class="footer-name">{{ displayName }}</span>
|
||||||
|
<span v-if="msg.streaming && tools.length === 0" class="footer-status"> ...</span>
|
||||||
|
<span v-else-if="!msg.streaming && tools.length === 0" class="footer-status"> | Done</span>
|
||||||
|
<span v-if="tools.length > 0" class="footer-tools">
|
||||||
|
<span
|
||||||
|
v-for="tool in tools"
|
||||||
|
:key="tool.id"
|
||||||
|
class="footer-tool-icon"
|
||||||
|
:class="tool.state"
|
||||||
|
:title="`${tool.label} [${tool.state}]`"
|
||||||
|
>{{ toolIcon(tool.tool || '') }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="msg.truncated" class="truncated-notice">⚠️ Output limit reached — response was cut off</span>
|
||||||
|
</template>
|
||||||
|
</MessageFrame>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import MessageFrame from './MessageFrame.vue';
|
||||||
|
import { useChatStore } from '../store/chat';
|
||||||
|
import { parseMd } from '../composables/useMessages';
|
||||||
|
import type { Agent } from '../composables/agents';
|
||||||
|
import { toolIcon } from '../composables/sessionHistory';
|
||||||
|
|
||||||
|
const chatStore = useChatStore();
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
msg: any;
|
||||||
|
agentDisplayName: string;
|
||||||
|
isAgentRunning: boolean;
|
||||||
|
allAgents: Agent[];
|
||||||
|
getToolsForTurn: (corrId: string | null | undefined) => any[];
|
||||||
|
hudVersion: number; // increments on every HUD tree mutation — reactive dep
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const displayName = computed(() => {
|
||||||
|
const id = props.msg.agentId;
|
||||||
|
if (id) {
|
||||||
|
const agent = props.allAgents?.find((a: any) => a.id === id);
|
||||||
|
return (agent ? agent.name : id).toUpperCase();
|
||||||
|
}
|
||||||
|
return props.agentDisplayName;
|
||||||
|
});
|
||||||
|
|
||||||
|
const content = computed(() => {
|
||||||
|
if (props.msg.streaming) {
|
||||||
|
return chatStore.streamingMessageVisibleContent + '<span class="typing-dots">...</span>';
|
||||||
|
}
|
||||||
|
return props.msg.content;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tool list for this message's turn — live during streaming, frozen after
|
||||||
|
// hudVersion is a primitive counter that increments on every HUD mutation,
|
||||||
|
// so Vue properly tracks it as a reactive dep (array identity doesn't change)
|
||||||
|
const tools = computed(() => {
|
||||||
|
void props.hudVersion;
|
||||||
|
return props.getToolsForTurn(props.msg.turnCorrId);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.footer-name {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.footer-status {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.footer-tools {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-left: 0.4rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.footer-tool-icon {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.footer-tool-icon.running {
|
||||||
|
opacity: 1;
|
||||||
|
animation: pulse-icon 1.2s infinite;
|
||||||
|
}
|
||||||
|
.footer-tool-icon.error {
|
||||||
|
opacity: 1;
|
||||||
|
filter: sepia(1) saturate(5) hue-rotate(300deg);
|
||||||
|
}
|
||||||
|
.footer-tool-icon.done {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
@keyframes pulse-icon {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
.truncated-notice {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #e5a950;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
44
frontend/src/src/components/FileTree.vue
Normal file
44
frontend/src/src/components/FileTree.vue
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<template>
|
||||||
|
<div class="file-tree">
|
||||||
|
<TreeNode
|
||||||
|
v-for="root in roots"
|
||||||
|
:key="root.prefix"
|
||||||
|
:label="root.label"
|
||||||
|
:path="root.prefix"
|
||||||
|
:token="token"
|
||||||
|
:active-path="activePath"
|
||||||
|
:expand-to="expandTo"
|
||||||
|
:depth="0"
|
||||||
|
@select="$emit('select', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import TreeNode from './TreeNode.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
token: string;
|
||||||
|
activePath: string;
|
||||||
|
expandTo: string;
|
||||||
|
roots?: string[];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{ (e: 'select', path: string): void }>();
|
||||||
|
|
||||||
|
const roots = computed(() =>
|
||||||
|
(props.roots && props.roots.length > 0 ? props.roots : ['shared', 'workspace-titan'])
|
||||||
|
.map(r => ({ label: r, prefix: r }))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.file-tree {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
182
frontend/src/src/components/HandoverCard.vue
Normal file
182
frontend/src/src/components/HandoverCard.vue
Normal file
@ -0,0 +1,182 @@
|
|||||||
|
<template>
|
||||||
|
<div class="handover-card" :class="{ collapsed: isCollapsed }">
|
||||||
|
<div class="handover-card-header" @click="isCollapsed = !isCollapsed">
|
||||||
|
<!-- Paper/document icon -->
|
||||||
|
<svg class="handover-icon" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="2.5" y="1.5" width="9" height="13" rx="1.2" stroke="currentColor" stroke-width="1.2"/>
|
||||||
|
<path d="M5 5h6M5 7.5h6M5 10h4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round"/>
|
||||||
|
<path d="M10.5 1.5v3h3" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M10.5 1.5l3 3" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span class="handover-label">{{ label }}</span>
|
||||||
|
<div class="handover-actions" @click.stop>
|
||||||
|
<button class="handover-copy-btn" @click="copy" :title="copied ? 'Copied!' : 'Copy'">
|
||||||
|
<svg v-if="!copied" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="5" y="5" width="8.5" height="9" rx="1.2" stroke="currentColor" stroke-width="1.2"/>
|
||||||
|
<path d="M5 4V3a1 1 0 0 1 1-1h7a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1h-1" stroke="currentColor" stroke-width="1.2"/>
|
||||||
|
</svg>
|
||||||
|
<svg v-else viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M3 8l3.5 3.5L13 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<svg class="chevron" :class="{ open: !isCollapsed }" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div v-if="!isCollapsed" class="handover-card-body markdown-body" v-html="parseMd(content)"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { parseMd } from '../composables/useMessages';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
content: string;
|
||||||
|
label?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const isCollapsed = ref(true);
|
||||||
|
const copied = ref(false);
|
||||||
|
|
||||||
|
function copy() {
|
||||||
|
navigator.clipboard.writeText(props.content).then(() => {
|
||||||
|
copied.value = true;
|
||||||
|
setTimeout(() => { copied.value = false; }, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.handover-card {
|
||||||
|
align-self: flex-start;
|
||||||
|
max-width: min(520px, 90%);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
/* subtle paper-like top highlight */
|
||||||
|
box-shadow: 0 1px 0 rgba(255,255,255,0.04) inset, 0 1px 4px rgba(0,0,0,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handover-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.handover-card-header:hover { background: rgba(255,255,255,0.03); }
|
||||||
|
|
||||||
|
.handover-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.7;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handover-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-dim);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handover-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handover-copy-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0.15rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.6;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.handover-copy-btn:hover { opacity: 1; background: rgba(255,255,255,0.07); }
|
||||||
|
.handover-copy-btn svg { width: 13px; height: 13px; }
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
.chevron.open { transform: rotate(0deg); }
|
||||||
|
|
||||||
|
.handover-card-body {
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
background: rgba(255,255,255,0.015);
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 420px;
|
||||||
|
scrollbar-color: var(--border) var(--surface);
|
||||||
|
}
|
||||||
|
.handover-card-body::-webkit-scrollbar { width: 4px; }
|
||||||
|
.handover-card-body::-webkit-scrollbar-track { background: var(--surface); }
|
||||||
|
.handover-card-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||||
|
|
||||||
|
/* Markdown styles */
|
||||||
|
.handover-card-body :deep(p) { margin: 0.35rem 0; }
|
||||||
|
.handover-card-body :deep(h1),
|
||||||
|
.handover-card-body :deep(h2),
|
||||||
|
.handover-card-body :deep(h3) {
|
||||||
|
margin: 0.7rem 0 0.3rem;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.handover-card-body :deep(h1) { font-size: 0.92rem; }
|
||||||
|
.handover-card-body :deep(ul),
|
||||||
|
.handover-card-body :deep(ol) {
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
list-style-position: outside;
|
||||||
|
}
|
||||||
|
.handover-card-body :deep(li) { margin: 0.25rem 0; line-height: 1.5; }
|
||||||
|
.handover-card-body :deep(code) {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
padding: 0.1rem 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.handover-card-body :deep(pre) {
|
||||||
|
background: rgba(0,0,0,0.35);
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.4rem 0;
|
||||||
|
}
|
||||||
|
.handover-card-body :deep(pre code) { background: none; padding: 0; }
|
||||||
|
.handover-card-body :deep(strong) { color: var(--text); font-weight: 600; }
|
||||||
|
.handover-card-body :deep(a) { color: var(--accent); text-decoration: underline; }
|
||||||
|
.handover-card-body :deep(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin: 0.6rem 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user