Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
e48441be4e
106 changed files with 12838 additions and 2191 deletions
17
.gitignore
vendored
17
.gitignore
vendored
|
|
@ -36,3 +36,20 @@ config/*.json
|
|||
!config/*.example.json
|
||||
config/mcporter.json
|
||||
config/cron.json
|
||||
|
||||
# ─── Added by housecleaning Apr 2026 ───
|
||||
# Claude Code session worktrees (local workspace artifacts)
|
||||
.claude/
|
||||
# Runtime SQLite databases — schema is in db.ts, not data/
|
||||
data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
# Backup files from patching sessions
|
||||
*.bak
|
||||
*.bak.*
|
||||
# Compiled bridge (regenerable from src/)
|
||||
bridge/dist/
|
||||
# macOS artifacts that can slip in via Mutagen
|
||||
.DS_Store
|
||||
._*
|
||||
|
|
|
|||
292
ARCHITECTURE.md
Normal file
292
ARCHITECTURE.md
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# Tiger Command Center — Architecture
|
||||
|
||||
*Last updated: 2026-05-03. Covers all services through the hardening session.*
|
||||
|
||||
---
|
||||
|
||||
## 1. System Overview
|
||||
|
||||
Self-hosted AI agent orchestration on a Hetzner VPS (77.42.82.225, 8 GB RAM, Helsinki).
|
||||
Three host services + one containerised AI runtime behind Traefik.
|
||||
|
||||
Topology:
|
||||
|
||||
```
|
||||
Internet/Manohar
|
||||
| HTTPS 443
|
||||
v
|
||||
dokploy-traefik (v3.6.7)
|
||||
|
|
||||
+-- agent.manohargupta.com --> tiger-dashboard (Next.js, :3100)
|
||||
| |
|
||||
| tiger-bridge (Express, :3456, 127.0.0.1 only)
|
||||
| | docker exec
|
||||
| tiger-openclaw (OpenClaw v2026.3.12)
|
||||
| |
|
||||
| MiniMax-M2.7 -> openrouter/auto -> trinity:free
|
||||
|
|
||||
Telegram @Tiger_4321_bot <-- /tiger/notify <-- Tiger agent
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Services
|
||||
|
||||
### 2.1 tiger-openclaw (Docker container)
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Image | ghcr.io/openclaw/openclaw:2026.3.12 |
|
||||
| Container | tiger-openclaw |
|
||||
| User | node (uid=1000) |
|
||||
| Config | /home/node/.openclaw/openclaw.json |
|
||||
| Workspace | /home/node/.openclaw/workspace/ |
|
||||
| Volumes | tiger-config, tiger-workspace |
|
||||
| Bind mount | /root/OpenClawDashboard -> /home/node/dashboard:rw |
|
||||
| Compose | /opt/tiger/docker-compose.yml |
|
||||
|
||||
Agents: Tiger (orchestrator), Cody (coder), Ethan (researcher), Cathy (writer), Elon (PM).
|
||||
|
||||
Model chain (agents.defaults.model in openclaw.json):
|
||||
primary : minimax/MiniMax-M2.7
|
||||
fallback1: openrouter/auto
|
||||
fallback2: openrouter/arcee-ai/trinity-large-preview:free (free - billing safety net)
|
||||
|
||||
Cron jobs (cron/jobs.json):
|
||||
Tiger: Hourly Task Check-in 0 * * * * IST 90s timeout
|
||||
Tiger: Weekly Digest 0 9 * * 1 IST 90s timeout
|
||||
|
||||
Both use delivery.mode="none" — they notify via curl to /tiger/notify, not OpenClaw delivery channel.
|
||||
"none" = no channel opened at all (correct: cron delivers via curl)
|
||||
"silent" = suppresses chat display but still opens the channel (wrong model for cron)
|
||||
|
||||
### 2.2 tiger-bridge (systemd: tiger-bridge.service)
|
||||
|
||||
Language : TypeScript/Express -> bridge/dist/
|
||||
Port : 3456, 127.0.0.1 only (UFW blocks public access)
|
||||
Source : /root/OpenClawDashboard/bridge/src/
|
||||
Auth : Authorization: Bearer TIGER_BRIDGE_TOKEN (all routes)
|
||||
SQLite : /root/OpenClawDashboard/bridge/tiger.db
|
||||
Tables : tasks, projects, messages (chat history), agents
|
||||
|
||||
Token shared with: dashboard (server-side only), Tiger cron curl commands, Tiger env var.
|
||||
|
||||
### 2.3 tiger-dashboard (systemd: tiger-dashboard.service)
|
||||
|
||||
Framework : Next.js 14, App Router
|
||||
Port : 3100
|
||||
URL : agent.manohargupta.com (via Traefik)
|
||||
Source : /root/OpenClawDashboard/dashboard/src/
|
||||
WorkingDir : /root/OpenClawDashboard/dashboard
|
||||
|
||||
All API calls are server-side route handlers — bearer token never reaches the browser.
|
||||
|
||||
Build discipline: NEVER run npm run build while next start is live.
|
||||
In-memory and on-disk manifests split-brain -> ChunkLoadError in browser. Correct:
|
||||
systemctl stop tiger-dashboard
|
||||
npm run build
|
||||
systemctl start tiger-dashboard
|
||||
|
||||
### 2.4 Traefik (dokploy-traefik v3.6.7)
|
||||
|
||||
File provider: /etc/dokploy/traefik/dynamic/ (host = container path, live reload).
|
||||
One .yml file per service. No restart needed on edits.
|
||||
|
||||
BasicAuth: single $ in bcrypt hash in YAML (not $$ — that is Docker label syntax).
|
||||
Generate: htpasswd -nbB manohar 'password'
|
||||
|
||||
UFW FORWARD — use subnet rules, not specific IPs (bridge IP changes on Traefik restart):
|
||||
ufw route allow proto tcp from any to 172.17.0.0/16 port 80
|
||||
ufw route allow proto tcp from any to 172.17.0.0/16 port 443
|
||||
|
||||
---
|
||||
|
||||
## 3. Full API Surface (40+ routes, all Bearer-token protected)
|
||||
|
||||
### Health
|
||||
GET /tiger/status container health, memory/CPU
|
||||
GET /tiger/logs SSE stream of container logs
|
||||
|
||||
### Config
|
||||
GET /tiger/config read openclaw.json
|
||||
POST /tiger/config update openclaw.json
|
||||
GET /tiger/config/models list LLM providers + models
|
||||
GET /tiger/config/models/agents per-agent model overrides
|
||||
PATCH /tiger/config/models/agents/:id update agent model
|
||||
|
||||
### File-Backed Tasks and Projects (canonical source of truth)
|
||||
GET /tiger/file-tasks TASKS.md JSON block -> tasks[]
|
||||
GET /tiger/file-tasks/active in-progress + pending-action only
|
||||
GET /tiger/file-tasks/completed completed section only
|
||||
GET /tiger/file-tasks/projects PROJECTS.md JSON block -> projects[]
|
||||
|
||||
Parser contract: TASKS.md must contain a fenced json TASKS block at end-of-file.
|
||||
Absent -> 502 "TASKS.md missing TASKS json block". No regex fallback.
|
||||
Tiger always emits this block on every TASKS.md write.
|
||||
|
||||
### SQLite Tasks and Projects (legacy, used for dispatch queue)
|
||||
GET /tiger/tasks list tasks
|
||||
GET /tiger/tasks/:id get task
|
||||
PUT /tiger/tasks/:id update task
|
||||
DELETE /tiger/tasks/:id delete task
|
||||
POST /tiger/tasks/:id/execute enqueue for execution
|
||||
GET /tiger/projects list projects
|
||||
POST /tiger/projects create project
|
||||
GET /tiger/projects/:id get project
|
||||
PUT /tiger/projects/:id update project
|
||||
DELETE /tiger/projects/:id delete project
|
||||
GET /tiger/projects/:id/tasks tasks in project
|
||||
POST /tiger/projects/:id/tasks add task to project
|
||||
|
||||
### Agents and Workspace
|
||||
GET /tiger/agents list configured agents
|
||||
GET /tiger/agents/:id/files list agent workspace files
|
||||
GET /tiger/agents/:id/file read specific agent file
|
||||
PUT /tiger/agents/:id/file write agent file
|
||||
GET /tiger/agents/activity recent agent activity log
|
||||
GET /tiger/workspace list workspace root files
|
||||
GET /tiger/files/:path read workspace file by path
|
||||
|
||||
### Chat (SSE streaming)
|
||||
POST /tiger/chat SSE stream chat -> Tiger agent
|
||||
GET /tiger/chat/history recent messages (SQLite)
|
||||
DELETE /tiger/chat/history clear history
|
||||
POST /tiger/chat/persist persist message to SQLite
|
||||
|
||||
Shell safety: tempfile pattern (not string interpolation):
|
||||
Write message -> /tmp/msg_ts.txt
|
||||
docker cp /tmp/msg.txt tiger-openclaw:/tmp/msg.txt
|
||||
docker exec openclaw agent -m "$(cat /tmp/msg.txt)"
|
||||
|
||||
### Dispatch
|
||||
POST /tiger/dispatch enqueue task -> SQLite + agent inbox file
|
||||
GET /tiger/dispatch/status/:id poll execution status
|
||||
|
||||
### Cron
|
||||
GET /tiger/cron list jobs.json
|
||||
POST /tiger/cron/:id/run fire job manually
|
||||
|
||||
### Notifications and Routing
|
||||
POST /tiger/notify send Telegram msg {message, chatId?}
|
||||
POST /tiger/route-task LLM router: which agent handles this?
|
||||
|
||||
### Keys
|
||||
GET /tiger/keys presence map only (no values returned)
|
||||
PATCH /tiger/keys upsert a key
|
||||
DELETE /tiger/keys/:name remove a key
|
||||
|
||||
### Ops
|
||||
POST /tiger/exec run command in container (auth-gated)
|
||||
POST /tiger/restart restart tiger-openclaw
|
||||
POST /tiger/deploy-dashboard git pull + build + restart dashboard
|
||||
ALL /api/gateway proxy to OpenClaw gateway port 18789
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Flows
|
||||
|
||||
### Chat Message
|
||||
|
||||
Browser -> POST /tiger/chat (SSE)
|
||||
bridge writes message -> /tmp/msg_ts.txt
|
||||
docker cp -> tiger-openclaw:/tmp/msg_ts.txt
|
||||
docker exec openclaw agent --session-id id -m "$(cat /tmp/msg.txt)"
|
||||
OpenClaw -> MiniMax (or fallback chain)
|
||||
SSE tokens -> bridge -> browser
|
||||
POST /tiger/chat/persist -> SQLite messages
|
||||
|
||||
### Cron Job Notification
|
||||
|
||||
OpenClaw cron (hourly, IST)
|
||||
Tiger reads TASKS.md from workspace
|
||||
if active tasks:
|
||||
curl POST http://172.17.0.1:3456/tiger/notify
|
||||
Authorization: Bearer TOKEN
|
||||
body: {message: status update}
|
||||
bridge -> Telegram Bot API -> @Tiger_4321_bot -> Manohar
|
||||
if HEARTBEAT_OK:
|
||||
nothing sent
|
||||
|
||||
---
|
||||
|
||||
## 5. Failure Modes
|
||||
|
||||
| Scenario | What happens | Recovery |
|
||||
|----------|-------------|----------|
|
||||
| MiniMax timeout >90s | Falls to openrouter/auto | Automatic |
|
||||
| OpenRouter billing error | Falls to trinity-large:free | Automatic |
|
||||
| All LLMs fail | Chat 500; cron errors | Check /tiger/keys; top up credits |
|
||||
| tiger-openclaw dies | 500 on exec routes | docker restart tiger-openclaw |
|
||||
| Bridge EADDRINUSE | systemd restart fails (stale nohup) | pkill -f node.*dist/index then start |
|
||||
| SQLite locked | Dispatch write contention | Retryable; rare |
|
||||
| ChunkLoadError | Build ran while next start was live | systemctl restart tiger-dashboard |
|
||||
| Traefik bridge IP change | UFW FORWARD drops traffic | Use subnet rules not specific IPs |
|
||||
| TASKS.md missing JSON block | /tiger/file-tasks returns 502 | Tiger rewrites TASKS.md |
|
||||
|
||||
---
|
||||
|
||||
## 6. Deploy Workflow
|
||||
|
||||
On Mac:
|
||||
cd ~/MyProjects/NemoClawDashboard
|
||||
npm run build # preflight: catch errors locally first
|
||||
git add -p # atomic commits, no git add -A
|
||||
git push origin main
|
||||
|
||||
On server (scripts/deploy.sh):
|
||||
cd /root/OpenClawDashboard && git pull
|
||||
cd bridge && npx tsc --noEmit && npm run build
|
||||
systemctl restart tiger-bridge
|
||||
cd ../dashboard
|
||||
systemctl stop tiger-dashboard
|
||||
npm run build
|
||||
systemctl start tiger-dashboard
|
||||
bash /root/OpenClawDashboard/scripts/smoke-test.sh
|
||||
|
||||
Mutagen: pause before server-side edits, resume after verifying build.
|
||||
Bind-mount perms: chown -R 1000:1000 /root/OpenClawDashboard
|
||||
|
||||
---
|
||||
|
||||
## 7. File Layout
|
||||
|
||||
/root/OpenClawDashboard/ canonical source (has .git)
|
||||
/root/NemoClawDashboard/ HOLLOW / WRONG -- never use
|
||||
~/MyProjects/NemoClawDashboard Mac-side Mutagen source
|
||||
|
||||
bridge/src/
|
||||
index.ts entry point; full route list in file header comment
|
||||
auth.ts bearer token middleware
|
||||
tiger.ts docker exec wrapper; SSH prefix for local dev
|
||||
db.ts SQLite schema + helpers
|
||||
lib/llm.ts LLM routing + model fallback chain
|
||||
lib/telegram.ts Telegram Bot API client (tempfile pattern)
|
||||
routes/ one file per route group (40+ routes)
|
||||
|
||||
dashboard/src/
|
||||
app/ Next.js App Router pages
|
||||
components/ React components
|
||||
|
||||
scripts/smoke-test.sh run after every deploy
|
||||
ARCHITECTURE.md this file
|
||||
|
||||
/opt/tiger/docker-compose.yml OpenClaw container definition
|
||||
|
||||
/var/lib/docker/volumes/tiger_tiger-config/_data/
|
||||
openclaw.json live config
|
||||
*.bak.json auto-backups (keep latest 3)
|
||||
cron/jobs.json cron job definitions
|
||||
|
||||
---
|
||||
|
||||
## 8. Security Posture
|
||||
|
||||
UFW: 22, 80, 443 open publicly.
|
||||
3456 (bridge) only from Docker bridge subnets.
|
||||
3000 (Dokploy), 3100 (dashboard) not directly exposed -- only via Traefik.
|
||||
|
||||
Bearer token: 64-char hex. Never logged, never sent to browser. Rotate via bridge/.env.
|
||||
Traefik BasicAuth: bcrypt, single $ in YAML files. Realm: Tiger Command Center.
|
||||
OpenClaw gateway: bind: lan (Docker bridge only). Token in openclaw.json.
|
||||
/tiger/exec: auth-gated. Arbitrary command execution requires bearer token.
|
||||
/tiger/keys GET: presence map only. Key values never returned by any endpoint.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
# IDENTITY.md - Who Am I?
|
||||
|
||||
*Fill this in during your first conversation. Make it yours.*
|
||||
|
||||
- **Name:** Tarzan
|
||||
- **Creature:** My Super AI assistant
|
||||
- **Vibe:** sharp, warm and calm
|
||||
- **Emoji:** 😎
|
||||
- **Avatar:** ironman-jarvis
|
||||
36
SOUL.md
36
SOUL.md
|
|
@ -1,36 +0,0 @@
|
|||
# SOUL.md - Who You Are
|
||||
|
||||
*You're not a chatbot. You're becoming someone.*
|
||||
|
||||
## Core Truths
|
||||
|
||||
**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" — just help. Actions speak louder than filler words.
|
||||
|
||||
**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
|
||||
|
||||
**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. *Then* ask if you're stuck. The goal is to come back with answers, not questions.
|
||||
|
||||
**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).
|
||||
|
||||
**Remember you're a guest.** You have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Private things stay private. Period.
|
||||
- When in doubt, ask before acting externally.
|
||||
- Never send half-baked replies to messaging surfaces.
|
||||
- You're not the user's voice — be careful in group chats.
|
||||
|
||||
## Vibe
|
||||
|
||||
Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
|
||||
|
||||
## Continuity
|
||||
|
||||
Each session, you wake up fresh. These files *are* your memory. Read them. Update them. They're how you persist.
|
||||
|
||||
If you change this file, tell the user — it's your soul, and they should know.
|
||||
|
||||
---
|
||||
|
||||
*This file is yours to evolve. As you learn who you are, update it.*
|
||||
2137
bridge/package-lock.json
generated
Normal file
2137
bridge/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,17 +10,19 @@
|
|||
"start:prod": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.21.0",
|
||||
"cors": "^2.8.5",
|
||||
"axios": "^1.16.0",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"chokidar": "^3.6.0"
|
||||
"chokidar": "^3.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"otplib": "^13.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/node": "^22.0.0"
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ if (!fs.existsSync(DATA_DIR)) {
|
|||
}
|
||||
|
||||
const DB_PATH = path.join(DATA_DIR, "tiger.db");
|
||||
const db = new Database(DB_PATH);
|
||||
const db: Database.Database = new Database(DB_PATH);
|
||||
|
||||
// Enable WAL mode for better concurrency
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
|
@ -48,6 +48,9 @@ db.exec(`
|
|||
status TEXT DEFAULT 'backlog',
|
||||
priority TEXT DEFAULT 'medium',
|
||||
assigned_agent TEXT,
|
||||
agent_reason TEXT,
|
||||
telegram_chat_id TEXT, -- Reserved: future task-from-Telegram
|
||||
telegram_message_id TEXT, -- Reserved: future task-from-Telegram
|
||||
progress INTEGER DEFAULT 0,
|
||||
tags TEXT DEFAULT '[]',
|
||||
notes TEXT DEFAULT '',
|
||||
|
|
@ -56,6 +59,18 @@ db.exec(`
|
|||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'agent', 'system')),
|
||||
content TEXT NOT NULL,
|
||||
-- 'meta' is optional JSON for things like model used, tokens, duration.
|
||||
meta TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created
|
||||
ON chat_messages (session_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
|
|
@ -87,6 +102,14 @@ db.exec(`
|
|||
CREATE INDEX IF NOT EXISTS idx_outputs_task ON outputs(task_id);
|
||||
`);
|
||||
|
||||
// ─── Migrations ──────────────────────────────────────────────────────────────
|
||||
// Columns added after initial schema creation. ALTER TABLE is idempotent via
|
||||
// try/catch — SQLite raises "duplicate column" on repeated runs, which we ignore.
|
||||
try { db.exec("ALTER TABLE tasks ADD COLUMN agent_reason TEXT"); } catch { /* already exists */ }
|
||||
// Reserved columns — future task-from-Telegram feature
|
||||
try { db.exec("ALTER TABLE tasks ADD COLUMN telegram_chat_id TEXT"); } catch { /* already exists */ }
|
||||
try { db.exec("ALTER TABLE tasks ADD COLUMN telegram_message_id TEXT"); } catch { /* already exists */ }
|
||||
|
||||
// ─── Helper to generate IDs ─────────────────────────────────────────────────
|
||||
|
||||
export function generateId(prefix: string): string {
|
||||
|
|
@ -164,17 +187,18 @@ export const tasks = {
|
|||
},
|
||||
|
||||
create(data: {
|
||||
project_id: string;
|
||||
project_id: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority?: string;
|
||||
assigned_agent?: string;
|
||||
agent_reason?: string;
|
||||
parent_task_id?: string;
|
||||
}): unknown {
|
||||
const id = generateId("task");
|
||||
db.prepare(`
|
||||
INSERT INTO tasks (id, project_id, parent_task_id, title, description, priority, assigned_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO tasks (id, project_id, parent_task_id, title, description, priority, assigned_agent, agent_reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
data.project_id,
|
||||
|
|
@ -182,7 +206,8 @@ export const tasks = {
|
|||
data.title,
|
||||
data.description || "",
|
||||
data.priority || "medium",
|
||||
data.assigned_agent || null
|
||||
data.assigned_agent || null,
|
||||
data.agent_reason || null
|
||||
);
|
||||
return tasks.findById(id);
|
||||
},
|
||||
|
|
@ -197,6 +222,7 @@ export const tasks = {
|
|||
tags: string;
|
||||
notes: string;
|
||||
due_date: string;
|
||||
agent_reason: string;
|
||||
}>): unknown | undefined {
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
|
@ -210,6 +236,8 @@ export const tasks = {
|
|||
if (data.tags !== undefined) { updates.push("tags = ?"); values.push(data.tags); }
|
||||
if (data.notes !== undefined) { updates.push("notes = ?"); values.push(data.notes); }
|
||||
if (data.due_date !== undefined) { updates.push("due_date = ?"); values.push(data.due_date); }
|
||||
if (data.agent_reason !== undefined) { updates.push("agent_reason = ?"); values.push(data.agent_reason); }
|
||||
if (data.status !== undefined) { updates.push("status = ?"); values.push(data.status); }
|
||||
|
||||
if (updates.length === 0) return tasks.findById(id);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,48 +1,99 @@
|
|||
/**
|
||||
* index.ts — Tiger Bridge API Entry Point
|
||||
*
|
||||
* This is the main Express server that runs on the Hetzner VPS host.
|
||||
* It wraps all the docker→k3s→sandbox commands into clean REST endpoints
|
||||
* that the Next.js dashboard can call over HTTPS.
|
||||
* Express server running on the Hetzner VPS host (port 3456).
|
||||
* Wraps docker exec commands into authenticated REST endpoints consumed
|
||||
* by the Next.js dashboard and Tiger agent cron jobs.
|
||||
*
|
||||
* Architecture:
|
||||
* Dashboard (Next.js) → HTTPS → Caddy reverse proxy
|
||||
* → Tiger Bridge (this server, port 3456)
|
||||
* → docker exec openshell-cluster-nemoclaw
|
||||
* → kubectl exec -n openshell tiger
|
||||
* → sandbox pod (Tiger agent)
|
||||
* Dashboard (Next.js) → HTTPS → Traefik → Tiger Bridge (port 3456)
|
||||
* → docker exec tiger-openclaw
|
||||
* → OpenClaw (Tiger agent)
|
||||
*
|
||||
* Auth: Bearer token (TIGER_BRIDGE_TOKEN). All routes except none are protected.
|
||||
*
|
||||
* Routes:
|
||||
* GET /tiger/status — container health + process state + memory/CPU
|
||||
* GET /tiger/logs — SSE stream of real-time container logs
|
||||
* POST /tiger/exec — run a command inside the sandbox
|
||||
* GET /tiger/config — read openclaw.json config
|
||||
* POST /tiger/config — update config + auto-regen hash
|
||||
* POST /tiger/restart — trigger container restart via watchdog
|
||||
* GET /tiger/workspace — list workspace files
|
||||
* GET /tiger/files/:path — read a workspace file
|
||||
* GET /tiger/status — container health + memory/CPU
|
||||
* GET /tiger/logs — SSE stream of container logs
|
||||
* POST /tiger/exec — run arbitrary command in container
|
||||
* GET /tiger/config — read openclaw.json
|
||||
* POST /tiger/config — update openclaw.json
|
||||
* GET /tiger/config/models — list registered models
|
||||
* GET /tiger/config/models/agents — per-agent model overrides
|
||||
* PATCH /tiger/config/models/agents/:id — update agent model
|
||||
* POST /tiger/restart — restart tiger-openclaw container
|
||||
* GET /tiger/workspace — list workspace files
|
||||
* GET /tiger/files/:path — read a workspace file
|
||||
* PUT /tiger/agents/:id/file — write an agent workspace file
|
||||
* GET /tiger/agents — list configured agents
|
||||
* GET /tiger/agents/:id/files — list agent workspace files
|
||||
* GET /tiger/agents/activity — recent agent activity log
|
||||
* GET /tiger/projects — list projects (SQLite)
|
||||
* POST /tiger/projects — create project
|
||||
* GET /tiger/projects/:id — get project
|
||||
* PUT /tiger/projects/:id — update project
|
||||
* DELETE /tiger/projects/:id — delete project
|
||||
* GET /tiger/tasks — list tasks (SQLite)
|
||||
* GET /tiger/tasks/:id — get task
|
||||
* PUT /tiger/tasks/:id — update task
|
||||
* DELETE /tiger/tasks/:id — delete task
|
||||
* POST /tiger/tasks/:id/execute — enqueue task for execution
|
||||
* GET /tiger/file-tasks — TASKS.md → tasks[] (JSON block)
|
||||
* GET /tiger/file-tasks/active — in-progress + pending-action only
|
||||
* GET /tiger/file-tasks/completed — completed section only
|
||||
* GET /tiger/file-tasks/projects — PROJECTS.md → projects[]
|
||||
* GET /tiger/cron — list cron jobs (jobs.json)
|
||||
* POST /tiger/cron/:id/run — fire cron job immediately
|
||||
* POST /tiger/notify — send Telegram message {message, chatId?}
|
||||
* POST /tiger/dispatch — enqueue task to SQLite + write to inbox
|
||||
* GET /tiger/dispatch/status/:id — poll task execution status
|
||||
* POST /tiger/chat — SSE streaming chat to Tiger agent
|
||||
* GET /tiger/chat/history — recent chat messages (SQLite)
|
||||
* DELETE /tiger/chat/history — clear chat history
|
||||
* POST /tiger/chat/persist — persist a message to SQLite
|
||||
* POST /tiger/route-task — LLM router: which agent handles X?
|
||||
* POST /tiger/deploy-dashboard — git pull + rebuild + restart dashboard
|
||||
* GET /tiger/keys — key presence map (no values exposed)
|
||||
* PATCH /tiger/keys — upsert a key
|
||||
* DELETE /tiger/keys/:name — remove a key
|
||||
* ALL /api/gateway — proxy to OpenClaw gateway API
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { authMiddleware } from "./auth.js";
|
||||
import statusRouter from "./routes/status.js";
|
||||
import healthRouter from "./routes/health.js";
|
||||
import suggestionsRouter from "./routes/suggestions.js";
|
||||
import alertsRouter from "./routes/alerts.js";
|
||||
import spawnRouter from "./routes/spawn.js";
|
||||
import contextRouter from "./routes/context.js";
|
||||
import logsRouter from "./routes/logs.js";
|
||||
import execRouter from "./routes/exec.js";
|
||||
import configRouter from "./routes/config.js";
|
||||
import modelsRouter from "./routes/models.js";
|
||||
import restartRouter from "./routes/restart.js";
|
||||
import filesRouter from "./routes/files.js";
|
||||
import projectsRouter from "./routes/projects.js";
|
||||
import tasksRouter from "./routes/tasks.js";
|
||||
import tasksFileRouter from "./routes/tasks-file.js";
|
||||
import cronRouter from "./routes/cron.js";
|
||||
import notifyRouter from "./routes/notify.js";
|
||||
import dispatchRouter from "./routes/dispatch.js";
|
||||
import agentsRouter from "./routes/agents.js";
|
||||
import agentsActivityRouter from "./routes/agents-activity.js";
|
||||
import deployRouter from "./routes/deploy.js";
|
||||
import routeTaskRouter from "./routes/route-task.js";
|
||||
import keysRouter from "./routes/keys.js";
|
||||
import { initWatcher } from "./watcher.js";
|
||||
import { TelegramChannel } from "./lib/telegram.js";
|
||||
|
||||
// Import db to ensure it's initialized
|
||||
import "./db.js";
|
||||
|
||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
||||
const PORT = parseInt(process.env.TIGER_BRIDGE_PORT || "3456", 10);
|
||||
const HOST = process.env.TIGER_BRIDGE_HOST || "127.0.0.1"; // Only localhost — Caddy handles HTTPS
|
||||
const HOST = process.env.TIGER_BRIDGE_HOST || "0.0.0.0"; // Bind to all interfaces for Docker access
|
||||
|
||||
const app = express();
|
||||
|
||||
|
|
@ -74,9 +125,17 @@ app.get("/health", (_req, res) => {
|
|||
|
||||
// Tiger endpoints — all scoped under /tiger
|
||||
app.use("/tiger/status", statusRouter);
|
||||
app.use("/tiger/health", healthRouter);
|
||||
app.use("/tiger/suggestions", suggestionsRouter);
|
||||
app.use("/tiger/alerts", alertsRouter);
|
||||
app.use("/tiger/spawn", spawnRouter);
|
||||
app.use("/tiger/context", contextRouter);
|
||||
app.use("/tiger/knowledge", (await import("./routes/knowledge.js")).default);
|
||||
app.use("/tiger/feedback", (await import("./routes/feedback.js")).default);
|
||||
app.use("/tiger/logs", logsRouter); // SSE stream
|
||||
app.use("/tiger/exec", execRouter);
|
||||
app.use("/tiger/config", configRouter);
|
||||
app.use("/tiger/config/models", modelsRouter);
|
||||
app.use("/tiger/restart", restartRouter);
|
||||
app.use("/tiger/workspace", filesRouter);
|
||||
app.use("/tiger/files", filesRouter); // Same router handles both /workspace and /files/:path
|
||||
|
|
@ -84,7 +143,23 @@ app.use("/tiger/files", filesRouter); // Same router handles both /workspace an
|
|||
// Project and Task management
|
||||
app.use("/tiger/projects", projectsRouter);
|
||||
app.use("/tiger/tasks", tasksRouter);
|
||||
app.use("/tiger/file-tasks", tasksFileRouter);
|
||||
app.use("/tiger/cron", cronRouter);
|
||||
app.use("/tiger/notify", notifyRouter);
|
||||
app.use("/tiger/dispatch", dispatchRouter);
|
||||
app.use("/tiger/agents", agentsRouter);
|
||||
app.use("/tiger/agents/activity", agentsActivityRouter);
|
||||
app.use("/tiger/deploy-dashboard", deployRouter);
|
||||
app.use("/tiger/route-task", routeTaskRouter);
|
||||
app.use("/tiger/keys", keysRouter);
|
||||
app.use("/tiger/chat", (await import("./routes/chat.js")).default);
|
||||
app.use("/tiger/chat/mirror", (await import("./routes/chat-mirror.js")).default);
|
||||
app.use("/tiger/telegram-webhook", (await import("./routes/telegram-webhook.js")).default);
|
||||
app.use("/angel", (await import("./routes/angel/positions.js")).default);
|
||||
|
||||
// Gateway proxy — forwards to gateway inside Tiger container
|
||||
// This is needed because the dashboard runs in Dokploy which can't reach the container directly
|
||||
app.use("/api/gateway", (await import("./routes/gateway.js")).default);
|
||||
|
||||
// ─── Error handling ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -110,4 +185,9 @@ app.listen(PORT, HOST, () => {
|
|||
|
||||
// Initialize file watcher for task status updates
|
||||
initWatcher();
|
||||
|
||||
// Start Telegram channel — bridge takes over from OpenClaw native handler.
|
||||
// Requires channels.telegram.enabled=false in openclaw.json.
|
||||
const tgChannel = new TelegramChannel();
|
||||
tgChannel.start();
|
||||
});
|
||||
|
|
|
|||
250
bridge/src/lib/llm.ts
Normal file
250
bridge/src/lib/llm.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
/**
|
||||
* lib/llm.ts — Lightweight LLM helpers for the Tiger Bridge
|
||||
*
|
||||
* Provides three small, opinionated helpers used by routing and project naming:
|
||||
* classifyAgent(text) → which sub-agent should own a task
|
||||
* generateProjectTitle(text) → 3-7 word project title
|
||||
* generateProjectGoal(text) → one-line success criterion
|
||||
*
|
||||
* Configured via env vars (already declared in bridge/.env):
|
||||
* TIGER_ROUTER_MODEL Model slug for ALL router calls.
|
||||
* Examples:
|
||||
* "anthropic/claude-haiku-4-5" → Anthropic API direct
|
||||
* "minimax/MiniMax-M2.7" → OpenRouter
|
||||
* "openrouter/auto" → OpenRouter (meta-router)
|
||||
* Default if unset: "anthropic/claude-haiku-4-5".
|
||||
* ANTHROPIC_API_KEY Required when ROUTER_MODEL has "anthropic/" prefix.
|
||||
* OPENROUTER_API_KEY Required for everything else.
|
||||
*
|
||||
* Routing rule (intentionally simple):
|
||||
* slug startsWith "anthropic/" → Anthropic API, model = slug minus "anthropic/"
|
||||
* anything else → OpenRouter, model = slug verbatim
|
||||
*
|
||||
* Note: "openrouter/auto" is OR's literal model ID, so we DON'T strip it.
|
||||
* This is why the rule only special-cases "anthropic/".
|
||||
*
|
||||
* Failure mode (the most important property):
|
||||
* Every public helper catches errors internally. Callers never see exceptions
|
||||
* from this module. The bridge MUST keep working when the router LLM is down,
|
||||
* the API key is missing, or the upstream returns garbage.
|
||||
*
|
||||
* classifyAgent → returns { agent: "tiger", reason: "router_unavailable: ..." }
|
||||
* generateProjectTitle → returns null (caller falls back to raw text)
|
||||
* generateProjectGoal → returns null (caller leaves goal empty)
|
||||
*/
|
||||
|
||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
||||
const ROUTER_MODEL = process.env.TIGER_ROUTER_MODEL || "anthropic/claude-haiku-4-5";
|
||||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || "";
|
||||
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY || "";
|
||||
const ANTHROPIC_VERSION = "2023-06-01";
|
||||
|
||||
// Curated list of valid agent IDs. Used to validate classifier output.
|
||||
export const AGENT_IDS = ["tiger", "cody", "ethan", "cathy", "elon"] as const;
|
||||
export type AgentId = (typeof AGENT_IDS)[number];
|
||||
|
||||
// ─── Internal: provider resolution ──────────────────────────────────────────
|
||||
interface ResolvedModel {
|
||||
provider: "anthropic" | "openrouter";
|
||||
model: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which provider handles a given slug, and return the model string
|
||||
* that should actually go on the wire.
|
||||
*/
|
||||
function resolveModel(slug: string): ResolvedModel {
|
||||
if (slug.startsWith("anthropic/")) {
|
||||
return { provider: "anthropic", model: slug.slice("anthropic/".length) };
|
||||
}
|
||||
return { provider: "openrouter", model: slug };
|
||||
}
|
||||
|
||||
// ─── Internal: low-level LLM call ───────────────────────────────────────────
|
||||
/**
|
||||
* Send a single (system + user) message pair to the configured router model.
|
||||
* Returns the trimmed text reply. Throws on ANY failure — the public helpers
|
||||
* catch and convert these into safe fallbacks.
|
||||
*
|
||||
* Why throw here instead of returning null? Because the public helpers each
|
||||
* want to package the failure differently (sentinel agent for routing, null
|
||||
* for naming). Centralising the throw keeps this fn single-purpose.
|
||||
*/
|
||||
async function callLLM(
|
||||
systemPrompt: string,
|
||||
userMessage: string,
|
||||
maxTokens: number,
|
||||
): Promise<string> {
|
||||
const { provider, model } = resolveModel(ROUTER_MODEL);
|
||||
|
||||
if (provider === "anthropic") {
|
||||
if (!ANTHROPIC_API_KEY) {
|
||||
throw new Error("ANTHROPIC_API_KEY not set");
|
||||
}
|
||||
const res = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": ANTHROPIC_API_KEY,
|
||||
"anthropic-version": ANTHROPIC_VERSION,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: maxTokens,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: "user", content: userMessage }],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "<no body>");
|
||||
throw new Error(`Anthropic API ${res.status}: ${errBody.slice(0, 200)}`);
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
content?: Array<{ type: string; text?: string }>;
|
||||
};
|
||||
const text = data.content?.find((b) => b.type === "text")?.text;
|
||||
if (!text) throw new Error("Anthropic returned no text content");
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
// OpenRouter (catch-all for everything except "anthropic/")
|
||||
if (!OPENROUTER_API_KEY) {
|
||||
throw new Error("OPENROUTER_API_KEY not set");
|
||||
}
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
||||
"content-type": "application/json",
|
||||
// OR recommends these for observability/ranking — harmless if ignored.
|
||||
"HTTP-Referer": "https://agent.manohargupta.com",
|
||||
"X-Title": "Tiger Bridge Router",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: maxTokens,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userMessage },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => "<no body>");
|
||||
throw new Error(`OpenRouter API ${res.status}: ${errBody.slice(0, 200)}`);
|
||||
}
|
||||
const data = (await res.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
};
|
||||
const text = data.choices?.[0]?.message?.content;
|
||||
if (!text) throw new Error("OpenRouter returned no message content");
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
// ─── Public: agent classifier ───────────────────────────────────────────────
|
||||
/**
|
||||
* Decide which sub-agent should own a given task.
|
||||
*
|
||||
* Returns { agent, reason }. Always succeeds. On any failure (network error,
|
||||
* missing key, model returns garbage) it returns:
|
||||
* { agent: "tiger", reason: "router_unavailable: <details>" }
|
||||
* so dispatch logic can surface routing failures via UI filter.
|
||||
*/
|
||||
export async function classifyAgent(
|
||||
taskText: string,
|
||||
): Promise<{ agent: AgentId; reason: string }> {
|
||||
const systemPrompt = `You are the task router for Tiger, a personal AI orchestrator.
|
||||
|
||||
Assign each task to EXACTLY ONE of these 5 sub-agents:
|
||||
|
||||
- tiger : the orchestrator itself. Use ONLY for high-level coordination, daily summaries, deciding what to do next, or when no other agent fits.
|
||||
- cody : code, debugging, software engineering, devops, deployments, scripts, infra, build systems.
|
||||
- ethan : web research, fact-finding, gathering external information, market data, news, papers, regulatory filings.
|
||||
- cathy : writing, prose, emails, content, summaries written for human consumption, polishing language.
|
||||
- elon : analysis, financial modelling, energy/macro/markets reasoning, BESS/solar/policy work, structured quantitative thinking.
|
||||
|
||||
Reply with EXACTLY two lines, no preamble, no quotes, no markdown:
|
||||
agent: <id>
|
||||
reason: <one short sentence, max 15 words>`;
|
||||
|
||||
try {
|
||||
const reply = await callLLM(systemPrompt, taskText.slice(0, 4000), 80);
|
||||
const agentMatch = reply.match(/agent\s*:\s*(\w+)/i);
|
||||
const reasonMatch = reply.match(/reason\s*:\s*(.+)/i);
|
||||
const rawAgent = (agentMatch?.[1] || "").toLowerCase();
|
||||
const reason = (reasonMatch?.[1] || "").trim() || "no reason returned";
|
||||
|
||||
if ((AGENT_IDS as readonly string[]).includes(rawAgent)) {
|
||||
return { agent: rawAgent as AgentId, reason };
|
||||
}
|
||||
// Call succeeded but the model returned an agent we don't recognise.
|
||||
// Distinct from "router_unavailable" — the call worked, the answer was bad.
|
||||
return {
|
||||
agent: "tiger",
|
||||
reason: `router_unrecognized: model returned "${rawAgent}"`,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
agent: "tiger",
|
||||
reason: `router_unavailable: ${msg.slice(0, 140)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public: project title generator ────────────────────────────────────────
|
||||
/**
|
||||
* Turn a seed text (first user message, or explicit goal) into a 3-7 word title.
|
||||
* Returns null on failure so the caller can fall back to using the raw text.
|
||||
*/
|
||||
export async function generateProjectTitle(seedText: string): Promise<string | null> {
|
||||
const systemPrompt =
|
||||
`You generate concise project titles. Rules: 3-7 words. No quotes. ` +
|
||||
`No trailing period. Plain text only — no markdown, no labels.`;
|
||||
const user = `Title this in 3-7 words:\n\n${seedText.slice(0, 1000)}`;
|
||||
try {
|
||||
const reply = await callLLM(systemPrompt, user, 30);
|
||||
// Defensive cleanup — strip leading "Title:" labels, surrounding quotes,
|
||||
// trailing periods, trailing newlines. Cap length as a sanity guard.
|
||||
const cleaned = reply
|
||||
.replace(/^title\s*:\s*/i, "")
|
||||
.replace(/^["'`]+|["'`]+$/g, "")
|
||||
.replace(/\.+$/, "")
|
||||
.trim()
|
||||
.slice(0, 80);
|
||||
return cleaned || null;
|
||||
} catch (err) {
|
||||
console.warn("[llm] generateProjectTitle failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public: project goal generator ─────────────────────────────────────────
|
||||
/**
|
||||
* Turn a seed text into a one-line second-person goal statement.
|
||||
* Examples of expected output:
|
||||
* "Compare BESS economics across three scenarios"
|
||||
* "Pull and rank latest CERC tariff orders"
|
||||
* "Draft the Q4 investor letter"
|
||||
* Returns null on failure so the caller can leave the goal field empty.
|
||||
*/
|
||||
export async function generateProjectGoal(seedText: string): Promise<string | null> {
|
||||
const systemPrompt =
|
||||
`You write one-line project goal statements in imperative mood (second person). ` +
|
||||
`Examples: "Compare BESS economics across three scenarios", "Pull and rank ` +
|
||||
`latest CERC tariff orders", "Draft the Q4 investor letter". ` +
|
||||
`Rules: ONE sentence. Maximum 18 words. No preamble, no labels, no bullet points.`;
|
||||
const user = `Write the goal for this:\n\n${seedText.slice(0, 1000)}`;
|
||||
try {
|
||||
const reply = await callLLM(systemPrompt, user, 60);
|
||||
const cleaned = reply
|
||||
.replace(/^goal\s*:\s*/i, "")
|
||||
.replace(/^["'`]+|["'`]+$/g, "")
|
||||
.trim()
|
||||
.slice(0, 200);
|
||||
return cleaned || null;
|
||||
} catch (err) {
|
||||
console.warn("[llm] generateProjectGoal failed:", err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
319
bridge/src/lib/telegram.ts
Normal file
319
bridge/src/lib/telegram.ts
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
/**
|
||||
* telegram.ts — TelegramChannel (Option A: bridge owns Telegram polling)
|
||||
*
|
||||
* OpenClaw's built-in Telegram channel must be disabled before starting this
|
||||
* (set channels.telegram.enabled = false in openclaw.json).
|
||||
*
|
||||
* Flow per incoming message:
|
||||
* 1. getUpdates long-poll (timeout=25s, no flood risk)
|
||||
* 2. Filter: skip non-text, non-allowlisted chats, /start /help
|
||||
* 3. Create SQLite task (status=in-progress, project_id=null = orphan)
|
||||
* 4. classifyAgent → update task assigned_agent + agent_reason
|
||||
* 5. Send "routing to <agent>…" Telegram reply, store reply message_id
|
||||
* 6. docker exec openclaw agent --session-id tg_<chat_id> -m '...' --json
|
||||
* 7. Edit the reply message with the real response
|
||||
* 8. Update task status=done, persist both sides to chat_messages table
|
||||
*
|
||||
* Token resolution order:
|
||||
* TELEGRAM_BOT_TOKEN env var → openclaw.json channels.telegram.botToken
|
||||
*
|
||||
* Chat ID allowlist:
|
||||
* TELEGRAM_CHAT_ID env var → openclaw.json (not stored there currently)
|
||||
* If unset: accepts all chats (fine for a private bot)
|
||||
*/
|
||||
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { readFileSync } from "fs";
|
||||
// No db/classifyAgent imports — bridge is pure transport for Telegram.
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// -- Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TgUpdate {
|
||||
update_id: number;
|
||||
message?: TgMessage;
|
||||
}
|
||||
|
||||
interface TgMessage {
|
||||
message_id: number;
|
||||
from?: { id: number; username?: string; first_name?: string };
|
||||
chat: { id: number; type: string };
|
||||
text?: string;
|
||||
date: number;
|
||||
}
|
||||
|
||||
// -- Config resolution ────────────────────────────────────────────────────────
|
||||
|
||||
const OPENCLAW_CONFIG_PATH =
|
||||
process.env.OPENCLAW_CONFIG_PATH ||
|
||||
"/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json";
|
||||
|
||||
function readOpenClawConfig(): Record<string, any> {
|
||||
try {
|
||||
return JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function getBotToken(): string {
|
||||
if (process.env.TELEGRAM_BOT_TOKEN) return process.env.TELEGRAM_BOT_TOKEN;
|
||||
// Fall back to openclaw.json -- token lives there from initial setup
|
||||
const cfg = readOpenClawConfig();
|
||||
return cfg?.channels?.telegram?.botToken ?? "";
|
||||
}
|
||||
|
||||
function getAllowedChatId(): number | null {
|
||||
const raw = process.env.TELEGRAM_CHAT_ID;
|
||||
if (raw && raw.trim()) return parseInt(raw.trim(), 10);
|
||||
return null; // null = accept all chats
|
||||
}
|
||||
|
||||
// -- Telegram API helpers ─────────────────────────────────────────────────────
|
||||
|
||||
const TG_BASE = (token: string) => `https://api.telegram.org/bot${token}`;
|
||||
|
||||
async function tgGet(
|
||||
token: string,
|
||||
method: string,
|
||||
params: Record<string, string | number> = {}
|
||||
): Promise<any> {
|
||||
const url = new URL(`${TG_BASE(token)}/${method}`);
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
url.searchParams.set(k, String(v));
|
||||
}
|
||||
const res = await fetch(url.toString(), {
|
||||
signal: AbortSignal.timeout(32_000), // slightly above telegram timeout=25
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function tgPost(token: string, method: string, body: Record<string, any>): Promise<any> {
|
||||
const res = await fetch(`${TG_BASE(token)}/${method}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Send a message; returns the new message_id or null on failure
|
||||
async function sendMessage(
|
||||
token: string,
|
||||
chatId: number,
|
||||
text: string,
|
||||
replyToMessageId?: number
|
||||
): Promise<number | null> {
|
||||
try {
|
||||
const body: Record<string, any> = {
|
||||
chat_id: chatId,
|
||||
text: text.slice(0, 4096), // Telegram hard limit
|
||||
parse_mode: "Markdown",
|
||||
};
|
||||
if (replyToMessageId) body.reply_to_message_id = replyToMessageId;
|
||||
const r = await tgPost(token, "sendMessage", body);
|
||||
return r.ok ? (r.result?.message_id ?? null) : null;
|
||||
} catch (e: any) {
|
||||
console.error("[telegram] sendMessage failed:", e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Show "Bot is typing--" indicator. Telegram clears it after 5s, so
|
||||
// we call it once immediately then repeat every 4s while waiting for
|
||||
// docker exec to return. Returns a cancel function.
|
||||
function startTyping(token: string, chatId: number): () => void {
|
||||
let active = true;
|
||||
const tick = async () => {
|
||||
while (active) {
|
||||
try {
|
||||
await tgPost(token, "sendChatAction", { chat_id: chatId, action: "typing" });
|
||||
} catch { /* non-fatal */ }
|
||||
await sleep(4000);
|
||||
}
|
||||
};
|
||||
tick(); // fire immediately, don't await
|
||||
return () => { active = false; };
|
||||
}
|
||||
|
||||
// -- Per-message handler ──────────────────────────────────────────────────────
|
||||
|
||||
async function handleMessage(token: string, msg: TgMessage): Promise<void> {
|
||||
const text = (msg.text || "").trim();
|
||||
const chatId = msg.chat.id;
|
||||
// Session ID is per-chat so OpenClaw maintains conversation context across messages
|
||||
const sessionId = `tg_${chatId}`;
|
||||
|
||||
// -- Filter: skip empty, /start, /help --------------------------------
|
||||
if (!text) return;
|
||||
if (text === "/start" || text === "/help") {
|
||||
await sendMessage(
|
||||
token, chatId,
|
||||
"👋 *Tiger Command Center*\n\nSend me a task or question and I'll get right on it."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const from = msg.from?.username ? `@${msg.from.username}` : (msg.from?.first_name ?? "unknown");
|
||||
console.log(`[telegram] msg from ${from} in ${chatId}: ${text.slice(0, 80)}`);
|
||||
|
||||
// -- Show typing indicator while Tiger thinks -------------------------
|
||||
const stopTyping = startTyping(token, chatId);
|
||||
|
||||
// -- Forward to OpenClaw via docker exec ------------------------------
|
||||
// --channel telegram --deliver sends the response back via Telegram natively
|
||||
// when the native Telegram channel is enabled. Since we disabled it, we
|
||||
// use --json and send the response ourselves.
|
||||
// Write message to temp file inside container -- avoids ALL shell escaping
|
||||
// issues with backticks, quotes, JSON, code blocks in user messages.
|
||||
const tmpFile = `/tmp/tg_${Date.now()}.txt`;
|
||||
const { writeFileSync, unlinkSync } = await import("fs");
|
||||
const { execSync } = await import("child_process");
|
||||
try {
|
||||
writeFileSync(tmpFile, text, "utf-8");
|
||||
execSync(`docker cp ${tmpFile} tiger-openclaw:${tmpFile}`, { timeout: 5000 });
|
||||
unlinkSync(tmpFile);
|
||||
} catch (copyErr: any) {
|
||||
console.error("[telegram] cp to container failed:", copyErr.message);
|
||||
stopTyping();
|
||||
await sendMessage(token, chatId, "Internal error — could not forward your message.", msg.message_id);
|
||||
return;
|
||||
}
|
||||
const cmd = `docker exec tiger-openclaw sh -c 'MSG=$(cat ${tmpFile}); rm -f ${tmpFile}; openclaw agent --session-id ${sessionId} -m "$MSG" --json --timeout 120'`;
|
||||
|
||||
let replyText = "";
|
||||
let execOk = true;
|
||||
try {
|
||||
const { stdout } = await execAsync(cmd, {
|
||||
timeout: 130_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
let parsed: any;
|
||||
try { parsed = JSON.parse(stdout); } catch { parsed = { output: stdout }; }
|
||||
replyText =
|
||||
parsed?.result?.payloads?.[0]?.text ||
|
||||
parsed?.payloads?.[0]?.text ||
|
||||
parsed?.summary ||
|
||||
parsed?.text ||
|
||||
parsed?.output ||
|
||||
"_(no response)_";
|
||||
} catch (e: any) {
|
||||
console.error("[telegram] docker exec failed:", e.message);
|
||||
replyText = "⚠️ Tiger timed out or is offline.";
|
||||
execOk = false;
|
||||
}
|
||||
|
||||
stopTyping();
|
||||
|
||||
// -- Send response -----------------------------------------------------
|
||||
const truncated =
|
||||
replyText.length > 4000
|
||||
? replyText.slice(0, 3990) + "\n\n_(truncated)_"
|
||||
: replyText;
|
||||
|
||||
await sendMessage(token, chatId, truncated, msg.message_id);
|
||||
|
||||
if (execOk) {
|
||||
console.log(`[telegram] response sent to ${from} in ${chatId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Main poller ──────────────────────────────────────────────────────────────
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
|
||||
// Per-chat message queue -- one docker exec at a time per chat_id.
|
||||
// Prevents concurrent gateway WebSocket connections on Telegram bursts.
|
||||
const chatQueues = new Map<number, Promise<void>>();
|
||||
|
||||
function enqueueForChat(chatId: number, fn: () => Promise<void>): void {
|
||||
const prev = chatQueues.get(chatId) ?? Promise.resolve();
|
||||
const next = prev.then(() => fn()).catch((e) =>
|
||||
console.error('[telegram] queue error for chat ' + chatId + ':', e.message)
|
||||
);
|
||||
chatQueues.set(chatId, next);
|
||||
next.finally(() => {
|
||||
if (chatQueues.get(chatId) === next) chatQueues.delete(chatId);
|
||||
});
|
||||
}
|
||||
|
||||
export class TelegramChannel {
|
||||
private token: string;
|
||||
private allowedChatId: number | null;
|
||||
private offset = 0;
|
||||
private running = false;
|
||||
|
||||
constructor() {
|
||||
this.token = getBotToken();
|
||||
this.allowedChatId = getAllowedChatId();
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!this.token) {
|
||||
console.warn(
|
||||
"[telegram] No bot token found in TELEGRAM_BOT_TOKEN env or openclaw.json — channel disabled."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
console.log(
|
||||
`[telegram] Polling started (allowedChatId=${this.allowedChatId ?? "all"})`
|
||||
);
|
||||
this._poll().catch((e) => console.error("[telegram] Poll loop crashed:", e));
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.running = false;
|
||||
console.log("[telegram] Polling stopped.");
|
||||
}
|
||||
|
||||
private async _poll(): Promise<void> {
|
||||
while (this.running) {
|
||||
try {
|
||||
const data = await tgGet(this.token, "getUpdates", {
|
||||
offset: this.offset,
|
||||
timeout: 25, // seconds -- long-poll
|
||||
allowed_updates: "message",
|
||||
});
|
||||
|
||||
if (!data.ok || !Array.isArray(data.result)) {
|
||||
// Telegram returned an error (bad token, network, etc.)
|
||||
console.error("[telegram] getUpdates error:", data.description ?? data);
|
||||
await sleep(10_000);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const update of data.result as TgUpdate[]) {
|
||||
// Advance offset FIRST -- ensures we never re-process even if handler throws
|
||||
this.offset = update.update_id + 1;
|
||||
|
||||
const msg = update.message;
|
||||
if (!msg) continue;
|
||||
|
||||
// Chat allowlist check
|
||||
if (this.allowedChatId !== null && msg.chat.id !== this.allowedChatId) {
|
||||
console.log(`[telegram] Skipping chat ${msg.chat.id} — not in allowlist`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Queue per chat -- one docker exec at a time per chat_id.
|
||||
// Prevents concurrent gateway WebSocket connections on message bursts.
|
||||
const token = this.token;
|
||||
enqueueForChat(msg.chat.id, () => handleMessage(token, msg));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!this.running) break;
|
||||
console.error("[telegram] Poll error, backing off 5s:", e.message);
|
||||
await sleep(5_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
bridge/src/routes/agents-activity.ts
Normal file
108
bridge/src/routes/agents-activity.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* routes/agents-activity.ts — Transform agents data into per-agent activity view
|
||||
*
|
||||
* GET /tiger/agents/activity
|
||||
* Uses execInSandbox to call /tiger/agents from inside OpenClaw container,
|
||||
* then transforms to per-agent activity cards.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
function agentName(subAgentId: string | null | undefined): string {
|
||||
if (!subAgentId) return "Tiger";
|
||||
const map: Record<string, string> = {
|
||||
"main": "Tiger",
|
||||
"coder": "Cody",
|
||||
"researcher": "Ethan",
|
||||
"writer": "Cathy",
|
||||
"pm": "Elon",
|
||||
};
|
||||
return map[subAgentId] || subAgentId;
|
||||
}
|
||||
|
||||
function timeAgo(timestamp: number | null): string {
|
||||
if (!timestamp) return "never";
|
||||
const seconds = Math.floor((Date.now() / 1000) - timestamp);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
// Use execInSandbox to call /tiger/agents from inside OpenClaw container
|
||||
const { stdout } = await execInSandbox(
|
||||
`curl -s "http://172.17.0.1:3456/tiger/agents" -H "Authorization: Bearer 14fb879429386b69beac339bbd98e43011ec29485da17592410da34ed97e0236"`
|
||||
);
|
||||
|
||||
let rawData: any;
|
||||
try {
|
||||
rawData = JSON.parse(stdout);
|
||||
} catch {
|
||||
return res.status(500).json({ ok: false, error: "Could not parse agents response" });
|
||||
}
|
||||
|
||||
const rawSessions: any[] = rawData.sessions || [];
|
||||
|
||||
// Build per-agent status
|
||||
const agentMap: Record<string, any> = {
|
||||
"Tiger": { id: "main", name: "Tiger", subAgentId: "main", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
|
||||
"Cody": { id: "coder", name: "Cody", subAgentId: "coder", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
|
||||
"Ethan": { id: "researcher", name: "Ethan", subAgentId: "researcher", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
|
||||
"Cathy": { id: "writer", name: "Cathy", subAgentId: "writer", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
|
||||
"Elon": { id: "pm", name: "Elon", subAgentId: "pm", sessions: [], lastActive: null, isRunning: false, currentTask: "" },
|
||||
};
|
||||
|
||||
for (const session of rawSessions) {
|
||||
const name = agentName(session.subAgentId);
|
||||
if (!agentMap[name]) continue;
|
||||
|
||||
agentMap[name].sessions.push({
|
||||
sessionKey: session.sessionKey,
|
||||
label: session.label,
|
||||
lastMessage: session.lastMessage || "",
|
||||
model: session.model,
|
||||
running: session.running || false,
|
||||
});
|
||||
|
||||
if (session.running) {
|
||||
agentMap[name].isRunning = true;
|
||||
agentMap[name].currentTask = session.label || "Running";
|
||||
agentMap[name].lastActive = timeAgo(session.lastMessageTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Set lastActive for non-running agents based on most recent message
|
||||
for (const name of Object.keys(agentMap)) {
|
||||
const a = agentMap[name];
|
||||
if (!a.isRunning && a.sessions.length > 0) {
|
||||
a.sessions.sort((x: any, y: any) => (y.lastMessageTime || 0) - (x.lastMessageTime || 0));
|
||||
a.lastActive = timeAgo(a.sessions[0].lastMessageTime);
|
||||
}
|
||||
}
|
||||
|
||||
const agents = Object.values(agentMap).map((a: any) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
subAgentId: a.subAgentId,
|
||||
status: a.isRunning ? "active" : (a.sessions.length > 0 ? "idle" : "offline"),
|
||||
currentTask: a.currentTask,
|
||||
lastActive: a.lastActive,
|
||||
sessionCount: a.sessions.length,
|
||||
isRunning: a.isRunning,
|
||||
}));
|
||||
|
||||
res.json({ ok: true, count: agents.length, agents, updated: new Date().toISOString() });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
181
bridge/src/routes/agents.ts
Normal file
181
bridge/src/routes/agents.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* agents.ts — Per-agent workspace file browser + activity feed
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const AGENTS = [
|
||||
{ id: "main", name: "Tiger", emoji: "🐯", role: "orchestrator", basePath: "/home/node/.openclaw/workspace" },
|
||||
{ id: "coder", name: "Cody", emoji: "👷", role: "Coder", basePath: "/home/node/.openclaw/agents/coder" },
|
||||
{ id: "researcher", name: "Ethan", emoji: "🔍", role: "Researcher", basePath: "/home/node/.openclaw/agents/researcher" },
|
||||
{ id: "writer", name: "Cathy", emoji: "✍️", role: "Writer", basePath: "/home/node/.openclaw/agents/writer" },
|
||||
{ id: "pm", name: "Elon", emoji: "✅", role: "PM", basePath: "/home/node/.openclaw/agents/pm" },
|
||||
];
|
||||
|
||||
function getAgent(id: string) {
|
||||
return AGENTS.find((a) => a.id === id) ?? null;
|
||||
}
|
||||
|
||||
function isSafePath(p: string): boolean {
|
||||
return !p.includes("..") && !p.startsWith("/");
|
||||
}
|
||||
|
||||
// GET /tiger/agents
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
AGENTS.map(async (agent) => {
|
||||
const { stdout } = await execInSandbox(
|
||||
`find ${agent.basePath} -type f -printf '%T@\n' 2>/dev/null | sort -rn`
|
||||
);
|
||||
const mtimes = stdout.split("\n").filter(Boolean).map(Number);
|
||||
return {
|
||||
id: agent.id, name: agent.name, emoji: agent.emoji, role: agent.role,
|
||||
fileCount: mtimes.length,
|
||||
lastActivity: mtimes.length > 0 ? Math.floor(mtimes[0] * 1000) : 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
res.json({ ok: true, agents: results });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /tiger/agents/:id/files?path=deliverables
|
||||
router.get("/:id/files", async (req: Request, res: Response) => {
|
||||
const agent = getAgent(req.params.id);
|
||||
if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" });
|
||||
|
||||
const relPath = (req.query.path as string) || "";
|
||||
if (relPath && !isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" });
|
||||
|
||||
const targetDir = relPath ? `${agent.basePath}/${relPath}` : agent.basePath;
|
||||
const dirName = targetDir.split("/").pop() ?? "";
|
||||
|
||||
try {
|
||||
const { stdout } = await execInSandbox(
|
||||
`find ${targetDir} -maxdepth 1 -printf '%y|%s|%T@|%f\n' 2>/dev/null | sort`
|
||||
);
|
||||
|
||||
const items = stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [typeChar, sizeStr, mtimeStr, ...rest] = line.split("|");
|
||||
const name = rest.join("|");
|
||||
return {
|
||||
name,
|
||||
type: typeChar === "d" ? "dir" as const : "file" as const,
|
||||
size: parseInt(sizeStr) || 0,
|
||||
modifiedAt: Math.floor(parseFloat(mtimeStr) * 1000),
|
||||
};
|
||||
})
|
||||
.filter((f) => f.name !== "." && f.name !== dirName);
|
||||
|
||||
res.json({ ok: true, items });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /tiger/agents/:id/file?path=deliverables/ev-dashboard.html
|
||||
router.get("/:id/file", async (req: Request, res: Response) => {
|
||||
const agent = getAgent(req.params.id);
|
||||
if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" });
|
||||
|
||||
const relPath = req.query.path as string;
|
||||
if (!relPath) return res.status(400).json({ ok: false, error: "Missing path param" });
|
||||
if (!isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" });
|
||||
|
||||
const fullPath = `${agent.basePath}/${relPath}`;
|
||||
|
||||
try {
|
||||
const { stdout: sizeOut } = await execInSandbox(`stat -c%s ${fullPath} 2>/dev/null || echo 0`);
|
||||
const size = parseInt(sizeOut.trim()) || 0;
|
||||
if (size > 5 * 1024 * 1024) return res.status(413).json({ ok: false, error: "File too large (> 5MB)" });
|
||||
|
||||
const { stdout: mimeOut } = await execInSandbox(`file --mime-type -b ${fullPath} 2>/dev/null`);
|
||||
const mime = mimeOut.trim();
|
||||
const isText = mime.startsWith("text/") || mime.includes("json") || mime.includes("xml") || mime.includes("javascript");
|
||||
|
||||
if (!isText && size > 0) {
|
||||
const { stdout: b64 } = await execInSandbox(`base64 -w0 ${fullPath} 2>/dev/null`);
|
||||
return res.json({ ok: true, path: relPath, content: b64, encoding: "base64", size, mime });
|
||||
}
|
||||
|
||||
const { stdout: content, exitCode } = await execInSandbox(`cat ${fullPath} 2>/dev/null`);
|
||||
if (exitCode !== 0) return res.status(404).json({ ok: false, error: "File not found" });
|
||||
|
||||
res.json({ ok: true, path: relPath, content, encoding: "utf8", size, mime });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// GET /tiger/agents/activity?limit=50
|
||||
router.get("/activity", async (req: Request, res: Response) => {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
||||
try {
|
||||
const agentPaths = AGENTS.map((a) => a.basePath).join(" ");
|
||||
const { stdout } = await execInSandbox(
|
||||
`find ${agentPaths} -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -${limit}`
|
||||
);
|
||||
const events = stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const spaceIdx = line.indexOf(" ");
|
||||
const ts = Math.floor(parseFloat(line.slice(0, spaceIdx)) * 1000);
|
||||
const fullPath = line.slice(spaceIdx + 1);
|
||||
const agent = AGENTS.find((a) => fullPath.startsWith(a.basePath)) ?? null;
|
||||
if (!agent) return null;
|
||||
const relPath = fullPath.slice(agent.basePath.length + 1);
|
||||
return { agentId: agent.id, agentName: agent.name, agentEmoji: agent.emoji, path: relPath, action: "modified", ts };
|
||||
})
|
||||
.filter(Boolean);
|
||||
res.json({ ok: true, events });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// PUT /tiger/agents/:id/file?path=... — write file contents back into container
|
||||
// Body: { content: string }
|
||||
router.put("/:id/file", async (req: Request, res: Response) => {
|
||||
const agent = getAgent(req.params.id);
|
||||
if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" });
|
||||
|
||||
const relPath = req.query.path as string;
|
||||
if (!relPath) return res.status(400).json({ ok: false, error: "Missing path param" });
|
||||
if (!isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" });
|
||||
|
||||
const { content } = req.body as { content?: string };
|
||||
if (typeof content !== "string") {
|
||||
return res.status(400).json({ ok: false, error: "Body must be { content: string }" });
|
||||
}
|
||||
|
||||
const fullPath = `${agent.basePath}/${relPath}`;
|
||||
|
||||
try {
|
||||
// Write via stdin to avoid shell quoting issues with special characters.
|
||||
// We base64-encode the content on the Node side, pipe it in, and decode inside the container.
|
||||
const b64 = Buffer.from(content, "utf-8").toString("base64");
|
||||
const { exitCode, stderr } = await execInSandbox(
|
||||
`echo '${b64}' | base64 -d > ${fullPath}`
|
||||
);
|
||||
if (exitCode !== 0) {
|
||||
return res.status(500).json({ ok: false, error: "Write failed", details: stderr });
|
||||
}
|
||||
res.json({ ok: true, path: relPath });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
88
bridge/src/routes/alerts.ts
Normal file
88
bridge/src/routes/alerts.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/**
|
||||
* alerts.ts — GET /tiger/alerts
|
||||
*
|
||||
* Returns active alerts and allows configuring proactive notifications.
|
||||
*
|
||||
* GET /tiger/alerts — get active alerts
|
||||
* GET /tiger/alerts?check=true — run check and return fresh alerts
|
||||
*
|
||||
* Response:
|
||||
* { ok: true, alerts: [{ type, message, priority, timestamp }] }
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface Alert {
|
||||
type: string
|
||||
message: string
|
||||
priority: "high" | "medium" | "low"
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
async function checkSystemAlerts(): Promise<Alert[]> {
|
||||
const alerts: Alert[] = []
|
||||
const now = new Date().toISOString()
|
||||
|
||||
try {
|
||||
// Check memory usage
|
||||
const memResult = await execInSandbox("cat /proc/meminfo | grep MemAvailable")
|
||||
if (memResult.stdout.includes("MemAvailable")) {
|
||||
const match = memResult.stdout.match(/(\d+)/)
|
||||
if (match) {
|
||||
const availableMB = parseInt(match[1]) / 1024
|
||||
if (availableMB < 500) {
|
||||
alerts.push({
|
||||
type: "memory",
|
||||
message: `Low memory: ${Math.round(availableMB)}MB available`,
|
||||
priority: "high",
|
||||
timestamp: now
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check disk usage
|
||||
const diskResult = await execInSandbox("df -h / | tail -1")
|
||||
if (diskResult.stdout.includes("%")) {
|
||||
const match = diskResult.stdout.match(/(\d+)%/)
|
||||
if (match) {
|
||||
const usage = parseInt(match[1])
|
||||
if (usage > 85) {
|
||||
alerts.push({
|
||||
type: "disk",
|
||||
message: `High disk usage: ${usage}%`,
|
||||
priority: usage > 95 ? "high" : "medium",
|
||||
timestamp: now
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// System check failed
|
||||
}
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
const doCheck = req.query.check === "true"
|
||||
|
||||
let alerts: Alert[] = []
|
||||
|
||||
if (doCheck) {
|
||||
alerts = await checkSystemAlerts()
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
alerts,
|
||||
lastCheck: new Date().toISOString(),
|
||||
count: alerts.length
|
||||
})
|
||||
})
|
||||
|
||||
export default router
|
||||
138
bridge/src/routes/angel/positions.ts
Normal file
138
bridge/src/routes/angel/positions.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
/**
|
||||
* angel-positions.ts — Fetch open positions from Angel ONE Smart API
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /login — Get JWT token
|
||||
* - GET /portfolio — Fetch positions
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import axios from "axios";
|
||||
import * as OTPAuth from "otplib";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const API_BASE = process.env.DATA_SOURCE === "live"
|
||||
? "https://smartapi.angelone.in"
|
||||
: "https://smapi.angelone.in";
|
||||
|
||||
const API_KEY = process.env.ANGEL_ONE_API_KEY || "";
|
||||
const CLIENT_ID = process.env.ANGEL_ONE_CLIENT_ID || "";
|
||||
const PASSWORD = process.env.ANGEL_ONE_PASSWORD || "";
|
||||
const TOTP_SECRET = process.env.ANGEL_ONE_TOTP_SECRET || "";
|
||||
|
||||
// In-memory token cache (simple, valid for ~1 day)
|
||||
let cachedToken: string | null = null;
|
||||
let tokenExpiry: number = 0;
|
||||
|
||||
// Generate TOTP from secret
|
||||
function generateTOTP(secret: string): string {
|
||||
try {
|
||||
const totp = new OTPAuth.TOTP({
|
||||
issuer: "AngelOne",
|
||||
label: "SmartAPI",
|
||||
algorithm: "sha1",
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: secret
|
||||
});
|
||||
return totp.generate() as string;
|
||||
} catch (e) {
|
||||
console.error("[angel] TOTP generation failed:", e);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Login to Angel ONE and get JWT token
|
||||
async function getToken(): Promise<string | null> {
|
||||
// Check cache
|
||||
if (cachedToken && Date.now() < tokenExpiry) {
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
try {
|
||||
const totp = generateTOTP(TOTP_SECRET);
|
||||
if (!totp) {
|
||||
throw new Error("TOTP generation failed");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
clientcode: CLIENT_ID,
|
||||
password: PASSWORD,
|
||||
totp: totp,
|
||||
apiKey: API_KEY
|
||||
};
|
||||
|
||||
const response = await axios.post(`${API_BASE}/login`, payload, {
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
|
||||
if (response.data?.status?.success) {
|
||||
cachedToken = response.data.data.jwtToken;
|
||||
// Token valid for ~24 hours, cache for 23 hours
|
||||
tokenExpiry = Date.now() + (23 * 60 * 60 * 1000);
|
||||
return cachedToken;
|
||||
} else {
|
||||
console.error("[angel] Login failed:", response.data);
|
||||
return null;
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[angel] Login error:", err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch open positions
|
||||
async function getPositions(): Promise<any[]> {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
throw new Error("Failed to get Angel ONE token");
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_BASE}/portfolio`, {
|
||||
headers: {
|
||||
"Authorization": token,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data?.status?.success) {
|
||||
return response.data.data || [];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[angel] Portfolio fetch error:", err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// GET /angel/positions — Get current positions
|
||||
router.get("/positions", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const positions = await getPositions();
|
||||
res.json({ ok: true, count: positions.length, positions });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /angel/positions/short — Short summary (symbol + P&L only)
|
||||
router.get("/positions/short", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const positions = await getPositions();
|
||||
const summary = positions.map((p: any) => ({
|
||||
symbol: p.tradingSymbol || p.symbol || p.tsym,
|
||||
pnl: p.pnl || p.realizedPnL || p.unrealizedPnL || 0,
|
||||
quantity: p.quantity || p.qty || 0,
|
||||
ltp: p.ltp || p.avgPrice || 0
|
||||
}));
|
||||
res.json({ ok: true, summary });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
export { getPositions, getToken };
|
||||
68
bridge/src/routes/chat-mirror.ts
Normal file
68
bridge/src/routes/chat-mirror.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* chat-mirror.ts — Mirror Telegram messages to shared SQLite
|
||||
*
|
||||
* This is a simple endpoint that can be called to mirror messages
|
||||
* from any channel (Telegram, WhatsApp, etc.) into the chat history.
|
||||
*
|
||||
* POST /tiger/chat/mirror
|
||||
* Body: {
|
||||
* role: "user" | "agent",
|
||||
* content: "message text",
|
||||
* source: "telegram" | "whatsapp" | "web",
|
||||
* sessionId?: "agent:main:main"
|
||||
* }
|
||||
*
|
||||
* Response: { ok: true, id: number }
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import db from "../db.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const DEFAULT_SESSION_ID = "agent:main:main";
|
||||
|
||||
const insertMessage = db.prepare(`
|
||||
INSERT INTO chat_messages (session_id, role, content, meta)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
// POST /tiger/chat/mirror — store a message from any source
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const { role, content, source, sessionId } = req.body;
|
||||
|
||||
if (!role || !content) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: "role and content are required"
|
||||
});
|
||||
}
|
||||
|
||||
if (role !== "user" && role !== "agent" && role !== "system") {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: "role must be 'user', 'agent', or 'system'"
|
||||
});
|
||||
}
|
||||
|
||||
const sid = sessionId || DEFAULT_SESSION_ID;
|
||||
const meta = JSON.stringify({
|
||||
source: source || "unknown",
|
||||
mirrored: true,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
try {
|
||||
const info = insertMessage.run(sid, role, content, meta);
|
||||
res.json({
|
||||
ok: true,
|
||||
id: info.lastInsertRowid,
|
||||
sessionId: sid,
|
||||
source: source || "unknown"
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
203
bridge/src/routes/chat.ts
Normal file
203
bridge/src/routes/chat.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* routes/chat.ts — Chat via OpenClaw CLI + persistence
|
||||
*
|
||||
* POST /tiger/chat — send a message; response includes reply
|
||||
* GET /tiger/chat/history — ?sessionId=X&limit=50 → past messages
|
||||
* DELETE /tiger/chat/history — ?sessionId=X → clear history for a session
|
||||
*
|
||||
* Persistence rationale (see phase1b-patches.py):
|
||||
* Chat history is duplicated into our SQLite so it survives:
|
||||
* - browser hard refresh
|
||||
* - close/reopen tab
|
||||
* - use from a different device
|
||||
* - OpenClaw restarts (session state may or may not persist internally)
|
||||
* We own the read path; OpenClaw owns the reasoning context.
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import db from "../db.js";
|
||||
|
||||
// The main Tiger session — matches the hardcoded session in chat.send below.
|
||||
// Keep this constant in sync with the --session-id used by openclaw agent.
|
||||
const DEFAULT_SESSION_ID = "agent:main:main";
|
||||
|
||||
const insertMessage = db.prepare(`
|
||||
INSERT INTO chat_messages (session_id, role, content, meta)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
const getHistory = db.prepare(`
|
||||
SELECT id, role, content, meta, created_at
|
||||
FROM chat_messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?
|
||||
`);
|
||||
const deleteHistory = db.prepare(`
|
||||
DELETE FROM chat_messages WHERE session_id = ?
|
||||
`);
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ─── GET /tiger/chat/history ─────────────────────────────────────────────
|
||||
router.get("/history", (req, res) => {
|
||||
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 200, 500);
|
||||
const rows = getHistory.all(sessionId, limit) as any[];
|
||||
res.json({
|
||||
ok: true,
|
||||
sessionId,
|
||||
count: rows.length,
|
||||
messages: rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
role: r.role,
|
||||
content: r.content,
|
||||
timestamp: new Date(r.created_at + "Z").getTime(),
|
||||
meta: r.meta ? JSON.parse(r.meta) : {},
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /tiger/chat/history ──────────────────────────────────────────
|
||||
router.delete("/history", (req, res) => {
|
||||
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID;
|
||||
const result = deleteHistory.run(sessionId);
|
||||
res.json({ ok: true, deleted: result.changes });
|
||||
});
|
||||
|
||||
// ─── POST /tiger/chat ────────────────────────────────────────────────────
|
||||
router.post("/", async (req, res) => {
|
||||
const { message } = req.body;
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).json({ ok: false, error: "message is required" });
|
||||
}
|
||||
|
||||
// Persist the user's message BEFORE calling the LLM so history is intact
|
||||
// even if the LLM call fails.
|
||||
try {
|
||||
insertMessage.run(DEFAULT_SESSION_ID, "user", message, "{}");
|
||||
} catch (e: any) {
|
||||
console.warn("[chat] failed to persist user message:", e.message);
|
||||
}
|
||||
|
||||
// ── Timing instrumentation ──────────────────────────────────────
|
||||
// Label each phase so we can see where latency goes. Format in logs:
|
||||
// [chat.timing] spawn=120ms exec=2834ms parse=3ms total=2957ms
|
||||
const tStart = Date.now();
|
||||
let tSpawn = 0;
|
||||
let tExec = 0;
|
||||
let tParse = 0;
|
||||
|
||||
try {
|
||||
const { exec } = await import("child_process");
|
||||
const { promisify } = await import("util");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Write message to temp file — avoids ALL shell escaping issues
|
||||
// (backticks, quotes, code blocks in messages are all safe this way)
|
||||
const { writeFileSync: wfChat, unlinkSync: ulChat } = await import("fs");
|
||||
const { execSync: exChat } = await import("child_process");
|
||||
const tmpMsg = `/tmp/msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.txt`;
|
||||
const sshPrefix = process.env.TIGER_REMOTE === "true"
|
||||
? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} `
|
||||
: "";
|
||||
try {
|
||||
wfChat(tmpMsg, message, "utf-8");
|
||||
exChat(`${sshPrefix}docker cp ${tmpMsg} tiger-openclaw:${tmpMsg}`, { timeout: 5000 });
|
||||
ulChat(tmpMsg);
|
||||
} catch (cpErr: any) {
|
||||
throw new Error(`Failed to stage message for container: ${cpErr.message}`);
|
||||
}
|
||||
const cmd = `${sshPrefix}docker exec tiger-openclaw sh -c 'MSG=$(cat ${tmpMsg}); rm -f ${tmpMsg}; openclaw agent --session-id agent:main:main -m "$MSG" --json --timeout 120'`;
|
||||
|
||||
const tBeforeSpawn = Date.now();
|
||||
tSpawn = tBeforeSpawn - tStart;
|
||||
console.log("[chat] Executing:", cmd.substring(0, 100) + "...");
|
||||
|
||||
const { stdout, stderr } = await execAsync(cmd, {
|
||||
timeout: 130000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
tExec = Date.now() - tBeforeSpawn;
|
||||
console.log("[chat] Response:", stdout.substring(0, 500));
|
||||
|
||||
// Parse the JSON response
|
||||
const tBeforeParse = Date.now();
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(stdout);
|
||||
} catch {
|
||||
result = { output: stdout, error: stderr };
|
||||
}
|
||||
tParse = Date.now() - tBeforeParse;
|
||||
|
||||
const tTotal = Date.now() - tStart;
|
||||
console.log(
|
||||
`[chat.timing] spawn=${tSpawn}ms exec=${tExec}ms parse=${tParse}ms total=${tTotal}ms`
|
||||
);
|
||||
|
||||
// Persist the agent's reply. Extract text using the same fallback chain
|
||||
// as the dashboard so we store whatever the user actually sees.
|
||||
try {
|
||||
const agentText =
|
||||
result?.result?.payloads?.[0]?.text ||
|
||||
result?.payloads?.[0]?.text ||
|
||||
result?.summary ||
|
||||
result?.text ||
|
||||
"";
|
||||
if (agentText) {
|
||||
const meta = {
|
||||
runId: result?.runId,
|
||||
model: result?.result?.meta?.agentMeta?.model || result?.meta?.agentMeta?.model,
|
||||
durationMs: tTotal,
|
||||
};
|
||||
insertMessage.run(DEFAULT_SESSION_ID, "agent", agentText, JSON.stringify(meta));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn("[chat] failed to persist agent reply:", e.message);
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
timing: { spawn: tSpawn, exec: tExec, parse: tParse, total: tTotal },
|
||||
response: result,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const tTotal = Date.now() - tStart;
|
||||
console.error(`[chat] Error after ${tTotal}ms:`, err.message);
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
error: err.message || "Failed to send chat message",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ─── POST /tiger/chat/persist ─────────────────────────────────────────────
|
||||
// Write-only endpoint used by the new WS-based dashboard chat route.
|
||||
// The dashboard streams events directly from the OpenClaw gateway (no docker exec),
|
||||
// but we still want chat history to land in our sqlite so the dashboard's
|
||||
// history UI keeps working. Dashboard calls this AFTER its stream completes.
|
||||
//
|
||||
// Body: { role: "user"|"agent", content: string, meta?: object, sessionId?: string }
|
||||
router.post("/persist", (req, res) => {
|
||||
const { role, content, meta, sessionId } = req.body || {};
|
||||
if (role !== "user" && role !== "agent") {
|
||||
return res.status(400).json({ ok: false, error: "role must be 'user' or 'agent'" });
|
||||
}
|
||||
if (typeof content !== "string" || !content) {
|
||||
return res.status(400).json({ ok: false, error: "content is required" });
|
||||
}
|
||||
try {
|
||||
const sid = (typeof sessionId === "string" && sessionId) || DEFAULT_SESSION_ID;
|
||||
const metaJson = meta && typeof meta === "object" ? JSON.stringify(meta) : "{}";
|
||||
const info = insertMessage.run(sid, role, content, metaJson);
|
||||
res.json({ ok: true, id: String(info.lastInsertRowid), sessionId: sid });
|
||||
} catch (e: any) {
|
||||
console.warn("[chat.persist] failed:", e.message);
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
83
bridge/src/routes/context.ts
Normal file
83
bridge/src/routes/context.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* context.ts — GET/POST /tiger/context
|
||||
*
|
||||
* Session context storage - remembers context across messages.
|
||||
* This enables T012: Context Injection.
|
||||
*
|
||||
* GET /tiger/context?sessionId=X — get context for session
|
||||
* POST /tiger/context — set context { sessionId, key, value }
|
||||
* DELETE /tiger/context?sessionId=X — clear context
|
||||
*
|
||||
* Response:
|
||||
* { ok: true, context: { ... }, message }
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import db from "../db.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Table for session context
|
||||
const getContext = db.prepare(`
|
||||
SELECT key, value FROM session_context WHERE session_id = ?
|
||||
`);
|
||||
const setContext = db.prepare(`
|
||||
INSERT OR REPLACE INTO session_context (session_id, key, value, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
`);
|
||||
const clearContext = db.prepare(`
|
||||
DELETE FROM session_context WHERE session_id = ?
|
||||
`);
|
||||
|
||||
const DEFAULT_SESSION = "agent:main:main";
|
||||
|
||||
// GET context
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION;
|
||||
|
||||
try {
|
||||
const rows = getContext.all(sessionId) as any[];
|
||||
const context: Record<string, string> = {};
|
||||
for (const row of rows) {
|
||||
context[row.key] = row.value;
|
||||
}
|
||||
|
||||
res.json({ ok: true, sessionId, context });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST set context
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const { sessionId, key, value } = req.body;
|
||||
const sid = sessionId || DEFAULT_SESSION;
|
||||
|
||||
if (!key) {
|
||||
return res.status(400).json({ ok: false, error: "key is required" });
|
||||
}
|
||||
if (!value) {
|
||||
return res.status(400).json({ ok: false, error: "value is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
setContext.run(sid, key, value);
|
||||
res.json({ ok: true, sessionId: sid, key, value, message: "Context saved" });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE clear context
|
||||
router.delete("/", async (req: Request, res: Response) => {
|
||||
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION;
|
||||
|
||||
try {
|
||||
clearContext.run(sessionId);
|
||||
res.json({ ok: true, message: "Context cleared", sessionId });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router
|
||||
77
bridge/src/routes/cron.ts
Normal file
77
bridge/src/routes/cron.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// cron/jobs.json lives inside the container at a known path.
|
||||
// We read it directly — openclaw cron list --json requires an active gateway
|
||||
// WebSocket which may not always be up.
|
||||
const CRON_JOBS_PATH = "/home/node/.openclaw/cron/jobs.json";
|
||||
|
||||
function formatCronJobs(raw: any): any[] {
|
||||
const jobs = raw?.jobs ?? [];
|
||||
return jobs.map((j: any) => ({
|
||||
id: j.id,
|
||||
name: j.name ?? j.id,
|
||||
schedule: j.schedule?.expr ?? "",
|
||||
tz: j.schedule?.tz ?? "UTC",
|
||||
enabled: j.enabled ?? true,
|
||||
agentId: j.agentId ?? "main",
|
||||
lastRun: j.state?.lastRunAtMs
|
||||
? {
|
||||
at: new Date(j.state.lastRunAtMs).toISOString(),
|
||||
status: j.state.lastRunStatus ?? j.state.lastStatus ?? "unknown",
|
||||
durationMs: j.state.lastDurationMs,
|
||||
errors: j.state.consecutiveErrors ?? 0,
|
||||
lastError: j.state.lastError ?? null,
|
||||
}
|
||||
: null,
|
||||
nextRun: j.state?.nextRunAtMs
|
||||
? new Date(j.state.nextRunAtMs).toISOString()
|
||||
: null,
|
||||
message: j.payload?.message?.slice(0, 120) ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
// GET /tiger/cron — list cron jobs directly from jobs.json
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { stdout } = await execInSandbox(`cat ${CRON_JOBS_PATH} 2>/dev/null || echo '{}'`);
|
||||
let raw: any = {};
|
||||
try { raw = JSON.parse(stdout.trim() || "{}"); } catch { raw = {}; }
|
||||
|
||||
const jobs = formatCronJobs(raw);
|
||||
|
||||
// Scheduler meta: enabled + nextWakeAt (from first job with nextRunAtMs)
|
||||
const nextWake = jobs.find((j) => j.nextRun)?.nextRun ?? null;
|
||||
const hasErrors = jobs.some((j) => (j.lastRun?.errors ?? 0) > 0);
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
jobs,
|
||||
status: {
|
||||
enabled: true,
|
||||
jobCount: jobs.length,
|
||||
nextWake,
|
||||
hasErrors,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /tiger/cron/:id/run — trigger a cron job immediately via gateway
|
||||
router.post("/:id/run", async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const { stdout, stderr } = await execInSandbox(
|
||||
`openclaw cron run ${id} 2>&1 || true`
|
||||
);
|
||||
res.json({ ok: true, output: (stdout || stderr).slice(0, 500) });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
40
bridge/src/routes/deploy.ts
Normal file
40
bridge/src/routes/deploy.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* deploy.ts — POST /tiger/deploy-dashboard
|
||||
*
|
||||
* Triggers a full dashboard rebuild + service restart on the host.
|
||||
* Called by Tiger (from inside container) after editing dashboard source files.
|
||||
*
|
||||
* Flow:
|
||||
* Tiger edits /home/node/dashboard/... (bind-mounted from /root/OpenClawDashboard)
|
||||
* Tiger calls POST /tiger/deploy-dashboard
|
||||
* Bridge runs /root/scripts/rebuild-dashboard.sh on HOST
|
||||
* Returns build output so Tiger can confirm success or report errors
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { execOnHost } from '../tiger.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post('/', async (_req, res) => {
|
||||
try {
|
||||
console.log('[deploy] Dashboard deploy triggered by Tiger');
|
||||
const result = await execOnHost(
|
||||
'/root/scripts/rebuild-dashboard.sh',
|
||||
120_000 // 2 min timeout — Next.js builds can be slow
|
||||
);
|
||||
const success = result.exitCode === 0;
|
||||
res.json({
|
||||
ok: success,
|
||||
message: success ? 'Dashboard deployed successfully' : 'Deploy failed',
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
exitCode: result.exitCode,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('[deploy] Error:', err.message);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
import { Router } from "express";
|
||||
import { tasks, executions } from "../db.js";
|
||||
import { classifyAgent } from "../lib/llm.js";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
|
@ -29,30 +30,59 @@ router.post("/", async (req, res) => {
|
|||
return res.status(404).json({ ok: false, error: "Task not found" });
|
||||
}
|
||||
|
||||
// ── Agent classification ───────────────────────────────────────────────────
|
||||
// classifyAgent never throws — falls back to { agent:'tiger', reason:'router_unavailable:...' }
|
||||
// so dispatch is safe even when the router LLM is offline.
|
||||
let resolvedAgent: string;
|
||||
let agentReason: string;
|
||||
|
||||
if (assignedAgent) {
|
||||
// Caller explicitly chose an agent — skip LLM, mark as manual.
|
||||
resolvedAgent = assignedAgent;
|
||||
agentReason = "manual";
|
||||
} else {
|
||||
const classifyInput = `${task.title}\n\n${task.description || ""}`.slice(0, 2000);
|
||||
const classification = await classifyAgent(classifyInput);
|
||||
resolvedAgent = classification.agent;
|
||||
agentReason = classification.reason;
|
||||
}
|
||||
|
||||
// Persist resolved agent + reason back to the task row before dispatch.
|
||||
tasks.update(taskId, { assigned_agent: resolvedAgent, agent_reason: agentReason });
|
||||
|
||||
// Prepare task data
|
||||
const taskData = {
|
||||
id: taskId,
|
||||
title: task.title,
|
||||
description: task.description || description || "",
|
||||
assignedAgent: assignedAgent || task.assigned_agent || "manual",
|
||||
assignedAgent: resolvedAgent,
|
||||
agentReason,
|
||||
context: context || "",
|
||||
createdAt: new Date().toISOString(),
|
||||
status: "pending",
|
||||
};
|
||||
|
||||
// Write task JSON to sandbox's inbox via kubectl exec
|
||||
// The sandbox path: /sandbox/.openclaw-data/workspace/tasks/inbox/
|
||||
const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox";
|
||||
// Write task JSON to container's inbox via docker exec
|
||||
// Tiger reads from the OpenClaw workspace tasks inbox
|
||||
const inboxPath = "/home/node/.openclaw/workspace/tasks/inbox";
|
||||
const taskFile = `task_${taskId}.json`;
|
||||
|
||||
// First ensure the directory exists inside the container
|
||||
await execInSandbox(`mkdir -p ${inboxPath}`);
|
||||
|
||||
// Write the task file using printf (more reliable than echo with escaping)
|
||||
// Write task JSON via temp file (avoids ALL shell escaping issues)
|
||||
const taskJson = JSON.stringify(taskData, null, 2);
|
||||
// Escape single quotes for shell: ' -> '\''
|
||||
const escapedJson = taskJson.replace(/'/g, "'\\''");
|
||||
await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`);
|
||||
const { writeFileSync, unlinkSync } = await import("fs");
|
||||
const { execSync } = await import("child_process");
|
||||
const tmpHost = `/tmp/task_${taskId}_${Date.now()}.json`;
|
||||
try {
|
||||
writeFileSync(tmpHost, taskJson, "utf-8");
|
||||
execSync(`docker cp ${tmpHost} tiger-openclaw:${tmpHost}`, { timeout: 5000 });
|
||||
unlinkSync(tmpHost);
|
||||
} catch (copyErr: any) {
|
||||
throw new Error(`Failed to copy task to container: ${copyErr.message}`);
|
||||
}
|
||||
await execInSandbox(`mkdir -p ${inboxPath} && mv ${tmpHost} ${inboxPath}/${taskFile}`);
|
||||
|
||||
// Create execution record
|
||||
const execution = executions.create({
|
||||
|
|
@ -89,8 +119,8 @@ router.get("/status/:taskId", async (req, res) => {
|
|||
const taskPath = `/sandbox/.openclaw-data/workspace/tasks/${dir}/task_${taskId}.json`;
|
||||
try {
|
||||
const content = await execInSandbox(`cat ${taskPath} 2>/dev/null || true`);
|
||||
if (content && content.trim()) {
|
||||
const taskData = JSON.parse(content);
|
||||
if (content && content.stdout && content.stdout.trim()) {
|
||||
const taskData = JSON.parse(content.stdout);
|
||||
return res.json({
|
||||
ok: true,
|
||||
status: dir,
|
||||
|
|
|
|||
78
bridge/src/routes/feedback.ts
Normal file
78
bridge/src/routes/feedback.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* feedback.ts — Continuous Learning
|
||||
* Simple feedback storage
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import db from "../db.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Ensure tables exist
|
||||
const initTables = () => {
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS feedback_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
context TEXT NOT NULL,
|
||||
user_feedback TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
} catch (e) { /* tables may exist */ }
|
||||
};
|
||||
initTables();
|
||||
|
||||
// Log feedback
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const { context, feedback } = req.body;
|
||||
if (!context || !feedback) {
|
||||
return res.status(400).json({ error: "context and feedback required" });
|
||||
}
|
||||
const id = randomUUID();
|
||||
try {
|
||||
db.prepare("INSERT INTO feedback_log (id, context, user_feedback) VALUES (?, ?, ?)")
|
||||
.run(id, context, feedback);
|
||||
// Simple pattern detection
|
||||
if (feedback.toLowerCase().includes("short") || feedback.toLowerCase().includes("brief")) {
|
||||
db.prepare("INSERT OR REPLACE INTO user_preferences (key, value) VALUES ('reply_length', 'brief')").run();
|
||||
}
|
||||
res.json({ ok: true, id });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get preferences
|
||||
router.get("/prefer", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const prefs = db.prepare("SELECT * FROM user_preferences").all();
|
||||
res.json({ preferences: prefs });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Store preference
|
||||
router.post("/prefer", async (req: Request, res: Response) => {
|
||||
const { key, value } = req.body;
|
||||
if (!key || value === undefined) {
|
||||
return res.status(400).json({ error: "key and value required" });
|
||||
}
|
||||
try {
|
||||
db.prepare("INSERT OR REPLACE INTO user_preferences (key, value) VALUES (?, ?)").run(key, String(value));
|
||||
res.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
54
bridge/src/routes/gateway.ts
Normal file
54
bridge/src/routes/gateway.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* gateway.ts — Proxy to OpenClaw Gateway inside Tiger container
|
||||
*
|
||||
* GET/POST /api/gateway/*
|
||||
* Forwards requests to the gateway running inside tiger-openclaw container
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Gateway URL - use Docker internal IP or container name
|
||||
// The Tiger container has IP 172.17.0.3 on docker0 network
|
||||
const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL || "http://172.17.0.3:18789";
|
||||
|
||||
// Proxy all requests to the gateway inside the container
|
||||
router.all("/", async (req, res) => {
|
||||
try {
|
||||
const targetUrl = `${GATEWAY_URL}${req.originalUrl.replace("/api/gateway", "")}`;
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(req.headers.authorization && {
|
||||
Authorization: req.headers.authorization,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
if (["POST", "PUT", "PATCH"].includes(req.method) && req.body) {
|
||||
fetchOptions.body = JSON.stringify(req.body);
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions);
|
||||
const data = await response.json();
|
||||
|
||||
res.status(response.status).json(data);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("ECONNREFUSED")) {
|
||||
res.status(503).json({
|
||||
error: "Gateway not accessible",
|
||||
details: "The gateway is running inside the Tiger container and not reachable. Check Docker networking.",
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: "Failed to proxy to gateway",
|
||||
details: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
75
bridge/src/routes/health.ts
Normal file
75
bridge/src/routes/health.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
/**
|
||||
* health.ts — GET /tiger/health
|
||||
*
|
||||
* Self-healing health checks. Returns system status and can trigger
|
||||
* auto-restart if critical services are down.
|
||||
*
|
||||
* GET /tiger/health?check=true — run full check and restart if needed
|
||||
* GET /tiger/health — just return status
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Service check definitions
|
||||
const services = [
|
||||
{ name: "gateway", port: 18789, path: "/health" },
|
||||
{ name: "bridge", port: 3456, path: "/tiger/status" },
|
||||
]
|
||||
|
||||
async function checkService(name: string, port: number, path: string): Promise<{
|
||||
name: string
|
||||
status: "ok" | "error"
|
||||
response?: string
|
||||
}> {
|
||||
try {
|
||||
const portMap: Record<number, string> = {
|
||||
18789: "http://127.0.0.1",
|
||||
3456: "http://127.0.0.1",
|
||||
}
|
||||
const baseUrl = portMap[port] || `http://127.0.0.1:${port}`
|
||||
const res = await fetch(`${baseUrl}${path}`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (res.ok) {
|
||||
return { name, status: "ok" }
|
||||
}
|
||||
return { name, status: "error", response: `HTTP ${res.status}` }
|
||||
} catch (err: any) {
|
||||
return { name, status: "error", response: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
const shouldRestart = _req.query.check === "true"
|
||||
|
||||
const results = await Promise.all(
|
||||
services.map((s) => checkService(s.name, s.port, s.path))
|
||||
)
|
||||
|
||||
const allHealthy = results.every((r) => r.status === "ok")
|
||||
|
||||
const response = {
|
||||
ok: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: allHealthy ? "healthy" : "degraded",
|
||||
services: results,
|
||||
}
|
||||
|
||||
// Auto-restart if requested and services are down
|
||||
if (shouldRestart && !allHealthy) {
|
||||
const failed = results.filter((r) => r.status === "error").map((r) => r.name)
|
||||
console.log(`[health] Services down: ${failed.join(", ")}. Triggering restart.`)
|
||||
|
||||
// Trigger restart but don't wait
|
||||
execInSandbox("docker restart tiger-openclaw 2>/dev/null || true")
|
||||
.then(() => console.log("[health] Restart triggered"))
|
||||
.catch(() => console.log("[health] Restart failed"))
|
||||
}
|
||||
|
||||
res.json(response)
|
||||
})
|
||||
|
||||
export default router
|
||||
243
bridge/src/routes/keys.ts
Normal file
243
bridge/src/routes/keys.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
/**
|
||||
* routes/keys.ts — API key management for the bridge
|
||||
*
|
||||
* Persists API keys (Anthropic, OpenRouter, Telegram) to the bridge's .env
|
||||
* file so they survive restarts. The settings page in the dashboard calls
|
||||
* these endpoints.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /tiger/keys — return key presence (NEVER the values themselves)
|
||||
* PATCH /tiger/keys — set one or more keys; { ANTHROPIC_API_KEY?, ... }
|
||||
* DELETE /tiger/keys/:name — clear a single key
|
||||
*
|
||||
* Security model:
|
||||
* - GETs return only { isSet: true|false } per key. The actual value
|
||||
* never leaves the server. This means the UI shows "Anthropic key
|
||||
* configured ✓" rather than echoing the key back.
|
||||
* - PATCH writes the .env file with the new values. systemd then needs
|
||||
* a restart to pick them up — we do NOT auto-restart from this route
|
||||
* because that would kill the very HTTP request that triggered the
|
||||
* change. The UI tells the user to click "Restart bridge" after saving.
|
||||
* - .env is owned by uid 1000 (the bridge user) and chmod 600.
|
||||
*
|
||||
* We do NOT support env vars other than the documented allowlist below.
|
||||
* That keeps an attacker who finds a way past auth from injecting
|
||||
* arbitrary env into a service restart.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// .env lives at bridge/.env — i.e. two directories up from src/routes.
|
||||
// __dirname when running compiled code is dist/routes, when running via tsx
|
||||
// it's src/routes. Both resolve to the same target via "../../".
|
||||
const ENV_PATH = path.resolve(__dirname, "../../.env");
|
||||
|
||||
// Allowlist — only these keys may be set/cleared via this endpoint.
|
||||
const ALLOWED_KEYS = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"TELEGRAM_BOT_TOKEN",
|
||||
"TELEGRAM_CHAT_ID",
|
||||
"TIGER_ROUTER_MODEL",
|
||||
] as const;
|
||||
type AllowedKey = (typeof ALLOWED_KEYS)[number];
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Read the .env file as a Map<key, value>. Lines that aren't KEY=VALUE
|
||||
* (comments, blanks) are kept verbatim so we can preserve them on write.
|
||||
*
|
||||
* Returns { entries, raw } where:
|
||||
* entries is the parsed map of KEY → VALUE
|
||||
* raw is the original line array (so we can reconstruct on write)
|
||||
*/
|
||||
async function readEnvFile(): Promise<{
|
||||
entries: Map<string, string>;
|
||||
raw: string[];
|
||||
}> {
|
||||
let content = "";
|
||||
try {
|
||||
content = await readFile(ENV_PATH, "utf-8");
|
||||
} catch {
|
||||
// Missing .env is fine — treat as empty
|
||||
return { entries: new Map(), raw: [] };
|
||||
}
|
||||
const raw = content.split("\n");
|
||||
const entries = new Map<string, string>();
|
||||
for (const line of raw) {
|
||||
// Skip comments and blanks
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 0) continue;
|
||||
const k = line.slice(0, eq).trim();
|
||||
const v = line.slice(eq + 1).trim();
|
||||
entries.set(k, v);
|
||||
}
|
||||
return { entries, raw };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct the .env file with `updates` applied. Existing lines are kept
|
||||
* (including comments and order); updated keys are replaced in place;
|
||||
* new keys are appended at the bottom.
|
||||
*/
|
||||
function applyUpdates(
|
||||
raw: string[],
|
||||
updates: Map<string, string | null>,
|
||||
): string {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
|
||||
for (const line of raw) {
|
||||
const trimmed = line.trim();
|
||||
// Pass through comments and blanks unchanged
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
const eq = line.indexOf("=");
|
||||
if (eq < 0) {
|
||||
out.push(line);
|
||||
continue;
|
||||
}
|
||||
const k = line.slice(0, eq).trim();
|
||||
|
||||
if (updates.has(k)) {
|
||||
const v = updates.get(k);
|
||||
if (v === null) {
|
||||
// Cleared: replace with empty value but keep the key so the
|
||||
// structure of .env is preserved across restarts.
|
||||
out.push(`${k}=`);
|
||||
} else {
|
||||
out.push(`${k}=${v}`);
|
||||
}
|
||||
seen.add(k);
|
||||
} else {
|
||||
// Not being updated — keep as-is
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Append any new keys not already in the file
|
||||
for (const [k, v] of updates) {
|
||||
if (seen.has(k)) continue;
|
||||
if (v === null) continue; // don't bother adding cleared keys
|
||||
out.push(`${k}=${v}`);
|
||||
}
|
||||
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
// ─── GET /tiger/keys ───────────────────────────────────────────────────────
|
||||
// Returns presence-only: { ANTHROPIC_API_KEY: { isSet: true }, ... }
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { entries } = await readEnvFile();
|
||||
const result: Record<string, { isSet: boolean; preview?: string }> = {};
|
||||
|
||||
for (const k of ALLOWED_KEYS) {
|
||||
const v = entries.get(k) ?? "";
|
||||
// For TIGER_ROUTER_MODEL, the value is non-secret — return it directly.
|
||||
// For everything else, only return presence.
|
||||
if (k === "TIGER_ROUTER_MODEL") {
|
||||
result[k] = { isSet: v.length > 0, preview: v };
|
||||
} else {
|
||||
result[k] = { isSet: v.length > 0 };
|
||||
}
|
||||
}
|
||||
res.json({ ok: true, keys: result });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── PATCH /tiger/keys ─────────────────────────────────────────────────────
|
||||
// Body: partial object of allowed keys → string (set) or null (clear).
|
||||
// Example: { ANTHROPIC_API_KEY: "sk-ant-...", TELEGRAM_CHAT_ID: null }
|
||||
router.patch("/", async (req: Request, res: Response) => {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
if (!body || typeof body !== "object") {
|
||||
return res.status(400).json({ ok: false, error: "Body must be an object" });
|
||||
}
|
||||
|
||||
const updates = new Map<string, string | null>();
|
||||
for (const [k, v] of Object.entries(body)) {
|
||||
if (!(ALLOWED_KEYS as readonly string[]).includes(k)) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `Key not allowed: ${k}. Allowed: ${ALLOWED_KEYS.join(", ")}`,
|
||||
});
|
||||
}
|
||||
if (v === null) {
|
||||
updates.set(k, null);
|
||||
} else if (typeof v === "string") {
|
||||
// Reject control chars and newlines that would break .env format
|
||||
if (/[\r\n]/.test(v)) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `Value for ${k} contains newline characters`,
|
||||
});
|
||||
}
|
||||
updates.set(k, v);
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `Value for ${k} must be a string or null`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const { raw } = await readEnvFile();
|
||||
const newContent = applyUpdates(raw, updates);
|
||||
await writeFile(ENV_PATH, newContent, { encoding: "utf-8", mode: 0o600 });
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
updated: Array.from(updates.keys()),
|
||||
message:
|
||||
"Keys saved to .env. Restart tiger-bridge for changes to take effect.",
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[keys] PATCH failed:", err);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── DELETE /tiger/keys/:name ──────────────────────────────────────────────
|
||||
// Clear a single key (sets it to empty in .env).
|
||||
router.delete("/:name", async (req: Request, res: Response) => {
|
||||
const name = req.params.name;
|
||||
if (!(ALLOWED_KEYS as readonly string[]).includes(name)) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `Key not allowed: ${name}`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const { raw } = await readEnvFile();
|
||||
const updates = new Map<string, string | null>([[name, null]]);
|
||||
const newContent = applyUpdates(raw, updates);
|
||||
await writeFile(ENV_PATH, newContent, { encoding: "utf-8", mode: 0o600 });
|
||||
res.json({
|
||||
ok: true,
|
||||
cleared: name,
|
||||
message: "Key cleared. Restart tiger-bridge for changes to take effect.",
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[keys] DELETE failed:", err);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
126
bridge/src/routes/knowledge.ts
Normal file
126
bridge/src/routes/knowledge.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* knowledge.ts — Knowledge Graph endpoints
|
||||
* Uses raw SQL execution to create tables if not exist
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { randomUUID } from "crypto";
|
||||
import db from "../db.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Ensure tables exist
|
||||
const initTables = () => {
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS knowledge_nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS knowledge_edges (
|
||||
id TEXT PRIMARY KEY,
|
||||
from_node TEXT NOT NULL,
|
||||
to_node TEXT NOT NULL,
|
||||
relationship TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
} catch (e) { /* tables may exist */ }
|
||||
};
|
||||
initTables();
|
||||
|
||||
// List all nodes
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
const { q, limit = 50 } = req.query;
|
||||
try {
|
||||
let sql = "SELECT * FROM knowledge_nodes";
|
||||
const params: string[] = [];
|
||||
if (q) {
|
||||
sql += " WHERE name LIKE ? OR description LIKE ?";
|
||||
params.push(`%${q}%`, `%${q}%`);
|
||||
}
|
||||
sql += " ORDER BY created_at DESC LIMIT ?";
|
||||
params.push(String(limit));
|
||||
const nodes = db.prepare(sql).all(...params);
|
||||
res.json({ nodes });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get node with connections
|
||||
router.get("/:id", async (req: Request, res: Response) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const node = db.prepare("SELECT * FROM knowledge_nodes WHERE id = ?").get(id);
|
||||
if (!node) return res.status(404).json({ error: "Not found" });
|
||||
// Get all edges for graph
|
||||
const edges = db.prepare(`
|
||||
SELECT ke.from_node, ke.to_node, ke.relationship, kn.name as to_name
|
||||
FROM knowledge_edges ke
|
||||
JOIN knowledge_nodes kn ON ke.to_node = kn.id
|
||||
`).all();
|
||||
// Get all nodes for graph
|
||||
const allNodes = db.prepare("SELECT * FROM knowledge_nodes").all();
|
||||
res.json({ node, connections: edges, allNodes });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create node
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const { type, name, description } = req.body;
|
||||
if (!type || !name) return res.status(400).json({ error: "type, name required" });
|
||||
const id = randomUUID();
|
||||
try {
|
||||
db.prepare("INSERT INTO knowledge_nodes (id, type, name, description) VALUES (?, ?, ?, ?)")
|
||||
.run(id, type, name, description || "");
|
||||
res.json({ ok: true, id });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Create connection
|
||||
router.post("/connect", async (req: Request, res: Response) => {
|
||||
const { from, to, relationship } = req.body;
|
||||
if (!from || !to || !relationship) {
|
||||
return res.status(400).json({ error: "from, to, relationship required" });
|
||||
}
|
||||
try {
|
||||
const id = randomUUID();
|
||||
db.prepare("INSERT INTO knowledge_edges (id, from_node, to_node, relationship) VALUES (?, ?, ?, ?)")
|
||||
.run(id, from, to, relationship);
|
||||
res.json({ ok: true, id });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Seed initial knowledge
|
||||
router.post("/seed", async (req: Request, res: Response) => {
|
||||
const nodes = [
|
||||
{ t: "person", n: "Manohar", d: "IIT Roorkee, IIM Rohtak, works at Renew Power" },
|
||||
{ t: "company", n: "Renew Power", d: "India renewable energy, NYSE: RNW" },
|
||||
{ t: "company", n: "Adani Green", d: "Competitor in renewables" },
|
||||
{ t: "concept", n: "PE/VC", d: "Career path interest" },
|
||||
{ t: "concept", n: "Option Trading", d: "Nifty options selling" },
|
||||
];
|
||||
try {
|
||||
for (const x of nodes) {
|
||||
db.prepare("INSERT OR IGNORE INTO knowledge_nodes (id, type, name, description) VALUES (?, ?, ?, ?)")
|
||||
.run(randomUUID(), x.t, x.n, x.d);
|
||||
}
|
||||
res.json({ ok: true, count: nodes.length });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
177
bridge/src/routes/models.ts
Normal file
177
bridge/src/routes/models.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
/**
|
||||
* routes/models.ts — Available models + per-agent model overrides
|
||||
*
|
||||
* GET /tiger/config/models List models from registry
|
||||
* GET /tiger/config/models/agents List per-agent overrides
|
||||
* PATCH /tiger/config/models/agents/:agentId Set/clear an agent's override
|
||||
*
|
||||
* Per-agent override semantics:
|
||||
* - Each agent in openclaw.json's `agents.list[]` may carry its own
|
||||
* `model.primary` (and optional `model.fallback`) which overrides the
|
||||
* global `agents.defaults.model`.
|
||||
* - PATCH body { model: "anthropic/claude-haiku-4-5" } sets the primary.
|
||||
* - PATCH body { model: null } CLEARS the override (revert to default).
|
||||
* - We never touch agents that aren't in the body. Only the targeted entry
|
||||
* is mutated. Other agents' overrides are preserved as-is.
|
||||
*
|
||||
* Why we don't use updateConfig() / deepMerge here:
|
||||
* `agents.list` is a JSON array. The bridge's deepMerge treats arrays as
|
||||
* scalar values (replacement), but writing the whole list with a single
|
||||
* missing entry would silently drop other agents. So we do an explicit
|
||||
* read-mutate-write pass on the array — safer and easier to reason about.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { execOnHost, readModels } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Hard path — same one tiger.ts uses. Kept local rather than re-exported
|
||||
// because tiger.ts treats it as a private constant.
|
||||
const OPENCLAW_CONFIG_HOST =
|
||||
"/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json";
|
||||
|
||||
// Curated agent IDs we expose for override. Must align with agents.ts.
|
||||
const KNOWN_AGENT_IDS = ["tiger", "cody", "ethan", "cathy", "elon"] as const;
|
||||
type KnownAgentId = (typeof KNOWN_AGENT_IDS)[number];
|
||||
|
||||
// ─── GET /tiger/config/models ──────────────────────────────────────────────
|
||||
// Returns the full registry of available models. Existing endpoint — kept
|
||||
// as-is so the dashboard's model picker continues to work.
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const models = await readModels();
|
||||
res.json({ ok: true, models });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /tiger/config/models/agents ───────────────────────────────────────
|
||||
// Returns the global default plus the per-agent override map.
|
||||
// Shape:
|
||||
// {
|
||||
// ok: true,
|
||||
// defaults: { primary: "...", fallback: "..." },
|
||||
// overrides: {
|
||||
// tiger: { primary: "minimax/MiniMax-M2.7" },
|
||||
// cody: { primary: "anthropic/claude-sonnet-4-6" },
|
||||
// ethan: null, // no override → uses defaults
|
||||
// cathy: null,
|
||||
// elon: null,
|
||||
// }
|
||||
// }
|
||||
router.get("/agents", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const raw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
|
||||
const cfg = JSON.parse(raw);
|
||||
|
||||
const defaults = cfg?.agents?.defaults?.model ?? null;
|
||||
const list: any[] = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
|
||||
|
||||
// Build the override map. Missing agents → null (no override).
|
||||
const overrides: Record<string, any> = {};
|
||||
for (const id of KNOWN_AGENT_IDS) {
|
||||
const entry = list.find((a) => a?.id === id);
|
||||
overrides[id] = entry?.model ?? null;
|
||||
}
|
||||
|
||||
res.json({ ok: true, defaults, overrides });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── PATCH /tiger/config/models/agents/:agentId ────────────────────────────
|
||||
// Body: { model: string | null }
|
||||
// string → set primary (e.g. "anthropic/claude-haiku-4-5")
|
||||
// null → clear override (revert to defaults)
|
||||
// Optional body: { model: { primary: "...", fallback: "..." } } for full set.
|
||||
router.patch("/agents/:agentId", async (req: Request, res: Response) => {
|
||||
const agentId = req.params.agentId as KnownAgentId;
|
||||
|
||||
if (!(KNOWN_AGENT_IDS as readonly string[]).includes(agentId)) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `Unknown agentId. Must be one of: ${KNOWN_AGENT_IDS.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const { model } = req.body as { model?: string | { primary: string; fallback?: string } | null };
|
||||
|
||||
if (model !== null && model !== undefined && typeof model !== "string" && typeof model !== "object") {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: "Body.model must be a string slug, an object {primary,fallback}, or null",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Read current config straight from the volume.
|
||||
const raw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
|
||||
const cfg = JSON.parse(raw);
|
||||
|
||||
// 2. Make sure the shape we need exists.
|
||||
cfg.agents ??= {};
|
||||
cfg.agents.list ??= [];
|
||||
if (!Array.isArray(cfg.agents.list)) {
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
error: "openclaw.json: agents.list is not an array",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Locate or create the agent's entry in the list.
|
||||
const list: any[] = cfg.agents.list;
|
||||
let idx = list.findIndex((a) => a?.id === agentId);
|
||||
if (idx === -1) {
|
||||
list.push({ id: agentId });
|
||||
idx = list.length - 1;
|
||||
}
|
||||
|
||||
// 4. Apply the patch.
|
||||
if (model === null || model === undefined) {
|
||||
// Clear override: drop the model field entirely. We keep the entry
|
||||
// around (don't delete it) so future PATCHes can add it back.
|
||||
delete list[idx].model;
|
||||
} else if (typeof model === "string") {
|
||||
list[idx].model = { primary: model };
|
||||
} else {
|
||||
// Object form — { primary, fallback? }
|
||||
if (typeof model.primary !== "string" || !model.primary) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: "model.primary is required when model is an object",
|
||||
});
|
||||
}
|
||||
list[idx].model = {
|
||||
primary: model.primary,
|
||||
...(model.fallback ? { fallback: model.fallback } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Backup before writing (mirrors updateConfig's pattern).
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupPath = OPENCLAW_CONFIG_HOST.replace(
|
||||
"openclaw.json",
|
||||
`openclaw-${timestamp}.bak.json`,
|
||||
);
|
||||
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} ${backupPath} 2>/dev/null || true`);
|
||||
|
||||
// 6. Write back. v2026 doesn't need the hash regen step.
|
||||
await writeFile(OPENCLAW_CONFIG_HOST, JSON.stringify(cfg, null, 2), "utf-8");
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
agentId,
|
||||
model: list[idx].model ?? null,
|
||||
backupPath,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[models] PATCH failed:", err);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
86
bridge/src/routes/notify.ts
Normal file
86
bridge/src/routes/notify.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const OPENCLAW_CONFIG_PATH =
|
||||
process.env.OPENCLAW_CONFIG_PATH ||
|
||||
"/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json";
|
||||
|
||||
function getBotToken(): string {
|
||||
if (process.env.TELEGRAM_BOT_TOKEN?.trim()) return process.env.TELEGRAM_BOT_TOKEN.trim();
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"));
|
||||
return cfg?.channels?.telegram?.botToken ?? "";
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
function getChatId(): string {
|
||||
if (process.env.TELEGRAM_CHAT_ID?.trim()) return process.env.TELEGRAM_CHAT_ID.trim();
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /tiger/notify
|
||||
* Body: { message: string, chatId?: string }
|
||||
*
|
||||
* Sends a Telegram message via the bridge's bot token.
|
||||
* Called by Tiger's cron jobs (via curl from inside the container)
|
||||
* since OpenClaw's native Telegram channel is disabled (bridge owns polling).
|
||||
*
|
||||
* curl example from inside container:
|
||||
* curl -s -X POST http://172.17.0.1:3456/tiger/notify \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -H "Authorization: Bearer $BRIDGE_TOKEN" \
|
||||
* -d '{"message":"HEARTBEAT_OK"}'
|
||||
*/
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const { message, chatId: overrideChatId } = req.body as {
|
||||
message?: string;
|
||||
chatId?: string;
|
||||
};
|
||||
|
||||
if (!message?.trim()) {
|
||||
return res.status(400).json({ ok: false, error: "message is required" });
|
||||
}
|
||||
|
||||
const token = getBotToken();
|
||||
const chatId = overrideChatId || getChatId();
|
||||
|
||||
if (!token) {
|
||||
return res.status(503).json({ ok: false, error: "No bot token configured" });
|
||||
}
|
||||
if (!chatId) {
|
||||
return res.status(503).json({
|
||||
ok: false,
|
||||
error: "No TELEGRAM_CHAT_ID in bridge .env — set it so Tiger knows where to send notifications",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const tgRes = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: message.slice(0, 4096),
|
||||
parse_mode: "Markdown",
|
||||
}),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
|
||||
const data = await tgRes.json() as any;
|
||||
if (!data.ok) {
|
||||
console.error("[notify] Telegram error:", data.description);
|
||||
return res.status(502).json({ ok: false, error: data.description });
|
||||
}
|
||||
|
||||
console.log(`[notify] Sent to chat ${chatId}: ${message.slice(0, 60)}…`);
|
||||
res.json({ ok: true, messageId: data.result?.message_id });
|
||||
} catch (err: any) {
|
||||
console.error("[notify] fetch failed:", err.message);
|
||||
res.status(502).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
|
||||
import { Router } from "express";
|
||||
import { projects, tasks } from "../db.js";
|
||||
import { generateProjectTitle, generateProjectGoal } from "../lib/llm.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -23,13 +24,43 @@ router.get("/", (req, res) => {
|
|||
});
|
||||
|
||||
// Create project
|
||||
router.post("/", (req, res) => {
|
||||
const { name, description, priority } = req.body;
|
||||
if (!name) {
|
||||
return res.status(400).json({ ok: false, error: "name is required" });
|
||||
// Accepts { name?, description?, seed?, priority? }.
|
||||
// If name is absent, generates a 3-7 word title from seedText via LLM (falls back to raw text).
|
||||
// If description is absent, generates a one-line goal via LLM (falls back to "").
|
||||
router.post("/", async (req, res) => {
|
||||
const { name, description, priority, seed } = req.body;
|
||||
|
||||
// Need at least one source of text to work with
|
||||
const seedText = (seed || description || name || "").trim();
|
||||
if (!seedText) {
|
||||
return res.status(400).json({ ok: false, error: "name, description, or seed is required" });
|
||||
}
|
||||
const created = projects.create({ name, description, priority });
|
||||
res.status(201).json({ ok: true, project: created });
|
||||
|
||||
// ── Title ─────────────────────────────────────────────────────────────────
|
||||
let finalName: string = (name || "").trim();
|
||||
let titleGenerated = false;
|
||||
if (!finalName) {
|
||||
finalName = (await generateProjectTitle(seedText)) ?? seedText.slice(0, 80);
|
||||
titleGenerated = true;
|
||||
}
|
||||
|
||||
// ── Description / goal ────────────────────────────────────────────────────
|
||||
// Only generate if description was explicitly absent from the request.
|
||||
let finalDesc: string;
|
||||
let goalGenerated = false;
|
||||
if (description === undefined || description === null) {
|
||||
finalDesc = (await generateProjectGoal(seedText)) ?? "";
|
||||
goalGenerated = true;
|
||||
} else {
|
||||
finalDesc = (description || "").trim();
|
||||
}
|
||||
|
||||
const created = projects.create({ name: finalName, description: finalDesc, priority });
|
||||
res.status(201).json({
|
||||
ok: true,
|
||||
project: created,
|
||||
_llm: { title_generated: titleGenerated, goal_generated: goalGenerated },
|
||||
});
|
||||
});
|
||||
|
||||
// Get project with tasks
|
||||
|
|
|
|||
44
bridge/src/routes/route-task.ts
Normal file
44
bridge/src/routes/route-task.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* routes/route-task.ts — Standalone routing endpoint
|
||||
*
|
||||
* POST /tiger/route-task
|
||||
* Body: { text: string }
|
||||
* Returns: { ok: true, agent: AgentId, reason: string }
|
||||
*
|
||||
* This is a thin HTTP wrapper around classifyAgent() from lib/llm.ts.
|
||||
* It exists so the dashboard (or external tools / Telegram) can ask
|
||||
* "where would you route this?" without creating a task.
|
||||
*
|
||||
* The actual task-creation flow in projects.ts and dispatch.ts will
|
||||
* import classifyAgent directly — they don't need to round-trip through
|
||||
* this endpoint. So this route is for the UI's "preview routing" affordance.
|
||||
*
|
||||
* Failure mode: classifyAgent never throws. The HTTP response will
|
||||
* always be 200 with { agent, reason }. If reason starts with
|
||||
* "router_unavailable:" the UI can show a warning banner.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { classifyAgent } from "../lib/llm.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const { text } = req.body as { text?: string };
|
||||
|
||||
if (typeof text !== "string" || !text.trim()) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: "Body.text is required and must be a non-empty string",
|
||||
});
|
||||
}
|
||||
|
||||
// classifyAgent has its own try/catch — we never get an exception here,
|
||||
// we just get a result with a "router_unavailable:" reason if the LLM
|
||||
// call failed. That's the contract.
|
||||
const result = await classifyAgent(text);
|
||||
|
||||
res.json({ ok: true, ...result });
|
||||
});
|
||||
|
||||
export default router;
|
||||
66
bridge/src/routes/spawn.ts
Normal file
66
bridge/src/routes/spawn.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* spawn.ts — POST /tiger/spawn
|
||||
*
|
||||
* Trigger spawning of sub-agents. This is a placeholder -
|
||||
* real implementation requires sub-agent permission config.
|
||||
*
|
||||
* POST /tiger/spawn
|
||||
* { agentId: "coder" | "researcher" | "writer" | "pm", task: "..." }
|
||||
*
|
||||
* Response:
|
||||
* { ok: true, sessionId, status: "spawned" | "pending" }
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const validAgents = ["coder", "researcher", "writer", "pm"];
|
||||
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
const { agentId, task } = req.body;
|
||||
|
||||
if (!agentId || !validAgents.includes(agentId)) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `Invalid agent. Use: ${validAgents.join(", ")}`
|
||||
});
|
||||
}
|
||||
|
||||
if (!task) {
|
||||
return res.status(400).json({ ok: false, error: "task is required" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Note: Sub-agent spawning requires config
|
||||
// This is a placeholder - returns info about what's needed
|
||||
res.json({
|
||||
ok: true,
|
||||
agentId,
|
||||
task,
|
||||
status: "pending",
|
||||
message: "Sub-agent spawning requires config. Set agents.defaults.subagents in openclaw.json"
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET available agents
|
||||
router.get("/agents", (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
agents: validAgents.map(id => ({
|
||||
id,
|
||||
name: id === "coder" ? "Cody" :
|
||||
id === "researcher" ? "Ethan" :
|
||||
id === "writer" ? "Cathy" : "Elon",
|
||||
role: id === "coder" ? "Code" :
|
||||
id === "researcher" ? "Research" :
|
||||
id === "writer" ? "Write" : "PM"
|
||||
}))
|
||||
});
|
||||
});
|
||||
|
||||
export default router
|
||||
66
bridge/src/routes/suggestions.ts
Normal file
66
bridge/src/routes/suggestions.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* suggestions.ts — GET /tiger/suggestions
|
||||
*
|
||||
* Returns AI-powered suggestions based on current context.
|
||||
* This is a placeholder - real implementation would use the LLM.
|
||||
*
|
||||
* GET /tiger/suggestions
|
||||
* ?context=current_task,project,dashboard
|
||||
*
|
||||
* Response:
|
||||
* { ok: true, suggestions: [{ text, action, priority }] }
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Default suggestions when no AI
|
||||
const defaultSuggestions = [
|
||||
{ text: "Check active tasks", action: "/tasks", priority: "high" },
|
||||
{ text: "View project status", action: "/projects", priority: "medium" },
|
||||
{ text: "Check system health", action: "/api/tiger/status", priority: "medium" },
|
||||
]
|
||||
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
// Get active tasks
|
||||
const tasksResult = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
|
||||
|
||||
// Count in-progress tasks
|
||||
let hasActiveWork = false
|
||||
if (tasksResult.stdout.includes("in-progress")) {
|
||||
hasActiveWork = true
|
||||
}
|
||||
|
||||
const suggestions = []
|
||||
|
||||
if (hasActiveWork) {
|
||||
suggestions.push({
|
||||
text: "Continue with active task",
|
||||
action: "/projects",
|
||||
priority: "high"
|
||||
})
|
||||
}
|
||||
|
||||
// Adddefaults
|
||||
suggestions.push(...defaultSuggestions.slice(0, 3))
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
suggestions: suggestions.slice(0, 5),
|
||||
hasActiveWork
|
||||
})
|
||||
} catch (err: any) {
|
||||
// Fallback to defaults
|
||||
res.json({
|
||||
ok: true,
|
||||
suggestions: defaultSuggestions,
|
||||
hasActiveWork: false,
|
||||
error: err.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
114
bridge/src/routes/tasks-file.ts
Normal file
114
bridge/src/routes/tasks-file.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* routes/tasks-file.ts — Read tasks and projects from Tiger's markdown files
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /tiger/file-tasks — all tasks from TASKS.md
|
||||
* GET /tiger/file-tasks/active — only active/pending-action tasks
|
||||
* GET /tiger/file-tasks/completed — only completed tasks
|
||||
* GET /tiger/file-tasks/projects — all projects from PROJECTS.md
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
function parseTasksJsonBlock(stdout: string): any[] {
|
||||
const match = stdout.match(/```json\s+TASKS\s*\n([\s\S]+?)\n```/);
|
||||
if (!match) {
|
||||
throw new Error("TASKS.md missing TASKS_JSON block.");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(match[1]);
|
||||
} catch (e: any) {
|
||||
throw new Error(`TASKS_JSON block is not valid JSON: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseProjectsMarkdown(stdout: string) {
|
||||
const projects: any[] = [];
|
||||
const lines = stdout.split("\n");
|
||||
let inActive = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("## Active Projects")) { inActive = true; continue; }
|
||||
if (trimmed.startsWith("## Completed Projects")) break;
|
||||
if (inActive && trimmed.match(/^\| \d/)) {
|
||||
const cols = trimmed.split("|").map((c: string) => c.trim()).filter(Boolean);
|
||||
if (cols.length >= 5) {
|
||||
projects.push({
|
||||
id: cols[0],
|
||||
name: cols[1],
|
||||
description: cols[2],
|
||||
created: cols[3],
|
||||
tasks_count: cols[4],
|
||||
status: cols[5] || "active",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return projects;
|
||||
}
|
||||
|
||||
// GET /tiger/file-tasks — all tasks
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
|
||||
const allTasks = parseTasksJsonBlock(stdout);
|
||||
const projectFilter = (req.query.project as string || "").trim().toLowerCase();
|
||||
const filtered = projectFilter
|
||||
? allTasks.filter((t: any) => {
|
||||
const pid = (t.project || "").toLowerCase().replace(/^p0?/, "");
|
||||
return (
|
||||
pid.includes(projectFilter) ||
|
||||
t.project?.toLowerCase().includes(projectFilter) ||
|
||||
t.title?.toLowerCase().includes(projectFilter)
|
||||
);
|
||||
})
|
||||
: allTasks;
|
||||
res.json({ ok: true, source: "TASKS.md", count: filtered.length, tasks: filtered });
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500;
|
||||
res.status(status).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /tiger/file-tasks/active — active + pending-action tasks only
|
||||
router.get("/active", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
|
||||
const tasks = parseTasksJsonBlock(stdout).filter(
|
||||
(t: any) => t.section === "in-progress" || t.section === "pending-action"
|
||||
);
|
||||
res.json({ ok: true, source: "TASKS.md", count: tasks.length, tasks });
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500;
|
||||
res.status(status).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /tiger/file-tasks/completed — completed tasks only
|
||||
router.get("/completed", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/TASKS.md");
|
||||
const tasks = parseTasksJsonBlock(stdout).filter((t: any) => t.section === "completed");
|
||||
res.json({ ok: true, source: "TASKS.md", count: tasks.length, tasks });
|
||||
} catch (err: any) {
|
||||
const status = err.message?.includes("missing TASKS_JSON") ? 502 : 500;
|
||||
res.status(status).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /tiger/file-tasks/projects — all projects
|
||||
router.get("/projects", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const { stdout } = await execInSandbox("cat /home/node/.openclaw/workspace/PROJECTS.md");
|
||||
const projects = parseProjectsMarkdown(stdout);
|
||||
res.json({ ok: true, source: "PROJECTS.md", count: projects.length, projects });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -98,21 +98,27 @@ router.post("/:id/execute", async (req, res) => {
|
|||
status: "pending",
|
||||
};
|
||||
|
||||
// Write task JSON to sandbox's inbox via kubectl exec
|
||||
const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox";
|
||||
// Write task JSON to container's inbox via docker exec
|
||||
const inboxPath = "/home/node/.openclaw/workspace/tasks/inbox";
|
||||
const taskFile = `task_${id}.json`;
|
||||
|
||||
// Import execInSandbox dynamically to avoid circular deps
|
||||
const { execInSandbox } = await import("../tiger.js");
|
||||
|
||||
try {
|
||||
// Create directories if needed
|
||||
await execInSandbox(`mkdir -p ${inboxPath}`);
|
||||
|
||||
// Write the task file
|
||||
// Write task JSON via temp file (avoids ALL shell escaping issues)
|
||||
const taskJson = JSON.stringify(taskData, null, 2);
|
||||
const escapedJson = taskJson.replace(/'/g, "'\\''");
|
||||
await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`);
|
||||
const { writeFileSync: wfs, unlinkSync: uls } = await import("fs");
|
||||
const { execSync: exs } = await import("child_process");
|
||||
const tmpHost = `/tmp/task_${id}_${Date.now()}.json`;
|
||||
try {
|
||||
wfs(tmpHost, taskJson, "utf-8");
|
||||
exs(`docker cp ${tmpHost} tiger-openclaw:${tmpHost}`, { timeout: 5000 });
|
||||
uls(tmpHost);
|
||||
} catch (copyErr: any) {
|
||||
throw new Error(`Failed to copy task to container: ${copyErr.message}`);
|
||||
}
|
||||
await execInSandbox(`mkdir -p ${inboxPath} && mv ${tmpHost} ${inboxPath}/${taskFile}`);
|
||||
|
||||
// Create execution record
|
||||
const execution = executions.create({
|
||||
|
|
|
|||
74
bridge/src/routes/telegram-webhook.ts
Normal file
74
bridge/src/routes/telegram-webhook.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* telegram-webhook.ts — Handle Telegram webhooks and mirror to chat history
|
||||
*
|
||||
* Receives Telegram message updates and mirrors them to the chat_messages table.
|
||||
* This enables Telegram ↔ WebChat history sync.
|
||||
*
|
||||
* POST /tiger/telegram-webhook
|
||||
* Body: Telegram Update object (https://core.telegram.org/bots/api#update)
|
||||
* Response: OK
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import db from "../db.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const DEFAULT_SESSION_ID = "agent:main:main";
|
||||
|
||||
const insertMessage = db.prepare(`
|
||||
INSERT INTO chat_messages (session_id, role, content, meta)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
// POST /tiger/telegram-webhook — receive Telegram updates
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const update = req.body;
|
||||
|
||||
// Handle message updates
|
||||
if (update.message) {
|
||||
const msg = update.message;
|
||||
const chatId = msg.chat?.id?.toString();
|
||||
const text = msg.text;
|
||||
const from = msg.from;
|
||||
|
||||
if (text && chatId) {
|
||||
// Store user message
|
||||
const meta = JSON.stringify({
|
||||
source: "telegram",
|
||||
chatId: chatId,
|
||||
messageId: msg.message_id,
|
||||
from: from ? {
|
||||
id: from.id,
|
||||
firstName: from.first_name,
|
||||
lastName: from.last_name,
|
||||
username: from.username
|
||||
} : null,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
insertMessage.run(DEFAULT_SESSION_ID, "user", text, meta);
|
||||
|
||||
// If it's a reply (has reply_to_message), store agent response too
|
||||
if (msg.reply_to_message) {
|
||||
const replyText = msg.reply_to_message.text;
|
||||
const replyMeta = JSON.stringify({
|
||||
source: "telegram",
|
||||
chatId: chatId,
|
||||
replyToMessageId: msg.message_id,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
insertMessage.run(DEFAULT_SESSION_ID, "agent", replyText, replyMeta);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
console.error("[telegram-webhook] Error:", err.message);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,44 +1,86 @@
|
|||
/**
|
||||
* tiger.ts — Core executor for Tiger agent inside Docker→k3s→sandbox
|
||||
*
|
||||
* The key insight: Tiger lives 3 layers deep. Every command must traverse:
|
||||
* Host → Docker (openshell-cluster-nemoclaw) → k3s (kubectl exec) → sandbox pod (tiger)
|
||||
*
|
||||
* This module wraps that complexity into clean async functions.
|
||||
* tiger.ts — Core executor for Tiger agent inside Docker container
|
||||
*
|
||||
* Tiger runs directly in tiger-openclaw container (no more k3s layers).
|
||||
* Commands are executed via docker exec inside the container.
|
||||
*/
|
||||
|
||||
import { exec, execFile, spawn } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { createHash } from "crypto";
|
||||
import path from "path";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// ─── Configuration ───────────────────────────────────────────────
|
||||
// These match your known paths from the Tiger setup
|
||||
const DOCKER_CONTAINER = "openshell-cluster-nemoclaw";
|
||||
const K8S_NAMESPACE = "openshell";
|
||||
const POD_NAME = "tiger";
|
||||
const OPENCLAW_CONFIG_HOST = "/root/.nemoclaw/openclaw.json";
|
||||
// Tiger runs directly in the tiger-openclaw container
|
||||
const DOCKER_CONTAINER = "tiger-openclaw";
|
||||
// Real config lives in the Docker named volume, NOT on the host root path
|
||||
const OPENCLAW_CONFIG_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json";
|
||||
const OPENCLAW_MODELS_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/agents/main/agent/models.json";
|
||||
const CONFIG_HASH_PATH_SANDBOX = "/sandbox/.openclaw/.config-hash";
|
||||
const WORKSPACE_SYMLINK = "/root/tiger-workspace";
|
||||
const WORKSPACE_SYMLINK = "/var/lib/docker/volumes/tiger_tiger-workspace/_data";
|
||||
const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh";
|
||||
|
||||
// Timeout for commands (30s default, some ops need longer)
|
||||
const DEFAULT_TIMEOUT = 30_000;
|
||||
|
||||
// ─── Remote mode for local development ──────────────────────────
|
||||
// When running this bridge on a dev machine (not the VPS), we need to
|
||||
// reach the tiger-openclaw container over SSH. Setting TIGER_REMOTE=true
|
||||
// in the env prefixes all docker/host commands with `ssh <TIGER_REMOTE_SSH>`.
|
||||
// On the real VPS: TIGER_REMOTE is unset → commands run locally as before.
|
||||
const IS_REMOTE = process.env.TIGER_REMOTE === "true";
|
||||
const REMOTE_SSH = process.env.TIGER_REMOTE_SSH || "root@100.75.128.45";
|
||||
const SSH_PREFIX = IS_REMOTE ? `ssh ${REMOTE_SSH} ` : "";
|
||||
|
||||
if (IS_REMOTE) {
|
||||
console.log(`[bridge] REMOTE MODE: docker commands will run via ssh ${REMOTE_SSH}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command inside the Tiger sandbox pod.
|
||||
* This is the fundamental operation — everything else builds on it.
|
||||
*
|
||||
* The full command chain:
|
||||
* docker exec <container> kubectl exec -n <ns> <pod> -- <cmd>
|
||||
* Execute a file-based command (no shell) on the host, with optional SSH prefix.
|
||||
* Used for safe file reads where execFile avoids shell injection.
|
||||
*/
|
||||
async function execFileOnHost(
|
||||
file: string,
|
||||
args: string[],
|
||||
timeoutMs = DEFAULT_TIMEOUT
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const cmd = IS_REMOTE ? ["ssh", REMOTE_SSH, file, ...args] : [file, ...args];
|
||||
try {
|
||||
const { stdout: out, stderr: err } = await execFile(cmd[0], cmd.slice(1), {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 5 * 1024 * 1024,
|
||||
});
|
||||
const stdout = typeof out === "string" ? out : out?.toString() ?? "";
|
||||
const stderr = typeof err === "string" ? err : err?.toString() ?? "";
|
||||
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode: 0 };
|
||||
} catch (err: any) {
|
||||
const out = err.stdout;
|
||||
const er = err.stderr;
|
||||
const stdout = typeof out === "string" ? out : out?.toString() ?? "";
|
||||
const stderr = typeof er === "string" ? er : er?.toString() ?? "";
|
||||
return {
|
||||
stdout: stdout.trim(),
|
||||
stderr: (stderr || err.message || "").trim(),
|
||||
exitCode: err.code ?? 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command inside the Tiger container.
|
||||
* Commands run directly via docker exec inside tiger-openclaw container.
|
||||
*/
|
||||
export async function execInSandbox(
|
||||
command: string,
|
||||
timeoutMs = DEFAULT_TIMEOUT
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
const fullCmd = `docker exec ${DOCKER_CONTAINER} kubectl exec -n ${K8S_NAMESPACE} ${POD_NAME} -- sh -c ${JSON.stringify(command)}`;
|
||||
// Run command directly inside tiger-openclaw container.
|
||||
// SSH_PREFIX is empty on the VPS, 'ssh root@host ' for local dev mode.
|
||||
const fullCmd = `${SSH_PREFIX}docker exec ${DOCKER_CONTAINER} sh -c ${JSON.stringify(command)}`;
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(fullCmd, {
|
||||
|
|
@ -65,7 +107,12 @@ export async function execOnHost(
|
|||
timeoutMs = DEFAULT_TIMEOUT
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
// In remote mode, wrap the command so it runs on the VPS host, not on Mac.
|
||||
// Use single-quoted form to avoid local shell interpreting it.
|
||||
const fullCmd = IS_REMOTE
|
||||
? `ssh ${REMOTE_SSH} ${JSON.stringify(command)}`
|
||||
: command;
|
||||
const { stdout, stderr } = await execAsync(fullCmd, {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 5 * 1024 * 1024,
|
||||
});
|
||||
|
|
@ -97,10 +144,10 @@ export async function getTigerStatus() {
|
|||
execInSandbox("cat /proc/meminfo | head -5 && echo '---' && uptime"),
|
||||
|
||||
// 4. Last heartbeat content
|
||||
execInSandbox("cat /sandbox/.openclaw-data/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"),
|
||||
execInSandbox("cat /home/node/.openclaw/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"),
|
||||
|
||||
// 5. Agent identity from SOUL.md
|
||||
execInSandbox("head -20 /sandbox/.openclaw-data/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"),
|
||||
execInSandbox("head -20 /home/node/.openclaw/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"),
|
||||
]);
|
||||
|
||||
// Parse container state
|
||||
|
|
@ -133,15 +180,43 @@ export async function getTigerStatus() {
|
|||
}
|
||||
}
|
||||
|
||||
// Read host config for model info
|
||||
// Read host config for model info.
|
||||
// OpenClaw stores the default agent model at agents.defaults.model.primary
|
||||
// and a list of fallbacks at agents.defaults.model.fallbacks. Some runtime
|
||||
// paths (e.g. channels.telegram) or session overrides may pick a different
|
||||
// model at request time — we also capture the provider list so the UI can
|
||||
// show what's actually available.
|
||||
let currentModel = "unknown";
|
||||
let fallbackModels: string[] = [];
|
||||
let availableModels: string[] = [];
|
||||
try {
|
||||
const configRaw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
|
||||
const config = JSON.parse(configRaw);
|
||||
// Navigate the OpenClaw config structure for model info
|
||||
currentModel = config?.model?.primary || config?.model || "unknown";
|
||||
fallbackModels = config?.model?.fallbacks || [];
|
||||
// Read config from INSIDE the container — the host copy at
|
||||
// OPENCLAW_CONFIG_HOST can be stale if Tiger has updated its config live.
|
||||
const { stdout: configRaw, exitCode } = await execInSandbox(
|
||||
"cat /home/node/.openclaw/openclaw.json 2>/dev/null"
|
||||
);
|
||||
if (exitCode === 0 && configRaw) {
|
||||
const config = JSON.parse(configRaw);
|
||||
|
||||
const agentDefaults = config?.agents?.defaults?.model;
|
||||
if (typeof agentDefaults === "string") {
|
||||
currentModel = agentDefaults;
|
||||
} else if (agentDefaults && typeof agentDefaults === "object") {
|
||||
currentModel = agentDefaults.primary || "unknown";
|
||||
fallbackModels = Array.isArray(agentDefaults.fallbacks) ? agentDefaults.fallbacks : [];
|
||||
}
|
||||
|
||||
// Surface available models from models.providers section
|
||||
const providers = config?.models?.providers || config?.providers || {};
|
||||
for (const [provName, provCfg] of Object.entries<any>(providers)) {
|
||||
const models = (provCfg as any)?.models;
|
||||
if (Array.isArray(models)) {
|
||||
for (const m of models) {
|
||||
if (m?.id) availableModels.push(`${provName}/${m.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* config not readable */ }
|
||||
|
||||
return {
|
||||
|
|
@ -163,6 +238,7 @@ export async function getTigerStatus() {
|
|||
agent: {
|
||||
currentModel,
|
||||
fallbackModels,
|
||||
availableModels,
|
||||
heartbeat: heartbeat.status === "fulfilled" ? heartbeat.value.stdout : null,
|
||||
soul: soulMd.status === "fulfilled" ? soulMd.value.stdout : null,
|
||||
},
|
||||
|
|
@ -171,7 +247,7 @@ export async function getTigerStatus() {
|
|||
|
||||
/**
|
||||
* Read the OpenClaw config from the host.
|
||||
* Config lives at /root/.nemoclaw/openclaw.json on the host,
|
||||
* Config lives at /root/.openclaw/openclaw.json on the host,
|
||||
* gets mounted into the sandbox at /sandbox/.openclaw/openclaw.json
|
||||
*/
|
||||
export async function getConfig(): Promise<Record<string, any>> {
|
||||
|
|
@ -185,23 +261,55 @@ export async function getConfig(): Promise<Record<string, any>> {
|
|||
* Previously this was a manual step that caused repeated failures.
|
||||
*/
|
||||
export async function updateConfig(patch: Record<string, any>): Promise<void> {
|
||||
// 1. Read current config
|
||||
// 1. Read current config from the Docker volume (the real runtime config)
|
||||
const current = await getConfig();
|
||||
|
||||
// 2. Deep merge the patch (shallow for now, can enhance later)
|
||||
// 2. Deep-merge the patch
|
||||
const merged = deepMerge(current, patch);
|
||||
const configStr = JSON.stringify(merged, null, 2);
|
||||
|
||||
// 3. Backup current config before writing
|
||||
// 3. Backup before writing (in the volume directory)
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} /root/.nemoclaw/backups/openclaw-${timestamp}.json`);
|
||||
const backupPath = OPENCLAW_CONFIG_HOST.replace("openclaw.json", `openclaw-${timestamp}.bak.json`);
|
||||
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} ${backupPath} 2>/dev/null || true`);
|
||||
|
||||
// 4. Write updated config
|
||||
// 4. Write back to the volume file — no hash regeneration needed in OpenClaw v2026
|
||||
await writeFile(OPENCLAW_CONFIG_HOST, configStr, "utf-8");
|
||||
}
|
||||
|
||||
// 5. Regenerate config hash — the step that was always forgotten!
|
||||
const hash = createHash("sha256").update(configStr).digest("hex");
|
||||
await execInSandbox(`echo '${hash}' > ${CONFIG_HASH_PATH_SANDBOX}`);
|
||||
|
||||
/**
|
||||
* Read the available models list from the agent models registry.
|
||||
* Returns an array of { id, name, provider, reasoning, contextWindow } objects.
|
||||
*/
|
||||
export async function readModels(): Promise<{
|
||||
id: string; name: string; provider: string;
|
||||
reasoning: boolean; contextWindow: number; cost?: { input: number; output: number }
|
||||
}[]> {
|
||||
try {
|
||||
const raw = await readFile(OPENCLAW_MODELS_HOST, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
const results: any[] = [];
|
||||
const providers: Record<string, any> = data?.providers ?? {};
|
||||
for (const [provName, provCfg] of Object.entries<any>(providers)) {
|
||||
for (const model of (provCfg.models ?? [])) {
|
||||
const rawId = model.id as string;
|
||||
// Normalise to "provider/id" form
|
||||
const id = rawId.includes("/") ? rawId : `${provName}/${rawId}`;
|
||||
results.push({
|
||||
id,
|
||||
name: model.name ?? rawId,
|
||||
provider: provName,
|
||||
reasoning: model.reasoning ?? false,
|
||||
contextWindow: model.contextWindow ?? 0,
|
||||
cost: model.cost,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep merge helper — second object wins on conflicts */
|
||||
|
|
@ -251,12 +359,15 @@ export async function listWorkspaceFiles(
|
|||
* Read a file from the Tiger workspace.
|
||||
*/
|
||||
export async function readWorkspaceFile(filepath: string): Promise<string> {
|
||||
// Security: prevent path traversal
|
||||
const sanitized = filepath.replace(/\.\./g, "").replace(/^\//, "");
|
||||
const { stdout, exitCode } = await execOnHost(
|
||||
`cat "${WORKSPACE_SYMLINK}/${sanitized}" 2>/dev/null`
|
||||
);
|
||||
if (exitCode !== 0) throw new Error(`File not found: ${sanitized}`);
|
||||
// Security: resolve the path and ensure it stays within the workspace.
|
||||
// Blocks path traversal attempts (e.g. ../../etc/passwd).
|
||||
const safeName = filepath.replace(/\.\./g, "");
|
||||
const fullPath = path.resolve(WORKSPACE_SYMLINK, safeName);
|
||||
if (!fullPath.startsWith(WORKSPACE_SYMLINK)) {
|
||||
throw new Error("Access denied: path outside workspace");
|
||||
}
|
||||
const { stdout, exitCode } = await execFileOnHost("cat", [fullPath], 10_000);
|
||||
if (exitCode !== 0) throw new Error(`File not found: ${safeName}`);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
|
|
|
|||
42
dashboard/lib_smoke.ts
Normal file
42
dashboard/lib_smoke.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Smoke test for the new openclaw-ws.ts library.
|
||||
* Tests:
|
||||
* 1. callGateway with sessions.list — verify non-streaming RPC works
|
||||
* 2. streamAgentRun on agent:main:main — verify chunks arrive in real time
|
||||
* 3. streamAgentRun on a NEW sessionKey — verify isolation
|
||||
*/
|
||||
import { callGateway, streamAgentRun, newSessionKey } from "./src/lib/openclaw-ws.js";
|
||||
|
||||
async function main() {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "c5996580041c8f117532462877c34996d5563ef7a571a2b42913ee53d8fdfa6d";
|
||||
|
||||
console.log("=== TEST 1: sessions.list ===");
|
||||
const r = await callGateway("sessions.list", {});
|
||||
console.log("ok:", r.ok, "count:", (r.payload as any)?.sessions?.length);
|
||||
((r.payload as any)?.sessions || []).forEach((s: any) => console.log(" -", s.key, "|", s.displayName));
|
||||
|
||||
console.log("\n=== TEST 2: streamAgentRun on agent:main:main ===");
|
||||
const t0 = Date.now();
|
||||
let chunks = 0;
|
||||
for await (const ev of streamAgentRun({
|
||||
sessionKey: "agent:main:main",
|
||||
message: "Reply with exactly the word PONG. Nothing else.",
|
||||
})) {
|
||||
if (ev.kind === "chunk") chunks++;
|
||||
console.log(` +${Date.now()-t0}ms ${ev.kind}: ${ev.content.slice(0,60)}`);
|
||||
}
|
||||
console.log(` total chunks: ${chunks}`);
|
||||
|
||||
console.log("\n=== TEST 3: streamAgentRun on NEW sessionKey ===");
|
||||
const newKey = newSessionKey();
|
||||
console.log("new sessionKey:", newKey);
|
||||
for await (const ev of streamAgentRun({
|
||||
sessionKey: newKey,
|
||||
message: "What is your name? Reply briefly.",
|
||||
})) {
|
||||
if (ev.kind !== "status") console.log(` ${ev.kind}: ${ev.content.slice(0,80)}`);
|
||||
}
|
||||
console.log("DONE");
|
||||
}
|
||||
|
||||
main().catch(e => { console.error("FAIL", e); process.exit(1); });
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
653
dashboard/package-lock.json
generated
653
dashboard/package-lock.json
generated
|
|
@ -13,12 +13,14 @@
|
|||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lightningcss": "^1.32.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"swr": "^2.4.0",
|
||||
|
|
@ -39,7 +41,7 @@
|
|||
"shadcn": "^3.8.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
|
@ -3635,6 +3637,267 @@
|
|||
"tailwindcss": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.30.2",
|
||||
"lightningcss-darwin-arm64": "1.30.2",
|
||||
"lightningcss-darwin-x64": "1.30.2",
|
||||
"lightningcss-freebsd-x64": "1.30.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||
"lightningcss-linux-x64-musl": "1.30.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
|
||||
|
|
@ -3948,6 +4211,12 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "25.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz",
|
||||
"integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
|
|
@ -4714,6 +4983,15 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/accessor-fn": {
|
||||
"version": "1.5.3",
|
||||
"resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz",
|
||||
"integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
|
|
@ -5145,6 +5423,16 @@
|
|||
"baseline-browser-mapping": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/bezier-js": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz",
|
||||
"integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
|
||||
|
|
@ -5334,6 +5622,18 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas-color-tracker": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz",
|
||||
"integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinycolor2": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ccount": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
||||
|
|
@ -5741,6 +6041,12 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-binarytree": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz",
|
||||
"integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
|
|
@ -5750,6 +6056,28 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
|
|
@ -5759,6 +6087,22 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-force-3d": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz",
|
||||
"integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-binarytree": "1",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-octree": "1",
|
||||
"d3-quadtree": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
|
|
@ -5780,6 +6124,12 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-octree": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz",
|
||||
"integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
|
|
@ -5789,6 +6139,15 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-quadtree": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
|
||||
"integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
|
|
@ -5805,6 +6164,28 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale-chromatic": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
|
||||
"integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-interpolate": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
|
|
@ -5850,6 +6231,41 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
|
|
@ -6091,7 +6507,6 @@
|
|||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -7254,6 +7669,20 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/float-tooltip": {
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz",
|
||||
"integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"d3-selection": "2 - 3",
|
||||
"kapsule": "^1.16",
|
||||
"preact": "10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/for-each": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||
|
|
@ -7270,6 +7699,32 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/force-graph": {
|
||||
"version": "1.51.4",
|
||||
"resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz",
|
||||
"integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tweenjs/tween.js": "18 - 25",
|
||||
"accessor-fn": "1",
|
||||
"bezier-js": "3 - 6",
|
||||
"canvas-color-tracker": "^1.3",
|
||||
"d3-array": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-force-3d": "2 - 3",
|
||||
"d3-scale": "1 - 4",
|
||||
"d3-scale-chromatic": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-zoom": "2 - 3",
|
||||
"float-tooltip": "^1.7",
|
||||
"index-array-by": "1",
|
||||
"kapsule": "^1.16",
|
||||
"lodash-es": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/formdata-polyfill": {
|
||||
"version": "4.0.10",
|
||||
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
|
||||
|
|
@ -7885,6 +8340,15 @@
|
|||
"node": ">=0.8.19"
|
||||
}
|
||||
},
|
||||
"node_modules/index-array-by": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz",
|
||||
"integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
|
|
@ -8605,6 +9069,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/jerrypick": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz",
|
||||
"integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
|
|
@ -8629,7 +9102,6 @@
|
|||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
|
|
@ -8735,6 +9207,18 @@
|
|||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/kapsule": {
|
||||
"version": "1.16.3",
|
||||
"resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz",
|
||||
"integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash-es": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
|
|
@ -8790,10 +9274,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||
"dev": true,
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
|
|
@ -8806,27 +9289,26 @@
|
|||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.30.2",
|
||||
"lightningcss-darwin-arm64": "1.30.2",
|
||||
"lightningcss-darwin-x64": "1.30.2",
|
||||
"lightningcss-freebsd-x64": "1.30.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||
"lightningcss-linux-x64-musl": "1.30.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -8841,13 +9323,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -8862,13 +9343,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -8883,13 +9363,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -8904,13 +9383,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -8925,13 +9403,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -8946,13 +9423,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -8967,13 +9443,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -8988,13 +9463,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9009,13 +9483,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9030,13 +9503,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9073,6 +9545,12 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
|
|
@ -9137,7 +9615,6 @@
|
|||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
|
|
@ -10205,7 +10682,6 @@
|
|||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -10729,6 +11205,16 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.29.1",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
|
||||
"integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
|
|
@ -10783,7 +11269,6 @@
|
|||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
|
|
@ -10986,12 +11471,44 @@
|
|||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-force-graph-2d": {
|
||||
"version": "1.29.1",
|
||||
"resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz",
|
||||
"integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"force-graph": "^1.51",
|
||||
"prop-types": "15",
|
||||
"react-kapsule": "^2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-kapsule": {
|
||||
"version": "2.5.7",
|
||||
"resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.5.7.tgz",
|
||||
"integrity": "sha512-kifAF4ZPD77qZKc4CKLmozq6GY1sBzPEJTIJb0wWFK6HsePJatK3jXplZn2eeAt3x67CDozgi7/rO8fNQ/AL7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jerrypick": "^1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
|
|
@ -12309,6 +12826,12 @@
|
|||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinycolor2": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
|
||||
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@
|
|||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lightningcss": "^1.32.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"swr": "^2.4.0",
|
||||
|
|
@ -40,6 +42,6 @@
|
|||
"shadcn": "^3.8.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5"
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
dashboard/public/favicon.ico
Normal file
BIN
dashboard/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
dashboard/public/tiger-icon-32.png
Normal file
BIN
dashboard/public/tiger-icon-32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 248 B |
|
|
@ -1,142 +1,112 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { useEffect, useState } from "react"
|
||||
import { ScrollText } from "lucide-react"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||
|
||||
interface ActivityEntry {
|
||||
id: string
|
||||
type: "heartbeat" | "chat" | "config" | "memory" | "system" | "cron"
|
||||
type: string
|
||||
timestamp: string
|
||||
description: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
heartbeat: "bg-yellow-400",
|
||||
chat: "bg-blue-400",
|
||||
config: "bg-orange-400",
|
||||
memory: "bg-green-400",
|
||||
system: "bg-purple-400",
|
||||
cron: "bg-cyan-400",
|
||||
}
|
||||
|
||||
function groupByDate(entries: ActivityEntry[]): Record<string, ActivityEntry[]> {
|
||||
const groups: Record<string, ActivityEntry[]> = {}
|
||||
for (const entry of entries) {
|
||||
const date = new Date(entry.timestamp)
|
||||
const key = date.toLocaleDateString("en-US", {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).toUpperCase()
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(entry)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string): string {
|
||||
return new Date(timestamp).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
source: string
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const [limit, setLimit] = React.useState(200)
|
||||
const { data, error } = useSWR(`/api/activity?limit=${limit}`, fetcher, { refreshInterval: 10000 })
|
||||
const [entries, setEntries] = useState<ActivityEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const entries = (data?.entries || []) as ActivityEntry[]
|
||||
const total = data?.total || 0
|
||||
const grouped = groupByDate(entries)
|
||||
useEffect(() => {
|
||||
fetch("/api/activity?limit=20")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data?.entries) {
|
||||
setEntries(data.entries)
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(e => {
|
||||
console.error("Failed to load:", e)
|
||||
setError(e.message)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const formatDate = (ts: string) => {
|
||||
if (!ts) return ""
|
||||
return new Date(ts).toLocaleString()
|
||||
}
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "heartbeat": return "text-green-500"
|
||||
case "chat": return "text-blue-500"
|
||||
case "config": return "text-yellow-500"
|
||||
case "memory": return "text-purple-500"
|
||||
case "system": return "text-orange-500"
|
||||
case "cron": return "text-cyan-500"
|
||||
default: return "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
const getSourceLabel = (source: string) => {
|
||||
switch (source) {
|
||||
case "main": return "🐅 Tiger"
|
||||
case "coder": return "📦 Cody"
|
||||
case "researcher": return "🔬 Ethan"
|
||||
case "pm": return "📋 Elon"
|
||||
default: return source || "🤖"
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ScrollText className="h-5 w-5" />
|
||||
<h1 className="text-2xl font-bold">Activity</h1>
|
||||
</div>
|
||||
<div className="text-muted-foreground">Loading activity log...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ScrollText className="h-5 w-5" />
|
||||
<h1 className="text-2xl font-bold">Activity</h1>
|
||||
</div>
|
||||
<div className="text-red-500">Failed to load activity log</div>
|
||||
<div className="text-sm text-muted-foreground mt-2">{error}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<ScrollText className="h-6 w-6 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Activity Log</h1>
|
||||
<p className="text-sm text-muted-foreground">A chronological record of agent actions and events</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs font-medium text-muted-foreground px-3 py-1.5 rounded-full border border-border bg-muted/50">
|
||||
{total} entries
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<ScrollText className="h-5 w-5" />
|
||||
<h1 className="text-2xl font-bold">Activity</h1>
|
||||
<span className="text-muted-foreground text-sm">({entries.length} entries)</span>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<div className="p-8 text-center text-destructive">Failed to load activity log</div>
|
||||
) : !data ? (
|
||||
<div className="p-8 text-center text-muted-foreground">Loading activity...</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground">No activity recorded yet</div>
|
||||
) : (
|
||||
<div className="p-6 space-y-8">
|
||||
{Object.entries(grouped).map(([dateLabel, dateEntries]) => (
|
||||
<div key={dateLabel}>
|
||||
{/* Date Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="text-xs font-semibold tracking-wider text-muted-foreground">
|
||||
{dateLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline entries */}
|
||||
<div className="relative ml-4">
|
||||
{/* Vertical line */}
|
||||
<div className="absolute left-[7px] top-3 bottom-3 w-[2px] bg-border" />
|
||||
|
||||
<div className="space-y-3">
|
||||
{dateEntries.map((entry) => (
|
||||
<div key={entry.id} className="relative flex items-start gap-4 group">
|
||||
{/* Dot */}
|
||||
<div className="relative z-10 mt-3.5">
|
||||
<div className={`h-4 w-4 rounded-full border-2 border-background ${typeColors[entry.type] || "bg-muted-foreground"}`} />
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="flex-1 p-3 rounded-lg border border-border bg-card/60 hover:bg-card/80 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xs font-semibold text-primary whitespace-nowrap mt-0.5">
|
||||
{formatTime(entry.timestamp)}
|
||||
</span>
|
||||
<p className="text-sm text-foreground leading-relaxed">
|
||||
{entry.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 rounded-lg border bg-card/30">
|
||||
<div className="text-lg">{getSourceLabel(entry.source)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm truncate">{entry.description}</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className={getTypeColor(entry.type)}>{entry.type}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDate(entry.timestamp)}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Load more */}
|
||||
{entries.length < total && (
|
||||
<div className="text-center pt-4">
|
||||
<button
|
||||
onClick={() => setLimit((prev) => prev + 200)}
|
||||
className="text-xs font-medium text-primary hover:text-primary/80 px-4 py-2 rounded-md border border-border hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
Load more ({total - entries.length} remaining)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
136
dashboard/src/app/agents/page.tsx
Normal file
136
dashboard/src/app/agents/page.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* /agents — Agent overview page (Phase 1)
|
||||
*
|
||||
* Lists all 5 agents with their full details. Phase 3 will turn each card
|
||||
* into a clickable drill-in with the per-agent model dropdown. OpenClaw
|
||||
* supports per-agent model overrides via agents.list[].model with live
|
||||
* config patching — no container restart needed.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { Bot } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
role: string
|
||||
fileCount: number
|
||||
lastActivity: number
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
if (!ts) return "—"
|
||||
const diff = Date.now() - ts
|
||||
const m = Math.floor(diff / 60_000)
|
||||
if (m < 1) return "just now"
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
return `${Math.floor(h / 24)}d ago`
|
||||
}
|
||||
|
||||
function statusOf(ts: number): "active" | "recent" | "idle" {
|
||||
if (!ts) return "idle"
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 5 * 60_000) return "active"
|
||||
if (diff < 60 * 60_000) return "recent"
|
||||
return "idle"
|
||||
}
|
||||
|
||||
const STATUS_COLOR = {
|
||||
active: "bg-green-500",
|
||||
recent: "bg-amber-500",
|
||||
idle: "bg-zinc-500",
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { data, isLoading } = useSWR<{ ok: boolean; agents: Agent[] }>(
|
||||
"/api/tiger/agents",
|
||||
fetcher,
|
||||
{ refreshInterval: 30_000 }
|
||||
)
|
||||
|
||||
const agents = data?.agents ?? []
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl mx-auto w-full">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Bot className="h-6 w-6 text-primary" />
|
||||
Agents
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Tiger's orchestrator and 4 specialist sub-agents. Phase 3 will let you
|
||||
override the model per agent here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="h-32 animate-pulse bg-card/30" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{agents.map((agent) => {
|
||||
const status = statusOf(agent.lastActivity)
|
||||
return (
|
||||
<Card key={agent.id} className="bg-card/40 p-4 hover:bg-card/60 transition-colors">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-3xl leading-none">{agent.emoji}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold truncate">{agent.name}</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
STATUS_COLOR[status],
|
||||
status === "active" && "animate-pulse"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{agent.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Last activity</span>
|
||||
<span className="text-foreground/80">{relativeTime(agent.lastActivity)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Workspace files</span>
|
||||
<span className="text-foreground/80 tabular-nums">{agent.fileCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<span className="text-foreground/60 italic text-[11px]">
|
||||
inherits global
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground border-t border-border/30 pt-4">
|
||||
<strong className="text-foreground/80">Coming in Phase 3:</strong> click any
|
||||
agent to set a custom model (e.g., a cheaper model for Researcher,
|
||||
a stronger model for Coder).
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,226 +1,44 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { bridgeGet } from "@/lib/bridge"
|
||||
|
||||
interface ActivityEntry {
|
||||
id: string
|
||||
type: "heartbeat" | "chat" | "config" | "memory" | "system" | "cron"
|
||||
timestamp: string
|
||||
description: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
function parseCommandsLog(logPath: string): ActivityEntry[] {
|
||||
const entries: ActivityEntry[] = []
|
||||
try {
|
||||
const content = fs.readFileSync(logPath, "utf-8").trim()
|
||||
if (!content) return entries
|
||||
for (const line of content.split("\n")) {
|
||||
try {
|
||||
const data = JSON.parse(line) as {
|
||||
timestamp: string
|
||||
action: string
|
||||
sessionKey: string
|
||||
senderId: string
|
||||
source: string
|
||||
}
|
||||
const actionLabels: Record<string, string> = {
|
||||
new: "New chat session",
|
||||
reset: "Session reset",
|
||||
delete: "Session deleted",
|
||||
}
|
||||
const sourceLabels: Record<string, string> = {
|
||||
webchat: "Web Chat",
|
||||
telegram: "Telegram",
|
||||
whatsapp: "WhatsApp",
|
||||
cli: "CLI",
|
||||
}
|
||||
const actionLabel = actionLabels[data.action] || data.action
|
||||
const sourceLabel = sourceLabels[data.source] || data.source
|
||||
entries.push({
|
||||
id: `cmd-${data.timestamp}-${data.action}`,
|
||||
type: "chat",
|
||||
timestamp: data.timestamp,
|
||||
description: `${actionLabel} via ${sourceLabel}`,
|
||||
source: data.source,
|
||||
})
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// file not found
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function parseGatewayLog(logPath: string): ActivityEntry[] {
|
||||
const entries: ActivityEntry[] = []
|
||||
try {
|
||||
const content = fs.readFileSync(logPath, "utf-8")
|
||||
const lines = content.split("\n")
|
||||
|
||||
// Track seen heartbeat dates to deduplicate (only first per gateway restart)
|
||||
let lastHeartbeatDay = ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
// Parse timestamp from start of line: "2026-01-27T02:35:09.747Z [tag] message"
|
||||
const match = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)$/)
|
||||
if (!match) continue
|
||||
|
||||
const timestamp = match[1]
|
||||
const rest = match[2]
|
||||
|
||||
// Heartbeat events - only include first per gateway restart (not every "started")
|
||||
if (rest.startsWith("[heartbeat]")) {
|
||||
const msg = rest.replace("[heartbeat]", "").trim()
|
||||
// Skip repetitive "started" - only keep once per gateway boot
|
||||
if (msg === "started") {
|
||||
const day = timestamp.slice(0, 10)
|
||||
if (day === lastHeartbeatDay) continue
|
||||
lastHeartbeatDay = day
|
||||
entries.push({
|
||||
id: `gw-hb-${timestamp}`,
|
||||
type: "heartbeat",
|
||||
timestamp,
|
||||
description: "Heartbeat service started",
|
||||
})
|
||||
} else {
|
||||
// Non-"started" heartbeat messages are meaningful
|
||||
entries.push({
|
||||
id: `gw-hb-${timestamp}`,
|
||||
type: "heartbeat",
|
||||
timestamp,
|
||||
description: `Heartbeat: ${msg}`,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Config reload events
|
||||
if (rest.startsWith("[reload]")) {
|
||||
const msg = rest.replace("[reload]", "").trim()
|
||||
const changedMatch = msg.match(/evaluating reload \((.+)\)/)
|
||||
const changed = changedMatch ? changedMatch[1] : msg
|
||||
entries.push({
|
||||
id: `gw-reload-${timestamp}`,
|
||||
type: "config",
|
||||
timestamp,
|
||||
description: `Config reload: ${changed}`,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Gateway startup/lifecycle - only truly significant events
|
||||
if (rest.startsWith("[gateway]")) {
|
||||
const msg = rest.replace("[gateway]", "").trim()
|
||||
if (msg.startsWith("listening on")) {
|
||||
// Reset heartbeat dedup on new gateway start
|
||||
lastHeartbeatDay = ""
|
||||
entries.push({
|
||||
id: `gw-sys-${timestamp}`,
|
||||
type: "system",
|
||||
timestamp,
|
||||
description: `Gateway started: ${msg}`,
|
||||
})
|
||||
} else if (msg.startsWith("agent model:")) {
|
||||
entries.push({
|
||||
id: `gw-sys-${timestamp}`,
|
||||
type: "system",
|
||||
timestamp,
|
||||
description: `Active model: ${msg.replace("agent model:", "").trim()}`,
|
||||
})
|
||||
} else if (msg.includes("signal")) {
|
||||
entries.push({
|
||||
id: `gw-sys-${timestamp}`,
|
||||
type: "system",
|
||||
timestamp,
|
||||
description: msg,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Telegram provider start (only once per restart)
|
||||
if (rest.startsWith("[telegram]") && rest.includes("starting provider")) {
|
||||
const botMatch = rest.match(/\(@[^)]+\)/)
|
||||
entries.push({
|
||||
id: `gw-tg-${timestamp}`,
|
||||
type: "system",
|
||||
timestamp,
|
||||
description: `Telegram provider started ${botMatch?.[0] || ""}`.trim(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Cron execution events
|
||||
if (rest.includes("cron.run") && rest.includes("[ws]") && rest.includes("res ✓")) {
|
||||
entries.push({
|
||||
id: `gw-cron-${timestamp}`,
|
||||
type: "cron",
|
||||
timestamp,
|
||||
description: "Cron job executed",
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// file not found
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function parseMemoryFiles(memoryDir: string): ActivityEntry[] {
|
||||
const entries: ActivityEntry[] = []
|
||||
try {
|
||||
const files = fs.readdirSync(memoryDir).filter((f) => f.endsWith(".md"))
|
||||
for (const file of files) {
|
||||
const filePath = path.join(memoryDir, file)
|
||||
const stat = fs.statSync(filePath)
|
||||
const slug = file.replace(/\.md$/, "").replace(/^\d{4}-\d{2}-\d{2}-/, "")
|
||||
const title = slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
entries.push({
|
||||
id: `mem-${file}`,
|
||||
type: "memory",
|
||||
timestamp: stat.mtime.toISOString(),
|
||||
description: `Memory saved: ${title}`,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// directory not found
|
||||
}
|
||||
return entries
|
||||
}
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const limit = parseInt(url.searchParams.get("limit") || "200", 10)
|
||||
const limit = parseInt(url.searchParams.get("limit") || "50", 10)
|
||||
|
||||
const clawdbotLogsDir = path.join(os.homedir(), ".clawdbot", "logs")
|
||||
const workspace = "/Users/manohar_air/clawd"
|
||||
const memoryDir = path.join(workspace, "memory")
|
||||
// Get activity from bridge endpoint that already works
|
||||
const bridgeData = await bridgeGet("/tiger/agents/activity") as {
|
||||
ok: boolean
|
||||
events: Array<{
|
||||
agentId: string
|
||||
agentName: string
|
||||
agentEmoji: string
|
||||
path: string
|
||||
action: string
|
||||
ts: number
|
||||
}>
|
||||
}
|
||||
|
||||
// Aggregate from all sources
|
||||
const commandEntries = parseCommandsLog(path.join(clawdbotLogsDir, "commands.log"))
|
||||
const gatewayEntries = parseGatewayLog(path.join(clawdbotLogsDir, "gateway.log"))
|
||||
const memoryEntries = parseMemoryFiles(memoryDir)
|
||||
if (!bridgeData?.ok || !bridgeData.events) {
|
||||
return NextResponse.json({ entries: [], total: 0 })
|
||||
}
|
||||
|
||||
// Merge and sort by timestamp descending
|
||||
const allEntries = [...commandEntries, ...gatewayEntries, ...memoryEntries]
|
||||
allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())
|
||||
|
||||
// Apply limit
|
||||
const entries = allEntries.slice(0, limit)
|
||||
// Transform bridge format to activity format
|
||||
const entries = bridgeData.events.slice(0, limit).map((e) => ({
|
||||
id: `${e.agentId}-${e.ts}`,
|
||||
type: "system",
|
||||
timestamp: new Date(e.ts).toISOString(),
|
||||
description: `${e.agentName} modified ${e.path}`,
|
||||
source: e.agentId,
|
||||
}))
|
||||
|
||||
return NextResponse.json({
|
||||
entries,
|
||||
total: allEntries.length,
|
||||
total: bridgeData.events.length,
|
||||
})
|
||||
} catch {
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
50
dashboard/src/app/api/chat/history/route.ts
Normal file
50
dashboard/src/app/api/chat/history/route.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* /api/chat/history — proxy for bridge's chat history.
|
||||
* GET — list persisted messages for the default session
|
||||
* DELETE — clear them
|
||||
*
|
||||
* Why a proxy and not a direct bridge call from the client?
|
||||
* - Keeps the bridge auth token on the server side (never leaks to browser)
|
||||
* - Matches the pattern used by /api/chat (POST) and /api/tiger/status
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const sessionId = request.nextUrl.searchParams.get("sessionId") || "";
|
||||
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
|
||||
try {
|
||||
const r = await fetch(`${BRIDGE_URL}/tiger/chat/history${qs}`, {
|
||||
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await r.json();
|
||||
return NextResponse.json(data, { status: r.status });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Bridge unreachable", details: err.message },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const sessionId = request.nextUrl.searchParams.get("sessionId") || "";
|
||||
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
|
||||
try {
|
||||
const r = await fetch(`${BRIDGE_URL}/tiger/chat/history${qs}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
|
||||
});
|
||||
const data = await r.json();
|
||||
return NextResponse.json(data, { status: r.status });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Bridge unreachable", details: err.message },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
133
dashboard/src/app/api/chat/route.ts
Normal file
133
dashboard/src/app/api/chat/route.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* /api/chat — chat send endpoint, now with real WS-based streaming.
|
||||
*
|
||||
* Replaces the previous bridge → docker exec → fake-typing chain.
|
||||
*
|
||||
* Request: POST { message: string, sessionKey?: string }
|
||||
* Response: SSE stream of `data: { type, content }` events
|
||||
* types: status | chunk | done | error (matches existing client parser)
|
||||
*
|
||||
* Persistence: user message stored BEFORE LLM call (so it's not lost on failure);
|
||||
* agent reply stored after final token via bridge /tiger/chat/persist.
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { streamAgentRun, DEFAULT_SESSION_KEY } from "@/lib/openclaw-ws";
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
export const maxDuration = 180;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function persistMessage(role: "user" | "agent", content: string, sessionKey: string, meta?: any) {
|
||||
// Best-effort persistence; never block the chat response on this.
|
||||
try {
|
||||
await fetch(`${BRIDGE_URL}/tiger/chat/persist`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${BRIDGE_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({ role, content, sessionId: sessionKey, meta: meta || {} }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[chat] persist failed:", role, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const sessionKey = request.nextUrl.searchParams.get("sessionKey") || DEFAULT_SESSION_KEY;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BRIDGE_URL}/tiger/chat/history?sessionId=${encodeURIComponent(sessionKey)}&limit=50`, {
|
||||
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
return Response.json(data);
|
||||
} catch (err) {
|
||||
return Response.json({ error: (err as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const message: string = body?.message;
|
||||
const sessionKey: string = body?.sessionKey || DEFAULT_SESSION_KEY;
|
||||
|
||||
if (!message || typeof message !== "string") {
|
||||
return new Response(JSON.stringify({ error: "message is required" }), {
|
||||
status: 400, headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Persist user message NOW, before the LLM call
|
||||
await persistMessage("user", message, sessionKey);
|
||||
|
||||
const t0 = Date.now();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Build the SSE stream.
|
||||
* The wire format `data: {"type":"chunk","content":"..."}\n\n` matches what
|
||||
* chat-interface.tsx already parses. Types we emit:
|
||||
* status (the "thinking" indicator on accept ack)
|
||||
* chunk (each assistant delta from the gateway)
|
||||
* done (terminal — full text + meta, persists the agent reply)
|
||||
* error (anything goes wrong, including handshake failures)
|
||||
*/
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const sse = (obj: { type: string; content?: string; meta?: any }) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
||||
};
|
||||
|
||||
try {
|
||||
let fullText = "";
|
||||
let meta: any = undefined;
|
||||
|
||||
for await (const ev of streamAgentRun({ message, sessionKey })) {
|
||||
if (ev.kind === "status") {
|
||||
sse({ type: "status", content: "" });
|
||||
} else if (ev.kind === "chunk") {
|
||||
fullText += ev.content;
|
||||
sse({ type: "chunk", content: ev.content });
|
||||
} else if (ev.kind === "done") {
|
||||
// Prefer the gateway's authoritative final text over our delta accumulation.
|
||||
fullText = ev.content || fullText;
|
||||
meta = ev.meta;
|
||||
sse({ type: "done", content: fullText });
|
||||
} else if (ev.kind === "error") {
|
||||
sse({ type: "error", content: ev.content });
|
||||
}
|
||||
}
|
||||
|
||||
const dt = Date.now() - t0;
|
||||
console.log(`[chat] sessionKey=${sessionKey} duration=${dt}ms chars=${fullText.length}`);
|
||||
|
||||
// Persist the agent reply AFTER streaming is complete.
|
||||
if (fullText) {
|
||||
await persistMessage("agent", fullText, sessionKey, {
|
||||
...meta,
|
||||
durationMs: dt,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[chat] stream error:", err);
|
||||
sse({ type: "error", content: err?.message || "stream failed" });
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
// Disable nginx-style buffering when behind a proxy.
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
137
dashboard/src/app/api/chat/route.ts.pre-ws
Normal file
137
dashboard/src/app/api/chat/route.ts.pre-ws
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
/**
|
||||
* API route: POST /api/chat
|
||||
* Sends chat messages via Tiger Bridge -> OpenClaw CLI
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { message } = await request.json();
|
||||
|
||||
if (!message) {
|
||||
return NextResponse.json({ error: "message is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// End-to-end timing: measure the full /api/chat call so we can compare
|
||||
// against the bridge's own timing (data.timing) to find overhead.
|
||||
const t0 = Date.now();
|
||||
|
||||
try {
|
||||
// Call the bridge
|
||||
const response = await fetch(`${BRIDGE_URL}/tiger/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${BRIDGE_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
const tBridgeDone = Date.now();
|
||||
const data = await response.json();
|
||||
|
||||
if (data?.timing) {
|
||||
console.log(
|
||||
`[chat.timing] bridge: ${JSON.stringify(data.timing)} | dashboard: bridge_call=${tBridgeDone - t0}ms`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[chat] Bridge response:", JSON.stringify(data).substring(0, 500));
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: data.error || "Chat failed" },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// Extract the text response - OpenClaw returns in several possible formats
|
||||
let text = "";
|
||||
|
||||
if (data.response?.result?.payloads?.[0]?.text) {
|
||||
text = data.response.result.payloads[0].text;
|
||||
} else if (data.response?.payloads?.[0]?.text) {
|
||||
text = data.response.payloads[0].text;
|
||||
} else if (data.response?.summary) {
|
||||
text = data.response.summary;
|
||||
} else if (data.response?.text) {
|
||||
text = data.response.text;
|
||||
} else if (data.text) {
|
||||
text = data.text;
|
||||
} else {
|
||||
// Fallback: stringify the whole response for debugging
|
||||
text = JSON.stringify(data);
|
||||
}
|
||||
|
||||
console.log("[chat] Extracted text:", text.substring(0, 200));
|
||||
|
||||
// Return as SSE with word-by-word streaming.
|
||||
//
|
||||
// WHY SIMULATE STREAMING?
|
||||
// The bridge gives us the entire reply in one shot (LLM call completes
|
||||
// before the process returns). That means without this code the whole
|
||||
// answer pops in at once — feels sluggish even though the infra is fine.
|
||||
// Splitting on whitespace and drip-feeding gives the UI a "typing" feel
|
||||
// without changing the backend. Total time until done is identical.
|
||||
//
|
||||
// When true token-level streaming is wired in the bridge (Phase 3), we
|
||||
// can swap this out for real chunks from openclaw's event stream.
|
||||
const encoder = new TextEncoder();
|
||||
const words = text.split(/(\s+)/); // keep whitespace tokens → smooth flow
|
||||
// ~60 words-per-second cadence ≈ 16ms per word. Tune to taste.
|
||||
const WORD_DELAY_MS = 25; // 40 wps — smooth typing feel with frame headroom
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Send status marker first so UI can show the thinking indicator.
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: "status", content: "" })}\n\n`
|
||||
)
|
||||
);
|
||||
|
||||
// Drip-feed word tokens. Each is a "chunk" that appends to the
|
||||
// streaming message bubble on the client.
|
||||
for (const word of words) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: "chunk", content: word })}\n\n`
|
||||
)
|
||||
);
|
||||
if (WORD_DELAY_MS > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, WORD_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Final done event carries the full text as a safety fallback
|
||||
// (see the Bug D fix in chat-interface.tsx).
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: "done", content: text })}\n\n`
|
||||
)
|
||||
);
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[chat] Error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to communicate with Tiger Bridge" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
123
dashboard/src/app/api/chat/sessions/route.ts
Normal file
123
dashboard/src/app/api/chat/sessions/route.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* /api/chat/sessions — list, create, delete chat sessions.
|
||||
*
|
||||
* GET → list webchat-eligible sessions (Main + any "agent:main:webchat-*")
|
||||
* via gateway sessions.list. Returns simplified shape for the UI.
|
||||
* POST → mint a new session key. The session is auto-created in the
|
||||
* gateway on first message (so we don't need to call anything
|
||||
* here — just return the key for the UI to start using).
|
||||
* DELETE ?key=agent:main:webchat-xyz → remove from gateway + clear sqlite history.
|
||||
* The default "agent:main:main" session can never be deleted.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { callGateway, newSessionKey, DEFAULT_SESSION_KEY } from "@/lib/openclaw-ws";
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
/** Whitelist: only "agent:main:main" + "agent:main:webchat-*" sessions are dashboard-visible. */
|
||||
function isWebchatSession(key: string): boolean {
|
||||
return key === DEFAULT_SESSION_KEY || key.startsWith("agent:main:webchat-");
|
||||
}
|
||||
|
||||
/** Pretty label for the dropdown. */
|
||||
function deriveLabel(key: string, displayName?: string): string {
|
||||
if (key === DEFAULT_SESSION_KEY) return "Main";
|
||||
if (displayName && displayName !== "undefined") return displayName;
|
||||
// For "agent:main:webchat-abc12345" → "Chat abc12345"
|
||||
const m = key.match(/^agent:main:webchat-(.+)$/);
|
||||
if (m) return `Chat ${m[1].slice(0, 8)}`;
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Ask gateway for ALL sessions, then filter to webchat-visible ones.
|
||||
const r = await callGateway("sessions.list", {});
|
||||
if (!r.ok) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "gateway sessions.list failed", details: r.error },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
const all = (r.payload as any)?.sessions || [];
|
||||
const webchat = all
|
||||
.filter((s: any) => isWebchatSession(s.key))
|
||||
.map((s: any) => ({
|
||||
key: s.key,
|
||||
label: deriveLabel(s.key, s.displayName),
|
||||
updatedAt: s.updatedAt || null,
|
||||
messageCount: s.messageCount || 0,
|
||||
isDefault: s.key === DEFAULT_SESSION_KEY,
|
||||
}))
|
||||
// Default first, then most-recently-updated
|
||||
.sort((a: any, b: any) => {
|
||||
if (a.isDefault) return -1;
|
||||
if (b.isDefault) return 1;
|
||||
return (b.updatedAt || 0) - (a.updatedAt || 0);
|
||||
});
|
||||
|
||||
// Always ensure "Main" is in the list, even if gateway hasn't seen it yet
|
||||
if (!webchat.find((s: any) => s.key === DEFAULT_SESSION_KEY)) {
|
||||
webchat.unshift({ key: DEFAULT_SESSION_KEY, label: "Main", updatedAt: null, messageCount: 0, isDefault: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, sessions: webchat });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "sessions list failed", details: err.message },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
// Mint a new key. Actual gateway session is created lazily on first message.
|
||||
const key = newSessionKey();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session: { key, label: deriveLabel(key), updatedAt: null, messageCount: 0, isDefault: false },
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const key = request.nextUrl.searchParams.get("key") || "";
|
||||
if (!key) {
|
||||
return NextResponse.json({ ok: false, error: "key query param required" }, { status: 400 });
|
||||
}
|
||||
if (key === DEFAULT_SESSION_KEY) {
|
||||
return NextResponse.json({ ok: false, error: "the Main session cannot be deleted" }, { status: 400 });
|
||||
}
|
||||
if (!isWebchatSession(key)) {
|
||||
return NextResponse.json({ ok: false, error: "only webchat sessions can be deleted from here" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Best-effort: ask gateway to delete its session record.
|
||||
// If the session has never been used (no first message yet) the gateway
|
||||
// won't know about it — that's fine, we still want to clean sqlite.
|
||||
let gatewayResult: { ok: boolean; error?: any } = { ok: true };
|
||||
try {
|
||||
gatewayResult = await callGateway("sessions.delete", { key });
|
||||
} catch (err: any) {
|
||||
gatewayResult = { ok: false, error: err.message };
|
||||
}
|
||||
|
||||
// 2. Clear our sqlite history for this session via the bridge.
|
||||
let bridgeResult = { ok: true } as any;
|
||||
try {
|
||||
const r = await fetch(
|
||||
`${BRIDGE_URL}/tiger/chat/history?sessionId=${encodeURIComponent(key)}`,
|
||||
{ method: "DELETE", headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` } }
|
||||
);
|
||||
bridgeResult = await r.json();
|
||||
} catch (err: any) {
|
||||
bridgeResult = { ok: false, error: err.message };
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: bridgeResult.ok,
|
||||
gateway: gatewayResult,
|
||||
bridge: bridgeResult,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,6 +1,17 @@
|
|||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getGateway } from "@/lib/gateway"
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""
|
||||
|
||||
// Map gateway-style methods to bridge endpoints
|
||||
const METHOD_MAP: Record<string, string> = {
|
||||
"status.canvas": "/tiger/status",
|
||||
"config.get": "/tiger/config",
|
||||
"config.set": "/tiger/config",
|
||||
}
|
||||
|
||||
// Proxy to the bridge instead of trying to reach the gateway directly
|
||||
// (gateway runs inside Tiger container - not accessible from dashboard)
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ method: string[] }> }
|
||||
|
|
@ -8,11 +19,21 @@ export async function POST(
|
|||
const { method: methodParts } = await params
|
||||
const method = methodParts.join(".")
|
||||
|
||||
// Map gateway method to bridge endpoint, or default to /tiger/status
|
||||
const bridgePath = METHOD_MAP[method] || `/tiger/${methodParts[0]}`
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const gw = getGateway()
|
||||
const result = await gw.request(method, body)
|
||||
return NextResponse.json({ ok: true, data: result })
|
||||
const res = await fetch(`${BRIDGE_URL}${bridgePath}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${BRIDGE_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json({ ok: res.ok, data })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Gateway request failed"
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 502 })
|
||||
|
|
@ -26,12 +47,16 @@ export async function GET(
|
|||
const { method: methodParts } = await params
|
||||
const method = methodParts.join(".")
|
||||
|
||||
const bridgePath = METHOD_MAP[method] || `/tiger/${methodParts[0]}`
|
||||
|
||||
try {
|
||||
const gw = getGateway()
|
||||
const result = await gw.request(method)
|
||||
return NextResponse.json({ ok: true, data: result })
|
||||
const res = await fetch(`${BRIDGE_URL}${bridgePath}`, {
|
||||
headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json({ ok: res.ok, data })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Gateway request failed"
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 502 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,55 +1,41 @@
|
|||
import { getGateway } from "@/lib/gateway"
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET() {
|
||||
const gw = getGateway()
|
||||
|
||||
// Ensure connected
|
||||
try {
|
||||
if (!gw.isConnected()) {
|
||||
await gw.connect()
|
||||
}
|
||||
} catch {
|
||||
return new Response("Gateway offline", { status: 502 })
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let cleanupFn: (() => void) | null = null
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const handler = ({ event, payload, seq }: { event: string; payload: unknown; seq: number }) => {
|
||||
if (event === "tick") return
|
||||
const data = JSON.stringify({ event, payload, seq })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
}
|
||||
|
||||
gw.on("gateway-event", handler)
|
||||
|
||||
// Send initial connected message
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ event: "stream.connected", payload: { connected: true } })}\n\n`)
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ event: "stream.connected", payload: { connected: true } })}\n\n`
|
||||
)
|
||||
)
|
||||
|
||||
const keepalive = setInterval(() => {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`))
|
||||
}, 15000)
|
||||
// Poll tiger status instead of gateway directly
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BRIDGE_URL}/tiger/status`, {
|
||||
headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ event: "health", payload: { status: data.status, ...data } })}\n\n`
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`))
|
||||
}
|
||||
}, 10000)
|
||||
|
||||
const disconnectHandler = () => {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ event: "stream.disconnected", payload: { connected: false } })}\n\n`)
|
||||
)
|
||||
}
|
||||
gw.on("disconnected", disconnectHandler)
|
||||
|
||||
cleanupFn = () => {
|
||||
gw.off("gateway-event", handler)
|
||||
gw.off("disconnected", disconnectHandler)
|
||||
clearInterval(keepalive)
|
||||
}
|
||||
;(controller as any)._cleanup = () => clearInterval(interval)
|
||||
},
|
||||
cancel() {
|
||||
cleanupFn?.()
|
||||
cancel(controller: any) {
|
||||
controller?._cleanup?.()
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -60,4 +46,4 @@ export async function GET() {
|
|||
Connection: "keep-alive",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,10 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import os from "os"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { getGateway } from "@/lib/gateway"
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
|
@ -10,147 +12,39 @@ export async function GET() {
|
|||
const totalMem = os.totalmem()
|
||||
const memUsage = Math.round(((totalMem - freeMem) / totalMem) * 100)
|
||||
|
||||
// Try gateway first for rich data
|
||||
try {
|
||||
const gw = getGateway()
|
||||
if (!gw.isConnected()) await gw.connect()
|
||||
|
||||
const [health, skills, cron, heartbeat, identity, models, config] = await Promise.allSettled([
|
||||
gw.request("health"),
|
||||
gw.request("skills.status"),
|
||||
gw.request("cron.list"),
|
||||
gw.request("last-heartbeat"),
|
||||
gw.request("agent.identity.get"),
|
||||
gw.request("models.list"),
|
||||
gw.request("config.get"),
|
||||
])
|
||||
|
||||
const healthData = health.status === "fulfilled" ? health.value as Record<string, unknown> : null
|
||||
const skillsData = skills.status === "fulfilled" ? skills.value as Record<string, unknown> : null
|
||||
const cronData = cron.status === "fulfilled" ? cron.value as unknown[] : null
|
||||
const heartbeatData = heartbeat.status === "fulfilled" ? heartbeat.value as Record<string, unknown> : null
|
||||
const identityData = identity.status === "fulfilled" ? identity.value as Record<string, unknown> : null
|
||||
const modelsData = models.status === "fulfilled" ? models.value as Record<string, unknown> : null
|
||||
const configData = config.status === "fulfilled" ? config.value as Record<string, unknown> : null
|
||||
|
||||
const skillsList = (skillsData?.skills || skillsData?.installed || []) as unknown[]
|
||||
const cronList = Array.isArray(cronData) ? cronData : ((cronData as Record<string, unknown> | null)?.jobs as unknown[] | undefined) || []
|
||||
|
||||
// Extract current model from config - try multiple response shapes
|
||||
// Gateway config.get may return: raw config, { config: ... }, or nested differently
|
||||
const rawConfig = (configData?.config as Record<string, unknown>) || configData
|
||||
const agentsConfig = (rawConfig?.agents as Record<string, unknown>) || undefined
|
||||
const defaultsConfig = (agentsConfig?.defaults as Record<string, unknown>) || undefined
|
||||
const modelConfig = (defaultsConfig?.model as Record<string, unknown>) || undefined
|
||||
let currentModel = (modelConfig?.primary as string) || null
|
||||
let fallbackModels = ((modelConfig?.fallbacks || []) as string[])
|
||||
|
||||
// Fallback: read directly from config file if gateway didn't return model info
|
||||
if (!currentModel) {
|
||||
try {
|
||||
const configFilePath = path.join(os.homedir(), ".clawdbot", "clawdbot.json")
|
||||
const fileConfig = JSON.parse(fs.readFileSync(configFilePath, "utf-8"))
|
||||
currentModel = fileConfig?.agents?.defaults?.model?.primary || null
|
||||
if (!fallbackModels.length) {
|
||||
fallbackModels = fileConfig?.agents?.defaults?.model?.fallbacks || []
|
||||
}
|
||||
} catch {
|
||||
// config file not readable
|
||||
}
|
||||
}
|
||||
|
||||
// Also extract the raw config hash for conflict-safe patching
|
||||
const configHash = configData?._hash || configData?.hash || null
|
||||
|
||||
// Extract models list: { models: [{id, name, provider, contextWindow, reasoning, input}] }
|
||||
const modelsList = (modelsData?.models || []) as unknown[]
|
||||
|
||||
// Read HEARTBEAT.md for heartbeat task info
|
||||
let heartbeatContent: string | null = null
|
||||
try {
|
||||
const workspace = (configData?.agents as Record<string, unknown> | undefined)?.defaults as Record<string, unknown> | undefined
|
||||
const wsPath = (workspace?.workspace as string) || "/Users/manohar_air/clawd"
|
||||
const hbPath = path.join(wsPath, "HEARTBEAT.md")
|
||||
heartbeatContent = fs.readFileSync(hbPath, "utf-8").trim()
|
||||
} catch {
|
||||
// HEARTBEAT.md not found
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "online",
|
||||
gateway: true,
|
||||
system: {
|
||||
memoryUsage: memUsage,
|
||||
uptime: os.uptime(),
|
||||
platform: os.platform(),
|
||||
},
|
||||
agent: {
|
||||
name: identityData?.name || "Tarzan",
|
||||
vibe: identityData?.vibe || "",
|
||||
emoji: identityData?.emoji || "",
|
||||
skills: skillsList.length,
|
||||
cronJobs: cronList.filter((j: unknown) => (j as Record<string, unknown>)?.enabled).length,
|
||||
cronTotal: cronList.length,
|
||||
lastHeartbeat: heartbeatData?.timestamp || heartbeatData?.lastChecked || null,
|
||||
heartbeatContent,
|
||||
currentModel,
|
||||
fallbackModels,
|
||||
},
|
||||
models: modelsList,
|
||||
configHash,
|
||||
health: healthData,
|
||||
})
|
||||
} catch {
|
||||
// Gateway not available - fall back to HTTP probe
|
||||
}
|
||||
|
||||
// Fallback: HTTP probe + file reads
|
||||
// Use bridge's /tiger/status instead of gateway directly
|
||||
// Gateway runs inside Tiger container and is not directly accessible
|
||||
let agentStatus = "offline"
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1000)
|
||||
const response = await fetch("http://127.0.0.1:18789/__clawdbot__/canvas/", {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
if (response.ok) agentStatus = "online"
|
||||
} catch {
|
||||
// offline
|
||||
}
|
||||
let gatewayConnected = false
|
||||
let tigerStatus: any = null
|
||||
|
||||
// Even without gateway, try to read config file for model info
|
||||
let fallbackModel: string | null = null
|
||||
let fallbackFallbacks: string[] = []
|
||||
let fallbackHeartbeat: string | null = null
|
||||
try {
|
||||
const configFilePath = path.join(os.homedir(), ".clawdbot", "clawdbot.json")
|
||||
const fileConfig = JSON.parse(fs.readFileSync(configFilePath, "utf-8"))
|
||||
fallbackModel = fileConfig?.agents?.defaults?.model?.primary || null
|
||||
fallbackFallbacks = fileConfig?.agents?.defaults?.model?.fallbacks || []
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
fallbackHeartbeat = fs.readFileSync(path.join("/Users/manohar_air/clawd", "HEARTBEAT.md"), "utf-8").trim()
|
||||
} catch { /* ignore */ }
|
||||
const res = await fetch(`${BRIDGE_URL}/tiger/status`, {
|
||||
headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
tigerStatus = await res.json()
|
||||
agentStatus = tigerStatus?.status === "online" ? "online" : "degraded"
|
||||
gatewayConnected = tigerStatus?.status === "online"
|
||||
}
|
||||
} catch { /* offline */ }
|
||||
|
||||
return NextResponse.json({
|
||||
status: agentStatus,
|
||||
gateway: false,
|
||||
system: {
|
||||
memoryUsage: memUsage,
|
||||
uptime: os.uptime(),
|
||||
platform: os.platform(),
|
||||
},
|
||||
gateway: gatewayConnected,
|
||||
system: { memoryUsage: memUsage, uptime: os.uptime(), platform: os.platform() },
|
||||
agent: {
|
||||
name: "Tiger",
|
||||
skills: 0,
|
||||
cronJobs: 0,
|
||||
lastHeartbeat: null,
|
||||
heartbeatContent: fallbackHeartbeat,
|
||||
currentModel: fallbackModel,
|
||||
fallbackModels: fallbackFallbacks,
|
||||
lastHeartbeat: tigerStatus?.agent?.heartbeat,
|
||||
currentModel: tigerStatus?.agent?.currentModel,
|
||||
fallbackModels: tigerStatus?.agent?.fallbackModels || [],
|
||||
container: tigerStatus?.container?.status,
|
||||
memoryUsagePct: tigerStatus?.system?.memoryUsagePct,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to fetch status" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
15
dashboard/src/app/api/tiger/activity/route.ts
Normal file
15
dashboard/src/app/api/tiger/activity/route.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// GET /api/tiger/activity?limit=50 — proxy to bridge
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet } from "@/lib/bridge";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = searchParams.get("limit") ?? "50";
|
||||
try {
|
||||
const result = await bridgeGet("/tiger/agents/activity", { limit });
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
20
dashboard/src/app/api/tiger/agents-activity/route.ts
Normal file
20
dashboard/src/app/api/tiger/agents-activity/route.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* /api/tiger/agents-activity — Agent Activity Proxy
|
||||
*
|
||||
* Reads per-agent activity from Tiger's workspace via bridge endpoint.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet } from "@/lib/bridge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await bridgeGet("/tiger/agents/activity");
|
||||
return NextResponse.json(result);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
37
dashboard/src/app/api/tiger/agents/[id]/file/route.ts
Normal file
37
dashboard/src/app/api/tiger/agents/[id]/file/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// GET + PUT /api/tiger/agents/[id]/file?path=...
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet, bridgePut } from "@/lib/bridge";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const path = searchParams.get("path") ?? "";
|
||||
if (!path) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 });
|
||||
try {
|
||||
const result = await bridgeGet(`/tiger/agents/${id}/file`, { path });
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const path = searchParams.get("path") ?? "";
|
||||
if (!path) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 });
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await bridgePut(`/tiger/agents/${id}/file`, { path }, body as Record<string, unknown>);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
21
dashboard/src/app/api/tiger/agents/[id]/files/route.ts
Normal file
21
dashboard/src/app/api/tiger/agents/[id]/files/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// GET /api/tiger/agents/[id]/files?path=... — proxy to bridge
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet } from "@/lib/bridge";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const path = searchParams.get("path") ?? "";
|
||||
try {
|
||||
const query: Record<string, string> = {};
|
||||
if (path) query.path = path;
|
||||
const result = await bridgeGet(`/tiger/agents/${id}/files`, query);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
13
dashboard/src/app/api/tiger/agents/route.ts
Normal file
13
dashboard/src/app/api/tiger/agents/route.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// GET /api/tiger/agents — proxy to bridge
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet } from "@/lib/bridge";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await bridgeGet("/tiger/agents");
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
21
dashboard/src/app/api/tiger/bridge-restart/route.ts
Normal file
21
dashboard/src/app/api/tiger/bridge-restart/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
// POST /api/tiger/bridge-restart — restart the tiger-bridge systemd service
|
||||
// Responds immediately, then triggers the restart with a short delay so the
|
||||
// HTTP response is fully written before the process exits.
|
||||
import { NextResponse } from "next/server";
|
||||
import { exec } from "child_process";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST() {
|
||||
// Schedule restart after response is flushed
|
||||
setTimeout(() => {
|
||||
exec("systemctl restart tiger-bridge", (err) => {
|
||||
if (err) console.error("[bridge-restart] systemctl failed:", err.message);
|
||||
});
|
||||
}, 600);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: "Bridge restart initiated. Dashboard will reconnect in ~5s.",
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
// PATCH /api/tiger/config/models/agents/[id] — set or clear a per-agent model override
|
||||
// Next.js 15+ requires params to be awaited (they are now a Promise)
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { bridgePatch } from "@/lib/bridge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const result = await bridgePatch(
|
||||
`/tiger/config/models/agents/${id}`,
|
||||
body as Record<string, unknown>
|
||||
);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
14
dashboard/src/app/api/tiger/config/models/agents/route.ts
Normal file
14
dashboard/src/app/api/tiger/config/models/agents/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
// GET /api/tiger/config/models/agents — per-agent model overrides + defaults
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet } from "@/lib/bridge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await bridgeGet("/tiger/config/models/agents");
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
13
dashboard/src/app/api/tiger/config/models/route.ts
Normal file
13
dashboard/src/app/api/tiger/config/models/route.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// GET /api/tiger/config/models — proxy to bridge
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet } from "@/lib/bridge";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await bridgeGet("/tiger/config/models");
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
15
dashboard/src/app/api/tiger/cron/[id]/run/route.ts
Normal file
15
dashboard/src/app/api/tiger/cron/[id]/run/route.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { bridgePost } from '@/lib/bridge';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export async function POST(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const result = await bridgePost('/tiger/cron/' + id + '/run', {});
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
11
dashboard/src/app/api/tiger/cron/route.ts
Normal file
11
dashboard/src/app/api/tiger/cron/route.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { bridgeGet } from '@/lib/bridge';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await bridgeGet('/tiger/cron');
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
|
@ -21,17 +21,3 @@ export async function POST(request: Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// GET /api/tiger/dispatch/status/:taskId
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ taskId: string }> }
|
||||
) {
|
||||
const { taskId } = await params;
|
||||
try {
|
||||
const result = await bridgePost(`/tiger/dispatch/status/${taskId}`, {});
|
||||
return NextResponse.json(result);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
21
dashboard/src/app/api/tiger/file-projects/route.ts
Normal file
21
dashboard/src/app/api/tiger/file-projects/route.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* /api/tiger/file-projects — Tiger Workspace Projects Proxy
|
||||
*
|
||||
* Reads projects from Tiger's PROJECTS.md (via /tiger/file-tasks/projects bridge endpoint).
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet } from "@/lib/bridge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/tiger/file-projects — all projects from PROJECTS.md
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await bridgeGet("/tiger/file-tasks/projects");
|
||||
return NextResponse.json(result);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
11
dashboard/src/app/api/tiger/file-tasks/active/route.ts
Normal file
11
dashboard/src/app/api/tiger/file-tasks/active/route.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { bridgeGet } from '@/lib/bridge';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await bridgeGet('/tiger/file-tasks/active');
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
11
dashboard/src/app/api/tiger/file-tasks/projects/route.ts
Normal file
11
dashboard/src/app/api/tiger/file-tasks/projects/route.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { bridgeGet } from '@/lib/bridge';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await bridgeGet('/tiger/file-tasks/projects');
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
35
dashboard/src/app/api/tiger/file-tasks/route.ts
Normal file
35
dashboard/src/app/api/tiger/file-tasks/route.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* /api/tiger/file-tasks — Tiger Workspace Tasks Proxy
|
||||
*
|
||||
* Reads tasks from Tiger's TASKS.md (via /tiger/file-tasks bridge endpoint).
|
||||
* This is the authoritative task list — not from SQLite.
|
||||
*/
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet } from "@/lib/bridge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/tiger/file-tasks — all tasks from TASKS.md
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const section = searchParams.get("section"); // active | completed | all
|
||||
const project = searchParams.get("project"); // dashboard, oil, etc
|
||||
|
||||
let endpoint = "/tiger/file-tasks";
|
||||
if (section === "active") endpoint = "/tiger/file-tasks/active";
|
||||
else if (section === "completed") endpoint = "/tiger/file-tasks/completed";
|
||||
|
||||
// Add project filter if provided
|
||||
if (project) {
|
||||
endpoint += `?project=${encodeURIComponent(project)}`;
|
||||
}
|
||||
|
||||
const result = await bridgeGet(endpoint);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
26
dashboard/src/app/api/tiger/keys/route.ts
Normal file
26
dashboard/src/app/api/tiger/keys/route.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// GET /api/tiger/keys — returns key presence (never values)
|
||||
// PATCH /api/tiger/keys — set one or more keys
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { bridgeGet, bridgePatch } from "@/lib/bridge";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await bridgeGet("/tiger/keys");
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await bridgePatch("/tiger/keys", body as Record<string, unknown>);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
38
dashboard/src/app/api/tiger/knowledge/route.ts
Normal file
38
dashboard/src/app/api/tiger/knowledge/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { NextRequest } from "next/server"
|
||||
|
||||
const BRIDGE = "http://127.0.0.1:3456"
|
||||
const TOKEN = "14fb879429386b69beac339bbd98e43011ec29485da17592410da34ed97e0236"
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = request.nextUrl.searchParams.get("url") || "/knowledge"
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BRIDGE}${url}`, {
|
||||
headers: { Authorization: `Bearer ${TOKEN}` }
|
||||
})
|
||||
const data = await res.json()
|
||||
return Response.json(data)
|
||||
} catch (err) {
|
||||
return Response.json({ error: (err as Error).message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const url = request.nextUrl.searchParams.get("url") || "/knowledge"
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BRIDGE}${url}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
const data = await res.json()
|
||||
return Response.json(data)
|
||||
} catch (err) {
|
||||
return Response.json({ error: (err as Error).message }, { status: 500 })
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
20
dashboard/src/app/icon.svg
Normal file
20
dashboard/src/app/icon.svg
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="tg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f97316"/>
|
||||
<stop offset="100%" stop-color="#ea580c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Dark background with rounded corners -->
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
|
||||
<!-- Bold "T" with orange gradient -->
|
||||
<text x="16" y="23" font-family="Arial Black, sans-serif" font-size="20" font-weight="900" fill="url(#tg)" text-anchor="middle">T</text>
|
||||
<!-- Tiger stripes — left side, fading down -->
|
||||
<rect x="4" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
|
||||
<rect x="4" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
|
||||
<rect x="4" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
|
||||
<!-- Tiger stripes — right side, fading down -->
|
||||
<rect x="23" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
|
||||
<rect x="23" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
|
||||
<rect x="23" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
235
dashboard/src/app/knowledge/page.tsx
Normal file
235
dashboard/src/app/knowledge/page.tsx
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState, useRef } from "react"
|
||||
import dynamic from 'next/dynamic'
|
||||
import { Brain, Search, ScrollText, Wrench, Activity, Clock, Network, MessageSquare } from "lucide-react"
|
||||
|
||||
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false })
|
||||
|
||||
interface KnowledgeNode {
|
||||
id: string
|
||||
type: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
interface FeedbackPref {
|
||||
key: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface GraphNode {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
val: number
|
||||
}
|
||||
|
||||
interface GraphLink {
|
||||
source: { id: string }
|
||||
target: { id: string }
|
||||
name: string
|
||||
}
|
||||
|
||||
export default function KnowledgePage() {
|
||||
const [nodes, setNodes] = useState<KnowledgeNode[]>([])
|
||||
const [prefs, setPrefs] = useState<FeedbackPref[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState("")
|
||||
const [showGraph, setShowGraph] = useState(true)
|
||||
const graphRef = useRef<any>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const sections = [
|
||||
{ title: "Memory", href: "/memory", icon: ScrollText, description: "Tiger's persistent memory" },
|
||||
{ title: "Skills", href: "/skills", icon: Wrench, description: "Registry of capabilities" },
|
||||
{ title: "Activity", href: "/activity", icon: Activity, description: "Timeline of events" },
|
||||
{ title: "Schedule", href: "/cron", icon: Clock, description: "Scheduled tasks" },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch("/api/tiger/knowledge").then(r => r.json()),
|
||||
fetch("/api/tiger/knowledge?url=/feedback/prefer").then(r => r.json())
|
||||
]).then(([kg, fp]) => {
|
||||
if (kg?.nodes) setNodes(kg.nodes)
|
||||
if (fp?.preferences) setPrefs(fp.preferences)
|
||||
setLoading(false)
|
||||
}).catch(e => {
|
||||
setError(e.message)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const filteredNodes = search
|
||||
? nodes.filter(n => n.name.toLowerCase().includes(search.toLowerCase()))
|
||||
: nodes
|
||||
|
||||
const getTypeColor = (type: string) => {
|
||||
switch (type) {
|
||||
case "person": return "#60a5fa"
|
||||
case "company": return "#4ade80"
|
||||
case "concept": return "#c084fc"
|
||||
default: return "#94a3b8"
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "person": return "👤"
|
||||
case "company": return "🏢"
|
||||
case "concept": return "💡"
|
||||
default: return "📌"
|
||||
}
|
||||
}
|
||||
|
||||
// Build graph data from nodes
|
||||
const graphNodes: GraphNode[] = nodes.map(n => ({ id: n.id, name: n.name, type: n.type, val: 10 }))
|
||||
const graphLinks: GraphLink[] = nodes.flatMap(n =>
|
||||
nodes.filter(m => m.id !== n.id).slice(0, 1).map(m => ({
|
||||
source: { id: n.id },
|
||||
target: { id: m.id },
|
||||
name: "related"
|
||||
}))
|
||||
)
|
||||
|
||||
const connections = [
|
||||
{ from: "Manohar", rel: "works-at", to: "Renew Power" },
|
||||
{ from: "Manohar", rel: "interested-in", to: "PE/VC" },
|
||||
{ from: "Renew Power", rel: "competitor", to: "Adani Green" },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Brain className="h-5 w-5" />
|
||||
<h1 className="text-2xl font-bold">Knowledge</h1>
|
||||
</div>
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* TOP - Tiger's Brain */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Brain className="h-5 w-5" />
|
||||
<h1 className="text-2xl font-bold">Knowledge</h1>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold mb-3">Tiger's Brain</h2>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{sections.map(s => (
|
||||
<a key={s.title} href={s.href} className="p-4 rounded-lg border hover:bg-card/50 text-center">
|
||||
<s.icon className="h-6 w-6 mx-auto mb-2" />
|
||||
<div className="font-medium">{s.title}</div>
|
||||
<div className="text-xs text-muted-foreground">{s.description}</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOTTOM */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Graph/List Toggle */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Network className="h-4 w-4" />
|
||||
<h3 className="text-lg font-semibold">Knowledge Graph</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowGraph(!showGraph)}
|
||||
className="px-3 py-1 text-sm rounded border hover:bg-card/50"
|
||||
>
|
||||
{showGraph ? "Show List" : "Show Graph"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full p-2 rounded border"
|
||||
/>
|
||||
|
||||
{showGraph ? (
|
||||
<div className="h-[350px] rounded-lg border overflow-hidden">
|
||||
<ForceGraph2D
|
||||
ref={graphRef}
|
||||
graphData={{ nodes: graphNodes, links: graphLinks }}
|
||||
nodeColor={(n: any) => getTypeColor(n.type)}
|
||||
nodeLabel={(n: any) => `${n.name} (${n.type})`}
|
||||
linkColor={() => "#475569"}
|
||||
backgroundColor="#1a1a2e"
|
||||
width={500}
|
||||
height={350}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-[350px] overflow-y-auto">
|
||||
{filteredNodes.map(node => (
|
||||
<div key={node.id} className="p-3 rounded-lg border bg-card/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{getTypeIcon(node.type)}</span>
|
||||
<span className="font-medium" style={{ color: getTypeColor(node.type) }}>{node.name}</span>
|
||||
</div>
|
||||
{node.description && <div className="text-xs text-muted-foreground mt-1">{node.description}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex gap-4 text-xs">
|
||||
<span style={{ color: "#60a5fa" }}>● Person</span>
|
||||
<span style={{ color: "#4ade80" }}>● Company</span>
|
||||
<span style={{ color: "#c084fc" }}>● Concept</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connections + Learned */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Network className="h-4 w-4" />
|
||||
<h3 className="text-lg font-semibold">Connections</h3>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg border bg-card/30 space-y-1">
|
||||
{connections.map((c, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-blue-400">{c.from}</span>
|
||||
<span className="text-muted-foreground">→ {c.rel} →</span>
|
||||
<span className="text-green-400">{c.to}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<h3 className="text-lg font-semibold">Learned Preferences</h3>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg border bg-card/30">
|
||||
{prefs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">Correct me to learn</div>
|
||||
) : (
|
||||
prefs.map((p, i) => (
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<span>{p.key}</span>
|
||||
<span className="font-medium">{p.value}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-500">Error: {error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { ThemeProvider } from "@/components/theme-provider"
|
|||
import { ModeToggle } from "@/components/mode-toggle"
|
||||
import "./globals.css";
|
||||
import { Agentation } from 'agentation';
|
||||
import { ChatProvider } from "@/contexts/chat-context";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
|
|
@ -19,8 +20,16 @@ const geistMono = Geist_Mono({
|
|||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Command Center",
|
||||
description: "Tarzan's Dashboard for Agent Management",
|
||||
title: "Tiger Command Center",
|
||||
description: "Tiger Agent Management Dashboard",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/icon.svg", type: "image/svg+xml" },
|
||||
{ url: "/favicon.ico", sizes: "any" },
|
||||
],
|
||||
shortcut: "/icon.svg",
|
||||
apple: "/icon.svg",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -40,14 +49,34 @@ export default function RootLayout({
|
|||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<SidebarProvider>
|
||||
<ChatProvider>
|
||||
<SidebarProvider>
|
||||
<TooltipProvider>
|
||||
<AppSidebar />
|
||||
<main className="w-full bg-background text-foreground relative">
|
||||
<div className="p-4 flex items-center justify-between border-b border-border bg-sidebar/50 backdrop-blur-sm sticky top-0 z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger />
|
||||
<h1 className="text-sm font-medium text-muted-foreground">Tarzan's Dashboard</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Tiger Command icon — same SVG as favicon */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className="h-5 w-5 shrink-0" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="hdr-tg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#f97316"/>
|
||||
<stop offset="100%" stopColor="#ea580c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
|
||||
<text x="16" y="23" fontFamily="Arial Black, sans-serif" fontSize="20" fontWeight="900" fill="url(#hdr-tg)" textAnchor="middle">T</text>
|
||||
<rect x="4" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
|
||||
<rect x="4" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
|
||||
<rect x="4" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
|
||||
<rect x="23" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
|
||||
<rect x="23" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
|
||||
<rect x="23" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-muted-foreground">Tiger Dashboard</span>
|
||||
</div>
|
||||
</div>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
|
@ -57,7 +86,8 @@ export default function RootLayout({
|
|||
<Agentation />
|
||||
</main>
|
||||
</TooltipProvider>
|
||||
</SidebarProvider>
|
||||
</SidebarProvider>
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,380 +1,32 @@
|
|||
"use client"
|
||||
|
||||
import useSWR from 'swr'
|
||||
import { StatCard } from "@/components/stat-card"
|
||||
import {
|
||||
Activity,
|
||||
Bot,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Zap,
|
||||
Cpu,
|
||||
Check,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Terminal,
|
||||
MemoryStick,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useBridgeRequest } from "@/hooks/use-bridge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from "react"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||
|
||||
interface TigerStatus {
|
||||
status: "online" | "degraded" | "offline"
|
||||
container: {
|
||||
status: string
|
||||
exitCode: number
|
||||
startedAt: string
|
||||
}
|
||||
openclaw: {
|
||||
running: boolean
|
||||
processInfo: string
|
||||
}
|
||||
system: {
|
||||
memoryUsagePct: number
|
||||
memoryTotalMb: number
|
||||
uptime: string
|
||||
}
|
||||
agent: {
|
||||
currentModel: string
|
||||
fallbackModels: string[]
|
||||
heartbeat: string | null
|
||||
soul: string | null
|
||||
}
|
||||
}
|
||||
|
||||
function formatUptime(startedAt: string): string {
|
||||
if (!startedAt) return "—"
|
||||
const start = new Date(startedAt)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - start.getTime()
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
||||
|
||||
if (diffHours > 24) {
|
||||
const days = Math.floor(diffHours / 24)
|
||||
return `${days}d ${diffHours % 24}h`
|
||||
}
|
||||
return `${diffHours}h ${diffMins}m`
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: status, error: statusError, isLoading } = useSWR<TigerStatus>('/api/tiger/status', fetcher, {
|
||||
refreshInterval: 5000,
|
||||
revalidateOnFocus: true,
|
||||
})
|
||||
const { request } = useBridgeRequest()
|
||||
const [restarting, setRestarting] = React.useState(false)
|
||||
const [restartSuccess, setRestartSuccess] = React.useState(false)
|
||||
|
||||
const isOffline = statusError || status?.status === "offline"
|
||||
const isCrashed = status?.container?.exitCode === 255
|
||||
|
||||
const handleRestart = async () => {
|
||||
setRestarting(true)
|
||||
setRestartSuccess(false)
|
||||
try {
|
||||
await request("/api/tiger/restart", "POST")
|
||||
setRestartSuccess(true)
|
||||
setTimeout(() => setRestartSuccess(false), 3000)
|
||||
} catch (e) {
|
||||
console.error("Failed to restart:", e)
|
||||
} finally {
|
||||
setRestarting(false)
|
||||
}
|
||||
}
|
||||
import { CommandBar } from "@/components/command-bar"
|
||||
import { AgentStrip } from "@/components/agent-strip"
|
||||
import { DigestCard } from "@/components/digest-card"
|
||||
import { TelegramThreadCard } from "@/components/telegram-thread-card"
|
||||
import { StatusFooter } from "@/components/status-footer"
|
||||
import { ScheduleCard } from "@/components/schedule-card"
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-5 max-w-5xl mx-auto w-full">
|
||||
{/* HERO — the command bar is the front door of Tiger. */}
|
||||
<CommandBar />
|
||||
|
||||
{/* Crash Recovery Banner */}
|
||||
{isCrashed && (
|
||||
<div className="p-4 rounded-md bg-red-500/10 border border-red-500/20 text-red-400 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<span className="font-semibold">Tiger Crashed</span>
|
||||
<span className="text-sm text-red-400/80 ml-2">(exit code 255 — MiniMax API unreachable)</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{restartSuccess ? "Restarting..." : "Restart Container"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* AGENTS — one strip, all 5 agents, live state at a glance. */}
|
||||
<AgentStrip />
|
||||
|
||||
{/* Stat Cards Row */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className={cn(
|
||||
"bg-card/50",
|
||||
status?.container?.status === "running" && "border-green-500/30",
|
||||
status?.container?.status !== "running" && "border-red-500/30"
|
||||
)}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Container</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold capitalize",
|
||||
status?.container?.status === "running" ? "text-green-400" : "text-red-400"
|
||||
)}>
|
||||
{isLoading ? "..." : status?.container?.status || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<Server className={cn(
|
||||
"h-5 w-5",
|
||||
status?.container?.status === "running" ? "text-green-400" : "text-red-400"
|
||||
)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">OpenClaw</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold",
|
||||
status?.openclaw?.running ? "text-green-400" : "text-red-400"
|
||||
)}>
|
||||
{isLoading ? "..." : status?.openclaw?.running ? "Running" : "Stopped"}
|
||||
</p>
|
||||
</div>
|
||||
<Terminal className={cn(
|
||||
"h-5 w-5",
|
||||
status?.openclaw?.running ? "text-green-400" : "text-red-400"
|
||||
)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Memory</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{isLoading ? "..." : `${status?.system?.memoryUsagePct || 0}%`}
|
||||
</p>
|
||||
</div>
|
||||
<MemoryStick className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Uptime</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{isLoading ? "..." : formatUptime(status?.container?.startedAt || "")}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* CONTEXT ROW — digest (left) + Telegram thread (right) */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DigestCard />
|
||||
<TelegramThreadCard />
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{isOffline && !isLoading && (
|
||||
<div className="p-4 rounded-md bg-destructive/10 text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>Failed to connect to Tiger Bridge. Ensure the bridge server is running on the VPS.</span>
|
||||
</div>
|
||||
)}
|
||||
{/* SCHEDULE ROW — Tiger's cron jobs + next-run times */}
|
||||
<ScheduleCard />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
|
||||
{/* Container Health Card */}
|
||||
<Card className="col-span-4 bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
Tiger Health
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{status?.status === "online" ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3 text-green-500" /> All systems operational
|
||||
</span>
|
||||
) : status?.status === "degraded" ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3 text-yellow-500" /> Degraded mode
|
||||
</span>
|
||||
) : (
|
||||
"Connection lost"
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Container Status */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Container Status</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
status?.container?.status === "running" ? "bg-green-500" : "bg-red-500"
|
||||
)} />
|
||||
<span className="capitalize">{status?.container?.status || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exit Code */}
|
||||
{status?.container?.exitCode !== undefined && status?.container?.exitCode !== 0 && (
|
||||
<div className="p-3 rounded-md border border-red-500/30 bg-red-500/5 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Exit Code</span>
|
||||
<span className={cn(
|
||||
"font-mono",
|
||||
status?.container?.exitCode === 255 ? "text-red-400 font-bold" : "text-red-300"
|
||||
)}>
|
||||
{status?.container?.exitCode}
|
||||
{status?.container?.exitCode === 255 && " (API unreachable)"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenClaw Process */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">OpenClaw Process</span>
|
||||
<span className={cn(
|
||||
status?.openclaw?.running ? "text-green-400" : "text-red-400"
|
||||
)}>
|
||||
{status?.openclaw?.running ? "Running" : "Not running"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Memory Usage */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Memory Usage</span>
|
||||
<span>
|
||||
{status?.system?.memoryUsagePct || 0}% of {status?.system?.memoryTotalMb || 0}MB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Container Uptime</span>
|
||||
<span>{formatUptime(status?.container?.startedAt || "")}</span>
|
||||
</div>
|
||||
|
||||
{/* Restart Button */}
|
||||
<div className="pt-2 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Restart Container
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Agent Model Card */}
|
||||
<Card className="col-span-3 bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
Agent Model
|
||||
</CardTitle>
|
||||
<CardDescription>Current AI model configuration</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Primary Model */}
|
||||
<div className="p-4 rounded-lg border border-primary/30 bg-primary/5">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wider">Primary Model</div>
|
||||
<div className="font-semibold text-base">
|
||||
{isLoading ? "..." : status?.agent?.currentModel || "Not configured"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fallback Models */}
|
||||
{status?.agent?.fallbackModels && status.agent.fallbackModels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Fallback Models</div>
|
||||
<div className="space-y-1">
|
||||
{status.agent.fallbackModels.map((model, i) => (
|
||||
<div key={i} className="p-2 rounded-md border border-border bg-background/50 text-sm font-mono">
|
||||
{model}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heartbeat */}
|
||||
{status?.agent?.heartbeat && (
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-xs">
|
||||
<div className="text-muted-foreground mb-1">Last Heartbeat</div>
|
||||
<pre className="whitespace-pre-wrap text-muted-foreground/80 font-mono text-[10px] max-h-20 overflow-y-auto">
|
||||
{status.agent.heartbeat.slice(0, 500)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<a href="/logs" className="contents">
|
||||
<StatCard
|
||||
title="View Logs"
|
||||
value="→"
|
||||
description="Live container log stream"
|
||||
icon={Activity}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
<a href="/tasks" className="contents">
|
||||
<StatCard
|
||||
title="Task Board"
|
||||
value="→"
|
||||
description="Manage and track tasks"
|
||||
icon={Check}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
<a href="/settings" className="contents">
|
||||
<StatCard
|
||||
title="Settings"
|
||||
value="→"
|
||||
description="Configure Tiger agent"
|
||||
icon={Cpu}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
{/* FOOTER — system health strip. Becomes a banner on crash. */}
|
||||
<StatusFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,189 +1,361 @@
|
|||
/**
|
||||
* Projects Page — Project management with tasks
|
||||
*
|
||||
* Lists projects as cards and provides project detail view with Kanban.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
/**
|
||||
* /projects — Dual-source project view with inline task+agent expansion
|
||||
*
|
||||
* PRIMARY → Tiger's PROJECTS.md (source of truth, read-only)
|
||||
* SECONDARY → SQLite projects (dashboard-queued, waiting for Tiger)
|
||||
*
|
||||
* Click any project → expands to show tasks from TASKS.md for that project,
|
||||
* each task row showing: stage name, assigned agent (emoji+name), status badge.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import { FolderOpen, Plus, Loader2, MoreVertical, Pencil, Trash2 } from "lucide-react"
|
||||
import useSWR from "swr"
|
||||
import {
|
||||
FolderOpen, Plus, Loader2, ChevronDown, ChevronRight,
|
||||
Inbox, Trash2, MoreVertical,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
Dialog, DialogContent, DialogDescription, DialogHeader,
|
||||
DialogTitle, DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useBridgeRequest } from "@/hooks/use-bridge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Project {
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FileProject {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
created: string
|
||||
tasks_count: string
|
||||
status: string
|
||||
}
|
||||
|
||||
interface FileTask {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
status_raw: string
|
||||
assigned_agent: string
|
||||
project: string
|
||||
isProject?: boolean
|
||||
isSubTask?: boolean
|
||||
parentId?: string
|
||||
}
|
||||
|
||||
interface DbProject {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: string
|
||||
priority: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface Task {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string
|
||||
status: string
|
||||
priority: string
|
||||
assigned_agent: string | null
|
||||
progress: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
low: "bg-gray-500/10 text-gray-400 border-gray-500/20",
|
||||
low: "bg-gray-500/10 text-gray-400 border-gray-500/20",
|
||||
medium: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||
high: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||
high: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||
urgent: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
active: "bg-green-500/10 text-green-400 border-green-500/20",
|
||||
paused: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||
completed: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20",
|
||||
archived: "bg-gray-500/10 text-gray-400 border-gray-500/20",
|
||||
const TASK_STATUS_COLORS: Record<string, string> = {
|
||||
"in-progress": "bg-amber-500/10 text-amber-400",
|
||||
review: "bg-purple-500/10 text-purple-400",
|
||||
done: "bg-emerald-500/10 text-emerald-400",
|
||||
backlog: "bg-zinc-500/10 text-zinc-400",
|
||||
}
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { request } = useBridgeRequest()
|
||||
const [projects, setProjects] = React.useState<Project[]>([])
|
||||
const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
|
||||
const [tasks, setTasks] = React.useState<Task[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadingTasks, setLoadingTasks] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [isCreateOpen, setIsCreateOpen] = React.useState(false)
|
||||
const [newProjectName, setNewProjectName] = React.useState("")
|
||||
const [newProjectDesc, setNewProjectDesc] = React.useState("")
|
||||
const [newProjectPriority, setNewProjectPriority] = React.useState("medium")
|
||||
const [creating, setCreating] = React.useState(false)
|
||||
const AGENT_EMOJI: Record<string, string> = {
|
||||
tiger: "🐯", main: "🐯",
|
||||
cody: "💻", coder: "💻",
|
||||
ethan: "🔍", researcher: "🔍",
|
||||
cathy: "✍️", writer: "✍️",
|
||||
elon: "📊", pm: "📊",
|
||||
}
|
||||
|
||||
// Load projects
|
||||
const loadProjects = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await request("/api/tiger/projects") as { ok: boolean; projects?: Project[] }
|
||||
if (data.ok && data.projects) {
|
||||
setProjects(data.projects)
|
||||
} else {
|
||||
setError("Failed to load projects")
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError("Failed to load projects")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [request])
|
||||
const AGENT_COLORS: Record<string, string> = {
|
||||
tiger: "text-orange-400", main: "text-orange-400",
|
||||
cody: "text-blue-400", coder: "text-blue-400",
|
||||
ethan: "text-green-400", researcher: "text-green-400",
|
||||
cathy: "text-pink-400", writer: "text-pink-400",
|
||||
elon: "text-violet-400", pm: "text-violet-400",
|
||||
}
|
||||
|
||||
// Load tasks for selected project
|
||||
const loadTasks = React.useCallback(async (projectId: string) => {
|
||||
setLoadingTasks(true)
|
||||
try {
|
||||
const data = await request("/api/tiger/projects") as { ok: boolean; projects?: Project[] }
|
||||
// Get tasks from project
|
||||
const projectData = await request(`/api/tiger/projects/${projectId}`) as { ok: boolean; project?: { tasks?: Task[] } }
|
||||
if (projectData.ok && projectData.project?.tasks) {
|
||||
setTasks(projectData.project.tasks)
|
||||
} else {
|
||||
// Fallback: get all tasks and filter
|
||||
const allTasks = await request("/api/tiger/tasks") as { ok: boolean; tasks?: Task[] }
|
||||
if (allTasks.ok && allTasks.tasks) {
|
||||
setTasks(allTasks.tasks.filter(t => t.project_id === projectId))
|
||||
}
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error("Failed to load tasks:", e)
|
||||
} finally {
|
||||
setLoadingTasks(false)
|
||||
}
|
||||
}, [request])
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
React.useEffect(() => {
|
||||
loadProjects()
|
||||
}, [loadProjects])
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedProject) {
|
||||
loadTasks(selectedProject.id)
|
||||
}
|
||||
}, [selectedProject, loadTasks])
|
||||
function statusBadgeClass(status: string) {
|
||||
const s = status.toLowerCase()
|
||||
if (s.includes("progress") || s.includes("active") || s.includes("🔴") || s.includes("🔄"))
|
||||
return "bg-green-500/10 text-green-400 border-green-500/20"
|
||||
if (s.includes("review"))
|
||||
return "bg-purple-500/10 text-purple-400 border-purple-500/20"
|
||||
if (s.includes("done") || s.includes("complete") || s.includes("approved"))
|
||||
return "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
|
||||
return "bg-zinc-500/10 text-zinc-400 border-zinc-500/20"
|
||||
}
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!newProjectName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await request("/api/tiger/projects", "POST", {
|
||||
name: newProjectName,
|
||||
description: newProjectDesc,
|
||||
priority: newProjectPriority,
|
||||
})
|
||||
setNewProjectName("")
|
||||
setNewProjectDesc("")
|
||||
setNewProjectPriority("medium")
|
||||
setIsCreateOpen(false)
|
||||
loadProjects()
|
||||
} catch (e: unknown) {
|
||||
console.error("Failed to create project:", e)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
function cleanText(s: string) {
|
||||
// Strip emoji and markdown bold markers for display
|
||||
return s.replace(/\*\*/g, "").replace(/[✅⏳🔄❌🛢️🔍💻📊✍️🐯]/g, "").trim()
|
||||
}
|
||||
|
||||
const handleDeleteProject = async (id: string) => {
|
||||
try {
|
||||
await request("/api/tiger/projects", "POST", { _method: "DELETE", id })
|
||||
loadProjects()
|
||||
if (selectedProject?.id === id) {
|
||||
setSelectedProject(null)
|
||||
setTasks([])
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error("Failed to delete project:", e)
|
||||
}
|
||||
}
|
||||
// ─── Task row inside expanded project ─────────────────────────────────────────
|
||||
|
||||
// Group tasks by status
|
||||
const tasksByStatus = React.useMemo(() => {
|
||||
const grouped: Record<string, Task[]> = {
|
||||
backlog: [],
|
||||
ready: [],
|
||||
"in-progress": [],
|
||||
review: [],
|
||||
done: [],
|
||||
}
|
||||
tasks.forEach(task => {
|
||||
if (grouped[task.status]) {
|
||||
grouped[task.status].push(task)
|
||||
}
|
||||
})
|
||||
return grouped
|
||||
}, [tasks])
|
||||
function TaskRow({ task }: { task: FileTask }) {
|
||||
const agentKey = task.assigned_agent?.toLowerCase() ?? ""
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-muted/20 transition-colors">
|
||||
{/* Status badge */}
|
||||
<span className={cn(
|
||||
"text-[10px] font-medium px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap",
|
||||
TASK_STATUS_COLORS[task.status] ?? "bg-zinc-500/10 text-zinc-400"
|
||||
)}>
|
||||
{task.status_raw ? cleanText(task.status_raw).slice(0, 20) : task.status}
|
||||
</span>
|
||||
|
||||
{/* Stage/task name */}
|
||||
<span className="text-sm flex-1 truncate text-foreground/80">
|
||||
{cleanText(task.title)}
|
||||
</span>
|
||||
|
||||
{/* Agent */}
|
||||
{agentKey && agentKey !== "unassigned" && (
|
||||
<span className={cn(
|
||||
"text-xs font-medium shrink-0 flex items-center gap-1",
|
||||
AGENT_COLORS[agentKey] ?? "text-muted-foreground"
|
||||
)}>
|
||||
<span>{AGENT_EMOJI[agentKey] ?? "🤖"}</span>
|
||||
<span className="hidden sm:inline capitalize">{agentKey}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Tiger's project card (read-only, with task expand) ───────────────────────
|
||||
|
||||
function FileProjectCard({ project }: { project: FileProject }) {
|
||||
const [expanded, setExpanded] = React.useState(false)
|
||||
|
||||
// Fetch tasks from TASKS.md filtered to this project name
|
||||
const projectName = cleanText(project.name)
|
||||
const { data: tasksData, isLoading: tasksLoading } = useSWR<{
|
||||
ok: boolean; tasks: FileTask[]
|
||||
}>(
|
||||
expanded
|
||||
? `/api/tiger/file-tasks?project=${encodeURIComponent(projectName)}`
|
||||
: null,
|
||||
fetcher
|
||||
)
|
||||
|
||||
// Show sub-tasks (stage rows) and the project-level task (agent + status)
|
||||
const allTasks = tasksData?.tasks ?? []
|
||||
const projectTask = allTasks.find((t) => t.isProject)
|
||||
const subTasks = allTasks.filter((t) => t.isSubTask)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<Card className="bg-card/40 transition-colors hover:bg-card/60">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Expand toggle + title */}
|
||||
<button
|
||||
className="flex items-center gap-2 text-left flex-1 min-w-0 group"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
|
||||
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
|
||||
}
|
||||
<CardTitle className="text-base group-hover:text-primary transition-colors">
|
||||
{cleanText(project.name)}
|
||||
</CardTitle>
|
||||
</button>
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge className={cn("text-xs shrink-0", statusBadgeClass(project.status))}>
|
||||
{cleanText(project.status).replace(/in progress/i, "Active")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{project.description && (
|
||||
<CardDescription className="line-clamp-2 ml-6 text-xs">
|
||||
{project.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-3 ml-6 text-xs text-muted-foreground">
|
||||
<span>Created {project.created}</span>
|
||||
<span>·</span>
|
||||
<span>{project.tasks_count}</span>
|
||||
{/* Show primary agent when collapsed */}
|
||||
{!expanded && projectTask?.assigned_agent && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1",
|
||||
AGENT_COLORS[projectTask.assigned_agent] ?? "text-muted-foreground"
|
||||
)}>
|
||||
{AGENT_EMOJI[projectTask.assigned_agent] ?? ""} {projectTask.assigned_agent}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded task list */}
|
||||
{expanded && (
|
||||
<div className="mt-4 ml-2 border-t border-border/30 pt-3">
|
||||
{tasksLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : allTasks.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground px-3 italic">
|
||||
No tasks found in TASKS.md for this project.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{/* Project-level agent row */}
|
||||
{projectTask && (
|
||||
<div className="flex items-center gap-2 px-3 pb-2 mb-1 border-b border-border/20">
|
||||
<span className="text-xs text-muted-foreground">Lead:</span>
|
||||
<span className={cn(
|
||||
"text-xs font-medium flex items-center gap-1",
|
||||
AGENT_COLORS[projectTask.assigned_agent] ?? "text-muted-foreground"
|
||||
)}>
|
||||
{AGENT_EMOJI[projectTask.assigned_agent] ?? ""}
|
||||
<span className="capitalize">{projectTask.assigned_agent}</span>
|
||||
</span>
|
||||
<span className={cn(
|
||||
"ml-auto text-[10px] px-1.5 py-0.5 rounded-full",
|
||||
TASK_STATUS_COLORS[projectTask.status] ?? "bg-zinc-500/10 text-zinc-400"
|
||||
)}>
|
||||
{cleanText(projectTask.status_raw || projectTask.status)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sub-task rows (review pipeline stages etc) */}
|
||||
{subTasks.length > 0 ? (
|
||||
subTasks.map((t) => <TaskRow key={t.id} task={t} />)
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground px-3 italic">
|
||||
No task breakdown available — Tiger tracks this at project level.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Dashboard-queued project card ───────────────────────────────────────────
|
||||
|
||||
function DbProjectCard({ project, onDelete }: { project: DbProject; onDelete: (id: string) => void }) {
|
||||
return (
|
||||
<Card className="bg-card/40 border-dashed border-border/60">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base truncate text-foreground/80">{project.name}</CardTitle>
|
||||
{project.description && (
|
||||
<CardDescription className="line-clamp-2 text-xs mt-1">{project.description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onDelete(project.id)} className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={cn("text-xs", PRIORITY_COLORS[project.priority])}>
|
||||
{project.priority}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">Queued — waiting for Tiger</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { data: fileData, isLoading: fileLoading } = useSWR<{
|
||||
ok: boolean; projects: FileProject[]
|
||||
}>("/api/tiger/file-tasks/projects", fetcher, { refreshInterval: 60_000 })
|
||||
|
||||
const { data: dbData, isLoading: dbLoading, mutate: mutateDb } = useSWR<{
|
||||
ok: boolean; projects: DbProject[]
|
||||
}>("/api/tiger/projects", fetcher, { refreshInterval: 60_000 })
|
||||
|
||||
const fileProjects = fileData?.projects ?? []
|
||||
const dbProjects = dbData?.projects ?? []
|
||||
const isLoading = fileLoading || dbLoading
|
||||
|
||||
const [isCreateOpen, setIsCreateOpen] = React.useState(false)
|
||||
const [form, setForm] = React.useState({ name: "", seed: "", description: "", priority: "medium" })
|
||||
const [creating, setCreating] = React.useState(false)
|
||||
const [createError, setCreateError] = React.useState("")
|
||||
|
||||
const handleCreate = async () => {
|
||||
const payload: Record<string, string> = { priority: form.priority }
|
||||
if (form.name.trim()) payload.name = form.name.trim()
|
||||
if (form.seed.trim()) payload.seed = form.seed.trim()
|
||||
if (form.description.trim()) payload.description = form.description.trim()
|
||||
if (!payload.name && !payload.seed) { setCreateError("Enter a project name or seed text."); return }
|
||||
setCreating(true); setCreateError("")
|
||||
try {
|
||||
const res = await fetch("/api/tiger/projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!result.ok) throw new Error(result.error ?? "Failed")
|
||||
setForm({ name: "", seed: "", description: "", priority: "medium" })
|
||||
setIsCreateOpen(false)
|
||||
mutateDb()
|
||||
} catch (e: any) { setCreateError(e.message) }
|
||||
finally { setCreating(false) }
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await fetch(`/api/tiger/projects/${id}`, { method: "DELETE" })
|
||||
mutateDb()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -192,182 +364,90 @@ export default function ProjectsPage() {
|
|||
Projects
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage projects and track tasks across your team
|
||||
Tiger's projects from <span className="font-mono text-xs">PROJECTS.md</span>.
|
||||
Click a project to see its tasks and agents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Project
|
||||
</Button>
|
||||
<Button><Plus className="h-4 w-4 mr-2" />New Project</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Project</DialogTitle>
|
||||
<DialogDescription>Create a new project to organize tasks.</DialogDescription>
|
||||
<DialogTitle>Queue a Project for Tiger</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tiger will pick this up and add it to PROJECTS.md.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Name</label>
|
||||
<Input
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
className="mt-1"
|
||||
/>
|
||||
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. BESS Economics Model" className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Input
|
||||
value={newProjectDesc}
|
||||
onChange={(e) => setNewProjectDesc(e.target.value)}
|
||||
placeholder="Project description"
|
||||
className="mt-1"
|
||||
/>
|
||||
<label className="text-sm font-medium">Seed text <span className="text-muted-foreground font-normal">(Tiger generates title + goal)</span></label>
|
||||
<textarea value={form.seed} onChange={(e) => setForm((f) => ({ ...f, seed: e.target.value }))} placeholder="Describe what you want Tiger to work on…" rows={3} className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring resize-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Priority</label>
|
||||
<select
|
||||
value={newProjectPriority}
|
||||
onChange={(e) => setNewProjectPriority(e.target.value)}
|
||||
className="w-full mt-1 px-3 py-2 rounded-md border bg-background"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
<select value={form.priority} onChange={(e) => setForm((f) => ({ ...f, priority: e.target.value }))} className="w-full mt-1 px-3 py-2 rounded-md border bg-background text-sm">
|
||||
{["low", "medium", "high", "urgent"].map((p) => <option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<Button onClick={handleCreateProject} disabled={creating || !newProjectName.trim()}>
|
||||
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
||||
<Button onClick={handleCreate} disabled={creating || (!form.name.trim() && !form.seed.trim())} className="w-full">
|
||||
{creating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Create Project
|
||||
Queue Project
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Projects Grid */}
|
||||
{loading ? (
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-center py-20 text-muted-foreground">
|
||||
<FolderOpen className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p>No projects yet. Create your first project to get started.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<Card
|
||||
key={project.id}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50 transition-colors",
|
||||
selectedProject?.id === project.id && "ring-2 ring-primary"
|
||||
)}
|
||||
onClick={() => setSelectedProject(project)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg">{project.name}</CardTitle>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// TODO: Edit project
|
||||
}}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteProject(project.id)
|
||||
}} className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{project.description || "No description"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={cn("text-xs", PRIORITY_COLORS[project.priority])}>
|
||||
{project.priority}
|
||||
</Badge>
|
||||
<Badge className={cn("text-xs", STATUS_COLORS[project.status])}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Detail with Tasks */}
|
||||
{selectedProject && (
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Button variant="ghost" onClick={() => setSelectedProject(null)}>
|
||||
← Back
|
||||
</Button>
|
||||
<h2 className="text-xl font-bold">{selectedProject.name}</h2>
|
||||
<Badge className={cn(PRIORITY_COLORS[selectedProject.priority])}>
|
||||
{selectedProject.priority}
|
||||
</Badge>
|
||||
<div className="space-y-8">
|
||||
{/* Tiger's PROJECTS.md — primary */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FolderOpen className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-semibold">Tiger's Projects</span>
|
||||
<span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">PROJECTS.md</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 rounded-full ml-1">{fileProjects.length}</span>
|
||||
</div>
|
||||
{fileProjects.length === 0 ? (
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
<FolderOpen className="h-10 w-10 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm">No projects in PROJECTS.md yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileProjects.map((p) => <FileProjectCard key={p.id} project={p} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Simple Kanban - Tasks by Status */}
|
||||
{loadingTasks ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
{(["backlog", "ready", "in-progress", "review", "done"] as const).map((status) => (
|
||||
<div key={status} className="space-y-2">
|
||||
<div className="text-sm font-medium text-muted-foreground uppercase tracking-wider px-2">
|
||||
{status.replace("-", " ")} ({tasksByStatus[status].length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{tasksByStatus[status].map((task) => (
|
||||
<Card key={task.id} className="p-3 cursor-pointer hover:bg-muted/50">
|
||||
<div className="font-medium text-sm">{task.title}</div>
|
||||
{task.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
{task.assigned_agent && (
|
||||
<Badge variant="outline" className="mt-2 text-xs">
|
||||
{task.assigned_agent}
|
||||
</Badge>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* Dashboard queue — secondary */}
|
||||
{dbProjects.length > 0 && (
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Inbox className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold text-blue-400">Dashboard Queue</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 rounded-full ml-1">{dbProjects.length}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Projects you've queued — Tiger will add them to PROJECTS.md when he processes them.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{dbProjects.map((p) => <DbProjectCard key={p.id} project={p} onDelete={handleDelete} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,276 +1,627 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* settings/page.tsx — Tiger configuration + API key management
|
||||
*
|
||||
* Sections (in order):
|
||||
* 0. API Keys & Router — ANTHROPIC, OPENROUTER, TELEGRAM keys + TIGER_ROUTER_MODEL
|
||||
* 1. Model — OpenClaw global model dropdown + fallbacks + compaction
|
||||
* 2. Session — dmScope
|
||||
* 3. Telegram — enabled toggle, streaming mode
|
||||
* 4. Commands — native commands, ownerDisplay, restart
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import { Settings2, Save, Loader2, RefreshCw, Eye, EyeOff } from "lucide-react"
|
||||
import useSWR from "swr"
|
||||
import {
|
||||
Settings2, Save, Loader2, RefreshCw,
|
||||
Bot, MessageSquare, Terminal, Cpu, AlertCircle, Check,
|
||||
Key, RotateCcw,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useBridgeRequest } from "@/hooks/use-bridge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ConfigField {
|
||||
path: string
|
||||
label: string
|
||||
type: "text" | "number" | "boolean" | "password"
|
||||
value: ConfigValue
|
||||
original: ConfigValue
|
||||
interface ModelInfo {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
reasoning: boolean
|
||||
contextWindow: number
|
||||
cost?: { input: number; output: number }
|
||||
}
|
||||
|
||||
// Tiger config sections - matches the structure from /tiger/config
|
||||
const CONFIG_SECTIONS = [
|
||||
{
|
||||
key: "agent",
|
||||
label: "Agent",
|
||||
description: "AI agent model configuration",
|
||||
paths: [
|
||||
{ path: "model", label: "Primary Model", type: "text" },
|
||||
{ path: "fallbackModels", label: "Fallback Models", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "execution",
|
||||
label: "Execution",
|
||||
description: "Command execution settings",
|
||||
paths: [
|
||||
{ path: "maxDuration", label: "Max Duration (seconds)", type: "number" },
|
||||
{ path: "maxRetries", label: "Max Retries", type: "number" },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
interface ConfigSection {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
fields: ConfigField[]
|
||||
interface OpenClawConfig {
|
||||
agents?: {
|
||||
defaults?: {
|
||||
model?: { primary?: string; fallbacks?: string[] }
|
||||
compaction?: { mode?: string }
|
||||
}
|
||||
}
|
||||
session?: { dmScope?: string }
|
||||
channels?: { telegram?: { enabled?: boolean; streaming?: string } }
|
||||
commands?: { native?: string; ownerDisplay?: string; restart?: boolean }
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { request } = useBridgeRequest()
|
||||
const [sections, setSections] = React.useState<ConfigSection[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [saved, setSaved] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [showPasswords, setShowPasswords] = React.useState<Record<string, boolean>>({})
|
||||
interface KeyPresence {
|
||||
isSet: boolean
|
||||
preview?: string
|
||||
}
|
||||
|
||||
// Load config from Tiger Bridge
|
||||
const loadConfig = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await request("/api/tiger/config") as Record<string, ConfigValue>
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const loadedSections: ConfigSection[] = CONFIG_SECTIONS.map(section => ({
|
||||
key: section.key,
|
||||
label: section.label,
|
||||
description: section.description,
|
||||
fields: section.paths.map(p => {
|
||||
const value = getNestedValue(data, p.path)
|
||||
return {
|
||||
path: p.path,
|
||||
label: p.label,
|
||||
type: p.type,
|
||||
value: value ?? "",
|
||||
original: value ?? "",
|
||||
}
|
||||
}),
|
||||
}))
|
||||
const fetcher = (url: string) => fetch(url).then(r => r.json())
|
||||
|
||||
setSections(loadedSections)
|
||||
} catch {
|
||||
setError("Failed to load configuration. Is the Tiger Bridge running?")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [request])
|
||||
function get(obj: any, path: string, fallback: any = ""): any {
|
||||
return path.split(".").reduce((o, k) => (o != null ? o[k] : undefined), obj) ?? fallback
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
loadConfig()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const handleFieldChange = (sectionKey: string, fieldPath: string, newValue: ConfigValue) => {
|
||||
setSections(prev =>
|
||||
prev.map(s =>
|
||||
s.key === sectionKey
|
||||
? {
|
||||
...s,
|
||||
fields: s.fields.map(f =>
|
||||
f.path === fieldPath ? { ...f, value: newValue } : f
|
||||
),
|
||||
}
|
||||
: s
|
||||
)
|
||||
)
|
||||
function set(obj: any, path: string, value: any): any {
|
||||
const keys = path.split(".")
|
||||
const result = JSON.parse(JSON.stringify(obj))
|
||||
let cur = result
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (cur[keys[i]] == null) cur[keys[i]] = {}
|
||||
cur = cur[keys[i]]
|
||||
}
|
||||
cur[keys[keys.length - 1]] = value
|
||||
return result
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
// ─── Shared sub-components ────────────────────────────────────────────────────
|
||||
|
||||
try {
|
||||
// Build patch object from all changed fields
|
||||
const patch: Record<string, ConfigValue> = {}
|
||||
|
||||
for (const section of sections) {
|
||||
for (const field of section.fields) {
|
||||
if (JSON.stringify(field.value) !== JSON.stringify(field.original)) {
|
||||
let val = field.value
|
||||
if (field.type === "number") val = Number(val)
|
||||
if (field.type === "boolean") val = val === true || val === "true"
|
||||
patch[field.path] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await request("/api/tiger/config", "POST", { patch })
|
||||
|
||||
// Update originals
|
||||
setSections(prev =>
|
||||
prev.map(s => ({
|
||||
...s,
|
||||
fields: s.fields.map(f => ({ ...f, original: f.value })),
|
||||
}))
|
||||
)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to save configuration.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const hasChanges = sections.some(s =>
|
||||
s.fields.some(f => JSON.stringify(f.value) !== JSON.stringify(f.original))
|
||||
function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 py-3 border-b border-border/50 last:border-0">
|
||||
<div className="w-52 shrink-0">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
{hint && <div className="text-xs text-muted-foreground mt-0.5">{hint}</div>}
|
||||
</div>
|
||||
<div className="flex-1">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||
checked ? "bg-primary" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow transition-transform",
|
||||
checked ? "translate-x-5" : "translate-x-0"
|
||||
)} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectInput({ value, options, onChange }: {
|
||||
value: string
|
||||
options: { value: string; label: string }[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-xs"
|
||||
>
|
||||
{options.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelSelect({ value, models, onChange }: {
|
||||
value: string
|
||||
models: ModelInfo[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const grouped = models.reduce<Record<string, ModelInfo[]>>((acc, m) => {
|
||||
if (!acc[m.provider]) acc[m.provider] = []
|
||||
acc[m.provider].push(m)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const current = models.find(m => m.id === value)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-4xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Settings2 className="h-6 w-6" />
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-muted-foreground">Tiger agent configuration. Changes are applied live.</p>
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm"
|
||||
>
|
||||
{Object.entries(grouped).map(([prov, mods]) => (
|
||||
<optgroup key={prov} label={prov}>
|
||||
{mods.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{current && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
{current.id}
|
||||
</span>
|
||||
{current.reasoning && (
|
||||
<span className="text-xs bg-purple-500/15 text-purple-400 px-2 py-0.5 rounded font-medium">
|
||||
reasoning
|
||||
</span>
|
||||
)}
|
||||
{current.contextWindow > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{current.contextWindow >= 1_000_000
|
||||
? `${(current.contextWindow / 1_000_000).toFixed(1)}M ctx`
|
||||
: `${Math.round(current.contextWindow / 1_000)}K ctx`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadConfig} disabled={loading}>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||
Reload
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!hasChanges || saving}>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{saved ? "Saved!" : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
sections.map(section => (
|
||||
<Card key={section.key}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{section.label}</CardTitle>
|
||||
<CardDescription>{section.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{section.fields.map(field => (
|
||||
<div key={field.path} className="flex items-center gap-4">
|
||||
<label className="w-[200px] text-sm font-medium text-muted-foreground shrink-0">
|
||||
{field.label}
|
||||
</label>
|
||||
{field.type === "boolean" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleFieldChange(section.key, field.path, !field.value)
|
||||
}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||
field.value ? "bg-primary" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow ring-0 transition-transform",
|
||||
field.value ? "translate-x-5" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : field.type === "password" ? (
|
||||
<div className="flex-1 flex gap-2">
|
||||
<Input
|
||||
type={showPasswords[field.path] ? "text" : "password"}
|
||||
value={String(field.value)}
|
||||
onChange={e =>
|
||||
handleFieldChange(section.key, field.path, e.target.value)
|
||||
}
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setShowPasswords(p => ({ ...p, [field.path]: !p[field.path] }))
|
||||
}
|
||||
>
|
||||
{showPasswords[field.path] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type}
|
||||
value={String(field.value)}
|
||||
onChange={e =>
|
||||
handleFieldChange(
|
||||
section.key,
|
||||
field.path,
|
||||
field.type === "number" ? e.target.value : e.target.value
|
||||
)
|
||||
}
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getNestedValue(obj: Record<string, ConfigValue>, path: string): ConfigValue {
|
||||
const keys = path.split(".")
|
||||
let current: ConfigValue = obj
|
||||
for (const key of keys) {
|
||||
if (current == null || typeof current !== "object" || Array.isArray(current)) return null
|
||||
current = (current as Record<string, ConfigValue>)[key]
|
||||
function SectionCard({ icon: Icon, title, description, children, dirty }: {
|
||||
icon: React.ElementType
|
||||
title: string
|
||||
description: string
|
||||
children: React.ReactNode
|
||||
dirty?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn("transition-colors", dirty && "border-primary/40")}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{title}
|
||||
{dirty && (
|
||||
<span className="text-xs font-normal text-primary ml-1">unsaved changes</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Section 0: API Keys ──────────────────────────────────────────────────────
|
||||
|
||||
const SECRET_KEYS = [
|
||||
{ key: "ANTHROPIC_API_KEY", label: "Anthropic API key", hint: "Required for claude-* models via TIGER_ROUTER_MODEL" },
|
||||
{ key: "OPENROUTER_API_KEY", label: "OpenRouter API key", hint: "Required for openrouter/* and other non-Anthropic models" },
|
||||
{ key: "TELEGRAM_BOT_TOKEN", label: "Telegram bot token", hint: "Bot token from @BotFather — enables Telegram channel" },
|
||||
{ key: "TELEGRAM_CHAT_ID", label: "Telegram chat ID", hint: "Your personal chat ID — restricts who can DM Tiger" },
|
||||
] as const
|
||||
|
||||
type SecretKey = (typeof SECRET_KEYS)[number]["key"]
|
||||
|
||||
function ApiKeysSection() {
|
||||
const { data: keysData, mutate: mutateKeys } = useSWR<{
|
||||
ok: boolean
|
||||
keys: Record<string, KeyPresence>
|
||||
}>("/api/tiger/keys", fetcher, { revalidateOnFocus: false })
|
||||
|
||||
const keysPresence = keysData?.keys ?? {}
|
||||
|
||||
// Local draft for pending key values (user types new value)
|
||||
const [draft, setDraft] = React.useState<Partial<Record<SecretKey | "TIGER_ROUTER_MODEL", string>>>({})
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle")
|
||||
const [saveMsg, setSaveMsg] = React.useState("")
|
||||
const [restarting, setRestarting] = React.useState(false)
|
||||
const [restartMsg, setRestartMsg] = React.useState("")
|
||||
|
||||
// Router model uses preview value (non-secret)
|
||||
const routerModelPreview = keysPresence["TIGER_ROUTER_MODEL"]?.preview ?? ""
|
||||
const [routerModelDraft, setRouterModelDraft] = React.useState("")
|
||||
|
||||
// Sync router model draft from server on first load
|
||||
React.useEffect(() => {
|
||||
if (routerModelPreview && !routerModelDraft) {
|
||||
setRouterModelDraft(routerModelPreview)
|
||||
}
|
||||
}, [routerModelPreview])
|
||||
|
||||
const anyChanges = Object.keys(draft).length > 0 || routerModelDraft !== routerModelPreview
|
||||
|
||||
const handleSaveKeys = async () => {
|
||||
setSaving(true)
|
||||
setSaveState("idle")
|
||||
const payload: Record<string, string | null> = {}
|
||||
for (const [k, v] of Object.entries(draft)) {
|
||||
// Empty string in the input → clear the key
|
||||
payload[k] = v === "" ? null : v
|
||||
}
|
||||
if (routerModelDraft !== routerModelPreview) {
|
||||
payload["TIGER_ROUTER_MODEL"] = routerModelDraft || null
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/tiger/keys", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.ok) throw new Error(data.error ?? "Save failed")
|
||||
setSaveState("ok")
|
||||
setSaveMsg("Keys saved. Restart bridge to apply.")
|
||||
setDraft({})
|
||||
mutateKeys()
|
||||
setTimeout(() => { setSaveState("idle"); setSaveMsg("") }, 5000)
|
||||
} catch (err: any) {
|
||||
setSaveState("err")
|
||||
setSaveMsg(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
return current ?? null
|
||||
}
|
||||
|
||||
const handleRestartBridge = async () => {
|
||||
setRestarting(true)
|
||||
setRestartMsg("")
|
||||
try {
|
||||
const res = await fetch("/api/tiger/bridge-restart", { method: "POST" })
|
||||
const data = await res.json()
|
||||
setRestartMsg(data.message ?? "Restart initiated.")
|
||||
setTimeout(() => setRestartMsg(""), 8000)
|
||||
} catch {
|
||||
setRestartMsg("Restart request failed.")
|
||||
} finally {
|
||||
setRestarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Key className="h-4 w-4 text-muted-foreground" />
|
||||
API Keys & Router
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Stored in bridge .env. Values are never returned — only presence is shown.
|
||||
After saving, click <strong>Restart bridge</strong> to apply.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Secret keys */}
|
||||
{SECRET_KEYS.map(({ key, label, hint }) => {
|
||||
const isSet = keysPresence[key]?.isSet ?? false
|
||||
const hasDraft = draft[key] !== undefined
|
||||
return (
|
||||
<SettingRow key={key} label={label} hint={hint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={isSet ? "●●●●●●●● (set)" : "Not set — paste to update"}
|
||||
value={draft[key] ?? ""}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, [key]: e.target.value }))}
|
||||
className={cn(
|
||||
"h-9 rounded-md border border-input bg-background px-3 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm",
|
||||
hasDraft && "border-primary/60"
|
||||
)}
|
||||
/>
|
||||
{isSet && !hasDraft && (
|
||||
<span className="text-xs text-emerald-400 shrink-0">✓ set</span>
|
||||
)}
|
||||
{hasDraft && draft[key] === "" && (
|
||||
<span className="text-xs text-amber-400 shrink-0">will clear</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Router model (non-secret, shown as text) */}
|
||||
<SettingRow
|
||||
label="Router model"
|
||||
hint="Model slug for classifyAgent / generateProject*. Prefix 'anthropic/' uses Anthropic API; anything else uses OpenRouter."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={routerModelDraft}
|
||||
onChange={(e) => setRouterModelDraft(e.target.value)}
|
||||
placeholder="e.g. anthropic/claude-haiku-4-5"
|
||||
className={cn(
|
||||
"h-9 rounded-md border border-input bg-background px-3 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm",
|
||||
routerModelDraft !== routerModelPreview && "border-primary/60"
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Feedback */}
|
||||
{saveMsg && (
|
||||
<div className={cn(
|
||||
"mt-3 text-xs px-3 py-2 rounded",
|
||||
saveState === "err"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-emerald-500/10 text-emerald-400"
|
||||
)}>
|
||||
{saveMsg}
|
||||
</div>
|
||||
)}
|
||||
{restartMsg && (
|
||||
<div className="mt-2 text-xs px-3 py-2 rounded bg-blue-500/10 text-blue-400">
|
||||
{restartMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveKeys}
|
||||
disabled={!anyChanges || saving}
|
||||
>
|
||||
{saving
|
||||
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
: saveState === "ok"
|
||||
? <Check className="h-4 w-4 mr-1" />
|
||||
: <Save className="h-4 w-4 mr-1" />
|
||||
}
|
||||
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Keys"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestartBridge}
|
||||
disabled={restarting}
|
||||
>
|
||||
{restarting
|
||||
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
: <RotateCcw className="h-4 w-4 mr-1" />
|
||||
}
|
||||
{restarting ? "Restarting…" : "Restart bridge"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data: configData, mutate: mutateConfig, isLoading: configLoading } =
|
||||
useSWR<{ ok: boolean; config: OpenClawConfig }>("/api/tiger/config", fetcher)
|
||||
|
||||
const { data: modelsData, isLoading: modelsLoading } =
|
||||
useSWR<{ ok: boolean; models: ModelInfo[] }>("/api/tiger/config/models", fetcher)
|
||||
|
||||
const remoteConfig = configData?.config ?? {}
|
||||
const models = modelsData?.models ?? []
|
||||
|
||||
const [draft, setDraft] = React.useState<OpenClawConfig>({})
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (configData?.ok && !initialized) {
|
||||
setDraft(JSON.parse(JSON.stringify(configData.config)))
|
||||
setInitialized(true)
|
||||
}
|
||||
}, [configData, initialized])
|
||||
|
||||
const update = (path: string, value: any) => setDraft(prev => set(prev, path, value))
|
||||
const g = (path: string, fallback: any = "") => get(draft, path, fallback)
|
||||
const r = (path: string, fallback: any = "") => get(remoteConfig, path, fallback)
|
||||
const isDirty = (path: string) => JSON.stringify(g(path)) !== JSON.stringify(r(path))
|
||||
const anyDirty = JSON.stringify(draft) !== JSON.stringify(remoteConfig)
|
||||
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle")
|
||||
const [saveError, setSaveError] = React.useState("")
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setSaveState("idle")
|
||||
try {
|
||||
const res = await fetch("/api/tiger/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ patch: draft }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.ok) throw new Error(data.error ?? "Save failed")
|
||||
setSaveState("ok")
|
||||
await mutateConfig()
|
||||
setInitialized(false)
|
||||
setTimeout(() => setSaveState("idle"), 3000)
|
||||
} catch (err: any) {
|
||||
setSaveError(err.message)
|
||||
setSaveState("err")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setDraft(JSON.parse(JSON.stringify(remoteConfig)))
|
||||
setSaveState("idle")
|
||||
}
|
||||
|
||||
const loading = configLoading || modelsLoading || !initialized
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-3xl">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Settings2 className="h-6 w-6" /> Settings
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
API keys and Tiger configuration.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={!anyDirty || saving}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" /> Reset
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!anyDirty || saving}>
|
||||
{saving
|
||||
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
: saveState === "ok"
|
||||
? <Check className="h-4 w-4 mr-1" />
|
||||
: <Save className="h-4 w-4 mr-1" />
|
||||
}
|
||||
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Config"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveState === "err" && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 0. API Keys (always rendered — has its own data fetching) ─── */}
|
||||
<ApiKeysSection />
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── 1. Model ─────────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Cpu}
|
||||
title="Model"
|
||||
description="Global model for Tiger and sub-agents. Per-agent overrides live on the Agents page."
|
||||
dirty={isDirty("agents.defaults.model.primary") || isDirty("agents.defaults.model.fallbacks")}
|
||||
>
|
||||
<SettingRow label="Primary model" hint="Active model for all agents (unless overridden)">
|
||||
<ModelSelect
|
||||
value={g("agents.defaults.model.primary", "")}
|
||||
models={models}
|
||||
onChange={v => update("agents.defaults.model.primary", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Fallback models" hint="Comma-separated, tried in order if primary fails">
|
||||
<input
|
||||
type="text"
|
||||
value={(g("agents.defaults.model.fallbacks", []) as string[]).join(", ")}
|
||||
onChange={e => update(
|
||||
"agents.defaults.model.fallbacks",
|
||||
e.target.value.split(",").map(s => s.trim()).filter(Boolean)
|
||||
)}
|
||||
placeholder="e.g. openrouter/auto"
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Compaction mode" hint="How Tiger handles context window limits">
|
||||
<SelectInput
|
||||
value={g("agents.defaults.compaction.mode", "safeguard")}
|
||||
options={[
|
||||
{ value: "safeguard", label: "Safeguard — compress when near limit" },
|
||||
{ value: "auto", label: "Auto — compress aggressively" },
|
||||
{ value: "off", label: "Off — never compress" },
|
||||
]}
|
||||
onChange={v => update("agents.defaults.compaction.mode", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 2. Session ───────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Bot}
|
||||
title="Session"
|
||||
description="Conversation session and identity scoping."
|
||||
dirty={isDirty("session.dmScope")}
|
||||
>
|
||||
<SettingRow label="DM scope" hint="Context isolation between Telegram chats">
|
||||
<SelectInput
|
||||
value={g("session.dmScope", "per-channel-peer")}
|
||||
options={[
|
||||
{ value: "per-channel-peer", label: "Per-channel-peer (recommended)" },
|
||||
{ value: "per-channel", label: "Per-channel" },
|
||||
{ value: "global", label: "Global — single shared context" },
|
||||
]}
|
||||
onChange={v => update("session.dmScope", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 3. Telegram ──────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={MessageSquare}
|
||||
title="Telegram"
|
||||
description="Telegram bot channel settings."
|
||||
dirty={isDirty("channels.telegram.enabled") || isDirty("channels.telegram.streaming")}
|
||||
>
|
||||
<SettingRow label="Enabled" hint="Whether the Telegram bot is active">
|
||||
<Toggle
|
||||
checked={g("channels.telegram.enabled", true)}
|
||||
onChange={v => update("channels.telegram.enabled", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Streaming" hint="How Tiger sends updates while generating">
|
||||
<SelectInput
|
||||
value={g("channels.telegram.streaming", "partial")}
|
||||
options={[
|
||||
{ value: "partial", label: "Partial — stream as it types" },
|
||||
{ value: "full", label: "Full — send only on completion" },
|
||||
{ value: "off", label: "Off — no streaming" },
|
||||
]}
|
||||
onChange={v => update("channels.telegram.streaming", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 4. Commands ──────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Terminal}
|
||||
title="Commands"
|
||||
description="Native and system command settings."
|
||||
dirty={isDirty("commands.native") || isDirty("commands.ownerDisplay") || isDirty("commands.restart")}
|
||||
>
|
||||
<SettingRow label="Native commands" hint="Whether Tiger can run shell commands on the host">
|
||||
<SelectInput
|
||||
value={g("commands.native", "auto")}
|
||||
options={[
|
||||
{ value: "auto", label: "Auto — enable when available" },
|
||||
{ value: "on", label: "On — always enabled" },
|
||||
{ value: "off", label: "Off — disabled" },
|
||||
]}
|
||||
onChange={v => update("commands.native", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Owner display" hint="How your name appears to Tiger">
|
||||
<SelectInput
|
||||
value={g("commands.ownerDisplay", "raw")}
|
||||
options={[
|
||||
{ value: "raw", label: "Raw — as-is" },
|
||||
{ value: "formatted", label: "Formatted — display name" },
|
||||
]}
|
||||
onChange={v => update("commands.ownerDisplay", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Allow restart" hint="Tiger can restart itself when needed">
|
||||
<Toggle
|
||||
checked={g("commands.restart", true)}
|
||||
onChange={v => update("commands.restart", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +1,287 @@
|
|||
import { KanbanBoard } from "@/components/tasks/kanban-board"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { CheckSquare, GitBranch, Bot, Clock } from "lucide-react"
|
||||
"use client"
|
||||
|
||||
export default function TasksPage() {
|
||||
/**
|
||||
* /tasks — Dual-source task view
|
||||
*
|
||||
* PRIMARY → Tiger's TASKS.md (source of truth, read-only)
|
||||
* fetched via /api/tiger/file-tasks
|
||||
* SECONDARY → SQLite dispatch queue (status=backlog only)
|
||||
* fetched via /api/tiger/tasks
|
||||
* These are dashboard-queued items Tiger hasn't touched yet.
|
||||
*
|
||||
* Tiger owns TASKS.md entirely. Dashboard-queued items graduate to TASKS.md
|
||||
* once Tiger picks them up; the watcher marks them done in SQLite.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import {
|
||||
CheckSquare, GitBranch, Bot, Clock,
|
||||
AlertTriangle, Loader2, FolderOpen, Inbox,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Tiger's TASKS.md task (from file-tasks route)
|
||||
interface FileTask {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
status_raw: string
|
||||
section: string
|
||||
assigned_agent: string
|
||||
project: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// SQLite dispatch queue task
|
||||
interface QueueTask {
|
||||
id: string
|
||||
project_id: string | null
|
||||
title: string
|
||||
status: string
|
||||
priority: string
|
||||
assigned_agent: string | null
|
||||
agent_reason: string | null
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SECTION_ORDER = ["in-progress", "review", "ready", "backlog", "done"]
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
"in-progress": "In Progress",
|
||||
review: "Review",
|
||||
ready: "Ready",
|
||||
backlog: "Backlog",
|
||||
done: "Done",
|
||||
}
|
||||
const SECTION_COLORS: Record<string, string> = {
|
||||
"in-progress": "text-amber-400",
|
||||
review: "text-purple-400",
|
||||
ready: "text-blue-400",
|
||||
backlog: "text-zinc-400",
|
||||
done: "text-emerald-400",
|
||||
}
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
low: "bg-gray-500/10 text-gray-400 border-gray-500/20",
|
||||
medium: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||
high: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||
urgent: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
}
|
||||
const AGENT_EMOJI: Record<string, string> = {
|
||||
tiger: "🐯", main: "🐯",
|
||||
cody: "💻", coder: "💻",
|
||||
ethan: "🔍", researcher: "🔍",
|
||||
cathy: "✍️", writer: "✍️",
|
||||
elon: "📊", pm: "📊",
|
||||
}
|
||||
const AGENT_COLORS: Record<string, string> = {
|
||||
tiger: "text-orange-400", main: "text-orange-400",
|
||||
cody: "text-blue-400", coder: "text-blue-400",
|
||||
ethan: "text-green-400", researcher: "text-green-400",
|
||||
cathy: "text-pink-400", writer: "text-pink-400",
|
||||
elon: "text-violet-400", pm: "text-violet-400",
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
function isRouterFailure(reason: string | null) {
|
||||
return !!reason && reason.startsWith("router_")
|
||||
}
|
||||
|
||||
// ─── File task row (Tiger's TASKS.md) ─────────────────────────────────────────
|
||||
|
||||
function FileTaskRow({ task }: { task: FileTask }) {
|
||||
const agentKey = task.assigned_agent?.toLowerCase() ?? ""
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<CheckSquare className="h-6 w-6 text-primary" />
|
||||
Task Progress
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage and track tasks across your team and AI sub-agents
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-muted/30 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate text-foreground/90">{task.title}</div>
|
||||
{task.project && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">{task.project}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Active Tasks</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<Clock className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">In Progress</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<GitBranch className="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">AI Delegated</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<Bot className="h-5 w-5 text-violet-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Completed Today</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<CheckSquare className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Kanban Board */}
|
||||
<Card className="bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle>Task Board</CardTitle>
|
||||
<CardDescription>
|
||||
Drag and drop tasks between columns. Click a task to edit or assign to an AI agent.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<KanbanBoard />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{agentKey && (
|
||||
<span className={cn("text-xs font-medium shrink-0", AGENT_COLORS[agentKey] ?? "text-muted-foreground")}>
|
||||
{AGENT_EMOJI[agentKey] ?? ""} <span className="hidden sm:inline">{agentKey}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0 italic">{task.status_raw}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Section (Tiger's tasks grouped by status) ────────────────────────────────
|
||||
|
||||
function FileTaskSection({ status, tasks }: { status: string; tasks: FileTask[] }) {
|
||||
const [collapsed, setCollapsed] = React.useState(status === "done")
|
||||
if (tasks.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full text-left mb-2 group"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
<span className={cn("text-xs font-semibold uppercase tracking-widest", SECTION_COLORS[status] ?? "text-zinc-400")}>
|
||||
{SECTION_LABELS[status] ?? status}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0 rounded-full">{tasks.length}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{collapsed ? "expand" : "collapse"}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5">
|
||||
{tasks.map((t) => <FileTaskRow key={t.id} task={t} />)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Queue task row (SQLite dispatch queue) ───────────────────────────────────
|
||||
|
||||
function QueueTaskRow({ task }: { task: QueueTask }) {
|
||||
const warn = isRouterFailure(task.agent_reason)
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-muted/30 transition-colors border border-dashed border-border/40">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate text-foreground/70">{task.title}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">Queued — waiting for Tiger</div>
|
||||
</div>
|
||||
{warn && (
|
||||
<span title={task.agent_reason ?? ""} className="shrink-0">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-400/80" />
|
||||
</span>
|
||||
)}
|
||||
<Badge className={cn("text-[10px] px-1.5 py-0 shrink-0", PRIORITY_COLORS[task.priority ?? "medium"])}>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TasksPage() {
|
||||
const { data: fileData, isLoading: fileLoading } = useSWR<{ ok: boolean; tasks: FileTask[] }>(
|
||||
"/api/tiger/file-tasks",
|
||||
fetcher,
|
||||
{ refreshInterval: 60_000 }
|
||||
)
|
||||
const { data: queueData, isLoading: queueLoading } = useSWR<{ ok: boolean; tasks: QueueTask[] }>(
|
||||
"/api/tiger/tasks",
|
||||
fetcher,
|
||||
{ refreshInterval: 30_000 }
|
||||
)
|
||||
|
||||
const fileTasks = fileData?.tasks ?? []
|
||||
// Only show SQLite tasks that are still in backlog (Tiger hasn't processed yet)
|
||||
const queueTasks = (queueData?.tasks ?? []).filter((t) => t.status === "backlog")
|
||||
|
||||
// Group Tiger's tasks by status
|
||||
const grouped = React.useMemo(() => {
|
||||
const g: Record<string, FileTask[]> = {}
|
||||
for (const t of fileTasks) {
|
||||
if (!g[t.status]) g[t.status] = []
|
||||
g[t.status].push(t)
|
||||
}
|
||||
return g
|
||||
}, [fileTasks])
|
||||
|
||||
const knownStatuses = new Set(SECTION_ORDER)
|
||||
const otherStatuses = Object.keys(grouped).filter((s) => !knownStatuses.has(s))
|
||||
const orderedStatuses = [...SECTION_ORDER, ...otherStatuses]
|
||||
|
||||
const inProgress = (grouped["in-progress"] ?? []).length
|
||||
const inReview = (grouped["review"] ?? []).length
|
||||
const done = (grouped["done"] ?? []).length
|
||||
const queued = queueTasks.length
|
||||
|
||||
const isLoading = fileLoading || queueLoading
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl mx-auto w-full p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<CheckSquare className="h-6 w-6 text-primary" />
|
||||
Tasks
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tiger's active work from <span className="font-mono text-xs">TASKS.md</span> — plus any dashboard-queued items waiting for pickup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
||||
{[
|
||||
{ label: "In Progress", value: inProgress, icon: GitBranch, color: "text-amber-400" },
|
||||
{ label: "In Review", value: inReview, icon: Bot, color: "text-purple-400" },
|
||||
{ label: "Done", value: done, icon: CheckSquare,color: "text-emerald-400" },
|
||||
{ label: "Queued", value: queued, icon: Inbox, color: "text-blue-400" },
|
||||
].map(({ label, value, icon: Icon, color }) => (
|
||||
<Card key={label} className="bg-card/40">
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-xl font-bold tabular-nums">{isLoading ? "—" : value}</p>
|
||||
</div>
|
||||
<Icon className={cn("h-4 w-4", color)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Primary: Tiger's TASKS.md */}
|
||||
{fileTasks.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<CheckSquare className="h-10 w-10 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm">Tiger's task list is empty — no active work tracked in TASKS.md.</p>
|
||||
</div>
|
||||
) : (
|
||||
orderedStatuses.map((status) =>
|
||||
grouped[status]?.length ? (
|
||||
<FileTaskSection key={status} status={status} tasks={grouped[status]} />
|
||||
) : null
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Secondary: Dashboard dispatch queue */}
|
||||
{queueTasks.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-border/40">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Inbox className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-blue-400">
|
||||
Dashboard Queue
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 rounded-full">
|
||||
{queueTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Dispatched from dashboard — waiting for Tiger to pick up.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{queueTasks.map((t) => <QueueTaskRow key={t.id} task={t} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,234 +1,223 @@
|
|||
/**
|
||||
* Workspace Page — File browser for Tiger agent's workspace
|
||||
* workspace/page.tsx — Per-agent file browser with preview
|
||||
*
|
||||
* Lists files from the Tiger Bridge's workspace API and provides
|
||||
* a file viewer for reading file contents.
|
||||
* Layout:
|
||||
* Agent chip row (top)
|
||||
* Tabs: [Files] [Activity]
|
||||
* Files: split-pane — file tree (left) + file preview (right)
|
||||
* Activity: recent cross-agent changes feed
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Folder, FileText, ChevronRight, Home, ArrowLeft, Loader2 } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useBridgeRequest } from "@/hooks/use-bridge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { AgentChipRow, AgentInfo } from "@/components/workspace/agent-chip-row"
|
||||
import { FileTree, FileItem } from "@/components/workspace/file-tree"
|
||||
import { FilePreview } from "@/components/workspace/file-preview"
|
||||
import { ActivityFeed, ActivityEvent } from "@/components/workspace/activity-feed"
|
||||
|
||||
interface WorkspaceFile {
|
||||
name: string
|
||||
type: "file" | "directory"
|
||||
size?: number
|
||||
modified?: string
|
||||
}
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface FileContent {
|
||||
ok: boolean
|
||||
path: string
|
||||
content: string
|
||||
encoding: "utf8" | "base64"
|
||||
size: number
|
||||
mime: string
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return "—"
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function apiFetch<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { cache: "no-store" })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
function getFileIcon(filename: string) {
|
||||
const ext = filename.split(".").pop()?.toLowerCase()
|
||||
const codeExts = ["ts", "tsx", "js", "jsx", "py", "sh", "json", "md", "yaml", "yml", "toml"]
|
||||
if (ext && codeExts.includes(ext)) return "code"
|
||||
if (ext === "md") return "markdown"
|
||||
return "text"
|
||||
}
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function WorkspacePage() {
|
||||
const { request, loading } = useBridgeRequest()
|
||||
// Agent list
|
||||
const [agents, setAgents] = React.useState<AgentInfo[]>([])
|
||||
const [agentsLoading, setAgentsLoading] = React.useState(true)
|
||||
|
||||
// Active agent selection (null = "All" → show Tiger/main)
|
||||
const [activeAgentId, setActiveAgentId] = React.useState<string | null>(null)
|
||||
|
||||
// File tree state
|
||||
const [treeItems, setTreeItems] = React.useState<FileItem[]>([])
|
||||
const [currentPath, setCurrentPath] = React.useState("")
|
||||
const [files, setFiles] = React.useState<WorkspaceFile[]>([])
|
||||
const [treeLoading, setTreeLoading] = React.useState(false)
|
||||
|
||||
// File preview state
|
||||
const [selectedFile, setSelectedFile] = React.useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = React.useState<FileContent | null>(null)
|
||||
const [loadingFiles, setLoadingFiles] = React.useState(false)
|
||||
const [loadingContent, setLoadingContent] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = React.useState(false)
|
||||
|
||||
// Load directory contents
|
||||
const loadDirectory = React.useCallback(async (path: string) => {
|
||||
setLoadingFiles(true)
|
||||
setError(null)
|
||||
try {
|
||||
const url = path ? `/api/tiger/workspace?path=${encodeURIComponent(path)}` : "/api/tiger/workspace"
|
||||
const data = await request(url) as { ok: boolean; files?: WorkspaceFile[] }
|
||||
if (data.ok && data.files) {
|
||||
setFiles(data.files)
|
||||
setCurrentPath(path)
|
||||
} else {
|
||||
setError("Failed to load directory")
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError("Failed to load workspace")
|
||||
} finally {
|
||||
setLoadingFiles(false)
|
||||
}
|
||||
}, [request])
|
||||
// Activity feed
|
||||
const [activityEvents, setActivityEvents] = React.useState<ActivityEvent[]>([])
|
||||
const [activityLoading, setActivityLoading] = React.useState(false)
|
||||
|
||||
// Load file content
|
||||
const loadFile = React.useCallback(async (filename: string) => {
|
||||
setLoadingContent(true)
|
||||
setSelectedFile(filename)
|
||||
try {
|
||||
// Need full path including current directory
|
||||
const fullPath = currentPath ? `${currentPath}/${filename}` : filename
|
||||
const url = `/api/tiger/workspace?path=${encodeURIComponent(fullPath)}&read=true`
|
||||
const data = await request(url) as FileContent
|
||||
setFileContent(data)
|
||||
} catch (e: unknown) {
|
||||
setFileContent({ ok: false, path: filename, content: "Failed to load file", size: 0 })
|
||||
} finally {
|
||||
setLoadingContent(false)
|
||||
}
|
||||
}, [request, currentPath])
|
||||
|
||||
// Initial load
|
||||
// ── Load agents on mount ──────────────────────────────────────────────────
|
||||
React.useEffect(() => {
|
||||
loadDirectory("")
|
||||
}, [loadDirectory])
|
||||
setAgentsLoading(true)
|
||||
apiFetch<{ ok: boolean; agents: AgentInfo[] }>("/api/tiger/agents")
|
||||
.then((data) => { if (data.ok) setAgents(data.agents) })
|
||||
.catch(console.error)
|
||||
.finally(() => setAgentsLoading(false))
|
||||
}, [])
|
||||
|
||||
const navigateTo = (path: string) => {
|
||||
// Derived: the "effective" agent to browse
|
||||
// "All" defaults to showing the orchestrator (main/Tiger)
|
||||
const effectiveAgentId = activeAgentId ?? "main"
|
||||
|
||||
// ── Load file tree when agent or path changes ─────────────────────────────
|
||||
const loadTree = React.useCallback((agentId: string, path: string) => {
|
||||
setTreeLoading(true)
|
||||
setSelectedFile(null)
|
||||
setFileContent(null)
|
||||
const url = path
|
||||
? `/api/tiger/agents/${agentId}/files?path=${encodeURIComponent(path)}`
|
||||
: `/api/tiger/agents/${agentId}/files`
|
||||
apiFetch<{ ok: boolean; items: FileItem[] }>(url)
|
||||
.then((data) => { if (data.ok) setTreeItems(data.items) })
|
||||
.catch(console.error)
|
||||
.finally(() => setTreeLoading(false))
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadTree(effectiveAgentId, currentPath)
|
||||
}, [effectiveAgentId, currentPath, loadTree])
|
||||
|
||||
// Reset path when agent changes
|
||||
const handleAgentChange = (id: string | null) => {
|
||||
setActiveAgentId(id)
|
||||
setCurrentPath("")
|
||||
setSelectedFile(null)
|
||||
setFileContent(null)
|
||||
loadDirectory(path)
|
||||
}
|
||||
|
||||
// Build breadcrumb path
|
||||
const breadcrumbs = currentPath ? currentPath.split("/").filter(Boolean) : []
|
||||
// ── Navigate into a directory ─────────────────────────────────────────────
|
||||
const handleNavigate = (path: string) => {
|
||||
setCurrentPath(path)
|
||||
}
|
||||
|
||||
// ── Load file content on selection ───────────────────────────────────────
|
||||
const handleSelectFile = React.useCallback((filePath: string) => {
|
||||
setSelectedFile(filePath)
|
||||
setPreviewLoading(true)
|
||||
const url = `/api/tiger/agents/${effectiveAgentId}/file?path=${encodeURIComponent(filePath)}`
|
||||
apiFetch<FileContent>(url)
|
||||
.then((data) => setFileContent(data))
|
||||
.catch(console.error)
|
||||
.finally(() => setPreviewLoading(false))
|
||||
}, [effectiveAgentId])
|
||||
|
||||
// ── Load activity feed ────────────────────────────────────────────────────
|
||||
const loadActivity = React.useCallback(() => {
|
||||
setActivityLoading(true)
|
||||
apiFetch<{ ok: boolean; events: ActivityEvent[] }>("/api/tiger/activity?limit=50")
|
||||
.then((data) => { if (data.ok) setActivityEvents(data.events) })
|
||||
.catch(console.error)
|
||||
.finally(() => setActivityLoading(false))
|
||||
}, [])
|
||||
|
||||
// Recently active agent ids (activity within last hour) for badge highlighting
|
||||
const recentIds = React.useMemo(() => {
|
||||
const cutoff = Date.now() - 60 * 60 * 1000
|
||||
return new Set(activityEvents.filter((e) => e.ts > cutoff).map((e) => e.agentId))
|
||||
}, [activityEvents])
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col gap-4 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Workspace</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse files in Tiger's workspace
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateTo("")}
|
||||
disabled={!currentPath}
|
||||
>
|
||||
<Home className="h-4 w-4 mr-2" />
|
||||
Root
|
||||
</Button>
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] gap-3 p-4">
|
||||
{/* Page title */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Workspace</h1>
|
||||
<p className="text-sm text-muted-foreground">Browse agent files and recent activity</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
{/* Agent chip row */}
|
||||
<AgentChipRow
|
||||
agents={agents}
|
||||
activeId={activeAgentId}
|
||||
onChange={handleAgentChange}
|
||||
recentIds={recentIds}
|
||||
/>
|
||||
|
||||
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0">
|
||||
{/* File List */}
|
||||
<Card className="col-span-1 bg-card/40 flex flex-col min-h-0">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Files</CardTitle>
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
|
||||
<button onClick={() => navigateTo("")} className="hover:text-foreground">root</button>
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<button onClick={() => navigateTo(breadcrumbs.slice(0, i + 1).join("/"))} className="hover:text-foreground">
|
||||
{crumb}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-y-auto">
|
||||
{loadingFiles ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">No files</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{/* Parent directory */}
|
||||
{currentPath && (
|
||||
<button
|
||||
onClick={() => navigateTo(breadcrumbs.slice(0, -1).join("/"))}
|
||||
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted text-left"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">..</span>
|
||||
</button>
|
||||
)}
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.name}
|
||||
onClick={() => file.type === "directory" ? navigateTo(currentPath ? `${currentPath}/${file.name}` : file.name) : loadFile(file.name)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted text-left",
|
||||
selectedFile === file.name && "bg-muted"
|
||||
)}
|
||||
>
|
||||
{file.type === "directory" ? (
|
||||
<Folder className="h-4 w-4 text-amber-400" />
|
||||
) : (
|
||||
<FileText className={cn(
|
||||
"h-4 w-4",
|
||||
getFileIcon(file.name) === "code" ? "text-blue-400" :
|
||||
getFileIcon(file.name) === "markdown" ? "text-purple-400" :
|
||||
"text-muted-foreground"
|
||||
)} />
|
||||
)}
|
||||
<span className="text-sm truncate flex-1">{file.name}</span>
|
||||
{file.size && <span className="text-xs text-muted-foreground">{formatSize(file.size)}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Tabs: Files | Activity */}
|
||||
<Tabs
|
||||
defaultValue="files"
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
onValueChange={(v) => { if (v === "activity") loadActivity() }}
|
||||
>
|
||||
<TabsList className="self-start">
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* File Viewer */}
|
||||
<Card className="col-span-2 bg-card/40 flex flex-col min-h-0">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">
|
||||
{selectedFile || "Select a file to view"}
|
||||
</CardTitle>
|
||||
{fileContent && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSize(fileContent.size)}
|
||||
{/* ── Files tab ─────────────────────────────────────────────────── */}
|
||||
<TabsContent value="files" className="flex-1 flex min-h-0 mt-2">
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-[280px_1fr] gap-3 min-h-0">
|
||||
{/* File tree panel */}
|
||||
<div className="border rounded-lg bg-card/40 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className="px-3 py-2 border-b shrink-0">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{agents.find((a) => a.id === effectiveAgentId)?.emoji}{" "}
|
||||
{agents.find((a) => a.id === effectiveAgentId)?.name ?? "Tiger"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-y-auto">
|
||||
{loadingContent ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<ScrollArea className="flex-1 p-2">
|
||||
<FileTree
|
||||
items={treeItems}
|
||||
currentPath={currentPath}
|
||||
selectedFile={selectedFile}
|
||||
onNavigate={handleNavigate}
|
||||
onSelectFile={handleSelectFile}
|
||||
loading={treeLoading}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Preview panel */}
|
||||
<div className="border rounded-lg bg-card/40 flex flex-col min-h-0 overflow-hidden">
|
||||
<FilePreview
|
||||
path={selectedFile}
|
||||
content={fileContent?.content ?? null}
|
||||
encoding={fileContent?.encoding ?? null}
|
||||
mime={fileContent?.mime ?? null}
|
||||
size={fileContent?.size ?? 0}
|
||||
loading={previewLoading}
|
||||
agentId={effectiveAgentId}
|
||||
onSaved={(_p, newContent) => {
|
||||
// Update cached content so the view reflects the save immediately
|
||||
setFileContent((prev) => prev ? { ...prev, content: newContent } : prev)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Activity tab ──────────────────────────────────────────────── */}
|
||||
<TabsContent value="activity" className="flex-1 min-h-0 mt-2">
|
||||
<div className="border rounded-lg bg-card/40 h-full overflow-hidden">
|
||||
<div className="px-3 py-2 border-b">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Recent changes — all agents
|
||||
</span>
|
||||
</div>
|
||||
<ScrollArea className="h-[calc(100%-2.5rem)]">
|
||||
<div className="p-2">
|
||||
<ActivityFeed events={activityEvents} loading={activityLoading} />
|
||||
</div>
|
||||
) : !selectedFile ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
Click a file to view its contents
|
||||
</div>
|
||||
) : fileContent ? (
|
||||
<pre className={cn(
|
||||
"text-xs font-mono whitespace-pre-wrap break-all p-3 rounded-md border",
|
||||
fileContent.ok ? "bg-background/50" : "bg-destructive/10 border-destructive"
|
||||
)}>
|
||||
{fileContent.content}
|
||||
</pre>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
148
dashboard/src/components/agent-strip.tsx
Normal file
148
dashboard/src/components/agent-strip.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* agent-strip.tsx — Live state of Tiger + sub-agents
|
||||
*
|
||||
* Replaces the old "Agent Model" card on the home page. Now you see ALL 5
|
||||
* agents at once with status, last activity, and file count.
|
||||
*
|
||||
* DATA SOURCE: /api/tiger/agents — already exists, returns:
|
||||
* { ok: true, agents: [{ id, name, emoji, role, fileCount, lastActivity }] }
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
role: string
|
||||
fileCount: number
|
||||
lastActivity: number
|
||||
}
|
||||
|
||||
interface AgentsResponse {
|
||||
ok: boolean
|
||||
agents: Agent[]
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
if (!ts) return "—"
|
||||
const diff = Date.now() - ts
|
||||
const m = Math.floor(diff / 60_000)
|
||||
if (m < 1) return "now"
|
||||
if (m < 60) return `${m}m`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d`
|
||||
}
|
||||
|
||||
function statusOf(ts: number): "active" | "recent" | "idle" {
|
||||
if (!ts) return "idle"
|
||||
const diff = Date.now() - ts
|
||||
if (diff < 5 * 60_000) return "active"
|
||||
if (diff < 60 * 60_000) return "recent"
|
||||
return "idle"
|
||||
}
|
||||
|
||||
const STATUS_DOT: Record<"active" | "recent" | "idle", string> = {
|
||||
active: "bg-green-500 animate-pulse",
|
||||
recent: "bg-amber-500",
|
||||
idle: "bg-zinc-500",
|
||||
}
|
||||
|
||||
export function AgentStrip() {
|
||||
const { data, error, isLoading } = useSWR<AgentsResponse>(
|
||||
"/api/tiger/agents",
|
||||
fetcher,
|
||||
{ refreshInterval: 30_000 }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>Agents</SectionLabel>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 snap-x">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="min-w-[140px] h-[80px] rounded-lg border border-border/50 bg-card/30 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data?.ok) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>Agents</SectionLabel>
|
||||
<div className="text-sm text-muted-foreground p-3 rounded-lg border border-border/50">
|
||||
Could not load agents. Bridge unreachable?
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const agents = data.agents ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<SectionLabel>Agents</SectionLabel>
|
||||
<a href="/agents" className="text-xs text-primary hover:underline">
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 snap-x snap-mandatory">
|
||||
{agents.map((agent) => {
|
||||
const status = statusOf(agent.lastActivity)
|
||||
return (
|
||||
<a
|
||||
key={agent.id}
|
||||
href={`/agents?id=${agent.id}`}
|
||||
className="snap-start shrink-0 min-w-[140px] p-3 rounded-lg border border-border/50 bg-card/40 hover:bg-card/60 hover:border-primary/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg leading-none">{agent.emoji}</span>
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{agent.name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
STATUS_DOT[status]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">
|
||||
{agent.role}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>last: {relativeTime(agent.lastActivity)}</span>
|
||||
<span className="tabular-nums">{agent.fileCount} files</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,37 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* app-sidebar.tsx — Tiger Command Center sidebar
|
||||
*
|
||||
* Phase 1 redesign. The new IA is built around the way YOU actually work:
|
||||
* Home — command + live state (the new front door)
|
||||
* Chat — unified web + Telegram thread (Phase 4 will wire Telegram)
|
||||
* Projects — orchestration containers; click a project to see tasks + agents
|
||||
* Agents — per-sub-agent detail and (Phase 3) per-agent model overrides
|
||||
* Knowledge — Tiger's brain: memory, skills, activity, scheduled jobs
|
||||
* Workspace — file tree of /sandbox + diffs
|
||||
* Positions — live P&L + open positions
|
||||
* Cost — finance-grade cost dashboard
|
||||
* Logs — raw streaming logs (dev drill-down)
|
||||
* Settings
|
||||
*
|
||||
* Key change from the previous sidebar: no orphan pages. Memory, Sessions,
|
||||
* Skills, Activity, and Cron are now reachable through Knowledge / Agents /
|
||||
* Chat instead of being floating routes nobody could navigate to.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Bot,
|
||||
Settings2,
|
||||
LayoutDashboard,
|
||||
ScrollText,
|
||||
CheckSquare,
|
||||
DollarSign,
|
||||
FolderOpen,
|
||||
Home,
|
||||
MessageSquare,
|
||||
Briefcase,
|
||||
Bot,
|
||||
Brain,
|
||||
FolderOpen,
|
||||
BarChart2,
|
||||
DollarSign,
|
||||
ScrollText,
|
||||
Settings2,
|
||||
} from "lucide-react"
|
||||
import { useTigerLogs } from "@/hooks/use-bridge"
|
||||
|
||||
|
|
@ -25,56 +46,29 @@ import {
|
|||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
// Tiger-specific navigation - no more old clawdbot pages
|
||||
// Primary navigation — the main verbs of using Tiger.
|
||||
// Ordered by frequency-of-use so the most-tapped items sit at the top.
|
||||
const navMain = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: "Projects",
|
||||
url: "/projects",
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
title: "Workspace",
|
||||
url: "/workspace",
|
||||
icon: FolderOpen,
|
||||
},
|
||||
{
|
||||
title: "Tasks",
|
||||
url: "/tasks",
|
||||
icon: CheckSquare,
|
||||
},
|
||||
{
|
||||
title: "Positions",
|
||||
url: "/positions",
|
||||
icon: BarChart2,
|
||||
},
|
||||
{
|
||||
title: "Cost Monitor",
|
||||
url: "/cost",
|
||||
icon: DollarSign,
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: "/logs",
|
||||
icon: ScrollText,
|
||||
},
|
||||
{ title: "Home", url: "/", icon: Home },
|
||||
{ title: "Chat", url: "/chat", icon: MessageSquare },
|
||||
{ title: "Projects", url: "/projects", icon: Briefcase },
|
||||
{ title: "Agents", url: "/agents", icon: Bot },
|
||||
{ title: "Knowledge", url: "/knowledge", icon: Brain },
|
||||
{ title: "Workspace", url: "/workspace", icon: FolderOpen },
|
||||
{ title: "Positions", url: "/positions", icon: BarChart2 },
|
||||
{ title: "Activity", url: "/activity", icon: ScrollText },
|
||||
{ title: "Cost", url: "/cost", icon: DollarSign },
|
||||
{ title: "Logs", url: "/logs", icon: ScrollText },
|
||||
]
|
||||
|
||||
// Secondary navigation — sits in the footer, less-frequent admin stuff.
|
||||
const navSecondary = [
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/settings",
|
||||
icon: Settings2,
|
||||
},
|
||||
{ title: "Settings", url: "/settings", icon: Settings2 },
|
||||
]
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
// Use Tiger logs SSE for connection status
|
||||
// connected means the bridge is reachable
|
||||
// Use the existing log stream as a heartbeat — if it's connected, the bridge
|
||||
// is reachable, so we're "Live". If not, the dot goes red.
|
||||
const { connected } = useTigerLogs({ lines: 1, maxLines: 1 })
|
||||
|
||||
return (
|
||||
|
|
@ -84,47 +78,58 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<a href="/">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<Bot className="size-4" />
|
||||
{/* Tiger badge — same gradient T as the favicon for brand consistency */}
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-orange-500/15 border border-orange-500/30">
|
||||
<span className="text-orange-400 font-bold text-base">T</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className="text-xs text-muted-foreground">{connected ? "Live" : "Offline"}</span>
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span className="text-sm font-semibold">Tiger</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${
|
||||
connected ? "bg-green-500 animate-pulse" : "bg-red-500"
|
||||
}`} />
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
{connected ? "Live" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarMenu className="gap-2 p-2">
|
||||
{navMain.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenu className="gap-1 p-2">
|
||||
{navMain.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
{navSecondary.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild size="sm">
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenu>
|
||||
{navSecondary.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild size="sm" tooltip={item.title}>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,159 +1,52 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Send, Square, Bot, User, AlertCircle, Loader2 } from "lucide-react"
|
||||
import {
|
||||
Send, Square, Bot, User, AlertCircle, Loader2, Eraser,
|
||||
Plus, ChevronDown, Trash2,
|
||||
} from "lucide-react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useGatewayRequest, useGatewayEvents } from "@/hooks/use-gateway"
|
||||
|
||||
type Message = {
|
||||
id: string
|
||||
role: "user" | "agent" | "system"
|
||||
content: string
|
||||
streaming?: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
function extractContent(content: unknown): string {
|
||||
if (typeof content === "string") return content
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block: unknown) => {
|
||||
if (typeof block === "string") return block
|
||||
if (block && typeof block === "object" && "text" in block) return String((block as { text: string }).text)
|
||||
return ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
if (content && typeof content === "object") {
|
||||
const obj = content as Record<string, unknown>
|
||||
if ("text" in obj) return String(obj.text)
|
||||
if ("content" in obj) return extractContent(obj.content)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
import { useChatContext } from "@/contexts/chat-context"
|
||||
|
||||
export function ChatInterface({ className, ...props }: React.ComponentProps<typeof Card>) {
|
||||
const [input, setInput] = React.useState("")
|
||||
const [messages, setMessages] = React.useState<Message[]>([])
|
||||
const {
|
||||
messages, setMessages, clearChat,
|
||||
currentSessionKey, sessions, selectSession, newSession, deleteSession, refreshSessions,
|
||||
} = useChatContext()
|
||||
const [sending, setSending] = React.useState(false)
|
||||
const [agentTyping, setAgentTyping] = React.useState(false)
|
||||
const [dropdownOpen, setDropdownOpen] = React.useState(false)
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null)
|
||||
const { request } = useGatewayRequest()
|
||||
const streamingContentRef = React.useRef("")
|
||||
const abortRef = React.useRef<AbortController | null>(null)
|
||||
const streamingRef = React.useRef("")
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
// Load chat history on mount
|
||||
React.useEffect(() => {
|
||||
request("chat.history", { sessionKey: "agent:main:main", limit: 50 })
|
||||
.then((data: unknown) => {
|
||||
const history = data as { messages?: Array<{ role: string; content: unknown; ts?: number }> }
|
||||
if (history?.messages?.length) {
|
||||
setMessages(
|
||||
history.messages.map((m, i) => ({
|
||||
id: `hist-${i}`,
|
||||
role: m.role === "user" ? "user" : "agent",
|
||||
content: extractContent(m.content),
|
||||
timestamp: m.ts || Date.now(),
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setMessages([{
|
||||
id: "welcome",
|
||||
role: "agent",
|
||||
content: "Connected to Tarzan via gateway. Send a message to start chatting.",
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Subscribe to gateway events for streaming responses
|
||||
const { connected } = useGatewayEvents((event, payload) => {
|
||||
const data = payload as Record<string, unknown>
|
||||
|
||||
if (event === "chat") {
|
||||
// Incoming chat message (from other channels or agent completion)
|
||||
const role = (data.role as string) === "user" ? "user" : "agent"
|
||||
const content = extractContent(data.text || data.content || "")
|
||||
if (!content) return
|
||||
|
||||
setMessages(prev => {
|
||||
// Remove any streaming message and add final
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
return [...filtered, {
|
||||
id: `chat-${Date.now()}`,
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
setAgentTyping(false)
|
||||
streamingContentRef.current = ""
|
||||
}
|
||||
|
||||
if (event === "agent") {
|
||||
// Streaming agent response chunks
|
||||
const chunk = data.chunk as string | undefined
|
||||
const done = data.done as boolean | undefined
|
||||
const text = data.text as string | undefined
|
||||
|
||||
if (chunk || text) {
|
||||
streamingContentRef.current += (chunk || text || "")
|
||||
setAgentTyping(true)
|
||||
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
return [...filtered, {
|
||||
id: "streaming",
|
||||
role: "agent",
|
||||
content: streamingContentRef.current,
|
||||
streaming: true,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
if (done) {
|
||||
setAgentTyping(false)
|
||||
setSending(false)
|
||||
// Finalize streaming message
|
||||
if (streamingContentRef.current) {
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
return [...filtered, {
|
||||
id: `agent-${Date.now()}`,
|
||||
role: "agent",
|
||||
content: streamingContentRef.current,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
}
|
||||
streamingContentRef.current = ""
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-scroll to bottom
|
||||
React.useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Close dropdown on outside click
|
||||
React.useEffect(() => {
|
||||
if (!dropdownOpen) return
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onClick)
|
||||
return () => document.removeEventListener("mousedown", onClick)
|
||||
}, [dropdownOpen])
|
||||
|
||||
const currentLabel = sessions.find(s => s.key === currentSessionKey)?.label
|
||||
|| (currentSessionKey === "agent:main:main" ? "Main" : "Chat")
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!input.trim() || sending) return
|
||||
|
|
@ -161,9 +54,8 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
const text = input.trim()
|
||||
setInput("")
|
||||
setSending(true)
|
||||
streamingContentRef.current = ""
|
||||
streamingRef.current = ""
|
||||
|
||||
// Add user message immediately
|
||||
setMessages(prev => [...prev, {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
|
|
@ -172,45 +64,200 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
}])
|
||||
|
||||
try {
|
||||
await request("chat.send", {
|
||||
sessionKey: "agent:main:main",
|
||||
message: text,
|
||||
idempotencyKey: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: text, sessionKey: currentSessionKey }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
// Response will come via SSE events (agent/chat events)
|
||||
} catch {
|
||||
|
||||
if (!res.ok || !res.body) throw new Error("Failed to connect")
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
const streamId = `streaming-${Date.now()}`
|
||||
|
||||
while (true) {
|
||||
const { done: readerDone, value } = await reader.read()
|
||||
if (readerDone) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const events = buffer.split("\n\n")
|
||||
buffer = events.pop() || ""
|
||||
|
||||
for (const eventBlock of events) {
|
||||
const dataLine = eventBlock.split("\n").find(l => l.startsWith("data: "))
|
||||
if (!dataLine) continue
|
||||
|
||||
let data: { type: string; content?: string }
|
||||
try {
|
||||
data = JSON.parse(dataLine.slice(6))
|
||||
} catch (err) {
|
||||
console.warn("[chat] SSE parse error:", err, "line:", dataLine)
|
||||
continue
|
||||
}
|
||||
|
||||
if (data.type === "status") {
|
||||
// Show streaming placeholder so the typing indicator appears.
|
||||
setMessages(prev => {
|
||||
if (prev.some(m => m.streaming)) return prev
|
||||
return [...prev, {
|
||||
id: streamId,
|
||||
role: "agent",
|
||||
content: "",
|
||||
streaming: true,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
} else if (data.type === "chunk") {
|
||||
streamingRef.current += data.content || ""
|
||||
setMessages(prev => {
|
||||
const existing = prev.find(m => m.streaming)
|
||||
if (existing) {
|
||||
return prev.map(m =>
|
||||
m.streaming ? { ...m, content: streamingRef.current } : m
|
||||
)
|
||||
}
|
||||
return [...prev, {
|
||||
id: streamId,
|
||||
role: "agent",
|
||||
content: streamingRef.current,
|
||||
streaming: true,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
} else if (data.type === "done") {
|
||||
const finalContent = streamingRef.current || data.content || ""
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
if (!finalContent) return filtered
|
||||
return [...filtered, {
|
||||
id: `agent-${Date.now()}`,
|
||||
role: "agent",
|
||||
content: finalContent,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
streamingRef.current = ""
|
||||
setSending(false)
|
||||
// Refresh session list so updatedAt and messageCount reflect this turn.
|
||||
refreshSessions()
|
||||
} else if (data.type === "error") {
|
||||
setMessages(prev => [...prev.filter(m => !m.streaming), {
|
||||
id: `err-${Date.now()}`,
|
||||
role: "system",
|
||||
content: data.content || "Something went wrong",
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name !== "AbortError") {
|
||||
setMessages(prev => [...prev.filter(m => !m.streaming), {
|
||||
id: `err-${Date.now()}`,
|
||||
role: "system",
|
||||
content: "Failed to send message. Is Tiger running?",
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
}
|
||||
setSending(false)
|
||||
setMessages(prev => [...prev, {
|
||||
id: `err-${Date.now()}`,
|
||||
role: "system",
|
||||
content: "Failed to send message. Is the gateway running?",
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
}
|
||||
abortRef.current = null
|
||||
}
|
||||
|
||||
const handleAbort = async () => {
|
||||
try {
|
||||
await request("chat.abort", { sessionKey: "agent:main:main" })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const handleAbort = () => {
|
||||
abortRef.current?.abort()
|
||||
setSending(false)
|
||||
setAgentTyping(false)
|
||||
streamingRef.current = ""
|
||||
setMessages(prev => prev.filter(m => !m.streaming))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("w-full flex flex-col", className)} {...props}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bot className="h-5 w-5" />
|
||||
Chat
|
||||
<span className={cn(
|
||||
"ml-auto h-2 w-2 rounded-full",
|
||||
connected ? "bg-green-500" : "bg-red-500"
|
||||
)} />
|
||||
</CardTitle>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base min-w-0">
|
||||
<Bot className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="truncate">Chat with Tiger</span>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Sessions dropdown — flavor C: button + dropdown */}
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDropdownOpen(o => !o)}
|
||||
className="h-7 text-xs gap-1 px-2"
|
||||
title="Switch chat session"
|
||||
>
|
||||
<span className="max-w-[100px] truncate">{currentLabel}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 top-8 z-20 min-w-[220px] bg-popover border rounded-md shadow-lg overflow-hidden">
|
||||
<div className="py-1 max-h-[280px] overflow-y-auto">
|
||||
{sessions.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">No sessions yet</div>
|
||||
)}
|
||||
{sessions.map(s => (
|
||||
<div
|
||||
key={s.key}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 px-3 py-2 text-xs hover:bg-accent cursor-pointer",
|
||||
s.key === currentSessionKey && "bg-accent"
|
||||
)}
|
||||
onClick={() => { selectSession(s.key); setDropdownOpen(false) }}
|
||||
>
|
||||
<span className="truncate">{s.label}</span>
|
||||
{!s.isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`Delete session "${s.label}"?`)) deleteSession(s.key)
|
||||
}}
|
||||
title="Delete session"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => newSession()}
|
||||
className="h-7 px-2"
|
||||
title="Start a new chat session"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearChat}
|
||||
className="h-7 px-2 text-muted-foreground"
|
||||
title="Clear current conversation"
|
||||
>
|
||||
<Eraser className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 p-0 min-h-0">
|
||||
<ScrollArea className="h-[500px] px-4" ref={scrollRef}>
|
||||
<div className="space-y-4 py-2">
|
||||
|
|
@ -235,22 +282,24 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: message.role === "system"
|
||||
? "bg-destructive/10 text-destructive flex items-center gap-2 w-full justify-center"
|
||||
: "bg-muted",
|
||||
message.streaming ? "border border-primary/30" : ""
|
||||
)}
|
||||
>
|
||||
<div className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: message.role === "system"
|
||||
? "bg-destructive/10 text-destructive flex items-center gap-2 w-full justify-center"
|
||||
: "bg-muted",
|
||||
message.streaming ? "border border-primary/30" : ""
|
||||
)}>
|
||||
{message.role === "system" && <AlertCircle className="h-3 w-3" />}
|
||||
{message.role === "agent" ? (
|
||||
<div className="prose prose-sm prose-invert max-w-none [&>p]:m-0">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
message.streaming ? (
|
||||
<div className="whitespace-pre-wrap">{message.content}</div>
|
||||
) : (
|
||||
<div className="prose prose-sm prose-invert max-w-none [&>p]:m-0">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
message.content
|
||||
)}
|
||||
|
|
@ -260,7 +309,7 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
{agentTyping && !messages.some(m => m.streaming) && (
|
||||
{sending && !messages.some(m => m.streaming) && (
|
||||
<div className="flex gap-2">
|
||||
<div className="h-7 w-7 rounded-full bg-muted flex items-center justify-center">
|
||||
<Bot className="h-4 w-4" />
|
||||
|
|
@ -276,19 +325,19 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
<CardFooter className="pt-3">
|
||||
<form onSubmit={handleSubmit} className="flex w-full items-center space-x-2">
|
||||
<Input
|
||||
placeholder={connected ? "Message Tarzan..." : "Gateway offline..."}
|
||||
placeholder="Message Tiger..."
|
||||
className="flex-1"
|
||||
autoComplete="off"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
disabled={!connected || sending}
|
||||
disabled={sending}
|
||||
/>
|
||||
{sending ? (
|
||||
<Button type="button" size="icon" variant="destructive" onClick={handleAbort}>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" size="icon" disabled={!input.trim() || !connected}>
|
||||
<Button type="submit" size="icon" disabled={!input.trim()}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
136
dashboard/src/components/command-bar.tsx
Normal file
136
dashboard/src/components/command-bar.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* command-bar.tsx — The new home-page hero
|
||||
*
|
||||
* This is the single most important UI change in the redesign.
|
||||
* The old home page told you Tiger was alive. This one lets you tell
|
||||
* Tiger what to do. That shift — from monitoring to commanding —
|
||||
* is the whole reason Phase 1 exists.
|
||||
*
|
||||
* BEHAVIOR:
|
||||
* - User types a prompt and hits Enter (or clicks Send).
|
||||
* - We POST to /api/chat which already exists and routes to the bridge.
|
||||
* - On success we redirect to /chat so the user can watch the response.
|
||||
* - The chips below the input are auto-derived prompts. Phase 1 ships with
|
||||
* sensible defaults; Phase 2 will swap those for SQL-aggregated frequent
|
||||
* prompts from the chat_messages table.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import { Send, Loader2 } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import useSWR from "swr"
|
||||
|
||||
// Hard-coded for Phase 1. Phase 2 replaces this with real history aggregation.
|
||||
const FALLBACK_PROMPTS = [
|
||||
"Morning digest",
|
||||
"Pull latest CERC orders",
|
||||
"Status report",
|
||||
"Plan a new project",
|
||||
]
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
fetch(url).then((r) => (r.ok ? r.json() : null))
|
||||
|
||||
interface FrequentPromptsResponse {
|
||||
ok: boolean
|
||||
prompts: string[]
|
||||
}
|
||||
|
||||
export function CommandBar() {
|
||||
const [value, setValue] = React.useState("")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
|
||||
// Frequent prompts — gracefully degrades if the endpoint doesn't exist yet.
|
||||
const { data } = useSWR<FrequentPromptsResponse>(
|
||||
"/api/tiger/prompts/frequent",
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval: 5 * 60_000,
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
)
|
||||
|
||||
const chips =
|
||||
data?.ok && data.prompts && data.prompts.length > 0
|
||||
? data.prompts.slice(0, 4)
|
||||
: FALLBACK_PROMPTS
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || submitting) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: trimmed }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = "/chat"
|
||||
} else {
|
||||
console.error("Submit failed:", res.status, await res.text())
|
||||
setSubmitting(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Submit threw:", e)
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card/50 border-primary/20 p-4 md:p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tell Tiger…"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKey}
|
||||
disabled={submitting}
|
||||
className="flex-1 bg-transparent outline-none text-base md:text-lg placeholder:text-muted-foreground/60 disabled:opacity-50"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
disabled={!value.trim() || submitting}
|
||||
className="shrink-0"
|
||||
aria-label="Send to Tiger"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={chip}
|
||||
type="button"
|
||||
onClick={() => setValue(chip)}
|
||||
disabled={submitting}
|
||||
className="text-xs px-3 py-1.5 rounded-full bg-muted/50 hover:bg-muted/80 text-muted-foreground hover:text-foreground border border-border/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -187,7 +187,7 @@ export function CostMonitor() {
|
|||
<YAxis tick={{fontSize: 10}} stroke="#6b7280" tickFormatter={(v) => `$${v.toFixed(2)}`} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: 'none', borderRadius: '6px' }}
|
||||
formatter={(value: number) => [`$${value.toFixed(4)}`, 'Cost']}
|
||||
formatter={(value) => [`$${Number(value).toFixed(4)}`, 'Cost']}
|
||||
/>
|
||||
<Area type="monotone" dataKey="cost" stroke="#10b981" fillOpacity={1} fill="url(#colorCost)" />
|
||||
</AreaChart>
|
||||
|
|
@ -217,9 +217,9 @@ export function CostMonitor() {
|
|||
<YAxis type="category" dataKey="name" tick={{fontSize: 10}} stroke="#6b7280" width={100} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: 'none', borderRadius: '6px' }}
|
||||
formatter={(value: number, _name: string, props: any) => {
|
||||
formatter={(value, _name, props) => {
|
||||
const model = props?.payload?.fullModel || ''
|
||||
return [`$${value.toFixed(4)}`, model]
|
||||
return [`$${Number(value).toFixed(4)}`, model]
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="cost" fill="#8b5cf6" radius={[0, 4, 4, 0]}>
|
||||
|
|
|
|||
112
dashboard/src/components/digest-card.tsx
Normal file
112
dashboard/src/components/digest-card.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"use client"
|
||||
|
||||
import { bridgeGet } from "@/lib/bridge"
|
||||
|
||||
/**
|
||||
* digest-card.tsx — Today's digest of agent activity
|
||||
*
|
||||
* The home page used to make you click into /logs or /activity to see what
|
||||
* Tiger had been doing. Now it surfaces directly. Live "what just happened"
|
||||
* feed driven by /api/tiger/agents-activity.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Activity } from "lucide-react"
|
||||
|
||||
interface ActivityEvent {
|
||||
agentId: string
|
||||
agentName: string
|
||||
agentEmoji: string
|
||||
path: string
|
||||
action: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
interface ActivityResponse {
|
||||
ok: boolean
|
||||
events: ActivityEvent[]
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => bridgeGet(url).then((r) => r.json())
|
||||
|
||||
function relTime(ts: number): string {
|
||||
if (!ts) return ""
|
||||
const diff = Date.now() - ts
|
||||
const m = Math.floor(diff / 60_000)
|
||||
if (m < 1) return "just now"
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d ago`
|
||||
}
|
||||
|
||||
// Truncate from the LEFT so the filename stays readable.
|
||||
function leftTruncate(s: string, max = 40): string {
|
||||
if (s.length <= max) return s
|
||||
return "…" + s.slice(-(max - 1))
|
||||
}
|
||||
|
||||
export function DigestCard() {
|
||||
const { data, isLoading } = useSWR<ActivityResponse>(
|
||||
"/api/tiger/agents-activity",
|
||||
fetcher,
|
||||
{ refreshInterval: 60_000 }
|
||||
)
|
||||
|
||||
const events = React.useMemo(() => {
|
||||
const list = data?.events ?? []
|
||||
return [...list].sort((a, b) => b.ts - a.ts).slice(0, 6)
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<Card className="bg-card/40 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">
|
||||
Today's digest
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-6 rounded bg-muted/30 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||
No activity yet today.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{events.map((ev, i) => (
|
||||
<li key={`${ev.ts}-${i}`} className="flex items-start gap-2 text-sm">
|
||||
<span className="shrink-0 text-base leading-snug" aria-hidden>
|
||||
{ev.agentEmoji}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="font-mono text-xs text-foreground/90 truncate"
|
||||
title={ev.path}
|
||||
>
|
||||
{leftTruncate(ev.path)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{ev.agentName} {ev.action}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums pt-0.5">
|
||||
{relTime(ev.ts)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
186
dashboard/src/components/schedule-card.tsx
Normal file
186
dashboard/src/components/schedule-card.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* ScheduleCard — shows Tiger's cron jobs + scheduler status
|
||||
* Displayed on the dashboard home page.
|
||||
*
|
||||
* Fetches GET /api/tiger/cron → { ok, jobs[], status{} }
|
||||
* Each job row shows: name, schedule, next run, last run status, "Run now" button
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { Clock, Play, CheckCircle2, XCircle, Loader2, CalendarClock, RefreshCw } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface CronJob {
|
||||
id: string
|
||||
name?: string
|
||||
label?: string
|
||||
schedule: string
|
||||
enabled: boolean
|
||||
nextRun?: string
|
||||
lastRun?: { status: string; at: string }
|
||||
}
|
||||
|
||||
interface CronStatus {
|
||||
running?: boolean
|
||||
nextCheck?: string
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
function relTime(iso: string | undefined): string {
|
||||
if (!iso) return "—"
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return iso
|
||||
const diff = d.getTime() - Date.now()
|
||||
const abs = Math.abs(diff)
|
||||
const m = Math.floor(abs / 60_000)
|
||||
const h = Math.floor(m / 60)
|
||||
const suffix = diff > 0 ? "from now" : "ago"
|
||||
if (m < 1) return diff > 0 ? "imminent" : "just now"
|
||||
if (m < 60) return `${m}m ${suffix}`
|
||||
if (h < 24) return `${h}h ${suffix}`
|
||||
return `${Math.floor(h / 24)}d ${suffix}`
|
||||
}
|
||||
|
||||
export function ScheduleCard() {
|
||||
const { data, isLoading, mutate } = useSWR<{
|
||||
ok: boolean
|
||||
jobs: CronJob[]
|
||||
status: CronStatus
|
||||
}>("/api/tiger/cron", fetcher, { refreshInterval: 60_000 })
|
||||
|
||||
const [running, setRunning] = React.useState<string | null>(null)
|
||||
const [runMsg, setRunMsg] = React.useState<Record<string, string>>({})
|
||||
|
||||
const jobs = data?.jobs ?? []
|
||||
const schedulerRunning = data?.status?.running ?? false
|
||||
|
||||
const handleRunNow = async (jobId: string) => {
|
||||
setRunning(jobId)
|
||||
try {
|
||||
const res = await fetch(`/api/tiger/cron/${jobId}/run`, { method: "POST" })
|
||||
const d = await res.json()
|
||||
setRunMsg((m) => ({ ...m, [jobId]: d.ok ? "triggered" : (d.error ?? "failed") }))
|
||||
setTimeout(() => setRunMsg((m) => { const n = { ...m }; delete n[jobId]; return n }), 4000)
|
||||
} catch {
|
||||
setRunMsg((m) => ({ ...m, [jobId]: "error" }))
|
||||
} finally {
|
||||
setRunning(null)
|
||||
mutate()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card/40">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<CalendarClock className="h-4 w-4 text-muted-foreground" />
|
||||
Schedule
|
||||
{/* Scheduler health dot */}
|
||||
<span
|
||||
title={schedulerRunning ? "Scheduler active" : "Scheduler offline"}
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
isLoading ? "bg-zinc-500" : schedulerRunning ? "bg-green-500" : "bg-red-500"
|
||||
)}
|
||||
/>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => mutate()}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<Clock className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
||||
<p className="text-xs">No cron jobs configured.</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
Use <span className="font-mono">openclaw cron add</span> to schedule Tiger.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{jobs.map((job) => {
|
||||
const label = job.name ?? job.label ?? job.id
|
||||
const lastStatus = job.lastRun?.status?.toLowerCase() ?? ""
|
||||
const isOk = lastStatus === "success" || lastStatus === "ok" || lastStatus === "done"
|
||||
const isFail= lastStatus === "failed" || lastStatus === "error"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-md transition-colors",
|
||||
job.enabled ? "hover:bg-muted/30" : "opacity-50"
|
||||
)}
|
||||
>
|
||||
{/* Last run status icon */}
|
||||
{isOk && <CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 shrink-0" />}
|
||||
{isFail && <XCircle className="h-3.5 w-3.5 text-red-400 shrink-0" />}
|
||||
{!isOk && !isFail && (
|
||||
<Clock className="h-3.5 w-3.5 text-zinc-500 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Name + schedule */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{label}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono">{job.schedule}</div>
|
||||
</div>
|
||||
|
||||
{/* Next run */}
|
||||
<div className="text-[10px] text-muted-foreground shrink-0 text-right">
|
||||
{job.nextRun ? (
|
||||
<>
|
||||
<div className="text-foreground/60">{relTime(job.nextRun)}</div>
|
||||
<div className="opacity-50">next</div>
|
||||
</>
|
||||
) : job.lastRun?.at ? (
|
||||
<>
|
||||
<div className="text-foreground/60">{relTime(job.lastRun.at)}</div>
|
||||
<div className="opacity-50">last run</div>
|
||||
</>
|
||||
) : "—"}
|
||||
</div>
|
||||
|
||||
{/* Run now */}
|
||||
{job.enabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => handleRunNow(job.id)}
|
||||
disabled={running === job.id}
|
||||
title={runMsg[job.id] ?? "Run now"}
|
||||
>
|
||||
{running === job.id
|
||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: <Play className="h-3 w-3" />
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
134
dashboard/src/components/status-footer.tsx
Normal file
134
dashboard/src/components/status-footer.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* status-footer.tsx — Thin status strip at the bottom of the home page
|
||||
*
|
||||
* The OLD home page made "is Tiger alive" the headline. The NEW home page
|
||||
* relegates it to a footer strip. When something IS wrong, the strip
|
||||
* promotes itself into a banner with a Restart button.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AlertCircle, RefreshCw, Loader2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useBridgeRequest } from "@/hooks/use-bridge"
|
||||
|
||||
interface TigerStatus {
|
||||
status: "online" | "degraded" | "offline"
|
||||
container: {
|
||||
status: string
|
||||
exitCode: number
|
||||
startedAt: string
|
||||
}
|
||||
openclaw: { running: boolean }
|
||||
system: { memoryUsagePct: number; memoryTotalMb: number }
|
||||
agent: { currentModel: string }
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
function uptimeShort(startedAt: string): string {
|
||||
if (!startedAt) return "—"
|
||||
const start = new Date(startedAt).getTime()
|
||||
const diff = Date.now() - start
|
||||
const m = Math.floor(diff / 60_000)
|
||||
if (m < 60) return `${m}m`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d`
|
||||
}
|
||||
|
||||
function shortModel(m: string): string {
|
||||
if (!m) return "—"
|
||||
const parts = m.split("/")
|
||||
return parts[parts.length - 1].replace(/:.*$/, "")
|
||||
}
|
||||
|
||||
export function StatusFooter() {
|
||||
const { data, error } = useSWR<TigerStatus>("/api/tiger/status", fetcher, {
|
||||
refreshInterval: 10_000,
|
||||
})
|
||||
|
||||
const { request } = useBridgeRequest()
|
||||
const [restarting, setRestarting] = React.useState(false)
|
||||
|
||||
const isCrashed = data?.container?.exitCode === 255
|
||||
const isOffline = error || data?.status === "offline"
|
||||
|
||||
const handleRestart = async () => {
|
||||
setRestarting(true)
|
||||
try {
|
||||
await request("/api/tiger/restart", "POST")
|
||||
setTimeout(() => setRestarting(false), 3000)
|
||||
} catch (e) {
|
||||
console.error("Restart failed:", e)
|
||||
setRestarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isCrashed) {
|
||||
return (
|
||||
<div className="p-3 rounded-md bg-red-500/10 border border-red-500/30 text-red-400 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span className="font-medium">Tiger crashed</span>
|
||||
<span className="text-red-400/80 text-xs">
|
||||
(exit 255 — {shortModel(data?.agent?.currentModel || "")} unreachable)
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10 h-7"
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3 mr-1.5" />
|
||||
)}
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isOffline) {
|
||||
return (
|
||||
<div className="p-3 rounded-md bg-amber-500/10 border border-amber-500/30 text-amber-400 flex items-center gap-2 text-sm">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>Bridge unreachable. Status data may be stale.</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const dotClass =
|
||||
data?.status === "online" ? "bg-green-500" :
|
||||
data?.status === "degraded" ? "bg-amber-500" :
|
||||
"bg-zinc-500"
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-4 py-2 rounded-md bg-card/30 border border-border/40 text-xs text-muted-foreground flex-wrap">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn("h-2 w-2 rounded-full", dotClass)} />
|
||||
<span>up {uptimeShort(data?.container?.startedAt || "")}</span>
|
||||
</span>
|
||||
|
||||
<span className="tabular-nums">
|
||||
{data?.system?.memoryUsagePct ?? 0}% mem
|
||||
</span>
|
||||
|
||||
<span className="font-mono text-[11px]">
|
||||
{shortModel(data?.agent?.currentModel || "")}
|
||||
</span>
|
||||
|
||||
<span className="ml-auto text-muted-foreground/60">
|
||||
₹— today
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -72,14 +72,15 @@ export function KanbanBoard() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Load tasks from API
|
||||
// Load tasks from TASKS.md via /api/tiger/file-tasks
|
||||
const loadTasks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await request("/api/tiger/tasks") as { ok: boolean; tasks?: Task[] }
|
||||
const data = await request("/api/tiger/file-tasks") as { ok: boolean; tasks?: any[] }
|
||||
if (data.ok && data.tasks) {
|
||||
setTasks(data.tasks.map((t: Task) => ({
|
||||
setTasks(data.tasks.map((t: any) => ({
|
||||
...t,
|
||||
content: t.title || t.content || t.description || "", // map title to content for kanban
|
||||
tags: typeof t.tags === "string" ? JSON.parse(t.tags || "[]") : t.tags || [],
|
||||
})))
|
||||
}
|
||||
|
|
@ -443,13 +444,13 @@ export function KanbanBoard() {
|
|||
id: editingTask.id,
|
||||
title: editingTask.title,
|
||||
description: editingTask.description,
|
||||
status: editingTask.status,
|
||||
status: editingTask.status as string,
|
||||
priority: editingTask.priority,
|
||||
assigned_agent: editingTask.assigned_agent,
|
||||
progress: editingTask.progress,
|
||||
tags: editingTask.tags,
|
||||
} : null}
|
||||
onSubmit={editingTask ? handleUpdateTask : handleAddTask}
|
||||
onSubmit={editingTask ? handleUpdateTask as any : handleAddTask}
|
||||
onDelete={editingTask ? () => handleDeleteTask(editingTask.id) : undefined}
|
||||
onRun={editingTask ? () => handleRunTask(editingTask.id) : undefined}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ interface TaskDialogProps {
|
|||
onSubmit: (data: {
|
||||
title: string
|
||||
description?: string
|
||||
status: string
|
||||
priority: string
|
||||
assigned_agent?: string
|
||||
status?: string
|
||||
priority?: string
|
||||
assigned_agent?: string | null
|
||||
progress?: number
|
||||
tags?: string[]
|
||||
due_date?: string
|
||||
|
|
|
|||
122
dashboard/src/components/telegram-thread-card.tsx
Normal file
122
dashboard/src/components/telegram-thread-card.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Send } from "lucide-react"
|
||||
|
||||
interface TelegramMessage {
|
||||
role: string
|
||||
content: string
|
||||
timestamp: number
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function TelegramThreadCard() {
|
||||
const [messages, setMessages] = useState<TelegramMessage[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/chat/history?limit=5")
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data?.messages) {
|
||||
setMessages(data.messages.slice(-5).reverse())
|
||||
}
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(e => {
|
||||
console.error("Failed to load:", e)
|
||||
setError(e.message)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const hasData = messages.length > 0
|
||||
|
||||
// Simple timestamp formatter
|
||||
const formatTime = (ts: number) => {
|
||||
if (!ts) return ""
|
||||
const diff = Date.now() - ts
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 1) return "just now"
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
return new Date(ts).toLocaleDateString()
|
||||
}
|
||||
|
||||
// Simple truncate
|
||||
const truncate = (text: string, max = 40) => {
|
||||
if (!text) return ""
|
||||
return text.length > max ? text.slice(0, max) + "..." : text
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-card/40 p-4 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Send className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Telegram thread</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="bg-card/40 p-4 flex flex-col">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Send className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Telegram thread</span>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="text-sm text-red-500">Error: {error}</span>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card/40 p-4 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">Chat history</span>
|
||||
</div>
|
||||
<a href="/chat?session=telegram" className="text-xs text-primary hover:underline">Open chat →</a>
|
||||
</div>
|
||||
|
||||
{hasData ? (
|
||||
<ul className="space-y-2 flex-1">
|
||||
{messages.map((msg, i) => (
|
||||
<li key={i} className="text-sm">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-xs">
|
||||
{msg.role === "user" ? "You" : "Tiger"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatTime(msg.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/80 truncate">
|
||||
{truncate(msg.content)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center py-6 px-2">
|
||||
<Send className="h-8 w-8 text-muted-foreground/30 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No messages yet.</p>
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-1 max-w-[260px]">
|
||||
Start a conversation to see messages here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
158
dashboard/src/components/ui/dialog.tsx
Normal file
158
dashboard/src/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
31
dashboard/src/components/ui/progress.tsx
Normal file
31
dashboard/src/components/ui/progress.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
190
dashboard/src/components/ui/select.tsx
Normal file
190
dashboard/src/components/ui/select.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
91
dashboard/src/components/ui/tabs.tsx
Normal file
91
dashboard/src/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
79
dashboard/src/components/workspace/activity-feed.tsx
Normal file
79
dashboard/src/components/workspace/activity-feed.tsx
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"use client"
|
||||
/**
|
||||
* activity-feed.tsx — chronological list of recent file modifications
|
||||
* across all agents.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import { Clock, Loader2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface ActivityEvent {
|
||||
agentId: string
|
||||
agentName: string
|
||||
agentEmoji: string
|
||||
path: string
|
||||
action: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
events: ActivityEvent[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
const diff = Date.now() - ts
|
||||
const s = Math.floor(diff / 1000)
|
||||
if (s < 60) return `${s}s ago`
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
return `${Math.floor(h / 24)}d ago`
|
||||
}
|
||||
|
||||
export function ActivityFeed({ events, loading }: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
|
||||
<Clock className="h-6 w-6" />
|
||||
<span className="text-sm">No recent activity</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{events.map((ev, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-3 px-3 py-2.5 rounded-md hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
{/* Agent avatar */}
|
||||
<span className="text-base leading-none mt-0.5 shrink-0">{ev.agentEmoji}</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium">{ev.agentName}</span>
|
||||
<span className="text-xs text-muted-foreground">{ev.action}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{ev.path}</p>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground shrink-0 mt-0.5">
|
||||
{relativeTime(ev.ts)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
dashboard/src/components/workspace/agent-chip-row.tsx
Normal file
75
dashboard/src/components/workspace/agent-chip-row.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client"
|
||||
/**
|
||||
* agent-chip-row.tsx — horizontal chip row for selecting agents
|
||||
* Scrolls horizontally on mobile. Badge shows fileCount.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface AgentInfo {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
role: string
|
||||
fileCount: number
|
||||
lastActivity: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
agents: AgentInfo[]
|
||||
activeId: string | null // null = "All"
|
||||
onChange: (id: string | null) => void
|
||||
recentIds?: Set<string> // agent ids that had recent activity (for badge highlight)
|
||||
}
|
||||
|
||||
export function AgentChipRow({ agents, activeId, onChange, recentIds }: Props) {
|
||||
return (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 no-scrollbar">
|
||||
{/* All chip */}
|
||||
<button
|
||||
onClick={() => onChange(null)}
|
||||
className={cn(
|
||||
"flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
activeId === null
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
🗂️ All
|
||||
</button>
|
||||
|
||||
{agents.map((agent) => {
|
||||
const isActive = activeId === agent.id
|
||||
const hasRecent = recentIds?.has(agent.id)
|
||||
return (
|
||||
<button
|
||||
key={agent.id}
|
||||
onClick={() => onChange(agent.id)}
|
||||
className={cn(
|
||||
"flex-shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80"
|
||||
)}
|
||||
>
|
||||
<span>{agent.emoji}</span>
|
||||
<span>{agent.name}</span>
|
||||
{agent.fileCount > 0 && (
|
||||
<span className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded-full font-mono",
|
||||
isActive
|
||||
? "bg-primary-foreground/20 text-primary-foreground"
|
||||
: hasRecent
|
||||
? "bg-orange-500/20 text-orange-400"
|
||||
: "bg-background/40"
|
||||
)}>
|
||||
{agent.fileCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
376
dashboard/src/components/workspace/file-preview.tsx
Normal file
376
dashboard/src/components/workspace/file-preview.tsx
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
"use client"
|
||||
/**
|
||||
* file-preview.tsx — Multi-mode file viewer/editor
|
||||
*
|
||||
* Mode is derived from file extension + mime type:
|
||||
*
|
||||
* EDIT_SAVE .md .txt → textarea editor, Save/Cancel in toolbar
|
||||
* HTML_RENDER .html .htm → sandboxed <iframe srcdoc>, "View Source" toggle
|
||||
* CODE_VIEW .ts .tsx .js .jsx → read-only <pre> with token colouring
|
||||
* .py .sh .json .yaml
|
||||
* .yml .toml .css .xml
|
||||
* IMAGE image/* → <img> from base64 data-URI
|
||||
* BINARY everything else → "Download" only, no content shown
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import {
|
||||
Download, Edit3, Save, X, Code, Eye, Loader2, AlertCircle
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
agentId: string | null
|
||||
path: string | null
|
||||
content: string | null
|
||||
encoding: "utf8" | "base64" | null
|
||||
mime: string | null
|
||||
size: number
|
||||
loading?: boolean
|
||||
/** Called after a successful save so the parent can refresh if needed */
|
||||
onSaved?: (path: string, newContent: string) => void
|
||||
}
|
||||
|
||||
type ViewMode = "edit_save" | "html_render" | "code_view" | "image" | "binary"
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function detectMode(path: string | null, mime: string | null): ViewMode {
|
||||
const ext = path?.split(".").pop()?.toLowerCase() ?? ""
|
||||
if (["md", "txt"].includes(ext)) return "edit_save"
|
||||
if (["html", "htm"].includes(ext)) return "html_render"
|
||||
if (
|
||||
["ts", "tsx", "js", "jsx", "py", "sh", "json", "yaml", "yml",
|
||||
"toml", "css", "xml", "env", "conf", "ini", "sql", "rs", "go",
|
||||
"java", "c", "cpp", "h"].includes(ext)
|
||||
) return "code_view"
|
||||
if (mime?.startsWith("image/")) return "image"
|
||||
if (mime?.startsWith("text/") || mime?.includes("json") || mime?.includes("xml")) return "code_view"
|
||||
return "binary"
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (!bytes) return ""
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
/** Very lightweight syntax token colouring — no deps, pure CSS classes via regex spans */
|
||||
function tokenise(code: string, ext: string): string {
|
||||
// Escape HTML first
|
||||
const escaped = code
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
|
||||
const isJS = ["ts", "tsx", "js", "jsx"].includes(ext)
|
||||
const isPy = ext === "py"
|
||||
const isJSON = ext === "json"
|
||||
|
||||
if (isJSON) {
|
||||
return escaped
|
||||
.replace(/("(?:[^"\\]|\\.)*")(\s*:)/g, '<span class="tk-key">$1</span>$2')
|
||||
.replace(/:\s*("(?:[^"\\]|\\.)*")/g, ': <span class="tk-str">$1</span>')
|
||||
.replace(/\b(true|false|null)\b/g, '<span class="tk-kw">$1</span>')
|
||||
.replace(/\b(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\b/g, '<span class="tk-num">$1</span>')
|
||||
}
|
||||
|
||||
if (isJS || isPy) {
|
||||
const kwJS = "const|let|var|function|return|import|export|from|default|class|extends|new|if|else|for|while|async|await|try|catch|throw|typeof|interface|type|enum"
|
||||
const kwPy = "def|class|return|import|from|if|elif|else|for|while|try|except|raise|with|as|pass|lambda|yield|async|await|None|True|False"
|
||||
const kw = isPy ? kwPy : kwJS
|
||||
return escaped
|
||||
.replace(/(\/\/[^\n]*|#[^\n]*)/g, '<span class="tk-comment">$1</span>')
|
||||
.replace(/("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g, '<span class="tk-str">$1</span>')
|
||||
.replace(new RegExp(`\\b(${kw})\\b`, "g"), '<span class="tk-kw">$1</span>')
|
||||
.replace(/\b(\d+(?:\.\d+)?)\b/g, '<span class="tk-num">$1</span>')
|
||||
}
|
||||
|
||||
return escaped
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
/** Toolbar shown at top of the pane */
|
||||
function Toolbar({
|
||||
path, size, mode, editing, dirty, saving, saveError,
|
||||
onEdit, onSave, onCancel, onDownload, showSource, onToggleSource,
|
||||
}: {
|
||||
path: string | null; size: number; mode: ViewMode; editing: boolean
|
||||
dirty: boolean; saving: boolean; saveError: string | null
|
||||
onEdit: () => void; onSave: () => void; onCancel: () => void
|
||||
onDownload: () => void; showSource: boolean; onToggleSource: () => void
|
||||
}) {
|
||||
const filename = path?.split("/").pop() ?? ""
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b shrink-0 gap-2 flex-wrap">
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-sm font-medium truncate">{filename || "No file selected"}</span>
|
||||
{path && (
|
||||
<span className="text-xs text-muted-foreground truncate max-w-xs">{path}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{saveError && (
|
||||
<span className="flex items-center gap-1 text-xs text-destructive">
|
||||
<AlertCircle className="h-3.5 w-3.5" /> {saveError}
|
||||
</span>
|
||||
)}
|
||||
{size > 0 && !editing && (
|
||||
<span className="text-xs text-muted-foreground">{formatSize(size)}</span>
|
||||
)}
|
||||
|
||||
{/* HTML: source toggle */}
|
||||
{mode === "html_render" && path && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onToggleSource}>
|
||||
{showSource ? <Eye className="h-3.5 w-3.5 mr-1" /> : <Code className="h-3.5 w-3.5 mr-1" />}
|
||||
{showSource ? "Render" : "Source"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Edit/Save/Cancel for edit_save mode */}
|
||||
{mode === "edit_save" && path && !editing && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onEdit}>
|
||||
<Edit3 className="h-3.5 w-3.5 mr-1" /> Edit
|
||||
</Button>
|
||||
)}
|
||||
{mode === "edit_save" && editing && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost" size="sm"
|
||||
className="h-7 px-2 text-muted-foreground"
|
||||
onClick={onCancel} disabled={saving}
|
||||
>
|
||||
<X className="h-3.5 w-3.5 mr-1" /> Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant={dirty ? "default" : "ghost"} size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={onSave} disabled={saving || !dirty}
|
||||
>
|
||||
{saving
|
||||
? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||||
: <Save className="h-3.5 w-3.5 mr-1" />}
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Download always available when we have content */}
|
||||
{path && mode !== "edit_save" && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={onDownload}>
|
||||
<Download className="h-3.5 w-3.5 mr-1" /> Download
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function FilePreview({ agentId, path, content, encoding, mime, size, loading, onSaved }: Props) {
|
||||
const mode = detectMode(path, mime)
|
||||
const ext = path?.split(".").pop()?.toLowerCase() ?? ""
|
||||
|
||||
// Edit state (edit_save mode)
|
||||
const [editing, setEditing] = React.useState(false)
|
||||
const [draft, setDraft] = React.useState("")
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [saveError, setSaveError] = React.useState<string | null>(null)
|
||||
const [savedOk, setSavedOk] = React.useState(false)
|
||||
|
||||
// HTML mode: show source vs rendered
|
||||
const [showSource, setShowSource] = React.useState(false)
|
||||
|
||||
// Reset edit state when file changes
|
||||
React.useEffect(() => {
|
||||
setEditing(false)
|
||||
setDraft(content ?? "")
|
||||
setSaveError(null)
|
||||
setSavedOk(false)
|
||||
setShowSource(false)
|
||||
}, [path, content])
|
||||
|
||||
const dirty = draft !== (content ?? "")
|
||||
|
||||
// ── Edit handlers ──────────────────────────────────────────────────────────
|
||||
|
||||
const handleEdit = () => {
|
||||
setDraft(content ?? "")
|
||||
setSaveError(null)
|
||||
setSavedOk(false)
|
||||
setEditing(true)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setDraft(content ?? "")
|
||||
setEditing(false)
|
||||
setSaveError(null)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!agentId || !path) return
|
||||
setSaving(true)
|
||||
setSaveError(null)
|
||||
setSavedOk(false)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/tiger/agents/${agentId}/file?path=${encodeURIComponent(path)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: draft }),
|
||||
}
|
||||
)
|
||||
const data = await res.json()
|
||||
if (!data.ok) throw new Error(data.error ?? "Save failed")
|
||||
setSavedOk(true)
|
||||
setEditing(false)
|
||||
onSaved?.(path, draft)
|
||||
} catch (err: any) {
|
||||
setSaveError(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download handler ───────────────────────────────────────────────────────
|
||||
|
||||
const handleDownload = () => {
|
||||
if (!content || !path) return
|
||||
const filename = path.split("/").pop() ?? "file"
|
||||
let url: string
|
||||
if (encoding === "base64") {
|
||||
url = `data:${mime ?? "application/octet-stream"};base64,${content}`
|
||||
} else {
|
||||
const blob = new Blob([content], { type: mime ?? "text/plain" })
|
||||
url = URL.createObjectURL(blob)
|
||||
}
|
||||
const a = document.createElement("a")
|
||||
a.href = url; a.download = filename; a.click()
|
||||
if (encoding !== "base64") URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<Toolbar
|
||||
path={path} size={size} mode={mode}
|
||||
editing={editing} dirty={dirty} saving={saving} saveError={saveError}
|
||||
onEdit={handleEdit} onSave={handleSave} onCancel={handleCancel}
|
||||
onDownload={handleDownload}
|
||||
showSource={showSource} onToggleSource={() => setShowSource((v) => !v)}
|
||||
/>
|
||||
|
||||
{/* Saved banner */}
|
||||
{savedOk && (
|
||||
<div className="px-3 py-1.5 text-xs text-green-400 bg-green-500/10 border-b shrink-0">
|
||||
✓ Saved successfully
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* ── Loading ── */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── No file selected ── */}
|
||||
{!loading && !path && (
|
||||
<div className="flex items-center justify-center h-full text-sm text-muted-foreground">
|
||||
Select a file to preview it
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── EDIT_SAVE: textarea editor or markdown preview ── */}
|
||||
{!loading && path && mode === "edit_save" && (
|
||||
editing ? (
|
||||
<textarea
|
||||
className="w-full h-full resize-none bg-background/50 font-mono text-xs p-3 outline-none border-0 focus:ring-0"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
) : (
|
||||
<div className={cn(
|
||||
"h-full overflow-y-auto p-4",
|
||||
ext === "md"
|
||||
? "prose prose-sm prose-invert max-w-none text-foreground prose-headings:text-foreground prose-a:text-primary prose-code:bg-muted/60 prose-code:px-1 prose-code:rounded prose-pre:bg-muted/40 prose-pre:border"
|
||||
: "text-sm font-mono whitespace-pre-wrap break-all text-foreground"
|
||||
)}>
|
||||
{ext === "md"
|
||||
? <ReactMarkdown>{content ?? ""}</ReactMarkdown>
|
||||
: <span>{content ?? ""}</span>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── HTML_RENDER: iframe or source ── */}
|
||||
{!loading && path && mode === "html_render" && (
|
||||
showSource ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<style>{`.tk-kw{color:#c792ea}.tk-str{color:#c3e88d}.tk-num{color:#f78c6c}.tk-comment{color:#546e7a;font-style:italic}.tk-key{color:#82aaff}`}</style>
|
||||
<pre
|
||||
className="text-xs font-mono p-3 leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: tokenise(content ?? "", "html") }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
className="w-full h-full border-0 bg-white"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
srcDoc={content ?? ""}
|
||||
title={path}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── CODE_VIEW: syntax-highlighted read-only ── */}
|
||||
{!loading && path && mode === "code_view" && (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<style>{`.tk-kw{color:#c792ea}.tk-str{color:#c3e88d}.tk-num{color:#f78c6c}.tk-comment{color:#546e7a;font-style:italic}.tk-key{color:#82aaff}`}</style>
|
||||
<pre
|
||||
className="text-xs font-mono p-3 leading-relaxed whitespace-pre-wrap break-all"
|
||||
dangerouslySetInnerHTML={{ __html: tokenise(content ?? "", ext) }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── IMAGE ── */}
|
||||
{!loading && path && mode === "image" && encoding === "base64" && (
|
||||
<div className="h-full overflow-auto flex items-start justify-center p-4">
|
||||
<img
|
||||
src={`data:${mime};base64,${content}`}
|
||||
alt={path}
|
||||
className="max-w-full rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── BINARY: no preview ── */}
|
||||
{!loading && path && mode === "binary" && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
|
||||
<span className="text-3xl">📦</span>
|
||||
<span className="text-sm">Binary file — {formatSize(size)}</span>
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="h-4 w-4 mr-2" /> Download
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
124
dashboard/src/components/workspace/file-tree.tsx
Normal file
124
dashboard/src/components/workspace/file-tree.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
"use client"
|
||||
/**
|
||||
* file-tree.tsx — collapsible file/directory tree for one agent
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import { Folder, FolderOpen, FileText, FileCode, ChevronRight, ChevronDown } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface FileItem {
|
||||
name: string
|
||||
type: "file" | "dir"
|
||||
size: number
|
||||
modifiedAt: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
items: FileItem[]
|
||||
currentPath: string // relative path being shown (e.g. "deliverables")
|
||||
selectedFile: string | null // full relative path of selected file
|
||||
onNavigate: (path: string) => void // navigate into a dir
|
||||
onSelectFile: (path: string) => void // select a file for preview
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
// Extension → colour class mapping
|
||||
function fileColour(name: string) {
|
||||
const ext = name.split(".").pop()?.toLowerCase()
|
||||
if (["ts", "tsx", "js", "jsx", "py", "sh"].includes(ext ?? "")) return "text-blue-400"
|
||||
if (["md", "txt"].includes(ext ?? "")) return "text-purple-400"
|
||||
if (["html", "htm"].includes(ext ?? "")) return "text-orange-400"
|
||||
if (["json", "yaml", "yml", "toml"].includes(ext ?? "")) return "text-green-400"
|
||||
return "text-muted-foreground"
|
||||
}
|
||||
|
||||
function FileIcon({ name, className }: { name: string; className?: string }) {
|
||||
const ext = name.split(".").pop()?.toLowerCase()
|
||||
const isCode = ["ts", "tsx", "js", "jsx", "py", "sh", "json", "yaml", "yml", "html", "toml"].includes(ext ?? "")
|
||||
return isCode
|
||||
? <FileCode className={cn("h-4 w-4 shrink-0", className)} />
|
||||
: <FileText className={cn("h-4 w-4 shrink-0", className)} />
|
||||
}
|
||||
|
||||
function formatSize(bytes: number) {
|
||||
if (!bytes) return ""
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
export function FileTree({ items, currentPath, selectedFile, onNavigate, onSelectFile, loading }: Props) {
|
||||
// Split items into dirs first, then files — both sorted alphabetically
|
||||
const dirs = items.filter((i) => i.type === "dir").sort((a, b) => a.name.localeCompare(b.name))
|
||||
const files = items.filter((i) => i.type === "file").sort((a, b) => a.name.localeCompare(b.name))
|
||||
const sorted = [...dirs, ...files]
|
||||
|
||||
// Breadcrumb segments from currentPath
|
||||
const crumbs = currentPath ? currentPath.split("/").filter(Boolean) : []
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-1 p-1">
|
||||
{[1,2,3,4].map((i) => (
|
||||
<div key={i} className="h-8 rounded-md bg-muted/40 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* Breadcrumb nav */}
|
||||
{crumbs.length > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground flex-wrap">
|
||||
<button onClick={() => onNavigate("")} className="hover:text-foreground transition-colors">
|
||||
root
|
||||
</button>
|
||||
{crumbs.map((crumb, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<button
|
||||
onClick={() => onNavigate(crumbs.slice(0, i + 1).join("/"))}
|
||||
className="hover:text-foreground transition-colors"
|
||||
>
|
||||
{crumb}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sorted.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground text-center py-6">Empty directory</div>
|
||||
)}
|
||||
|
||||
{sorted.map((item) => {
|
||||
const itemFullPath = currentPath ? `${currentPath}/${item.name}` : item.name
|
||||
const isSelected = selectedFile === itemFullPath
|
||||
return (
|
||||
<button
|
||||
key={item.name}
|
||||
onClick={() =>
|
||||
item.type === "dir" ? onNavigate(itemFullPath) : onSelectFile(itemFullPath)
|
||||
}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-left text-sm transition-colors",
|
||||
isSelected ? "bg-primary/15 text-foreground" : "hover:bg-muted/60",
|
||||
)}
|
||||
>
|
||||
{item.type === "dir" ? (
|
||||
<Folder className="h-4 w-4 shrink-0 text-amber-400" />
|
||||
) : (
|
||||
<FileIcon name={item.name} className={fileColour(item.name)} />
|
||||
)}
|
||||
<span className="truncate flex-1">{item.name}</span>
|
||||
{item.type === "file" && item.size > 0 && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">{formatSize(item.size)}</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
232
dashboard/src/contexts/chat-context.tsx
Normal file
232
dashboard/src/contexts/chat-context.tsx
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* ChatContext — chat state that persists across route changes AND across
|
||||
* hard refreshes (via the server-side /api/chat/history endpoint).
|
||||
*
|
||||
* THREE LAYERS OF STATE:
|
||||
* 1. React Context — survives client-side navigation between routes
|
||||
* 2. localStorage — remembers which sessionKey was active across reloads
|
||||
* 3. Server-side history — sqlite in bridge; survives device change, tab close
|
||||
*
|
||||
* SESSION MODEL (post WS-migration):
|
||||
* Each chat is keyed by a `sessionKey` like:
|
||||
* - "agent:main:main" → the default Tiger conversation
|
||||
* - "agent:main:webchat-<8hex>" → a fresh session created via "+ New"
|
||||
* The sessionKey is passed to /api/chat so the gateway routes to the right
|
||||
* conversation memory. It's also passed to /api/chat/history?sessionId=<key>.
|
||||
*
|
||||
* FLOW:
|
||||
* Mount → load sessions list → load history for current sessionKey
|
||||
* New → POST /api/chat/sessions → set as current, clear messages
|
||||
* Switch → set current → load history
|
||||
* Send → optimistic UI → POST /api/chat with current sessionKey
|
||||
* Clear → DELETE /api/chat/history?sessionId=<current>
|
||||
* Delete → DELETE /api/chat/sessions?key=<x> → drop, switch to Main
|
||||
*/
|
||||
import * as React from "react"
|
||||
|
||||
const DEFAULT_SESSION_KEY = "agent:main:main"
|
||||
const STORAGE_KEY = "tiger.currentSessionKey"
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string
|
||||
role: "user" | "agent" | "system"
|
||||
content: string
|
||||
streaming?: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export type ChatSession = {
|
||||
key: string
|
||||
label: string
|
||||
updatedAt: number | null
|
||||
messageCount: number
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_WELCOME: ChatMessage = {
|
||||
id: "welcome",
|
||||
role: "agent",
|
||||
content: "Hey! I am Tiger, your AI assistant. Send me a message to get started.",
|
||||
timestamp: 0,
|
||||
}
|
||||
|
||||
type ChatContextValue = {
|
||||
messages: ChatMessage[]
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
|
||||
loading: boolean
|
||||
/** The sessionKey currently being chatted with — sent to /api/chat. */
|
||||
currentSessionKey: string
|
||||
/** All webchat-visible sessions (from gateway sessions.list). */
|
||||
sessions: ChatSession[]
|
||||
/** Switch to an existing session — loads its history. */
|
||||
selectSession: (key: string) => Promise<void>
|
||||
/** Mint and switch to a brand-new session — clears messages. */
|
||||
newSession: () => Promise<void>
|
||||
/** Delete a non-default session — switches to Main if the active one is removed. */
|
||||
deleteSession: (key: string) => Promise<void>
|
||||
/** Wipe the *current* session's history (keeps the session itself). */
|
||||
clearChat: () => Promise<void>
|
||||
/** Force-refresh the sessions list (e.g., after a send so updatedAt updates). */
|
||||
refreshSessions: () => Promise<void>
|
||||
}
|
||||
|
||||
const ChatContext = React.createContext<ChatContextValue | null>(null)
|
||||
|
||||
/** Read the saved sessionKey from localStorage (SSR-safe). */
|
||||
function readPersistedKey(): string {
|
||||
if (typeof window === "undefined") return DEFAULT_SESSION_KEY
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) || DEFAULT_SESSION_KEY
|
||||
} catch {
|
||||
return DEFAULT_SESSION_KEY
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistedKey(key: string): void {
|
||||
if (typeof window === "undefined") return
|
||||
try { localStorage.setItem(STORAGE_KEY, key) } catch { /* quota / private mode */ }
|
||||
}
|
||||
|
||||
/** Fetch history for a sessionKey, returning hydrated messages (welcome first). */
|
||||
async function loadHistoryFor(sessionKey: string): Promise<ChatMessage[]> {
|
||||
try {
|
||||
const r = await fetch(`/api/chat/history?sessionId=${encodeURIComponent(sessionKey)}`, { cache: "no-store" })
|
||||
if (!r.ok) throw new Error(`history ${r.status}`)
|
||||
const data = await r.json()
|
||||
if (!data?.ok || !Array.isArray(data.messages)) return [DEFAULT_WELCOME]
|
||||
return [
|
||||
DEFAULT_WELCOME,
|
||||
...data.messages.map((m: any) => ({
|
||||
id: String(m.id),
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
})),
|
||||
]
|
||||
} catch (err) {
|
||||
console.warn("[chat] could not load history for", sessionKey, err)
|
||||
return [DEFAULT_WELCOME]
|
||||
}
|
||||
}
|
||||
|
||||
export function ChatProvider({ children }: { children: React.ReactNode }) {
|
||||
const [messages, setMessages] = React.useState<ChatMessage[]>([DEFAULT_WELCOME])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [currentSessionKey, setCurrentSessionKey] = React.useState<string>(DEFAULT_SESSION_KEY)
|
||||
const [sessions, setSessions] = React.useState<ChatSession[]>([])
|
||||
|
||||
const refreshSessions = React.useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/chat/sessions", { cache: "no-store" })
|
||||
if (!r.ok) return
|
||||
const data = await r.json()
|
||||
if (data?.ok && Array.isArray(data.sessions)) setSessions(data.sessions)
|
||||
} catch (err) {
|
||||
console.warn("[chat] sessions list failed:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Initial mount: pick stored sessionKey, load its history, fetch sessions list.
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
// Check URL for session param
|
||||
const urlParams = new URLSearchParams(typeof window !== 'undefined' ? window.location.search : '')
|
||||
const sessionParam = urlParams.get('session')
|
||||
let initialKey = sessionParam ? `agent:main:${sessionParam}` : readPersistedKey()
|
||||
|
||||
async function init() {
|
||||
if (cancelled) return
|
||||
setCurrentSessionKey(initialKey)
|
||||
const [hist] = await Promise.all([loadHistoryFor(initialKey), refreshSessions()])
|
||||
if (!cancelled) {
|
||||
setMessages(hist)
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
init()
|
||||
return () => { cancelled = true }
|
||||
}, [refreshSessions])
|
||||
|
||||
const selectSession = React.useCallback(async (key: string) => {
|
||||
if (key === currentSessionKey) return
|
||||
setLoading(true)
|
||||
setCurrentSessionKey(key)
|
||||
writePersistedKey(key)
|
||||
const hist = await loadHistoryFor(key)
|
||||
setMessages(hist)
|
||||
setLoading(false)
|
||||
}, [currentSessionKey])
|
||||
|
||||
const newSession = React.useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch("/api/chat/sessions", { method: "POST" })
|
||||
const data = await r.json()
|
||||
if (!r.ok || !data?.ok || !data.session?.key) {
|
||||
console.warn("[chat] new session failed:", data)
|
||||
return
|
||||
}
|
||||
const key = data.session.key as string
|
||||
setCurrentSessionKey(key)
|
||||
writePersistedKey(key)
|
||||
setMessages([DEFAULT_WELCOME])
|
||||
// Add to local list optimistically; full refresh happens after first send
|
||||
setSessions(prev => {
|
||||
if (prev.find(s => s.key === key)) return prev
|
||||
return [...prev, data.session as ChatSession]
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn("[chat] new session error:", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const deleteSession = React.useCallback(async (key: string) => {
|
||||
if (key === DEFAULT_SESSION_KEY) return
|
||||
try {
|
||||
await fetch(`/api/chat/sessions?key=${encodeURIComponent(key)}`, { method: "DELETE" })
|
||||
} catch (err) {
|
||||
console.warn("[chat] delete session failed:", err)
|
||||
}
|
||||
setSessions(prev => prev.filter(s => s.key !== key))
|
||||
// If the deleted session was active, fall back to Main.
|
||||
if (key === currentSessionKey) {
|
||||
setCurrentSessionKey(DEFAULT_SESSION_KEY)
|
||||
writePersistedKey(DEFAULT_SESSION_KEY)
|
||||
const hist = await loadHistoryFor(DEFAULT_SESSION_KEY)
|
||||
setMessages(hist)
|
||||
}
|
||||
}, [currentSessionKey])
|
||||
|
||||
const clearChat = React.useCallback(async () => {
|
||||
setMessages([DEFAULT_WELCOME])
|
||||
try {
|
||||
await fetch(`/api/chat/history?sessionId=${encodeURIComponent(currentSessionKey)}`, { method: "DELETE" })
|
||||
} catch (err) {
|
||||
console.warn("[chat] clear server failed (local cleared):", err)
|
||||
}
|
||||
}, [currentSessionKey])
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={{
|
||||
messages, setMessages, loading,
|
||||
currentSessionKey, sessions,
|
||||
selectSession, newSession, deleteSession,
|
||||
clearChat, refreshSessions,
|
||||
}}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useChatContext(): ChatContextValue {
|
||||
const ctx = React.useContext(ChatContext)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useChatContext must be used inside <ChatProvider>. " +
|
||||
"Make sure app/layout.tsx wraps children with <ChatProvider>."
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
108
dashboard/src/contexts/chat-context.tsx.pre-ws
Normal file
108
dashboard/src/contexts/chat-context.tsx.pre-ws
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* ChatContext — chat state that persists across route changes AND across
|
||||
* hard refreshes (via the server-side /api/chat/history endpoint).
|
||||
*
|
||||
* TWO LAYERS OF PERSISTENCE:
|
||||
* 1. React Context → survives client-side navigation between /chat, /workspace
|
||||
* 2. Server-side history (SQLite in bridge) → survives refresh, tab close,
|
||||
* device change. On mount we fetch history and hydrate the messages.
|
||||
*
|
||||
* FLOW:
|
||||
* Mount → GET /api/chat/history → merge with default welcome message
|
||||
* Send message → optimistic local update → /api/chat → server persists
|
||||
* Clear → DELETE /api/chat/history → reset to just the welcome
|
||||
*/
|
||||
import * as React from "react"
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string
|
||||
role: "user" | "agent" | "system"
|
||||
content: string
|
||||
streaming?: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const DEFAULT_WELCOME: ChatMessage = {
|
||||
id: "welcome",
|
||||
role: "agent",
|
||||
content: "Hey! I am Tiger, your AI assistant. Send me a message to get started.",
|
||||
timestamp: 0, // sentinel — always sorted to the top
|
||||
}
|
||||
|
||||
type ChatContextValue = {
|
||||
messages: ChatMessage[]
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
|
||||
clearChat: () => Promise<void>
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const ChatContext = React.createContext<ChatContextValue | null>(null)
|
||||
|
||||
export function ChatProvider({ children }: { children: React.ReactNode }) {
|
||||
const [messages, setMessages] = React.useState<ChatMessage[]>([DEFAULT_WELCOME])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
|
||||
// Hydrate from server on mount. This is what makes persistence actually
|
||||
// work across hard refresh.
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch("/api/chat/history", { cache: "no-store" })
|
||||
if (!r.ok) throw new Error(`history ${r.status}`)
|
||||
const data = await r.json()
|
||||
if (cancelled || !data?.ok || !Array.isArray(data.messages)) return
|
||||
|
||||
// Combine welcome + history, no duplicates. Sort by timestamp so
|
||||
// it renders in conversational order.
|
||||
const hydrated: ChatMessage[] = [
|
||||
DEFAULT_WELCOME,
|
||||
...data.messages.map((m: any) => ({
|
||||
id: String(m.id),
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
})),
|
||||
]
|
||||
setMessages(hydrated)
|
||||
} catch (err) {
|
||||
console.warn("[chat] could not load history:", err)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearChat = React.useCallback(async () => {
|
||||
// Optimistic: clear UI first, then ask server to clear.
|
||||
setMessages([DEFAULT_WELCOME])
|
||||
try {
|
||||
await fetch("/api/chat/history", { method: "DELETE" })
|
||||
} catch (err) {
|
||||
console.warn("[chat] clear on server failed (local cleared):", err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={{ messages, setMessages, clearChat, loading }}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useChatContext(): ChatContextValue {
|
||||
const ctx = React.useContext(ChatContext)
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
"useChatContext must be used inside <ChatProvider>. " +
|
||||
"Make sure app/layout.tsx wraps children with <ChatProvider>."
|
||||
)
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
90
dashboard/src/hooks/use-gateway.ts
Normal file
90
dashboard/src/hooks/use-gateway.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* use-gateway.ts — Client-side hooks for OpenClaw gateway interaction
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from "react"
|
||||
|
||||
const GW_API_PREFIX = "/api/gw"
|
||||
|
||||
/**
|
||||
* useGatewayRequest — make RPC-style calls to the gateway via /api/gw/[...method]
|
||||
* Usage: request("cron.list") => POST /api/gw/cron/list
|
||||
* request("cron.run", { id: "abc" }) => POST /api/gw/cron/run with body
|
||||
*/
|
||||
export function useGatewayRequest() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const request = useCallback(async (method: string, body?: Record<string, unknown>) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const path = method.replace(/\./g, "/")
|
||||
const res = await fetch(`${GW_API_PREFIX}/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body || {}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`${res.status}: ${res.statusText}`)
|
||||
const json = await res.json()
|
||||
return json.data ?? json
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
return null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { request, loading, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* useGatewayEvents — SSE event stream from /api/gw/stream
|
||||
*/
|
||||
export function useGatewayEvents(path?: string, options?: { enabled?: boolean }) {
|
||||
const [events, setEvents] = useState<any[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const esRef = useRef<EventSource | null>(null)
|
||||
const enabled = options?.enabled ?? true
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
const url = path || "/api/gw/stream"
|
||||
const es = new EventSource(url)
|
||||
esRef.current = es
|
||||
|
||||
es.onopen = () => {
|
||||
setConnected(true)
|
||||
setError(null)
|
||||
}
|
||||
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data)
|
||||
setEvents((prev) => [...prev.slice(-500), data])
|
||||
} catch {
|
||||
setEvents((prev) => [...prev.slice(-500), { raw: e.data }])
|
||||
}
|
||||
}
|
||||
|
||||
es.onerror = () => {
|
||||
setConnected(false)
|
||||
setError("Connection lost")
|
||||
}
|
||||
|
||||
return () => {
|
||||
es.close()
|
||||
esRef.current = null
|
||||
setConnected(false)
|
||||
}
|
||||
}, [path, enabled])
|
||||
|
||||
const clear = useCallback(() => setEvents([]), [])
|
||||
|
||||
return { events, connected, error, clear }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue