Compare commits
No commits in common. "572418f0ea6f9f815895c626d9cdb8a49f1daa73" and "d4a3f2b86986aae4d48e6782a9b4e2790cf8475c" have entirely different histories.
572418f0ea
...
d4a3f2b869
112 changed files with 2157 additions and 13934 deletions
17
.gitignore
vendored
17
.gitignore
vendored
|
|
@ -36,20 +36,3 @@ config/*.json
|
|||
!config/*.example.json
|
||||
config/mcporter.json
|
||||
config/cron.json
|
||||
|
||||
# ─── Added by housecleaning Apr 2026 ───
|
||||
# Claude Code session worktrees (local workspace artifacts)
|
||||
.claude/
|
||||
# Runtime SQLite databases — schema is in db.ts, not data/
|
||||
data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
# Backup files from patching sessions
|
||||
*.bak
|
||||
*.bak.*
|
||||
# Compiled bridge (regenerable from src/)
|
||||
bridge/dist/
|
||||
# macOS artifacts that can slip in via Mutagen
|
||||
.DS_Store
|
||||
._*
|
||||
|
|
|
|||
292
ARCHITECTURE.md
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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ if (!fs.existsSync(DATA_DIR)) {
|
|||
}
|
||||
|
||||
const DB_PATH = path.join(DATA_DIR, "tiger.db");
|
||||
const db: Database.Database = new Database(DB_PATH);
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Enable WAL mode for better concurrency
|
||||
db.pragma("journal_mode = WAL");
|
||||
|
|
@ -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 '',
|
||||
|
|
@ -59,18 +56,6 @@ db.exec(`
|
|||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS chat_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('user', 'agent', 'system')),
|
||||
content TEXT NOT NULL,
|
||||
-- 'meta' is optional JSON for things like model used, tokens, duration.
|
||||
meta TEXT DEFAULT '{}',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created
|
||||
ON chat_messages (session_id, created_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
|
|
@ -102,14 +87,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 +164,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 +182,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 +197,6 @@ export const tasks = {
|
|||
tags: string;
|
||||
notes: string;
|
||||
due_date: string;
|
||||
agent_reason: string;
|
||||
}>): unknown | undefined {
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
|
@ -236,8 +210,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,99 +1,48 @@
|
|||
/**
|
||||
* 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/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
|
||||
* 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
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { authMiddleware } from "./auth.js";
|
||||
import statusRouter from "./routes/status.js";
|
||||
import healthRouter from "./routes/health.js";
|
||||
import suggestionsRouter from "./routes/suggestions.js";
|
||||
import alertsRouter from "./routes/alerts.js";
|
||||
import spawnRouter from "./routes/spawn.js";
|
||||
import contextRouter from "./routes/context.js";
|
||||
import logsRouter from "./routes/logs.js";
|
||||
import execRouter from "./routes/exec.js";
|
||||
import configRouter from "./routes/config.js";
|
||||
import modelsRouter from "./routes/models.js";
|
||||
import restartRouter from "./routes/restart.js";
|
||||
import filesRouter from "./routes/files.js";
|
||||
import projectsRouter from "./routes/projects.js";
|
||||
import tasksRouter from "./routes/tasks.js";
|
||||
import tasksFileRouter from "./routes/tasks-file.js";
|
||||
import cronRouter from "./routes/cron.js";
|
||||
import notifyRouter from "./routes/notify.js";
|
||||
import dispatchRouter from "./routes/dispatch.js";
|
||||
import agentsRouter from "./routes/agents.js";
|
||||
import agentsActivityRouter from "./routes/agents-activity.js";
|
||||
import deployRouter from "./routes/deploy.js";
|
||||
import routeTaskRouter from "./routes/route-task.js";
|
||||
import keysRouter from "./routes/keys.js";
|
||||
import { initWatcher } from "./watcher.js";
|
||||
import { TelegramChannel } from "./lib/telegram.js";
|
||||
|
||||
// Import db to ensure it's initialized
|
||||
import "./db.js";
|
||||
|
||||
// ─── Configuration ─────────────────────────────────────────────────────────
|
||||
const PORT = parseInt(process.env.TIGER_BRIDGE_PORT || "3456", 10);
|
||||
const HOST = process.env.TIGER_BRIDGE_HOST || "0.0.0.0"; // Bind to all interfaces for Docker access
|
||||
const HOST = process.env.TIGER_BRIDGE_HOST || "127.0.0.1"; // Only localhost — Caddy handles HTTPS
|
||||
|
||||
const app = express();
|
||||
|
||||
|
|
@ -125,17 +74,9 @@ app.get("/health", (_req, res) => {
|
|||
|
||||
// Tiger endpoints — all scoped under /tiger
|
||||
app.use("/tiger/status", statusRouter);
|
||||
app.use("/tiger/health", healthRouter);
|
||||
app.use("/tiger/suggestions", suggestionsRouter);
|
||||
app.use("/tiger/alerts", alertsRouter);
|
||||
app.use("/tiger/spawn", spawnRouter);
|
||||
app.use("/tiger/context", contextRouter);
|
||||
app.use("/tiger/knowledge", (await import("./routes/knowledge.js")).default);
|
||||
app.use("/tiger/feedback", (await import("./routes/feedback.js")).default);
|
||||
app.use("/tiger/logs", logsRouter); // SSE stream
|
||||
app.use("/tiger/exec", execRouter);
|
||||
app.use("/tiger/config", configRouter);
|
||||
app.use("/tiger/config/models", modelsRouter);
|
||||
app.use("/tiger/restart", restartRouter);
|
||||
app.use("/tiger/workspace", filesRouter);
|
||||
app.use("/tiger/files", filesRouter); // Same router handles both /workspace and /files/:path
|
||||
|
|
@ -143,34 +84,7 @@ 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);
|
||||
// Telegram mirror v2 — reads OpenClaw's native session transcript directly.
|
||||
// (chat-mirror + telegram-webhook above are the legacy write-side, kept for
|
||||
// API compatibility but no longer the data source for the dashboard card.)
|
||||
app.use("/tiger/chat/telegram", (await import("./routes/chat-telegram.js")).default);
|
||||
app.use("/tiger/telegram-webhook", (await import("./routes/telegram-webhook.js")).default);
|
||||
|
||||
// TASKS.md inbox — manual drain trigger (the scheduler below runs it on its own)
|
||||
const { drainInboxOnce, startInboxScheduler } = await import("./lib/inbox.js");
|
||||
app.post("/tiger/inbox/drain", async (_req, res) => {
|
||||
const result = await drainInboxOnce(true);
|
||||
res.json({ ok: !result.startsWith("error"), result });
|
||||
});
|
||||
app.use("/angel", (await import("./routes/angel/positions.js")).default);
|
||||
|
||||
// Gateway proxy — forwards to gateway inside Tiger container
|
||||
// This is needed because the dashboard runs in Dokploy which can't reach the container directly
|
||||
app.use("/api/gateway", (await import("./routes/gateway.js")).default);
|
||||
|
||||
// ─── Error handling ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -196,13 +110,4 @@ app.listen(PORT, HOST, () => {
|
|||
|
||||
// Initialize file watcher for task status updates
|
||||
initWatcher();
|
||||
|
||||
// TASKS.md inbox drainer — dispatches one pending item per cycle to a
|
||||
// spawned specialist. See lib/inbox.ts for the contract.
|
||||
startInboxScheduler();
|
||||
|
||||
// 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,132 +0,0 @@
|
|||
/**
|
||||
* lib/agents.ts — Sub-agent registry (single source of truth)
|
||||
*
|
||||
* Why this file exists:
|
||||
* Agent identity was previously scattered across spawn.ts, agents-activity.ts,
|
||||
* dispatch.ts and the dashboard, with TWO competing id schemes:
|
||||
* - classifier ids: cody / ethan / cathy / elon (lib/llm.ts AGENT_IDS)
|
||||
* - legacy UI ids: coder / researcher / writer / pm
|
||||
* This registry canonicalizes on the classifier ids and maps legacy
|
||||
* aliases onto them, so every layer can call normalizeAgentId() and agree.
|
||||
*
|
||||
* Personas:
|
||||
* Sub-agents currently run as isolated *sessions* of the `main` OpenClaw
|
||||
* agent (one shared workspace, separate conversation histories). The
|
||||
* persona block below is prepended to the task message, acting as the
|
||||
* specialist's system prompt for that session.
|
||||
*
|
||||
* Upgrade path (documented, not yet taken): define real per-agent entries
|
||||
* in openclaw.json `agents.list`, each with its own IDENTITY.md and
|
||||
* workspace, then change ONE line in spawn.ts — the `--agent` flag.
|
||||
*/
|
||||
|
||||
export type SpecialistId = "cody" | "ethan" | "cathy" | "elon";
|
||||
|
||||
export interface SpecialistAgent {
|
||||
id: SpecialistId;
|
||||
/** Display name used across the dashboard and Telegram reports. */
|
||||
name: string;
|
||||
/** Short role label for UI chips. */
|
||||
role: string;
|
||||
/** Legacy ids that must keep working (old UI, old API callers). */
|
||||
aliases: string[];
|
||||
/** Persona preamble injected at the top of every spawned session. */
|
||||
persona: string;
|
||||
}
|
||||
|
||||
export const SPECIALISTS: Record<SpecialistId, SpecialistAgent> = {
|
||||
cody: {
|
||||
id: "cody",
|
||||
name: "Cody",
|
||||
role: "Code",
|
||||
aliases: ["coder"],
|
||||
persona: [
|
||||
"You are Cody, Tiger's software engineering specialist.",
|
||||
"Scope: code, debugging, devops, deployments, scripts, infra, build systems.",
|
||||
"Style: read existing code before changing it; smallest correct diff;",
|
||||
"state assumptions explicitly; never run destructive commands without flagging.",
|
||||
].join(" "),
|
||||
},
|
||||
ethan: {
|
||||
id: "ethan",
|
||||
name: "Ethan",
|
||||
role: "Research",
|
||||
aliases: ["researcher"],
|
||||
persona: [
|
||||
"You are Ethan, Tiger's research specialist.",
|
||||
"Scope: market research, policy analysis, technical investigation, due diligence.",
|
||||
"Style: cite sources, separate facts from inference, quantify with units,",
|
||||
"end with a short actionable summary.",
|
||||
].join(" "),
|
||||
},
|
||||
cathy: {
|
||||
id: "cathy",
|
||||
name: "Cathy",
|
||||
role: "Write",
|
||||
aliases: ["writer"],
|
||||
persona: [
|
||||
"You are Cathy, Tiger's writing specialist.",
|
||||
"Scope: documents, summaries, reports, communication drafts.",
|
||||
"Style: clear structure, no filler, match the register the task asks for.",
|
||||
].join(" "),
|
||||
},
|
||||
elon: {
|
||||
id: "elon",
|
||||
name: "Elon",
|
||||
role: "PM",
|
||||
aliases: ["pm"],
|
||||
persona: [
|
||||
"You are Elon, Tiger's project management specialist.",
|
||||
"Scope: planning, prioritization, breaking work into tasks, status synthesis.",
|
||||
"Style: concrete next actions with owners and order; surface blockers first.",
|
||||
].join(" "),
|
||||
},
|
||||
};
|
||||
|
||||
/** All ids + aliases that POST /tiger/spawn accepts. */
|
||||
export const ACCEPTED_AGENT_IDS: string[] = Object.values(SPECIALISTS).flatMap(
|
||||
(a) => [a.id, ...a.aliases],
|
||||
);
|
||||
|
||||
/**
|
||||
* Map any accepted id/alias ("coder", "cody", "CODY") to its canonical
|
||||
* specialist, or null if unknown. "tiger"/"main" are deliberately NOT
|
||||
* spawnable — Tiger is the orchestrator, not a sub-agent.
|
||||
*/
|
||||
export function normalizeAgentId(raw: string): SpecialistAgent | null {
|
||||
const id = (raw || "").trim().toLowerCase();
|
||||
for (const agent of Object.values(SPECIALISTS)) {
|
||||
if (agent.id === id || agent.aliases.includes(id)) return agent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the message a spawned session receives: persona + task + optional
|
||||
* context + reporting contract. The reporting contract matters — the spawn
|
||||
* runner parses the final reply and relays it to Telegram, so we ask for a
|
||||
* result the human can read in one glance.
|
||||
*/
|
||||
export function buildSpawnPrompt(
|
||||
agent: SpecialistAgent,
|
||||
task: string,
|
||||
context?: string,
|
||||
): string {
|
||||
const parts = [
|
||||
`[SUB-AGENT SESSION — ${agent.name} (${agent.role})]`,
|
||||
agent.persona,
|
||||
"",
|
||||
"TASK:",
|
||||
task.trim(),
|
||||
];
|
||||
if (context && context.trim()) {
|
||||
parts.push("", "CONTEXT:", context.trim());
|
||||
}
|
||||
parts.push(
|
||||
"",
|
||||
"When finished, end your reply with a line starting with 'RESULT:' " +
|
||||
"summarizing the outcome in 1-3 sentences. If you could not complete " +
|
||||
"the task, start that line with 'BLOCKED:' and say what you need.",
|
||||
);
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
/**
|
||||
* lib/inbox.ts — TASKS.md as Tiger's inbox, drained by the bridge
|
||||
*
|
||||
* The productivity loop this enables:
|
||||
* You drop a one-line task into the `## 📥 INBOX` section of TASKS.md
|
||||
* (from Telegram via Tiger, from the dashboard workspace editor, or by
|
||||
* hand). Every DRAIN_INTERVAL the bridge picks the FIRST unchecked item,
|
||||
* asks classifyAgent() which specialist owns it, spawns that specialist
|
||||
* (lib/agents.ts + routes/spawn.ts), and rewrites the line in place with
|
||||
* the run id so nothing is picked twice. Completion is reported to
|
||||
* Telegram by the spawn runner.
|
||||
*
|
||||
* Why the BRIDGE schedules this instead of an OpenClaw cron:
|
||||
* - an OpenClaw cron job is itself an agent turn → it would burn a model
|
||||
* call just to decide whether there is work, every hour
|
||||
* - the cron prompt would need the bridge bearer token embedded in it
|
||||
* (a secret inside a prompt — bad pattern)
|
||||
* - the bridge can check TASKS.md for free and only spend model tokens
|
||||
* (one classify call) when there is actually an item to dispatch
|
||||
* The existing "Hourly Task Check-in" cron stays — it is Tiger's
|
||||
* *narrative* status report; this is the *mechanical* dispatcher.
|
||||
*
|
||||
* INBOX line contract (inside TASKS.md):
|
||||
* - [ ] research BESS tender pipeline in Gujarat ← pending
|
||||
* - [⏳ exec_ab12cd → ethan] research BESS tender ... ← dispatched
|
||||
* The drainer only ever touches `- [ ]` lines, one per cycle.
|
||||
*/
|
||||
|
||||
import { writeFileSync, unlinkSync } from "fs";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { classifyAgent } from "./llm.js";
|
||||
import { spawnTask } from "../routes/spawn.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const DOCKER_CONTAINER = "tiger-openclaw";
|
||||
const TASKS_PATH = "/home/node/.openclaw/workspace/TASKS.md";
|
||||
const INBOX_HEADER = "## 📥 INBOX";
|
||||
/** Check every 30 minutes, only act inside working hours (IST). */
|
||||
const DRAIN_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const WORK_HOURS_IST = { start: 9, end: 20 };
|
||||
|
||||
const PENDING_LINE = /^- \[ \] (.+)$/;
|
||||
|
||||
let draining = false;
|
||||
|
||||
function istHour(): number {
|
||||
return Number(
|
||||
new Intl.DateTimeFormat("en-GB", {
|
||||
timeZone: "Asia/Kolkata",
|
||||
hour: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date()),
|
||||
);
|
||||
}
|
||||
|
||||
async function readTasksFile(): Promise<string> {
|
||||
const { stdout } = await execAsync(
|
||||
`docker exec ${DOCKER_CONTAINER} cat ${TASKS_PATH}`,
|
||||
{ timeout: 10_000, maxBuffer: 1024 * 1024 },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async function writeTasksFile(content: string): Promise<void> {
|
||||
// docker cp (same escaping-proof transport as spawn/telegram message passing)
|
||||
const tmp = `/tmp/tasks_inbox_${Date.now()}.md`;
|
||||
writeFileSync(tmp, content, "utf-8");
|
||||
try {
|
||||
await execAsync(`docker cp ${tmp} ${DOCKER_CONTAINER}:${TASKS_PATH}`, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
} finally {
|
||||
unlinkSync(tmp);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One drain cycle: dispatch at most ONE pending inbox item.
|
||||
* Exported so routes can trigger it manually (POST /tiger/inbox/drain).
|
||||
* Returns a human-readable outcome for logs/API.
|
||||
*/
|
||||
export async function drainInboxOnce(force = false): Promise<string> {
|
||||
if (draining) return "skipped: drain already in progress";
|
||||
const hour = istHour();
|
||||
if (!force && (hour < WORK_HOURS_IST.start || hour >= WORK_HOURS_IST.end)) {
|
||||
return `skipped: outside work hours (IST hour ${hour})`;
|
||||
}
|
||||
|
||||
draining = true;
|
||||
try {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readTasksFile();
|
||||
} catch {
|
||||
return "skipped: TASKS.md not readable";
|
||||
}
|
||||
|
||||
const lines = content.split("\n");
|
||||
const headerIdx = lines.findIndex((l) => l.trim().startsWith(INBOX_HEADER));
|
||||
if (headerIdx === -1) return "skipped: no INBOX section in TASKS.md";
|
||||
|
||||
// Scan from the header to the next section header (or EOF).
|
||||
let target = -1;
|
||||
let taskText = "";
|
||||
for (let i = headerIdx + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line.startsWith("## ")) break; // next section — inbox ended
|
||||
const m = line.match(PENDING_LINE);
|
||||
if (m) {
|
||||
target = i;
|
||||
taskText = m[1].trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (target === -1) return "ok: inbox empty";
|
||||
|
||||
// Route → spawn → mark, in that order. If classify fails (e.g. the LLM
|
||||
// gateway is down) we leave the line untouched and retry next cycle.
|
||||
const { agent: agentId, reason } = await classifyAgent(taskText);
|
||||
const spawnable = agentId === "tiger" ? "elon" : agentId; // orchestrator work → PM
|
||||
const ticket = spawnTask({ agentId: spawnable, task: taskText });
|
||||
|
||||
lines[target] = `- [⏳ ${ticket.runId} → ${ticket.agent.id}] ${taskText}`;
|
||||
await writeTasksFile(lines.join("\n"));
|
||||
|
||||
console.log(
|
||||
`[inbox] dispatched "${taskText.slice(0, 60)}" → ${ticket.agent.id} ` +
|
||||
`(${ticket.runId}; classifier said ${agentId}: ${reason.slice(0, 80)})`,
|
||||
);
|
||||
return `dispatched: ${ticket.runId} → ${ticket.agent.id}`;
|
||||
} catch (err) {
|
||||
const m = err instanceof Error ? err.message : String(err);
|
||||
console.error("[inbox] drain failed:", m);
|
||||
return `error: ${m}`;
|
||||
} finally {
|
||||
draining = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Call once from index.ts at startup. */
|
||||
export function startInboxScheduler(): void {
|
||||
setInterval(() => {
|
||||
void drainInboxOnce();
|
||||
}, DRAIN_INTERVAL_MS);
|
||||
console.log(
|
||||
`[inbox] scheduler started — every ${DRAIN_INTERVAL_MS / 60000}min, ` +
|
||||
`${WORK_HOURS_IST.start}:00–${WORK_HOURS_IST.end}:00 IST`,
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (declared in bridge/.env):
|
||||
* TIGER_ROUTER_MODEL Model slug for ALL router calls.
|
||||
* Examples:
|
||||
* "anthropic/claude-haiku-4-5" → Anthropic API direct
|
||||
* "minimax-3" → self-hosted LiteLLM gateway
|
||||
* Default if unset: "minimax-3" (gateway).
|
||||
* ANTHROPIC_API_KEY Required when ROUTER_MODEL has "anthropic/" prefix.
|
||||
* LLM_GATEWAY_URL Self-hosted gateway base URL.
|
||||
* Default: https://llm.manohargupta.com/v1
|
||||
* LLM_GATEWAY_KEY Bearer key for the gateway (LiteLLM master/virtual key).
|
||||
*
|
||||
* Routing rule (intentionally simple):
|
||||
* slug startsWith "anthropic/" → Anthropic API, model = slug minus "anthropic/"
|
||||
* anything else → LiteLLM gateway, model = slug verbatim
|
||||
*
|
||||
* OpenRouter was removed 2026-06-10: its credits ran dry and silently took
|
||||
* classifyAgent down with it. The gateway runs on Manohar's own MiniMax /
|
||||
* Anthropic keys, so there is no third-party balance to surprise us.
|
||||
*
|
||||
* 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 || "minimax-3";
|
||||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY || "";
|
||||
const LLM_GATEWAY_URL = (process.env.LLM_GATEWAY_URL || "https://llm.manohargupta.com/v1").replace(/\/$/, "");
|
||||
const LLM_GATEWAY_KEY = process.env.LLM_GATEWAY_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" | "gateway";
|
||||
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: "gateway", 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();
|
||||
}
|
||||
|
||||
// Self-hosted LiteLLM gateway (catch-all for everything except "anthropic/")
|
||||
if (!LLM_GATEWAY_KEY) {
|
||||
throw new Error("LLM_GATEWAY_KEY not set");
|
||||
}
|
||||
const res = await fetch(`${LLM_GATEWAY_URL}/chat/completions`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${LLM_GATEWAY_KEY}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
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(`LLM gateway ${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("LLM gateway 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,111 +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.
|
||||
// Token comes from env — a previous version hardcoded it here, which
|
||||
// leaked it to the public GitHub mirror (rotated 2026-06-10).
|
||||
const token = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
const { stdout } = await execInSandbox(
|
||||
`curl -s "http://172.17.0.1:3456/tiger/agents" -H "Authorization: Bearer ${token}"`
|
||||
);
|
||||
|
||||
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,181 +0,0 @@
|
|||
/**
|
||||
* agents.ts — Per-agent workspace file browser + activity feed
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { execInSandbox } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
const AGENTS = [
|
||||
{ id: "main", name: "Tiger", emoji: "🐯", role: "orchestrator", basePath: "/home/node/.openclaw/workspace" },
|
||||
{ id: "coder", name: "Cody", emoji: "👷", role: "Coder", basePath: "/home/node/.openclaw/agents/coder" },
|
||||
{ id: "researcher", name: "Ethan", emoji: "🔍", role: "Researcher", basePath: "/home/node/.openclaw/agents/researcher" },
|
||||
{ id: "writer", name: "Cathy", emoji: "✍️", role: "Writer", basePath: "/home/node/.openclaw/agents/writer" },
|
||||
{ id: "pm", name: "Elon", emoji: "✅", role: "PM", basePath: "/home/node/.openclaw/agents/pm" },
|
||||
];
|
||||
|
||||
function getAgent(id: string) {
|
||||
return AGENTS.find((a) => a.id === id) ?? null;
|
||||
}
|
||||
|
||||
function isSafePath(p: string): boolean {
|
||||
return !p.includes("..") && !p.startsWith("/");
|
||||
}
|
||||
|
||||
// GET /tiger/agents
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
AGENTS.map(async (agent) => {
|
||||
const { stdout } = await execInSandbox(
|
||||
`find ${agent.basePath} -type f -printf '%T@\n' 2>/dev/null | sort -rn`
|
||||
);
|
||||
const mtimes = stdout.split("\n").filter(Boolean).map(Number);
|
||||
return {
|
||||
id: agent.id, name: agent.name, emoji: agent.emoji, role: agent.role,
|
||||
fileCount: mtimes.length,
|
||||
lastActivity: mtimes.length > 0 ? Math.floor(mtimes[0] * 1000) : 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
res.json({ ok: true, agents: results });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /tiger/agents/:id/files?path=deliverables
|
||||
router.get("/:id/files", async (req: Request, res: Response) => {
|
||||
const agent = getAgent(req.params.id);
|
||||
if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" });
|
||||
|
||||
const relPath = (req.query.path as string) || "";
|
||||
if (relPath && !isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" });
|
||||
|
||||
const targetDir = relPath ? `${agent.basePath}/${relPath}` : agent.basePath;
|
||||
const dirName = targetDir.split("/").pop() ?? "";
|
||||
|
||||
try {
|
||||
const { stdout } = await execInSandbox(
|
||||
`find ${targetDir} -maxdepth 1 -printf '%y|%s|%T@|%f\n' 2>/dev/null | sort`
|
||||
);
|
||||
|
||||
const items = stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [typeChar, sizeStr, mtimeStr, ...rest] = line.split("|");
|
||||
const name = rest.join("|");
|
||||
return {
|
||||
name,
|
||||
type: typeChar === "d" ? "dir" as const : "file" as const,
|
||||
size: parseInt(sizeStr) || 0,
|
||||
modifiedAt: Math.floor(parseFloat(mtimeStr) * 1000),
|
||||
};
|
||||
})
|
||||
.filter((f) => f.name !== "." && f.name !== dirName);
|
||||
|
||||
res.json({ ok: true, items });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /tiger/agents/:id/file?path=deliverables/ev-dashboard.html
|
||||
router.get("/:id/file", async (req: Request, res: Response) => {
|
||||
const agent = getAgent(req.params.id);
|
||||
if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" });
|
||||
|
||||
const relPath = req.query.path as string;
|
||||
if (!relPath) return res.status(400).json({ ok: false, error: "Missing path param" });
|
||||
if (!isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" });
|
||||
|
||||
const fullPath = `${agent.basePath}/${relPath}`;
|
||||
|
||||
try {
|
||||
const { stdout: sizeOut } = await execInSandbox(`stat -c%s ${fullPath} 2>/dev/null || echo 0`);
|
||||
const size = parseInt(sizeOut.trim()) || 0;
|
||||
if (size > 5 * 1024 * 1024) return res.status(413).json({ ok: false, error: "File too large (> 5MB)" });
|
||||
|
||||
const { stdout: mimeOut } = await execInSandbox(`file --mime-type -b ${fullPath} 2>/dev/null`);
|
||||
const mime = mimeOut.trim();
|
||||
const isText = mime.startsWith("text/") || mime.includes("json") || mime.includes("xml") || mime.includes("javascript");
|
||||
|
||||
if (!isText && size > 0) {
|
||||
const { stdout: b64 } = await execInSandbox(`base64 -w0 ${fullPath} 2>/dev/null`);
|
||||
return res.json({ ok: true, path: relPath, content: b64, encoding: "base64", size, mime });
|
||||
}
|
||||
|
||||
const { stdout: content, exitCode } = await execInSandbox(`cat ${fullPath} 2>/dev/null`);
|
||||
if (exitCode !== 0) return res.status(404).json({ ok: false, error: "File not found" });
|
||||
|
||||
res.json({ ok: true, path: relPath, content, encoding: "utf8", size, mime });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// GET /tiger/agents/activity?limit=50
|
||||
router.get("/activity", async (req: Request, res: Response) => {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
|
||||
try {
|
||||
const agentPaths = AGENTS.map((a) => a.basePath).join(" ");
|
||||
const { stdout } = await execInSandbox(
|
||||
`find ${agentPaths} -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -${limit}`
|
||||
);
|
||||
const events = stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const spaceIdx = line.indexOf(" ");
|
||||
const ts = Math.floor(parseFloat(line.slice(0, spaceIdx)) * 1000);
|
||||
const fullPath = line.slice(spaceIdx + 1);
|
||||
const agent = AGENTS.find((a) => fullPath.startsWith(a.basePath)) ?? null;
|
||||
if (!agent) return null;
|
||||
const relPath = fullPath.slice(agent.basePath.length + 1);
|
||||
return { agentId: agent.id, agentName: agent.name, agentEmoji: agent.emoji, path: relPath, action: "modified", ts };
|
||||
})
|
||||
.filter(Boolean);
|
||||
res.json({ ok: true, events });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// PUT /tiger/agents/:id/file?path=... — write file contents back into container
|
||||
// Body: { content: string }
|
||||
router.put("/:id/file", async (req: Request, res: Response) => {
|
||||
const agent = getAgent(req.params.id);
|
||||
if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" });
|
||||
|
||||
const relPath = req.query.path as string;
|
||||
if (!relPath) return res.status(400).json({ ok: false, error: "Missing path param" });
|
||||
if (!isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" });
|
||||
|
||||
const { content } = req.body as { content?: string };
|
||||
if (typeof content !== "string") {
|
||||
return res.status(400).json({ ok: false, error: "Body must be { content: string }" });
|
||||
}
|
||||
|
||||
const fullPath = `${agent.basePath}/${relPath}`;
|
||||
|
||||
try {
|
||||
// Write via stdin to avoid shell quoting issues with special characters.
|
||||
// We base64-encode the content on the Node side, pipe it in, and decode inside the container.
|
||||
const b64 = Buffer.from(content, "utf-8").toString("base64");
|
||||
const { exitCode, stderr } = await execInSandbox(
|
||||
`echo '${b64}' | base64 -d > ${fullPath}`
|
||||
);
|
||||
if (exitCode !== 0) {
|
||||
return res.status(500).json({ ok: false, error: "Write failed", details: stderr });
|
||||
}
|
||||
res.json({ ok: true, path: relPath });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
/**
|
||||
* chat-telegram.ts — GET /tiger/chat/telegram : the REAL Telegram mirror
|
||||
*
|
||||
* History of this feature (why the old one showed nothing):
|
||||
* The original design (telegram-webhook.ts + chat-mirror.ts) waited for
|
||||
* Telegram to POST updates to the bridge. But the bot is handled by
|
||||
* OpenClaw's NATIVE telegram channel via long-polling, and Telegram's API
|
||||
* forbids webhook + getUpdates on the same bot token — so the webhook was
|
||||
* never registered, chat_messages never received a single Telegram row,
|
||||
* and the dashboard card stayed empty. Even if it had worked, it could
|
||||
* only see inbound messages, never Tiger's replies.
|
||||
*
|
||||
* This route reads the conversation from the source of truth instead:
|
||||
* OpenClaw's session transcript (JSONL) for the telegram:direct session.
|
||||
* It is the same file Tiger's own context is built from, so the dashboard
|
||||
* is in perfect sync by construction — both directions, full history,
|
||||
* nothing to register, nothing to double-write.
|
||||
*
|
||||
* GET /tiger/chat/telegram?limit=50&before=<seq>
|
||||
* → { ok, sessionKey, messages: [{ seq, role, text, timestamp }], hasMore, oldestSeq }
|
||||
* - messages are ascending by seq (chronological)
|
||||
* - `before` pages backwards through history (omit for the newest page)
|
||||
*
|
||||
* Transcript line shape (verified live on tiger-config volume):
|
||||
* { type:"message", timestamp:"2026-06-09T21:08:38.574Z",
|
||||
* message:{ role:"user"|"assistant"|"toolResult", content:[{type:"text",text}] } }
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { readFileSync, statSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// The bridge runs on the host as root, so it reads the docker volume directly —
|
||||
// no docker-exec hop on a route that the dashboard polls every few seconds.
|
||||
const DATA_DIR =
|
||||
process.env.OPENCLAW_DATA_DIR ||
|
||||
"/var/lib/docker/volumes/tiger_tiger-config/_data";
|
||||
const SESSIONS_DIR = join(DATA_DIR, "agents", "main", "sessions");
|
||||
|
||||
interface ThreadMessage {
|
||||
seq: number;
|
||||
role: "user" | "agent";
|
||||
text: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface SessionIndexEntry {
|
||||
sessionId?: string;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
/** Newest telegram session: key + transcript path. */
|
||||
function resolveTelegramSession(): { key: string; file: string } | null {
|
||||
let index: Record<string, SessionIndexEntry>;
|
||||
try {
|
||||
index = JSON.parse(
|
||||
readFileSync(join(SESSIONS_DIR, "sessions.json"), "utf-8"),
|
||||
) as Record<string, SessionIndexEntry>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = Object.entries(index)
|
||||
.filter(([key]) => key.includes(":telegram:") || key.includes(":tg_"))
|
||||
.sort((a, b) => (b[1].updatedAt ?? 0) - (a[1].updatedAt ?? 0));
|
||||
|
||||
for (const [key, entry] of candidates) {
|
||||
if (entry.sessionId) {
|
||||
return { key, file: join(SESSIONS_DIR, `${entry.sessionId}.jsonl`) };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Parse cache ─────────────────────────────────────────────────────────────
|
||||
// The card polls for new messages; re-parsing the whole transcript each poll
|
||||
// is wasted work. Cache the parsed array keyed on (path, mtime, size).
|
||||
|
||||
let cache: { file: string; mtimeMs: number; size: number; messages: ThreadMessage[] } | null = null;
|
||||
|
||||
function parseTranscript(file: string): ThreadMessage[] {
|
||||
const st = statSync(file);
|
||||
if (
|
||||
cache &&
|
||||
cache.file === file &&
|
||||
cache.mtimeMs === st.mtimeMs &&
|
||||
cache.size === st.size
|
||||
) {
|
||||
return cache.messages;
|
||||
}
|
||||
|
||||
const messages: ThreadMessage[] = [];
|
||||
const lines = readFileSync(file, "utf-8").split("\n");
|
||||
let seq = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
seq += 1;
|
||||
let entry: Record<string, any>;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (entry.type !== "message") continue;
|
||||
|
||||
const role = entry.message?.role;
|
||||
if (role !== "user" && role !== "assistant") continue; // skip toolResult etc.
|
||||
|
||||
const content: unknown = entry.message?.content;
|
||||
let text = "";
|
||||
if (typeof content === "string") {
|
||||
text = content;
|
||||
} else if (Array.isArray(content)) {
|
||||
text = content
|
||||
.filter((c) => c && c.type === "text" && typeof c.text === "string")
|
||||
.map((c) => c.text)
|
||||
.join("\n");
|
||||
}
|
||||
text = text.trim();
|
||||
if (!text) continue; // tool-call-only assistant turns have no text
|
||||
|
||||
messages.push({
|
||||
seq,
|
||||
role: role === "user" ? "user" : "agent",
|
||||
text,
|
||||
timestamp: typeof entry.timestamp === "string" ? entry.timestamp : "",
|
||||
});
|
||||
}
|
||||
|
||||
cache = { file, mtimeMs: st.mtimeMs, size: st.size, messages };
|
||||
return messages;
|
||||
}
|
||||
|
||||
router.get("/", (req: Request, res: Response) => {
|
||||
const limit = Math.min(
|
||||
Math.max(parseInt(String(req.query.limit ?? "50"), 10) || 50, 1),
|
||||
200,
|
||||
);
|
||||
const before = parseInt(String(req.query.before ?? ""), 10) || null;
|
||||
|
||||
const session = resolveTelegramSession();
|
||||
if (!session) {
|
||||
return res.status(404).json({
|
||||
ok: false,
|
||||
error: "No telegram session found in OpenClaw session index",
|
||||
});
|
||||
}
|
||||
|
||||
let all: ThreadMessage[];
|
||||
try {
|
||||
all = parseTranscript(session.file);
|
||||
} catch (err) {
|
||||
const m = err instanceof Error ? err.message : String(err);
|
||||
return res.status(500).json({ ok: false, error: `transcript read failed: ${m}` });
|
||||
}
|
||||
|
||||
const upTo = before === null ? all : all.filter((m) => m.seq < before);
|
||||
const page = upTo.slice(-limit);
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
sessionKey: session.key,
|
||||
messages: page,
|
||||
hasMore: upTo.length > page.length,
|
||||
oldestSeq: page.length > 0 ? page[0].seq : null,
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,203 +0,0 @@
|
|||
/**
|
||||
* routes/chat.ts — Chat via OpenClaw CLI + persistence
|
||||
*
|
||||
* POST /tiger/chat — send a message; response includes reply
|
||||
* GET /tiger/chat/history — ?sessionId=X&limit=50 → past messages
|
||||
* DELETE /tiger/chat/history — ?sessionId=X → clear history for a session
|
||||
*
|
||||
* Persistence rationale (see phase1b-patches.py):
|
||||
* Chat history is duplicated into our SQLite so it survives:
|
||||
* - browser hard refresh
|
||||
* - close/reopen tab
|
||||
* - use from a different device
|
||||
* - OpenClaw restarts (session state may or may not persist internally)
|
||||
* We own the read path; OpenClaw owns the reasoning context.
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
import db from "../db.js";
|
||||
|
||||
// The main Tiger session — matches the hardcoded session in chat.send below.
|
||||
// Keep this constant in sync with the --session-id used by openclaw agent.
|
||||
const DEFAULT_SESSION_ID = "agent:main:main";
|
||||
|
||||
const insertMessage = db.prepare(`
|
||||
INSERT INTO chat_messages (session_id, role, content, meta)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
const getHistory = db.prepare(`
|
||||
SELECT id, role, content, meta, created_at
|
||||
FROM chat_messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT ?
|
||||
`);
|
||||
const deleteHistory = db.prepare(`
|
||||
DELETE FROM chat_messages WHERE session_id = ?
|
||||
`);
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ─── GET /tiger/chat/history ─────────────────────────────────────────────
|
||||
router.get("/history", (req, res) => {
|
||||
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID;
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 200, 500);
|
||||
const rows = getHistory.all(sessionId, limit) as any[];
|
||||
res.json({
|
||||
ok: true,
|
||||
sessionId,
|
||||
count: rows.length,
|
||||
messages: rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
role: r.role,
|
||||
content: r.content,
|
||||
timestamp: new Date(r.created_at + "Z").getTime(),
|
||||
meta: r.meta ? JSON.parse(r.meta) : {},
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
// ─── DELETE /tiger/chat/history ──────────────────────────────────────────
|
||||
router.delete("/history", (req, res) => {
|
||||
const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID;
|
||||
const result = deleteHistory.run(sessionId);
|
||||
res.json({ ok: true, deleted: result.changes });
|
||||
});
|
||||
|
||||
// ─── POST /tiger/chat ────────────────────────────────────────────────────
|
||||
router.post("/", async (req, res) => {
|
||||
const { message } = req.body;
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).json({ ok: false, error: "message is required" });
|
||||
}
|
||||
|
||||
// Persist the user's message BEFORE calling the LLM so history is intact
|
||||
// even if the LLM call fails.
|
||||
try {
|
||||
insertMessage.run(DEFAULT_SESSION_ID, "user", message, "{}");
|
||||
} catch (e: any) {
|
||||
console.warn("[chat] failed to persist user message:", e.message);
|
||||
}
|
||||
|
||||
// ── Timing instrumentation ──────────────────────────────────────
|
||||
// Label each phase so we can see where latency goes. Format in logs:
|
||||
// [chat.timing] spawn=120ms exec=2834ms parse=3ms total=2957ms
|
||||
const tStart = Date.now();
|
||||
let tSpawn = 0;
|
||||
let tExec = 0;
|
||||
let tParse = 0;
|
||||
|
||||
try {
|
||||
const { exec } = await import("child_process");
|
||||
const { promisify } = await import("util");
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// Write message to temp file — avoids ALL shell escaping issues
|
||||
// (backticks, quotes, code blocks in messages are all safe this way)
|
||||
const { writeFileSync: wfChat, unlinkSync: ulChat } = await import("fs");
|
||||
const { execSync: exChat } = await import("child_process");
|
||||
const tmpMsg = `/tmp/msg_${Date.now()}_${Math.random().toString(36).slice(2, 8)}.txt`;
|
||||
const sshPrefix = process.env.TIGER_REMOTE === "true"
|
||||
? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} `
|
||||
: "";
|
||||
try {
|
||||
wfChat(tmpMsg, message, "utf-8");
|
||||
exChat(`${sshPrefix}docker cp ${tmpMsg} tiger-openclaw:${tmpMsg}`, { timeout: 5000 });
|
||||
ulChat(tmpMsg);
|
||||
} catch (cpErr: any) {
|
||||
throw new Error(`Failed to stage message for container: ${cpErr.message}`);
|
||||
}
|
||||
const cmd = `${sshPrefix}docker exec tiger-openclaw sh -c 'MSG=$(cat ${tmpMsg}); rm -f ${tmpMsg}; openclaw agent --session-id agent:main:main -m "$MSG" --json --timeout 120'`;
|
||||
|
||||
const tBeforeSpawn = Date.now();
|
||||
tSpawn = tBeforeSpawn - tStart;
|
||||
console.log("[chat] Executing:", cmd.substring(0, 100) + "...");
|
||||
|
||||
const { stdout, stderr } = await execAsync(cmd, {
|
||||
timeout: 130000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
tExec = Date.now() - tBeforeSpawn;
|
||||
console.log("[chat] Response:", stdout.substring(0, 500));
|
||||
|
||||
// Parse the JSON response
|
||||
const tBeforeParse = Date.now();
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(stdout);
|
||||
} catch {
|
||||
result = { output: stdout, error: stderr };
|
||||
}
|
||||
tParse = Date.now() - tBeforeParse;
|
||||
|
||||
const tTotal = Date.now() - tStart;
|
||||
console.log(
|
||||
`[chat.timing] spawn=${tSpawn}ms exec=${tExec}ms parse=${tParse}ms total=${tTotal}ms`
|
||||
);
|
||||
|
||||
// Persist the agent's reply. Extract text using the same fallback chain
|
||||
// as the dashboard so we store whatever the user actually sees.
|
||||
try {
|
||||
const agentText =
|
||||
result?.result?.payloads?.[0]?.text ||
|
||||
result?.payloads?.[0]?.text ||
|
||||
result?.summary ||
|
||||
result?.text ||
|
||||
"";
|
||||
if (agentText) {
|
||||
const meta = {
|
||||
runId: result?.runId,
|
||||
model: result?.result?.meta?.agentMeta?.model || result?.meta?.agentMeta?.model,
|
||||
durationMs: tTotal,
|
||||
};
|
||||
insertMessage.run(DEFAULT_SESSION_ID, "agent", agentText, JSON.stringify(meta));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn("[chat] failed to persist agent reply:", e.message);
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
timing: { spawn: tSpawn, exec: tExec, parse: tParse, total: tTotal },
|
||||
response: result,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const tTotal = Date.now() - tStart;
|
||||
console.error(`[chat] Error after ${tTotal}ms:`, err.message);
|
||||
res.status(500).json({
|
||||
ok: false,
|
||||
error: err.message || "Failed to send chat message",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ─── POST /tiger/chat/persist ─────────────────────────────────────────────
|
||||
// Write-only endpoint used by the new WS-based dashboard chat route.
|
||||
// The dashboard streams events directly from the OpenClaw gateway (no docker exec),
|
||||
// but we still want chat history to land in our sqlite so the dashboard's
|
||||
// history UI keeps working. Dashboard calls this AFTER its stream completes.
|
||||
//
|
||||
// Body: { role: "user"|"agent", content: string, meta?: object, sessionId?: string }
|
||||
router.post("/persist", (req, res) => {
|
||||
const { role, content, meta, sessionId } = req.body || {};
|
||||
if (role !== "user" && role !== "agent") {
|
||||
return res.status(400).json({ ok: false, error: "role must be 'user' or 'agent'" });
|
||||
}
|
||||
if (typeof content !== "string" || !content) {
|
||||
return res.status(400).json({ ok: false, error: "content is required" });
|
||||
}
|
||||
try {
|
||||
const sid = (typeof sessionId === "string" && sessionId) || DEFAULT_SESSION_ID;
|
||||
const metaJson = meta && typeof meta === "object" ? JSON.stringify(meta) : "{}";
|
||||
const info = insertMessage.run(sid, role, content, metaJson);
|
||||
res.json({ ok: true, id: String(info.lastInsertRowid), sessionId: sid });
|
||||
} catch (e: any) {
|
||||
console.warn("[chat.persist] failed:", e.message);
|
||||
res.status(500).json({ ok: false, error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -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({
|
||||
|
|
@ -119,8 +89,8 @@ router.get("/status/:taskId", async (req, res) => {
|
|||
const taskPath = `/sandbox/.openclaw-data/workspace/tasks/${dir}/task_${taskId}.json`;
|
||||
try {
|
||||
const content = await execInSandbox(`cat ${taskPath} 2>/dev/null || true`);
|
||||
if (content && content.stdout && content.stdout.trim()) {
|
||||
const taskData = JSON.parse(content.stdout);
|
||||
if (content && content.trim()) {
|
||||
const taskData = JSON.parse(content);
|
||||
return res.json({
|
||||
ok: true,
|
||||
status: dir,
|
||||
|
|
|
|||
|
|
@ -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,54 +0,0 @@
|
|||
/**
|
||||
* gateway.ts — Proxy to OpenClaw Gateway inside Tiger container
|
||||
*
|
||||
* GET/POST /api/gateway/*
|
||||
* Forwards requests to the gateway running inside tiger-openclaw container
|
||||
*/
|
||||
|
||||
import { Router } from "express";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Gateway URL - use Docker internal IP or container name
|
||||
// The Tiger container has IP 172.17.0.3 on docker0 network
|
||||
const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL || "http://172.17.0.3:18789";
|
||||
|
||||
// Proxy all requests to the gateway inside the container
|
||||
router.all("/", async (req, res) => {
|
||||
try {
|
||||
const targetUrl = `${GATEWAY_URL}${req.originalUrl.replace("/api/gateway", "")}`;
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(req.headers.authorization && {
|
||||
Authorization: req.headers.authorization,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
if (["POST", "PUT", "PATCH"].includes(req.method) && req.body) {
|
||||
fetchOptions.body = JSON.stringify(req.body);
|
||||
}
|
||||
|
||||
const response = await fetch(targetUrl, fetchOptions);
|
||||
const data = await response.json();
|
||||
|
||||
res.status(response.status).json(data);
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes("ECONNREFUSED")) {
|
||||
res.status(503).json({
|
||||
error: "Gateway not accessible",
|
||||
details: "The gateway is running inside the Tiger container and not reachable. Check Docker networking.",
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
error: "Failed to proxy to gateway",
|
||||
details: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -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,177 +0,0 @@
|
|||
/**
|
||||
* routes/models.ts — Available models + per-agent model overrides
|
||||
*
|
||||
* GET /tiger/config/models List models from registry
|
||||
* GET /tiger/config/models/agents List per-agent overrides
|
||||
* PATCH /tiger/config/models/agents/:agentId Set/clear an agent's override
|
||||
*
|
||||
* Per-agent override semantics:
|
||||
* - Each agent in openclaw.json's `agents.list[]` may carry its own
|
||||
* `model.primary` (and optional `model.fallback`) which overrides the
|
||||
* global `agents.defaults.model`.
|
||||
* - PATCH body { model: "anthropic/claude-haiku-4-5" } sets the primary.
|
||||
* - PATCH body { model: null } CLEARS the override (revert to default).
|
||||
* - We never touch agents that aren't in the body. Only the targeted entry
|
||||
* is mutated. Other agents' overrides are preserved as-is.
|
||||
*
|
||||
* Why we don't use updateConfig() / deepMerge here:
|
||||
* `agents.list` is a JSON array. The bridge's deepMerge treats arrays as
|
||||
* scalar values (replacement), but writing the whole list with a single
|
||||
* missing entry would silently drop other agents. So we do an explicit
|
||||
* read-mutate-write pass on the array — safer and easier to reason about.
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { readFile, writeFile } from "fs/promises";
|
||||
import { execOnHost, readModels } from "../tiger.js";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Hard path — same one tiger.ts uses. Kept local rather than re-exported
|
||||
// because tiger.ts treats it as a private constant.
|
||||
const OPENCLAW_CONFIG_HOST =
|
||||
"/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json";
|
||||
|
||||
// Curated agent IDs we expose for override. Must align with agents.ts.
|
||||
const KNOWN_AGENT_IDS = ["tiger", "cody", "ethan", "cathy", "elon"] as const;
|
||||
type KnownAgentId = (typeof KNOWN_AGENT_IDS)[number];
|
||||
|
||||
// ─── GET /tiger/config/models ──────────────────────────────────────────────
|
||||
// Returns the full registry of available models. Existing endpoint — kept
|
||||
// as-is so the dashboard's model picker continues to work.
|
||||
router.get("/", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const models = await readModels();
|
||||
res.json({ ok: true, models });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── GET /tiger/config/models/agents ───────────────────────────────────────
|
||||
// Returns the global default plus the per-agent override map.
|
||||
// Shape:
|
||||
// {
|
||||
// ok: true,
|
||||
// defaults: { primary: "...", fallback: "..." },
|
||||
// overrides: {
|
||||
// tiger: { primary: "minimax/MiniMax-M2.7" },
|
||||
// cody: { primary: "anthropic/claude-sonnet-4-6" },
|
||||
// ethan: null, // no override → uses defaults
|
||||
// cathy: null,
|
||||
// elon: null,
|
||||
// }
|
||||
// }
|
||||
router.get("/agents", async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const raw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
|
||||
const cfg = JSON.parse(raw);
|
||||
|
||||
const defaults = cfg?.agents?.defaults?.model ?? null;
|
||||
const list: any[] = Array.isArray(cfg?.agents?.list) ? cfg.agents.list : [];
|
||||
|
||||
// Build the override map. Missing agents → null (no override).
|
||||
const overrides: Record<string, any> = {};
|
||||
for (const id of KNOWN_AGENT_IDS) {
|
||||
const entry = list.find((a) => a?.id === id);
|
||||
overrides[id] = entry?.model ?? null;
|
||||
}
|
||||
|
||||
res.json({ ok: true, defaults, overrides });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── PATCH /tiger/config/models/agents/:agentId ────────────────────────────
|
||||
// Body: { model: string | null }
|
||||
// string → set primary (e.g. "anthropic/claude-haiku-4-5")
|
||||
// null → clear override (revert to defaults)
|
||||
// Optional body: { model: { primary: "...", fallback: "..." } } for full set.
|
||||
router.patch("/agents/:agentId", async (req: Request, res: Response) => {
|
||||
const agentId = req.params.agentId as KnownAgentId;
|
||||
|
||||
if (!(KNOWN_AGENT_IDS as readonly string[]).includes(agentId)) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: `Unknown agentId. Must be one of: ${KNOWN_AGENT_IDS.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const { model } = req.body as { model?: string | { primary: string; fallback?: string } | null };
|
||||
|
||||
if (model !== null && model !== undefined && typeof model !== "string" && typeof model !== "object") {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: "Body.model must be a string slug, an object {primary,fallback}, or null",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Read current config straight from the volume.
|
||||
const raw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
|
||||
const cfg = JSON.parse(raw);
|
||||
|
||||
// 2. Make sure the shape we need exists.
|
||||
cfg.agents ??= {};
|
||||
cfg.agents.list ??= [];
|
||||
if (!Array.isArray(cfg.agents.list)) {
|
||||
return res.status(500).json({
|
||||
ok: false,
|
||||
error: "openclaw.json: agents.list is not an array",
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Locate or create the agent's entry in the list.
|
||||
const list: any[] = cfg.agents.list;
|
||||
let idx = list.findIndex((a) => a?.id === agentId);
|
||||
if (idx === -1) {
|
||||
list.push({ id: agentId });
|
||||
idx = list.length - 1;
|
||||
}
|
||||
|
||||
// 4. Apply the patch.
|
||||
if (model === null || model === undefined) {
|
||||
// Clear override: drop the model field entirely. We keep the entry
|
||||
// around (don't delete it) so future PATCHes can add it back.
|
||||
delete list[idx].model;
|
||||
} else if (typeof model === "string") {
|
||||
list[idx].model = { primary: model };
|
||||
} else {
|
||||
// Object form — { primary, fallback? }
|
||||
if (typeof model.primary !== "string" || !model.primary) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
error: "model.primary is required when model is an object",
|
||||
});
|
||||
}
|
||||
list[idx].model = {
|
||||
primary: model.primary,
|
||||
...(model.fallback ? { fallback: model.fallback } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Backup before writing (mirrors updateConfig's pattern).
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const backupPath = OPENCLAW_CONFIG_HOST.replace(
|
||||
"openclaw.json",
|
||||
`openclaw-${timestamp}.bak.json`,
|
||||
);
|
||||
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} ${backupPath} 2>/dev/null || true`);
|
||||
|
||||
// 6. Write back. v2026 doesn't need the hash regen step.
|
||||
await writeFile(OPENCLAW_CONFIG_HOST, JSON.stringify(cfg, null, 2), "utf-8");
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
agentId,
|
||||
model: list[idx].model ?? null,
|
||||
backupPath,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[models] PATCH failed:", err);
|
||||
res.status(500).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -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,322 +0,0 @@
|
|||
/**
|
||||
* spawn.ts — POST /tiger/spawn : REAL sub-agent execution
|
||||
*
|
||||
* Replaces the long-standing placeholder. A spawn is an isolated OpenClaw
|
||||
* session of the `main` agent running with a specialist persona prepended
|
||||
* (see lib/agents.ts for the registry and the per-agent upgrade path).
|
||||
*
|
||||
* Flow per spawn:
|
||||
* 1. validate + normalize agent id (accepts cody/ethan/cathy/elon + legacy aliases)
|
||||
* 2. insert a row into `executions` (status = running while exit_code IS NULL)
|
||||
* 3. enqueue — at most MAX_CONCURRENT sessions run at once. The VPS is
|
||||
* memory-constrained; parallel agent turns push it into swap and every
|
||||
* turn times out. Serializing is a feature, not a limitation.
|
||||
* 4. run `openclaw agent --session-id spawn-<agent>-<n> ... --json` inside
|
||||
* the tiger-openclaw container. The message travels via docker cp of a
|
||||
* temp file — same battle-tested pattern as lib/telegram.ts, immune to
|
||||
* shell-escaping bugs from quotes/backticks/JSON in task text.
|
||||
* 5. parse the reply, complete the executions row, fire a Telegram
|
||||
* notification through the bridge's own /tiger/notify route.
|
||||
*
|
||||
* Routes:
|
||||
* POST /tiger/spawn { agentId, task, context?, taskId? }
|
||||
* GET /tiger/spawn/runs recent spawn runs (+ live queue state)
|
||||
* GET /tiger/spawn/runs/:id one run with full output
|
||||
* GET /tiger/spawn/agents the specialist registry
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { writeFileSync, unlinkSync } from "fs";
|
||||
import { randomUUID } from "crypto";
|
||||
import db, { generateId } from "../db.js";
|
||||
import {
|
||||
SPECIALISTS,
|
||||
ACCEPTED_AGENT_IDS,
|
||||
normalizeAgentId,
|
||||
buildSpawnPrompt,
|
||||
type SpecialistAgent,
|
||||
} from "../lib/agents.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const router = Router();
|
||||
|
||||
const DOCKER_CONTAINER = "tiger-openclaw";
|
||||
/** One agent turn at a time — see header comment about RAM. Raise after the
|
||||
* server is upgraded / the homelab is evicted. */
|
||||
const MAX_CONCURRENT = 1;
|
||||
/** Keep below the 300s cron budget so cron-triggered spawns can't be the
|
||||
* thing that blows the cron's own timeout. */
|
||||
const SPAWN_TIMEOUT_SECONDS = 240;
|
||||
|
||||
const BRIDGE_SELF_URL = process.env.TIGER_BRIDGE_SELF_URL || "http://127.0.0.1:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
// ─── Run bookkeeping ─────────────────────────────────────────────────────────
|
||||
|
||||
interface SpawnRequest {
|
||||
runId: string;
|
||||
agent: SpecialistAgent;
|
||||
task: string;
|
||||
context?: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface SpawnOutcome {
|
||||
ok: boolean;
|
||||
reply: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let activeCount = 0;
|
||||
const queue: Array<() => Promise<void>> = [];
|
||||
|
||||
function pump(): void {
|
||||
while (activeCount < MAX_CONCURRENT && queue.length > 0) {
|
||||
const job = queue.shift();
|
||||
if (!job) break;
|
||||
activeCount += 1;
|
||||
void job().finally(() => {
|
||||
activeCount -= 1;
|
||||
pump();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Core runner (exported so lib/inbox.ts can spawn without HTTP) ──────────
|
||||
|
||||
export interface SpawnTicket {
|
||||
runId: string;
|
||||
sessionId: string;
|
||||
agent: { id: string; name: string };
|
||||
queued: number;
|
||||
}
|
||||
|
||||
export function spawnTask(input: {
|
||||
agentId: string;
|
||||
task: string;
|
||||
context?: string;
|
||||
taskId?: string;
|
||||
}): SpawnTicket {
|
||||
const agent = normalizeAgentId(input.agentId);
|
||||
if (!agent) {
|
||||
throw new Error(
|
||||
`Unknown agent '${input.agentId}'. Accepted: ${ACCEPTED_AGENT_IDS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
const task = (input.task || "").trim();
|
||||
if (!task) throw new Error("task is required");
|
||||
|
||||
const runId = generateId("exec");
|
||||
const sessionId = `spawn-${agent.id}-${randomUUID().slice(0, 8)}`;
|
||||
|
||||
// exit_code NULL = still running; completed_at NULL until the turn ends.
|
||||
db.prepare(
|
||||
`INSERT INTO executions (id, task_id, agent, command)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
).run(runId, input.taskId ?? null, agent.id, `spawn: ${task.slice(0, 300)}`);
|
||||
|
||||
const req: SpawnRequest = { runId, agent, task, context: input.context, sessionId };
|
||||
queue.push(() => executeSpawn(req));
|
||||
pump();
|
||||
|
||||
return {
|
||||
runId,
|
||||
sessionId,
|
||||
agent: { id: agent.id, name: agent.name },
|
||||
queued: queue.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function executeSpawn(req: SpawnRequest): Promise<void> {
|
||||
const { runId, agent, task, context, sessionId } = req;
|
||||
const prompt = buildSpawnPrompt(agent, task, context);
|
||||
const tmpFile = `/tmp/spawn_${runId}.txt`;
|
||||
|
||||
let outcome: SpawnOutcome;
|
||||
try {
|
||||
// Stage the message inside the container (escaping-proof transport).
|
||||
writeFileSync(tmpFile, prompt, "utf-8");
|
||||
await execAsync(`docker cp ${tmpFile} ${DOCKER_CONTAINER}:${tmpFile}`, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
unlinkSync(tmpFile);
|
||||
|
||||
const cmd =
|
||||
`docker exec ${DOCKER_CONTAINER} sh -c '` +
|
||||
`MSG=$(cat ${tmpFile}); rm -f ${tmpFile}; ` +
|
||||
`openclaw agent --session-id ${sessionId} -m "$MSG" --json ` +
|
||||
`--timeout ${SPAWN_TIMEOUT_SECONDS}'`;
|
||||
|
||||
const { stdout } = await execAsync(cmd, {
|
||||
timeout: (SPAWN_TIMEOUT_SECONDS + 30) * 1000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
outcome = { ok: true, reply: extractReply(stdout) };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[spawn] ${runId} (${agent.id}) failed:`, message);
|
||||
outcome = { ok: false, reply: "", error: message };
|
||||
try { unlinkSync(tmpFile); } catch { /* already gone */ }
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`UPDATE executions
|
||||
SET stdout = ?, stderr = ?, exit_code = ?, completed_at = datetime('now')
|
||||
WHERE id = ?`,
|
||||
).run(outcome.reply, outcome.error ?? "", outcome.ok ? 0 : 1, runId);
|
||||
|
||||
await notifyCompletion(req, outcome);
|
||||
}
|
||||
|
||||
/** Pull the text reply out of `openclaw agent --json` output. */
|
||||
function extractReply(stdout: string): string {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch {
|
||||
return stdout.trim();
|
||||
}
|
||||
const p = parsed as Record<string, any>;
|
||||
return (
|
||||
p?.result?.payloads?.[0]?.text ||
|
||||
p?.payloads?.[0]?.text ||
|
||||
p?.summary ||
|
||||
p?.text ||
|
||||
p?.output ||
|
||||
stdout.trim()
|
||||
);
|
||||
}
|
||||
|
||||
/** Report the outcome to Telegram via the bridge's own notify route. */
|
||||
async function notifyCompletion(req: SpawnRequest, outcome: SpawnOutcome): Promise<void> {
|
||||
const { agent, task, runId } = req;
|
||||
const resultLine =
|
||||
outcome.reply
|
||||
.split("\n")
|
||||
.reverse()
|
||||
.find((l) => l.startsWith("RESULT:") || l.startsWith("BLOCKED:")) ??
|
||||
outcome.reply.slice(-300);
|
||||
|
||||
const message = outcome.ok
|
||||
? `🤖 *${agent.name}* finished: ${task.slice(0, 120)}\n\n${resultLine.slice(0, 800)}\n\n_run ${runId}_`
|
||||
: `⚠️ *${agent.name}* failed: ${task.slice(0, 120)}\n\n${(outcome.error ?? "unknown error").slice(0, 300)}\n\n_run ${runId}_`;
|
||||
|
||||
try {
|
||||
await fetch(`${BRIDGE_SELF_URL}/tiger/notify`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${BRIDGE_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
} catch (err) {
|
||||
// Notification failure must never mark the run failed — log and move on.
|
||||
const m = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[spawn] notify failed for ${runId}:`, m);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── HTTP surface ────────────────────────────────────────────────────────────
|
||||
|
||||
interface ExecutionRow {
|
||||
id: string;
|
||||
task_id: string | null;
|
||||
agent: string | null;
|
||||
command: string | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exit_code: number | null;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
function rowStatus(row: ExecutionRow): "running" | "done" | "error" {
|
||||
if (row.exit_code === null) return "running";
|
||||
return row.exit_code === 0 ? "done" : "error";
|
||||
}
|
||||
|
||||
router.post("/", (req: Request, res: Response) => {
|
||||
const { agentId, task, context, taskId } = req.body as {
|
||||
agentId?: string;
|
||||
task?: string;
|
||||
context?: string;
|
||||
taskId?: string;
|
||||
};
|
||||
try {
|
||||
const ticket = spawnTask({
|
||||
agentId: agentId ?? "",
|
||||
task: task ?? "",
|
||||
context,
|
||||
taskId,
|
||||
});
|
||||
res.json({ ok: true, status: "spawned", ...ticket });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
res.status(400).json({ ok: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/runs", (_req: Request, res: Response) => {
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT id, task_id, agent, command, exit_code, started_at, completed_at
|
||||
FROM executions
|
||||
WHERE command LIKE 'spawn:%'
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 50`,
|
||||
)
|
||||
.all() as ExecutionRow[];
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
active: activeCount,
|
||||
queued: queue.length,
|
||||
runs: rows.map((r) => ({
|
||||
runId: r.id,
|
||||
agent: r.agent,
|
||||
task: (r.command ?? "").replace(/^spawn:\s*/, ""),
|
||||
status: rowStatus(r),
|
||||
startedAt: r.started_at,
|
||||
completedAt: r.completed_at,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/runs/:id", (req: Request, res: Response) => {
|
||||
const row = db
|
||||
.prepare(`SELECT * FROM executions WHERE id = ?`)
|
||||
.get(req.params.id) as ExecutionRow | undefined;
|
||||
if (!row) return res.status(404).json({ ok: false, error: "run not found" });
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
run: {
|
||||
runId: row.id,
|
||||
agent: row.agent,
|
||||
task: (row.command ?? "").replace(/^spawn:\s*/, ""),
|
||||
status: rowStatus(row),
|
||||
reply: row.stdout,
|
||||
error: row.stderr,
|
||||
startedAt: row.started_at,
|
||||
completedAt: row.completed_at,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.get("/agents", (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
ok: true,
|
||||
agents: Object.values(SPECIALISTS).map((a) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
role: a.role,
|
||||
aliases: a.aliases,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
|
|
@ -1,86 +1,44 @@
|
|||
/**
|
||||
* tiger.ts — Core executor for Tiger agent inside Docker container
|
||||
*
|
||||
* Tiger runs directly in tiger-openclaw container (no more k3s layers).
|
||||
* Commands are executed via docker exec inside the container.
|
||||
* tiger.ts — Core executor for Tiger agent inside Docker→k3s→sandbox
|
||||
*
|
||||
* The key insight: Tiger lives 3 layers deep. Every command must traverse:
|
||||
* Host → Docker (openshell-cluster-nemoclaw) → k3s (kubectl exec) → sandbox pod (tiger)
|
||||
*
|
||||
* This module wraps that complexity into clean async functions.
|
||||
*/
|
||||
|
||||
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";
|
||||
// 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";
|
||||
// These match your known paths from the Tiger setup
|
||||
const DOCKER_CONTAINER = "openshell-cluster-nemoclaw";
|
||||
const K8S_NAMESPACE = "openshell";
|
||||
const POD_NAME = "tiger";
|
||||
const OPENCLAW_CONFIG_HOST = "/root/.nemoclaw/openclaw.json";
|
||||
const CONFIG_HASH_PATH_SANDBOX = "/sandbox/.openclaw/.config-hash";
|
||||
const WORKSPACE_SYMLINK = "/var/lib/docker/volumes/tiger_tiger-workspace/_data";
|
||||
const WORKSPACE_SYMLINK = "/root/tiger-workspace";
|
||||
const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh";
|
||||
|
||||
// Timeout for commands (30s default, some ops need longer)
|
||||
const DEFAULT_TIMEOUT = 30_000;
|
||||
|
||||
// ─── Remote mode for local development ──────────────────────────
|
||||
// When running this bridge on a dev machine (not the VPS), we need to
|
||||
// reach the tiger-openclaw container over SSH. Setting TIGER_REMOTE=true
|
||||
// in the env prefixes all docker/host commands with `ssh <TIGER_REMOTE_SSH>`.
|
||||
// On the real VPS: TIGER_REMOTE is unset → commands run locally as before.
|
||||
const IS_REMOTE = process.env.TIGER_REMOTE === "true";
|
||||
const REMOTE_SSH = process.env.TIGER_REMOTE_SSH || "root@100.75.128.45";
|
||||
const SSH_PREFIX = IS_REMOTE ? `ssh ${REMOTE_SSH} ` : "";
|
||||
|
||||
if (IS_REMOTE) {
|
||||
console.log(`[bridge] REMOTE MODE: docker commands will run via ssh ${REMOTE_SSH}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a 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.
|
||||
* Execute a command inside the Tiger sandbox pod.
|
||||
* This is the fundamental operation — everything else builds on it.
|
||||
*
|
||||
* The full command chain:
|
||||
* docker exec <container> kubectl exec -n <ns> <pod> -- <cmd>
|
||||
*/
|
||||
export async function execInSandbox(
|
||||
command: string,
|
||||
timeoutMs = DEFAULT_TIMEOUT
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
// Run command directly inside tiger-openclaw container.
|
||||
// SSH_PREFIX is empty on the VPS, 'ssh root@host ' for local dev mode.
|
||||
const fullCmd = `${SSH_PREFIX}docker exec ${DOCKER_CONTAINER} sh -c ${JSON.stringify(command)}`;
|
||||
const fullCmd = `docker exec ${DOCKER_CONTAINER} kubectl exec -n ${K8S_NAMESPACE} ${POD_NAME} -- sh -c ${JSON.stringify(command)}`;
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(fullCmd, {
|
||||
|
|
@ -107,12 +65,7 @@ export async function execOnHost(
|
|||
timeoutMs = DEFAULT_TIMEOUT
|
||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
||||
try {
|
||||
// In remote mode, wrap the command so it runs on the VPS host, not on Mac.
|
||||
// Use single-quoted form to avoid local shell interpreting it.
|
||||
const fullCmd = IS_REMOTE
|
||||
? `ssh ${REMOTE_SSH} ${JSON.stringify(command)}`
|
||||
: command;
|
||||
const { stdout, stderr } = await execAsync(fullCmd, {
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 5 * 1024 * 1024,
|
||||
});
|
||||
|
|
@ -144,10 +97,10 @@ export async function getTigerStatus() {
|
|||
execInSandbox("cat /proc/meminfo | head -5 && echo '---' && uptime"),
|
||||
|
||||
// 4. Last heartbeat content
|
||||
execInSandbox("cat /home/node/.openclaw/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"),
|
||||
execInSandbox("cat /sandbox/.openclaw-data/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"),
|
||||
|
||||
// 5. Agent identity from SOUL.md
|
||||
execInSandbox("head -20 /home/node/.openclaw/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"),
|
||||
execInSandbox("head -20 /sandbox/.openclaw-data/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"),
|
||||
]);
|
||||
|
||||
// Parse container state
|
||||
|
|
@ -180,43 +133,15 @@ export async function getTigerStatus() {
|
|||
}
|
||||
}
|
||||
|
||||
// Read host config for model info.
|
||||
// OpenClaw stores the default agent model at agents.defaults.model.primary
|
||||
// and a list of fallbacks at agents.defaults.model.fallbacks. Some runtime
|
||||
// paths (e.g. channels.telegram) or session overrides may pick a different
|
||||
// model at request time — we also capture the provider list so the UI can
|
||||
// show what's actually available.
|
||||
// Read host config for model info
|
||||
let currentModel = "unknown";
|
||||
let fallbackModels: string[] = [];
|
||||
let availableModels: string[] = [];
|
||||
try {
|
||||
// Read config from INSIDE the container — the host copy at
|
||||
// OPENCLAW_CONFIG_HOST can be stale if Tiger has updated its config live.
|
||||
const { stdout: configRaw, exitCode } = await execInSandbox(
|
||||
"cat /home/node/.openclaw/openclaw.json 2>/dev/null"
|
||||
);
|
||||
if (exitCode === 0 && configRaw) {
|
||||
const config = JSON.parse(configRaw);
|
||||
|
||||
const agentDefaults = config?.agents?.defaults?.model;
|
||||
if (typeof agentDefaults === "string") {
|
||||
currentModel = agentDefaults;
|
||||
} else if (agentDefaults && typeof agentDefaults === "object") {
|
||||
currentModel = agentDefaults.primary || "unknown";
|
||||
fallbackModels = Array.isArray(agentDefaults.fallbacks) ? agentDefaults.fallbacks : [];
|
||||
}
|
||||
|
||||
// Surface available models from models.providers section
|
||||
const providers = config?.models?.providers || config?.providers || {};
|
||||
for (const [provName, provCfg] of Object.entries<any>(providers)) {
|
||||
const models = (provCfg as any)?.models;
|
||||
if (Array.isArray(models)) {
|
||||
for (const m of models) {
|
||||
if (m?.id) availableModels.push(`${provName}/${m.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const configRaw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
|
||||
const config = JSON.parse(configRaw);
|
||||
// Navigate the OpenClaw config structure for model info
|
||||
currentModel = config?.model?.primary || config?.model || "unknown";
|
||||
fallbackModels = config?.model?.fallbacks || [];
|
||||
} catch { /* config not readable */ }
|
||||
|
||||
return {
|
||||
|
|
@ -238,7 +163,6 @@ export async function getTigerStatus() {
|
|||
agent: {
|
||||
currentModel,
|
||||
fallbackModels,
|
||||
availableModels,
|
||||
heartbeat: heartbeat.status === "fulfilled" ? heartbeat.value.stdout : null,
|
||||
soul: soulMd.status === "fulfilled" ? soulMd.value.stdout : null,
|
||||
},
|
||||
|
|
@ -247,7 +171,7 @@ export async function getTigerStatus() {
|
|||
|
||||
/**
|
||||
* Read the OpenClaw config from the host.
|
||||
* Config lives at /root/.openclaw/openclaw.json on the host,
|
||||
* Config lives at /root/.nemoclaw/openclaw.json on the host,
|
||||
* gets mounted into the sandbox at /sandbox/.openclaw/openclaw.json
|
||||
*/
|
||||
export async function getConfig(): Promise<Record<string, any>> {
|
||||
|
|
@ -261,55 +185,23 @@ export async function getConfig(): Promise<Record<string, any>> {
|
|||
* Previously this was a manual step that caused repeated failures.
|
||||
*/
|
||||
export async function updateConfig(patch: Record<string, any>): Promise<void> {
|
||||
// 1. Read current config from the Docker volume (the real runtime config)
|
||||
// 1. Read current config
|
||||
const current = await getConfig();
|
||||
|
||||
// 2. Deep-merge the patch
|
||||
// 2. Deep merge the patch (shallow for now, can enhance later)
|
||||
const merged = deepMerge(current, patch);
|
||||
const configStr = JSON.stringify(merged, null, 2);
|
||||
|
||||
// 3. Backup before writing (in the volume directory)
|
||||
// 3. Backup current config before writing
|
||||
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`);
|
||||
await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} /root/.nemoclaw/backups/openclaw-${timestamp}.json`);
|
||||
|
||||
// 4. Write back to the volume file — no hash regeneration needed in OpenClaw v2026
|
||||
// 4. Write updated config
|
||||
await writeFile(OPENCLAW_CONFIG_HOST, configStr, "utf-8");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Read the available models list from the agent models registry.
|
||||
* Returns an array of { id, name, provider, reasoning, contextWindow } objects.
|
||||
*/
|
||||
export async function readModels(): Promise<{
|
||||
id: string; name: string; provider: string;
|
||||
reasoning: boolean; contextWindow: number; cost?: { input: number; output: number }
|
||||
}[]> {
|
||||
try {
|
||||
const raw = await readFile(OPENCLAW_MODELS_HOST, "utf-8");
|
||||
const data = JSON.parse(raw);
|
||||
const results: any[] = [];
|
||||
const providers: Record<string, any> = data?.providers ?? {};
|
||||
for (const [provName, provCfg] of Object.entries<any>(providers)) {
|
||||
for (const model of (provCfg.models ?? [])) {
|
||||
const rawId = model.id as string;
|
||||
// Normalise to "provider/id" form
|
||||
const id = rawId.includes("/") ? rawId : `${provName}/${rawId}`;
|
||||
results.push({
|
||||
id,
|
||||
name: model.name ?? rawId,
|
||||
provider: provName,
|
||||
reasoning: model.reasoning ?? false,
|
||||
contextWindow: model.contextWindow ?? 0,
|
||||
cost: model.cost,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
// 5. Regenerate config hash — the step that was always forgotten!
|
||||
const hash = createHash("sha256").update(configStr).digest("hex");
|
||||
await execInSandbox(`echo '${hash}' > ${CONFIG_HASH_PATH_SANDBOX}`);
|
||||
}
|
||||
|
||||
/** Deep merge helper — second object wins on conflicts */
|
||||
|
|
@ -359,15 +251,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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
/**
|
||||
* Smoke test for the new openclaw-ws.ts library.
|
||||
* Tests:
|
||||
* 1. callGateway with sessions.list — verify non-streaming RPC works
|
||||
* 2. streamAgentRun on agent:main:main — verify chunks arrive in real time
|
||||
* 3. streamAgentRun on a NEW sessionKey — verify isolation
|
||||
*/
|
||||
import { callGateway, streamAgentRun, newSessionKey } from "./src/lib/openclaw-ws.js";
|
||||
|
||||
async function main() {
|
||||
process.env.OPENCLAW_GATEWAY_TOKEN = "c5996580041c8f117532462877c34996d5563ef7a571a2b42913ee53d8fdfa6d";
|
||||
|
||||
console.log("=== TEST 1: sessions.list ===");
|
||||
const r = await callGateway("sessions.list", {});
|
||||
console.log("ok:", r.ok, "count:", (r.payload as any)?.sessions?.length);
|
||||
((r.payload as any)?.sessions || []).forEach((s: any) => console.log(" -", s.key, "|", s.displayName));
|
||||
|
||||
console.log("\n=== TEST 2: streamAgentRun on agent:main:main ===");
|
||||
const t0 = Date.now();
|
||||
let chunks = 0;
|
||||
for await (const ev of streamAgentRun({
|
||||
sessionKey: "agent:main:main",
|
||||
message: "Reply with exactly the word PONG. Nothing else.",
|
||||
})) {
|
||||
if (ev.kind === "chunk") chunks++;
|
||||
console.log(` +${Date.now()-t0}ms ${ev.kind}: ${ev.content.slice(0,60)}`);
|
||||
}
|
||||
console.log(` total chunks: ${chunks}`);
|
||||
|
||||
console.log("\n=== TEST 3: streamAgentRun on NEW sessionKey ===");
|
||||
const newKey = newSessionKey();
|
||||
console.log("new sessionKey:", newKey);
|
||||
for await (const ev of streamAgentRun({
|
||||
sessionKey: newKey,
|
||||
message: "What is your name? Reply briefly.",
|
||||
})) {
|
||||
if (ev.kind !== "status") console.log(` ${ev.kind}: ${ev.content.slice(0,80)}`);
|
||||
}
|
||||
console.log("DONE");
|
||||
}
|
||||
|
||||
main().catch(e => { console.error("FAIL", e); process.exit(1); });
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
653
dashboard/package-lock.json
generated
653
dashboard/package-lock.json
generated
|
|
@ -13,14 +13,12 @@
|
|||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lightningcss": "^1.32.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"swr": "^2.4.0",
|
||||
|
|
@ -41,7 +39,7 @@
|
|||
"shadcn": "^3.8.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"node_modules/@alloc/quick-lru": {
|
||||
|
|
@ -3637,267 +3635,6 @@
|
|||
"tailwindcss": "4.1.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.30.2",
|
||||
"lightningcss-darwin-arm64": "1.30.2",
|
||||
"lightningcss-darwin-x64": "1.30.2",
|
||||
"lightningcss-freebsd-x64": "1.30.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||
"lightningcss-linux-x64-musl": "1.30.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz",
|
||||
|
|
@ -4211,12 +3948,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 +4714,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 +5145,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 +5334,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 +5741,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 +5750,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 +5759,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 +5780,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 +5789,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 +5805,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 +5850,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",
|
||||
|
|
@ -6507,6 +6091,7 @@
|
|||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
|
|
@ -7669,20 +7254,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 +7270,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 +7885,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 +8605,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 +8629,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 +8735,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",
|
||||
|
|
@ -9274,9 +8790,10 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
"integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
|
|
@ -9289,26 +8806,27 @@
|
|||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
"lightningcss-android-arm64": "1.30.2",
|
||||
"lightningcss-darwin-arm64": "1.30.2",
|
||||
"lightningcss-darwin-x64": "1.30.2",
|
||||
"lightningcss-freebsd-x64": "1.30.2",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.30.2",
|
||||
"lightningcss-linux-arm64-gnu": "1.30.2",
|
||||
"lightningcss-linux-arm64-musl": "1.30.2",
|
||||
"lightningcss-linux-x64-gnu": "1.30.2",
|
||||
"lightningcss-linux-x64-musl": "1.30.2",
|
||||
"lightningcss-win32-arm64-msvc": "1.30.2",
|
||||
"lightningcss-win32-x64-msvc": "1.30.2"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9323,12 +8841,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz",
|
||||
"integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9343,12 +8862,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9363,12 +8883,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz",
|
||||
"integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9383,12 +8904,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz",
|
||||
"integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9403,12 +8925,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9423,12 +8946,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9443,12 +8967,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz",
|
||||
"integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9463,12 +8988,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz",
|
||||
"integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9483,12 +9009,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9503,12 +9030,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz",
|
||||
"integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
|
|
@ -9545,12 +9073,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 +9137,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 +10205,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 +10729,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 +10783,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 +10986,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 +12309,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",
|
||||
|
|
|
|||
|
|
@ -14,14 +14,12 @@
|
|||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lightningcss": "^1.32.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"next": "16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-force-graph-2d": "^1.29.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"recharts": "^3.7.0",
|
||||
"swr": "^2.4.0",
|
||||
|
|
@ -42,6 +40,6 @@
|
|||
"shadcn": "^3.8.4",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "5.9.3"
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 248 B |
|
|
@ -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>
|
||||
</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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
/**
|
||||
* /api/chat/history — proxy for bridge's chat history.
|
||||
* GET — list persisted messages for the default session
|
||||
* DELETE — clear them
|
||||
*
|
||||
* Why a proxy and not a direct bridge call from the client?
|
||||
* - Keeps the bridge auth token on the server side (never leaks to browser)
|
||||
* - Matches the pattern used by /api/chat (POST) and /api/tiger/status
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const sessionId = request.nextUrl.searchParams.get("sessionId") || "";
|
||||
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
|
||||
try {
|
||||
const r = await fetch(`${BRIDGE_URL}/tiger/chat/history${qs}`, {
|
||||
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await r.json();
|
||||
return NextResponse.json(data, { status: r.status });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Bridge unreachable", details: err.message },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const sessionId = request.nextUrl.searchParams.get("sessionId") || "";
|
||||
const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
|
||||
try {
|
||||
const r = await fetch(`${BRIDGE_URL}/tiger/chat/history${qs}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
|
||||
});
|
||||
const data = await r.json();
|
||||
return NextResponse.json(data, { status: r.status });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Bridge unreachable", details: err.message },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,133 +0,0 @@
|
|||
/**
|
||||
* /api/chat — chat send endpoint, now with real WS-based streaming.
|
||||
*
|
||||
* Replaces the previous bridge → docker exec → fake-typing chain.
|
||||
*
|
||||
* Request: POST { message: string, sessionKey?: string }
|
||||
* Response: SSE stream of `data: { type, content }` events
|
||||
* types: status | chunk | done | error (matches existing client parser)
|
||||
*
|
||||
* Persistence: user message stored BEFORE LLM call (so it's not lost on failure);
|
||||
* agent reply stored after final token via bridge /tiger/chat/persist.
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { streamAgentRun, DEFAULT_SESSION_KEY } from "@/lib/openclaw-ws";
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
export const maxDuration = 180;
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function persistMessage(role: "user" | "agent", content: string, sessionKey: string, meta?: any) {
|
||||
// Best-effort persistence; never block the chat response on this.
|
||||
try {
|
||||
await fetch(`${BRIDGE_URL}/tiger/chat/persist`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${BRIDGE_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({ role, content, sessionId: sessionKey, meta: meta || {} }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("[chat] persist failed:", role, (err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const sessionKey = request.nextUrl.searchParams.get("sessionKey") || DEFAULT_SESSION_KEY;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BRIDGE_URL}/tiger/chat/history?sessionId=${encodeURIComponent(sessionKey)}&limit=50`, {
|
||||
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
return Response.json(data);
|
||||
} catch (err) {
|
||||
return Response.json({ error: (err as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => ({}));
|
||||
const message: string = body?.message;
|
||||
const sessionKey: string = body?.sessionKey || DEFAULT_SESSION_KEY;
|
||||
|
||||
if (!message || typeof message !== "string") {
|
||||
return new Response(JSON.stringify({ error: "message is required" }), {
|
||||
status: 400, headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// Persist user message NOW, before the LLM call
|
||||
await persistMessage("user", message, sessionKey);
|
||||
|
||||
const t0 = Date.now();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Build the SSE stream.
|
||||
* The wire format `data: {"type":"chunk","content":"..."}\n\n` matches what
|
||||
* chat-interface.tsx already parses. Types we emit:
|
||||
* status (the "thinking" indicator on accept ack)
|
||||
* chunk (each assistant delta from the gateway)
|
||||
* done (terminal — full text + meta, persists the agent reply)
|
||||
* error (anything goes wrong, including handshake failures)
|
||||
*/
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const sse = (obj: { type: string; content?: string; meta?: any }) => {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
||||
};
|
||||
|
||||
try {
|
||||
let fullText = "";
|
||||
let meta: any = undefined;
|
||||
|
||||
for await (const ev of streamAgentRun({ message, sessionKey })) {
|
||||
if (ev.kind === "status") {
|
||||
sse({ type: "status", content: "" });
|
||||
} else if (ev.kind === "chunk") {
|
||||
fullText += ev.content;
|
||||
sse({ type: "chunk", content: ev.content });
|
||||
} else if (ev.kind === "done") {
|
||||
// Prefer the gateway's authoritative final text over our delta accumulation.
|
||||
fullText = ev.content || fullText;
|
||||
meta = ev.meta;
|
||||
sse({ type: "done", content: fullText });
|
||||
} else if (ev.kind === "error") {
|
||||
sse({ type: "error", content: ev.content });
|
||||
}
|
||||
}
|
||||
|
||||
const dt = Date.now() - t0;
|
||||
console.log(`[chat] sessionKey=${sessionKey} duration=${dt}ms chars=${fullText.length}`);
|
||||
|
||||
// Persist the agent reply AFTER streaming is complete.
|
||||
if (fullText) {
|
||||
await persistMessage("agent", fullText, sessionKey, {
|
||||
...meta,
|
||||
durationMs: dt,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("[chat] stream error:", err);
|
||||
sse({ type: "error", content: err?.message || "stream failed" });
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
// Disable nginx-style buffering when behind a proxy.
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
/**
|
||||
* API route: POST /api/chat
|
||||
* Sends chat messages via Tiger Bridge -> OpenClaw CLI
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const { message } = await request.json();
|
||||
|
||||
if (!message) {
|
||||
return NextResponse.json({ error: "message is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// End-to-end timing: measure the full /api/chat call so we can compare
|
||||
// against the bridge's own timing (data.timing) to find overhead.
|
||||
const t0 = Date.now();
|
||||
|
||||
try {
|
||||
// Call the bridge
|
||||
const response = await fetch(`${BRIDGE_URL}/tiger/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${BRIDGE_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
const tBridgeDone = Date.now();
|
||||
const data = await response.json();
|
||||
|
||||
if (data?.timing) {
|
||||
console.log(
|
||||
`[chat.timing] bridge: ${JSON.stringify(data.timing)} | dashboard: bridge_call=${tBridgeDone - t0}ms`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("[chat] Bridge response:", JSON.stringify(data).substring(0, 500));
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: data.error || "Chat failed" },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// Extract the text response - OpenClaw returns in several possible formats
|
||||
let text = "";
|
||||
|
||||
if (data.response?.result?.payloads?.[0]?.text) {
|
||||
text = data.response.result.payloads[0].text;
|
||||
} else if (data.response?.payloads?.[0]?.text) {
|
||||
text = data.response.payloads[0].text;
|
||||
} else if (data.response?.summary) {
|
||||
text = data.response.summary;
|
||||
} else if (data.response?.text) {
|
||||
text = data.response.text;
|
||||
} else if (data.text) {
|
||||
text = data.text;
|
||||
} else {
|
||||
// Fallback: stringify the whole response for debugging
|
||||
text = JSON.stringify(data);
|
||||
}
|
||||
|
||||
console.log("[chat] Extracted text:", text.substring(0, 200));
|
||||
|
||||
// Return as SSE with word-by-word streaming.
|
||||
//
|
||||
// WHY SIMULATE STREAMING?
|
||||
// The bridge gives us the entire reply in one shot (LLM call completes
|
||||
// before the process returns). That means without this code the whole
|
||||
// answer pops in at once — feels sluggish even though the infra is fine.
|
||||
// Splitting on whitespace and drip-feeding gives the UI a "typing" feel
|
||||
// without changing the backend. Total time until done is identical.
|
||||
//
|
||||
// When true token-level streaming is wired in the bridge (Phase 3), we
|
||||
// can swap this out for real chunks from openclaw's event stream.
|
||||
const encoder = new TextEncoder();
|
||||
const words = text.split(/(\s+)/); // keep whitespace tokens → smooth flow
|
||||
// ~60 words-per-second cadence ≈ 16ms per word. Tune to taste.
|
||||
const WORD_DELAY_MS = 25; // 40 wps — smooth typing feel with frame headroom
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Send status marker first so UI can show the thinking indicator.
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: "status", content: "" })}\n\n`
|
||||
)
|
||||
);
|
||||
|
||||
// Drip-feed word tokens. Each is a "chunk" that appends to the
|
||||
// streaming message bubble on the client.
|
||||
for (const word of words) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: "chunk", content: word })}\n\n`
|
||||
)
|
||||
);
|
||||
if (WORD_DELAY_MS > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, WORD_DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Final done event carries the full text as a safety fallback
|
||||
// (see the Bug D fix in chat-interface.tsx).
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ type: "done", content: text })}\n\n`
|
||||
)
|
||||
);
|
||||
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error("[chat] Error:", err.message);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to communicate with Tiger Bridge" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,123 +0,0 @@
|
|||
/**
|
||||
* /api/chat/sessions — list, create, delete chat sessions.
|
||||
*
|
||||
* GET → list webchat-eligible sessions (Main + any "agent:main:webchat-*")
|
||||
* via gateway sessions.list. Returns simplified shape for the UI.
|
||||
* POST → mint a new session key. The session is auto-created in the
|
||||
* gateway on first message (so we don't need to call anything
|
||||
* here — just return the key for the UI to start using).
|
||||
* DELETE ?key=agent:main:webchat-xyz → remove from gateway + clear sqlite history.
|
||||
* The default "agent:main:main" session can never be deleted.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { callGateway, newSessionKey, DEFAULT_SESSION_KEY } from "@/lib/openclaw-ws";
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
/** Whitelist: only "agent:main:main" + "agent:main:webchat-*" sessions are dashboard-visible. */
|
||||
function isWebchatSession(key: string): boolean {
|
||||
return key === DEFAULT_SESSION_KEY || key.startsWith("agent:main:webchat-");
|
||||
}
|
||||
|
||||
/** Pretty label for the dropdown. */
|
||||
function deriveLabel(key: string, displayName?: string): string {
|
||||
if (key === DEFAULT_SESSION_KEY) return "Main";
|
||||
if (displayName && displayName !== "undefined") return displayName;
|
||||
// For "agent:main:webchat-abc12345" → "Chat abc12345"
|
||||
const m = key.match(/^agent:main:webchat-(.+)$/);
|
||||
if (m) return `Chat ${m[1].slice(0, 8)}`;
|
||||
return key;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Ask gateway for ALL sessions, then filter to webchat-visible ones.
|
||||
const r = await callGateway("sessions.list", {});
|
||||
if (!r.ok) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "gateway sessions.list failed", details: r.error },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
const all = (r.payload as any)?.sessions || [];
|
||||
const webchat = all
|
||||
.filter((s: any) => isWebchatSession(s.key))
|
||||
.map((s: any) => ({
|
||||
key: s.key,
|
||||
label: deriveLabel(s.key, s.displayName),
|
||||
updatedAt: s.updatedAt || null,
|
||||
messageCount: s.messageCount || 0,
|
||||
isDefault: s.key === DEFAULT_SESSION_KEY,
|
||||
}))
|
||||
// Default first, then most-recently-updated
|
||||
.sort((a: any, b: any) => {
|
||||
if (a.isDefault) return -1;
|
||||
if (b.isDefault) return 1;
|
||||
return (b.updatedAt || 0) - (a.updatedAt || 0);
|
||||
});
|
||||
|
||||
// Always ensure "Main" is in the list, even if gateway hasn't seen it yet
|
||||
if (!webchat.find((s: any) => s.key === DEFAULT_SESSION_KEY)) {
|
||||
webchat.unshift({ key: DEFAULT_SESSION_KEY, label: "Main", updatedAt: null, messageCount: 0, isDefault: true });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, sessions: webchat });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "sessions list failed", details: err.message },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
// Mint a new key. Actual gateway session is created lazily on first message.
|
||||
const key = newSessionKey();
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
session: { key, label: deriveLabel(key), updatedAt: null, messageCount: 0, isDefault: false },
|
||||
});
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const key = request.nextUrl.searchParams.get("key") || "";
|
||||
if (!key) {
|
||||
return NextResponse.json({ ok: false, error: "key query param required" }, { status: 400 });
|
||||
}
|
||||
if (key === DEFAULT_SESSION_KEY) {
|
||||
return NextResponse.json({ ok: false, error: "the Main session cannot be deleted" }, { status: 400 });
|
||||
}
|
||||
if (!isWebchatSession(key)) {
|
||||
return NextResponse.json({ ok: false, error: "only webchat sessions can be deleted from here" }, { status: 400 });
|
||||
}
|
||||
|
||||
// 1. Best-effort: ask gateway to delete its session record.
|
||||
// If the session has never been used (no first message yet) the gateway
|
||||
// won't know about it — that's fine, we still want to clean sqlite.
|
||||
let gatewayResult: { ok: boolean; error?: any } = { ok: true };
|
||||
try {
|
||||
gatewayResult = await callGateway("sessions.delete", { key });
|
||||
} catch (err: any) {
|
||||
gatewayResult = { ok: false, error: err.message };
|
||||
}
|
||||
|
||||
// 2. Clear our sqlite history for this session via the bridge.
|
||||
let bridgeResult = { ok: true } as any;
|
||||
try {
|
||||
const r = await fetch(
|
||||
`${BRIDGE_URL}/tiger/chat/history?sessionId=${encodeURIComponent(key)}`,
|
||||
{ method: "DELETE", headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` } }
|
||||
);
|
||||
bridgeResult = await r.json();
|
||||
} catch (err: any) {
|
||||
bridgeResult = { ok: false, error: err.message };
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: bridgeResult.ok,
|
||||
gateway: gatewayResult,
|
||||
bridge: bridgeResult,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
/**
|
||||
* /api/chat/telegram-thread — proxy for the bridge's Telegram mirror.
|
||||
*
|
||||
* GET ?limit=50&before=<seq>
|
||||
* Same proxy pattern as /api/chat/history: the bridge bearer token stays on
|
||||
* the server, the browser only ever talks to this route.
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456";
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const limit = request.nextUrl.searchParams.get("limit") || "50";
|
||||
const before = request.nextUrl.searchParams.get("before") || "";
|
||||
const qs = new URLSearchParams({ limit });
|
||||
if (before) qs.set("before", before);
|
||||
|
||||
try {
|
||||
const r = await fetch(`${BRIDGE_URL}/tiger/chat/telegram?${qs.toString()}`, {
|
||||
headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
const data = await r.json();
|
||||
return NextResponse.json(data, { status: r.status });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json(
|
||||
{ ok: false, error: "Bridge unreachable", details: message },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,6 @@
|
|||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getGateway } from "@/lib/gateway"
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""
|
||||
|
||||
// Map gateway-style methods to bridge endpoints
|
||||
const METHOD_MAP: Record<string, string> = {
|
||||
"status.canvas": "/tiger/status",
|
||||
"config.get": "/tiger/config",
|
||||
"config.set": "/tiger/config",
|
||||
}
|
||||
|
||||
// Proxy to the bridge instead of trying to reach the gateway directly
|
||||
// (gateway runs inside Tiger container - not accessible from dashboard)
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ method: string[] }> }
|
||||
|
|
@ -19,21 +8,11 @@ export async function POST(
|
|||
const { method: methodParts } = await params
|
||||
const method = methodParts.join(".")
|
||||
|
||||
// Map gateway method to bridge endpoint, or default to /tiger/status
|
||||
const bridgePath = METHOD_MAP[method] || `/tiger/${methodParts[0]}`
|
||||
|
||||
try {
|
||||
const body = await request.json().catch(() => ({}))
|
||||
const res = await fetch(`${BRIDGE_URL}${bridgePath}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${BRIDGE_TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json({ ok: res.ok, data })
|
||||
const gw = getGateway()
|
||||
const result = await gw.request(method, body)
|
||||
return NextResponse.json({ ok: true, data: result })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Gateway request failed"
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 502 })
|
||||
|
|
@ -47,16 +26,12 @@ export async function GET(
|
|||
const { method: methodParts } = await params
|
||||
const method = methodParts.join(".")
|
||||
|
||||
const bridgePath = METHOD_MAP[method] || `/tiger/${methodParts[0]}`
|
||||
|
||||
try {
|
||||
const res = await fetch(`${BRIDGE_URL}${bridgePath}`, {
|
||||
headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
return NextResponse.json({ ok: res.ok, data })
|
||||
const gw = getGateway()
|
||||
const result = await gw.request(method)
|
||||
return NextResponse.json({ ok: true, data: result })
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Gateway request failed"
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 502 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +1,55 @@
|
|||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""
|
||||
import { getGateway } from "@/lib/gateway"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function GET() {
|
||||
const gw = getGateway()
|
||||
|
||||
// Ensure connected
|
||||
try {
|
||||
if (!gw.isConnected()) {
|
||||
await gw.connect()
|
||||
}
|
||||
} catch {
|
||||
return new Response("Gateway offline", { status: 502 })
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let cleanupFn: (() => void) | null = null
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Send initial connected message
|
||||
const handler = ({ event, payload, seq }: { event: string; payload: unknown; seq: number }) => {
|
||||
if (event === "tick") return
|
||||
const data = JSON.stringify({ event, payload, seq })
|
||||
controller.enqueue(encoder.encode(`data: ${data}\n\n`))
|
||||
}
|
||||
|
||||
gw.on("gateway-event", handler)
|
||||
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ event: "stream.connected", payload: { connected: true } })}\n\n`
|
||||
)
|
||||
encoder.encode(`data: ${JSON.stringify({ event: "stream.connected", payload: { connected: true } })}\n\n`)
|
||||
)
|
||||
|
||||
// Poll tiger status instead of gateway directly
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const res = await fetch(`${BRIDGE_URL}/tiger/status`, {
|
||||
headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` },
|
||||
})
|
||||
const data = await res.json()
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`data: ${JSON.stringify({ event: "health", payload: { status: data.status, ...data } })}\n\n`
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`))
|
||||
}
|
||||
}, 10000)
|
||||
const keepalive = setInterval(() => {
|
||||
controller.enqueue(encoder.encode(`: keepalive\n\n`))
|
||||
}, 15000)
|
||||
|
||||
;(controller as any)._cleanup = () => clearInterval(interval)
|
||||
const disconnectHandler = () => {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ event: "stream.disconnected", payload: { connected: false } })}\n\n`)
|
||||
)
|
||||
}
|
||||
gw.on("disconnected", disconnectHandler)
|
||||
|
||||
cleanupFn = () => {
|
||||
gw.off("gateway-event", handler)
|
||||
gw.off("disconnected", disconnectHandler)
|
||||
clearInterval(keepalive)
|
||||
}
|
||||
},
|
||||
cancel(controller: any) {
|
||||
controller?._cleanup?.()
|
||||
cancel() {
|
||||
cleanupFn?.()
|
||||
},
|
||||
})
|
||||
|
||||
|
|
@ -46,4 +60,4 @@ export async function GET() {
|
|||
Connection: "keep-alive",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
import { NextResponse } from "next/server"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const ANGEL_API_URL = process.env.ANGEL_API_URL || "https://angel.manohargupta.com"
|
||||
|
||||
async function angelFetch(path: string) {
|
||||
const res = await fetch(`${ANGEL_API_URL}${path}`, { cache: "no-store" })
|
||||
if (!res.ok) throw new Error(`angel API ${path} failed: ${res.status}`)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const [posData, histData] = await Promise.all([
|
||||
angelFetch("/api/positions"),
|
||||
angelFetch("/api/pnl-history"),
|
||||
])
|
||||
return NextResponse.json({ ok: true, positions: posData.data ?? [], summary: histData.summary ?? {} })
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
try {
|
||||
const res = await fetch(`${ANGEL_API_URL}/api/refresh`, { method: "POST", cache: "no-store" })
|
||||
if (!res.ok) throw new Error(`refresh failed: ${res.status}`)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,8 @@
|
|||
import { NextResponse } from "next/server"
|
||||
import os from "os"
|
||||
|
||||
const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"
|
||||
const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { getGateway } from "@/lib/gateway"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
|
@ -12,39 +10,147 @@ export async function GET() {
|
|||
const totalMem = os.totalmem()
|
||||
const memUsage = Math.round(((totalMem - freeMem) / totalMem) * 100)
|
||||
|
||||
// Use bridge's /tiger/status instead of gateway directly
|
||||
// Gateway runs inside Tiger container and is not directly accessible
|
||||
let agentStatus = "offline"
|
||||
let gatewayConnected = false
|
||||
let tigerStatus: any = null
|
||||
|
||||
// Try gateway first for rich data
|
||||
try {
|
||||
const res = await fetch(`${BRIDGE_URL}/tiger/status`, {
|
||||
headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` },
|
||||
})
|
||||
if (res.ok) {
|
||||
tigerStatus = await res.json()
|
||||
agentStatus = tigerStatus?.status === "online" ? "online" : "degraded"
|
||||
gatewayConnected = tigerStatus?.status === "online"
|
||||
const gw = getGateway()
|
||||
if (!gw.isConnected()) await gw.connect()
|
||||
|
||||
const [health, skills, cron, heartbeat, identity, models, config] = await Promise.allSettled([
|
||||
gw.request("health"),
|
||||
gw.request("skills.status"),
|
||||
gw.request("cron.list"),
|
||||
gw.request("last-heartbeat"),
|
||||
gw.request("agent.identity.get"),
|
||||
gw.request("models.list"),
|
||||
gw.request("config.get"),
|
||||
])
|
||||
|
||||
const healthData = health.status === "fulfilled" ? health.value as Record<string, unknown> : null
|
||||
const skillsData = skills.status === "fulfilled" ? skills.value as Record<string, unknown> : null
|
||||
const cronData = cron.status === "fulfilled" ? cron.value as unknown[] : null
|
||||
const heartbeatData = heartbeat.status === "fulfilled" ? heartbeat.value as Record<string, unknown> : null
|
||||
const identityData = identity.status === "fulfilled" ? identity.value as Record<string, unknown> : null
|
||||
const modelsData = models.status === "fulfilled" ? models.value as Record<string, unknown> : null
|
||||
const configData = config.status === "fulfilled" ? config.value as Record<string, unknown> : null
|
||||
|
||||
const skillsList = (skillsData?.skills || skillsData?.installed || []) as unknown[]
|
||||
const cronList = Array.isArray(cronData) ? cronData : ((cronData as Record<string, unknown> | null)?.jobs as unknown[] | undefined) || []
|
||||
|
||||
// Extract current model from config - try multiple response shapes
|
||||
// Gateway config.get may return: raw config, { config: ... }, or nested differently
|
||||
const rawConfig = (configData?.config as Record<string, unknown>) || configData
|
||||
const agentsConfig = (rawConfig?.agents as Record<string, unknown>) || undefined
|
||||
const defaultsConfig = (agentsConfig?.defaults as Record<string, unknown>) || undefined
|
||||
const modelConfig = (defaultsConfig?.model as Record<string, unknown>) || undefined
|
||||
let currentModel = (modelConfig?.primary as string) || null
|
||||
let fallbackModels = ((modelConfig?.fallbacks || []) as string[])
|
||||
|
||||
// Fallback: read directly from config file if gateway didn't return model info
|
||||
if (!currentModel) {
|
||||
try {
|
||||
const configFilePath = path.join(os.homedir(), ".clawdbot", "clawdbot.json")
|
||||
const fileConfig = JSON.parse(fs.readFileSync(configFilePath, "utf-8"))
|
||||
currentModel = fileConfig?.agents?.defaults?.model?.primary || null
|
||||
if (!fallbackModels.length) {
|
||||
fallbackModels = fileConfig?.agents?.defaults?.model?.fallbacks || []
|
||||
}
|
||||
} catch {
|
||||
// config file not readable
|
||||
}
|
||||
}
|
||||
} catch { /* offline */ }
|
||||
|
||||
// Also extract the raw config hash for conflict-safe patching
|
||||
const configHash = configData?._hash || configData?.hash || null
|
||||
|
||||
// Extract models list: { models: [{id, name, provider, contextWindow, reasoning, input}] }
|
||||
const modelsList = (modelsData?.models || []) as unknown[]
|
||||
|
||||
// Read HEARTBEAT.md for heartbeat task info
|
||||
let heartbeatContent: string | null = null
|
||||
try {
|
||||
const workspace = (configData?.agents as Record<string, unknown> | undefined)?.defaults as Record<string, unknown> | undefined
|
||||
const wsPath = (workspace?.workspace as string) || "/Users/manohar_air/clawd"
|
||||
const hbPath = path.join(wsPath, "HEARTBEAT.md")
|
||||
heartbeatContent = fs.readFileSync(hbPath, "utf-8").trim()
|
||||
} catch {
|
||||
// HEARTBEAT.md not found
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
status: "online",
|
||||
gateway: true,
|
||||
system: {
|
||||
memoryUsage: memUsage,
|
||||
uptime: os.uptime(),
|
||||
platform: os.platform(),
|
||||
},
|
||||
agent: {
|
||||
name: identityData?.name || "Tarzan",
|
||||
vibe: identityData?.vibe || "",
|
||||
emoji: identityData?.emoji || "",
|
||||
skills: skillsList.length,
|
||||
cronJobs: cronList.filter((j: unknown) => (j as Record<string, unknown>)?.enabled).length,
|
||||
cronTotal: cronList.length,
|
||||
lastHeartbeat: heartbeatData?.timestamp || heartbeatData?.lastChecked || null,
|
||||
heartbeatContent,
|
||||
currentModel,
|
||||
fallbackModels,
|
||||
},
|
||||
models: modelsList,
|
||||
configHash,
|
||||
health: healthData,
|
||||
})
|
||||
} catch {
|
||||
// Gateway not available - fall back to HTTP probe
|
||||
}
|
||||
|
||||
// Fallback: HTTP probe + file reads
|
||||
let agentStatus = "offline"
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 1000)
|
||||
const response = await fetch("http://127.0.0.1:18789/__clawdbot__/canvas/", {
|
||||
signal: controller.signal,
|
||||
cache: "no-store",
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
if (response.ok) agentStatus = "online"
|
||||
} catch {
|
||||
// offline
|
||||
}
|
||||
|
||||
// Even without gateway, try to read config file for model info
|
||||
let fallbackModel: string | null = null
|
||||
let fallbackFallbacks: string[] = []
|
||||
let fallbackHeartbeat: string | null = null
|
||||
try {
|
||||
const configFilePath = path.join(os.homedir(), ".clawdbot", "clawdbot.json")
|
||||
const fileConfig = JSON.parse(fs.readFileSync(configFilePath, "utf-8"))
|
||||
fallbackModel = fileConfig?.agents?.defaults?.model?.primary || null
|
||||
fallbackFallbacks = fileConfig?.agents?.defaults?.model?.fallbacks || []
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
fallbackHeartbeat = fs.readFileSync(path.join("/Users/manohar_air/clawd", "HEARTBEAT.md"), "utf-8").trim()
|
||||
} catch { /* ignore */ }
|
||||
|
||||
return NextResponse.json({
|
||||
status: agentStatus,
|
||||
gateway: gatewayConnected,
|
||||
system: { memoryUsage: memUsage, uptime: os.uptime(), platform: os.platform() },
|
||||
gateway: false,
|
||||
system: {
|
||||
memoryUsage: memUsage,
|
||||
uptime: os.uptime(),
|
||||
platform: os.platform(),
|
||||
},
|
||||
agent: {
|
||||
name: "Tiger",
|
||||
skills: 0,
|
||||
cronJobs: 0,
|
||||
lastHeartbeat: tigerStatus?.agent?.heartbeat,
|
||||
currentModel: tigerStatus?.agent?.currentModel,
|
||||
fallbackModels: tigerStatus?.agent?.fallbackModels || [],
|
||||
container: tigerStatus?.container?.status,
|
||||
memoryUsagePct: tigerStatus?.system?.memoryUsagePct,
|
||||
lastHeartbeat: null,
|
||||
heartbeatContent: fallbackHeartbeat,
|
||||
currentModel: fallbackModel,
|
||||
fallbackModels: fallbackFallbacks,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to fetch status" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
// GET /api/tiger/activity?limit=50 — proxy to bridge
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet } from "@/lib/bridge";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const limit = searchParams.get("limit") ?? "50";
|
||||
try {
|
||||
const result = await bridgeGet("/tiger/agents/activity", { limit });
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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,37 +0,0 @@
|
|||
// GET + PUT /api/tiger/agents/[id]/file?path=...
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet, bridgePut } from "@/lib/bridge";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const path = searchParams.get("path") ?? "";
|
||||
if (!path) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 });
|
||||
try {
|
||||
const result = await bridgeGet(`/tiger/agents/${id}/file`, { path });
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const path = searchParams.get("path") ?? "";
|
||||
if (!path) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 });
|
||||
try {
|
||||
const body = await request.json();
|
||||
const result = await bridgePut(`/tiger/agents/${id}/file`, { path }, body as Record<string, unknown>);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
// GET /api/tiger/agents/[id]/files?path=... — proxy to bridge
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet } from "@/lib/bridge";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const path = searchParams.get("path") ?? "";
|
||||
try {
|
||||
const query: Record<string, string> = {};
|
||||
if (path) query.path = path;
|
||||
const result = await bridgeGet(`/tiger/agents/${id}/files`, query);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
// GET /api/tiger/agents — proxy to bridge
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet } from "@/lib/bridge";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await bridgeGet("/tiger/agents");
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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,13 +0,0 @@
|
|||
// GET /api/tiger/config/models — proxy to bridge
|
||||
import { NextResponse } from "next/server";
|
||||
import { bridgeGet } from "@/lib/bridge";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await bridgeGet("/tiger/config/models");
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -21,3 +21,17 @@ export async function POST(request: Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// GET /api/tiger/dispatch/status/:taskId
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ taskId: string }> }
|
||||
) {
|
||||
const { taskId } = await params;
|
||||
try {
|
||||
const result = await bridgePost(`/tiger/dispatch/status/${taskId}`, {});
|
||||
return NextResponse.json(result);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return NextResponse.json({ ok: false, error: message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
BIN
dashboard/src/app/favicon.ico
Normal file
BIN
dashboard/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -1,20 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="tg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f97316"/>
|
||||
<stop offset="100%" stop-color="#ea580c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- Dark background with rounded corners -->
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
|
||||
<!-- Bold "T" with orange gradient -->
|
||||
<text x="16" y="23" font-family="Arial Black, sans-serif" font-size="20" font-weight="900" fill="url(#tg)" text-anchor="middle">T</text>
|
||||
<!-- Tiger stripes — left side, fading down -->
|
||||
<rect x="4" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
|
||||
<rect x="4" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
|
||||
<rect x="4" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
|
||||
<!-- Tiger stripes — right side, fading down -->
|
||||
<rect x="23" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
|
||||
<rect x="23" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
|
||||
<rect x="23" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ import { ThemeProvider } from "@/components/theme-provider"
|
|||
import { ModeToggle } from "@/components/mode-toggle"
|
||||
import "./globals.css";
|
||||
import { Agentation } from 'agentation';
|
||||
import { ChatProvider } from "@/contexts/chat-context";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
|
|
@ -20,16 +19,8 @@ const geistMono = Geist_Mono({
|
|||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Tiger Command Center",
|
||||
description: "Tiger Agent Management Dashboard",
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/icon.svg", type: "image/svg+xml" },
|
||||
{ url: "/favicon.ico", sizes: "any" },
|
||||
],
|
||||
shortcut: "/icon.svg",
|
||||
apple: "/icon.svg",
|
||||
},
|
||||
title: "Command Center",
|
||||
description: "Tarzan's Dashboard for Agent Management",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
|
@ -49,34 +40,14 @@ export default function RootLayout({
|
|||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ChatProvider>
|
||||
<SidebarProvider>
|
||||
<SidebarProvider>
|
||||
<TooltipProvider>
|
||||
<AppSidebar />
|
||||
<main className="w-full bg-background text-foreground relative">
|
||||
<div className="p-4 flex items-center justify-between border-b border-border bg-sidebar/50 backdrop-blur-sm sticky top-0 z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger />
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Tiger Command icon — same SVG as favicon */}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" className="h-5 w-5 shrink-0" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="hdr-tg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#f97316"/>
|
||||
<stop offset="100%" stopColor="#ea580c"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="6" fill="#1a1a2e"/>
|
||||
<text x="16" y="23" fontFamily="Arial Black, sans-serif" fontSize="20" fontWeight="900" fill="url(#hdr-tg)" textAnchor="middle">T</text>
|
||||
<rect x="4" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
|
||||
<rect x="4" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
|
||||
<rect x="4" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
|
||||
<rect x="23" y="4" width="5" height="3" rx="1" fill="#f97316" opacity="0.7"/>
|
||||
<rect x="23" y="9" width="5" height="3" rx="1" fill="#f97316" opacity="0.5"/>
|
||||
<rect x="23" y="14" width="5" height="3" rx="1" fill="#f97316" opacity="0.3"/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-muted-foreground">Tiger Dashboard</span>
|
||||
</div>
|
||||
<h1 className="text-sm font-medium text-muted-foreground">Tarzan's Dashboard</h1>
|
||||
</div>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
|
@ -86,8 +57,7 @@ export default function RootLayout({
|
|||
<Agentation />
|
||||
</main>
|
||||
</TooltipProvider>
|
||||
</SidebarProvider>
|
||||
</ChatProvider>
|
||||
</SidebarProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,32 +1,380 @@
|
|||
"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[]
|
||||
heartbeat: string | null
|
||||
soul: string | null
|
||||
}
|
||||
}
|
||||
|
||||
function formatUptime(startedAt: string): string {
|
||||
if (!startedAt) return "—"
|
||||
const start = new Date(startedAt)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - start.getTime()
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
||||
|
||||
if (diffHours > 24) {
|
||||
const days = Math.floor(diffHours / 24)
|
||||
return `${days}d ${diffHours % 24}h`
|
||||
}
|
||||
return `${diffHours}h ${diffMins}m`
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: status, error: statusError, isLoading } = useSWR<TigerStatus>('/api/tiger/status', fetcher, {
|
||||
refreshInterval: 5000,
|
||||
revalidateOnFocus: true,
|
||||
})
|
||||
const { request } = useBridgeRequest()
|
||||
const [restarting, setRestarting] = React.useState(false)
|
||||
const [restartSuccess, setRestartSuccess] = React.useState(false)
|
||||
|
||||
const isOffline = statusError || status?.status === "offline"
|
||||
const isCrashed = status?.container?.exitCode === 255
|
||||
|
||||
const handleRestart = async () => {
|
||||
setRestarting(true)
|
||||
setRestartSuccess(false)
|
||||
try {
|
||||
await request("/api/tiger/restart", "POST")
|
||||
setRestartSuccess(true)
|
||||
setTimeout(() => setRestartSuccess(false), 3000)
|
||||
} catch (e) {
|
||||
console.error("Failed to restart:", e)
|
||||
} finally {
|
||||
setRestarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
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 — MiniMax API unreachable)</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{restartSuccess ? "Restarting..." : "Restart Container"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* Restart Button */}
|
||||
<div className="pt-2 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Restart Container
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Agent Model Card */}
|
||||
<Card className="col-span-3 bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
Agent Model
|
||||
</CardTitle>
|
||||
<CardDescription>Current AI model configuration</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Primary Model */}
|
||||
<div className="p-4 rounded-lg border border-primary/30 bg-primary/5">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wider">Primary Model</div>
|
||||
<div className="font-semibold text-base">
|
||||
{isLoading ? "..." : status?.agent?.currentModel || "Not configured"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fallback Models */}
|
||||
{status?.agent?.fallbackModels && status.agent.fallbackModels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Fallback Models</div>
|
||||
<div className="space-y-1">
|
||||
{status.agent.fallbackModels.map((model, i) => (
|
||||
<div key={i} className="p-2 rounded-md border border-border bg-background/50 text-sm font-mono">
|
||||
{model}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heartbeat */}
|
||||
{status?.agent?.heartbeat && (
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-xs">
|
||||
<div className="text-muted-foreground mb-1">Last Heartbeat</div>
|
||||
<pre className="whitespace-pre-wrap text-muted-foreground/80 font-mono text-[10px] max-h-20 overflow-y-auto">
|
||||
{status.agent.heartbeat.slice(0, 500)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<a href="/logs" className="contents">
|
||||
<StatCard
|
||||
title="View Logs"
|
||||
value="→"
|
||||
description="Live container log stream"
|
||||
icon={Activity}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
<a href="/tasks" className="contents">
|
||||
<StatCard
|
||||
title="Task Board"
|
||||
value="→"
|
||||
description="Manage and track tasks"
|
||||
icon={Check}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
<a href="/settings" className="contents">
|
||||
<StatCard
|
||||
title="Settings"
|
||||
value="→"
|
||||
description="Configure Tiger agent"
|
||||
icon={Cpu}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,230 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { TrendingUp, TrendingDown, RefreshCw, Activity, DollarSign, BarChart2, Layers } from "lucide-react"
|
||||
import { StatCard } from "@/components/stat-card"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Position {
|
||||
key: string
|
||||
tradingsymbol: string
|
||||
exchange: string
|
||||
instrumenttype: string
|
||||
producttype: string
|
||||
netqty: number
|
||||
ltp: number
|
||||
avg_price: number
|
||||
unrealised_pnl: number
|
||||
realised_pnl: number
|
||||
total_pnl: number
|
||||
is_closed: number
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface Summary {
|
||||
totalUnrealised: number
|
||||
totalRealised: number
|
||||
totalPnl: number
|
||||
openPositions: number
|
||||
asOf?: string
|
||||
}
|
||||
|
||||
function fmt(n: number) {
|
||||
const sign = n >= 0 ? "+" : ""
|
||||
return `${sign}₹${Math.abs(n).toLocaleString("en-IN", { maximumFractionDigits: 0 })}`
|
||||
}
|
||||
|
||||
function PnlCell({ value }: { value: number }) {
|
||||
return (
|
||||
<span className={cn("font-mono tabular-nums", value > 0 ? "text-emerald-500" : value < 0 ? "text-rose-500" : "text-muted-foreground")}>
|
||||
{fmt(value)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default function PositionsPage() {
|
||||
const [positions, setPositions] = React.useState<Position[]>([])
|
||||
const [summary, setSummary] = React.useState<Summary | null>(null)
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = React.useState<Date | null>(null)
|
||||
|
||||
const load = React.useCallback(async (silent = false) => {
|
||||
if (!silent) setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch("/api/positions", { cache: "no-store" })
|
||||
const data = await res.json()
|
||||
if (!data.ok) throw new Error(data.error ?? "Failed to load")
|
||||
setPositions(data.positions ?? [])
|
||||
setSummary(data.summary ?? null)
|
||||
setLastUpdated(new Date())
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
await fetch("/api/positions", { method: "POST" })
|
||||
} catch { /* ignore */ }
|
||||
await load(true)
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
load()
|
||||
const id = setInterval(() => load(true), 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
const open = positions.filter(p => p.netqty !== 0 && !p.is_closed)
|
||||
const closed = positions.filter(p => p.netqty === 0 && p.is_closed && p.realised_pnl !== 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<BarChart2 className="h-6 w-6 text-primary" />
|
||||
Positions
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString("en-IN", { timeZone: "Asia/Kolkata", hour12: false })} IST` : "Live positions from Angel One"}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={refreshing || loading}>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-2", refreshing && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Card className="border-rose-500/30 bg-rose-500/10">
|
||||
<CardContent className="pt-4 text-sm text-rose-400">{error}</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Summary stat cards */}
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Total P&L"
|
||||
value={summary ? fmt(summary.totalPnl) : "—"}
|
||||
icon={summary && summary.totalPnl >= 0 ? TrendingUp : TrendingDown}
|
||||
className={summary && summary.totalPnl < 0 ? "border-rose-500/30" : "border-emerald-500/30"}
|
||||
/>
|
||||
<StatCard
|
||||
title="Unrealised"
|
||||
value={summary ? fmt(summary.totalUnrealised) : "—"}
|
||||
icon={Activity}
|
||||
description="Open positions"
|
||||
/>
|
||||
<StatCard
|
||||
title="Realised"
|
||||
value={summary ? fmt(summary.totalRealised) : "—"}
|
||||
icon={DollarSign}
|
||||
description="Closed today"
|
||||
/>
|
||||
<StatCard
|
||||
title="Open Positions"
|
||||
value={loading ? "…" : open.length}
|
||||
icon={Layers}
|
||||
description={closed.length > 0 ? `${closed.length} closed today` : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Open positions table */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Open Positions ({open.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center text-muted-foreground text-sm">Loading…</div>
|
||||
) : open.length === 0 ? (
|
||||
<div className="p-8 text-center text-muted-foreground text-sm">No open positions</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground text-xs">
|
||||
<th className="px-4 py-2 text-left font-medium">Symbol</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Qty</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Avg</th>
|
||||
<th className="px-4 py-2 text-right font-medium">LTP</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Unrealised</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Total P&L</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{open.map(p => (
|
||||
<tr key={p.key} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-2.5 font-mono font-medium">{p.tradingsymbol}</td>
|
||||
<td className="px-4 py-2.5 text-right tabular-nums">
|
||||
<span className={p.netqty > 0 ? "text-emerald-500" : "text-rose-500"}>
|
||||
{p.netqty > 0 ? "+" : ""}{p.netqty}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono tabular-nums">₹{p.avg_price.toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right font-mono tabular-nums">₹{p.ltp.toFixed(2)}</td>
|
||||
<td className="px-4 py-2.5 text-right"><PnlCell value={p.unrealised_pnl} /></td>
|
||||
<td className="px-4 py-2.5 text-right"><PnlCell value={p.total_pnl} /></td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant="secondary" className="text-xs font-normal">
|
||||
{p.instrumenttype || p.producttype}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Closed today */}
|
||||
{!loading && closed.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base text-muted-foreground">Closed Today ({closed.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-muted-foreground text-xs">
|
||||
<th className="px-4 py-2 text-left font-medium">Symbol</th>
|
||||
<th className="px-4 py-2 text-right font-medium">Realised P&L</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{closed.map(p => (
|
||||
<tr key={p.key} className="hover:bg-muted/30 transition-colors">
|
||||
<td className="px-4 py-2.5 font-mono font-medium text-muted-foreground">{p.tradingsymbol}</td>
|
||||
<td className="px-4 py-2.5 text-right"><PnlCell value={p.realised_pnl} /></td>
|
||||
<td className="px-4 py-2.5">
|
||||
<Badge variant="outline" className="text-xs font-normal opacity-60">
|
||||
{p.instrumenttype || p.producttype}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,361 +1,189 @@
|
|||
"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",
|
||||
low: "bg-gray-500/10 text-gray-400 border-gray-500/20",
|
||||
medium: "bg-blue-500/10 text-blue-400 border-blue-500/20",
|
||||
high: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||
high: "bg-amber-500/10 text-amber-400 border-amber-500/20",
|
||||
urgent: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
}
|
||||
|
||||
const 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,90 +192,182 @@ 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>
|
||||
) : 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="space-y-8">
|
||||
{/* Tiger's PROJECTS.md — primary */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FolderOpen className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-semibold">Tiger's Projects</span>
|
||||
<span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">PROJECTS.md</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 rounded-full ml-1">{fileProjects.length}</span>
|
||||
</div>
|
||||
{fileProjects.length === 0 ? (
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
<FolderOpen className="h-10 w-10 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm">No projects in PROJECTS.md yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileProjects.map((p) => <FileProjectCard key={p.id} project={p} />)}
|
||||
</div>
|
||||
)}
|
||||
<div 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>
|
||||
</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>
|
||||
{/* Simple Kanban - Tasks by Status */}
|
||||
{loadingTasks ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
{(["backlog", "ready", "in-progress", "review", "done"] as const).map((status) => (
|
||||
<div key={status} className="space-y-2">
|
||||
<div className="text-sm font-medium text-muted-foreground uppercase tracking-wider px-2">
|
||||
{status.replace("-", " ")} ({tasksByStatus[status].length})
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{tasksByStatus[status].map((task) => (
|
||||
<Card key={task.id} className="p-3 cursor-pointer hover:bg-muted/50">
|
||||
<div className="font-medium text-sm">{task.title}</div>
|
||||
{task.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
{task.assigned_agent && (
|
||||
<Badge variant="outline" className="mt-2 text-xs">
|
||||
{task.assigned_agent}
|
||||
</Badge>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,627 +1,276 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* settings/page.tsx — Tiger configuration + API key management
|
||||
*
|
||||
* Sections (in order):
|
||||
* 0. API Keys & Router — ANTHROPIC, OPENROUTER, TELEGRAM keys + TIGER_ROUTER_MODEL
|
||||
* 1. Model — OpenClaw global model dropdown + fallbacks + compaction
|
||||
* 2. Session — dmScope
|
||||
* 3. Telegram — enabled toggle, streaming mode
|
||||
* 4. Commands — native commands, ownerDisplay, restart
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import {
|
||||
Settings2, Save, Loader2, RefreshCw,
|
||||
Bot, MessageSquare, Terminal, Cpu, AlertCircle, Check,
|
||||
Key, RotateCcw,
|
||||
} from "lucide-react"
|
||||
import { Settings2, Save, Loader2, RefreshCw, Eye, EyeOff } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { useBridgeRequest } from "@/hooks/use-bridge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue }
|
||||
|
||||
interface ModelInfo {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
reasoning: boolean
|
||||
contextWindow: number
|
||||
cost?: { input: number; output: number }
|
||||
interface ConfigField {
|
||||
path: string
|
||||
label: string
|
||||
type: "text" | "number" | "boolean" | "password"
|
||||
value: ConfigValue
|
||||
original: ConfigValue
|
||||
}
|
||||
|
||||
interface OpenClawConfig {
|
||||
agents?: {
|
||||
defaults?: {
|
||||
model?: { primary?: string; fallbacks?: string[] }
|
||||
compaction?: { mode?: string }
|
||||
}
|
||||
}
|
||||
session?: { dmScope?: string }
|
||||
channels?: { telegram?: { enabled?: boolean; streaming?: string } }
|
||||
commands?: { native?: string; ownerDisplay?: string; restart?: boolean }
|
||||
}
|
||||
|
||||
interface KeyPresence {
|
||||
isSet: boolean
|
||||
preview?: string
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then(r => r.json())
|
||||
|
||||
function get(obj: any, path: string, fallback: any = ""): any {
|
||||
return path.split(".").reduce((o, k) => (o != null ? o[k] : undefined), obj) ?? fallback
|
||||
}
|
||||
|
||||
function set(obj: any, path: string, value: any): any {
|
||||
const keys = path.split(".")
|
||||
const result = JSON.parse(JSON.stringify(obj))
|
||||
let cur = result
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (cur[keys[i]] == null) cur[keys[i]] = {}
|
||||
cur = cur[keys[i]]
|
||||
}
|
||||
cur[keys[keys.length - 1]] = value
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Shared sub-components ────────────────────────────────────────────────────
|
||||
|
||||
function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-4 py-3 border-b border-border/50 last:border-0">
|
||||
<div className="w-52 shrink-0">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
{hint && <div className="text-xs text-muted-foreground mt-0.5">{hint}</div>}
|
||||
</div>
|
||||
<div className="flex-1">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||
checked ? "bg-primary" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow transition-transform",
|
||||
checked ? "translate-x-5" : "translate-x-0"
|
||||
)} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectInput({ value, options, onChange }: {
|
||||
value: string
|
||||
options: { value: string; label: string }[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-xs"
|
||||
>
|
||||
{options.map(o => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
function ModelSelect({ value, models, onChange }: {
|
||||
value: string
|
||||
models: ModelInfo[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const grouped = models.reduce<Record<string, ModelInfo[]>>((acc, m) => {
|
||||
if (!acc[m.provider]) acc[m.provider] = []
|
||||
acc[m.provider].push(m)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const current = models.find(m => m.id === value)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm"
|
||||
>
|
||||
{Object.entries(grouped).map(([prov, mods]) => (
|
||||
<optgroup key={prov} label={prov}>
|
||||
{mods.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{current && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
{current.id}
|
||||
</span>
|
||||
{current.reasoning && (
|
||||
<span className="text-xs bg-purple-500/15 text-purple-400 px-2 py-0.5 rounded font-medium">
|
||||
reasoning
|
||||
</span>
|
||||
)}
|
||||
{current.contextWindow > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{current.contextWindow >= 1_000_000
|
||||
? `${(current.contextWindow / 1_000_000).toFixed(1)}M ctx`
|
||||
: `${Math.round(current.contextWindow / 1_000)}K ctx`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionCard({ icon: Icon, title, description, children, dirty }: {
|
||||
icon: React.ElementType
|
||||
title: string
|
||||
description: string
|
||||
children: React.ReactNode
|
||||
dirty?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn("transition-colors", dirty && "border-primary/40")}>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
{title}
|
||||
{dirty && (
|
||||
<span className="text-xs font-normal text-primary ml-1">unsaved changes</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>{children}</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Section 0: API Keys ──────────────────────────────────────────────────────
|
||||
|
||||
const SECRET_KEYS = [
|
||||
{ key: "ANTHROPIC_API_KEY", label: "Anthropic API key", hint: "Required for claude-* models via TIGER_ROUTER_MODEL" },
|
||||
{ key: "OPENROUTER_API_KEY", label: "OpenRouter API key", hint: "Required for openrouter/* and other non-Anthropic models" },
|
||||
{ key: "TELEGRAM_BOT_TOKEN", label: "Telegram bot token", hint: "Bot token from @BotFather — enables Telegram channel" },
|
||||
{ key: "TELEGRAM_CHAT_ID", label: "Telegram chat ID", hint: "Your personal chat ID — restricts who can DM Tiger" },
|
||||
// Tiger config sections - matches the structure from /tiger/config
|
||||
const CONFIG_SECTIONS = [
|
||||
{
|
||||
key: "agent",
|
||||
label: "Agent",
|
||||
description: "AI agent model configuration",
|
||||
paths: [
|
||||
{ path: "model", label: "Primary Model", type: "text" },
|
||||
{ path: "fallbackModels", label: "Fallback Models", type: "text" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "execution",
|
||||
label: "Execution",
|
||||
description: "Command execution settings",
|
||||
paths: [
|
||||
{ path: "maxDuration", label: "Max Duration (seconds)", type: "number" },
|
||||
{ path: "maxRetries", label: "Max Retries", type: "number" },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
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>
|
||||
)
|
||||
interface ConfigSection {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
fields: ConfigField[]
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data: configData, mutate: mutateConfig, isLoading: configLoading } =
|
||||
useSWR<{ ok: boolean; config: OpenClawConfig }>("/api/tiger/config", fetcher)
|
||||
const { request } = useBridgeRequest()
|
||||
const [sections, setSections] = React.useState<ConfigSection[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [saved, setSaved] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [showPasswords, setShowPasswords] = React.useState<Record<string, boolean>>({})
|
||||
|
||||
const { data: modelsData, isLoading: modelsLoading } =
|
||||
useSWR<{ ok: boolean; models: ModelInfo[] }>("/api/tiger/config/models", fetcher)
|
||||
// Load config from Tiger Bridge
|
||||
const loadConfig = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const data = await request("/api/tiger/config") as Record<string, ConfigValue>
|
||||
|
||||
const remoteConfig = configData?.config ?? {}
|
||||
const models = modelsData?.models ?? []
|
||||
const loadedSections: ConfigSection[] = CONFIG_SECTIONS.map(section => ({
|
||||
key: section.key,
|
||||
label: section.label,
|
||||
description: section.description,
|
||||
fields: section.paths.map(p => {
|
||||
const value = getNestedValue(data, p.path)
|
||||
return {
|
||||
path: p.path,
|
||||
label: p.label,
|
||||
type: p.type,
|
||||
value: value ?? "",
|
||||
original: value ?? "",
|
||||
}
|
||||
}),
|
||||
}))
|
||||
|
||||
const [draft, setDraft] = React.useState<OpenClawConfig>({})
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
setSections(loadedSections)
|
||||
} catch {
|
||||
setError("Failed to load configuration. Is the Tiger Bridge running?")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [request])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (configData?.ok && !initialized) {
|
||||
setDraft(JSON.parse(JSON.stringify(configData.config)))
|
||||
setInitialized(true)
|
||||
}
|
||||
}, [configData, initialized])
|
||||
loadConfig()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const update = (path: string, value: any) => setDraft(prev => set(prev, path, value))
|
||||
const g = (path: string, fallback: any = "") => get(draft, path, fallback)
|
||||
const r = (path: string, fallback: any = "") => get(remoteConfig, path, fallback)
|
||||
const isDirty = (path: string) => JSON.stringify(g(path)) !== JSON.stringify(r(path))
|
||||
const anyDirty = JSON.stringify(draft) !== JSON.stringify(remoteConfig)
|
||||
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle")
|
||||
const [saveError, setSaveError] = React.useState("")
|
||||
const handleFieldChange = (sectionKey: string, fieldPath: string, newValue: ConfigValue) => {
|
||||
setSections(prev =>
|
||||
prev.map(s =>
|
||||
s.key === sectionKey
|
||||
? {
|
||||
...s,
|
||||
fields: s.fields.map(f =>
|
||||
f.path === fieldPath ? { ...f, value: newValue } : f
|
||||
),
|
||||
}
|
||||
: s
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setSaveState("idle")
|
||||
setError(null)
|
||||
setSaved(false)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/tiger/config", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ patch: draft }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.ok) throw new Error(data.error ?? "Save failed")
|
||||
setSaveState("ok")
|
||||
await mutateConfig()
|
||||
setInitialized(false)
|
||||
setTimeout(() => setSaveState("idle"), 3000)
|
||||
} catch (err: any) {
|
||||
setSaveError(err.message)
|
||||
setSaveState("err")
|
||||
// Build patch object from all changed fields
|
||||
const patch: Record<string, ConfigValue> = {}
|
||||
|
||||
for (const section of sections) {
|
||||
for (const field of section.fields) {
|
||||
if (JSON.stringify(field.value) !== JSON.stringify(field.original)) {
|
||||
let val = field.value
|
||||
if (field.type === "number") val = Number(val)
|
||||
if (field.type === "boolean") val = val === true || val === "true"
|
||||
patch[field.path] = val
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await request("/api/tiger/config", "POST", { patch })
|
||||
|
||||
// Update originals
|
||||
setSections(prev =>
|
||||
prev.map(s => ({
|
||||
...s,
|
||||
fields: s.fields.map(f => ({ ...f, original: f.value })),
|
||||
}))
|
||||
)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
} catch {
|
||||
setError("Failed to save configuration.")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setDraft(JSON.parse(JSON.stringify(remoteConfig)))
|
||||
setSaveState("idle")
|
||||
}
|
||||
|
||||
const loading = configLoading || modelsLoading || !initialized
|
||||
const hasChanges = sections.some(s =>
|
||||
s.fields.some(f => JSON.stringify(f.value) !== JSON.stringify(f.original))
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-3xl">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-6 p-6 max-w-4xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Settings2 className="h-6 w-6" /> Settings
|
||||
<Settings2 className="h-6 w-6" />
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
API keys and Tiger configuration.
|
||||
</p>
|
||||
<p className="text-muted-foreground">Tiger agent configuration. Changes are applied live.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleReset} disabled={!anyDirty || saving}>
|
||||
<RefreshCw className="h-4 w-4 mr-1" /> Reset
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadConfig} disabled={loading}>
|
||||
<RefreshCw className={cn("h-4 w-4 mr-1", loading && "animate-spin")} />
|
||||
Reload
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={!anyDirty || saving}>
|
||||
{saving
|
||||
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
: saveState === "ok"
|
||||
? <Check className="h-4 w-4 mr-1" />
|
||||
: <Save className="h-4 w-4 mr-1" />
|
||||
}
|
||||
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Config"}
|
||||
<Button size="sm" onClick={handleSave} disabled={!hasChanges || saving}>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{saved ? "Saved!" : "Save Changes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveState === "err" && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{saveError}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</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-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── 1. Model ─────────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Cpu}
|
||||
title="Model"
|
||||
description="Global model for Tiger and sub-agents. Per-agent overrides live on the Agents page."
|
||||
dirty={isDirty("agents.defaults.model.primary") || isDirty("agents.defaults.model.fallbacks")}
|
||||
>
|
||||
<SettingRow label="Primary model" hint="Active model for all agents (unless overridden)">
|
||||
<ModelSelect
|
||||
value={g("agents.defaults.model.primary", "")}
|
||||
models={models}
|
||||
onChange={v => update("agents.defaults.model.primary", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Fallback models" hint="Comma-separated, tried in order if primary fails">
|
||||
<input
|
||||
type="text"
|
||||
value={(g("agents.defaults.model.fallbacks", []) as string[]).join(", ")}
|
||||
onChange={e => update(
|
||||
"agents.defaults.model.fallbacks",
|
||||
e.target.value.split(",").map(s => s.trim()).filter(Boolean)
|
||||
)}
|
||||
placeholder="e.g. openrouter/auto"
|
||||
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Compaction mode" hint="How Tiger handles context window limits">
|
||||
<SelectInput
|
||||
value={g("agents.defaults.compaction.mode", "safeguard")}
|
||||
options={[
|
||||
{ value: "safeguard", label: "Safeguard — compress when near limit" },
|
||||
{ value: "auto", label: "Auto — compress aggressively" },
|
||||
{ value: "off", label: "Off — never compress" },
|
||||
]}
|
||||
onChange={v => update("agents.defaults.compaction.mode", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 2. Session ───────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Bot}
|
||||
title="Session"
|
||||
description="Conversation session and identity scoping."
|
||||
dirty={isDirty("session.dmScope")}
|
||||
>
|
||||
<SettingRow label="DM scope" hint="Context isolation between Telegram chats">
|
||||
<SelectInput
|
||||
value={g("session.dmScope", "per-channel-peer")}
|
||||
options={[
|
||||
{ value: "per-channel-peer", label: "Per-channel-peer (recommended)" },
|
||||
{ value: "per-channel", label: "Per-channel" },
|
||||
{ value: "global", label: "Global — single shared context" },
|
||||
]}
|
||||
onChange={v => update("session.dmScope", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 3. Telegram ──────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={MessageSquare}
|
||||
title="Telegram"
|
||||
description="Telegram bot channel settings."
|
||||
dirty={isDirty("channels.telegram.enabled") || isDirty("channels.telegram.streaming")}
|
||||
>
|
||||
<SettingRow label="Enabled" hint="Whether the Telegram bot is active">
|
||||
<Toggle
|
||||
checked={g("channels.telegram.enabled", true)}
|
||||
onChange={v => update("channels.telegram.enabled", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Streaming" hint="How Tiger sends updates while generating">
|
||||
<SelectInput
|
||||
value={g("channels.telegram.streaming", "partial")}
|
||||
options={[
|
||||
{ value: "partial", label: "Partial — stream as it types" },
|
||||
{ value: "full", label: "Full — send only on completion" },
|
||||
{ value: "off", label: "Off — no streaming" },
|
||||
]}
|
||||
onChange={v => update("channels.telegram.streaming", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 4. Commands ──────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Terminal}
|
||||
title="Commands"
|
||||
description="Native and system command settings."
|
||||
dirty={isDirty("commands.native") || isDirty("commands.ownerDisplay") || isDirty("commands.restart")}
|
||||
>
|
||||
<SettingRow label="Native commands" hint="Whether Tiger can run shell commands on the host">
|
||||
<SelectInput
|
||||
value={g("commands.native", "auto")}
|
||||
options={[
|
||||
{ value: "auto", label: "Auto — enable when available" },
|
||||
{ value: "on", label: "On — always enabled" },
|
||||
{ value: "off", label: "Off — disabled" },
|
||||
]}
|
||||
onChange={v => update("commands.native", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Owner display" hint="How your name appears to Tiger">
|
||||
<SelectInput
|
||||
value={g("commands.ownerDisplay", "raw")}
|
||||
options={[
|
||||
{ value: "raw", label: "Raw — as-is" },
|
||||
{ value: "formatted", label: "Formatted — display name" },
|
||||
]}
|
||||
onChange={v => update("commands.ownerDisplay", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow label="Allow restart" hint="Tiger can restart itself when needed">
|
||||
<Toggle
|
||||
checked={g("commands.restart", true)}
|
||||
onChange={v => update("commands.restart", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
</div>
|
||||
sections.map(section => (
|
||||
<Card key={section.key}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{section.label}</CardTitle>
|
||||
<CardDescription>{section.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{section.fields.map(field => (
|
||||
<div key={field.path} className="flex items-center gap-4">
|
||||
<label className="w-[200px] text-sm font-medium text-muted-foreground shrink-0">
|
||||
{field.label}
|
||||
</label>
|
||||
{field.type === "boolean" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleFieldChange(section.key, field.path, !field.value)
|
||||
}
|
||||
className={cn(
|
||||
"relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
|
||||
field.value ? "bg-primary" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"pointer-events-none inline-block h-5 w-5 transform rounded-full bg-background shadow ring-0 transition-transform",
|
||||
field.value ? "translate-x-5" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : field.type === "password" ? (
|
||||
<div className="flex-1 flex gap-2">
|
||||
<Input
|
||||
type={showPasswords[field.path] ? "text" : "password"}
|
||||
value={String(field.value)}
|
||||
onChange={e =>
|
||||
handleFieldChange(section.key, field.path, e.target.value)
|
||||
}
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setShowPasswords(p => ({ ...p, [field.path]: !p[field.path] }))
|
||||
}
|
||||
>
|
||||
{showPasswords[field.path] ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type}
|
||||
value={String(field.value)}
|
||||
onChange={e =>
|
||||
handleFieldChange(
|
||||
section.key,
|
||||
field.path,
|
||||
field.type === "number" ? e.target.value : e.target.value
|
||||
)
|
||||
}
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getNestedValue(obj: Record<string, ConfigValue>, path: string): ConfigValue {
|
||||
const keys = path.split(".")
|
||||
let current: ConfigValue = obj
|
||||
for (const key of keys) {
|
||||
if (current == null || typeof current !== "object" || Array.isArray(current)) return null
|
||||
current = (current as Record<string, ConfigValue>)[key]
|
||||
}
|
||||
return current ?? null
|
||||
}
|
||||
|
|
@ -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>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<CheckSquare className="h-6 w-6 text-primary" />
|
||||
Tasks
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tiger's active work from <span className="font-mono text-xs">TASKS.md</span> — plus any dashboard-queued items waiting for pickup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
||||
{[
|
||||
{ label: "In Progress", value: inProgress, icon: GitBranch, color: "text-amber-400" },
|
||||
{ label: "In Review", value: inReview, icon: Bot, color: "text-purple-400" },
|
||||
{ label: "Done", value: done, icon: CheckSquare,color: "text-emerald-400" },
|
||||
{ label: "Queued", value: queued, icon: Inbox, color: "text-blue-400" },
|
||||
].map(({ label, value, icon: Icon, color }) => (
|
||||
<Card key={label} className="bg-card/40">
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-xl font-bold tabular-nums">{isLoading ? "—" : value}</p>
|
||||
</div>
|
||||
<Icon className={cn("h-4 w-4", color)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-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>
|
||||
</div>
|
||||
) : (
|
||||
orderedStatuses.map((status) =>
|
||||
grouped[status]?.length ? (
|
||||
<FileTaskSection key={status} status={status} tasks={grouped[status]} />
|
||||
) : null
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Secondary: Dashboard dispatch queue */}
|
||||
{queueTasks.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-border/40">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Inbox className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-blue-400">
|
||||
Dashboard Queue
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 rounded-full">
|
||||
{queueTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Dispatched from dashboard — waiting for Tiger to pick up.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{queueTasks.map((t) => <QueueTaskRow key={t.id} task={t} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<CheckSquare className="h-6 w-6 text-primary" />
|
||||
Task Progress
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage and track tasks across your team and AI sub-agents
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Active Tasks</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<Clock className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">In Progress</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<GitBranch className="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">AI Delegated</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<Bot className="h-5 w-5 text-violet-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Completed Today</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<CheckSquare className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Kanban Board */}
|
||||
<Card className="bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle>Task Board</CardTitle>
|
||||
<CardDescription>
|
||||
Drag and drop tasks between columns. Click a task to edit or assign to an AI agent.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<KanbanBoard />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,223 +1,234 @@
|
|||
/**
|
||||
* workspace/page.tsx — Per-agent file browser with preview
|
||||
* Workspace Page — File browser for Tiger agent's workspace
|
||||
*
|
||||
* Layout:
|
||||
* Agent chip row (top)
|
||||
* Tabs: [Files] [Activity]
|
||||
* Files: split-pane — file tree (left) + file preview (right)
|
||||
* Activity: recent cross-agent changes feed
|
||||
* Lists files from the Tiger Bridge's workspace API and provides
|
||||
* a file viewer for reading file contents.
|
||||
*/
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { AgentChipRow, AgentInfo } from "@/components/workspace/agent-chip-row"
|
||||
import { FileTree, FileItem } from "@/components/workspace/file-tree"
|
||||
import { FilePreview } from "@/components/workspace/file-preview"
|
||||
import { ActivityFeed, ActivityEvent } from "@/components/workspace/activity-feed"
|
||||
import { Folder, FileText, ChevronRight, Home, ArrowLeft, Loader2 } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useBridgeRequest } from "@/hooks/use-bridge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
interface WorkspaceFile {
|
||||
name: string
|
||||
type: "file" | "directory"
|
||||
size?: number
|
||||
modified?: string
|
||||
}
|
||||
|
||||
interface FileContent {
|
||||
ok: boolean
|
||||
path: string
|
||||
content: string
|
||||
encoding: "utf8" | "base64"
|
||||
size: number
|
||||
mime: string
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function apiFetch<T>(url: string): Promise<T> {
|
||||
const res = await fetch(url, { cache: "no-store" })
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
return res.json() as Promise<T>
|
||||
function formatSize(bytes?: number): string {
|
||||
if (!bytes) return "—"
|
||||
if (bytes < 1024) return `${bytes}B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
function getFileIcon(filename: string) {
|
||||
const ext = filename.split(".").pop()?.toLowerCase()
|
||||
const codeExts = ["ts", "tsx", "js", "jsx", "py", "sh", "json", "md", "yaml", "yml", "toml"]
|
||||
if (ext && codeExts.includes(ext)) return "code"
|
||||
if (ext === "md") return "markdown"
|
||||
return "text"
|
||||
}
|
||||
|
||||
export default function WorkspacePage() {
|
||||
// Agent list
|
||||
const [agents, setAgents] = React.useState<AgentInfo[]>([])
|
||||
const [agentsLoading, setAgentsLoading] = React.useState(true)
|
||||
|
||||
// Active agent selection (null = "All" → show Tiger/main)
|
||||
const [activeAgentId, setActiveAgentId] = React.useState<string | null>(null)
|
||||
|
||||
// File tree state
|
||||
const [treeItems, setTreeItems] = React.useState<FileItem[]>([])
|
||||
const { request, loading } = useBridgeRequest()
|
||||
const [currentPath, setCurrentPath] = React.useState("")
|
||||
const [treeLoading, setTreeLoading] = React.useState(false)
|
||||
|
||||
// File preview state
|
||||
const [files, setFiles] = React.useState<WorkspaceFile[]>([])
|
||||
const [selectedFile, setSelectedFile] = React.useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = React.useState<FileContent | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = React.useState(false)
|
||||
const [loadingFiles, setLoadingFiles] = React.useState(false)
|
||||
const [loadingContent, setLoadingContent] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
|
||||
// Activity feed
|
||||
const [activityEvents, setActivityEvents] = React.useState<ActivityEvent[]>([])
|
||||
const [activityLoading, setActivityLoading] = React.useState(false)
|
||||
// Load directory contents
|
||||
const loadDirectory = React.useCallback(async (path: string) => {
|
||||
setLoadingFiles(true)
|
||||
setError(null)
|
||||
try {
|
||||
const url = path ? `/api/tiger/workspace?path=${encodeURIComponent(path)}` : "/api/tiger/workspace"
|
||||
const data = await request(url) as { ok: boolean; files?: WorkspaceFile[] }
|
||||
if (data.ok && data.files) {
|
||||
setFiles(data.files)
|
||||
setCurrentPath(path)
|
||||
} else {
|
||||
setError("Failed to load directory")
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError("Failed to load workspace")
|
||||
} finally {
|
||||
setLoadingFiles(false)
|
||||
}
|
||||
}, [request])
|
||||
|
||||
// ── Load agents on mount ──────────────────────────────────────────────────
|
||||
// Load file content
|
||||
const loadFile = React.useCallback(async (filename: string) => {
|
||||
setLoadingContent(true)
|
||||
setSelectedFile(filename)
|
||||
try {
|
||||
// Need full path including current directory
|
||||
const fullPath = currentPath ? `${currentPath}/${filename}` : filename
|
||||
const url = `/api/tiger/workspace?path=${encodeURIComponent(fullPath)}&read=true`
|
||||
const data = await request(url) as FileContent
|
||||
setFileContent(data)
|
||||
} catch (e: unknown) {
|
||||
setFileContent({ ok: false, path: filename, content: "Failed to load file", size: 0 })
|
||||
} finally {
|
||||
setLoadingContent(false)
|
||||
}
|
||||
}, [request, currentPath])
|
||||
|
||||
// Initial load
|
||||
React.useEffect(() => {
|
||||
setAgentsLoading(true)
|
||||
apiFetch<{ ok: boolean; agents: AgentInfo[] }>("/api/tiger/agents")
|
||||
.then((data) => { if (data.ok) setAgents(data.agents) })
|
||||
.catch(console.error)
|
||||
.finally(() => setAgentsLoading(false))
|
||||
}, [])
|
||||
loadDirectory("")
|
||||
}, [loadDirectory])
|
||||
|
||||
// Derived: the "effective" agent to browse
|
||||
// "All" defaults to showing the orchestrator (main/Tiger)
|
||||
const effectiveAgentId = activeAgentId ?? "main"
|
||||
|
||||
// ── Load file tree when agent or path changes ─────────────────────────────
|
||||
const loadTree = React.useCallback((agentId: string, path: string) => {
|
||||
setTreeLoading(true)
|
||||
setSelectedFile(null)
|
||||
setFileContent(null)
|
||||
const url = path
|
||||
? `/api/tiger/agents/${agentId}/files?path=${encodeURIComponent(path)}`
|
||||
: `/api/tiger/agents/${agentId}/files`
|
||||
apiFetch<{ ok: boolean; items: FileItem[] }>(url)
|
||||
.then((data) => { if (data.ok) setTreeItems(data.items) })
|
||||
.catch(console.error)
|
||||
.finally(() => setTreeLoading(false))
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadTree(effectiveAgentId, currentPath)
|
||||
}, [effectiveAgentId, currentPath, loadTree])
|
||||
|
||||
// Reset path when agent changes
|
||||
const handleAgentChange = (id: string | null) => {
|
||||
setActiveAgentId(id)
|
||||
setCurrentPath("")
|
||||
const navigateTo = (path: string) => {
|
||||
setSelectedFile(null)
|
||||
setFileContent(null)
|
||||
loadDirectory(path)
|
||||
}
|
||||
|
||||
// ── Navigate into a directory ─────────────────────────────────────────────
|
||||
const handleNavigate = (path: string) => {
|
||||
setCurrentPath(path)
|
||||
}
|
||||
|
||||
// ── Load file content on selection ───────────────────────────────────────
|
||||
const handleSelectFile = React.useCallback((filePath: string) => {
|
||||
setSelectedFile(filePath)
|
||||
setPreviewLoading(true)
|
||||
const url = `/api/tiger/agents/${effectiveAgentId}/file?path=${encodeURIComponent(filePath)}`
|
||||
apiFetch<FileContent>(url)
|
||||
.then((data) => setFileContent(data))
|
||||
.catch(console.error)
|
||||
.finally(() => setPreviewLoading(false))
|
||||
}, [effectiveAgentId])
|
||||
|
||||
// ── Load activity feed ────────────────────────────────────────────────────
|
||||
const loadActivity = React.useCallback(() => {
|
||||
setActivityLoading(true)
|
||||
apiFetch<{ ok: boolean; events: ActivityEvent[] }>("/api/tiger/activity?limit=50")
|
||||
.then((data) => { if (data.ok) setActivityEvents(data.events) })
|
||||
.catch(console.error)
|
||||
.finally(() => setActivityLoading(false))
|
||||
}, [])
|
||||
|
||||
// Recently active agent ids (activity within last hour) for badge highlighting
|
||||
const recentIds = React.useMemo(() => {
|
||||
const cutoff = Date.now() - 60 * 60 * 1000
|
||||
return new Set(activityEvents.filter((e) => e.ts > cutoff).map((e) => e.agentId))
|
||||
}, [activityEvents])
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────
|
||||
// Build breadcrumb path
|
||||
const breadcrumbs = currentPath ? currentPath.split("/").filter(Boolean) : []
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-4rem)] gap-3 p-4">
|
||||
{/* Page title */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Workspace</h1>
|
||||
<p className="text-sm text-muted-foreground">Browse agent files and recent activity</p>
|
||||
<div className="h-[calc(100vh-4rem)] flex flex-col gap-4 p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Workspace</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browse files in Tiger's workspace
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigateTo("")}
|
||||
disabled={!currentPath}
|
||||
>
|
||||
<Home className="h-4 w-4 mr-2" />
|
||||
Root
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Agent chip row */}
|
||||
<AgentChipRow
|
||||
agents={agents}
|
||||
activeId={activeAgentId}
|
||||
onChange={handleAgentChange}
|
||||
recentIds={recentIds}
|
||||
/>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Tabs: Files | Activity */}
|
||||
<Tabs
|
||||
defaultValue="files"
|
||||
className="flex-1 flex flex-col min-h-0"
|
||||
onValueChange={(v) => { if (v === "activity") loadActivity() }}
|
||||
>
|
||||
<TabsList className="self-start">
|
||||
<TabsTrigger value="files">Files</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ── Files tab ─────────────────────────────────────────────────── */}
|
||||
<TabsContent value="files" className="flex-1 flex min-h-0 mt-2">
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-[280px_1fr] gap-3 min-h-0">
|
||||
{/* File tree panel */}
|
||||
<div className="border rounded-lg bg-card/40 flex flex-col min-h-0 overflow-hidden">
|
||||
<div className="px-3 py-2 border-b shrink-0">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{agents.find((a) => a.id === effectiveAgentId)?.emoji}{" "}
|
||||
{agents.find((a) => a.id === effectiveAgentId)?.name ?? "Tiger"}
|
||||
</span>
|
||||
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0">
|
||||
{/* File List */}
|
||||
<Card className="col-span-1 bg-card/40 flex flex-col min-h-0">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Files</CardTitle>
|
||||
{/* Breadcrumb */}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-1">
|
||||
<button onClick={() => navigateTo("")} className="hover:text-foreground">root</button>
|
||||
{breadcrumbs.map((crumb, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<button onClick={() => navigateTo(breadcrumbs.slice(0, i + 1).join("/"))} className="hover:text-foreground">
|
||||
{crumb}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
<ScrollArea className="flex-1 p-2">
|
||||
<FileTree
|
||||
items={treeItems}
|
||||
currentPath={currentPath}
|
||||
selectedFile={selectedFile}
|
||||
onNavigate={handleNavigate}
|
||||
onSelectFile={handleSelectFile}
|
||||
loading={treeLoading}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Preview panel */}
|
||||
<div className="border rounded-lg bg-card/40 flex flex-col min-h-0 overflow-hidden">
|
||||
<FilePreview
|
||||
path={selectedFile}
|
||||
content={fileContent?.content ?? null}
|
||||
encoding={fileContent?.encoding ?? null}
|
||||
mime={fileContent?.mime ?? null}
|
||||
size={fileContent?.size ?? 0}
|
||||
loading={previewLoading}
|
||||
agentId={effectiveAgentId}
|
||||
onSaved={(_p, newContent) => {
|
||||
// Update cached content so the view reflects the save immediately
|
||||
setFileContent((prev) => prev ? { ...prev, content: newContent } : prev)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ── Activity tab ──────────────────────────────────────────────── */}
|
||||
<TabsContent value="activity" className="flex-1 min-h-0 mt-2">
|
||||
<div className="border rounded-lg bg-card/40 h-full overflow-hidden">
|
||||
<div className="px-3 py-2 border-b">
|
||||
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
Recent changes — all agents
|
||||
</span>
|
||||
</div>
|
||||
<ScrollArea className="h-[calc(100%-2.5rem)]">
|
||||
<div className="p-2">
|
||||
<ActivityFeed events={activityEvents} loading={activityLoading} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-y-auto">
|
||||
{loadingFiles ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : files.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">No files</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{/* Parent directory */}
|
||||
{currentPath && (
|
||||
<button
|
||||
onClick={() => navigateTo(breadcrumbs.slice(0, -1).join("/"))}
|
||||
className="w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted text-left"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">..</span>
|
||||
</button>
|
||||
)}
|
||||
{files.map((file) => (
|
||||
<button
|
||||
key={file.name}
|
||||
onClick={() => file.type === "directory" ? navigateTo(currentPath ? `${currentPath}/${file.name}` : file.name) : loadFile(file.name)}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 p-2 rounded-md hover:bg-muted text-left",
|
||||
selectedFile === file.name && "bg-muted"
|
||||
)}
|
||||
>
|
||||
{file.type === "directory" ? (
|
||||
<Folder className="h-4 w-4 text-amber-400" />
|
||||
) : (
|
||||
<FileText className={cn(
|
||||
"h-4 w-4",
|
||||
getFileIcon(file.name) === "code" ? "text-blue-400" :
|
||||
getFileIcon(file.name) === "markdown" ? "text-purple-400" :
|
||||
"text-muted-foreground"
|
||||
)} />
|
||||
)}
|
||||
<span className="text-sm truncate flex-1">{file.name}</span>
|
||||
{file.size && <span className="text-xs text-muted-foreground">{formatSize(file.size)}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* File Viewer */}
|
||||
<Card className="col-span-2 bg-card/40 flex flex-col min-h-0">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">
|
||||
{selectedFile || "Select a file to view"}
|
||||
</CardTitle>
|
||||
{fileContent && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatSize(fileContent.size)}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-y-auto">
|
||||
{loadingContent ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !selectedFile ? (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
Click a file to view its contents
|
||||
</div>
|
||||
) : fileContent ? (
|
||||
<pre className={cn(
|
||||
"text-xs font-mono whitespace-pre-wrap break-all p-3 rounded-md border",
|
||||
fileContent.ok ? "bg-background/50" : "bg-destructive/10 border-destructive"
|
||||
)}>
|
||||
{fileContent.content}
|
||||
</pre>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</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,37 +1,15 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* app-sidebar.tsx — Tiger Command Center sidebar
|
||||
*
|
||||
* Phase 1 redesign. The new IA is built around the way YOU actually work:
|
||||
* Home — command + live state (the new front door)
|
||||
* Chat — unified web + Telegram thread (Phase 4 will wire Telegram)
|
||||
* Projects — orchestration containers; click a project to see tasks + agents
|
||||
* Agents — per-sub-agent detail and (Phase 3) per-agent model overrides
|
||||
* Knowledge — Tiger's brain: memory, skills, activity, scheduled jobs
|
||||
* Workspace — file tree of /sandbox + diffs
|
||||
* Positions — live P&L + open positions
|
||||
* Cost — finance-grade cost dashboard
|
||||
* Logs — raw streaming logs (dev drill-down)
|
||||
* Settings
|
||||
*
|
||||
* Key change from the previous sidebar: no orphan pages. Memory, Sessions,
|
||||
* Skills, Activity, and Cron are now reachable through Knowledge / Agents /
|
||||
* Chat instead of being floating routes nobody could navigate to.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Home,
|
||||
MessageSquare,
|
||||
Briefcase,
|
||||
Bot,
|
||||
Brain,
|
||||
FolderOpen,
|
||||
BarChart2,
|
||||
DollarSign,
|
||||
ScrollText,
|
||||
Settings2,
|
||||
LayoutDashboard,
|
||||
ScrollText,
|
||||
CheckSquare,
|
||||
DollarSign,
|
||||
FolderOpen,
|
||||
Briefcase,
|
||||
} from "lucide-react"
|
||||
import { useTigerLogs } from "@/hooks/use-bridge"
|
||||
|
||||
|
|
@ -46,29 +24,51 @@ 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: "Positions", url: "/positions", icon: BarChart2 },
|
||||
{ title: "Activity", url: "/activity", icon: ScrollText },
|
||||
{ title: "Cost", url: "/cost", icon: DollarSign },
|
||||
{ title: "Logs", url: "/logs", icon: ScrollText },
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
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 (
|
||||
|
|
@ -78,58 +78,47 @@ 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 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 flex-col gap-0.5 leading-none">
|
||||
<span className="text-sm font-semibold">Tiger</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${
|
||||
connected ? "bg-green-500 animate-pulse" : "bg-red-500"
|
||||
}`} />
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
{connected ? "Live" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
<div 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">
|
||||
{navMain.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenu className="gap-2 p-2">
|
||||
{navMain.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
{navSecondary.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild size="sm" tooltip={item.title}>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
<SidebarMenu>
|
||||
{navSecondary.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild size="sm">
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +1,159 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Send, Square, Bot, User, AlertCircle, Loader2, Eraser,
|
||||
Plus, ChevronDown, Trash2,
|
||||
} from "lucide-react"
|
||||
import { Send, Square, Bot, User, AlertCircle, Loader2 } from "lucide-react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useChatContext } from "@/contexts/chat-context"
|
||||
import { useGatewayRequest, useGatewayEvents } from "@/hooks/use-gateway"
|
||||
|
||||
type Message = {
|
||||
id: string
|
||||
role: "user" | "agent" | "system"
|
||||
content: string
|
||||
streaming?: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
function extractContent(content: unknown): string {
|
||||
if (typeof content === "string") return content
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((block: unknown) => {
|
||||
if (typeof block === "string") return block
|
||||
if (block && typeof block === "object" && "text" in block) return String((block as { text: string }).text)
|
||||
return ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
if (content && typeof content === "object") {
|
||||
const obj = content as Record<string, unknown>
|
||||
if ("text" in obj) return String(obj.text)
|
||||
if ("content" in obj) return extractContent(obj.content)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
export function ChatInterface({ className, ...props }: React.ComponentProps<typeof Card>) {
|
||||
const [input, setInput] = React.useState("")
|
||||
const {
|
||||
messages, setMessages, clearChat,
|
||||
currentSessionKey, sessions, selectSession, newSession, deleteSession, refreshSessions,
|
||||
} = useChatContext()
|
||||
const [messages, setMessages] = React.useState<Message[]>([])
|
||||
const [sending, setSending] = React.useState(false)
|
||||
const [dropdownOpen, setDropdownOpen] = React.useState(false)
|
||||
const [agentTyping, setAgentTyping] = React.useState(false)
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null)
|
||||
const abortRef = React.useRef<AbortController | null>(null)
|
||||
const streamingRef = React.useRef("")
|
||||
const dropdownRef = React.useRef<HTMLDivElement>(null)
|
||||
const { request } = useGatewayRequest()
|
||||
const streamingContentRef = React.useRef("")
|
||||
|
||||
// Load chat history on mount
|
||||
React.useEffect(() => {
|
||||
request("chat.history", { sessionKey: "agent:main:main", limit: 50 })
|
||||
.then((data: unknown) => {
|
||||
const history = data as { messages?: Array<{ role: string; content: unknown; ts?: number }> }
|
||||
if (history?.messages?.length) {
|
||||
setMessages(
|
||||
history.messages.map((m, i) => ({
|
||||
id: `hist-${i}`,
|
||||
role: m.role === "user" ? "user" : "agent",
|
||||
content: extractContent(m.content),
|
||||
timestamp: m.ts || Date.now(),
|
||||
}))
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setMessages([{
|
||||
id: "welcome",
|
||||
role: "agent",
|
||||
content: "Connected to Tarzan via gateway. Send a message to start chatting.",
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
// Subscribe to gateway events for streaming responses
|
||||
const { connected } = useGatewayEvents((event, payload) => {
|
||||
const data = payload as Record<string, unknown>
|
||||
|
||||
if (event === "chat") {
|
||||
// Incoming chat message (from other channels or agent completion)
|
||||
const role = (data.role as string) === "user" ? "user" : "agent"
|
||||
const content = extractContent(data.text || data.content || "")
|
||||
if (!content) return
|
||||
|
||||
setMessages(prev => {
|
||||
// Remove any streaming message and add final
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
return [...filtered, {
|
||||
id: `chat-${Date.now()}`,
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
setAgentTyping(false)
|
||||
streamingContentRef.current = ""
|
||||
}
|
||||
|
||||
if (event === "agent") {
|
||||
// Streaming agent response chunks
|
||||
const chunk = data.chunk as string | undefined
|
||||
const done = data.done as boolean | undefined
|
||||
const text = data.text as string | undefined
|
||||
|
||||
if (chunk || text) {
|
||||
streamingContentRef.current += (chunk || text || "")
|
||||
setAgentTyping(true)
|
||||
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
return [...filtered, {
|
||||
id: "streaming",
|
||||
role: "agent",
|
||||
content: streamingContentRef.current,
|
||||
streaming: true,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
if (done) {
|
||||
setAgentTyping(false)
|
||||
setSending(false)
|
||||
// Finalize streaming message
|
||||
if (streamingContentRef.current) {
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
return [...filtered, {
|
||||
id: `agent-${Date.now()}`,
|
||||
role: "agent",
|
||||
content: streamingContentRef.current,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
}
|
||||
streamingContentRef.current = ""
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-scroll to bottom
|
||||
React.useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
// Close dropdown on outside click
|
||||
React.useEffect(() => {
|
||||
if (!dropdownOpen) return
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setDropdownOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", onClick)
|
||||
return () => document.removeEventListener("mousedown", onClick)
|
||||
}, [dropdownOpen])
|
||||
|
||||
const currentLabel = sessions.find(s => s.key === currentSessionKey)?.label
|
||||
|| (currentSessionKey === "agent:main:main" ? "Main" : "Chat")
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!input.trim() || sending) return
|
||||
|
|
@ -54,8 +161,9 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
const text = input.trim()
|
||||
setInput("")
|
||||
setSending(true)
|
||||
streamingRef.current = ""
|
||||
streamingContentRef.current = ""
|
||||
|
||||
// Add user message immediately
|
||||
setMessages(prev => [...prev, {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
|
|
@ -64,200 +172,45 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
}])
|
||||
|
||||
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, sessionKey: currentSessionKey }),
|
||||
signal: controller.signal,
|
||||
await request("chat.send", {
|
||||
sessionKey: "agent:main:main",
|
||||
message: text,
|
||||
idempotencyKey: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
})
|
||||
|
||||
if (!res.ok || !res.body) throw new Error("Failed to connect")
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
const streamId = `streaming-${Date.now()}`
|
||||
|
||||
while (true) {
|
||||
const { done: readerDone, value } = await reader.read()
|
||||
if (readerDone) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const events = buffer.split("\n\n")
|
||||
buffer = events.pop() || ""
|
||||
|
||||
for (const eventBlock of events) {
|
||||
const dataLine = eventBlock.split("\n").find(l => l.startsWith("data: "))
|
||||
if (!dataLine) continue
|
||||
|
||||
let data: { type: string; content?: string }
|
||||
try {
|
||||
data = JSON.parse(dataLine.slice(6))
|
||||
} catch (err) {
|
||||
console.warn("[chat] SSE parse error:", err, "line:", dataLine)
|
||||
continue
|
||||
}
|
||||
|
||||
if (data.type === "status") {
|
||||
// Show streaming placeholder so the typing indicator appears.
|
||||
setMessages(prev => {
|
||||
if (prev.some(m => m.streaming)) return prev
|
||||
return [...prev, {
|
||||
id: streamId,
|
||||
role: "agent",
|
||||
content: "",
|
||||
streaming: true,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
} else if (data.type === "chunk") {
|
||||
streamingRef.current += data.content || ""
|
||||
setMessages(prev => {
|
||||
const existing = prev.find(m => m.streaming)
|
||||
if (existing) {
|
||||
return prev.map(m =>
|
||||
m.streaming ? { ...m, content: streamingRef.current } : m
|
||||
)
|
||||
}
|
||||
return [...prev, {
|
||||
id: streamId,
|
||||
role: "agent",
|
||||
content: streamingRef.current,
|
||||
streaming: true,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
} else if (data.type === "done") {
|
||||
const finalContent = streamingRef.current || data.content || ""
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
if (!finalContent) return filtered
|
||||
return [...filtered, {
|
||||
id: `agent-${Date.now()}`,
|
||||
role: "agent",
|
||||
content: finalContent,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
streamingRef.current = ""
|
||||
setSending(false)
|
||||
// Refresh session list so updatedAt and messageCount reflect this turn.
|
||||
refreshSessions()
|
||||
} else if (data.type === "error") {
|
||||
setMessages(prev => [...prev.filter(m => !m.streaming), {
|
||||
id: `err-${Date.now()}`,
|
||||
role: "system",
|
||||
content: data.content || "Something went wrong",
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name !== "AbortError") {
|
||||
setMessages(prev => [...prev.filter(m => !m.streaming), {
|
||||
id: `err-${Date.now()}`,
|
||||
role: "system",
|
||||
content: "Failed to send message. Is Tiger running?",
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
}
|
||||
// Response will come via SSE events (agent/chat events)
|
||||
} catch {
|
||||
setSending(false)
|
||||
setMessages(prev => [...prev, {
|
||||
id: `err-${Date.now()}`,
|
||||
role: "system",
|
||||
content: "Failed to send message. Is the gateway running?",
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
}
|
||||
abortRef.current = null
|
||||
}
|
||||
|
||||
const handleAbort = () => {
|
||||
abortRef.current?.abort()
|
||||
const handleAbort = async () => {
|
||||
try {
|
||||
await request("chat.abort", { sessionKey: "agent:main:main" })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
setSending(false)
|
||||
streamingRef.current = ""
|
||||
setMessages(prev => prev.filter(m => !m.streaming))
|
||||
setAgentTyping(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("w-full flex flex-col", className)} {...props}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="flex items-center gap-2 text-base min-w-0">
|
||||
<Bot className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="truncate">Chat with Tiger</span>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Sessions dropdown — flavor C: button + dropdown */}
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDropdownOpen(o => !o)}
|
||||
className="h-7 text-xs gap-1 px-2"
|
||||
title="Switch chat session"
|
||||
>
|
||||
<span className="max-w-[100px] truncate">{currentLabel}</span>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 top-8 z-20 min-w-[220px] bg-popover border rounded-md shadow-lg overflow-hidden">
|
||||
<div className="py-1 max-h-[280px] overflow-y-auto">
|
||||
{sessions.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">No sessions yet</div>
|
||||
)}
|
||||
{sessions.map(s => (
|
||||
<div
|
||||
key={s.key}
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 px-3 py-2 text-xs hover:bg-accent cursor-pointer",
|
||||
s.key === currentSessionKey && "bg-accent"
|
||||
)}
|
||||
onClick={() => { selectSession(s.key); setDropdownOpen(false) }}
|
||||
>
|
||||
<span className="truncate">{s.label}</span>
|
||||
{!s.isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm(`Delete session "${s.label}"?`)) deleteSession(s.key)
|
||||
}}
|
||||
title="Delete session"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => newSession()}
|
||||
className="h-7 px-2"
|
||||
title="Start a new chat session"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearChat}
|
||||
className="h-7 px-2 text-muted-foreground"
|
||||
title="Clear current conversation"
|
||||
>
|
||||
<Eraser className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bot className="h-5 w-5" />
|
||||
Chat
|
||||
<span className={cn(
|
||||
"ml-auto h-2 w-2 rounded-full",
|
||||
connected ? "bg-green-500" : "bg-red-500"
|
||||
)} />
|
||||
</CardTitle>
|
||||
</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">
|
||||
|
|
@ -282,24 +235,22 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: message.role === "system"
|
||||
? "bg-destructive/10 text-destructive flex items-center gap-2 w-full justify-center"
|
||||
: "bg-muted",
|
||||
message.streaming ? "border border-primary/30" : ""
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: message.role === "system"
|
||||
? "bg-destructive/10 text-destructive flex items-center gap-2 w-full justify-center"
|
||||
: "bg-muted",
|
||||
message.streaming ? "border border-primary/30" : ""
|
||||
)}
|
||||
>
|
||||
{message.role === "system" && <AlertCircle className="h-3 w-3" />}
|
||||
{message.role === "agent" ? (
|
||||
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>
|
||||
)
|
||||
<div className="prose prose-sm prose-invert max-w-none [&>p]:m-0">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
message.content
|
||||
)}
|
||||
|
|
@ -309,7 +260,7 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sending && !messages.some(m => m.streaming) && (
|
||||
{agentTyping && !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" />
|
||||
|
|
@ -325,19 +276,19 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
<CardFooter className="pt-3">
|
||||
<form onSubmit={handleSubmit} className="flex w-full items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Message Tiger..."
|
||||
placeholder={connected ? "Message Tarzan..." : "Gateway offline..."}
|
||||
className="flex-1"
|
||||
autoComplete="off"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
disabled={sending}
|
||||
disabled={!connected || 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()}>
|
||||
<Button type="submit" size="icon" disabled={!input.trim() || !connected}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -187,7 +187,7 @@ export function CostMonitor() {
|
|||
<YAxis tick={{fontSize: 10}} stroke="#6b7280" tickFormatter={(v) => `$${v.toFixed(2)}`} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: 'none', borderRadius: '6px' }}
|
||||
formatter={(value) => [`$${Number(value).toFixed(4)}`, 'Cost']}
|
||||
formatter={(value: number) => [`$${value.toFixed(4)}`, 'Cost']}
|
||||
/>
|
||||
<Area type="monotone" dataKey="cost" stroke="#10b981" fillOpacity={1} fill="url(#colorCost)" />
|
||||
</AreaChart>
|
||||
|
|
@ -217,9 +217,9 @@ export function CostMonitor() {
|
|||
<YAxis type="category" dataKey="name" tick={{fontSize: 10}} stroke="#6b7280" width={100} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1f2937', border: 'none', borderRadius: '6px' }}
|
||||
formatter={(value, _name, props) => {
|
||||
formatter={(value: number, _name: string, props: any) => {
|
||||
const model = props?.payload?.fullModel || ''
|
||||
return [`$${Number(value).toFixed(4)}`, model]
|
||||
return [`$${value.toFixed(4)}`, model]
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="cost" fill="#8b5cf6" radius={[0, 4, 4, 0]}>
|
||||
|
|
|
|||
|
|
@ -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 || [],
|
||||
})))
|
||||
}
|
||||
|
|
@ -444,13 +443,13 @@ export function KanbanBoard() {
|
|||
id: editingTask.id,
|
||||
title: editingTask.title,
|
||||
description: editingTask.description,
|
||||
status: editingTask.status as string,
|
||||
status: editingTask.status,
|
||||
priority: editingTask.priority,
|
||||
assigned_agent: editingTask.assigned_agent,
|
||||
progress: editingTask.progress,
|
||||
tags: editingTask.tags,
|
||||
} : null}
|
||||
onSubmit={editingTask ? handleUpdateTask as any : handleAddTask}
|
||||
onSubmit={editingTask ? handleUpdateTask : handleAddTask}
|
||||
onDelete={editingTask ? () => handleDeleteTask(editingTask.id) : undefined}
|
||||
onRun={editingTask ? () => handleRunTask(editingTask.id) : undefined}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -43,9 +43,9 @@ interface TaskDialogProps {
|
|||
onSubmit: (data: {
|
||||
title: string
|
||||
description?: string
|
||||
status?: string
|
||||
priority?: string
|
||||
assigned_agent?: string | null
|
||||
status: string
|
||||
priority: string
|
||||
assigned_agent?: string
|
||||
progress?: number
|
||||
tags?: string[]
|
||||
due_date?: string
|
||||
|
|
|
|||
|
|
@ -1,220 +0,0 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* TelegramThreadCard — live mirror of the Telegram conversation with Tiger.
|
||||
*
|
||||
* Data source: /api/chat/telegram-thread → bridge /tiger/chat/telegram, which
|
||||
* reads OpenClaw's native session transcript (the same file Tiger's context
|
||||
* comes from). Both directions, full history, in sync by construction.
|
||||
*
|
||||
* Behaviour:
|
||||
* - loads the newest page, scrolled to the bottom (like Telegram itself)
|
||||
* - "Load older" at the top pages backwards through the entire history
|
||||
* - polls for new messages every 15s; only repaints when something changed
|
||||
* - preserves scroll position when older messages are prepended
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Send, ChevronUp, RefreshCw } from "lucide-react"
|
||||
|
||||
interface ThreadMessage {
|
||||
seq: number
|
||||
role: "user" | "agent"
|
||||
text: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
interface ThreadResponse {
|
||||
ok: boolean
|
||||
messages?: ThreadMessage[]
|
||||
hasMore?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 40
|
||||
const POLL_MS = 15_000
|
||||
|
||||
export function TelegramThreadCard() {
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
// Tracks whether the user is parked at the bottom — only then do we
|
||||
// auto-scroll on new messages, so reading history is never interrupted.
|
||||
const stickToBottom = useRef(true)
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (before?: number): Promise<ThreadResponse> => {
|
||||
const qs = new URLSearchParams({ limit: String(PAGE_SIZE) })
|
||||
if (before) qs.set("before", String(before))
|
||||
const r = await fetch(`/api/chat/telegram-thread?${qs.toString()}`)
|
||||
return r.json()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetchPage()
|
||||
.then((data) => {
|
||||
if (data.ok && data.messages) {
|
||||
setMessages(data.messages)
|
||||
setHasMore(Boolean(data.hasMore))
|
||||
} else {
|
||||
setError(data.error || "No data")
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [fetchPage])
|
||||
|
||||
// Poll for new messages
|
||||
useEffect(() => {
|
||||
const t = setInterval(() => {
|
||||
fetchPage()
|
||||
.then((data) => {
|
||||
if (!data.ok || !data.messages) return
|
||||
setMessages((prev) => {
|
||||
const newest = data.messages!
|
||||
if (
|
||||
prev.length > 0 &&
|
||||
newest.length > 0 &&
|
||||
prev[prev.length - 1].seq === newest[newest.length - 1].seq
|
||||
) {
|
||||
return prev // nothing new — keep referential equality, no repaint
|
||||
}
|
||||
// Merge: keep any older pages we already loaded, append the fresh tail.
|
||||
const known = new Set(prev.map((m) => m.seq))
|
||||
const fresh = newest.filter((m) => !known.has(m.seq))
|
||||
return fresh.length > 0 ? [...prev, ...fresh] : prev
|
||||
})
|
||||
})
|
||||
.catch(() => { /* transient poll errors are fine — next tick retries */ })
|
||||
}, POLL_MS)
|
||||
return () => clearInterval(t)
|
||||
}, [fetchPage])
|
||||
|
||||
// Auto-scroll to bottom on new tail messages (only if user was at bottom)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el && stickToBottom.current) {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
stickToBottom.current =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight < 40
|
||||
}
|
||||
|
||||
const loadOlder = async () => {
|
||||
if (loadingOlder || messages.length === 0) return
|
||||
setLoadingOlder(true)
|
||||
const el = scrollRef.current
|
||||
const prevHeight = el?.scrollHeight ?? 0
|
||||
try {
|
||||
const data = await fetchPage(messages[0].seq)
|
||||
if (data.ok && data.messages && data.messages.length > 0) {
|
||||
setMessages((prev) => [...data.messages!, ...prev])
|
||||
setHasMore(Boolean(data.hasMore))
|
||||
// Keep the viewport anchored on the message the user was reading.
|
||||
requestAnimationFrame(() => {
|
||||
if (el) el.scrollTop = el.scrollHeight - prevHeight
|
||||
})
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
} finally {
|
||||
setLoadingOlder(false)
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (iso: string) => {
|
||||
if (!iso) return ""
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const sameDay = d.toDateString() === now.toDateString()
|
||||
return sameDay
|
||||
? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
: d.toLocaleDateString([], { day: "numeric", month: "short" }) +
|
||||
" " +
|
||||
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
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>
|
||||
{!loading && !error && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground/60 flex items-center gap-1">
|
||||
<RefreshCw className="h-3 w-3" /> live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="flex-1 flex items-center justify-center h-80">
|
||||
<span className="text-sm text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex-1 flex items-center justify-center h-80">
|
||||
<span className="text-sm text-red-500">Error: {error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
className="h-80 overflow-y-auto pr-1 flex flex-col gap-2"
|
||||
>
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={loadOlder}
|
||||
disabled={loadingOlder}
|
||||
className="self-center text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1 py-1 px-2 rounded hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
{loadingOlder ? "Loading..." : "Load older"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{messages.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No messages yet
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.seq}
|
||||
className={`max-w-[85%] rounded-lg px-3 py-2 text-sm whitespace-pre-wrap break-words ${
|
||||
m.role === "user"
|
||||
? "self-end bg-primary/15 text-foreground"
|
||||
: "self-start bg-muted/50 text-foreground"
|
||||
}`}
|
||||
>
|
||||
<div>{m.text}</div>
|
||||
<div className="mt-1 text-[10px] text-muted-foreground/70 text-right">
|
||||
{m.role === "user" ? "you" : "tiger"} · {formatTime(m.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
import { Select as SelectPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
"use client"
|
||||
/**
|
||||
* activity-feed.tsx — chronological list of recent file modifications
|
||||
* across all agents.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import { Clock, Loader2 } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface ActivityEvent {
|
||||
agentId: string
|
||||
agentName: string
|
||||
agentEmoji: string
|
||||
path: string
|
||||
action: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
events: ActivityEvent[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
const diff = Date.now() - ts
|
||||
const s = Math.floor(diff / 1000)
|
||||
if (s < 60) return `${s}s ago`
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60) return `${m}m ago`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h ago`
|
||||
return `${Math.floor(h / 24)}d ago`
|
||||
}
|
||||
|
||||
export function ActivityFeed({ events, loading }: Props) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (events.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-2 text-muted-foreground">
|
||||
<Clock className="h-6 w-6" />
|
||||
<span className="text-sm">No recent activity</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{events.map((ev, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-start gap-3 px-3 py-2.5 rounded-md hover:bg-muted/40 transition-colors"
|
||||
>
|
||||
{/* Agent avatar */}
|
||||
<span className="text-base leading-none mt-0.5 shrink-0">{ev.agentEmoji}</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-medium">{ev.agentName}</span>
|
||||
<span className="text-xs text-muted-foreground">{ev.action}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono truncate">{ev.path}</p>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground shrink-0 mt-0.5">
|
||||
{relativeTime(ev.ts)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue