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/*.example.json
|
||||||
config/mcporter.json
|
config/mcporter.json
|
||||||
config/cron.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 ─────────────────────────────────────────────────────────
|
// ─── Configuration ─────────────────────────────────────────────────────────
|
||||||
const PORT = parseInt(process.env.TIGER_BRIDGE_PORT || "3456", 10);
|
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();
|
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/projects", projectsRouter);
|
||||||
app.use("/tiger/tasks", tasksRouter);
|
app.use("/tiger/tasks", tasksRouter);
|
||||||
app.use("/tiger/dispatch", dispatchRouter);
|
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 ─────────────────────────────────────────────────────────
|
// ─── 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:
|
* Tiger runs directly in tiger-openclaw container (no more k3s layers).
|
||||||
* Host → Docker (openshell-cluster-nemoclaw) → k3s (kubectl exec) → sandbox pod (tiger)
|
* Commands are executed via docker exec inside the container.
|
||||||
*
|
|
||||||
* This module wraps that complexity into clean async functions.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { exec, execFile, spawn } from "child_process";
|
import { exec, execFile, spawn } from "child_process";
|
||||||
|
|
@ -15,30 +13,28 @@ import { createHash } from "crypto";
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
// ─── Configuration ───────────────────────────────────────────────
|
// ─── Configuration ───────────────────────────────────────────────
|
||||||
// These match your known paths from the Tiger setup
|
// Tiger runs directly in the tiger-openclaw container
|
||||||
const DOCKER_CONTAINER = "openshell-cluster-nemoclaw";
|
const DOCKER_CONTAINER = "tiger-openclaw";
|
||||||
const K8S_NAMESPACE = "openshell";
|
const K8S_NAMESPACE = "openshell";
|
||||||
const POD_NAME = "tiger";
|
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 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";
|
const GATEWAY_WATCHDOG = "/root/gateway-watchdog.sh";
|
||||||
|
|
||||||
// Timeout for commands (30s default, some ops need longer)
|
// Timeout for commands (30s default, some ops need longer)
|
||||||
const DEFAULT_TIMEOUT = 30_000;
|
const DEFAULT_TIMEOUT = 30_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute a command inside the Tiger sandbox pod.
|
* Execute a command inside the Tiger container.
|
||||||
* This is the fundamental operation — everything else builds on it.
|
* Commands run directly via docker exec (no kubectl needed).
|
||||||
*
|
|
||||||
* The full command chain:
|
|
||||||
* docker exec <container> kubectl exec -n <ns> <pod> -- <cmd>
|
|
||||||
*/
|
*/
|
||||||
export async function execInSandbox(
|
export async function execInSandbox(
|
||||||
command: string,
|
command: string,
|
||||||
timeoutMs = DEFAULT_TIMEOUT
|
timeoutMs = DEFAULT_TIMEOUT
|
||||||
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
): 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 {
|
try {
|
||||||
const { stdout, stderr } = await execAsync(fullCmd, {
|
const { stdout, stderr } = await execAsync(fullCmd, {
|
||||||
|
|
@ -97,10 +93,10 @@ export async function getTigerStatus() {
|
||||||
execInSandbox("cat /proc/meminfo | head -5 && echo '---' && uptime"),
|
execInSandbox("cat /proc/meminfo | head -5 && echo '---' && uptime"),
|
||||||
|
|
||||||
// 4. Last heartbeat content
|
// 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
|
// 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
|
// 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 currentModel = "unknown";
|
||||||
let fallbackModels: string[] = [];
|
let fallbackModels: string[] = [];
|
||||||
|
let availableModels: string[] = [];
|
||||||
try {
|
try {
|
||||||
const configRaw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
|
const configRaw = await readFile(OPENCLAW_CONFIG_HOST, "utf-8");
|
||||||
const config = JSON.parse(configRaw);
|
const config = JSON.parse(configRaw);
|
||||||
// Navigate the OpenClaw config structure for model info
|
|
||||||
currentModel = config?.model?.primary || config?.model || "unknown";
|
const agentDefaults = config?.agents?.defaults?.model;
|
||||||
fallbackModels = config?.model?.fallbacks || [];
|
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 */ }
|
} catch { /* config not readable */ }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -163,6 +182,7 @@ export async function getTigerStatus() {
|
||||||
agent: {
|
agent: {
|
||||||
currentModel,
|
currentModel,
|
||||||
fallbackModels,
|
fallbackModels,
|
||||||
|
availableModels,
|
||||||
heartbeat: heartbeat.status === "fulfilled" ? heartbeat.value.stdout : null,
|
heartbeat: heartbeat.status === "fulfilled" ? heartbeat.value.stdout : null,
|
||||||
soul: soulMd.status === "fulfilled" ? soulMd.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.
|
* 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
|
* gets mounted into the sandbox at /sandbox/.openclaw/openclaw.json
|
||||||
*/
|
*/
|
||||||
export async function getConfig(): Promise<Record<string, any>> {
|
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
|
// 3. Backup current config before writing
|
||||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
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
|
// 4. Write updated config
|
||||||
await writeFile(OPENCLAW_CONFIG_HOST, configStr, "utf-8");
|
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 { ModeToggle } from "@/components/mode-toggle"
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { Agentation } from 'agentation';
|
import { Agentation } from 'agentation';
|
||||||
|
import { ChatProvider } from "@/contexts/chat-context";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
|
|
@ -19,8 +20,8 @@ const geistMono = Geist_Mono({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Command Center",
|
title: "Tiger Command Center",
|
||||||
description: "Tarzan's Dashboard for Agent Management",
|
description: "Tiger Agent Management Dashboard",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|
@ -40,14 +41,15 @@ export default function RootLayout({
|
||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<SidebarProvider>
|
<ChatProvider>
|
||||||
|
<SidebarProvider>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main className="w-full bg-background text-foreground relative">
|
<main className="w-full bg-background text-foreground relative">
|
||||||
<div className="p-4 flex items-center justify-between border-b border-border bg-sidebar/50 backdrop-blur-sm sticky top-0 z-10">
|
<div className="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">
|
<div className="flex items-center gap-2">
|
||||||
<SidebarTrigger />
|
<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>
|
</div>
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -57,7 +59,8 @@ export default function RootLayout({
|
||||||
<Agentation />
|
<Agentation />
|
||||||
</main>
|
</main>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
|
</ChatProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ interface TigerStatus {
|
||||||
agent: {
|
agent: {
|
||||||
currentModel: string
|
currentModel: string
|
||||||
fallbackModels: string[]
|
fallbackModels: string[]
|
||||||
|
availableModels?: string[]
|
||||||
heartbeat: string | null
|
heartbeat: string | null
|
||||||
soul: string | null
|
soul: string | null
|
||||||
}
|
}
|
||||||
|
|
@ -331,6 +332,20 @@ export default function DashboardPage() {
|
||||||
</div>
|
</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 */}
|
{/* Heartbeat */}
|
||||||
{status?.agent?.heartbeat && (
|
{status?.agent?.heartbeat && (
|
||||||
<div className="p-3 rounded-md border border-border bg-background/50 text-xs">
|
<div className="p-3 rounded-md border border-border bg-background/50 text-xs">
|
||||||
|
|
|
||||||
|
|
@ -1,153 +1,25 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
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 ReactMarkdown from "react-markdown"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import {
|
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card"
|
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { useGatewayRequest, useGatewayEvents } from "@/hooks/use-gateway"
|
import { useChatContext } from "@/contexts/chat-context"
|
||||||
|
|
||||||
type Message = {
|
|
||||||
id: string
|
|
||||||
role: "user" | "agent" | "system"
|
|
||||||
content: string
|
|
||||||
streaming?: boolean
|
|
||||||
timestamp: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractContent(content: unknown): string {
|
|
||||||
if (typeof content === "string") return content
|
|
||||||
if (Array.isArray(content)) {
|
|
||||||
return content
|
|
||||||
.map((block: unknown) => {
|
|
||||||
if (typeof block === "string") return block
|
|
||||||
if (block && typeof block === "object" && "text" in block) return String((block as { text: string }).text)
|
|
||||||
return ""
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n")
|
|
||||||
}
|
|
||||||
if (content && typeof content === "object") {
|
|
||||||
const obj = content as Record<string, unknown>
|
|
||||||
if ("text" in obj) return String(obj.text)
|
|
||||||
if ("content" in obj) return extractContent(obj.content)
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatInterface({ className, ...props }: React.ComponentProps<typeof Card>) {
|
export function ChatInterface({ className, ...props }: React.ComponentProps<typeof Card>) {
|
||||||
const [input, setInput] = React.useState("")
|
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 [sending, setSending] = React.useState(false)
|
||||||
const [agentTyping, setAgentTyping] = React.useState(false)
|
|
||||||
const scrollRef = React.useRef<HTMLDivElement>(null)
|
const scrollRef = React.useRef<HTMLDivElement>(null)
|
||||||
const { request } = useGatewayRequest()
|
const abortRef = React.useRef<AbortController | null>(null)
|
||||||
const streamingContentRef = React.useRef("")
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||||
|
|
@ -161,9 +33,9 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
||||||
const text = input.trim()
|
const text = input.trim()
|
||||||
setInput("")
|
setInput("")
|
||||||
setSending(true)
|
setSending(true)
|
||||||
streamingContentRef.current = ""
|
streamingRef.current = ""
|
||||||
|
|
||||||
// Add user message immediately
|
// Add user message
|
||||||
setMessages(prev => [...prev, {
|
setMessages(prev => [...prev, {
|
||||||
id: `user-${Date.now()}`,
|
id: `user-${Date.now()}`,
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|
@ -172,44 +44,165 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
||||||
}])
|
}])
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await request("chat.send", {
|
const controller = new AbortController()
|
||||||
sessionKey: "agent:main:main",
|
abortRef.current = controller
|
||||||
message: text,
|
|
||||||
idempotencyKey: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
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)
|
||||||
|
} 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)
|
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 () => {
|
const handleAbort = () => {
|
||||||
try {
|
abortRef.current?.abort()
|
||||||
await request("chat.abort", { sessionKey: "agent:main:main" })
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
setSending(false)
|
setSending(false)
|
||||||
setAgentTyping(false)
|
streamingRef.current = ""
|
||||||
|
setMessages(prev => prev.filter(m => !m.streaming))
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn("w-full flex flex-col", className)} {...props}>
|
<Card className={cn("w-full flex flex-col", className)} {...props}>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<div className="flex items-center justify-between">
|
||||||
<Bot className="h-5 w-5" />
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
Chat
|
<Bot className="h-5 w-5" />
|
||||||
<span className={cn(
|
Chat with Tiger
|
||||||
"ml-auto h-2 w-2 rounded-full",
|
</CardTitle>
|
||||||
connected ? "bg-green-500" : "bg-red-500"
|
<Button
|
||||||
)} />
|
type="button"
|
||||||
</CardTitle>
|
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>
|
</CardHeader>
|
||||||
<CardContent className="flex-1 p-0 min-h-0">
|
<CardContent className="flex-1 p-0 min-h-0">
|
||||||
<ScrollArea className="h-[500px] px-4" ref={scrollRef}>
|
<ScrollArea className="h-[500px] px-4" ref={scrollRef}>
|
||||||
|
|
@ -235,22 +228,27 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className={cn(
|
||||||
className={cn(
|
"rounded-lg px-3 py-2 text-sm",
|
||||||
"rounded-lg px-3 py-2 text-sm",
|
message.role === "user"
|
||||||
message.role === "user"
|
? "bg-primary text-primary-foreground"
|
||||||
? "bg-primary text-primary-foreground"
|
: message.role === "system"
|
||||||
: message.role === "system"
|
? "bg-destructive/10 text-destructive flex items-center gap-2 w-full justify-center"
|
||||||
? "bg-destructive/10 text-destructive flex items-center gap-2 w-full justify-center"
|
: "bg-muted",
|
||||||
: "bg-muted",
|
message.streaming ? "border border-primary/30" : ""
|
||||||
message.streaming ? "border border-primary/30" : ""
|
)}>
|
||||||
)}
|
|
||||||
>
|
|
||||||
{message.role === "system" && <AlertCircle className="h-3 w-3" />}
|
{message.role === "system" && <AlertCircle className="h-3 w-3" />}
|
||||||
{message.role === "agent" ? (
|
{message.role === "agent" ? (
|
||||||
<div className="prose prose-sm prose-invert max-w-none [&>p]:m-0">
|
// While streaming: render raw text (cheap, one DOM node update per token).
|
||||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
// After streaming completes: render full ReactMarkdown (expensive but
|
||||||
</div>
|
// 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
|
message.content
|
||||||
)}
|
)}
|
||||||
|
|
@ -260,7 +258,7 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{agentTyping && !messages.some(m => m.streaming) && (
|
{sending && !messages.some(m => m.streaming) && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="h-7 w-7 rounded-full bg-muted flex items-center justify-center">
|
<div className="h-7 w-7 rounded-full bg-muted flex items-center justify-center">
|
||||||
<Bot className="h-4 w-4" />
|
<Bot className="h-4 w-4" />
|
||||||
|
|
@ -276,19 +274,19 @@ export function ChatInterface({ className, ...props }: React.ComponentProps<type
|
||||||
<CardFooter className="pt-3">
|
<CardFooter className="pt-3">
|
||||||
<form onSubmit={handleSubmit} className="flex w-full items-center space-x-2">
|
<form onSubmit={handleSubmit} className="flex w-full items-center space-x-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder={connected ? "Message Tarzan..." : "Gateway offline..."}
|
placeholder="Message Tiger..."
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
disabled={!connected || sending}
|
disabled={sending}
|
||||||
/>
|
/>
|
||||||
{sending ? (
|
{sending ? (
|
||||||
<Button type="button" size="icon" variant="destructive" onClick={handleAbort}>
|
<Button type="button" size="icon" variant="destructive" onClick={handleAbort}>
|
||||||
<Square className="h-4 w-4" />
|
<Square className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button type="submit" size="icon" disabled={!input.trim() || !connected}>
|
<Button type="submit" size="icon" disabled={!input.trim()}>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue