diff --git a/bridge/src/db.ts b/bridge/src/db.ts index b87d54a..7056888 100644 --- a/bridge/src/db.ts +++ b/bridge/src/db.ts @@ -21,7 +21,7 @@ if (!fs.existsSync(DATA_DIR)) { } const DB_PATH = path.join(DATA_DIR, "tiger.db"); -const db = new Database(DB_PATH); +const db: Database.Database = new Database(DB_PATH); // Enable WAL mode for better concurrency db.pragma("journal_mode = WAL"); diff --git a/bridge/src/index.ts b/bridge/src/index.ts index 9ba77c2..5aa4581 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -30,11 +30,13 @@ import statusRouter from "./routes/status.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 dispatchRouter from "./routes/dispatch.js"; +import agentsRouter from "./routes/agents.js"; import { initWatcher } from "./watcher.js"; // Import db to ensure it's initialized @@ -77,6 +79,7 @@ app.use("/tiger/status", statusRouter); 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 @@ -85,6 +88,7 @@ app.use("/tiger/files", filesRouter); // Same router handles both /workspace an app.use("/tiger/projects", projectsRouter); app.use("/tiger/tasks", tasksRouter); app.use("/tiger/dispatch", dispatchRouter); +app.use("/tiger/agents", agentsRouter); app.use("/tiger/chat", (await import("./routes/chat.js")).default); // Gateway proxy β€” forwards to gateway inside Tiger container diff --git a/bridge/src/routes/agents.ts b/bridge/src/routes/agents.ts new file mode 100644 index 0000000..2223fb4 --- /dev/null +++ b/bridge/src/routes/agents.ts @@ -0,0 +1,181 @@ +/** + * agents.ts β€” Per-agent workspace file browser + activity feed + */ + +import { Router, Request, Response } from "express"; +import { execInSandbox } from "../tiger.js"; + +const router = Router(); + +const AGENTS = [ + { id: "main", name: "Tiger", emoji: "🐯", role: "orchestrator", basePath: "/home/node/.openclaw/workspace" }, + { id: "coder", name: "Cody", emoji: "πŸ‘·", role: "Coder", basePath: "/home/node/.openclaw/agents/coder" }, + { id: "researcher", name: "Ethan", emoji: "πŸ”", role: "Researcher", basePath: "/home/node/.openclaw/agents/researcher" }, + { id: "writer", name: "Cathy", emoji: "✍️", role: "Writer", basePath: "/home/node/.openclaw/agents/writer" }, + { id: "pm", name: "Elon", emoji: "βœ…", role: "PM", basePath: "/home/node/.openclaw/agents/pm" }, +]; + +function getAgent(id: string) { + return AGENTS.find((a) => a.id === id) ?? null; +} + +function isSafePath(p: string): boolean { + return !p.includes("..") && !p.startsWith("/"); +} + +// GET /tiger/agents +router.get("/", async (_req: Request, res: Response) => { + try { + const results = await Promise.all( + AGENTS.map(async (agent) => { + const { stdout } = await execInSandbox( + `find ${agent.basePath} -type f -printf '%T@\n' 2>/dev/null | sort -rn` + ); + const mtimes = stdout.split("\n").filter(Boolean).map(Number); + return { + id: agent.id, name: agent.name, emoji: agent.emoji, role: agent.role, + fileCount: mtimes.length, + lastActivity: mtimes.length > 0 ? Math.floor(mtimes[0] * 1000) : 0, + }; + }) + ); + res.json({ ok: true, agents: results }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// GET /tiger/agents/:id/files?path=deliverables +router.get("/:id/files", async (req: Request, res: Response) => { + const agent = getAgent(req.params.id); + if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" }); + + const relPath = (req.query.path as string) || ""; + if (relPath && !isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" }); + + const targetDir = relPath ? `${agent.basePath}/${relPath}` : agent.basePath; + const dirName = targetDir.split("/").pop() ?? ""; + + try { + const { stdout } = await execInSandbox( + `find ${targetDir} -maxdepth 1 -printf '%y|%s|%T@|%f\n' 2>/dev/null | sort` + ); + + const items = stdout + .split("\n") + .filter(Boolean) + .map((line) => { + const [typeChar, sizeStr, mtimeStr, ...rest] = line.split("|"); + const name = rest.join("|"); + return { + name, + type: typeChar === "d" ? "dir" as const : "file" as const, + size: parseInt(sizeStr) || 0, + modifiedAt: Math.floor(parseFloat(mtimeStr) * 1000), + }; + }) + .filter((f) => f.name !== "." && f.name !== dirName); + + res.json({ ok: true, items }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +// GET /tiger/agents/:id/file?path=deliverables/ev-dashboard.html +router.get("/:id/file", async (req: Request, res: Response) => { + const agent = getAgent(req.params.id); + if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" }); + + const relPath = req.query.path as string; + if (!relPath) return res.status(400).json({ ok: false, error: "Missing path param" }); + if (!isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" }); + + const fullPath = `${agent.basePath}/${relPath}`; + + try { + const { stdout: sizeOut } = await execInSandbox(`stat -c%s ${fullPath} 2>/dev/null || echo 0`); + const size = parseInt(sizeOut.trim()) || 0; + if (size > 5 * 1024 * 1024) return res.status(413).json({ ok: false, error: "File too large (> 5MB)" }); + + const { stdout: mimeOut } = await execInSandbox(`file --mime-type -b ${fullPath} 2>/dev/null`); + const mime = mimeOut.trim(); + const isText = mime.startsWith("text/") || mime.includes("json") || mime.includes("xml") || mime.includes("javascript"); + + if (!isText && size > 0) { + const { stdout: b64 } = await execInSandbox(`base64 -w0 ${fullPath} 2>/dev/null`); + return res.json({ ok: true, path: relPath, content: b64, encoding: "base64", size, mime }); + } + + const { stdout: content, exitCode } = await execInSandbox(`cat ${fullPath} 2>/dev/null`); + if (exitCode !== 0) return res.status(404).json({ ok: false, error: "File not found" }); + + res.json({ ok: true, path: relPath, content, encoding: "utf8", size, mime }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + + +// GET /tiger/agents/activity?limit=50 +router.get("/activity", async (req: Request, res: Response) => { + const limit = Math.min(parseInt(req.query.limit as string) || 50, 200); + try { + const agentPaths = AGENTS.map((a) => a.basePath).join(" "); + const { stdout } = await execInSandbox( + `find ${agentPaths} -type f -printf '%T@ %p\n' 2>/dev/null | sort -rn | head -${limit}` + ); + const events = stdout + .split("\n") + .filter(Boolean) + .map((line) => { + const spaceIdx = line.indexOf(" "); + const ts = Math.floor(parseFloat(line.slice(0, spaceIdx)) * 1000); + const fullPath = line.slice(spaceIdx + 1); + const agent = AGENTS.find((a) => fullPath.startsWith(a.basePath)) ?? null; + if (!agent) return null; + const relPath = fullPath.slice(agent.basePath.length + 1); + return { agentId: agent.id, agentName: agent.name, agentEmoji: agent.emoji, path: relPath, action: "modified", ts }; + }) + .filter(Boolean); + res.json({ ok: true, events }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + + +// PUT /tiger/agents/:id/file?path=... β€” write file contents back into container +// Body: { content: string } +router.put("/:id/file", async (req: Request, res: Response) => { + const agent = getAgent(req.params.id); + if (!agent) return res.status(404).json({ ok: false, error: "Unknown agent id" }); + + const relPath = req.query.path as string; + if (!relPath) return res.status(400).json({ ok: false, error: "Missing path param" }); + if (!isSafePath(relPath)) return res.status(400).json({ ok: false, error: "Invalid path" }); + + const { content } = req.body as { content?: string }; + if (typeof content !== "string") { + return res.status(400).json({ ok: false, error: "Body must be { content: string }" }); + } + + const fullPath = `${agent.basePath}/${relPath}`; + + try { + // Write via stdin to avoid shell quoting issues with special characters. + // We base64-encode the content on the Node side, pipe it in, and decode inside the container. + const b64 = Buffer.from(content, "utf-8").toString("base64"); + const { exitCode, stderr } = await execInSandbox( + `echo '${b64}' | base64 -d > ${fullPath}` + ); + if (exitCode !== 0) { + return res.status(500).json({ ok: false, error: "Write failed", details: stderr }); + } + res.json({ ok: true, path: relPath }); + } catch (err: any) { + res.status(500).json({ ok: false, error: err.message }); + } +}); + +export default router; diff --git a/bridge/src/routes/chat.ts b/bridge/src/routes/chat.ts index 10d07f6..66891c1 100644 --- a/bridge/src/routes/chat.ts +++ b/bridge/src/routes/chat.ts @@ -19,7 +19,7 @@ import db from "../db.js"; // The main Tiger session β€” matches the hardcoded session in chat.send below. // Keep this constant in sync with the --session-id used by openclaw agent. -const DEFAULT_SESSION_ID = "c1e6a067-7ca5-423b-9506-105db0702997"; +const DEFAULT_SESSION_ID = "agent:main:main"; const insertMessage = db.prepare(` INSERT INTO chat_messages (session_id, role, content, meta) @@ -97,12 +97,12 @@ router.post("/", async (req, res) => { const escapedMessage = message.replace(/'/g, "'\\''"); // Use openclaw agent to send a message to the main session - // Session ID: c1e6a067-7ca5-423b-9506-105db0702997 (agent:main:main) + // Session ID: agent:main:main (agent:main:main) // In TIGER_REMOTE mode, prefix with ssh so docker runs on the VPS. const sshPrefix = process.env.TIGER_REMOTE === "true" ? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} ` : ""; - const cmd = `${sshPrefix}docker exec tiger-openclaw openclaw agent --session-id c1e6a067-7ca5-423b-9506-105db0702997 -m '${escapedMessage}' --json --timeout 120`; + const cmd = `${sshPrefix}docker exec tiger-openclaw openclaw agent --session-id agent:main:main -m '${escapedMessage}' --json --timeout 120`; const tBeforeSpawn = Date.now(); tSpawn = tBeforeSpawn - tStart; @@ -167,4 +167,31 @@ router.post("/", async (req, res) => { } }); + +// ─── 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; \ No newline at end of file diff --git a/bridge/src/routes/chat.ts.pre-ws-migration b/bridge/src/routes/chat.ts.pre-ws-migration new file mode 100644 index 0000000..4d5b9b4 --- /dev/null +++ b/bridge/src/routes/chat.ts.pre-ws-migration @@ -0,0 +1,197 @@ +/** + * routes/chat.ts β€” Chat via OpenClaw CLI + persistence + * + * POST /tiger/chat β€” send a message; response includes reply + * GET /tiger/chat/history β€” ?sessionId=X&limit=50 β†’ past messages + * DELETE /tiger/chat/history β€” ?sessionId=X β†’ clear history for a session + * + * Persistence rationale (see phase1b-patches.py): + * Chat history is duplicated into our SQLite so it survives: + * - browser hard refresh + * - close/reopen tab + * - use from a different device + * - OpenClaw restarts (session state may or may not persist internally) + * We own the read path; OpenClaw owns the reasoning context. + */ + +import { Router } from "express"; +import db from "../db.js"; + +// The main Tiger session β€” matches the hardcoded session in chat.send below. +// Keep this constant in sync with the --session-id used by openclaw agent. +const DEFAULT_SESSION_ID = "c1e6a067-7ca5-423b-9506-105db0702997"; + +const insertMessage = db.prepare(` + INSERT INTO chat_messages (session_id, role, content, meta) + VALUES (?, ?, ?, ?) +`); +const getHistory = db.prepare(` + SELECT id, role, content, meta, created_at + FROM chat_messages + WHERE session_id = ? + ORDER BY created_at ASC, id ASC + LIMIT ? +`); +const deleteHistory = db.prepare(` + DELETE FROM chat_messages WHERE session_id = ? +`); + +const router = Router(); + +// ─── GET /tiger/chat/history ───────────────────────────────────────────── +router.get("/history", (req, res) => { + const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID; + const limit = Math.min(parseInt(req.query.limit as string) || 200, 500); + const rows = getHistory.all(sessionId, limit) as any[]; + res.json({ + ok: true, + sessionId, + count: rows.length, + messages: rows.map((r) => ({ + id: String(r.id), + role: r.role, + content: r.content, + timestamp: new Date(r.created_at + "Z").getTime(), + meta: r.meta ? JSON.parse(r.meta) : {}, + })), + }); +}); + +// ─── DELETE /tiger/chat/history ────────────────────────────────────────── +router.delete("/history", (req, res) => { + const sessionId = (req.query.sessionId as string) || DEFAULT_SESSION_ID; + const result = deleteHistory.run(sessionId); + res.json({ ok: true, deleted: result.changes }); +}); + +// ─── POST /tiger/chat ──────────────────────────────────────────────────── +router.post("/", async (req, res) => { + const { message } = req.body; + + if (!message) { + return res.status(400).json({ ok: false, error: "message is required" }); + } + + // Persist the user's message BEFORE calling the LLM so history is intact + // even if the LLM call fails. + try { + insertMessage.run(DEFAULT_SESSION_ID, "user", message, "{}"); + } catch (e: any) { + console.warn("[chat] failed to persist user message:", e.message); + } + + // ── Timing instrumentation ────────────────────────────────────── + // Label each phase so we can see where latency goes. Format in logs: + // [chat.timing] spawn=120ms exec=2834ms parse=3ms total=2957ms + const tStart = Date.now(); + let tSpawn = 0; + let tExec = 0; + let tParse = 0; + + try { + const { exec } = await import("child_process"); + const { promisify } = await import("util"); + const execAsync = promisify(exec); + + // Escape the message for shell + const escapedMessage = message.replace(/'/g, "'\\''"); + + // Use openclaw agent to send a message to the main session + // Session ID: c1e6a067-7ca5-423b-9506-105db0702997 (agent:main:main) + // In TIGER_REMOTE mode, prefix with ssh so docker runs on the VPS. + const sshPrefix = process.env.TIGER_REMOTE === "true" + ? `ssh ${process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"} ` + : ""; + const cmd = `${sshPrefix}docker exec tiger-openclaw openclaw agent --session-id c1e6a067-7ca5-423b-9506-105db0702997 -m '${escapedMessage}' --json --timeout 120`; + + const tBeforeSpawn = Date.now(); + tSpawn = tBeforeSpawn - tStart; + console.log("[chat] Executing:", cmd.substring(0, 100) + "..."); + + const { stdout, stderr } = await execAsync(cmd, { + timeout: 130000, + maxBuffer: 10 * 1024 * 1024, + }); + + tExec = Date.now() - tBeforeSpawn; + console.log("[chat] Response:", stdout.substring(0, 500)); + + // Parse the JSON response + const tBeforeParse = Date.now(); + let result; + try { + result = JSON.parse(stdout); + } catch { + result = { output: stdout, error: stderr }; + } + tParse = Date.now() - tBeforeParse; + + const tTotal = Date.now() - tStart; + console.log( + `[chat.timing] spawn=${tSpawn}ms exec=${tExec}ms parse=${tParse}ms total=${tTotal}ms` + ); + + // Persist the agent's reply. Extract text using the same fallback chain + // as the dashboard so we store whatever the user actually sees. + try { + const agentText = + result?.result?.payloads?.[0]?.text || + result?.payloads?.[0]?.text || + result?.summary || + result?.text || + ""; + if (agentText) { + const meta = { + runId: result?.runId, + model: result?.result?.meta?.agentMeta?.model || result?.meta?.agentMeta?.model, + durationMs: tTotal, + }; + insertMessage.run(DEFAULT_SESSION_ID, "agent", agentText, JSON.stringify(meta)); + } + } catch (e: any) { + console.warn("[chat] failed to persist agent reply:", e.message); + } + + res.json({ + ok: true, + timing: { spawn: tSpawn, exec: tExec, parse: tParse, total: tTotal }, + response: result, + }); + } catch (err: any) { + const tTotal = Date.now() - tStart; + console.error(`[chat] Error after ${tTotal}ms:`, err.message); + res.status(500).json({ + ok: false, + error: err.message || "Failed to send chat message", + }); + } +}); + + +// ─── POST /tiger/chat/persist ───────────────────────────────────────────── +// Write-only endpoint used by the new WS-based dashboard chat route. +// The dashboard streams events directly from the OpenClaw gateway (no docker exec), +// but we still want chat history to land in our sqlite so the dashboard's +// history UI keeps working. Dashboard calls this AFTER its stream completes. +// +// Body: { role: "user"|"agent", content: string, meta?: object, sessionId?: string } +router.post("/persist", (req, res) => { + const { role, content, meta, sessionId } = req.body || {}; + if (role !== "user" && role !== "agent") { + return res.status(400).json({ ok: false, error: "role must be 'user' or 'agent'" }); + } + if (typeof content !== "string" || !content) { + return res.status(400).json({ ok: false, error: "content is required" }); + } + try { + const sid = (typeof sessionId === "string" && sessionId) || DEFAULT_SESSION_ID; + const metaJson = meta && typeof meta === "object" ? JSON.stringify(meta) : "{}"; + const info = insertMessage.run(sid, role, content, metaJson); + res.json({ ok: true, id: String(info.lastInsertRowid), sessionId: sid }); + } catch (e: any) { + console.warn("[chat.persist] failed:", e.message); + res.status(500).json({ ok: false, error: e.message }); + } +}); + +export default router; \ No newline at end of file diff --git a/bridge/src/routes/dispatch.ts b/bridge/src/routes/dispatch.ts index 8089c0d..3b11a62 100644 --- a/bridge/src/routes/dispatch.ts +++ b/bridge/src/routes/dispatch.ts @@ -89,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.trim()) { - const taskData = JSON.parse(content); + if (content && content.stdout && content.stdout.trim()) { + const taskData = JSON.parse(content.stdout); return res.json({ ok: true, status: dir, diff --git a/bridge/src/routes/models.ts b/bridge/src/routes/models.ts new file mode 100644 index 0000000..cb5998c --- /dev/null +++ b/bridge/src/routes/models.ts @@ -0,0 +1,19 @@ +/** + * GET /tiger/config/models β€” list all models Tiger knows about + * Response: { ok: true, models: [{ id, name, provider, reasoning, contextWindow }] } + */ +import { Router, Request, Response } from "express"; +import { readModels } from "../tiger.js"; + +const router = Router(); + +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 }); + } +}); + +export default router; diff --git a/bridge/src/tiger.ts b/bridge/src/tiger.ts index 7efcd23..c3b0bec 100644 --- a/bridge/src/tiger.ts +++ b/bridge/src/tiger.ts @@ -17,7 +17,9 @@ const execAsync = promisify(exec); const DOCKER_CONTAINER = "tiger-openclaw"; const K8S_NAMESPACE = "openshell"; const POD_NAME = "tiger"; -const OPENCLAW_CONFIG_HOST = "/root/.openclaw/openclaw.json"; +// Real config lives in the Docker named volume, NOT on the host root path +const OPENCLAW_CONFIG_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/openclaw.json"; +const OPENCLAW_MODELS_HOST = "/var/lib/docker/volumes/tiger_tiger-config/_data/agents/main/agent/models.json"; const CONFIG_HASH_PATH_SANDBOX = "/sandbox/.openclaw/.config-hash"; const WORKSPACE_SYMLINK = "/var/lib/docker/volumes/tiger_tiger-workspace/_data"; const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh"; @@ -158,25 +160,30 @@ export async function getTigerStatus() { let fallbackModels: string[] = []; let availableModels: string[] = []; try { - const configRaw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8"); - const config = JSON.parse(configRaw); + // 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 : []; - } + 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 : []; + } - // Also surface all configured provider/model IDs so the UI shows what's - // available, not just what's selected. Format: "provider/model-id" - const providers = config?.providers || {}; - for (const [provName, provCfg] of Object.entries(providers)) { - const models = provCfg?.models; - if (Array.isArray(models)) { - for (const m of models) { - if (m?.id) availableModels.push(`${provName}/${m.id}`); + // Surface available models from models.providers section + const providers = config?.models?.providers || config?.providers || {}; + for (const [provName, provCfg] of Object.entries(providers)) { + const models = (provCfg as any)?.models; + if (Array.isArray(models)) { + for (const m of models) { + if (m?.id) availableModels.push(`${provName}/${m.id}`); + } } } } @@ -224,23 +231,55 @@ export async function getConfig(): Promise> { * Previously this was a manual step that caused repeated failures. */ export async function updateConfig(patch: Record): Promise { - // 1. Read current config + // 1. Read current config from the Docker volume (the real runtime config) const current = await getConfig(); - // 2. Deep merge the patch (shallow for now, can enhance later) + // 2. Deep-merge the patch const merged = deepMerge(current, patch); const configStr = JSON.stringify(merged, null, 2); - // 3. Backup current config before writing + // 3. Backup before writing (in the volume directory) const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} /root/.openclaw/backups/openclaw-${timestamp}.json`); + const backupPath = OPENCLAW_CONFIG_HOST.replace("openclaw.json", `openclaw-${timestamp}.bak.json`); + await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} ${backupPath} 2>/dev/null || true`); - // 4. Write updated config + // 4. Write back to the volume file β€” no hash regeneration needed in OpenClaw v2026 await writeFile(OPENCLAW_CONFIG_HOST, configStr, "utf-8"); +} - // 5. Regenerate config hash β€” the step that was always forgotten! - const hash = createHash("sha256").update(configStr).digest("hex"); - await execInSandbox(`echo '${hash}' > ${CONFIG_HASH_PATH_SANDBOX}`); + +/** + * Read the available models list from the agent models registry. + * Returns an array of { id, name, provider, reasoning, contextWindow } objects. + */ +export async function readModels(): Promise<{ + id: string; name: string; provider: string; + reasoning: boolean; contextWindow: number; cost?: { input: number; output: number } +}[]> { + try { + const raw = await readFile(OPENCLAW_MODELS_HOST, "utf-8"); + const data = JSON.parse(raw); + const results: any[] = []; + const providers: Record = data?.providers ?? {}; + for (const [provName, provCfg] of Object.entries(providers)) { + for (const model of (provCfg.models ?? [])) { + const rawId = model.id as string; + // Normalise to "provider/id" form + const id = rawId.includes("/") ? rawId : `${provName}/${rawId}`; + results.push({ + id, + name: model.name ?? rawId, + provider: provName, + reasoning: model.reasoning ?? false, + contextWindow: model.contextWindow ?? 0, + cost: model.cost, + }); + } + } + return results; + } catch { + return []; + } } /** Deep merge helper β€” second object wins on conflicts */ diff --git a/dashboard/lib_smoke.ts b/dashboard/lib_smoke.ts new file mode 100644 index 0000000..d4efd5d --- /dev/null +++ b/dashboard/lib_smoke.ts @@ -0,0 +1,42 @@ +/** + * Smoke test for the new openclaw-ws.ts library. + * Tests: + * 1. callGateway with sessions.list β€” verify non-streaming RPC works + * 2. streamAgentRun on agent:main:main β€” verify chunks arrive in real time + * 3. streamAgentRun on a NEW sessionKey β€” verify isolation + */ +import { callGateway, streamAgentRun, newSessionKey } from "./src/lib/openclaw-ws.js"; + +async function main() { + process.env.OPENCLAW_GATEWAY_TOKEN = "c5996580041c8f117532462877c34996d5563ef7a571a2b42913ee53d8fdfa6d"; + + console.log("=== TEST 1: sessions.list ==="); + const r = await callGateway("sessions.list", {}); + console.log("ok:", r.ok, "count:", (r.payload as any)?.sessions?.length); + ((r.payload as any)?.sessions || []).forEach((s: any) => console.log(" -", s.key, "|", s.displayName)); + + console.log("\n=== TEST 2: streamAgentRun on agent:main:main ==="); + const t0 = Date.now(); + let chunks = 0; + for await (const ev of streamAgentRun({ + sessionKey: "agent:main:main", + message: "Reply with exactly the word PONG. Nothing else.", + })) { + if (ev.kind === "chunk") chunks++; + console.log(` +${Date.now()-t0}ms ${ev.kind}: ${ev.content.slice(0,60)}`); + } + console.log(` total chunks: ${chunks}`); + + console.log("\n=== TEST 3: streamAgentRun on NEW sessionKey ==="); + const newKey = newSessionKey(); + console.log("new sessionKey:", newKey); + for await (const ev of streamAgentRun({ + sessionKey: newKey, + message: "What is your name? Reply briefly.", + })) { + if (ev.kind !== "status") console.log(` ${ev.kind}: ${ev.content.slice(0,80)}`); + } + console.log("DONE"); +} + +main().catch(e => { console.error("FAIL", e); process.exit(1); }); diff --git a/dashboard/public/favicon.ico b/dashboard/public/favicon.ico new file mode 100644 index 0000000..200f805 Binary files /dev/null and b/dashboard/public/favicon.ico differ diff --git a/dashboard/public/tiger-icon-32.png b/dashboard/public/tiger-icon-32.png new file mode 100644 index 0000000..8b68967 Binary files /dev/null and b/dashboard/public/tiger-icon-32.png differ diff --git a/dashboard/src/app/api/chat/route.ts b/dashboard/src/app/api/chat/route.ts index b28c25c..f638aa8 100644 --- a/dashboard/src/app/api/chat/route.ts +++ b/dashboard/src/app/api/chat/route.ts @@ -1,137 +1,120 @@ /** - * API route: POST /api/chat - * Sends chat messages via Tiger Bridge -> OpenClaw CLI + * /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, NextResponse } from "next/server"; +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 = 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(); +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 { - // Call the bridge - const response = await fetch(`${BRIDGE_URL}/tiger/chat`, { + await fetch(`${BRIDGE_URL}/tiger/chat/persist`, { method: "POST", headers: { "Content-Type": "application/json", - "Authorization": `Bearer ${BRIDGE_TOKEN}`, + Authorization: `Bearer ${BRIDGE_TOKEN}`, }, - body: JSON.stringify({ message }), + body: JSON.stringify({ role, content, sessionId: sessionKey, meta: meta || {} }), }); + } catch (err) { + console.warn("[chat] persist failed:", role, (err as Error).message); + } +} - const tBridgeDone = Date.now(); - const data = await response.json(); +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 (data?.timing) { - console.log( - `[chat.timing] bridge: ${JSON.stringify(data.timing)} | dashboard: bridge_call=${tBridgeDone - t0}ms` - ); - } + if (!message || typeof message !== "string") { + return new Response(JSON.stringify({ error: "message is required" }), { + status: 400, headers: { "Content-Type": "application/json" }, + }); + } - console.log("[chat] Bridge response:", JSON.stringify(data).substring(0, 500)); + // Persist user message NOW, before the LLM call. If the call fails, the + // history still records what the user said. + await persistMessage("user", message, sessionKey); - if (!response.ok) { - return NextResponse.json( - { error: data.error || "Chat failed" }, - { status: response.status } - ); - } + const t0 = Date.now(); + const encoder = new TextEncoder(); - // Extract the text response - OpenClaw returns in several possible formats - let text = ""; + /** + * 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`)); + }; - 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); - } + try { + let fullText = ""; + let meta: any = undefined; - 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)); + 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 }); } } - // 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` - ) - ); + 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", - Connection: "keep-alive", - }, - }); - } catch (err: any) { - console.error("[chat] Error:", err.message); - return NextResponse.json( - { error: "Failed to communicate with Tiger Bridge" }, - { status: 500 } - ); - } -} \ No newline at end of file + 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", + }, + }); +} diff --git a/dashboard/src/app/api/chat/route.ts.pre-ws b/dashboard/src/app/api/chat/route.ts.pre-ws new file mode 100644 index 0000000..b28c25c --- /dev/null +++ b/dashboard/src/app/api/chat/route.ts.pre-ws @@ -0,0 +1,137 @@ +/** + * API route: POST /api/chat + * Sends chat messages via Tiger Bridge -> OpenClaw CLI + */ + +import { NextRequest, NextResponse } from "next/server"; + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"; +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""; + +export const maxDuration = 120; + +export async function POST(request: NextRequest) { + const { message } = await request.json(); + + if (!message) { + return NextResponse.json({ error: "message is required" }, { status: 400 }); + } + + // End-to-end timing: measure the full /api/chat call so we can compare + // against the bridge's own timing (data.timing) to find overhead. + const t0 = Date.now(); + + try { + // Call the bridge + const response = await fetch(`${BRIDGE_URL}/tiger/chat`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${BRIDGE_TOKEN}`, + }, + body: JSON.stringify({ message }), + }); + + const tBridgeDone = Date.now(); + const data = await response.json(); + + if (data?.timing) { + console.log( + `[chat.timing] bridge: ${JSON.stringify(data.timing)} | dashboard: bridge_call=${tBridgeDone - t0}ms` + ); + } + + console.log("[chat] Bridge response:", JSON.stringify(data).substring(0, 500)); + + if (!response.ok) { + return NextResponse.json( + { error: data.error || "Chat failed" }, + { status: response.status } + ); + } + + // Extract the text response - OpenClaw returns in several possible formats + let text = ""; + + if (data.response?.result?.payloads?.[0]?.text) { + text = data.response.result.payloads[0].text; + } else if (data.response?.payloads?.[0]?.text) { + text = data.response.payloads[0].text; + } else if (data.response?.summary) { + text = data.response.summary; + } else if (data.response?.text) { + text = data.response.text; + } else if (data.text) { + text = data.text; + } else { + // Fallback: stringify the whole response for debugging + text = JSON.stringify(data); + } + + console.log("[chat] Extracted text:", text.substring(0, 200)); + + // Return as SSE with word-by-word streaming. + // + // WHY SIMULATE STREAMING? + // The bridge gives us the entire reply in one shot (LLM call completes + // before the process returns). That means without this code the whole + // answer pops in at once β€” feels sluggish even though the infra is fine. + // Splitting on whitespace and drip-feeding gives the UI a "typing" feel + // without changing the backend. Total time until done is identical. + // + // When true token-level streaming is wired in the bridge (Phase 3), we + // can swap this out for real chunks from openclaw's event stream. + const encoder = new TextEncoder(); + const words = text.split(/(\s+)/); // keep whitespace tokens β†’ smooth flow + // ~60 words-per-second cadence β‰ˆ 16ms per word. Tune to taste. + const WORD_DELAY_MS = 25; // 40 wps β€” smooth typing feel with frame headroom + + const stream = new ReadableStream({ + async start(controller) { + // Send status marker first so UI can show the thinking indicator. + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "status", content: "" })}\n\n` + ) + ); + + // Drip-feed word tokens. Each is a "chunk" that appends to the + // streaming message bubble on the client. + for (const word of words) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "chunk", content: word })}\n\n` + ) + ); + if (WORD_DELAY_MS > 0) { + await new Promise((resolve) => setTimeout(resolve, WORD_DELAY_MS)); + } + } + + // Final done event carries the full text as a safety fallback + // (see the Bug D fix in chat-interface.tsx). + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ type: "done", content: text })}\n\n` + ) + ); + + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } catch (err: any) { + console.error("[chat] Error:", err.message); + return NextResponse.json( + { error: "Failed to communicate with Tiger Bridge" }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/dashboard/src/app/api/chat/sessions/route.ts b/dashboard/src/app/api/chat/sessions/route.ts new file mode 100644 index 0000000..74d1187 --- /dev/null +++ b/dashboard/src/app/api/chat/sessions/route.ts @@ -0,0 +1,123 @@ +/** + * /api/chat/sessions β€” list, create, delete chat sessions. + * + * GET β†’ list webchat-eligible sessions (Main + any "agent:main:webchat-*") + * via gateway sessions.list. Returns simplified shape for the UI. + * POST β†’ mint a new session key. The session is auto-created in the + * gateway on first message (so we don't need to call anything + * here β€” just return the key for the UI to start using). + * DELETE ?key=agent:main:webchat-xyz β†’ remove from gateway + clear sqlite history. + * The default "agent:main:main" session can never be deleted. + */ + +import { NextRequest, NextResponse } from "next/server"; +import { callGateway, newSessionKey, DEFAULT_SESSION_KEY } from "@/lib/openclaw-ws"; + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"; +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""; + +/** Whitelist: only "agent:main:main" + "agent:main:webchat-*" sessions are dashboard-visible. */ +function isWebchatSession(key: string): boolean { + return key === DEFAULT_SESSION_KEY || key.startsWith("agent:main:webchat-"); +} + +/** Pretty label for the dropdown. */ +function deriveLabel(key: string, displayName?: string): string { + if (key === DEFAULT_SESSION_KEY) return "Main"; + if (displayName && displayName !== "undefined") return displayName; + // For "agent:main:webchat-abc12345" β†’ "Chat abc12345" + const m = key.match(/^agent:main:webchat-(.+)$/); + if (m) return `Chat ${m[1].slice(0, 8)}`; + return key; +} + +export async function GET() { + try { + // Ask gateway for ALL sessions, then filter to webchat-visible ones. + const r = await callGateway("sessions.list", {}); + if (!r.ok) { + return NextResponse.json( + { ok: false, error: "gateway sessions.list failed", details: r.error }, + { status: 502 } + ); + } + const all = (r.payload as any)?.sessions || []; + const webchat = all + .filter((s: any) => isWebchatSession(s.key)) + .map((s: any) => ({ + key: s.key, + label: deriveLabel(s.key, s.displayName), + updatedAt: s.updatedAt || null, + messageCount: s.messageCount || 0, + isDefault: s.key === DEFAULT_SESSION_KEY, + })) + // Default first, then most-recently-updated + .sort((a: any, b: any) => { + if (a.isDefault) return -1; + if (b.isDefault) return 1; + return (b.updatedAt || 0) - (a.updatedAt || 0); + }); + + // Always ensure "Main" is in the list, even if gateway hasn't seen it yet + if (!webchat.find((s: any) => s.key === DEFAULT_SESSION_KEY)) { + webchat.unshift({ key: DEFAULT_SESSION_KEY, label: "Main", updatedAt: null, messageCount: 0, isDefault: true }); + } + + return NextResponse.json({ ok: true, sessions: webchat }); + } catch (err: any) { + return NextResponse.json( + { ok: false, error: "sessions list failed", details: err.message }, + { status: 502 } + ); + } +} + +export async function POST() { + // Mint a new key. Actual gateway session is created lazily on first message. + const key = newSessionKey(); + return NextResponse.json({ + ok: true, + session: { key, label: deriveLabel(key), updatedAt: null, messageCount: 0, isDefault: false }, + }); +} + +export async function DELETE(request: NextRequest) { + const key = request.nextUrl.searchParams.get("key") || ""; + if (!key) { + return NextResponse.json({ ok: false, error: "key query param required" }, { status: 400 }); + } + if (key === DEFAULT_SESSION_KEY) { + return NextResponse.json({ ok: false, error: "the Main session cannot be deleted" }, { status: 400 }); + } + if (!isWebchatSession(key)) { + return NextResponse.json({ ok: false, error: "only webchat sessions can be deleted from here" }, { status: 400 }); + } + + // 1. Best-effort: ask gateway to delete its session record. + // If the session has never been used (no first message yet) the gateway + // won't know about it β€” that's fine, we still want to clean sqlite. + let gatewayResult: { ok: boolean; error?: any } = { ok: true }; + try { + gatewayResult = await callGateway("sessions.delete", { key }); + } catch (err: any) { + gatewayResult = { ok: false, error: err.message }; + } + + // 2. Clear our sqlite history for this session via the bridge. + let bridgeResult = { ok: true } as any; + try { + const r = await fetch( + `${BRIDGE_URL}/tiger/chat/history?sessionId=${encodeURIComponent(key)}`, + { method: "DELETE", headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` } } + ); + bridgeResult = await r.json(); + } catch (err: any) { + bridgeResult = { ok: false, error: err.message }; + } + + return NextResponse.json({ + ok: bridgeResult.ok, + gateway: gatewayResult, + bridge: bridgeResult, + }); +} diff --git a/dashboard/src/app/api/tiger/activity/route.ts b/dashboard/src/app/api/tiger/activity/route.ts new file mode 100644 index 0000000..2d9b010 --- /dev/null +++ b/dashboard/src/app/api/tiger/activity/route.ts @@ -0,0 +1,15 @@ +// GET /api/tiger/activity?limit=50 β€” proxy to bridge +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const limit = searchParams.get("limit") ?? "50"; + try { + const result = await bridgeGet("/tiger/agents/activity", { limit }); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/agents/[id]/file/route.ts b/dashboard/src/app/api/tiger/agents/[id]/file/route.ts new file mode 100644 index 0000000..4fd650e --- /dev/null +++ b/dashboard/src/app/api/tiger/agents/[id]/file/route.ts @@ -0,0 +1,37 @@ +// GET + PUT /api/tiger/agents/[id]/file?path=... +import { NextResponse } from "next/server"; +import { bridgeGet, bridgePut } from "@/lib/bridge"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = new URL(request.url); + const path = searchParams.get("path") ?? ""; + if (!path) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 }); + try { + const result = await bridgeGet(`/tiger/agents/${id}/file`, { path }); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} + +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = new URL(request.url); + const path = searchParams.get("path") ?? ""; + if (!path) return NextResponse.json({ ok: false, error: "Missing path" }, { status: 400 }); + try { + const body = await request.json(); + const result = await bridgePut(`/tiger/agents/${id}/file`, { path }, body as Record); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/agents/[id]/files/route.ts b/dashboard/src/app/api/tiger/agents/[id]/files/route.ts new file mode 100644 index 0000000..ae715e5 --- /dev/null +++ b/dashboard/src/app/api/tiger/agents/[id]/files/route.ts @@ -0,0 +1,21 @@ +// GET /api/tiger/agents/[id]/files?path=... β€” proxy to bridge +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params; + const { searchParams } = new URL(request.url); + const path = searchParams.get("path") ?? ""; + try { + const query: Record = {}; + 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 }); + } +} diff --git a/dashboard/src/app/api/tiger/agents/route.ts b/dashboard/src/app/api/tiger/agents/route.ts new file mode 100644 index 0000000..5d49bec --- /dev/null +++ b/dashboard/src/app/api/tiger/agents/route.ts @@ -0,0 +1,13 @@ +// GET /api/tiger/agents β€” proxy to bridge +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const result = await bridgeGet("/tiger/agents"); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/config/models/route.ts b/dashboard/src/app/api/tiger/config/models/route.ts new file mode 100644 index 0000000..5b5b8d0 --- /dev/null +++ b/dashboard/src/app/api/tiger/config/models/route.ts @@ -0,0 +1,13 @@ +// GET /api/tiger/config/models β€” proxy to bridge +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const result = await bridgeGet("/tiger/config/models"); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/favicon.ico b/dashboard/src/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/dashboard/src/app/favicon.ico and /dev/null differ diff --git a/dashboard/src/app/icon.svg b/dashboard/src/app/icon.svg new file mode 100644 index 0000000..2585677 --- /dev/null +++ b/dashboard/src/app/icon.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + T + + + + + + + + + \ No newline at end of file diff --git a/dashboard/src/app/layout.tsx b/dashboard/src/app/layout.tsx index 7e7dab2..61e1f05 100644 --- a/dashboard/src/app/layout.tsx +++ b/dashboard/src/app/layout.tsx @@ -22,6 +22,14 @@ 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", + }, }; export default function RootLayout({ @@ -49,7 +57,26 @@ export default function RootLayout({
-

Tiger Dashboard

+
+ {/* Tiger Command icon β€” same SVG as favicon */} + + Tiger Dashboard +
diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx index 98e3306..063710a 100644 --- a/dashboard/src/app/page.tsx +++ b/dashboard/src/app/page.tsx @@ -69,6 +69,14 @@ export default function DashboardPage() { refreshInterval: 5000, revalidateOnFocus: true, }) + // Agent activity β€” used for "Last Activity" row in health card + const { data: agentsData } = useSWR<{ ok: boolean; agents: { lastActivity: number }[] }>( + '/api/tiger/agents', fetcher, { refreshInterval: 30000 } + ) + const lastActivity = React.useMemo(() => { + const ts = agentsData?.agents?.map(a => a.lastActivity).filter(Boolean) ?? [] + return ts.length > 0 ? Math.max(...ts) : 0 + }, [agentsData]) const { request } = useBridgeRequest() const [restarting, setRestarting] = React.useState(false) const [restartSuccess, setRestartSuccess] = React.useState(false) @@ -100,7 +108,7 @@ export default function DashboardPage() {
Tiger Crashed - (exit code 255 β€” MiniMax API unreachable) + {`(exit code 255 β€” ${status?.agent?.currentModel ?? "API"} unreachable)`}
+ ) +} + +function SelectInput({ value, options, onChange }: { + value: string + options: { value: string; label: string }[] + onChange: (v: string) => void +}) { + return ( + + ) +} + +// ─── Model dropdown component ───────────────────────────────────────────────── + +function ModelSelect({ value, models, onChange }: { + value: string + models: ModelInfo[] + onChange: (v: string) => void +}) { + // Group by provider + const grouped = models.reduce>((acc, m) => { + if (!acc[m.provider]) acc[m.provider] = [] + acc[m.provider].push(m) + return acc + }, {}) + + const providerLabels: Record = { + minimax: "MiniMax", + "minimax-portal": "MiniMax Portal", + openrouter: "OpenRouter", + } + + const current = models.find(m => m.id === value) return ( -
-
-
-

- - Settings -

-

Tiger agent configuration. Changes are applied live.

-
-
- - -
-
+
+ - {error && ( -
{error}
- )} - - {loading ? ( -
- + {/* Model detail badge row */} + {current && ( +
+ + {current.id} + + {current.reasoning && ( + + reasoning + + )} + {current.contextWindow > 0 && ( + + {current.contextWindow >= 1000000 + ? `${(current.contextWindow / 1000000).toFixed(1)}M ctx` + : `${Math.round(current.contextWindow / 1000)}K ctx`} + + )} + {current.cost && ( + + ${current.cost.input}/M in Β· ${current.cost.output}/M out + + )}
- ) : ( - sections.map(section => ( - - - {section.label} - {section.description} - - - {section.fields.map(field => ( -
- - {field.type === "boolean" ? ( - - ) : field.type === "password" ? ( -
- - handleFieldChange(section.key, field.path, e.target.value) - } - className="flex-1 font-mono text-sm" - /> - -
- ) : ( - - handleFieldChange( - section.key, - field.path, - field.type === "number" ? e.target.value : e.target.value - ) - } - className="flex-1 font-mono text-sm" - /> - )} -
- ))} -
-
- )) )}
) } -function getNestedValue(obj: Record, 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)[key] +// ─── Section card wrapper ───────────────────────────────────────────────────── + +function SectionCard({ icon: Icon, title, description, children, dirty }: { + icon: React.ElementType + title: string + description: string + children: React.ReactNode + dirty?: boolean +}) { + return ( + + + + + {title} + {dirty && ( + unsaved changes + )} + + {description} + + {children} + + ) +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function SettingsPage() { + // Raw config from bridge + const { data: configData, mutate: mutateConfig, isLoading: configLoading } = + useSWR<{ ok: boolean; config: OpenClawConfig }>("/api/tiger/config", fetcher) + + // Available models + const { data: modelsData, isLoading: modelsLoading } = + useSWR<{ ok: boolean; models: ModelInfo[] }>("/api/tiger/config/models", fetcher) + + const remoteConfig = configData?.config ?? {} + const models = modelsData?.models ?? [] + + // ── Local draft β€” tracks unsaved edits ───────────────────────────────────── + const [draft, setDraft] = React.useState({}) + const [initialized, setInitialized] = React.useState(false) + + React.useEffect(() => { + if (configData?.ok && !initialized) { + setDraft(JSON.parse(JSON.stringify(configData.config))) + setInitialized(true) + } + }, [configData, initialized]) + + const update = (path: string, value: any) => { + setDraft(prev => set(prev, path, value)) } - return current ?? null -} \ No newline at end of file + + const g = (path: string, fallback: any = "") => get(draft, path, fallback) + const r = (path: string, fallback: any = "") => get(remoteConfig, path, fallback) + + // Dirty check β€” compare draft to remote at path level + const isDirty = (path: string) => JSON.stringify(g(path)) !== JSON.stringify(r(path)) + const anyDirty = JSON.stringify(draft) !== JSON.stringify(remoteConfig) + + // ── Save ──────────────────────────────────────────────────────────────────── + const [saving, setSaving] = React.useState(false) + const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle") + const [saveError, setSaveError] = React.useState("") + + const handleSave = async () => { + setSaving(true) + setSaveState("idle") + try { + const res = await fetch("/api/tiger/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ patch: draft }), + }) + const data = await res.json() + if (!data.ok) throw new Error(data.error ?? "Save failed") + setSaveState("ok") + await mutateConfig() + setInitialized(false) // re-sync draft from fresh server data + setTimeout(() => setSaveState("idle"), 3000) + } catch (err: any) { + setSaveError(err.message) + setSaveState("err") + } finally { + setSaving(false) + } + } + + const handleReset = () => { + setDraft(JSON.parse(JSON.stringify(remoteConfig))) + setSaveState("idle") + } + + const loading = configLoading || modelsLoading || !initialized + + // ── Render ────────────────────────────────────────────────────────────────── + return ( +
+ + {/* Header */} +
+
+

+ Settings +

+

+ Live Tiger configuration β€” writes directly to openclaw.json inside the container. +

+
+
+ + +
+
+ + {/* Save error */} + {saveState === "err" && ( +
+ + {saveError} +
+ )} + + {loading ? ( +
+ +
+ ) : ( +
+ + {/* ── 1. Model ───────────────────────────────────────────────────── */} + + + update("agents.defaults.model.primary", v)} + /> + + + + 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" + /> + + + + update("agents.defaults.compaction.mode", v)} + /> + + + + {/* ── 2. Session ─────────────────────────────────────────────────── */} + + + update("session.dmScope", v)} + /> + + + + {/* ── 3. Telegram ────────────────────────────────────────────────── */} + + + update("channels.telegram.enabled", v)} + /> + + + + update("channels.telegram.streaming", v)} + /> + + + + {/* ── 4. Commands ────────────────────────────────────────────────── */} + + + update("commands.native", v)} + /> + + + + update("commands.ownerDisplay", v)} + /> + + + + update("commands.restart", v)} + /> + + + +
+ )} +
+ ) +} diff --git a/dashboard/src/app/workspace/page.tsx b/dashboard/src/app/workspace/page.tsx index 1bc6260..e45005b 100644 --- a/dashboard/src/app/workspace/page.tsx +++ b/dashboard/src/app/workspace/page.tsx @@ -1,234 +1,223 @@ /** - * Workspace Page β€” File browser for Tiger agent's workspace + * workspace/page.tsx β€” Per-agent file browser with preview * - * Lists files from the Tiger Bridge's workspace API and provides - * a file viewer for reading file contents. + * Layout: + * Agent chip row (top) + * Tabs: [Files] [Activity] + * Files: split-pane β€” file tree (left) + file preview (right) + * Activity: recent cross-agent changes feed */ "use client" import * as React from "react" -import { Folder, FileText, ChevronRight, Home, ArrowLeft, Loader2 } from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { useBridgeRequest } from "@/hooks/use-bridge" -import { cn } from "@/lib/utils" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ScrollArea } from "@/components/ui/scroll-area" +import { AgentChipRow, AgentInfo } from "@/components/workspace/agent-chip-row" +import { FileTree, FileItem } from "@/components/workspace/file-tree" +import { FilePreview } from "@/components/workspace/file-preview" +import { ActivityFeed, ActivityEvent } from "@/components/workspace/activity-feed" -interface WorkspaceFile { - name: string - type: "file" | "directory" - size?: number - modified?: string -} +// ─── Types ──────────────────────────────────────────────────────────────────── interface FileContent { ok: boolean path: string content: string + encoding: "utf8" | "base64" size: number + mime: string } -function formatSize(bytes?: number): string { - if (!bytes) return "β€”" - if (bytes < 1024) return `${bytes}B` - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` - return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +// ─── Helpers ───────────────────────────────────────────────────────────────── + +async function apiFetch(url: string): Promise { + const res = await fetch(url, { cache: "no-store" }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() as Promise } -function getFileIcon(filename: string) { - const ext = filename.split(".").pop()?.toLowerCase() - const codeExts = ["ts", "tsx", "js", "jsx", "py", "sh", "json", "md", "yaml", "yml", "toml"] - if (ext && codeExts.includes(ext)) return "code" - if (ext === "md") return "markdown" - return "text" -} +// ─── Page ───────────────────────────────────────────────────────────────────── export default function WorkspacePage() { - const { request, loading } = useBridgeRequest() + // Agent list + const [agents, setAgents] = React.useState([]) + const [agentsLoading, setAgentsLoading] = React.useState(true) + + // Active agent selection (null = "All" β†’ show Tiger/main) + const [activeAgentId, setActiveAgentId] = React.useState(null) + + // File tree state + const [treeItems, setTreeItems] = React.useState([]) const [currentPath, setCurrentPath] = React.useState("") - const [files, setFiles] = React.useState([]) + const [treeLoading, setTreeLoading] = React.useState(false) + + // File preview state const [selectedFile, setSelectedFile] = React.useState(null) const [fileContent, setFileContent] = React.useState(null) - const [loadingFiles, setLoadingFiles] = React.useState(false) - const [loadingContent, setLoadingContent] = React.useState(false) - const [error, setError] = React.useState(null) + const [previewLoading, setPreviewLoading] = React.useState(false) - // Load directory contents - const loadDirectory = React.useCallback(async (path: string) => { - setLoadingFiles(true) - setError(null) - try { - const url = path ? `/api/tiger/workspace?path=${encodeURIComponent(path)}` : "/api/tiger/workspace" - const data = await request(url) as { ok: boolean; files?: WorkspaceFile[] } - if (data.ok && data.files) { - setFiles(data.files) - setCurrentPath(path) - } else { - setError("Failed to load directory") - } - } catch (e: unknown) { - setError("Failed to load workspace") - } finally { - setLoadingFiles(false) - } - }, [request]) + // Activity feed + const [activityEvents, setActivityEvents] = React.useState([]) + const [activityLoading, setActivityLoading] = React.useState(false) - // Load file content - const loadFile = React.useCallback(async (filename: string) => { - setLoadingContent(true) - setSelectedFile(filename) - try { - // Need full path including current directory - const fullPath = currentPath ? `${currentPath}/${filename}` : filename - const url = `/api/tiger/workspace?path=${encodeURIComponent(fullPath)}&read=true` - const data = await request(url) as FileContent - setFileContent(data) - } catch (e: unknown) { - setFileContent({ ok: false, path: filename, content: "Failed to load file", size: 0 }) - } finally { - setLoadingContent(false) - } - }, [request, currentPath]) - - // Initial load + // ── Load agents on mount ────────────────────────────────────────────────── React.useEffect(() => { - loadDirectory("") - }, [loadDirectory]) + setAgentsLoading(true) + apiFetch<{ ok: boolean; agents: AgentInfo[] }>("/api/tiger/agents") + .then((data) => { if (data.ok) setAgents(data.agents) }) + .catch(console.error) + .finally(() => setAgentsLoading(false)) + }, []) - const navigateTo = (path: string) => { + // Derived: the "effective" agent to browse + // "All" defaults to showing the orchestrator (main/Tiger) + const effectiveAgentId = activeAgentId ?? "main" + + // ── Load file tree when agent or path changes ───────────────────────────── + const loadTree = React.useCallback((agentId: string, path: string) => { + setTreeLoading(true) + setSelectedFile(null) + setFileContent(null) + const url = path + ? `/api/tiger/agents/${agentId}/files?path=${encodeURIComponent(path)}` + : `/api/tiger/agents/${agentId}/files` + apiFetch<{ ok: boolean; items: FileItem[] }>(url) + .then((data) => { if (data.ok) setTreeItems(data.items) }) + .catch(console.error) + .finally(() => setTreeLoading(false)) + }, []) + + React.useEffect(() => { + loadTree(effectiveAgentId, currentPath) + }, [effectiveAgentId, currentPath, loadTree]) + + // Reset path when agent changes + const handleAgentChange = (id: string | null) => { + setActiveAgentId(id) + setCurrentPath("") setSelectedFile(null) setFileContent(null) - loadDirectory(path) } - // Build breadcrumb path - const breadcrumbs = currentPath ? currentPath.split("/").filter(Boolean) : [] + // ── Navigate into a directory ───────────────────────────────────────────── + const handleNavigate = (path: string) => { + setCurrentPath(path) + } + + // ── Load file content on selection ─────────────────────────────────────── + const handleSelectFile = React.useCallback((filePath: string) => { + setSelectedFile(filePath) + setPreviewLoading(true) + const url = `/api/tiger/agents/${effectiveAgentId}/file?path=${encodeURIComponent(filePath)}` + apiFetch(url) + .then((data) => setFileContent(data)) + .catch(console.error) + .finally(() => setPreviewLoading(false)) + }, [effectiveAgentId]) + + // ── Load activity feed ──────────────────────────────────────────────────── + const loadActivity = React.useCallback(() => { + setActivityLoading(true) + apiFetch<{ ok: boolean; events: ActivityEvent[] }>("/api/tiger/activity?limit=50") + .then((data) => { if (data.ok) setActivityEvents(data.events) }) + .catch(console.error) + .finally(() => setActivityLoading(false)) + }, []) + + // Recently active agent ids (activity within last hour) for badge highlighting + const recentIds = React.useMemo(() => { + const cutoff = Date.now() - 60 * 60 * 1000 + return new Set(activityEvents.filter((e) => e.ts > cutoff).map((e) => e.agentId)) + }, [activityEvents]) + + // ─── Render ──────────────────────────────────────────────────────────────── return ( -
- {/* Header */} -
-
-

Workspace

-

- Browse files in Tiger's workspace -

-
- +
+ {/* Page title */} +
+

Workspace

+

Browse agent files and recent activity

- {/* Error */} - {error && ( -
{error}
- )} + {/* Agent chip row */} + -
- {/* File List */} - - - Files - {/* Breadcrumb */} - {breadcrumbs.length > 0 && ( -
- - {breadcrumbs.map((crumb, i) => ( - - - - - ))} -
- )} -
- - {loadingFiles ? ( -
- -
- ) : files.length === 0 ? ( -
No files
- ) : ( -
- {/* Parent directory */} - {currentPath && ( - - )} - {files.map((file) => ( - - ))} -
- )} -
-
+ {/* Tabs: Files | Activity */} + { if (v === "activity") loadActivity() }} + > + + Files + Activity + - {/* File Viewer */} - - - - {selectedFile || "Select a file to view"} - - {fileContent && ( -
- {formatSize(fileContent.size)} + {/* ── Files tab ─────────────────────────────────────────────────── */} + +
+ {/* File tree panel */} +
+
+ + {agents.find((a) => a.id === effectiveAgentId)?.emoji}{" "} + {agents.find((a) => a.id === effectiveAgentId)?.name ?? "Tiger"} +
- )} - - - {loadingContent ? ( -
- + + + +
+ + {/* Preview panel */} +
+ { + // Update cached content so the view reflects the save immediately + setFileContent((prev) => prev ? { ...prev, content: newContent } : prev) + }} + /> +
+
+ + + {/* ── Activity tab ──────────────────────────────────────────────── */} + +
+
+ + Recent changes β€” all agents + +
+ +
+
- ) : !selectedFile ? ( -
- Click a file to view its contents -
- ) : fileContent ? ( -
-                {fileContent.content}
-              
- ) : null} - - -
+ +
+
+
) -} \ No newline at end of file +} diff --git a/dashboard/src/components/chat-interface.tsx b/dashboard/src/components/chat-interface.tsx index f39e79b..7991fb2 100644 --- a/dashboard/src/components/chat-interface.tsx +++ b/dashboard/src/components/chat-interface.tsx @@ -1,7 +1,10 @@ "use client" import * as React from "react" -import { Send, Square, Bot, User, AlertCircle, Loader2, Eraser } from "lucide-react" +import { + Send, Square, Bot, User, AlertCircle, Loader2, Eraser, + Plus, ChevronDown, Trash2, +} from "lucide-react" import ReactMarkdown from "react-markdown" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" @@ -12,13 +15,16 @@ import { useChatContext } from "@/contexts/chat-context" export function ChatInterface({ className, ...props }: React.ComponentProps) { const [input, setInput] = React.useState("") - // Persistent chat state β€” survives navigation between routes. - // See contexts/chat-context.tsx for the rationale. - const { messages, setMessages, clearChat } = useChatContext() + const { + messages, setMessages, clearChat, + currentSessionKey, sessions, selectSession, newSession, deleteSession, refreshSessions, + } = useChatContext() const [sending, setSending] = React.useState(false) + const [dropdownOpen, setDropdownOpen] = React.useState(false) const scrollRef = React.useRef(null) const abortRef = React.useRef(null) const streamingRef = React.useRef("") + const dropdownRef = React.useRef(null) React.useEffect(() => { if (scrollRef.current) { @@ -26,6 +32,21 @@ export function ChatInterface({ className, ...props }: React.ComponentProps { + 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 @@ -35,7 +56,6 @@ export function ChatInterface({ className, ...props }: React.ComponentProps [...prev, { id: `user-${Date.now()}`, role: "user", @@ -50,7 +70,7 @@ export function ChatInterface({ className, ...props }: React.ComponentProps { if (prev.some(m => m.streaming)) return prev return [...prev, { @@ -123,20 +129,7 @@ export function ChatInterface({ className, ...props }: React.ComponentProps { - const filtered = prev.filter(m => !m.streaming) - return [...filtered, { - id: `agent-${Date.now()}`, - role: "agent", - content: data.content || "", - timestamp: Date.now(), - }] - }) } else if (data.type === "done") { - // Fall back to data.content if the chunk event somehow didn't - // land β€” Bug D. This is a belt-and-suspenders safety. const finalContent = streamingRef.current || data.content || "" setMessages(prev => { const filtered = prev.filter(m => !m.streaming) @@ -150,6 +143,8 @@ export function ChatInterface({ className, ...props }: React.ComponentProps [...prev.filter(m => !m.streaming), { id: `err-${Date.now()}`, @@ -172,7 +167,6 @@ export function ChatInterface({ className, ...props }: React.ComponentProps -
- - - Chat with Tiger +
+ + + Chat with Tiger - +
+ {/* Sessions dropdown β€” flavor C: button + dropdown */} +
+ + {dropdownOpen && ( +
+
+ {sessions.length === 0 && ( +
No sessions yet
+ )} + {sessions.map(s => ( +
{ selectSession(s.key); setDropdownOpen(false) }} + > + {s.label} + {!s.isDefault && ( + + )} +
+ ))} +
+
+ )} +
+ + +
+
@@ -239,9 +293,6 @@ export function ChatInterface({ className, ...props }: React.ComponentProps {message.role === "system" && } {message.role === "agent" ? ( - // While streaming: render raw text (cheap, one DOM node update per token). - // After streaming completes: render full ReactMarkdown (expensive but - // only happens once). This is what makes the typing feel actually show up. message.streaming ? (
{message.content}
) : ( diff --git a/dashboard/src/components/chat-interface.tsx.pre-ws b/dashboard/src/components/chat-interface.tsx.pre-ws new file mode 100644 index 0000000..f39e79b --- /dev/null +++ b/dashboard/src/components/chat-interface.tsx.pre-ws @@ -0,0 +1,297 @@ +"use client" + +import * as React from "react" +import { Send, Square, Bot, User, AlertCircle, Loader2, Eraser } from "lucide-react" +import ReactMarkdown from "react-markdown" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { ScrollArea } from "@/components/ui/scroll-area" +import { useChatContext } from "@/contexts/chat-context" + +export function ChatInterface({ className, ...props }: React.ComponentProps) { + const [input, setInput] = React.useState("") + // Persistent chat state β€” survives navigation between routes. + // See contexts/chat-context.tsx for the rationale. + const { messages, setMessages, clearChat } = useChatContext() + const [sending, setSending] = React.useState(false) + const scrollRef = React.useRef(null) + const abortRef = React.useRef(null) + const streamingRef = React.useRef("") + + React.useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [messages]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!input.trim() || sending) return + + const text = input.trim() + setInput("") + setSending(true) + streamingRef.current = "" + + // Add user message + setMessages(prev => [...prev, { + id: `user-${Date.now()}`, + role: "user", + content: text, + timestamp: Date.now(), + }]) + + try { + const controller = new AbortController() + abortRef.current = controller + + const res = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: text }), + signal: controller.signal, + }) + + if (!res.ok || !res.body) throw new Error("Failed to connect") + + const reader = res.body.getReader() + const decoder = new TextDecoder() + // Buffer across reads β€” a single SSE event ("data: ...\n\n") may be + // split across TCP chunks. Accumulate, then split on the SSE delimiter. + let buffer = "" + + const streamId = `streaming-${Date.now()}` + + while (true) { + const { done: readerDone, value } = await reader.read() + if (readerDone) break + + // {stream: true} preserves decoder state for multi-byte UTF-8 chars + // (e.g. emoji) that happen to land across chunk boundaries. + buffer += decoder.decode(value, { stream: true }) + + // SSE events end with a blank line (\n\n). Anything after the last + // \n\n is a partial event β€” keep it in `buffer` for the next read. + const events = buffer.split("\n\n") + buffer = events.pop() || "" + + for (const eventBlock of events) { + const dataLine = eventBlock.split("\n").find(l => l.startsWith("data: ")) + if (!dataLine) continue + + let data: { type: string; content?: string } + try { + data = JSON.parse(dataLine.slice(6)) + } catch (err) { + // Don't swallow silently β€” log so real parse bugs are visible. + console.warn("[chat] SSE parse error:", err, "line:", dataLine) + continue + } + + console.log("[chat] event:", data.type, "content:", data.content?.substring(0, 50)) + + if (data.type === "status") { + // Transient 'Tiger is thinking...' indicator. Do NOT append to + // the message content β€” that was Bug A. Just ensure a streaming + // placeholder exists so the UI shows activity. + setMessages(prev => { + if (prev.some(m => m.streaming)) return prev + return [...prev, { + id: streamId, + role: "agent", + content: "", + streaming: true, + timestamp: Date.now(), + }] + }) + } else if (data.type === "chunk") { + streamingRef.current += data.content || "" + setMessages(prev => { + const existing = prev.find(m => m.streaming) + if (existing) { + return prev.map(m => + m.streaming ? { ...m, content: streamingRef.current } : m + ) + } + return [...prev, { + id: streamId, + role: "agent", + content: streamingRef.current, + streaming: true, + timestamp: Date.now(), + }] + }) + } else if (data.type === "message") { + // Non-streaming full message + setMessages(prev => { + const filtered = prev.filter(m => !m.streaming) + return [...filtered, { + id: `agent-${Date.now()}`, + role: "agent", + content: data.content || "", + timestamp: Date.now(), + }] + }) + } else if (data.type === "done") { + // Fall back to data.content if the chunk event somehow didn't + // land β€” Bug D. This is a belt-and-suspenders safety. + const finalContent = streamingRef.current || data.content || "" + setMessages(prev => { + const filtered = prev.filter(m => !m.streaming) + if (!finalContent) return filtered + return [...filtered, { + id: `agent-${Date.now()}`, + role: "agent", + content: finalContent, + timestamp: Date.now(), + }] + }) + streamingRef.current = "" + setSending(false) + } else if (data.type === "error") { + setMessages(prev => [...prev.filter(m => !m.streaming), { + id: `err-${Date.now()}`, + role: "system", + content: data.content || "Something went wrong", + timestamp: Date.now(), + }]) + setSending(false) + } + } + } + } catch (err: any) { + if (err.name !== "AbortError") { + setMessages(prev => [...prev.filter(m => !m.streaming), { + id: `err-${Date.now()}`, + role: "system", + content: "Failed to send message. Is Tiger running?", + timestamp: Date.now(), + }]) + } + setSending(false) + } + + abortRef.current = null + } + + const handleAbort = () => { + abortRef.current?.abort() + setSending(false) + streamingRef.current = "" + setMessages(prev => prev.filter(m => !m.streaming)) + } + + return ( + + +
+ + + Chat with Tiger + + +
+
+ + +
+ {messages.map((message) => ( +
+ {message.role !== "system" && ( +
+ {message.role === "user" ? ( + + ) : ( + + )} +
+ )} +
+ {message.role === "system" && } + {message.role === "agent" ? ( + // While streaming: render raw text (cheap, one DOM node update per token). + // After streaming completes: render full ReactMarkdown (expensive but + // only happens once). This is what makes the typing feel actually show up. + message.streaming ? ( +
{message.content}
+ ) : ( +
+ {message.content} +
+ ) + ) : ( + message.content + )} + {message.streaming && ( + + )} +
+
+ ))} + {sending && !messages.some(m => m.streaming) && ( +
+
+ +
+
+ +
+
+ )} +
+
+
+ +
+ setInput(e.target.value)} + disabled={sending} + /> + {sending ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/dashboard/src/components/ui/tabs.tsx b/dashboard/src/components/ui/tabs.tsx new file mode 100644 index 0000000..b463afd --- /dev/null +++ b/dashboard/src/components/ui/tabs.tsx @@ -0,0 +1,91 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Tabs as TabsPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Tabs({ + className, + orientation = "horizontal", + ...props +}: React.ComponentProps) { + return ( + + ) +} + +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 & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/dashboard/src/components/workspace/activity-feed.tsx b/dashboard/src/components/workspace/activity-feed.tsx new file mode 100644 index 0000000..4e7a03c --- /dev/null +++ b/dashboard/src/components/workspace/activity-feed.tsx @@ -0,0 +1,79 @@ +"use client" +/** + * activity-feed.tsx β€” chronological list of recent file modifications + * across all agents. + */ + +import * as React from "react" +import { Clock, Loader2 } from "lucide-react" +import { cn } from "@/lib/utils" + +export interface ActivityEvent { + agentId: string + agentName: string + agentEmoji: string + path: string + action: string + ts: number +} + +interface Props { + events: ActivityEvent[] + loading?: boolean +} + +function relativeTime(ts: number): string { + const diff = Date.now() - ts + const s = Math.floor(diff / 1000) + if (s < 60) return `${s}s ago` + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + return `${Math.floor(h / 24)}d ago` +} + +export function ActivityFeed({ events, loading }: Props) { + if (loading) { + return ( +
+ +
+ ) + } + + if (events.length === 0) { + return ( +
+ + No recent activity +
+ ) + } + + return ( +
+ {events.map((ev, i) => ( +
+ {/* Agent avatar */} + {ev.agentEmoji} + +
+
+ {ev.agentName} + {ev.action} +
+

{ev.path}

+
+ + + {relativeTime(ev.ts)} + +
+ ))} +
+ ) +} diff --git a/dashboard/src/components/workspace/agent-chip-row.tsx b/dashboard/src/components/workspace/agent-chip-row.tsx new file mode 100644 index 0000000..16b1a82 --- /dev/null +++ b/dashboard/src/components/workspace/agent-chip-row.tsx @@ -0,0 +1,75 @@ +"use client" +/** + * agent-chip-row.tsx β€” horizontal chip row for selecting agents + * Scrolls horizontally on mobile. Badge shows fileCount. + */ + +import * as React from "react" +import { cn } from "@/lib/utils" + +export interface AgentInfo { + id: string + name: string + emoji: string + role: string + fileCount: number + lastActivity: number +} + +interface Props { + agents: AgentInfo[] + activeId: string | null // null = "All" + onChange: (id: string | null) => void + recentIds?: Set // agent ids that had recent activity (for badge highlight) +} + +export function AgentChipRow({ agents, activeId, onChange, recentIds }: Props) { + return ( +
+ {/* All chip */} + + + {agents.map((agent) => { + const isActive = activeId === agent.id + const hasRecent = recentIds?.has(agent.id) + return ( + + ) + })} +
+ ) +} diff --git a/dashboard/src/components/workspace/file-preview.tsx b/dashboard/src/components/workspace/file-preview.tsx new file mode 100644 index 0000000..90293de --- /dev/null +++ b/dashboard/src/components/workspace/file-preview.tsx @@ -0,0 +1,376 @@ +"use client" +/** + * file-preview.tsx β€” Multi-mode file viewer/editor + * + * Mode is derived from file extension + mime type: + * + * EDIT_SAVE .md .txt β†’ textarea editor, Save/Cancel in toolbar + * HTML_RENDER .html .htm β†’ sandboxed