From 8fe6694a210f6ae539f7a266cdfb303604731fe4 Mon Sep 17 00:00:00 2001 From: Manohar Gupta Date: Sat, 18 Apr 2026 19:10:47 +0000 Subject: [PATCH] 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 ? ( ) : ( - )}