fix(infra): rebrand, workspace path, model config, chat SSE streaming
- 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.
This commit is contained in:
parent
d4a3f2b869
commit
8fe6694a21
6 changed files with 271 additions and 213 deletions
17
.gitignore
vendored
17
.gitignore
vendored
|
|
@ -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
|
||||
._*
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
/**
|
||||
* tiger.ts — Core executor for Tiger agent inside Docker→k3s→sandbox
|
||||
* tiger.ts — Core executor for Tiger agent inside Docker container
|
||||
*
|
||||
* 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 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 <container> kubectl exec -n <ns> <pod> -- <cmd>
|
||||
* 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<any>(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<Record<string, any>> {
|
||||
|
|
@ -194,7 +214,7 @@ export async function updateConfig(patch: Record<string, any>): Promise<void> {
|
|||
|
||||
// 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");
|
||||
|
|
|
|||
|
|
@ -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,6 +41,7 @@ export default function RootLayout({
|
|||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
<ChatProvider>
|
||||
<SidebarProvider>
|
||||
<TooltipProvider>
|
||||
<AppSidebar />
|
||||
|
|
@ -47,7 +49,7 @@ export default function RootLayout({
|
|||
<div className="p-4 flex items-center justify-between border-b border-border bg-sidebar/50 backdrop-blur-sm sticky top-0 z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger />
|
||||
<h1 className="text-sm font-medium text-muted-foreground">Tarzan's Dashboard</h1>
|
||||
<h1 className="text-sm font-medium text-muted-foreground">Tiger Dashboard</h1>
|
||||
</div>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
|
@ -58,6 +60,7 @@ export default function RootLayout({
|
|||
</main>
|
||||
</TooltipProvider>
|
||||
</SidebarProvider>
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Models (from providers config) */}
|
||||
{status?.agent?.availableModels && status.agent.availableModels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Available Models</div>
|
||||
<div className="space-y-1">
|
||||
{status.agent.availableModels.map((model, i) => (
|
||||
<div key={i} className="p-2 rounded-md border border-border/50 bg-background/30 text-xs font-mono text-muted-foreground">
|
||||
{model}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heartbeat */}
|
||||
{status?.agent?.heartbeat && (
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-xs">
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>
|
||||
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<typeof Card>) {
|
||||
const [input, setInput] = React.useState("")
|
||||
const [messages, setMessages] = React.useState<Message[]>([])
|
||||
// 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<HTMLDivElement>(null)
|
||||
const { request } = useGatewayRequest()
|
||||
const streamingContentRef = React.useRef("")
|
||||
const abortRef = React.useRef<AbortController | null>(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<string, unknown>
|
||||
|
||||
if (event === "chat") {
|
||||
// Incoming chat message (from other channels or agent completion)
|
||||
const role = (data.role as string) === "user" ? "user" : "agent"
|
||||
const content = extractContent(data.text || data.content || "")
|
||||
if (!content) return
|
||||
|
||||
setMessages(prev => {
|
||||
// Remove any streaming message and add final
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
return [...filtered, {
|
||||
id: `chat-${Date.now()}`,
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
setAgentTyping(false)
|
||||
streamingContentRef.current = ""
|
||||
}
|
||||
|
||||
if (event === "agent") {
|
||||
// Streaming agent response chunks
|
||||
const chunk = data.chunk as string | undefined
|
||||
const done = data.done as boolean | undefined
|
||||
const text = data.text as string | undefined
|
||||
|
||||
if (chunk || text) {
|
||||
streamingContentRef.current += (chunk || text || "")
|
||||
setAgentTyping(true)
|
||||
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
return [...filtered, {
|
||||
id: "streaming",
|
||||
role: "agent",
|
||||
content: streamingContentRef.current,
|
||||
streaming: true,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
if (done) {
|
||||
setAgentTyping(false)
|
||||
setSending(false)
|
||||
// Finalize streaming message
|
||||
if (streamingContentRef.current) {
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
return [...filtered, {
|
||||
id: `agent-${Date.now()}`,
|
||||
role: "agent",
|
||||
content: streamingContentRef.current,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
}
|
||||
streamingContentRef.current = ""
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-scroll to bottom
|
||||
React.useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
|
|
@ -161,9 +33,9 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
const text = input.trim()
|
||||
setInput("")
|
||||
setSending(true)
|
||||
streamingContentRef.current = ""
|
||||
streamingRef.current = ""
|
||||
|
||||
// Add user message immediately
|
||||
// Add user message
|
||||
setMessages(prev => [...prev, {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
|
|
@ -172,44 +44,165 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
}])
|
||||
|
||||
try {
|
||||
await request("chat.send", {
|
||||
sessionKey: "agent:main:main",
|
||||
message: text,
|
||||
idempotencyKey: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
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,
|
||||
})
|
||||
// Response will come via SSE events (agent/chat events)
|
||||
} catch {
|
||||
|
||||
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)
|
||||
setMessages(prev => [...prev, {
|
||||
} else if (data.type === "error") {
|
||||
setMessages(prev => [...prev.filter(m => !m.streaming), {
|
||||
id: `err-${Date.now()}`,
|
||||
role: "system",
|
||||
content: "Failed to send message. Is the gateway running?",
|
||||
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)
|
||||
}
|
||||
|
||||
const handleAbort = async () => {
|
||||
try {
|
||||
await request("chat.abort", { sessionKey: "agent:main:main" })
|
||||
} catch {
|
||||
// ignore
|
||||
abortRef.current = null
|
||||
}
|
||||
|
||||
const handleAbort = () => {
|
||||
abortRef.current?.abort()
|
||||
setSending(false)
|
||||
setAgentTyping(false)
|
||||
streamingRef.current = ""
|
||||
setMessages(prev => prev.filter(m => !m.streaming))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("w-full flex flex-col", className)} {...props}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bot className="h-5 w-5" />
|
||||
Chat
|
||||
<span className={cn(
|
||||
"ml-auto h-2 w-2 rounded-full",
|
||||
connected ? "bg-green-500" : "bg-red-500"
|
||||
)} />
|
||||
Chat with Tiger
|
||||
</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearChat}
|
||||
className="text-xs text-muted-foreground h-7"
|
||||
title="Clear conversation"
|
||||
>
|
||||
<Eraser className="h-3 w-3 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-0 min-h-0">
|
||||
<ScrollArea className="h-[500px] px-4" ref={scrollRef}>
|
||||
|
|
@ -235,8 +228,7 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
<div className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
|
|
@ -244,13 +236,19 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
? "bg-destructive/10 text-destructive flex items-center gap-2 w-full justify-center"
|
||||
: "bg-muted",
|
||||
message.streaming ? "border border-primary/30" : ""
|
||||
)}
|
||||
>
|
||||
)}>
|
||||
{message.role === "system" && <AlertCircle className="h-3 w-3" />}
|
||||
{message.role === "agent" ? (
|
||||
// While streaming: render raw text (cheap, one DOM node update per token).
|
||||
// After streaming completes: render full ReactMarkdown (expensive but
|
||||
// only happens once). This is what makes the typing feel actually show up.
|
||||
message.streaming ? (
|
||||
<div className="whitespace-pre-wrap">{message.content}</div>
|
||||
) : (
|
||||
<div className="prose prose-sm prose-invert max-w-none [&>p]:m-0">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
message.content
|
||||
)}
|
||||
|
|
@ -260,7 +258,7 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
</div>
|
||||
</div>
|
||||
))}
|
||||
{agentTyping && !messages.some(m => m.streaming) && (
|
||||
{sending && !messages.some(m => m.streaming) && (
|
||||
<div className="flex gap-2">
|
||||
<div className="h-7 w-7 rounded-full bg-muted flex items-center justify-center">
|
||||
<Bot className="h-4 w-4" />
|
||||
|
|
@ -276,19 +274,19 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
|||
<CardFooter className="pt-3">
|
||||
<form onSubmit={handleSubmit} className="flex w-full items-center space-x-2">
|
||||
<Input
|
||||
placeholder={connected ? "Message Tarzan..." : "Gateway offline..."}
|
||||
placeholder="Message Tiger..."
|
||||
className="flex-1"
|
||||
autoComplete="off"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
disabled={!connected || sending}
|
||||
disabled={sending}
|
||||
/>
|
||||
{sending ? (
|
||||
<Button type="button" size="icon" variant="destructive" onClick={handleAbort}>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" size="icon" disabled={!input.trim() || !connected}>
|
||||
<Button type="submit" size="icon" disabled={!input.trim()}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue