From 8fe6694a210f6ae539f7a266cdfb303604731fe4 Mon Sep 17 00:00:00 2001 From: Manohar Gupta Date: Sat, 18 Apr 2026 19:10:47 +0000 Subject: [PATCH 01/20] fix(infra): rebrand, workspace path, model config, chat SSE streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rebrand Tarzan → Tiger in layout metadata and header - Bridge: point WORKSPACE_SYMLINK at the docker volume path post-migration (/var/lib/docker/volumes/tiger_tiger-workspace/_data) — the old /root/tiger-workspace symlink was orphaned after the April standalone migration, causing workspace endpoint to return empty. - Bridge: read agents.defaults.model.primary for model info + expose all configured availableModels so the dashboard card can show them. - Dashboard: page.tsx renders currentModel/fallbackModels/availableModels. - Chat streaming fix (client side): * proper SSE buffering across TCP chunks (split on \n\n, keep tail in buffer, {stream: true} decoder for multi-byte UTF-8) * separate status vs chunk handlers — status no longer pollutes content * fall back to data.content in done event if streamingRef is empty * visible parse errors instead of silent catch * plain-text rendering while streaming, ReactMarkdown only after done — avoids per-token markdown reparse which was killing the typing feel Root causes: 1. tiger-bridge crash-looped for 36h on EADDRINUSE because a manual nohup restart squatted on port 3456; systemd's tsx version couldn't bind. Killing the squatter restored the expected tsx-src workflow. 2. ChunkLoadError on /chat: npm run build ran under a live next start prod server, creating an in-memory manifest vs on-disk build split. Fixed by disciplined build-then-restart. 3. Dashboard chat silently dropped responses: SSE 'status' event text was being concatenated into the agent message content. --- .gitignore | 17 + bridge/src/index.ts | 7 +- bridge/src/tiger.ts | 68 ++-- dashboard/src/app/layout.tsx | 13 +- dashboard/src/app/page.tsx | 15 + dashboard/src/components/chat-interface.tsx | 364 ++++++++++---------- 6 files changed, 271 insertions(+), 213 deletions(-) diff --git a/.gitignore b/.gitignore index 7e94420..8fe116c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,20 @@ config/*.json !config/*.example.json config/mcporter.json config/cron.json + +# ─── Added by housecleaning Apr 2026 ─── +# Claude Code session worktrees (local workspace artifacts) +.claude/ +# Runtime SQLite databases — schema is in db.ts, not data/ +data/ +*.db +*.db-shm +*.db-wal +# Backup files from patching sessions +*.bak +*.bak.* +# Compiled bridge (regenerable from src/) +bridge/dist/ +# macOS artifacts that can slip in via Mutagen +.DS_Store +._* diff --git a/bridge/src/index.ts b/bridge/src/index.ts index 17e98cb..9ba77c2 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -42,7 +42,7 @@ import "./db.js"; // ─── Configuration ───────────────────────────────────────────────────────── const PORT = parseInt(process.env.TIGER_BRIDGE_PORT || "3456", 10); -const HOST = process.env.TIGER_BRIDGE_HOST || "127.0.0.1"; // Only localhost — Caddy handles HTTPS +const HOST = process.env.TIGER_BRIDGE_HOST || "0.0.0.0"; // Bind to all interfaces for Docker access const app = express(); @@ -85,6 +85,11 @@ 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/chat", (await import("./routes/chat.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 ───────────────────────────────────────────────────────── diff --git a/bridge/src/tiger.ts b/bridge/src/tiger.ts index 8ad4d36..e0d575d 100644 --- a/bridge/src/tiger.ts +++ b/bridge/src/tiger.ts @@ -1,10 +1,8 @@ /** - * tiger.ts — Core executor for Tiger agent inside Docker→k3s→sandbox - * - * The key insight: Tiger lives 3 layers deep. Every command must traverse: - * Host → Docker (openshell-cluster-nemoclaw) → k3s (kubectl exec) → sandbox pod (tiger) - * - * This module wraps that complexity into clean async functions. + * tiger.ts — Core executor for Tiger agent inside Docker container + * + * Tiger runs directly in tiger-openclaw container (no more k3s layers). + * Commands are executed via docker exec inside the container. */ import { exec, execFile, spawn } from "child_process"; @@ -15,30 +13,28 @@ import { createHash } from "crypto"; const execAsync = promisify(exec); // ─── Configuration ─────────────────────────────────────────────── -// These match your known paths from the Tiger setup -const DOCKER_CONTAINER = "openshell-cluster-nemoclaw"; +// Tiger runs directly in the tiger-openclaw container +const DOCKER_CONTAINER = "tiger-openclaw"; const K8S_NAMESPACE = "openshell"; const POD_NAME = "tiger"; -const OPENCLAW_CONFIG_HOST = "/root/.nemoclaw/openclaw.json"; +const OPENCLAW_CONFIG_HOST = "/root/.openclaw/openclaw.json"; const CONFIG_HASH_PATH_SANDBOX = "/sandbox/.openclaw/.config-hash"; -const WORKSPACE_SYMLINK = "/root/tiger-workspace"; +const WORKSPACE_SYMLINK = "/var/lib/docker/volumes/tiger_tiger-workspace/_data"; const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh"; // Timeout for commands (30s default, some ops need longer) const DEFAULT_TIMEOUT = 30_000; /** - * Execute a command inside the Tiger sandbox pod. - * This is the fundamental operation — everything else builds on it. - * - * The full command chain: - * docker exec kubectl exec -n -- + * Execute a command inside the Tiger container. + * Commands run directly via docker exec (no kubectl needed). */ export async function execInSandbox( command: string, timeoutMs = DEFAULT_TIMEOUT ): Promise<{ stdout: string; stderr: string; exitCode: number }> { - const fullCmd = `docker exec ${DOCKER_CONTAINER} kubectl exec -n ${K8S_NAMESPACE} ${POD_NAME} -- sh -c ${JSON.stringify(command)}`; + // Run command directly inside tiger-openclaw container + const fullCmd = `docker exec ${DOCKER_CONTAINER} sh -c ${JSON.stringify(command)}`; try { const { stdout, stderr } = await execAsync(fullCmd, { @@ -97,10 +93,10 @@ export async function getTigerStatus() { execInSandbox("cat /proc/meminfo | head -5 && echo '---' && uptime"), // 4. Last heartbeat content - execInSandbox("cat /sandbox/.openclaw-data/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"), + execInSandbox("cat /home/node/.openclaw/workspace/HEARTBEAT.md 2>/dev/null || echo 'NO_HEARTBEAT'"), // 5. Agent identity from SOUL.md - execInSandbox("head -20 /sandbox/.openclaw-data/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"), + execInSandbox("head -20 /home/node/.openclaw/workspace/SOUL.md 2>/dev/null || echo 'NO_SOUL'"), ]); // Parse container state @@ -133,15 +129,38 @@ export async function getTigerStatus() { } } - // Read host config for model info + // Read host config for model info. + // OpenClaw stores the default agent model at agents.defaults.model.primary + // and a list of fallbacks at agents.defaults.model.fallbacks. Some runtime + // paths (e.g. channels.telegram) or session overrides may pick a different + // model at request time — we also capture the provider list so the UI can + // show what's actually available. let currentModel = "unknown"; let fallbackModels: string[] = []; + let availableModels: string[] = []; try { const configRaw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8"); const config = JSON.parse(configRaw); - // Navigate the OpenClaw config structure for model info - currentModel = config?.model?.primary || config?.model || "unknown"; - fallbackModels = config?.model?.fallbacks || []; + + 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}`); + } + } + } } catch { /* config not readable */ } return { @@ -163,6 +182,7 @@ export async function getTigerStatus() { agent: { currentModel, fallbackModels, + availableModels, heartbeat: heartbeat.status === "fulfilled" ? heartbeat.value.stdout : null, soul: soulMd.status === "fulfilled" ? soulMd.value.stdout : null, }, @@ -171,7 +191,7 @@ export async function getTigerStatus() { /** * Read the OpenClaw config from the host. - * Config lives at /root/.nemoclaw/openclaw.json on the host, + * Config lives at /root/.openclaw/openclaw.json on the host, * gets mounted into the sandbox at /sandbox/.openclaw/openclaw.json */ export async function getConfig(): Promise> { @@ -194,7 +214,7 @@ export async function updateConfig(patch: Record): Promise { // 3. Backup current config before writing const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} /root/.nemoclaw/backups/openclaw-${timestamp}.json`); + await execOnHost(`cp ${OPENCLAW_CONFIG_HOST} /root/.openclaw/backups/openclaw-${timestamp}.json`); // 4. Write updated config await writeFile(OPENCLAW_CONFIG_HOST, configStr, "utf-8"); diff --git a/dashboard/src/app/layout.tsx b/dashboard/src/app/layout.tsx index 1979245..7e7dab2 100644 --- a/dashboard/src/app/layout.tsx +++ b/dashboard/src/app/layout.tsx @@ -7,6 +7,7 @@ import { ThemeProvider } from "@/components/theme-provider" import { ModeToggle } from "@/components/mode-toggle" import "./globals.css"; import { Agentation } from 'agentation'; +import { ChatProvider } from "@/contexts/chat-context"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -19,8 +20,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Command Center", - description: "Tarzan's Dashboard for Agent Management", + title: "Tiger Command Center", + description: "Tiger Agent Management Dashboard", }; export default function RootLayout({ @@ -40,14 +41,15 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - + +
-

Tarzan's Dashboard

+

Tiger Dashboard

@@ -57,7 +59,8 @@ export default function RootLayout({
-
+
+ diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx index d91b473..98e3306 100644 --- a/dashboard/src/app/page.tsx +++ b/dashboard/src/app/page.tsx @@ -43,6 +43,7 @@ interface TigerStatus { agent: { currentModel: string fallbackModels: string[] + availableModels?: string[] heartbeat: string | null soul: string | null } @@ -331,6 +332,20 @@ export default function DashboardPage() { )} + {/* Available Models (from providers config) */} + {status?.agent?.availableModels && status.agent.availableModels.length > 0 && ( +
+
Available Models
+
+ {status.agent.availableModels.map((model, i) => ( +
+ {model} +
+ ))} +
+
+ )} + {/* Heartbeat */} {status?.agent?.heartbeat && (
diff --git a/dashboard/src/components/chat-interface.tsx b/dashboard/src/components/chat-interface.tsx index 4db86ae..f39e79b 100644 --- a/dashboard/src/components/chat-interface.tsx +++ b/dashboard/src/components/chat-interface.tsx @@ -1,153 +1,25 @@ "use client" import * as React from "react" -import { Send, Square, Bot, User, AlertCircle, Loader2 } from "lucide-react" +import { Send, Square, Bot, User, AlertCircle, Loader2, Eraser } from "lucide-react" import ReactMarkdown from "react-markdown" - import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card" +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Input } from "@/components/ui/input" import { ScrollArea } from "@/components/ui/scroll-area" -import { useGatewayRequest, useGatewayEvents } from "@/hooks/use-gateway" - -type Message = { - id: string - role: "user" | "agent" | "system" - content: string - streaming?: boolean - timestamp: number -} - -function extractContent(content: unknown): string { - if (typeof content === "string") return content - if (Array.isArray(content)) { - return content - .map((block: unknown) => { - if (typeof block === "string") return block - if (block && typeof block === "object" && "text" in block) return String((block as { text: string }).text) - return "" - }) - .filter(Boolean) - .join("\n") - } - if (content && typeof content === "object") { - const obj = content as Record - if ("text" in obj) return String(obj.text) - if ("content" in obj) return extractContent(obj.content) - } - return "" -} +import { useChatContext } from "@/contexts/chat-context" export function ChatInterface({ className, ...props }: React.ComponentProps) { const [input, setInput] = React.useState("") - const [messages, setMessages] = 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 [agentTyping, setAgentTyping] = React.useState(false) const scrollRef = React.useRef(null) - const { request } = useGatewayRequest() - const streamingContentRef = React.useRef("") + const abortRef = React.useRef(null) + const streamingRef = 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 - - 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 @@ -161,9 +33,9 @@ export function ChatInterface({ className, ...props }: React.ComponentProps [...prev, { id: `user-${Date.now()}`, role: "user", @@ -172,44 +44,165 @@ export function ChatInterface({ className, ...props }: React.ComponentProps 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) - setMessages(prev => [...prev, { - id: `err-${Date.now()}`, - role: "system", - content: "Failed to send message. Is the gateway running?", - timestamp: Date.now(), - }]) } + + abortRef.current = null } - const handleAbort = async () => { - try { - await request("chat.abort", { sessionKey: "agent:main:main" }) - } catch { - // ignore - } + const handleAbort = () => { + abortRef.current?.abort() setSending(false) - setAgentTyping(false) + streamingRef.current = "" + setMessages(prev => prev.filter(m => !m.streaming)) } return ( - - - Chat - - +
+ + + Chat with Tiger + + +
@@ -235,22 +228,27 @@ export function ChatInterface({ className, ...props }: React.ComponentProps )} -
+
{message.role === "system" && } {message.role === "agent" ? ( -
- {message.content} -
+ // 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 )} @@ -260,7 +258,7 @@ export function ChatInterface({ className, ...props }: React.ComponentProps
))} - {agentTyping && !messages.some(m => m.streaming) && ( + {sending && !messages.some(m => m.streaming) && (
@@ -276,19 +274,19 @@ export function ChatInterface({ className, ...props }: React.ComponentProps
setInput(e.target.value)} - disabled={!connected || sending} + disabled={sending} /> {sending ? ( ) : ( - )} From 6621c6b28b1666147520098075928bd91a4ae611 Mon Sep 17 00:00:00 2001 From: Manohar Gupta Date: Sat, 18 Apr 2026 19:10:47 +0000 Subject: [PATCH 02/20] feat(chat): server-side persistence via SQLite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chat history now survives hard refresh, tab close, and multi-device use. Schema: chat_messages(id, session_id, role, content, meta, created_at) + index on (session_id, created_at DESC) Bridge endpoints: POST /tiger/chat — unchanged externally, now persists user + agent messages alongside the existing LLM dispatch GET /tiger/chat/history — ?sessionId=X&limit=200 → ordered messages DELETE /tiger/chat/history — ?sessionId=X → wipe history Dashboard: /api/chat/history — proxy route, bridge token stays server-side contexts/chat-context.tsx — ChatProvider hydrates messages from the history endpoint on mount; clearChat() now also hits DELETE /api/chat/history Design: single-session model for now (DEFAULT_SESSION_ID constant matches the openclaw agent --session-id used by the dispatch call). Multi-session support would require session UI + session-aware routing — deferred to a later feature sprint. Tradeoff noted: message data is duplicated between our SQLite and whatever state OpenClaw keeps internally. Chose duplication over coupling — if OpenClaw session semantics change, dashboard history remains intact. --- bridge/src/db.ts | 12 ++ bridge/src/routes/chat.ts | 166 ++++++++++++++++++++ dashboard/src/app/api/chat/history/route.ts | 50 ++++++ dashboard/src/app/api/chat/route.ts | 137 ++++++++++++++++ dashboard/src/contexts/chat-context.tsx | 108 +++++++++++++ 5 files changed, 473 insertions(+) create mode 100644 bridge/src/routes/chat.ts create mode 100644 dashboard/src/app/api/chat/history/route.ts create mode 100644 dashboard/src/app/api/chat/route.ts create mode 100644 dashboard/src/contexts/chat-context.tsx diff --git a/bridge/src/db.ts b/bridge/src/db.ts index 2457140..b87d54a 100644 --- a/bridge/src/db.ts +++ b/bridge/src/db.ts @@ -56,6 +56,18 @@ db.exec(` updated_at TEXT DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('user', 'agent', 'system')), + content TEXT NOT NULL, + -- 'meta' is optional JSON for things like model used, tokens, duration. + meta TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created + ON chat_messages (session_id, created_at DESC); + CREATE TABLE IF NOT EXISTS executions ( id TEXT PRIMARY KEY, task_id TEXT REFERENCES tasks(id) ON DELETE CASCADE, diff --git a/bridge/src/routes/chat.ts b/bridge/src/routes/chat.ts new file mode 100644 index 0000000..e81a7ef --- /dev/null +++ b/bridge/src/routes/chat.ts @@ -0,0 +1,166 @@ +/** + * 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) + const cmd = `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", + }); + } +}); + +export default router; \ No newline at end of file diff --git a/dashboard/src/app/api/chat/history/route.ts b/dashboard/src/app/api/chat/history/route.ts new file mode 100644 index 0000000..41914d0 --- /dev/null +++ b/dashboard/src/app/api/chat/history/route.ts @@ -0,0 +1,50 @@ +/** + * /api/chat/history — proxy for bridge's chat history. + * GET — list persisted messages for the default session + * DELETE — clear them + * + * Why a proxy and not a direct bridge call from the client? + * - Keeps the bridge auth token on the server side (never leaks to browser) + * - Matches the pattern used by /api/chat (POST) and /api/tiger/status + */ + +import { NextRequest, NextResponse } from "next/server"; + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456"; +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || ""; + +export async function GET(request: NextRequest) { + const sessionId = request.nextUrl.searchParams.get("sessionId") || ""; + const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : ""; + try { + const r = await fetch(`${BRIDGE_URL}/tiger/chat/history${qs}`, { + headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` }, + cache: "no-store", + }); + const data = await r.json(); + return NextResponse.json(data, { status: r.status }); + } catch (err: any) { + return NextResponse.json( + { ok: false, error: "Bridge unreachable", details: err.message }, + { status: 502 } + ); + } +} + +export async function DELETE(request: NextRequest) { + const sessionId = request.nextUrl.searchParams.get("sessionId") || ""; + const qs = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : ""; + try { + const r = await fetch(`${BRIDGE_URL}/tiger/chat/history${qs}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${BRIDGE_TOKEN}` }, + }); + const data = await r.json(); + return NextResponse.json(data, { status: r.status }); + } catch (err: any) { + return NextResponse.json( + { ok: false, error: "Bridge unreachable", details: err.message }, + { status: 502 } + ); + } +} diff --git a/dashboard/src/app/api/chat/route.ts b/dashboard/src/app/api/chat/route.ts new file mode 100644 index 0000000..b28c25c --- /dev/null +++ b/dashboard/src/app/api/chat/route.ts @@ -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/contexts/chat-context.tsx b/dashboard/src/contexts/chat-context.tsx new file mode 100644 index 0000000..608365d --- /dev/null +++ b/dashboard/src/contexts/chat-context.tsx @@ -0,0 +1,108 @@ +"use client" + +/** + * ChatContext — chat state that persists across route changes AND across + * hard refreshes (via the server-side /api/chat/history endpoint). + * + * TWO LAYERS OF PERSISTENCE: + * 1. React Context → survives client-side navigation between /chat, /workspace + * 2. Server-side history (SQLite in bridge) → survives refresh, tab close, + * device change. On mount we fetch history and hydrate the messages. + * + * FLOW: + * Mount → GET /api/chat/history → merge with default welcome message + * Send message → optimistic local update → /api/chat → server persists + * Clear → DELETE /api/chat/history → reset to just the welcome + */ +import * as React from "react" + +export type ChatMessage = { + id: string + role: "user" | "agent" | "system" + content: string + streaming?: boolean + timestamp: number +} + +const DEFAULT_WELCOME: ChatMessage = { + id: "welcome", + role: "agent", + content: "Hey! I am Tiger, your AI assistant. Send me a message to get started.", + timestamp: 0, // sentinel — always sorted to the top +} + +type ChatContextValue = { + messages: ChatMessage[] + setMessages: React.Dispatch> + clearChat: () => Promise + loading: boolean +} + +const ChatContext = React.createContext(null) + +export function ChatProvider({ children }: { children: React.ReactNode }) { + const [messages, setMessages] = React.useState([DEFAULT_WELCOME]) + const [loading, setLoading] = React.useState(true) + + // Hydrate from server on mount. This is what makes persistence actually + // work across hard refresh. + React.useEffect(() => { + let cancelled = false + async function load() { + try { + const r = await fetch("/api/chat/history", { cache: "no-store" }) + if (!r.ok) throw new Error(`history ${r.status}`) + const data = await r.json() + if (cancelled || !data?.ok || !Array.isArray(data.messages)) return + + // Combine welcome + history, no duplicates. Sort by timestamp so + // it renders in conversational order. + const hydrated: ChatMessage[] = [ + DEFAULT_WELCOME, + ...data.messages.map((m: any) => ({ + id: String(m.id), + role: m.role, + content: m.content, + timestamp: m.timestamp, + })), + ] + setMessages(hydrated) + } catch (err) { + console.warn("[chat] could not load history:", err) + } finally { + if (!cancelled) setLoading(false) + } + } + load() + return () => { + cancelled = true + } + }, []) + + const clearChat = React.useCallback(async () => { + // Optimistic: clear UI first, then ask server to clear. + setMessages([DEFAULT_WELCOME]) + try { + await fetch("/api/chat/history", { method: "DELETE" }) + } catch (err) { + console.warn("[chat] clear on server failed (local cleared):", err) + } + }, []) + + return ( + + {children} + + ) +} + +export function useChatContext(): ChatContextValue { + const ctx = React.useContext(ChatContext) + if (!ctx) { + throw new Error( + "useChatContext must be used inside . " + + "Make sure app/layout.tsx wraps children with ." + ) + } + return ctx +} From 1c04c9d5f164a4b0bc6a3ab32afebeae883f7883 Mon Sep 17 00:00:00 2001 From: Manohar Gupta Date: Sat, 18 Apr 2026 19:10:47 +0000 Subject: [PATCH 03/20] chore: sync pre-existing uncommitted work from migration era MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These files accumulated between commit d4a3f2b (initial dashboard) and today's fix work. Grouping them into one commit to avoid losing history rather than attempting to backdate individual changes. Contents: • dashboard gateway routes: /api/gw/* + /api/status + /api/tiger/dispatch • dashboard components: app-sidebar, cost-monitor, kanban-board, task-dialog • dashboard hooks/lib: use-gateway, gateway client • dashboard shadcn/ui: dialog, progress, select • bridge routes: gateway (gateway proxy for bridge-side control) • next.config.ts + package.json/lock updates Future work should commit in smaller, topical units. --- bridge/src/routes/gateway.ts | 54 +++ dashboard/next.config.ts | 4 +- dashboard/package-lock.json | 369 +++++++++++++++--- dashboard/package.json | 1 + dashboard/src/app/api/gw/[...method]/route.ts | 41 +- dashboard/src/app/api/gw/stream/route.ts | 66 ++-- dashboard/src/app/api/status/route.ts | 160 ++------ dashboard/src/app/api/tiger/dispatch/route.ts | 14 - dashboard/src/components/app-sidebar.tsx | 6 + .../src/components/cost/cost-monitor.tsx | 6 +- .../src/components/tasks/kanban-board.tsx | 4 +- .../src/components/tasks/task-dialog.tsx | 6 +- dashboard/src/components/ui/dialog.tsx | 158 ++++++++ dashboard/src/components/ui/progress.tsx | 31 ++ dashboard/src/components/ui/select.tsx | 190 +++++++++ dashboard/src/hooks/use-gateway.ts | 90 +++++ dashboard/src/lib/gateway.ts | 68 ++++ 17 files changed, 1004 insertions(+), 264 deletions(-) create mode 100644 bridge/src/routes/gateway.ts create mode 100644 dashboard/src/components/ui/dialog.tsx create mode 100644 dashboard/src/components/ui/progress.tsx create mode 100644 dashboard/src/components/ui/select.tsx create mode 100644 dashboard/src/hooks/use-gateway.ts create mode 100644 dashboard/src/lib/gateway.ts diff --git a/bridge/src/routes/gateway.ts b/bridge/src/routes/gateway.ts new file mode 100644 index 0000000..6cb2189 --- /dev/null +++ b/bridge/src/routes/gateway.ts @@ -0,0 +1,54 @@ +/** + * gateway.ts — Proxy to OpenClaw Gateway inside Tiger container + * + * GET/POST /api/gateway/* + * Forwards requests to the gateway running inside tiger-openclaw container + */ + +import { Router } from "express"; + +const router = Router(); + +// Gateway URL - use Docker internal IP or container name +// The Tiger container has IP 172.17.0.3 on docker0 network +const GATEWAY_URL = process.env.OPENCLAW_GATEWAY_URL || "http://172.17.0.3:18789"; + +// Proxy all requests to the gateway inside the container +router.all("/", async (req, res) => { + try { + const targetUrl = `${GATEWAY_URL}${req.originalUrl.replace("/api/gateway", "")}`; + + const fetchOptions: RequestInit = { + method: req.method, + headers: { + "Content-Type": "application/json", + ...(req.headers.authorization && { + Authorization: req.headers.authorization, + }), + }, + }; + + if (["POST", "PUT", "PATCH"].includes(req.method) && req.body) { + fetchOptions.body = JSON.stringify(req.body); + } + + const response = await fetch(targetUrl, fetchOptions); + const data = await response.json(); + + res.status(response.status).json(data); + } catch (err: any) { + if (err.message?.includes("ECONNREFUSED")) { + res.status(503).json({ + error: "Gateway not accessible", + details: "The gateway is running inside the Tiger container and not reachable. Check Docker networking.", + }); + } else { + res.status(500).json({ + error: "Failed to proxy to gateway", + details: err.message, + }); + } + } +}); + +export default router; \ No newline at end of file diff --git a/dashboard/next.config.ts b/dashboard/next.config.ts index e9ffa30..83d8424 100644 --- a/dashboard/next.config.ts +++ b/dashboard/next.config.ts @@ -1,7 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + typescript: { + ignoreBuildErrors: true, + }, }; export default nextConfig; diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 273152e..bb7f8e0 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -13,6 +13,7 @@ "@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", @@ -3635,6 +3636,267 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tailwindcss/node/node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/node/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/@tailwindcss/oxide": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", @@ -6091,7 +6353,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -8790,10 +9051,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -8806,27 +9066,26 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8841,13 +9100,12 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8862,13 +9120,12 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8883,13 +9140,12 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8904,13 +9160,12 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", "cpu": [ "arm" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8925,13 +9180,12 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8946,13 +9200,12 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8967,13 +9220,12 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -8988,13 +9240,12 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9009,13 +9260,12 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", "cpu": [ "arm64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ @@ -9030,13 +9280,12 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", "cpu": [ "x64" ], - "dev": true, "license": "MPL-2.0", "optional": true, "os": [ diff --git a/dashboard/package.json b/dashboard/package.json index ffc88fb..faca07d 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -14,6 +14,7 @@ "@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", diff --git a/dashboard/src/app/api/gw/[...method]/route.ts b/dashboard/src/app/api/gw/[...method]/route.ts index 3898db1..b646978 100644 --- a/dashboard/src/app/api/gw/[...method]/route.ts +++ b/dashboard/src/app/api/gw/[...method]/route.ts @@ -1,6 +1,17 @@ import { NextRequest, NextResponse } from "next/server" -import { getGateway } from "@/lib/gateway" +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456" +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "" + +// Map gateway-style methods to bridge endpoints +const METHOD_MAP: Record = { + "status.canvas": "/tiger/status", + "config.get": "/tiger/config", + "config.set": "/tiger/config", +} + +// Proxy to the bridge instead of trying to reach the gateway directly +// (gateway runs inside Tiger container - not accessible from dashboard) export async function POST( request: NextRequest, { params }: { params: Promise<{ method: string[] }> } @@ -8,11 +19,21 @@ export async function POST( const { method: methodParts } = await params const method = methodParts.join(".") + // Map gateway method to bridge endpoint, or default to /tiger/status + const bridgePath = METHOD_MAP[method] || `/tiger/${methodParts[0]}` + try { const body = await request.json().catch(() => ({})) - const gw = getGateway() - const result = await gw.request(method, body) - return NextResponse.json({ ok: true, data: result }) + const res = await fetch(`${BRIDGE_URL}${bridgePath}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${BRIDGE_TOKEN}`, + }, + body: JSON.stringify(body), + }) + const data = await res.json() + return NextResponse.json({ ok: res.ok, data }) } catch (error) { const message = error instanceof Error ? error.message : "Gateway request failed" return NextResponse.json({ ok: false, error: message }, { status: 502 }) @@ -26,12 +47,16 @@ export async function GET( const { method: methodParts } = await params const method = methodParts.join(".") + const bridgePath = METHOD_MAP[method] || `/tiger/${methodParts[0]}` + try { - const gw = getGateway() - const result = await gw.request(method) - return NextResponse.json({ ok: true, data: result }) + const res = await fetch(`${BRIDGE_URL}${bridgePath}`, { + headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` }, + }) + const data = await res.json() + return NextResponse.json({ ok: res.ok, data }) } catch (error) { const message = error instanceof Error ? error.message : "Gateway request failed" return NextResponse.json({ ok: false, error: message }, { status: 502 }) } -} +} \ No newline at end of file diff --git a/dashboard/src/app/api/gw/stream/route.ts b/dashboard/src/app/api/gw/stream/route.ts index 8ffadbb..bd84bf3 100644 --- a/dashboard/src/app/api/gw/stream/route.ts +++ b/dashboard/src/app/api/gw/stream/route.ts @@ -1,55 +1,41 @@ -import { getGateway } from "@/lib/gateway" +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456" +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "" export const dynamic = "force-dynamic" export async function GET() { - const gw = getGateway() - - // Ensure connected - try { - if (!gw.isConnected()) { - await gw.connect() - } - } catch { - return new Response("Gateway offline", { status: 502 }) - } - const encoder = new TextEncoder() - let cleanupFn: (() => void) | null = null const stream = new ReadableStream({ start(controller) { - const handler = ({ event, payload, seq }: { event: string; payload: unknown; seq: number }) => { - if (event === "tick") return - const data = JSON.stringify({ event, payload, seq }) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) - } - - gw.on("gateway-event", handler) - + // Send initial connected message controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ event: "stream.connected", payload: { connected: true } })}\n\n`) + encoder.encode( + `data: ${JSON.stringify({ event: "stream.connected", payload: { connected: true } })}\n\n` + ) ) - const keepalive = setInterval(() => { - controller.enqueue(encoder.encode(`: keepalive\n\n`)) - }, 15000) + // Poll tiger status instead of gateway directly + const interval = setInterval(async () => { + try { + const res = await fetch(`${BRIDGE_URL}/tiger/status`, { + headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` }, + }) + const data = await res.json() + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ event: "health", payload: { status: data.status, ...data } })}\n\n` + ) + ) + } catch { + controller.enqueue(encoder.encode(`: keepalive\n\n`)) + } + }, 10000) - const disconnectHandler = () => { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ event: "stream.disconnected", payload: { connected: false } })}\n\n`) - ) - } - gw.on("disconnected", disconnectHandler) - - cleanupFn = () => { - gw.off("gateway-event", handler) - gw.off("disconnected", disconnectHandler) - clearInterval(keepalive) - } + ;(controller as any)._cleanup = () => clearInterval(interval) }, - cancel() { - cleanupFn?.() + cancel(controller: any) { + controller?._cleanup?.() }, }) @@ -60,4 +46,4 @@ export async function GET() { Connection: "keep-alive", }, }) -} +} \ No newline at end of file diff --git a/dashboard/src/app/api/status/route.ts b/dashboard/src/app/api/status/route.ts index e8f2ac8..31c4a8b 100644 --- a/dashboard/src/app/api/status/route.ts +++ b/dashboard/src/app/api/status/route.ts @@ -1,8 +1,10 @@ import { NextResponse } from "next/server" import os from "os" -import fs from "fs" -import path from "path" -import { getGateway } from "@/lib/gateway" + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456" +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "" + +export const dynamic = "force-dynamic" export async function GET() { try { @@ -10,147 +12,39 @@ export async function GET() { const totalMem = os.totalmem() const memUsage = Math.round(((totalMem - freeMem) / totalMem) * 100) - // Try gateway first for rich data - try { - const gw = getGateway() - if (!gw.isConnected()) await gw.connect() - - const [health, skills, cron, heartbeat, identity, models, config] = await Promise.allSettled([ - gw.request("health"), - gw.request("skills.status"), - gw.request("cron.list"), - gw.request("last-heartbeat"), - gw.request("agent.identity.get"), - gw.request("models.list"), - gw.request("config.get"), - ]) - - const healthData = health.status === "fulfilled" ? health.value as Record : null - const skillsData = skills.status === "fulfilled" ? skills.value as Record : null - const cronData = cron.status === "fulfilled" ? cron.value as unknown[] : null - const heartbeatData = heartbeat.status === "fulfilled" ? heartbeat.value as Record : null - const identityData = identity.status === "fulfilled" ? identity.value as Record : null - const modelsData = models.status === "fulfilled" ? models.value as Record : null - const configData = config.status === "fulfilled" ? config.value as Record : null - - const skillsList = (skillsData?.skills || skillsData?.installed || []) as unknown[] - const cronList = Array.isArray(cronData) ? cronData : ((cronData as Record | 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) || configData - const agentsConfig = (rawConfig?.agents as Record) || undefined - const defaultsConfig = (agentsConfig?.defaults as Record) || undefined - const modelConfig = (defaultsConfig?.model as Record) || undefined - let currentModel = (modelConfig?.primary as string) || null - let fallbackModels = ((modelConfig?.fallbacks || []) as string[]) - - // Fallback: read directly from config file if gateway didn't return model info - if (!currentModel) { - try { - const configFilePath = path.join(os.homedir(), ".clawdbot", "clawdbot.json") - const fileConfig = JSON.parse(fs.readFileSync(configFilePath, "utf-8")) - currentModel = fileConfig?.agents?.defaults?.model?.primary || null - if (!fallbackModels.length) { - fallbackModels = fileConfig?.agents?.defaults?.model?.fallbacks || [] - } - } catch { - // config file not readable - } - } - - // Also extract the raw config hash for conflict-safe patching - const configHash = configData?._hash || configData?.hash || null - - // Extract models list: { models: [{id, name, provider, contextWindow, reasoning, input}] } - const modelsList = (modelsData?.models || []) as unknown[] - - // Read HEARTBEAT.md for heartbeat task info - let heartbeatContent: string | null = null - try { - const workspace = (configData?.agents as Record | undefined)?.defaults as Record | 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)?.enabled).length, - cronTotal: cronList.length, - lastHeartbeat: heartbeatData?.timestamp || heartbeatData?.lastChecked || null, - heartbeatContent, - currentModel, - fallbackModels, - }, - models: modelsList, - configHash, - health: healthData, - }) - } catch { - // Gateway not available - fall back to HTTP probe - } - - // Fallback: HTTP probe + file reads + // Use bridge's /tiger/status instead of gateway directly + // Gateway runs inside Tiger container and is not directly accessible let agentStatus = "offline" - try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), 1000) - const response = await fetch("http://127.0.0.1:18789/__clawdbot__/canvas/", { - signal: controller.signal, - cache: "no-store", - }) - clearTimeout(timeoutId) - if (response.ok) agentStatus = "online" - } catch { - // offline - } + let gatewayConnected = false + let tigerStatus: any = null - // Even without gateway, try to read config file for model info - let fallbackModel: string | null = null - let fallbackFallbacks: string[] = [] - let fallbackHeartbeat: string | null = null try { - const configFilePath = path.join(os.homedir(), ".clawdbot", "clawdbot.json") - const fileConfig = JSON.parse(fs.readFileSync(configFilePath, "utf-8")) - fallbackModel = fileConfig?.agents?.defaults?.model?.primary || null - fallbackFallbacks = fileConfig?.agents?.defaults?.model?.fallbacks || [] - } catch { /* ignore */ } - try { - fallbackHeartbeat = fs.readFileSync(path.join("/Users/manohar_air/clawd", "HEARTBEAT.md"), "utf-8").trim() - } catch { /* ignore */ } + const res = await fetch(`${BRIDGE_URL}/tiger/status`, { + headers: { "Authorization": `Bearer ${BRIDGE_TOKEN}` }, + }) + if (res.ok) { + tigerStatus = await res.json() + agentStatus = tigerStatus?.status === "online" ? "online" : "degraded" + gatewayConnected = tigerStatus?.status === "online" + } + } catch { /* offline */ } return NextResponse.json({ status: agentStatus, - gateway: false, - system: { - memoryUsage: memUsage, - uptime: os.uptime(), - platform: os.platform(), - }, + gateway: gatewayConnected, + system: { memoryUsage: memUsage, uptime: os.uptime(), platform: os.platform() }, agent: { + name: "Tiger", skills: 0, cronJobs: 0, - lastHeartbeat: null, - heartbeatContent: fallbackHeartbeat, - currentModel: fallbackModel, - fallbackModels: fallbackFallbacks, + lastHeartbeat: tigerStatus?.agent?.heartbeat, + currentModel: tigerStatus?.agent?.currentModel, + fallbackModels: tigerStatus?.agent?.fallbackModels || [], + container: tigerStatus?.container?.status, + memoryUsagePct: tigerStatus?.system?.memoryUsagePct, }, }) } catch (error) { return NextResponse.json({ error: "Failed to fetch status" }, { status: 500 }) } -} +} \ No newline at end of file diff --git a/dashboard/src/app/api/tiger/dispatch/route.ts b/dashboard/src/app/api/tiger/dispatch/route.ts index 3c529ee..398e5b0 100644 --- a/dashboard/src/app/api/tiger/dispatch/route.ts +++ b/dashboard/src/app/api/tiger/dispatch/route.ts @@ -21,17 +21,3 @@ export async function POST(request: Request) { } } -// GET /api/tiger/dispatch/status/:taskId -export async function GET( - request: Request, - { params }: { params: Promise<{ taskId: string }> } -) { - const { taskId } = await params; - try { - const result = await bridgePost(`/tiger/dispatch/status/${taskId}`, {}); - return NextResponse.json(result); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Unknown error"; - return NextResponse.json({ ok: false, error: message }, { status: 502 }); - } -} \ No newline at end of file diff --git a/dashboard/src/components/app-sidebar.tsx b/dashboard/src/components/app-sidebar.tsx index 2bbf438..8298699 100644 --- a/dashboard/src/components/app-sidebar.tsx +++ b/dashboard/src/components/app-sidebar.tsx @@ -5,6 +5,7 @@ import { Bot, Settings2, LayoutDashboard, + MessageSquare, ScrollText, CheckSquare, DollarSign, @@ -31,6 +32,11 @@ const navMain = [ url: "/", icon: LayoutDashboard, }, + { + title: "Chat", + url: "/chat", + icon: MessageSquare, + }, { title: "Projects", url: "/projects", diff --git a/dashboard/src/components/cost/cost-monitor.tsx b/dashboard/src/components/cost/cost-monitor.tsx index 8bfb386..d382511 100644 --- a/dashboard/src/components/cost/cost-monitor.tsx +++ b/dashboard/src/components/cost/cost-monitor.tsx @@ -187,7 +187,7 @@ export function CostMonitor() { `$${v.toFixed(2)}`} /> [`$${value.toFixed(4)}`, 'Cost']} + formatter={(value) => [`$${Number(value).toFixed(4)}`, 'Cost']} /> @@ -217,9 +217,9 @@ export function CostMonitor() { { + formatter={(value, _name, props) => { const model = props?.payload?.fullModel || '' - return [`$${value.toFixed(4)}`, model] + return [`$${Number(value).toFixed(4)}`, model] }} /> diff --git a/dashboard/src/components/tasks/kanban-board.tsx b/dashboard/src/components/tasks/kanban-board.tsx index 75febda..e1abe07 100644 --- a/dashboard/src/components/tasks/kanban-board.tsx +++ b/dashboard/src/components/tasks/kanban-board.tsx @@ -443,13 +443,13 @@ export function KanbanBoard() { id: editingTask.id, title: editingTask.title, description: editingTask.description, - status: editingTask.status, + status: editingTask.status as string, priority: editingTask.priority, assigned_agent: editingTask.assigned_agent, progress: editingTask.progress, tags: editingTask.tags, } : null} - onSubmit={editingTask ? handleUpdateTask : handleAddTask} + onSubmit={editingTask ? handleUpdateTask as any : handleAddTask} onDelete={editingTask ? () => handleDeleteTask(editingTask.id) : undefined} onRun={editingTask ? () => handleRunTask(editingTask.id) : undefined} /> diff --git a/dashboard/src/components/tasks/task-dialog.tsx b/dashboard/src/components/tasks/task-dialog.tsx index ff4c97f..95fcac2 100644 --- a/dashboard/src/components/tasks/task-dialog.tsx +++ b/dashboard/src/components/tasks/task-dialog.tsx @@ -43,9 +43,9 @@ interface TaskDialogProps { onSubmit: (data: { title: string description?: string - status: string - priority: string - assigned_agent?: string + status?: string + priority?: string + assigned_agent?: string | null progress?: number tags?: string[] due_date?: string diff --git a/dashboard/src/components/ui/dialog.tsx b/dashboard/src/components/ui/dialog.tsx new file mode 100644 index 0000000..84bdef4 --- /dev/null +++ b/dashboard/src/components/ui/dialog.tsx @@ -0,0 +1,158 @@ +"use client" + +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/dashboard/src/components/ui/progress.tsx b/dashboard/src/components/ui/progress.tsx new file mode 100644 index 0000000..5a0a5a6 --- /dev/null +++ b/dashboard/src/components/ui/progress.tsx @@ -0,0 +1,31 @@ +"use client" + +import * as React from "react" +import { Progress as ProgressPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Progress({ + className, + value, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { Progress } diff --git a/dashboard/src/components/ui/select.tsx b/dashboard/src/components/ui/select.tsx new file mode 100644 index 0000000..c0dc712 --- /dev/null +++ b/dashboard/src/components/ui/select.tsx @@ -0,0 +1,190 @@ +"use client" + +import * as React from "react" +import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" +import { Select as SelectPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Select({ + ...props +}: React.ComponentProps) { + return +} + +function SelectGroup({ + ...props +}: React.ComponentProps) { + return +} + +function SelectValue({ + ...props +}: React.ComponentProps) { + return +} + +function SelectTrigger({ + className, + size = "default", + children, + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + {children} + + + + + ) +} + +function SelectContent({ + className, + children, + position = "item-aligned", + align = "center", + ...props +}: React.ComponentProps) { + return ( + + + + + {children} + + + + + ) +} + +function SelectLabel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ) +} + +function SelectSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SelectScrollUpButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function SelectScrollDownButton({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +export { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectScrollDownButton, + SelectScrollUpButton, + SelectSeparator, + SelectTrigger, + SelectValue, +} diff --git a/dashboard/src/hooks/use-gateway.ts b/dashboard/src/hooks/use-gateway.ts new file mode 100644 index 0000000..c8382ab --- /dev/null +++ b/dashboard/src/hooks/use-gateway.ts @@ -0,0 +1,90 @@ +"use client" + +/** + * use-gateway.ts — Client-side hooks for OpenClaw gateway interaction + */ + +import { useState, useCallback, useEffect, useRef } from "react" + +const GW_API_PREFIX = "/api/gw" + +/** + * useGatewayRequest — make RPC-style calls to the gateway via /api/gw/[...method] + * Usage: request("cron.list") => POST /api/gw/cron/list + * request("cron.run", { id: "abc" }) => POST /api/gw/cron/run with body + */ +export function useGatewayRequest() { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + const request = useCallback(async (method: string, body?: Record) => { + setLoading(true) + setError(null) + try { + const path = method.replace(/\./g, "/") + const res = await fetch(`${GW_API_PREFIX}/${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body || {}), + }) + if (!res.ok) throw new Error(`${res.status}: ${res.statusText}`) + const json = await res.json() + return json.data ?? json + } catch (err: any) { + setError(err.message) + return null + } finally { + setLoading(false) + } + }, []) + + return { request, loading, error } +} + +/** + * useGatewayEvents — SSE event stream from /api/gw/stream + */ +export function useGatewayEvents(path?: string, options?: { enabled?: boolean }) { + const [events, setEvents] = useState([]) + const [connected, setConnected] = useState(false) + const [error, setError] = useState(null) + const esRef = useRef(null) + const enabled = options?.enabled ?? true + + useEffect(() => { + if (!enabled) return + + const url = path || "/api/gw/stream" + const es = new EventSource(url) + esRef.current = es + + es.onopen = () => { + setConnected(true) + setError(null) + } + + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data) + setEvents((prev) => [...prev.slice(-500), data]) + } catch { + setEvents((prev) => [...prev.slice(-500), { raw: e.data }]) + } + } + + es.onerror = () => { + setConnected(false) + setError("Connection lost") + } + + return () => { + es.close() + esRef.current = null + setConnected(false) + } + }, [path, enabled]) + + const clear = useCallback(() => setEvents([]), []) + + return { events, connected, error, clear } +} diff --git a/dashboard/src/lib/gateway.ts b/dashboard/src/lib/gateway.ts new file mode 100644 index 0000000..41a6918 --- /dev/null +++ b/dashboard/src/lib/gateway.ts @@ -0,0 +1,68 @@ +/** + * gateway.ts — OpenClaw Gateway client for server-side API routes + * + * v3: Routes through Tiger Bridge proxy because the gateway runs inside + * the Tiger Docker container and is not directly accessible from Dokploy. + */ + +const BRIDGE_URL = process.env.TIGER_BRIDGE_URL || "http://localhost:3456" +const BRIDGE_TOKEN = process.env.TIGER_BRIDGE_TOKEN || "" +const GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN || "" + +interface GatewayOptions { + method?: string + body?: any + timeout?: number +} + +export function getGateway() { + // Use the bridge proxy which can access the gateway inside the container + const gatewayBase = `${BRIDGE_URL}/api/gateway` + + return { + url: gatewayBase, + token: GATEWAY_TOKEN, + + async request(path: string, opts: GatewayOptions = {}) { + const { method = "GET", body, timeout = 30000 } = opts + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeout) + + try { + const res = await fetch(`${gatewayBase}${path}`, { + method, + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${BRIDGE_TOKEN}`, + ...(GATEWAY_TOKEN ? { "X-Gateway-Token": GATEWAY_TOKEN } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }) + + if (!res.ok) { + throw new Error(`Gateway ${res.status}: ${res.statusText}`) + } + + return res + } finally { + clearTimeout(timer) + } + }, + + async json(path: string, opts: GatewayOptions = {}) { + const res = await this.request(path, opts) + return res.json() + }, + + async text(path: string, opts: GatewayOptions = {}) { + const res = await this.request(path, opts) + return res.text() + }, + + streamUrl(path: string) { + const sep = path.includes("?") ? "&" : "?" + return `${gatewayBase}${path}${GATEWAY_TOKEN ? `${sep}token=${GATEWAY_TOKEN}` : ""}` + }, + } +} \ No newline at end of file From 01ab6300854f10d4ac1ee137b0acf5bc227c8ca6 Mon Sep 17 00:00:00 2001 From: Mannu Date: Sun, 19 Apr 2026 01:24:23 +0530 Subject: [PATCH 04/20] feat(dev): deploy.sh + local-dev.sh + bridge remote mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy.sh: Validated explicit-deploy workflow. Pre-flight checks (local build, uncommitted changes, server reachability) run on Mac before touching server. Code pushed to server via 'git push ssh://...' over the existing SSH connection — no Mac SSH server required. Server does git reset --hard to the pushed commit, reinstalls deps if package.json changed, rebuilds dashboard, restarts services, verifies health. Full troubleshooting guide in file header. local-dev.sh: Runs bridge (:3457) and dashboard (:3101) locally on Mac while reaching Tiger via SSH. Separate ports + separate SQLite DB keep it isolated from prod (still live on :3100/:3456). Hot-reload in both layers. Clean Ctrl-C shutdown. bridge remote mode: Added TIGER_REMOTE=true support in bridge/src/tiger.ts and chat.ts. When set, 'docker exec tiger-openclaw' calls are prefixed with 'ssh $TIGER_REMOTE_SSH'. Backward-compatible: VPS leaves TIGER_REMOTE unset and runs docker locally as before. Workflow moving forward: • Edit locally on Mac • ./local-dev.sh to test against real Tiger • git commit small + often • ./deploy.sh to push to production --- bridge/src/routes/chat.ts | 6 +- bridge/src/tiger.ts | 25 +++- deploy.sh | 297 ++++++++++++++++++++++++++++++++++++++ local-dev.sh | 180 +++++++++++++++++++++++ 4 files changed, 504 insertions(+), 4 deletions(-) create mode 100755 deploy.sh create mode 100755 local-dev.sh diff --git a/bridge/src/routes/chat.ts b/bridge/src/routes/chat.ts index e81a7ef..10d07f6 100644 --- a/bridge/src/routes/chat.ts +++ b/bridge/src/routes/chat.ts @@ -98,7 +98,11 @@ router.post("/", async (req, res) => { // Use openclaw agent to send a message to the main session // Session ID: c1e6a067-7ca5-423b-9506-105db0702997 (agent:main:main) - const cmd = `docker exec tiger-openclaw openclaw agent --session-id c1e6a067-7ca5-423b-9506-105db0702997 -m '${escapedMessage}' --json --timeout 120`; + // 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; diff --git a/bridge/src/tiger.ts b/bridge/src/tiger.ts index e0d575d..7efcd23 100644 --- a/bridge/src/tiger.ts +++ b/bridge/src/tiger.ts @@ -25,6 +25,19 @@ 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 `. +// On the real VPS: TIGER_REMOTE is unset → commands run locally as before. +const IS_REMOTE = process.env.TIGER_REMOTE === "true"; +const REMOTE_SSH = process.env.TIGER_REMOTE_SSH || "root@100.75.128.45"; +const SSH_PREFIX = IS_REMOTE ? `ssh ${REMOTE_SSH} ` : ""; + +if (IS_REMOTE) { + console.log(`[bridge] REMOTE MODE: docker commands will run via ssh ${REMOTE_SSH}`); +} + /** * Execute a command inside the Tiger container. * Commands run directly via docker exec (no kubectl needed). @@ -33,8 +46,9 @@ export async function execInSandbox( command: string, timeoutMs = DEFAULT_TIMEOUT ): Promise<{ stdout: string; stderr: string; exitCode: number }> { - // Run command directly inside tiger-openclaw container - const fullCmd = `docker exec ${DOCKER_CONTAINER} sh -c ${JSON.stringify(command)}`; + // Run command directly inside tiger-openclaw container. + // SSH_PREFIX is empty on the VPS, 'ssh root@host ' for local dev mode. + const fullCmd = `${SSH_PREFIX}docker exec ${DOCKER_CONTAINER} sh -c ${JSON.stringify(command)}`; try { const { stdout, stderr } = await execAsync(fullCmd, { @@ -61,7 +75,12 @@ export async function execOnHost( timeoutMs = DEFAULT_TIMEOUT ): Promise<{ stdout: string; stderr: string; exitCode: number }> { try { - const { stdout, stderr } = await execAsync(command, { + // In remote mode, wrap the command so it runs on the VPS host, not on Mac. + // Use single-quoted form to avoid local shell interpreting it. + const fullCmd = IS_REMOTE + ? `ssh ${REMOTE_SSH} ${JSON.stringify(command)}` + : command; + const { stdout, stderr } = await execAsync(fullCmd, { timeout: timeoutMs, maxBuffer: 5 * 1024 * 1024, }); diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..665a03d --- /dev/null +++ b/deploy.sh @@ -0,0 +1,297 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════════════════════ +# deploy.sh — Push local changes to the Tiger VPS +# +# WHAT IT DOES: +# 1. Validates local state (no uncommitted changes, build works) +# 2. SSHes to server and pulls YOUR local git repo as the source +# 3. Installs any new dependencies +# 4. Rebuilds the Next.js dashboard +# 5. Restarts tiger-bridge and tiger-dashboard services +# 6. Verifies everything came back healthy +# +# USAGE: +# ./deploy.sh # deploy whatever is at current HEAD +# ./deploy.sh --skip-build-check # skip local build (NOT recommended) +# ./deploy.sh --dry-run # show what would happen, don't do it +# +# FAILURE MODES (what to do when it breaks): +# - "uncommitted changes" → git commit first, or git stash +# - "local build failed" → fix TypeScript errors locally, retry +# - "server unreachable" → check Tailscale; ssh root@100.75.128.45 manually +# - "deploy.sh fails mid-way" → server might be in broken state. See +# troubleshooting section at bottom of this file. +# ═══════════════════════════════════════════════════════════════════ + +set -euo pipefail + +# ─── Configuration ─────────────────────────────────────────────────── +SERVER="root@100.75.128.45" +SERVER_PATH="/root/NemoClawDashboard" +LOCAL_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Colors — makes scanning the output easier when things go wrong +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # reset + +# Parse flags +SKIP_BUILD_CHECK=false +DRY_RUN=false +for arg in "$@"; do + case $arg in + --skip-build-check) SKIP_BUILD_CHECK=true ;; + --dry-run) DRY_RUN=true ;; + --help|-h) + echo "Usage: $0 [--skip-build-check] [--dry-run]" + exit 0 + ;; + esac +done + +# ─── Helper functions ──────────────────────────────────────────────── +log() { echo -e "${BLUE}[deploy]${NC} $*"; } +ok() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } +die() { echo -e "${RED}✗${NC} $*"; exit 1; } +section() { echo; echo -e "${BLUE}═══ $* ═══${NC}"; } + +run_remote() { + if $DRY_RUN; then + echo " [dry-run] ssh $SERVER '$*'" + else + ssh "$SERVER" "$@" + fi +} + +START_TIME=$(date +%s) + +# ═══════════════════════════════════════════════════════════════════ +# PRE-FLIGHT: Local checks (fail fast, don't touch server if local is bad) +# ═══════════════════════════════════════════════════════════════════ + +section "Pre-flight checks" + +# [1] Are we in the right directory? +cd "$LOCAL_PATH" +if [ ! -d .git ] || [ ! -d dashboard ] || [ ! -d bridge ]; then + die "Not in NemoClawDashboard repo root. cd to the repo first." +fi +ok "In repo: $LOCAL_PATH" + +# [2] Uncommitted changes? +# WHY: if we deploy code that isn't committed, git log can't tell us what's +# running on the server. Commit first, always. +if ! git diff-index --quiet HEAD --; then + warn "You have uncommitted changes:" + git status --short | head -10 + echo + read -r -p "Deploy anyway? Uncommitted changes will NOT be deployed. (y/N) " ans + if [ "$ans" != "y" ]; then + die "Aborted. Commit or stash, then retry." + fi +fi + +# [3] Untracked files of note (warn only) +UNTRACKED=$(git status --short | grep '^??' | wc -l | xargs) +if [ "$UNTRACKED" -gt 0 ]; then + warn "$UNTRACKED untracked file(s) exist — they won't be deployed." +fi + +# [4] What commit are we about to deploy? +LOCAL_SHA=$(git rev-parse HEAD) +LOCAL_SHA_SHORT=$(git rev-parse --short HEAD) +LOCAL_MSG=$(git log -1 --pretty=format:"%s") +ok "Deploying commit: ${LOCAL_SHA_SHORT} — ${LOCAL_MSG}" + +# [5] Local build sanity check +# WHY: catches TypeScript errors in 30s on Mac instead of 5min on server. +# Building the dashboard also pre-checks bridge ts imports if types are +# shared, though bridge has its own tsc separately. +if ! $SKIP_BUILD_CHECK; then + log "Running local build check (dashboard)…" + if $DRY_RUN; then + echo " [dry-run] would: cd dashboard && npm run build" + else + # Use a subshell so we don't accidentally cd out of the repo + (cd dashboard && npm run build > /tmp/deploy-build.log 2>&1) || { + warn "Local build FAILED. Last 30 lines:" + tail -30 /tmp/deploy-build.log + die "Fix local build errors before deploying. (Or use --skip-build-check to bypass, NOT recommended.)" + } + fi + ok "Local dashboard build passed" +fi + +# [6] Is the server reachable? +log "Checking server reachability…" +if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "$SERVER" 'echo ok' >/dev/null 2>&1; then + die "Can't SSH to $SERVER. Check Tailscale + SSH auth." +fi +ok "Server reachable" + +# ═══════════════════════════════════════════════════════════════════ +# DEPLOY: Push code and restart services +# ═══════════════════════════════════════════════════════════════════ + +section "Deploying to server" + +# [7] On server: make sure its working tree is clean before we overwrite +# WHY: if someone SSHed in and edited files directly, this reset would +# silently discard their work. Check first so we can bail if so. +log "Checking server working tree…" +SERVER_DIRTY=$(ssh "$SERVER" "cd $SERVER_PATH && git status --porcelain | wc -l" 2>/dev/null | xargs) +if [ "$SERVER_DIRTY" -gt 0 ]; then + warn "Server has uncommitted changes:" + ssh "$SERVER" "cd $SERVER_PATH && git status --short | head -10" + read -r -p "Discard them and deploy? (y/N) " ans + if [ "$ans" != "y" ]; then + die "Aborted. SSH in and review those changes first." + fi +fi + +# [8] Ensure 'mac' remote exists on the server (points back to Mac via ssh) +# WHY: server needs to fetch from Mac. First-time setup = add remote. +log "Ensuring server can fetch from Mac…" +run_remote "cd $SERVER_PATH && git remote get-url mac 2>/dev/null || git remote add mac $(whoami)@$(hostname).local:$LOCAL_PATH" >/dev/null 2>&1 || true + +# ☝ Note: for this to work, Mac needs SSH server enabled. +# Alternative that doesn't require Mac to accept SSH: push over the +# existing SSH connection using git's stdio protocol. We'll use that +# instead — more reliable, no Mac SSH config required. + +# [9] Push local commits to server via SSH stdio +# WHY: uses the same SSH connection we already have to server, pushing +# our .git/ contents to a bare-ish path. This works even if Mac doesn't +# accept incoming SSH. +log "Pushing commits to server…" +if $DRY_RUN; then + echo " [dry-run] would: git push -f ssh://$SERVER$SERVER_PATH HEAD:refs/heads/incoming" +else + # We push to a throwaway branch 'incoming' on server. Then server-side + # we reset main to match. This lets us push even when server's main + # is checked out (which would otherwise block a direct push). + git push -f "ssh://$SERVER$SERVER_PATH" "HEAD:refs/heads/incoming" 2>&1 | tail -5 +fi +ok "Commits pushed" + +# [10] On server: reset main to the newly-pushed incoming, clean state +log "Updating server working tree…" +run_remote "cd $SERVER_PATH && \ + git checkout main 2>/dev/null && \ + git reset --hard refs/heads/incoming && \ + git branch -D incoming 2>/dev/null; true" +ok "Server at commit $LOCAL_SHA_SHORT" + +# [11] Install new deps if package.json changed +# WHY: if someone added a new library to dashboard or bridge, npm install +# must run or the build/runtime will error with 'Cannot find module'. +# We detect whether package.json or lockfile changed in the last commit. +log "Checking for dependency changes…" +if ! $DRY_RUN; then + DEPS_CHANGED=$(ssh "$SERVER" "cd $SERVER_PATH && git diff HEAD~1 HEAD --name-only 2>/dev/null | grep -E '(package\.json|package-lock\.json)$' | head -5") + if [ -n "$DEPS_CHANGED" ]; then + warn "Dependencies changed in this commit:" + echo "$DEPS_CHANGED" | sed 's/^/ /' + # Install in whichever subdirs have changes + if echo "$DEPS_CHANGED" | grep -q "^dashboard/"; then + log " Running npm install in dashboard/…" + run_remote "cd $SERVER_PATH/dashboard && npm install --no-audit --no-fund 2>&1 | tail -5" + fi + if echo "$DEPS_CHANGED" | grep -q "^bridge/"; then + log " Running npm install in bridge/…" + run_remote "cd $SERVER_PATH/bridge && npm install --no-audit --no-fund 2>&1 | tail -5" + fi + else + ok "No dependency changes" + fi +fi + +# [12] Rebuild dashboard +# WHY: see ChunkLoadError story — Next.js prod server won't hot-reload +# when .next/ changes on disk. Must rebuild before restart. +log "Building dashboard on server…" +run_remote "cd $SERVER_PATH/dashboard && npm run build > /tmp/deploy-build.log 2>&1" || { + warn "Server build failed. Last 30 lines:" + ssh "$SERVER" "tail -30 /tmp/deploy-build.log" + die "Server build failed. Server is now in inconsistent state — run deploy.sh again after fixing." +} +ok "Dashboard built" + +# [13] Restart services — bridge first (tsx auto-picks up src/ changes +# on restart), then dashboard (must be restarted to pick up new .next/) +log "Restarting services…" +run_remote "systemctl restart tiger-bridge" +sleep 3 +run_remote "systemctl restart tiger-dashboard" +sleep 5 + +# [14] Health verification — are services actually up and responding? +log "Verifying services…" +BRIDGE_STATE=$(ssh "$SERVER" "systemctl is-active tiger-bridge" 2>&1) +DASH_STATE=$(ssh "$SERVER" "systemctl is-active tiger-dashboard" 2>&1) +if [ "$BRIDGE_STATE" != "active" ]; then + warn "tiger-bridge is '$BRIDGE_STATE' — checking logs:" + ssh "$SERVER" 'journalctl -u tiger-bridge -n 15 --no-pager' + die "tiger-bridge failed to start" +fi +if [ "$DASH_STATE" != "active" ]; then + warn "tiger-dashboard is '$DASH_STATE' — checking logs:" + ssh "$SERVER" 'journalctl -u tiger-dashboard -n 15 --no-pager' + die "tiger-dashboard failed to start" +fi +ok "tiger-bridge: $BRIDGE_STATE" +ok "tiger-dashboard: $DASH_STATE" + +# [15] Final sanity check: does /api/tiger/status respond? +log "Probing /api/tiger/status…" +STATUS_CODE=$(ssh "$SERVER" "curl -sS -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:3100/api/tiger/status" 2>&1) +if [ "$STATUS_CODE" = "200" ]; then + ok "Dashboard API responding (HTTP $STATUS_CODE)" +else + warn "Dashboard API returned HTTP $STATUS_CODE — investigate with: ssh $SERVER 'journalctl -u tiger-dashboard -f'" +fi + +# ═══════════════════════════════════════════════════════════════════ +# SUMMARY +# ═══════════════════════════════════════════════════════════════════ + +END_TIME=$(date +%s) +DURATION=$((END_TIME - START_TIME)) + +section "Deploy complete" +echo " Commit: $LOCAL_SHA_SHORT — $LOCAL_MSG" +echo " Duration: ${DURATION}s" +echo " Production: https://agent.manohargupta.com/" +echo +echo " Rollback: git checkout && ./deploy.sh" +echo " Live logs: ssh $SERVER 'journalctl -u tiger-bridge -f'" +echo " ssh $SERVER 'journalctl -u tiger-dashboard -f'" + +# ═══════════════════════════════════════════════════════════════════ +# TROUBLESHOOTING (read this before panicking) +# ═══════════════════════════════════════════════════════════════════ +# +# "Site is down after deploy" +# ssh $SERVER 'journalctl -u tiger-bridge -n 50 --no-pager' +# ssh $SERVER 'journalctl -u tiger-dashboard -n 50 --no-pager' +# Common cause: TypeScript error in bridge src/ (tsx reads live so +# syntax errors crash it). Fix src/ on Mac, redeploy. +# +# "I deployed broken code — how to rollback?" +# git log --oneline # find the last-known-good commit +# git checkout # get back to it locally +# ./deploy.sh # push the good state to server +# git checkout main # return to tip +# (Or: git revert on main, ./deploy.sh — preserves history.) +# +# "deploy.sh hangs at 'Checking server reachability'" +# Tailscale is down on Mac or server. Check: tailscale status +# Or: ssh -v $SERVER — look for 'Connection timed out' +# +# "npm install takes forever" +# First deploy after new deps IS slow. Subsequent deploys skip it +# because only changed deps get reinstalled. +# ═══════════════════════════════════════════════════════════════════ diff --git a/local-dev.sh b/local-dev.sh new file mode 100755 index 0000000..c62b4aa --- /dev/null +++ b/local-dev.sh @@ -0,0 +1,180 @@ +#!/bin/bash +# ═══════════════════════════════════════════════════════════════════ +# local-dev.sh — Run bridge + dashboard locally on Mac +# +# WHAT IT DOES: +# Starts two Node.js processes: +# - Bridge on :3457 (runs in TIGER_REMOTE=true mode, talks to +# the real tiger-openclaw container on VPS via SSH) +# - Dashboard on :3101 (Next.js dev mode with hot-reload) +# +# ARCHITECTURE: +# +# [Mac] Dashboard :3101 ──HTTP──▶ [Mac] Bridge :3457 +# │ +# ├── SQLite (local ./data/tiger-local.db) +# │ +# └──SSH──▶ [VPS] docker exec tiger-openclaw +# │ +# └── OpenClaw + Tiger +# +# SAFETY: +# - Uses separate ports (3101, 3457) so production on :3100, :3456 +# keeps running normally. +# - Uses a SEPARATE SQLite file (data/tiger-local.db) so local chat +# history doesn't mix with production. +# - Read-only for Tiger state: your local bridge can invoke Tiger and +# READ its workspace, but there's no "commit my local chat history +# to server" step. Production Tiger is untouched. +# +# USAGE: +# ./local-dev.sh # start both services +# ./local-dev.sh --bridge # only bridge +# ./local-dev.sh --dashboard # only dashboard +# Ctrl-C # stop everything cleanly +# +# REQUIREMENTS: +# - Node 20+ (already have this) +# - bridge/node_modules installed (cd bridge && npm install, once) +# - dashboard/node_modules installed (cd dashboard && npm install, once) +# - SSH access to root@100.75.128.45 (already set up via Tailscale) +# ═══════════════════════════════════════════════════════════════════ + +set -uo pipefail + +LOCAL_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$LOCAL_PATH" + +# Colors +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +RED='\033[0;31m' +NC='\033[0m' + +# Parse flags +MODE="both" +for arg in "$@"; do + case $arg in + --bridge) MODE="bridge" ;; + --dashboard) MODE="dashboard" ;; + --help|-h) + echo "Usage: $0 [--bridge | --dashboard]" + exit 0 + ;; + esac +done + +# ─── Pre-flight ────────────────────────────────────────────────────── + +# Check ssh to server (we need it if bridge is starting) +if [ "$MODE" != "dashboard" ]; then + if ! ssh -o ConnectTimeout=5 -o BatchMode=yes root@100.75.128.45 'echo ok' >/dev/null 2>&1; then + echo -e "${RED}✗${NC} Can't SSH to server. Bridge needs it for remote docker exec." + echo " Try: ssh root@100.75.128.45 manually; check tailscale status" + exit 1 + fi + echo -e "${GREEN}✓${NC} SSH to server works" +fi + +# Check node_modules are installed +if [ "$MODE" != "dashboard" ] && [ ! -d bridge/node_modules ]; then + echo -e "${YELLOW}⚠${NC} bridge/node_modules missing. Installing…" + (cd bridge && npm install --no-audit --no-fund) || { echo "npm install failed"; exit 1; } +fi +if [ "$MODE" != "bridge" ] && [ ! -d dashboard/node_modules ]; then + echo -e "${YELLOW}⚠${NC} dashboard/node_modules missing. Installing…" + (cd dashboard && npm install --no-audit --no-fund) || { echo "npm install failed"; exit 1; } +fi + +# Check port conflicts (we use 3101 and 3457 to stay clear of prod's 3100/3456) +for port in 3101 3457; do + if lsof -ti:$port >/dev/null 2>&1; then + echo -e "${RED}✗${NC} Port $port is already in use. Kill the process first:" + echo " lsof -ti:$port | xargs kill" + exit 1 + fi +done + +# ─── PID tracking so Ctrl-C cleans up children ────────────────────── + +PIDS=() +cleanup() { + echo + echo -e "${YELLOW}Shutting down…${NC}" + for pid in "${PIDS[@]}"; do + kill "$pid" 2>/dev/null || true + done + wait 2>/dev/null + echo -e "${GREEN}✓${NC} All services stopped" + exit 0 +} +trap cleanup INT TERM + +# ─── Start bridge ──────────────────────────────────────────────────── + +start_bridge() { + echo -e "${BLUE}[bridge]${NC} Starting on :3457 in REMOTE mode…" + cd "$LOCAL_PATH/bridge" + + # Environment for local bridge: + # TIGER_REMOTE=true → prefix docker commands with SSH + # TIGER_REMOTE_SSH=... → which host to SSH to + # TIGER_BRIDGE_PORT=3457 → don't collide with prod :3456 + # TIGER_DB_DIR=./data → separate SQLite file from prod + # TIGER_BRIDGE_TOKEN=dev-local-token → auth for dev (server uses different token) + export TIGER_REMOTE=true + export TIGER_REMOTE_SSH=root@100.75.128.45 + export TIGER_BRIDGE_PORT=3457 + export TIGER_BRIDGE_HOST=127.0.0.1 + export TIGER_BRIDGE_TOKEN=dev-local-token + export TIGER_DB_DIR="$LOCAL_PATH/data" + + # Use tsx directly (same as systemd does on server) so changes to src/ + # reload automatically without rebuilding. + node --import tsx src/index.ts 2>&1 | sed -u "s/^/$(printf "${BLUE}[bridge]${NC} ")/" & + PIDS+=($!) + cd "$LOCAL_PATH" +} + +# ─── Start dashboard ───────────────────────────────────────────────── + +start_dashboard() { + echo -e "${GREEN}[dashboard]${NC} Starting on :3101…" + cd "$LOCAL_PATH/dashboard" + + # Environment for local dashboard: + # PORT=3101 → don't collide with prod :3100 + # TIGER_BRIDGE_URL=localhost:3457 → talk to OUR local bridge, not prod + # TIGER_BRIDGE_TOKEN=dev-local-token → match local bridge's auth + export PORT=3101 + export TIGER_BRIDGE_URL=http://localhost:3457 + export TIGER_BRIDGE_TOKEN=dev-local-token + + # next dev → hot-reload, fast compile + npm run dev 2>&1 | sed -u "s/^/$(printf "${GREEN}[dashboard]${NC} ")/" & + PIDS+=($!) + cd "$LOCAL_PATH" +} + +# ─── Start requested services ────────────────────────────────────── + +echo +echo "═══════════════════════════════════════════════════════════════" +echo "Local dev environment starting" +echo "═══════════════════════════════════════════════════════════════" +[ "$MODE" = "both" ] || [ "$MODE" = "bridge" ] && start_bridge +[ "$MODE" = "both" ] || [ "$MODE" = "dashboard" ] && start_dashboard + +sleep 2 +echo +echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}" +[ "$MODE" != "bridge" ] && echo -e " Dashboard: ${GREEN}http://localhost:3101${NC}" +[ "$MODE" != "dashboard" ] && echo -e " Bridge: ${BLUE}http://localhost:3457${NC} (TIGER_REMOTE mode)" +echo -e " Production: https://agent.manohargupta.com (${YELLOW}untouched${NC})" +echo -e " Stop with: ${YELLOW}Ctrl-C${NC}" +echo -e "${GREEN}═══════════════════════════════════════════════════════════════${NC}" +echo + +# Wait for any child to exit (usually Ctrl-C triggers cleanup first) +wait "${PIDS[@]}" From 03ae4072d91407f4965e8d6e251511a9109d19fd Mon Sep 17 00:00:00 2001 From: Mannu Date: Sun, 19 Apr 2026 13:21:01 +0530 Subject: [PATCH 05/20] fix(deploy): grep with no matches no longer aborts script under set -e The UNTRACKED count used 'git status --short | grep '^??' | wc -l | xargs'. When the working tree is clean, grep exits 1 (no matches found). Combined with 'set -euo pipefail' at the top of the script, that exit 1 killed the script mid-preflight. Fix: use 'grep -c' with '|| true' fallback. grep -c counts matches and prints 0 if none; the fallback handles the exit code so set -e is happy. --- deploy.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy.sh b/deploy.sh index 665a03d..9c4a1f2 100755 --- a/deploy.sh +++ b/deploy.sh @@ -95,7 +95,7 @@ if ! git diff-index --quiet HEAD --; then fi # [3] Untracked files of note (warn only) -UNTRACKED=$(git status --short | grep '^??' | wc -l | xargs) +UNTRACKED=$(git status --short | grep -c '^??' || true) if [ "$UNTRACKED" -gt 0 ]; then warn "$UNTRACKED untracked file(s) exist — they won't be deployed." fi From 76620da6b28a69f52f223367dcfdd9f1376dbaa9 Mon Sep 17 00:00:00 2001 From: Manohar Gupta Date: Sat, 25 Apr 2026 20:05:51 +0000 Subject: [PATCH 06/20] chore: save working state before bind-mount setup (pre tiger write access) --- bridge/src/db.ts | 2 +- bridge/src/index.ts | 4 + bridge/src/routes/agents.ts | 181 +++++ bridge/src/routes/chat.ts | 33 +- bridge/src/routes/chat.ts.pre-ws-migration | 197 +++++ bridge/src/routes/dispatch.ts | 4 +- bridge/src/routes/models.ts | 19 + bridge/src/tiger.ts | 91 ++- dashboard/lib_smoke.ts | 42 ++ dashboard/public/favicon.ico | Bin 0 -> 1200 bytes dashboard/public/tiger-icon-32.png | Bin 0 -> 248 bytes dashboard/src/app/api/chat/route.ts | 203 +++--- dashboard/src/app/api/chat/route.ts.pre-ws | 137 ++++ dashboard/src/app/api/chat/sessions/route.ts | 123 ++++ dashboard/src/app/api/tiger/activity/route.ts | 15 + .../app/api/tiger/agents/[id]/file/route.ts | 37 + .../app/api/tiger/agents/[id]/files/route.ts | 21 + dashboard/src/app/api/tiger/agents/route.ts | 13 + .../src/app/api/tiger/config/models/route.ts | 13 + dashboard/src/app/favicon.ico | Bin 25931 -> 0 bytes dashboard/src/app/icon.svg | 20 + dashboard/src/app/layout.tsx | 29 +- dashboard/src/app/page.tsx | 43 +- dashboard/src/app/settings/page.tsx | 690 +++++++++++------- dashboard/src/app/workspace/page.tsx | 375 +++++----- dashboard/src/components/chat-interface.tsx | 157 ++-- .../src/components/chat-interface.tsx.pre-ws | 297 ++++++++ dashboard/src/components/ui/tabs.tsx | 91 +++ .../components/workspace/activity-feed.tsx | 79 ++ .../components/workspace/agent-chip-row.tsx | 75 ++ .../src/components/workspace/file-preview.tsx | 376 ++++++++++ .../src/components/workspace/file-tree.tsx | 124 ++++ dashboard/src/contexts/chat-context.tsx | 203 ++++-- .../src/contexts/chat-context.tsx.pre-ws | 108 +++ dashboard/src/lib/bridge.ts | 30 + dashboard/src/lib/openclaw-ws.ts | 324 ++++++++ 36 files changed, 3476 insertions(+), 680 deletions(-) create mode 100644 bridge/src/routes/agents.ts create mode 100644 bridge/src/routes/chat.ts.pre-ws-migration create mode 100644 bridge/src/routes/models.ts create mode 100644 dashboard/lib_smoke.ts create mode 100644 dashboard/public/favicon.ico create mode 100644 dashboard/public/tiger-icon-32.png create mode 100644 dashboard/src/app/api/chat/route.ts.pre-ws create mode 100644 dashboard/src/app/api/chat/sessions/route.ts create mode 100644 dashboard/src/app/api/tiger/activity/route.ts create mode 100644 dashboard/src/app/api/tiger/agents/[id]/file/route.ts create mode 100644 dashboard/src/app/api/tiger/agents/[id]/files/route.ts create mode 100644 dashboard/src/app/api/tiger/agents/route.ts create mode 100644 dashboard/src/app/api/tiger/config/models/route.ts delete mode 100644 dashboard/src/app/favicon.ico create mode 100644 dashboard/src/app/icon.svg create mode 100644 dashboard/src/components/chat-interface.tsx.pre-ws create mode 100644 dashboard/src/components/ui/tabs.tsx create mode 100644 dashboard/src/components/workspace/activity-feed.tsx create mode 100644 dashboard/src/components/workspace/agent-chip-row.tsx create mode 100644 dashboard/src/components/workspace/file-preview.tsx create mode 100644 dashboard/src/components/workspace/file-tree.tsx create mode 100644 dashboard/src/contexts/chat-context.tsx.pre-ws create mode 100644 dashboard/src/lib/openclaw-ws.ts 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 0000000000000000000000000000000000000000..200f805b90a8e53e9cf52af0e4f59808225572c3 GIT binary patch literal 1200 zcmZQzU}Run5D;Jh(h3Zd7#JAbfLK8R!v6te-vD9*0|-BYk%8d?5IZmdKI;VstEG|+2N-Ka}R9sc{1<2VC zbCCmcP*BzXi$_;*&a>Tpq~lEbqUBF-yh%8u>ZtE-aPf9giMv74=09~$y3ZPHdsM_3 z@1>)ZA|||xVG~LRIxVZ3$dP_90JK=*EAg1movv3b`< zefMyRJ{nf@QSta1#{Q;2_8zN*D~yZzBuWoxNU$zW5b0rJc(vSdqNWG?Dxg>J`5PE_ z3W9n5NK6*+;H*GA^A%k3F~49XtaF~-+nSvPdQJaC>FWlrsw=dW&HKJH zsgSjF;xA9z|H*721$~#ToPuK4DyMKi5-46%rM^o1U1z7nTJJ4)o^HLjwT|sk-Qvj^ zYaU*=OY&U!O8oldzf3WPdjE8TVl|hWGT&Wr=Gn5j#=Nty_g7>GE`8m*S2TNa#nI&b z2Fi;zDuu2&qd$Gg?@0S21_@FU2E05o*w}oSnU^&(UOv!}xyBq6ia>DT8E5k@-{brp z87DzpJn6{+DD40a*HxK?_kiK*4-MBDhJJ?~1YD1csfxOaxSCiph{QB$*~&CV7x3f> zXmJ$@>p3)vD5^|pV+(W;<(&ETU5n4B#J_Pn=T+RVS+5=_RctVGPF>Zje^RHPItQ;h zDfZ|3vE5IaJmOX?{iwH6;nq8+wP$B9tr6Q`tC8k=u&CO;>|iQc}WH9 zqCGL{3gyaA1$sUd@gAI2SkF6chkgCl$&33}+)lOJEq(KqZCZ^X%T=Z; zJAU$O?Z&m!U$cD44cfIWTWL?%|Cz3ziV7=o()?#mT7B{D?X&x8&dgwBabU<|G-X*J f7T`<-!x&WDKtV!84Ws_!nD9+09OWP(SUdm#peViH literal 0 HcmV?d00001 diff --git a/dashboard/public/tiger-icon-32.png b/dashboard/public/tiger-icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..8b68967c750e2f5dcc0d94d0eb2b9a7258bfc38f GIT binary patch literal 248 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE1|*BCs=ffJ{hlt4Ar*6yQyiFsf~x*sJi3B& zp6%`<9cR)PEq{9BO~N5nM}2pLi?@qP+zpa8|EYV@eb!*xqax0DFCC>6G2vYdo7i${ zigb^sinTFci1qa35J(ogrs2T8oH^dGqu`J7$xo8kcSu_4e-a957i9XvwnHe9xo7Q; zm_>}S^ZMV)A3pjZyx`xy^fx>w*;zM6Ih@dOD`8AJu$n`Tk&UhGyN6Ts(Xg72ipSS5 w_BREx_gE!dVO-27QF=f_f^~6%NDmXktL2UpH9go@0lmWD>FVdQ&MBb@0OwO(L;wH) literal 0 HcmV?d00001 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 718d6fea4835ec2d246af9800eddb7ffb276240c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25931 zcmeHv30#a{`}aL_*G&7qml|y<+KVaDM2m#dVr!KsA!#An?kSQM(q<_dDNCpjEux83 zLb9Z^XxbDl(w>%i@8hT6>)&Gu{h#Oeyszu?xtw#Zb1mO{pgX9699l+Qppw7jXaYf~-84xW z)w4x8?=youko|}Vr~(D$UXIbiXABHh`p1?nn8Po~fxRJv}|0e(BPs|G`(TT%kKVJAdg5*Z|x0leQq0 zkdUBvb#>9F()jo|T~kx@OM8$9wzs~t2l;K=woNssA3l6|sx2r3+kdfVW@e^8e*E}v zA1y5{bRi+3Z`uD3{F7LgFJDdvm;nJilkzDku>BwXH(8ItVCXk*-lSJnR?-2UN%hJ){&rlvg`CDTj z)Bzo!3v7Ou#83zEDEFcKt(f1E0~=rqeEbTnMvWR#{+9pg%7G8y>u1OVRUSoox-ovF z2Ydma(;=YuBY(eI|04{hXzZD6_f(v~H;C~y5=DhAC{MMS>2fm~1H_t2$56pc$NH8( z5bH|<)71dV-_oCHIrzrT`2s-5w_+2CM0$95I6X8p^r!gHp+j_gd;9O<1~CEQQGS8) zS9Qh3#p&JM-G8rHekNmKVewU;pJRcTAog68KYo^dRo}(M>36U4Us zfgYWSiHZL3;lpWT=zNAW>Dh#mB!_@Lg%$ms8N-;aPqMn+C2HqZgz&9~Eu z4|Kp<`$q)Uw1R?y(~S>ePdonHxpV1#eSP1B;Ogo+-Pk}6#0GsZZ5!||ev2MGdh}_m z{DeR7?0-1^zVs&`AV6Vt;r3`I`OI_wgs*w=eO%_#7Kepl{B@xiyCANc(l zzIyd4y|c6PXWq9-|KM8(zIk8LPk(>a)zyFWjhT!$HJ$qX1vo@d25W<fvZQ2zUz5WRc(UnFMKHwe1| zWmlB1qdbiA(C0jmnV<}GfbKtmcu^2*P^O?MBLZKt|As~ge8&AAO~2K@zbXelK|4T<{|y4`raF{=72kC2Kn(L4YyenWgrPiv z@^mr$t{#X5VuIMeL!7Ab6_kG$&#&5p*Z{+?5U|TZ`B!7llpVmp@skYz&n^8QfPJzL z0G6K_OJM9x+Wu2gfN45phANGt{7=C>i34CV{Xqlx(fWpeAoj^N0Biu`w+MVcCUyU* zDZuzO0>4Z6fbu^T_arWW5n!E45vX8N=bxTVeFoep_G#VmNlQzAI_KTIc{6>c+04vr zx@W}zE5JNSU>!THJ{J=cqjz+4{L4A{Ob9$ZJ*S1?Ggg3klFp!+Y1@K+pK1DqI|_gq z5ZDXVpge8-cs!o|;K73#YXZ3AShj50wBvuq3NTOZ`M&qtjj#GOFfgExjg8Gn8>Vq5 z`85n+9|!iLCZF5$HJ$Iu($dm?8~-ofu}tEc+-pyke=3!im#6pk_Wo8IA|fJwD&~~F zc16osQ)EBo58U7XDuMexaPRjU@h8tXe%S{fA0NH3vGJFhuyyO!Uyl2^&EOpX{9As0 zWj+P>{@}jxH)8|r;2HdupP!vie{sJ28b&bo!8`D^x}TE$%zXNb^X1p@0PJ86`dZyj z%ce7*{^oo+6%&~I!8hQy-vQ7E)0t0ybH4l%KltWOo~8cO`T=157JqL(oq_rC%ea&4 z2NcTJe-HgFjNg-gZ$6!Y`SMHrlj}Etf7?r!zQTPPSv}{so2e>Fjs1{gzk~LGeesX%r(Lh6rbhSo_n)@@G-FTQy93;l#E)hgP@d_SGvyCp0~o(Y;Ee8{ zdVUDbHm5`2taPUOY^MAGOw*>=s7=Gst=D+p+2yON!0%Hk` zz5mAhyT4lS*T3LS^WSxUy86q&GnoHxzQ6vm8)VS}_zuqG?+3td68_x;etQAdu@sc6 zQJ&5|4(I?~3d-QOAODHpZ=hlSg(lBZ!JZWCtHHSj`0Wh93-Uk)_S%zsJ~aD>{`A0~ z9{AG(e|q3g5B%wYKRxiL2Y$8(4w6bzchKuloQW#e&S3n+P- z8!ds-%f;TJ1>)v)##>gd{PdS2Oc3VaR`fr=`O8QIO(6(N!A?pr5C#6fc~Ge@N%Vvu zaoAX2&(a6eWy_q&UwOhU)|P3J0Qc%OdhzW=F4D|pt0E4osw;%<%Dn58hAWD^XnZD= z>9~H(3bmLtxpF?a7su6J7M*x1By7YSUbxGi)Ot0P77`}P3{)&5Un{KD?`-e?r21!4vTTnN(4Y6Lin?UkSM z`MXCTC1@4A4~mvz%Rh2&EwY))LeoT=*`tMoqcEXI>TZU9WTP#l?uFv+@Dn~b(>xh2 z;>B?;Tz2SR&KVb>vGiBSB`@U7VIWFSo=LDSb9F{GF^DbmWAfpms8Sx9OX4CnBJca3 zlj9(x!dIjN?OG1X4l*imJNvRCk}F%!?SOfiOq5y^mZW)jFL@a|r-@d#f7 z2gmU8L3IZq0ynIws=}~m^#@&C%J6QFo~Mo4V`>v7MI-_!EBMMtb%_M&kvAaN)@ZVw z+`toz&WG#HkWDjnZE!6nk{e-oFdL^$YnbOCN}JC&{$#$O27@|Tn-skXr)2ml2~O!5 zX+gYoxhoc7qoU?C^3~&!U?kRFtnSEecWuH0B0OvLodgUAi}8p1 zrO6RSXHH}DMc$&|?D004DiOVMHV8kXCP@7NKB zgaZq^^O<7PoKEp72kby@W0Z!Y*Ay{&vfg#C&gG@YVR9g?FEocMUi1gSN$+V+ayF45{a zuDZDTN}mS|;BO%gEf}pjBfN2-gIrU#G5~cucA;dokXW89%>AyXJJI z9X4UlIWA|ZYHgbI z5?oFk@A=Ik7lrEQPDH!H+b`7_Y~aDb_qa=B2^Y&Ow41cU=4WDd40dp5(QS-WMN-=Y z9g;6_-JdNU;|6cPwf$ak*aJIcwL@1n$#l~zi{c{EW?T;DaW*E8DYq?Umtz{nJ&w-M zEMyTDrC&9K$d|kZe2#ws6)L=7K+{ zQw{XnV6UC$6-rW0emqm8wJoeZK)wJIcV?dST}Z;G0Arq{dVDu0&4kd%N!3F1*;*pW zR&qUiFzK=@44#QGw7k1`3t_d8&*kBV->O##t|tonFc2YWrL7_eqg+=+k;!F-`^b8> z#KWCE8%u4k@EprxqiV$VmmtiWxDLgnGu$Vs<8rppV5EajBXL4nyyZM$SWVm!wnCj-B!Wjqj5-5dNXukI2$$|Bu3Lrw}z65Lc=1G z^-#WuQOj$hwNGG?*CM_TO8Bg-1+qc>J7k5c51U8g?ZU5n?HYor;~JIjoWH-G>AoUP ztrWWLbRNqIjW#RT*WqZgPJXU7C)VaW5}MiijYbABmzoru6EmQ*N8cVK7a3|aOB#O& zBl8JY2WKfmj;h#Q!pN%9o@VNLv{OUL?rixHwOZuvX7{IJ{(EdPpuVFoQqIOa7giLVkBOKL@^smUA!tZ1CKRK}#SSM)iQHk)*R~?M!qkCruaS!#oIL1c z?J;U~&FfH#*98^G?i}pA{ z9Jg36t4=%6mhY(quYq*vSxptes9qy|7xSlH?G=S@>u>Ebe;|LVhs~@+06N<4CViBk zUiY$thvX;>Tby6z9Y1edAMQaiH zm^r3v#$Q#2T=X>bsY#D%s!bhs^M9PMAcHbCc0FMHV{u-dwlL;a1eJ63v5U*?Q_8JO zT#50!RD619#j_Uf))0ooADz~*9&lN!bBDRUgE>Vud-i5ck%vT=r^yD*^?Mp@Q^v+V zG#-?gKlr}Eeqifb{|So?HM&g91P8|av8hQoCmQXkd?7wIJwb z_^v8bbg`SAn{I*4bH$u(RZ6*xUhuA~hc=8czK8SHEKTzSxgbwi~9(OqJB&gwb^l4+m`k*Q;_?>Y-APi1{k zAHQ)P)G)f|AyjSgcCFps)Fh6Bca*Xznq36!pV6Az&m{O8$wGFD? zY&O*3*J0;_EqM#jh6^gMQKpXV?#1?>$ml1xvh8nSN>-?H=V;nJIwB07YX$e6vLxH( zqYwQ>qxwR(i4f)DLd)-$P>T-no_c!LsN@)8`e;W@)-Hj0>nJ-}Kla4-ZdPJzI&Mce zv)V_j;(3ERN3_@I$N<^|4Lf`B;8n+bX@bHbcZTopEmDI*Jfl)-pFDvo6svPRoo@(x z);_{lY<;);XzT`dBFpRmGrr}z5u1=pC^S-{ce6iXQlLGcItwJ^mZx{m$&DA_oEZ)B{_bYPq-HA zcH8WGoBG(aBU_j)vEy+_71T34@4dmSg!|M8Vf92Zj6WH7Q7t#OHQqWgFE3ARt+%!T z?oLovLVlnf?2c7pTc)~cc^($_8nyKwsN`RA-23ed3sdj(ys%pjjM+9JrctL;dy8a( z@en&CQmnV(()bu|Y%G1-4a(6x{aLytn$T-;(&{QIJB9vMox11U-1HpD@d(QkaJdEb zG{)+6Dos_L+O3NpWo^=gR?evp|CqEG?L&Ut#D*KLaRFOgOEK(Kq1@!EGcTfo+%A&I z=dLbB+d$u{sh?u)xP{PF8L%;YPPW53+@{>5W=Jt#wQpN;0_HYdw1{ksf_XhO4#2F= zyPx6Lx2<92L-;L5PD`zn6zwIH`Jk($?Qw({erA$^bC;q33hv!d!>%wRhj# zal^hk+WGNg;rJtb-EB(?czvOM=H7dl=vblBwAv>}%1@{}mnpUznfq1cE^sgsL0*4I zJ##!*B?=vI_OEVis5o+_IwMIRrpQyT_Sq~ZU%oY7c5JMIADzpD!Upz9h@iWg_>>~j zOLS;wp^i$-E?4<_cp?RiS%Rd?i;f*mOz=~(&3lo<=@(nR!_Rqiprh@weZlL!t#NCc zO!QTcInq|%#>OVgobj{~ixEUec`E25zJ~*DofsQdzIa@5^nOXj2T;8O`l--(QyU^$t?TGY^7#&FQ+2SS3B#qK*k3`ye?8jUYSajE5iBbJls75CCc(m3dk{t?- zopcER9{Z?TC)mk~gpi^kbbu>b-+a{m#8-y2^p$ka4n60w;Sc2}HMf<8JUvhCL0B&Btk)T`ctE$*qNW8L$`7!r^9T+>=<=2qaq-;ll2{`{Rg zc5a0ZUI$oG&j-qVOuKa=*v4aY#IsoM+1|c4Z)<}lEDvy;5huB@1RJPquU2U*U-;gu z=En2m+qjBzR#DEJDO`WU)hdd{Vj%^0V*KoyZ|5lzV87&g_j~NCjwv0uQVqXOb*QrQ zy|Qn`hxx(58c70$E;L(X0uZZ72M1!6oeg)(cdKO ze0gDaTz+ohR-#d)NbAH4x{I(21yjwvBQfmpLu$)|m{XolbgF!pmsqJ#D}(ylp6uC> z{bqtcI#hT#HW=wl7>p!38sKsJ`r8}lt-q%Keqy%u(xk=yiIJiUw6|5IvkS+#?JTBl z8H5(Q?l#wzazujH!8o>1xtn8#_w+397*_cy8!pQGP%K(Ga3pAjsaTbbXJlQF_+m+-UpUUent@xM zg%jqLUExj~o^vQ3Gl*>wh=_gOr2*|U64_iXb+-111aH}$TjeajM+I20xw(((>fej-@CIz4S1pi$(#}P7`4({6QS2CaQS4NPENDp>sAqD z$bH4KGzXGffkJ7R>V>)>tC)uax{UsN*dbeNC*v}#8Y#OWYwL4t$ePR?VTyIs!wea+ z5Urmc)X|^`MG~*dS6pGSbU+gPJoq*^a=_>$n4|P^w$sMBBy@f*Z^Jg6?n5?oId6f{ z$LW4M|4m502z0t7g<#Bx%X;9<=)smFolV&(V^(7Cv2-sxbxopQ!)*#ZRhTBpx1)Fc zNm1T%bONzv6@#|dz(w02AH8OXe>kQ#1FMCzO}2J_mST)+ExmBr9cva-@?;wnmWMOk z{3_~EX_xadgJGv&H@zK_8{(x84`}+c?oSBX*Ge3VdfTt&F}yCpFP?CpW+BE^cWY0^ zb&uBN!Ja3UzYHK-CTyA5=L zEMW{l3Usky#ly=7px648W31UNV@K)&Ub&zP1c7%)`{);I4b0Q<)B}3;NMG2JH=X$U zfIW4)4n9ZM`-yRj67I)YSLDK)qfUJ_ij}a#aZN~9EXrh8eZY2&=uY%2N0UFF7<~%M zsB8=erOWZ>Ct_#^tHZ|*q`H;A)5;ycw*IcmVxi8_0Xk}aJA^ath+E;xg!x+As(M#0=)3!NJR6H&9+zd#iP(m0PIW8$ z1Y^VX`>jm`W!=WpF*{ioM?C9`yOR>@0q=u7o>BP-eSHqCgMDj!2anwH?s%i2p+Q7D zzszIf5XJpE)IG4;d_(La-xenmF(tgAxK`Y4sQ}BSJEPs6N_U2vI{8=0C_F?@7<(G; zo$~G=8p+076G;`}>{MQ>t>7cm=zGtfbdDXm6||jUU|?X?CaE?(<6bKDYKeHlz}DA8 zXT={X=yp_R;HfJ9h%?eWvQ!dRgz&Su*JfNt!Wu>|XfU&68iRikRrHRW|ZxzRR^`eIGt zIeiDgVS>IeExKVRWW8-=A=yA`}`)ZkWBrZD`hpWIxBGkh&f#ijr449~m`j6{4jiJ*C!oVA8ZC?$1RM#K(_b zL9TW)kN*Y4%^-qPpMP7d4)o?Nk#>aoYHT(*g)qmRUb?**F@pnNiy6Fv9rEiUqD(^O zzyS?nBrX63BTRYduaG(0VVG2yJRe%o&rVrLjbxTaAFTd8s;<<@Qs>u(<193R8>}2_ zuwp{7;H2a*X7_jryzriZXMg?bTuegABb^87@SsKkr2)0Gyiax8KQWstw^v#ix45EVrcEhr>!NMhprl$InQMzjSFH54x5k9qHc`@9uKQzvL4ihcq{^B zPrVR=o_ic%Y>6&rMN)hTZsI7I<3&`#(nl+3y3ys9A~&^=4?PL&nd8)`OfG#n zwAMN$1&>K++c{^|7<4P=2y(B{jJsQ0a#U;HTo4ZmWZYvI{+s;Td{Yzem%0*k#)vjpB zia;J&>}ICate44SFYY3vEelqStQWFihx%^vQ@Do(sOy7yR2@WNv7Y9I^yL=nZr3mb zXKV5t@=?-Sk|b{XMhA7ZGB@2hqsx}4xwCW!in#C zI@}scZlr3-NFJ@NFaJlhyfcw{k^vvtGl`N9xSo**rDW4S}i zM9{fMPWo%4wYDG~BZ18BD+}h|GQKc-g^{++3MY>}W_uq7jGHx{mwE9fZiPCoxN$+7 zrODGGJrOkcPQUB(FD5aoS4g~7#6NR^ma7-!>mHuJfY5kTe6PpNNKC9GGRiu^L31uG z$7v`*JknQHsYB!Tm_W{a32TM099djW%5e+j0Ve_ct}IM>XLF1Ap+YvcrLV=|CKo6S zb+9Nl3_YdKP6%Cxy@6TxZ>;4&nTneadr z_ES90ydCev)LV!dN=#(*f}|ZORFdvkYBni^aLbUk>BajeWIOcmHP#8S)*2U~QKI%S zyrLmtPqb&TphJ;>yAxri#;{uyk`JJqODDw%(Z=2`1uc}br^V%>j!gS)D*q*f_-qf8&D;W1dJgQMlaH5er zN2U<%Smb7==vE}dDI8K7cKz!vs^73o9f>2sgiTzWcwY|BMYHH5%Vn7#kiw&eItCqa zIkR2~Q}>X=Ar8W|^Ms41Fm8o6IB2_j60eOeBB1Br!boW7JnoeX6Gs)?7rW0^5psc- zjS16yb>dFn>KPOF;imD}e!enuIniFzv}n$m2#gCCv4jM#ArwlzZ$7@9&XkFxZ4n!V zj3dyiwW4Ki2QG{@i>yuZXQizw_OkZI^-3otXC{!(lUpJF33gI60ak;Uqitp74|B6I zgg{b=Iz}WkhCGj1M=hu4#Aw173YxIVbISaoc z-nLZC*6Tgivd5V`K%GxhBsp@SUU60-rfc$=wb>zdJzXS&-5(NRRodFk;Kxk!S(O(a0e7oY=E( zAyS;Ow?6Q&XA+cnkCb{28_1N8H#?J!*$MmIwLq^*T_9-z^&UE@A(z9oGYtFy6EZef LrJugUA?W`A8`#=m 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)`}
+ {/* Last Activity — most recent agent file write */} +
+ Last Activity + + {lastActivity > 0 + ? (() => { + const diff = Date.now() - lastActivity + const m = Math.floor(diff / 60000) + if (m < 1) return "just now" + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ${m % 60}m ago` + return `${Math.floor(h / 24)}d ago` + })() + : "—"} + +
+ {/* Restart Button */}
{/* Fallback Models */} diff --git a/dashboard/src/app/settings/page.tsx b/dashboard/src/app/settings/page.tsx index 3f1f7de..9301d3c 100644 --- a/dashboard/src/app/settings/page.tsx +++ b/dashboard/src/app/settings/page.tsx @@ -1,276 +1,472 @@ "use client" +/** + * settings/page.tsx — Tiger configuration + * + * Sections: + * 1. Model — primary model dropdown + fallback models + * 2. Session — dmScope, compaction mode + * 3. Telegram — enabled toggle, streaming mode + * 4. Commands — native commands, ownerDisplay + */ import * as React from "react" -import { Settings2, Save, Loader2, RefreshCw, Eye, EyeOff } from "lucide-react" +import useSWR from "swr" +import { + Settings2, Save, Loader2, RefreshCw, + Bot, MessageSquare, Terminal, Cpu, AlertCircle, Check, +} from "lucide-react" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { useBridgeRequest } from "@/hooks/use-bridge" import { cn } from "@/lib/utils" -type ConfigValue = string | number | boolean | null | ConfigValue[] | { [key: string]: ConfigValue } +// ─── Types ──────────────────────────────────────────────────────────────────── -interface ConfigField { - path: string - label: string - type: "text" | "number" | "boolean" | "password" - value: ConfigValue - original: ConfigValue +interface ModelInfo { + id: string + name: string + provider: string + reasoning: boolean + contextWindow: number + cost?: { input: number; output: number } } -// Tiger config sections - matches the structure from /tiger/config -const CONFIG_SECTIONS = [ - { - key: "agent", - label: "Agent", - description: "AI agent model configuration", - paths: [ - { path: "model", label: "Primary Model", type: "text" }, - { path: "fallbackModels", label: "Fallback Models", type: "text" }, - ], - }, - { - key: "execution", - label: "Execution", - description: "Command execution settings", - paths: [ - { path: "maxDuration", label: "Max Duration (seconds)", type: "number" }, - { path: "maxRetries", label: "Max Retries", type: "number" }, - ], - }, -] as const - -interface ConfigSection { - key: string - label: string - description: string - fields: ConfigField[] +interface OpenClawConfig { + agents?: { defaults?: { model?: { primary?: string; fallbacks?: string[] }; compaction?: { mode?: string } } } + session?: { dmScope?: string } + channels?: { telegram?: { enabled?: boolean; streaming?: string } } + commands?: { native?: string; ownerDisplay?: string; restart?: boolean } } -export default function SettingsPage() { - const { request } = useBridgeRequest() - const [sections, setSections] = React.useState([]) - const [loading, setLoading] = React.useState(true) - const [saving, setSaving] = React.useState(false) - const [saved, setSaved] = React.useState(false) - const [error, setError] = React.useState(null) - const [showPasswords, setShowPasswords] = React.useState>({}) +// ─── Helpers ────────────────────────────────────────────────────────────────── - // Load config from Tiger Bridge - const loadConfig = React.useCallback(async () => { - setLoading(true) - setError(null) - try { - const data = await request("/api/tiger/config") as Record +const fetcher = (url: string) => fetch(url).then(r => r.json()) - 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 ?? "", - } - }), - })) +function get(obj: any, path: string, fallback: any = ""): any { + return path.split(".").reduce((o, k) => (o != null ? o[k] : undefined), obj) ?? fallback +} - setSections(loadedSections) - } catch { - setError("Failed to load configuration. Is the Tiger Bridge running?") - } finally { - setLoading(false) - } - }, [request]) - - React.useEffect(() => { - loadConfig() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - const handleFieldChange = (sectionKey: string, fieldPath: string, newValue: ConfigValue) => { - setSections(prev => - prev.map(s => - s.key === sectionKey - ? { - ...s, - fields: s.fields.map(f => - f.path === fieldPath ? { ...f, value: newValue } : f - ), - } - : s - ) - ) +function set(obj: any, path: string, value: any): any { + const keys = path.split(".") + const result = JSON.parse(JSON.stringify(obj)) + let cur = result + for (let i = 0; i < keys.length - 1; i++) { + if (cur[keys[i]] == null) cur[keys[i]] = {} + cur = cur[keys[i]] } + cur[keys[keys.length - 1]] = value + return result +} - const handleSave = async () => { - setSaving(true) - setError(null) - setSaved(false) +// ─── Sub-components ─────────────────────────────────────────────────────────── - try { - // Build patch object from all changed fields - const patch: Record = {} - - for (const section of sections) { - for (const field of section.fields) { - if (JSON.stringify(field.value) !== JSON.stringify(field.original)) { - let val = field.value - if (field.type === "number") val = Number(val) - if (field.type === "boolean") val = val === true || val === "true" - patch[field.path] = val - } - } - } - - if (Object.keys(patch).length > 0) { - await request("/api/tiger/config", "POST", { patch }) - - // Update originals - setSections(prev => - prev.map(s => ({ - ...s, - fields: s.fields.map(f => ({ ...f, original: f.value })), - })) - ) - setSaved(true) - setTimeout(() => setSaved(false), 2000) - } - } catch { - setError("Failed to save configuration.") - } finally { - setSaving(false) - } - } - - const hasChanges = sections.some(s => - s.fields.some(f => JSON.stringify(f.value) !== JSON.stringify(f.original)) +function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+
+
{label}
+ {hint &&
{hint}
} +
+
{children}
+
) +} + +function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { + return ( + + ) +} + +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