Compare commits
No commits in common. "0970160f2906b365c3cb7142c0e3176ea2bbccea" and "76620da6b28a69f52f223367dcfdd9f1376dbaa9" have entirely different histories.
0970160f29
...
76620da6b2
69 changed files with 1912 additions and 7995 deletions
292
ARCHITECTURE.md
292
ARCHITECTURE.md
|
|
@ -1,292 +0,0 @@
|
|||
# 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.
|
||||
9
IDENTITY.md
Normal file
9
IDENTITY.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# 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
Normal file
36
SOUL.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# 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
2137
bridge/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -10,19 +10,17 @@
|
|||
"start:prod": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"chokidar": "^3.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"otplib": "^13.4.0"
|
||||
"cors": "^2.8.5",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"chokidar": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.6.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/node": "^22.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,9 +48,6 @@ 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 '',
|
||||
|
|
@ -102,14 +99,6 @@ 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 {
|
||||
|
|
@ -187,18 +176,17 @@ export const tasks = {
|
|||
},
|
||||
|
||||
create(data: {
|
||||
project_id: string | null;
|
||||
project_id: string;
|
||||
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, agent_reason)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO tasks (id, project_id, parent_task_id, title, description, priority, assigned_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
data.project_id,
|
||||
|
|
@ -206,8 +194,7 @@ export const tasks = {
|
|||
data.title,
|
||||
data.description || "",
|
||||
data.priority || "medium",
|
||||
data.assigned_agent || null,
|
||||
data.agent_reason || null
|
||||
data.assigned_agent || null
|
||||
);
|
||||
return tasks.findById(id);
|
||||
},
|
||||
|
|
@ -222,7 +209,6 @@ export const tasks = {
|
|||
tags: string;
|
||||
notes: string;
|
||||
due_date: string;
|
||||
agent_reason: string;
|
||||
}>): unknown | undefined {
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
|
@ -236,8 +222,6 @@ 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,73 +1,32 @@
|
|||
/**
|
||||
* index.ts — Tiger Bridge API Entry Point
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*
|
||||
* Architecture:
|
||||
* 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.
|
||||
* 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)
|
||||
*
|
||||
* Routes:
|
||||
* 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/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
|
||||
* 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";
|
||||
|
|
@ -76,17 +35,9 @@ 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";
|
||||
|
|
@ -125,13 +76,6 @@ 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);
|
||||
|
|
@ -143,19 +87,9 @@ 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
|
||||
|
|
@ -185,9 +119,4 @@ 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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,250 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
/**
|
||||
* 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
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
/**
|
||||
* 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 };
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -93,22 +93,16 @@ router.post("/", async (req, res) => {
|
|||
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`;
|
||||
// Escape the message for shell
|
||||
const escapedMessage = message.replace(/'/g, "'\\''");
|
||||
|
||||
// Use openclaw agent to send a message to the main session
|
||||
// Session ID: agent:main:main (agent:main:main)
|
||||
// In TIGER_REMOTE mode, prefix with ssh so docker runs on the VPS.
|
||||
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 cmd = `${sshPrefix}docker exec tiger-openclaw openclaw agent --session-id agent:main:main -m '${escapedMessage}' --json --timeout 120`;
|
||||
|
||||
const tBeforeSpawn = Date.now();
|
||||
tSpawn = tBeforeSpawn - tStart;
|
||||
|
|
|
|||
197
bridge/src/routes/chat.ts.pre-ws-migration
Normal file
197
bridge/src/routes/chat.ts.pre-ws-migration
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* 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 = "c1e6a067-7ca5-423b-9506-105db0702997";
|
||||
|
||||
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);
|
||||
|
||||
// Escape the message for shell
|
||||
const escapedMessage = message.replace(/'/g, "'\\''");
|
||||
|
||||
// Use openclaw agent to send a message to the main session
|
||||
// Session ID: c1e6a067-7ca5-423b-9506-105db0702997 (agent:main:main)
|
||||
// In TIGER_REMOTE mode, prefix with ssh so docker runs on the VPS.
|
||||
const sshPrefix = process.env.TIGER_REMOTE === "true"
|
||||
? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} `
|
||||
: "";
|
||||
const cmd = `${sshPrefix}docker exec tiger-openclaw openclaw agent --session-id c1e6a067-7ca5-423b-9506-105db0702997 -m '${escapedMessage}' --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;
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
/**
|
||||
* 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
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
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;
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
/**
|
||||
* 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,7 +10,6 @@
|
|||
|
||||
import { Router } from "express";
|
||||
import { tasks, executions } from "../db.js";
|
||||
import { classifyAgent } from "../lib/llm.js";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
|
@ -30,59 +29,30 @@ 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: resolvedAgent,
|
||||
agentReason,
|
||||
assignedAgent: assignedAgent || task.assigned_agent || "manual",
|
||||
context: context || "",
|
||||
createdAt: new Date().toISOString(),
|
||||
status: "pending",
|
||||
};
|
||||
|
||||
// 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";
|
||||
// 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";
|
||||
const taskFile = `task_${taskId}.json`;
|
||||
|
||||
// First ensure the directory exists inside the container
|
||||
await execInSandbox(`mkdir -p ${inboxPath}`);
|
||||
|
||||
// Write task JSON via temp file (avoids ALL shell escaping issues)
|
||||
// Write the task file using printf (more reliable than echo with escaping)
|
||||
const taskJson = JSON.stringify(taskData, null, 2);
|
||||
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}`);
|
||||
// Escape single quotes for shell: ' -> '\''
|
||||
const escapedJson = taskJson.replace(/'/g, "'\\''");
|
||||
await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`);
|
||||
|
||||
// Create execution record
|
||||
const execution = executions.create({
|
||||
|
|
|
|||
|
|
@ -1,78 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
/**
|
||||
* 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
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,44 +1,12 @@
|
|||
/**
|
||||
* 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.
|
||||
* GET /tiger/config/models — list all models Tiger knows about
|
||||
* Response: { ok: true, models: [{ id, name, provider, reasoning, contextWindow }] }
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { execOnHost, readModels } from "../tiger.js";
|
||||
import { 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();
|
||||
|
|
@ -48,130 +16,4 @@ router.get("/", async (_req: Request, res: Response) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ─── 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;
|
||||
|
|
|
|||
|
|
@ -1,86 +0,0 @@
|
|||
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,7 +13,6 @@
|
|||
|
||||
import { Router } from "express";
|
||||
import { projects, tasks } from "../db.js";
|
||||
import { generateProjectTitle, generateProjectGoal } from "../lib/llm.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -24,43 +23,13 @@ router.get("/", (req, res) => {
|
|||
});
|
||||
|
||||
// Create project
|
||||
// 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" });
|
||||
router.post("/", (req, res) => {
|
||||
const { name, description, priority } = req.body;
|
||||
if (!name) {
|
||||
return res.status(400).json({ ok: false, error: "name is required" });
|
||||
}
|
||||
|
||||
// ── 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 },
|
||||
});
|
||||
const created = projects.create({ name, description, priority });
|
||||
res.status(201).json({ ok: true, project: created });
|
||||
});
|
||||
|
||||
// Get project with tasks
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
/**
|
||||
* 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
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
/**
|
||||
* 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
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
/**
|
||||
* 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,27 +98,21 @@ router.post("/:id/execute", async (req, res) => {
|
|||
status: "pending",
|
||||
};
|
||||
|
||||
// Write task JSON to container's inbox via docker exec
|
||||
const inboxPath = "/home/node/.openclaw/workspace/tasks/inbox";
|
||||
// Write task JSON to sandbox's inbox via kubectl exec
|
||||
const inboxPath = "/sandbox/.openclaw-data/workspace/tasks/inbox";
|
||||
const taskFile = `task_${id}.json`;
|
||||
|
||||
// Import execInSandbox dynamically to avoid circular deps
|
||||
const { execInSandbox } = await import("../tiger.js");
|
||||
|
||||
try {
|
||||
// Write task JSON via temp file (avoids ALL shell escaping issues)
|
||||
// Create directories if needed
|
||||
await execInSandbox(`mkdir -p ${inboxPath}`);
|
||||
|
||||
// Write the task file
|
||||
const taskJson = JSON.stringify(taskData, null, 2);
|
||||
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}`);
|
||||
const escapedJson = taskJson.replace(/'/g, "'\\''");
|
||||
await execInSandbox(`printf '%s' '${escapedJson}' > ${inboxPath}/${taskFile}`);
|
||||
|
||||
// Create execution record
|
||||
const execution = executions.create({
|
||||
|
|
|
|||
|
|
@ -1,74 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -9,13 +9,14 @@ 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 ───────────────────────────────────────────────
|
||||
// Tiger runs directly in the tiger-openclaw container
|
||||
const DOCKER_CONTAINER = "tiger-openclaw";
|
||||
const K8S_NAMESPACE = "openshell";
|
||||
const POD_NAME = "tiger";
|
||||
// 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";
|
||||
|
|
@ -39,40 +40,9 @@ if (IS_REMOTE) {
|
|||
console.log(`[bridge] REMOTE MODE: docker commands will run via ssh ${REMOTE_SSH}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Commands run directly via docker exec (no kubectl needed).
|
||||
*/
|
||||
export async function execInSandbox(
|
||||
command: string,
|
||||
|
|
@ -359,15 +329,12 @@ export async function listWorkspaceFiles(
|
|||
* Read a file from the Tiger workspace.
|
||||
*/
|
||||
export async function readWorkspaceFile(filepath: string): Promise<string> {
|
||||
// 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}`);
|
||||
// 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}`);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
|
|
|
|||
284
dashboard/package-lock.json
generated
284
dashboard/package-lock.json
generated
|
|
@ -20,7 +20,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",
|
||||
|
|
@ -41,7 +40,7 @@
|
|||
"shadcn": "^3.8.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
|
@ -4211,12 +4210,6 @@
|
|||
"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",
|
||||
|
|
@ -4983,15 +4976,6 @@
|
|||
"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",
|
||||
|
|
@ -5423,16 +5407,6 @@
|
|||
"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",
|
||||
|
|
@ -5622,18 +5596,6 @@
|
|||
],
|
||||
"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",
|
||||
|
|
@ -6041,12 +6003,6 @@
|
|||
"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",
|
||||
|
|
@ -6056,28 +6012,6 @@
|
|||
"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",
|
||||
|
|
@ -6087,22 +6021,6 @@
|
|||
"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",
|
||||
|
|
@ -6124,12 +6042,6 @@
|
|||
"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",
|
||||
|
|
@ -6139,15 +6051,6 @@
|
|||
"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",
|
||||
|
|
@ -6164,28 +6067,6 @@
|
|||
"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",
|
||||
|
|
@ -6231,41 +6112,6 @@
|
|||
"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",
|
||||
|
|
@ -7669,20 +7515,6 @@
|
|||
"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",
|
||||
|
|
@ -7699,32 +7531,6 @@
|
|||
"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",
|
||||
|
|
@ -8340,15 +8146,6 @@
|
|||
"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",
|
||||
|
|
@ -9069,15 +8866,6 @@
|
|||
"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",
|
||||
|
|
@ -9102,6 +8890,7 @@
|
|||
"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": {
|
||||
|
|
@ -9207,18 +8996,6 @@
|
|||
"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",
|
||||
|
|
@ -9545,12 +9322,6 @@
|
|||
"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",
|
||||
|
|
@ -9615,6 +9386,7 @@
|
|||
"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"
|
||||
|
|
@ -10682,6 +10454,7 @@
|
|||
"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"
|
||||
|
|
@ -11205,16 +10978,6 @@
|
|||
"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",
|
||||
|
|
@ -11269,6 +11032,7 @@
|
|||
"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",
|
||||
|
|
@ -11471,44 +11235,12 @@
|
|||
"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",
|
||||
|
|
@ -12826,12 +12558,6 @@
|
|||
"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",
|
||||
|
|
|
|||
|
|
@ -21,7 +21,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",
|
||||
|
|
@ -42,6 +41,6 @@
|
|||
"shadcn": "^3.8.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,112 +1,142 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { ScrollText } from "lucide-react"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((res) => res.json())
|
||||
|
||||
interface ActivityEntry {
|
||||
id: string
|
||||
type: string
|
||||
type: "heartbeat" | "chat" | "config" | "memory" | "system" | "cron"
|
||||
timestamp: string
|
||||
description: string
|
||||
source: 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,
|
||||
})
|
||||
}
|
||||
|
||||
export default function ActivityPage() {
|
||||
const [entries, setEntries] = useState<ActivityEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [limit, setLimit] = React.useState(200)
|
||||
const { data, error } = useSWR(`/api/activity?limit=${limit}`, fetcher, { refreshInterval: 10000 })
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
const entries = (data?.entries || []) as ActivityEntry[]
|
||||
const total = data?.total || 0
|
||||
const grouped = groupByDate(entries)
|
||||
|
||||
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>
|
||||
<span className="text-muted-foreground text-sm">({entries.length} entries)</span>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{/* 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>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
"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,44 +1,226 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import { bridgeGet } from "@/lib/bridge"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
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 async function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const limit = parseInt(url.searchParams.get("limit") || "50", 10)
|
||||
const limit = parseInt(url.searchParams.get("limit") || "200", 10)
|
||||
|
||||
// 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
|
||||
}>
|
||||
}
|
||||
const clawdbotLogsDir = path.join(os.homedir(), ".clawdbot", "logs")
|
||||
const workspace = "/Users/manohar_air/clawd"
|
||||
const memoryDir = path.join(workspace, "memory")
|
||||
|
||||
if (!bridgeData?.ok || !bridgeData.events) {
|
||||
return NextResponse.json({ entries: [], total: 0 })
|
||||
}
|
||||
// Aggregate from all sources
|
||||
const commandEntries = parseCommandsLog(path.join(clawdbotLogsDir, "commands.log"))
|
||||
const gatewayEntries = parseGatewayLog(path.join(clawdbotLogsDir, "gateway.log"))
|
||||
const memoryEntries = parseMemoryFiles(memoryDir)
|
||||
|
||||
// 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,
|
||||
}))
|
||||
// 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)
|
||||
|
||||
return NextResponse.json({
|
||||
entries,
|
||||
total: bridgeData.events.length,
|
||||
total: allEntries.length,
|
||||
})
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Failed to fetch activity" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
|
@ -36,20 +36,6 @@ async function persistMessage(role: "user" | "agent", content: string, sessionKe
|
|||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
@ -61,7 +47,8 @@ export async function POST(request: NextRequest) {
|
|||
});
|
||||
}
|
||||
|
||||
// Persist user message NOW, before the LLM call
|
||||
// Persist user message NOW, before the LLM call. If the call fails, the
|
||||
// history still records what the user said.
|
||||
await persistMessage("user", message, sessionKey);
|
||||
|
||||
const t0 = Date.now();
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
/**
|
||||
* /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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
// 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.",
|
||||
});
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
// 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
// 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
/**
|
||||
* /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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
/**
|
||||
* /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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
// 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,32 +1,434 @@
|
|||
"use client"
|
||||
|
||||
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"
|
||||
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[]
|
||||
availableModels?: 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,
|
||||
})
|
||||
// Agent activity — used for "Last Activity" row in health card
|
||||
const { data: agentsData } = useSWR<{ ok: boolean; agents: { lastActivity: number }[] }>(
|
||||
'/api/tiger/agents', fetcher, { refreshInterval: 30000 }
|
||||
)
|
||||
const lastActivity = React.useMemo(() => {
|
||||
const ts = agentsData?.agents?.map(a => a.lastActivity).filter(Boolean) ?? []
|
||||
return ts.length > 0 ? Math.max(...ts) : 0
|
||||
}, [agentsData])
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<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 />
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
{/* AGENTS — one strip, all 5 agents, live state at a glance. */}
|
||||
<AgentStrip />
|
||||
{/* 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 — ${status?.agent?.currentModel ?? "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>
|
||||
)}
|
||||
|
||||
{/* CONTEXT ROW — digest (left) + Telegram thread (right) */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DigestCard />
|
||||
<TelegramThreadCard />
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{/* SCHEDULE ROW — Tiger's cron jobs + next-run times */}
|
||||
<ScheduleCard />
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* FOOTER — system health strip. Becomes a banner on crash. */}
|
||||
<StatusFooter />
|
||||
<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>
|
||||
|
||||
{/* Last Activity — most recent agent file write */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Last Activity</span>
|
||||
<span className="text-xs tabular-nums">
|
||||
{lastActivity > 0
|
||||
? (() => {
|
||||
const diff = Date.now() - lastActivity
|
||||
const m = Math.floor(diff / 60000)
|
||||
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 ${m % 60}m ago`
|
||||
return `${Math.floor(h / 24)}d ago`
|
||||
})()
|
||||
: "—"}
|
||||
</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 ? "..." : (() => {
|
||||
const m = status?.agent?.currentModel || ""
|
||||
if (!m) return "Not configured"
|
||||
// openrouter/provider/model-name:tag → "model-name" + badge
|
||||
const parts = m.split("/")
|
||||
const modelSlug = parts[parts.length - 1].replace(/:.*$/, "")
|
||||
const via = parts.length >= 2 ? parts[0] : null
|
||||
return modelSlug || m
|
||||
})()}
|
||||
</div>
|
||||
{!isLoading && status?.agent?.currentModel && (
|
||||
<div className="text-xs text-muted-foreground mt-1 font-mono truncate">
|
||||
{status.agent.currentModel}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Available Models (from providers config) */}
|
||||
{status?.agent?.availableModels && status.agent.availableModels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Available Models</div>
|
||||
<div className="space-y-1">
|
||||
{status.agent.availableModels.map((model, i) => (
|
||||
<div key={i} className="p-2 rounded-md border border-border/50 bg-background/30 text-xs font-mono text-muted-foreground">
|
||||
{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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,67 +1,56 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* /projects — Dual-source project view with inline task+agent expansion
|
||||
* Projects Page — Project management with tasks
|
||||
*
|
||||
* 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.
|
||||
* Lists projects as cards and provides project detail view with Kanban.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import {
|
||||
FolderOpen, Plus, Loader2, ChevronDown, ChevronRight,
|
||||
Inbox, Trash2, MoreVertical,
|
||||
} from "lucide-react"
|
||||
import { FolderOpen, Plus, Loader2, MoreVertical, Pencil, Trash2 } 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"
|
||||
|
||||
// ─── 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 {
|
||||
interface Project {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: string
|
||||
priority: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
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
|
||||
}
|
||||
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
low: "bg-gray-500/10 text-gray-400 border-gray-500/20",
|
||||
|
|
@ -70,292 +59,131 @@ const PRIORITY_COLORS: Record<string, string> = {
|
|||
urgent: "bg-red-500/10 text-red-400 border-red-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",
|
||||
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 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 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"
|
||||
}
|
||||
|
||||
function cleanText(s: string) {
|
||||
// Strip emoji and markdown bold markers for display
|
||||
return s.replace(/\*\*/g, "").replace(/[✅⏳🔄❌🛢️🔍💻📊✍️🐯]/g, "").trim()
|
||||
}
|
||||
|
||||
// ─── Task row inside expanded project ─────────────────────────────────────────
|
||||
|
||||
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 (
|
||||
<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 { 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 [form, setForm] = React.useState({ name: "", seed: "", description: "", priority: "medium" })
|
||||
const [newProjectName, setNewProjectName] = React.useState("")
|
||||
const [newProjectDesc, setNewProjectDesc] = React.useState("")
|
||||
const [newProjectPriority, setNewProjectPriority] = React.useState("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("")
|
||||
// Load projects
|
||||
const loadProjects = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch("/api/tiger/projects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
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])
|
||||
|
||||
// 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])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadProjects()
|
||||
}, [loadProjects])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedProject) {
|
||||
loadTasks(selectedProject.id)
|
||||
}
|
||||
}, [selectedProject, loadTasks])
|
||||
|
||||
const handleCreateProject = async () => {
|
||||
if (!newProjectName.trim()) return
|
||||
setCreating(true)
|
||||
try {
|
||||
await request("/api/tiger/projects", "POST", {
|
||||
name: newProjectName,
|
||||
description: newProjectDesc,
|
||||
priority: newProjectPriority,
|
||||
})
|
||||
const result = await res.json()
|
||||
if (!result.ok) throw new Error(result.error ?? "Failed")
|
||||
setForm({ name: "", seed: "", description: "", priority: "medium" })
|
||||
setNewProjectName("")
|
||||
setNewProjectDesc("")
|
||||
setNewProjectPriority("medium")
|
||||
setIsCreateOpen(false)
|
||||
mutateDb()
|
||||
} catch (e: any) { setCreateError(e.message) }
|
||||
finally { setCreating(false) }
|
||||
loadProjects()
|
||||
} catch (e: unknown) {
|
||||
console.error("Failed to create project:", e)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await fetch(`/api/tiger/projects/${id}`, { method: "DELETE" })
|
||||
mutateDb()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-4xl">
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -364,86 +192,178 @@ export default function ProjectsPage() {
|
|||
Projects
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tiger's projects from <span className="font-mono text-xs">PROJECTS.md</span>.
|
||||
Click a project to see its tasks and agents.
|
||||
Manage projects and track tasks across your team
|
||||
</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>Queue a Project for Tiger</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tiger will pick this up and add it to PROJECTS.md.
|
||||
</DialogDescription>
|
||||
<DialogTitle>Create Project</DialogTitle>
|
||||
<DialogDescription>Create a new project to organize tasks.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="space-y-4 pt-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Name</label>
|
||||
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. BESS Economics Model" className="mt-1" />
|
||||
<Input
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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" />
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Input
|
||||
value={newProjectDesc}
|
||||
onChange={(e) => setNewProjectDesc(e.target.value)}
|
||||
placeholder="Project description"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Priority</label>
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
||||
<Button onClick={handleCreate} disabled={creating || (!form.name.trim() && !form.seed.trim())} className="w-full">
|
||||
<Button onClick={handleCreateProject} disabled={creating || !newProjectName.trim()}>
|
||||
{creating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Queue Project
|
||||
Create Project
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Projects Grid */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
) : 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="flex flex-col gap-3">
|
||||
{fileProjects.map((p) => <FileProjectCard key={p.id} project={p} />)}
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
<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 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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,12 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* settings/page.tsx — Tiger configuration + API key management
|
||||
* settings/page.tsx — Tiger configuration
|
||||
*
|
||||
* 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
|
||||
* Sections:
|
||||
* 1. Model — primary model dropdown + fallback models
|
||||
* 2. Session — dmScope, compaction mode
|
||||
* 3. Telegram — enabled toggle, streaming mode
|
||||
* 4. Commands — native commands, ownerDisplay, restart
|
||||
* 4. Commands — native commands, ownerDisplay
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
|
|
@ -16,7 +14,6 @@ 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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
|
@ -34,22 +31,12 @@ interface ModelInfo {
|
|||
}
|
||||
|
||||
interface OpenClawConfig {
|
||||
agents?: {
|
||||
defaults?: {
|
||||
model?: { primary?: string; fallbacks?: string[] }
|
||||
compaction?: { mode?: string }
|
||||
}
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
interface KeyPresence {
|
||||
isSet: boolean
|
||||
preview?: string
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then(r => r.json())
|
||||
|
|
@ -70,7 +57,7 @@ function set(obj: any, path: string, value: any): any {
|
|||
return result
|
||||
}
|
||||
|
||||
// ─── Shared sub-components ────────────────────────────────────────────────────
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
|
|
@ -120,17 +107,26 @@ function SelectInput({ value, options, onChange }: {
|
|||
)
|
||||
}
|
||||
|
||||
// ─── Model dropdown component ─────────────────────────────────────────────────
|
||||
|
||||
function ModelSelect({ value, models, onChange }: {
|
||||
value: string
|
||||
models: ModelInfo[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
// Group by provider
|
||||
const grouped = models.reduce<Record<string, ModelInfo[]>>((acc, m) => {
|
||||
if (!acc[m.provider]) acc[m.provider] = []
|
||||
acc[m.provider].push(m)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
minimax: "MiniMax",
|
||||
"minimax-portal": "MiniMax Portal",
|
||||
openrouter: "OpenRouter",
|
||||
}
|
||||
|
||||
const current = models.find(m => m.id === value)
|
||||
|
||||
return (
|
||||
|
|
@ -141,13 +137,15 @@ function ModelSelect({ value, models, onChange }: {
|
|||
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}>
|
||||
<optgroup key={prov} label={providerLabels[prov] ?? prov}>
|
||||
{mods.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Model detail badge row */}
|
||||
{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">
|
||||
|
|
@ -160,9 +158,14 @@ function ModelSelect({ value, models, onChange }: {
|
|||
)}
|
||||
{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`}
|
||||
{current.contextWindow >= 1000000
|
||||
? `${(current.contextWindow / 1000000).toFixed(1)}M ctx`
|
||||
: `${Math.round(current.contextWindow / 1000)}K ctx`}
|
||||
</span>
|
||||
)}
|
||||
{current.cost && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
${current.cost.input}/M in · ${current.cost.output}/M out
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -171,6 +174,8 @@ function ModelSelect({ value, models, onChange }: {
|
|||
)
|
||||
}
|
||||
|
||||
// ─── Section card wrapper ─────────────────────────────────────────────────────
|
||||
|
||||
function SectionCard({ icon: Icon, title, description, children, dirty }: {
|
||||
icon: React.ElementType
|
||||
title: string
|
||||
|
|
@ -195,214 +200,21 @@ function SectionCard({ icon: Icon, title, description, children, dirty }: {
|
|||
)
|
||||
}
|
||||
|
||||
// ─── 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)
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
// Raw config from bridge
|
||||
const { data: configData, mutate: mutateConfig, isLoading: configLoading } =
|
||||
useSWR<{ ok: boolean; config: OpenClawConfig }>("/api/tiger/config", fetcher)
|
||||
|
||||
// Available models
|
||||
const { data: modelsData, isLoading: modelsLoading } =
|
||||
useSWR<{ ok: boolean; models: ModelInfo[] }>("/api/tiger/config/models", fetcher)
|
||||
|
||||
const remoteConfig = configData?.config ?? {}
|
||||
const models = modelsData?.models ?? []
|
||||
|
||||
// ── Local draft — tracks unsaved edits ─────────────────────────────────────
|
||||
const [draft, setDraft] = React.useState<OpenClawConfig>({})
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
|
||||
|
|
@ -413,12 +225,18 @@ export default function SettingsPage() {
|
|||
}
|
||||
}, [configData, initialized])
|
||||
|
||||
const update = (path: string, value: any) => setDraft(prev => set(prev, path, value))
|
||||
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)
|
||||
|
||||
// Dirty check — compare draft to remote at path level
|
||||
const isDirty = (path: string) => JSON.stringify(g(path)) !== JSON.stringify(r(path))
|
||||
const anyDirty = JSON.stringify(draft) !== JSON.stringify(remoteConfig)
|
||||
|
||||
// ── Save ────────────────────────────────────────────────────────────────────
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle")
|
||||
const [saveError, setSaveError] = React.useState("")
|
||||
|
|
@ -436,7 +254,7 @@ export default function SettingsPage() {
|
|||
if (!data.ok) throw new Error(data.error ?? "Save failed")
|
||||
setSaveState("ok")
|
||||
await mutateConfig()
|
||||
setInitialized(false)
|
||||
setInitialized(false) // re-sync draft from fresh server data
|
||||
setTimeout(() => setSaveState("idle"), 3000)
|
||||
} catch (err: any) {
|
||||
setSaveError(err.message)
|
||||
|
|
@ -453,6 +271,7 @@ export default function SettingsPage() {
|
|||
|
||||
const loading = configLoading || modelsLoading || !initialized
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-3xl">
|
||||
|
||||
|
|
@ -463,7 +282,7 @@ export default function SettingsPage() {
|
|||
<Settings2 className="h-6 w-6" /> Settings
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
API keys and Tiger configuration.
|
||||
Live Tiger configuration — writes directly to openclaw.json inside the container.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -475,13 +294,13 @@ export default function SettingsPage() {
|
|||
? <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"}
|
||||
: <Save className="h-4 w-4 mr-1" />}
|
||||
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save error */}
|
||||
{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" />
|
||||
|
|
@ -489,31 +308,35 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 0. API Keys (always rendered — has its own data fetching) ─── */}
|
||||
<ApiKeysSection />
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── 1. Model ─────────────────────────────────────────────────── */}
|
||||
{/* ── 1. Model ───────────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Cpu}
|
||||
title="Model"
|
||||
description="Global model for Tiger and sub-agents. Per-agent overrides live on the Agents page."
|
||||
description="Which AI model Tiger and sub-agents use. Changes take effect on the next conversation."
|
||||
dirty={isDirty("agents.defaults.model.primary") || isDirty("agents.defaults.model.fallbacks")}
|
||||
>
|
||||
<SettingRow label="Primary model" hint="Active model for all agents (unless overridden)">
|
||||
<SettingRow
|
||||
label="Primary model"
|
||||
hint="Active model for all agents"
|
||||
>
|
||||
<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">
|
||||
|
||||
<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(", ")}
|
||||
|
|
@ -525,7 +348,11 @@ export default function SettingsPage() {
|
|||
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">
|
||||
|
||||
<SettingRow
|
||||
label="Compaction mode"
|
||||
hint="How Tiger handles context window limits"
|
||||
>
|
||||
<SelectInput
|
||||
value={g("agents.defaults.compaction.mode", "safeguard")}
|
||||
options={[
|
||||
|
|
@ -538,14 +365,17 @@ export default function SettingsPage() {
|
|||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 2. Session ───────────────────────────────────────────────── */}
|
||||
{/* ── 2. Session ─────────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Bot}
|
||||
title="Session"
|
||||
description="Conversation session and identity scoping."
|
||||
description="How Tiger manages conversation sessions and identity scoping."
|
||||
dirty={isDirty("session.dmScope")}
|
||||
>
|
||||
<SettingRow label="DM scope" hint="Context isolation between Telegram chats">
|
||||
<SettingRow
|
||||
label="DM scope"
|
||||
hint="How Tiger isolates context between different Telegram chats"
|
||||
>
|
||||
<SelectInput
|
||||
value={g("session.dmScope", "per-channel-peer")}
|
||||
options={[
|
||||
|
|
@ -558,7 +388,7 @@ export default function SettingsPage() {
|
|||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 3. Telegram ──────────────────────────────────────────────── */}
|
||||
{/* ── 3. Telegram ────────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={MessageSquare}
|
||||
title="Telegram"
|
||||
|
|
@ -571,7 +401,11 @@ export default function SettingsPage() {
|
|||
onChange={v => update("channels.telegram.enabled", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Streaming" hint="How Tiger sends updates while generating">
|
||||
|
||||
<SettingRow
|
||||
label="Streaming"
|
||||
hint="How Tiger sends message updates while generating"
|
||||
>
|
||||
<SelectInput
|
||||
value={g("channels.telegram.streaming", "partial")}
|
||||
options={[
|
||||
|
|
@ -584,14 +418,17 @@ export default function SettingsPage() {
|
|||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 4. Commands ──────────────────────────────────────────────── */}
|
||||
{/* ── 4. Commands ────────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Terminal}
|
||||
title="Commands"
|
||||
description="Native and system command settings."
|
||||
description="How Tiger handles native and system commands."
|
||||
dirty={isDirty("commands.native") || isDirty("commands.ownerDisplay") || isDirty("commands.restart")}
|
||||
>
|
||||
<SettingRow label="Native commands" hint="Whether Tiger can run shell commands on the host">
|
||||
<SettingRow
|
||||
label="Native commands"
|
||||
hint="Whether Tiger can run shell commands on the host"
|
||||
>
|
||||
<SelectInput
|
||||
value={g("commands.native", "auto")}
|
||||
options={[
|
||||
|
|
@ -602,17 +439,25 @@ export default function SettingsPage() {
|
|||
onChange={v => update("commands.native", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Owner display" hint="How your name appears to Tiger">
|
||||
|
||||
<SettingRow
|
||||
label="Owner display"
|
||||
hint="How Manohar's name appears to Tiger in context"
|
||||
>
|
||||
<SelectInput
|
||||
value={g("commands.ownerDisplay", "raw")}
|
||||
options={[
|
||||
{ value: "raw", label: "Raw — as-is" },
|
||||
{ value: "raw", label: "Raw — show 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">
|
||||
|
||||
<SettingRow
|
||||
label="Allow restart"
|
||||
hint="Tiger can restart itself when needed"
|
||||
>
|
||||
<Toggle
|
||||
checked={g("commands.restart", true)}
|
||||
onChange={v => update("commands.restart", v)}
|
||||
|
|
|
|||
|
|
@ -1,287 +1,85 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* /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="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>
|
||||
{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 ────────────────────────────────────────────────────────────────
|
||||
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"
|
||||
|
||||
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 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" />
|
||||
Tasks
|
||||
Task Progress
|
||||
</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 className="text-muted-foreground">
|
||||
Manage and track tasks across your team and AI sub-agents
|
||||
</p>
|
||||
</div>
|
||||
</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">
|
||||
{/* 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-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-xl font-bold tabular-nums">{isLoading ? "—" : value}</p>
|
||||
<p className="text-sm text-muted-foreground">Active Tasks</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<Icon className={cn("h-4 w-4", color)} />
|
||||
<Clock className="h-5 w-5 text-blue-400" />
|
||||
</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>
|
||||
) : (
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<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>
|
||||
<p className="text-sm text-muted-foreground">In Progress</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
) : (
|
||||
orderedStatuses.map((status) =>
|
||||
grouped[status]?.length ? (
|
||||
<FileTaskSection key={status} status={status} tasks={grouped[status]} />
|
||||
) : null
|
||||
)
|
||||
)}
|
||||
<GitBranch className="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
<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} />)}
|
||||
<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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
"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,35 +1,16 @@
|
|||
"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
|
||||
* 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 {
|
||||
Home,
|
||||
MessageSquare,
|
||||
Briefcase,
|
||||
Bot,
|
||||
Brain,
|
||||
FolderOpen,
|
||||
DollarSign,
|
||||
ScrollText,
|
||||
Settings2,
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
ScrollText,
|
||||
CheckSquare,
|
||||
DollarSign,
|
||||
FolderOpen,
|
||||
Briefcase,
|
||||
} from "lucide-react"
|
||||
import { useTigerLogs } from "@/hooks/use-bridge"
|
||||
|
||||
|
|
@ -44,28 +25,56 @@ import {
|
|||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
// Primary navigation — the main verbs of using Tiger.
|
||||
// Ordered by frequency-of-use so the most-tapped items sit at the top.
|
||||
// Tiger-specific navigation - no more old clawdbot pages
|
||||
const navMain = [
|
||||
{ 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: "Activity", url: "/activity", icon: ScrollText },
|
||||
{ title: "Cost", url: "/cost", icon: DollarSign },
|
||||
{ title: "Logs", url: "/logs", icon: ScrollText },
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: "Chat",
|
||||
url: "/chat",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
title: "Projects",
|
||||
url: "/projects",
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
title: "Workspace",
|
||||
url: "/workspace",
|
||||
icon: FolderOpen,
|
||||
},
|
||||
{
|
||||
title: "Tasks",
|
||||
url: "/tasks",
|
||||
icon: CheckSquare,
|
||||
},
|
||||
{
|
||||
title: "Cost Monitor",
|
||||
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 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.
|
||||
// Use Tiger logs SSE for connection status
|
||||
// connected means the bridge is reachable
|
||||
const { connected } = useTigerLogs({ lines: 1, maxLines: 1 })
|
||||
|
||||
return (
|
||||
|
|
@ -75,29 +84,20 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<a href="/">
|
||||
{/* 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 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 className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<Bot className="size-4" />
|
||||
</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>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarMenu className="gap-1 p-2">
|
||||
<SidebarMenu className="gap-2 p-2">
|
||||
{navMain.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
|
|
@ -110,12 +110,11 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
{navSecondary.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild size="sm" tooltip={item.title}>
|
||||
<SidebarMenuButton asChild size="sm">
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
|
|
@ -125,7 +124,6 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
|
|
|
|||
297
dashboard/src/components/chat-interface.tsx.pre-ws
Normal file
297
dashboard/src/components/chat-interface.tsx.pre-ws
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Send, Square, Bot, User, AlertCircle, Loader2, Eraser } 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 { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useChatContext } from "@/contexts/chat-context"
|
||||
|
||||
export function ChatInterface({ className, ...props }: React.ComponentProps<typeof Card>) {
|
||||
const [input, setInput] = React.useState("")
|
||||
// Persistent chat state — survives navigation between routes.
|
||||
// See contexts/chat-context.tsx for the rationale.
|
||||
const { messages, setMessages, clearChat } = useChatContext()
|
||||
const [sending, setSending] = React.useState(false)
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null)
|
||||
const abortRef = React.useRef<AbortController | null>(null)
|
||||
const streamingRef = React.useRef("")
|
||||
|
||||
React.useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!input.trim() || sending) return
|
||||
|
||||
const text = input.trim()
|
||||
setInput("")
|
||||
setSending(true)
|
||||
streamingRef.current = ""
|
||||
|
||||
// Add user message
|
||||
setMessages(prev => [...prev, {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
|
||||
try {
|
||||
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 }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!res.ok || !res.body) throw new Error("Failed to connect")
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
// Buffer across reads — a single SSE event ("data: ...\n\n") may be
|
||||
// split across TCP chunks. Accumulate, then split on the SSE delimiter.
|
||||
let buffer = ""
|
||||
|
||||
const streamId = `streaming-${Date.now()}`
|
||||
|
||||
while (true) {
|
||||
const { done: readerDone, value } = await reader.read()
|
||||
if (readerDone) break
|
||||
|
||||
// {stream: true} preserves decoder state for multi-byte UTF-8 chars
|
||||
// (e.g. emoji) that happen to land across chunk boundaries.
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
// SSE events end with a blank line (\n\n). Anything after the last
|
||||
// \n\n is a partial event — keep it in `buffer` for the next read.
|
||||
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) {
|
||||
// Don't swallow silently — log so real parse bugs are visible.
|
||||
console.warn("[chat] SSE parse error:", err, "line:", dataLine)
|
||||
continue
|
||||
}
|
||||
|
||||
console.log("[chat] event:", data.type, "content:", data.content?.substring(0, 50))
|
||||
|
||||
if (data.type === "status") {
|
||||
// Transient 'Tiger is thinking...' indicator. Do NOT append to
|
||||
// the message content — that was Bug A. Just ensure a streaming
|
||||
// placeholder exists so the UI shows activity.
|
||||
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 === "message") {
|
||||
// Non-streaming full message
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
return [...filtered, {
|
||||
id: `agent-${Date.now()}`,
|
||||
role: "agent",
|
||||
content: data.content || "",
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
} else if (data.type === "done") {
|
||||
// Fall back to data.content if the chunk event somehow didn't
|
||||
// land — Bug D. This is a belt-and-suspenders safety.
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
|
||||
abortRef.current = null
|
||||
}
|
||||
|
||||
const handleAbort = () => {
|
||||
abortRef.current?.abort()
|
||||
setSending(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">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bot className="h-5 w-5" />
|
||||
Chat with Tiger
|
||||
</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearChat}
|
||||
className="text-xs text-muted-foreground h-7"
|
||||
title="Clear conversation"
|
||||
>
|
||||
<Eraser className="h-3 w-3 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
</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">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-2 max-w-[85%]",
|
||||
message.role === "user" ? "ml-auto flex-row-reverse" : "",
|
||||
message.role === "system" ? "mx-auto max-w-full" : ""
|
||||
)}
|
||||
>
|
||||
{message.role !== "system" && (
|
||||
<div className={cn(
|
||||
"flex-shrink-0 h-7 w-7 rounded-full flex items-center justify-center",
|
||||
message.role === "user" ? "bg-primary" : "bg-muted"
|
||||
)}>
|
||||
{message.role === "user" ? (
|
||||
<User className="h-4 w-4 text-primary-foreground" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4" />
|
||||
)}
|
||||
</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" : ""
|
||||
)}>
|
||||
{message.role === "system" && <AlertCircle className="h-3 w-3" />}
|
||||
{message.role === "agent" ? (
|
||||
// While streaming: render raw text (cheap, one DOM node update per token).
|
||||
// After streaming completes: render full ReactMarkdown (expensive but
|
||||
// only happens once). This is what makes the typing feel actually show up.
|
||||
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
|
||||
)}
|
||||
{message.streaming && (
|
||||
<span className="inline-block w-1.5 h-4 bg-primary/70 animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{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" />
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg px-3 py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<form onSubmit={handleSubmit} className="flex w-full items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Message Tiger..."
|
||||
className="flex-1"
|
||||
autoComplete="off"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
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()}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,186 +0,0 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
"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,15 +72,14 @@ export function KanbanBoard() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Load tasks from TASKS.md via /api/tiger/file-tasks
|
||||
// Load tasks from API
|
||||
const loadTasks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await request("/api/tiger/file-tasks") as { ok: boolean; tasks?: any[] }
|
||||
const data = await request("/api/tiger/tasks") as { ok: boolean; tasks?: Task[] }
|
||||
if (data.ok && data.tasks) {
|
||||
setTasks(data.tasks.map((t: any) => ({
|
||||
setTasks(data.tasks.map((t: Task) => ({
|
||||
...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 || [],
|
||||
})))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
"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>
|
||||
)
|
||||
}
|
||||
|
|
@ -131,16 +131,11 @@ export function ChatProvider({ children }: { children: React.ReactNode }) {
|
|||
// 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() {
|
||||
const persisted = readPersistedKey()
|
||||
if (cancelled) return
|
||||
setCurrentSessionKey(initialKey)
|
||||
const [hist] = await Promise.all([loadHistoryFor(initialKey), refreshSessions()])
|
||||
setCurrentSessionKey(persisted)
|
||||
const [hist] = await Promise.all([loadHistoryFor(persisted), refreshSessions()])
|
||||
if (!cancelled) {
|
||||
setMessages(hist)
|
||||
setLoading(false)
|
||||
|
|
|
|||
|
|
@ -177,27 +177,6 @@ export async function bridgePut(
|
|||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
export async function bridgePatch(
|
||||
path: string,
|
||||
body: Record<string, unknown> = {}
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(BRIDGE_URL + path, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
throw new Error('Bridge PATCH ' + path + ' failed: ' + res.status + ' ' + errBody);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function bridgeLogsUrl(lines = 100, filter = ""): string {
|
||||
const url = new URL(`${BRIDGE_URL}/tiger/logs`);
|
||||
url.searchParams.set("lines", String(lines));
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ set -euo pipefail
|
|||
|
||||
# ─── Configuration ───────────────────────────────────────────────────
|
||||
SERVER="root@100.75.128.45"
|
||||
SERVER_PATH="/root/OpenClawDashboard"
|
||||
SERVER_PATH="/root/NemoClawDashboard"
|
||||
LOCAL_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Colors — makes scanning the output easier when things go wrong
|
||||
|
|
@ -77,7 +77,7 @@ section "Pre-flight checks"
|
|||
# [1] Are we in the right directory?
|
||||
cd "$LOCAL_PATH"
|
||||
if [ ! -d .git ] || [ ! -d dashboard ] || [ ! -d bridge ]; then
|
||||
die "Not in OpenClawDashboard repo root. cd to the repo first."
|
||||
die "Not in NemoClawDashboard repo root. cd to the repo first."
|
||||
fi
|
||||
ok "In repo: $LOCAL_PATH"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,102 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
# scripts/smoke-test.sh — Tiger Bridge smoke test
|
||||
# Run after every deploy: bash scripts/smoke-test.sh
|
||||
# Wire into deploy.sh as the final step.
|
||||
set -uo pipefail
|
||||
|
||||
BRIDGE_ENV="/root/OpenClawDashboard/bridge/.env"
|
||||
TOKEN=$(grep ^TIGER_BRIDGE_TOKEN= "$BRIDGE_ENV" | cut -d= -f2-)
|
||||
H="Authorization: Bearer $TOKEN"
|
||||
B="http://127.0.0.1:3456"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
green() { echo -e "\033[32m$*\033[0m"; }
|
||||
red() { echo -e "\033[31m$*\033[0m"; }
|
||||
|
||||
check_json() {
|
||||
local name=$1 url=$2 field=$3
|
||||
local resp
|
||||
resp=$(curl -sf -H "$H" "$B$url" 2>/dev/null)
|
||||
local code=$?
|
||||
if [ $code -ne 0 ]; then
|
||||
red "FAIL $name → curl error (bridge down?)"
|
||||
((FAIL++)); return
|
||||
fi
|
||||
if echo "$resp" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d.get('$field') is not None" 2>/dev/null; then
|
||||
green "PASS $name"
|
||||
((PASS++))
|
||||
else
|
||||
red "FAIL $name → $(echo "$resp" | head -c 150)"
|
||||
((FAIL++))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Tiger Bridge Smoke Test $(date '+%Y-%m-%d %H:%M:%S')"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# ── Core endpoints ─────────────────────────────────────────────────────────────
|
||||
check_json "status" /tiger/status "status"
|
||||
check_json "file-tasks" /tiger/file-tasks "tasks"
|
||||
check_json "file-tasks/active" /tiger/file-tasks/active "tasks"
|
||||
check_json "file-projects" /tiger/file-tasks/projects "projects"
|
||||
check_json "cron" /tiger/cron "jobs"
|
||||
check_json "models" /tiger/config/models "models"
|
||||
check_json "keys" /tiger/keys "ok"
|
||||
|
||||
# ── Auth: unauthenticated request must return 401 ──────────────────────────────
|
||||
http_code=$(curl -s -o /dev/null -w '%{http_code}' "$B/tiger/status")
|
||||
if [ "$http_code" = "401" ]; then
|
||||
green "PASS auth-required (got 401)"
|
||||
((PASS++))
|
||||
else
|
||||
red "FAIL auth-required (got $http_code, expected 401)"
|
||||
((FAIL++))
|
||||
fi
|
||||
|
||||
# ── OpenClaw direct exec ───────────────────────────────────────────────────────
|
||||
if docker exec tiger-openclaw openclaw agent --session-id smoke-test -m "reply OK only" \
|
||||
--json --timeout 30 2>/dev/null | grep -q '"text"'; then
|
||||
green "PASS openclaw-direct"
|
||||
((PASS++))
|
||||
else
|
||||
red "FAIL openclaw-direct (container issue or timeout)"
|
||||
((FAIL++))
|
||||
fi
|
||||
|
||||
# ── Model fallback chain check (verify config) ─────────────────────────────────
|
||||
fallbacks=$(python3 -c "
|
||||
import json
|
||||
with open('/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json') as f:
|
||||
c = json.load(f)
|
||||
fb = c.get('agents',{}).get('defaults',{}).get('model',{}).get('fallbacks',[])
|
||||
print(len(fb))
|
||||
" 2>/dev/null)
|
||||
if [ "${fallbacks:-0}" -ge 2 ]; then
|
||||
green "PASS model-fallback-chain (${fallbacks} fallbacks configured)"
|
||||
((PASS++))
|
||||
else
|
||||
red "FAIL model-fallback-chain (only ${fallbacks:-0} fallback(s) — need ≥2)"
|
||||
((FAIL++))
|
||||
fi
|
||||
|
||||
# ── TASKS.md JSON block present ────────────────────────────────────────────────
|
||||
if docker exec tiger-openclaw grep -q '```json' /home/node/.openclaw/workspace/TASKS.md 2>/dev/null; then
|
||||
green "PASS tasks-json-block (TASKS.md has JSON block)"
|
||||
((PASS++))
|
||||
else
|
||||
red "FAIL tasks-json-block (TASKS.md missing TASKS_JSON block — Tiger needs to add it)"
|
||||
((FAIL++))
|
||||
fi
|
||||
|
||||
# ── Summary ────────────────────────────────────────────────────────────────────
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
total=$((PASS + FAIL))
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
green " ALL PASSED ($PASS/$total)"
|
||||
else
|
||||
red " $FAIL FAILED ($PASS/$total passed)"
|
||||
fi
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
[ $FAIL -eq 0 ] # exit 0 on all pass, 1 on any failure
|
||||
Loading…
Add table
Reference in a new issue