diff --git a/raggr-frontend/src/api/artifactTypes.ts b/raggr-frontend/src/api/artifactTypes.ts new file mode 100644 index 0000000..b3db843 --- /dev/null +++ b/raggr-frontend/src/api/artifactTypes.ts @@ -0,0 +1,37 @@ +// Artifact types shared by the activity timeline and the artifact panel. +// Shapes follow the contract in docs/cowork-ui-plan.md. + +export type ArtifactKind = + | "mealie.recipe" + | "mealie.meal_plan" + | "mealie.shopping_lists" + | "obsidian.note" + | "search.documents" + | "ynab.summary" + | "ynab.transactions" + | "ynab.spending_report" + | "ynab.category_budget"; + +/** Lightweight reference carried by `tool_end` events and tool messages. */ +export type ArtifactRef = { + id: string; + kind: ArtifactKind; + title: string; +}; + +export type ArtifactSourceRef = { + system: "mealie" | "obsidian" | "paperless" | "ynab" | "web"; + id?: string; + path?: string; + url?: string; +}; + +/** Full artifact as returned by `GET /api/artifacts/`. */ +export type Artifact = ArtifactRef & { + data: object; + source_ref: ArtifactSourceRef | null; + tool: string; + conversation_id: string; + message_id: string | null; + created_at: string; +}; diff --git a/raggr-frontend/src/api/conversationService.ts b/raggr-frontend/src/api/conversationService.ts index 66bbc98..60b4ca6 100644 --- a/raggr-frontend/src/api/conversationService.ts +++ b/raggr-frontend/src/api/conversationService.ts @@ -1,10 +1,29 @@ import { userService } from "./userService"; +import type { ArtifactRef } from "./artifactTypes"; + +export type ToolInput = Record; export type SSEEvent = | { type: "thinking" } | { type: "thinking_delta"; delta: string } - | { type: "tool_start"; tool: string } - | { type: "tool_end"; tool: string } + // v2 servers send run_id/label/input and run_id/label/status/ms/artifact; + // older servers send only `tool`, so everything else is optional here. + | { + type: "tool_start"; + tool: string; + run_id?: string; + label?: string; + input?: ToolInput; + } + | { + type: "tool_end"; + tool: string; + run_id?: string; + label?: string; + status?: "ok" | "error"; + ms?: number; + artifact?: ArtifactRef | null; + } | { type: "content"; delta: string } | { type: "response"; message: string } | { type: "error"; message: string; stalled?: boolean }; @@ -17,6 +36,10 @@ interface Message { speaker: "user" | "simba" | "tool"; created_at: string; image_key?: string | null; + // Tool messages only (v2 servers). + tool?: string; + label?: string; + artifact?: ArtifactRef | null; } interface Conversation { diff --git a/raggr-frontend/src/components/ActivityTimeline.tsx b/raggr-frontend/src/components/ActivityTimeline.tsx new file mode 100644 index 0000000..1734fa6 --- /dev/null +++ b/raggr-frontend/src/components/ActivityTimeline.tsx @@ -0,0 +1,242 @@ +import React, { useEffect, useId, useState } from "react"; +import { + BookOpen, + CalendarDays, + Cat, + Check, + ChefHat, + ChevronDown, + Clock, + Globe, + Library, + LoaderCircle, + Mail, + NotebookPen, + Package, + PanelRightOpen, + PawPrint, + Utensils, + Wallet, + Wrench, + X, + type LucideIcon, +} from "lucide-react"; +import { cn } from "../lib/utils"; +import type { ArtifactRef } from "../api/artifactTypes"; +import type { ToolInput } from "../api/conversationService"; +import type { ToolMessage } from "../hooks/useChat"; + +// Checked in order, so longer prefixes (simbakit_) come before shorter ones. +const TOOL_ICONS: [prefix: string, icon: LucideIcon][] = [ + ["mealie", ChefHat], + ["ynab", Wallet], + ["obsidian", NotebookPen], + ["journal", BookOpen], + ["simbakit", PawPrint], + ["simba_search", Cat], + ["web_search", Globe], + ["homebox", Package], + ["bookstack", Library], + ["gmail", Mail], + ["beli", Utensils], + ["get_current_date", Clock], +]; + +// Calendar tools are verb-first (get_calendar_events, create_calendar_event), +// so they match anywhere in the name rather than by prefix. +const iconFor = (tool: string): LucideIcon => + tool.includes("calendar") + ? CalendarDays + : (TOOL_ICONS.find(([prefix]) => tool.startsWith(prefix))?.[1] ?? Wrench); + +const MAX_VALUE_CHARS = 40; + +/** `month: 2026-10 · group_by: category`, with long values clipped. */ +export const summariseInput = (input?: ToolInput): string => + Object.entries(input ?? {}) + .filter(([, value]) => value !== "" && value !== null && value !== undefined) + .map(([key, value]) => { + const text = String(value).replace(/\s+/g, " "); + return `${key}: ${text.length > MAX_VALUE_CHARS ? `${text.slice(0, MAX_VALUE_CHARS)}…` : text}`; + }) + .join(" · "); + +export const formatDuration = (ms: number): string => + ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`; + +/** + * Wall-clock span of the block when the client timed it (parallel calls + * overlap, so summing would overcount); otherwise the sum of server + * durations. Restored conversations have neither. + */ +const totalDuration = (rows: ToolMessage[]): number | null => { + const starts = rows.map((r) => r.startedAt); + const ends = rows.map((r) => r.endedAt); + if (starts.every((t) => t != null) && ends.every((t) => t != null)) { + return Math.max(...(ends as number[])) - Math.min(...(starts as number[])); + } + const timed = rows.filter((r) => r.ms != null); + return timed.length ? timed.reduce((sum, r) => sum + (r.ms ?? 0), 0) : null; +}; + +type ActivityTimelineProps = { + rows: ToolMessage[]; + /** True while this block belongs to the in-flight turn. */ + active: boolean; + onOpenArtifact: (ref: ArtifactRef) => void; +}; + +export const ActivityTimeline = React.memo( + ({ rows, active, onOpenArtifact }: ActivityTimelineProps) => { + const working = active || rows.some((r) => r.status === "running"); + // null = follow the turn (open while working, collapsed when done). + const [userExpanded, setUserExpanded] = useState(null); + const listId = useId(); + + // Collapse once the turn finishes, even if the user toggled mid-run. + useEffect(() => { + if (!working) setUserExpanded(null); + }, [working]); + + const expanded = userExpanded ?? working; + const failed = rows.filter((r) => r.status === "error").length; + const total = totalDuration(rows); + + const summary = [ + "Simba worked", + `${rows.length} ${rows.length === 1 ? "step" : "steps"}`, + total != null ? formatDuration(total) : null, + failed ? `${failed} failed` : null, + ] + .filter(Boolean) + .join(" · "); + + return ( +
+
+ + + {expanded && ( +
    + {rows.map((row) => ( + + ))} +
+ )} +
+
+ ); + }, +); + +const TimelineRow = ({ + row, + onOpenArtifact, +}: { + row: ToolMessage; + onOpenArtifact: (ref: ArtifactRef) => void; +}) => { + const Icon = iconFor(row.tool); + const inputSummary = summariseInput(row.input); + const artifact = row.artifact; + + return ( +
  • + + + + +
    +
    {row.label}
    + {inputSummary && ( +
    + {inputSummary} +
    + )} +
    + + {artifact && ( + + )} + + + {row.ms != null && row.status !== "running" && ( + {formatDuration(row.ms)} + )} + {row.status === "running" ? ( + + ) : row.status === "error" ? ( + + ) : ( + + )} + +
  • + ); +}; diff --git a/raggr-frontend/src/components/ChatScreen.tsx b/raggr-frontend/src/components/ChatScreen.tsx index dbc95d9..9a833e6 100644 --- a/raggr-frontend/src/components/ChatScreen.tsx +++ b/raggr-frontend/src/components/ChatScreen.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { PanelLeftOpen, Menu, X } from "lucide-react"; import { QuestionBubble } from "./QuestionBubble"; import { AnswerBubble } from "./AnswerBubble"; -import { ToolBubble } from "./ToolBubble"; +import { ActivityTimeline } from "./ActivityTimeline"; import { ThinkingBubble } from "./ThinkingBubble"; import { MessageInput } from "./MessageInput"; import { SidebarContent } from "./SidebarContent"; @@ -11,7 +11,8 @@ import { AdminPanel } from "./AdminPanel"; import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel"; import { cn } from "../lib/utils"; import { useConversations } from "../hooks/useConversations"; -import { useChat } from "../hooks/useChat"; +import { useChat, type ToolMessage } from "../hooks/useChat"; +import type { ArtifactRef } from "../api/artifactTypes"; import catIcon from "../assets/cat.png"; type ChatScreenProps = { @@ -73,6 +74,9 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { scrollToBottom, }); + // TODO(worker D): open the artifact in the side panel. + const handleOpenArtifact = useCallback((_ref: ArtifactRef) => {}, []); + // Keep ref in sync for scrollToBottom behavior isLoadingRef.current = isLoading; @@ -248,6 +252,29 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
    {messages.map((msg, index) => { + if (msg.speaker === "tool") { + // Consecutive tool calls render as one timeline block, + // emitted at the first row of the run. + if (index > 0 && messages[index - 1].speaker === "tool") { + return null; + } + let end = index; + while (end < messages.length && messages[end].speaker === "tool") { + end++; + } + const rows = messages.slice(index, end) as ToolMessage[]; + return ( + + ); + } + // When the model has been thinking and content is now streaming, // render ThinkingBubble just before the streaming simba message // so it appears above the answer rather than below it. @@ -259,9 +286,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { msg.speaker === "simba"; const bubble = - msg.speaker === "tool" ? ( - - ) : msg.speaker === "simba" ? ( + msg.speaker === "simba" ? ( ) : ( @@ -291,7 +316,12 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => { /> )} - {((isLoading && !streaming && !thinking) || messagesLoading) && ( + {/* Placeholder dots, unless the activity timeline already shows progress */} + {((isLoading && + !streaming && + !thinking && + messages[messages.length - 1]?.speaker !== "tool") || + messagesLoading) && ( )} diff --git a/raggr-frontend/src/components/ToolBubble.tsx b/raggr-frontend/src/components/ToolBubble.tsx deleted file mode 100644 index 213ef47..0000000 --- a/raggr-frontend/src/components/ToolBubble.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from "react"; -import { cn } from "../lib/utils"; - -export const ToolBubble = React.memo( - ({ text, pulse = false }: { text: string; pulse?: boolean }) => ( -
    -
    - {text} -
    -
    - ), -); diff --git a/raggr-frontend/src/hooks/useChat.ts b/raggr-frontend/src/hooks/useChat.ts index 1d61a3d..88c81a3 100644 --- a/raggr-frontend/src/hooks/useChat.ts +++ b/raggr-frontend/src/hooks/useChat.ts @@ -1,34 +1,42 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useMatch } from "react-router-dom"; -import { conversationService } from "../api/conversationService"; +import { + conversationService, + type ToolInput, +} from "../api/conversationService"; +import type { ArtifactRef } from "../api/artifactTypes"; import type { Conversation } from "./useConversations"; -type Message = { +export type ToolStatus = "running" | "ok" | "error"; + +export type ToolMessage = { + speaker: "tool"; + run_id: string; + tool: string; + label: string; + input?: ToolInput; + status: ToolStatus; + ms?: number; + artifact?: ArtifactRef | null; + // Client-side wall-clock bounds, used for the timeline's total duration. + startedAt?: number; + endedAt?: number; + // Synthesised run_id for a server that sends no run_id; such a row pairs + // with the next tool_end for the same tool. + legacy?: boolean; +}; + +export type TextMessage = { + speaker: "simba" | "user"; text: string; - speaker: "simba" | "user" | "tool"; image_key?: string | null; }; -const TOOL_MESSAGES: Record = { - simba_search: "Searching Simba's records...", - web_search: "Searching the web...", - get_current_date: "Checking today's date...", - ynab_budget_summary: "Checking budget summary...", - ynab_account_balances: "Checking account balances...", - ynab_search_transactions: "Looking up transactions...", - ynab_spending_report: "Adding up spending...", - ynab_category_budget: "Checking category budgets...", - ynab_recent_changes: "Checking recent budget changes...", - ynab_sql: "Querying budget data...", - obsidian_search_notes: "Searching notes...", - obsidian_read_note: "Reading note...", - obsidian_create_note: "Saving note...", - obsidian_create_task: "Creating task...", - journal_get_today: "Reading today's journal...", - journal_get_tasks: "Getting tasks...", - journal_add_task: "Adding task...", - journal_complete_task: "Completing task...", -}; +export type Message = TextMessage | ToolMessage; + +const fallbackLabel = (tool: string) => `Using ${tool}…`; + +let legacyRunCounter = 0; const simbaAnswers = ["meow.", "hiss...", "purrrrrr", "yowOWROWWowowr"]; @@ -117,11 +125,22 @@ export function useChat({ const fetched = await conversationService.getConversation(conversationId); if (cancelled) return; setMessages( - (fetched.messages ?? []).map((m) => ({ - text: m.text, - speaker: m.speaker, - image_key: m.image_key, - })), + (fetched.messages ?? []).map((m): Message => { + if (m.speaker === "tool") { + // Saved tool messages carry no input, status or duration. + // Pre-v2 servers send only the "[ result]" header line. + const tool = m.tool ?? m.text.match(/^\[(\S+) result\]/)?.[1] ?? ""; + return { + speaker: "tool", + run_id: m.id, + tool, + label: m.label ?? (tool ? fallbackLabel(tool) : "Used a tool"), + status: "ok", + artifact: m.artifact ?? null, + }; + } + return { text: m.text, speaker: m.speaker, image_key: m.image_key }; + }), ); scrollToBottom(); } catch (err) { @@ -189,8 +208,9 @@ export function useChat({ updateMessages((prev) => { const updated = [...prev]; for (let i = updated.length - 1; i >= 0; i--) { - if (updated[i].speaker === "user") { - updated[i] = { ...updated[i], image_key: imageKey }; + const msg = updated[i]; + if (msg.speaker === "user") { + updated[i] = { ...msg, image_key: imageKey }; break; } } @@ -214,11 +234,55 @@ export function useChat({ // Any text after a tool call belongs in a fresh bubble. streamingActiveRef.current = false; setThinking(false); - const friendly = - TOOL_MESSAGES[event.tool] ?? `Using ${event.tool}...`; - updateMessages((prev) => - prev.concat([{ text: friendly, speaker: "tool" }]), - ); + const legacy = !event.run_id; + const row: ToolMessage = { + speaker: "tool", + run_id: event.run_id ?? `legacy-${++legacyRunCounter}`, + tool: event.tool, + label: event.label ?? fallbackLabel(event.tool), + input: event.input, + status: "running", + startedAt: Date.now(), + legacy, + }; + updateMessages((prev) => prev.concat([row])); + } else if (event.type === "tool_end") { + const now = Date.now(); + updateMessages((prev) => { + // Pair by run_id; a legacy end (no run_id) pairs with the + // oldest still-running legacy row for the same tool. + const idx = prev.findIndex((m) => + m.speaker === "tool" && + (event.run_id + ? m.run_id === event.run_id + : m.legacy && m.status === "running" && m.tool === event.tool), + ); + const ended = { + status: event.status ?? "ok", + artifact: event.artifact ?? null, + endedAt: now, + } as const; + if (idx === -1) { + // End without a matching start: show it as a finished row. + return prev.concat([{ + speaker: "tool", + run_id: event.run_id ?? `legacy-${++legacyRunCounter}`, + tool: event.tool, + label: event.label ?? fallbackLabel(event.tool), + ms: event.ms, + ...ended, + }]); + } + const row = prev[idx] as ToolMessage; + const updated = [...prev]; + updated[idx] = { + ...row, + ...ended, + label: event.label ?? row.label, + ms: event.ms ?? (row.startedAt != null ? now - row.startedAt : undefined), + }; + return updated; + }); } else if (event.type === "content") { setThinking(false); if (thinkingStartRef.current !== null && thinkingDurationMs === null) { @@ -228,6 +292,7 @@ export function useChat({ updateMessages((prev) => { const updated = [...prev]; const last = updated[updated.length - 1]; + if (last.speaker === "tool") return prev; updated[updated.length - 1] = { ...last, text: last.text + event.delta, @@ -247,10 +312,9 @@ export function useChat({ if (streamingActiveRef.current) { updateMessages((prev) => { const updated = [...prev]; - updated[updated.length - 1] = { - ...updated[updated.length - 1], - text: event.message, - }; + const last = updated[updated.length - 1]; + if (last.speaker === "tool") return prev; + updated[updated.length - 1] = { ...last, text: event.message }; return updated; }); streamingActiveRef.current = false; @@ -289,6 +353,17 @@ export function useChat({ } } finally { if (isMountedRef.current) { + // A stream that ends (or is cancelled) mid-call leaves rows that + // will never get a tool_end; stop their spinners. + setMessages((prev) => + prev.some((m) => m.speaker === "tool" && m.status === "running") + ? prev.map((m) => + m.speaker === "tool" && m.status === "running" + ? { ...m, status: "error", endedAt: Date.now() } + : m, + ) + : prev, + ); setIsLoading(false); setStreaming(false); setThinking(false); diff --git a/raggr-frontend/test-fixtures/cowork/mock-server.mjs b/raggr-frontend/test-fixtures/cowork/mock-server.mjs new file mode 100644 index 0000000..e7f98e5 --- /dev/null +++ b/raggr-frontend/test-fixtures/cowork/mock-server.mjs @@ -0,0 +1,424 @@ +#!/usr/bin/env node +// Dependency-free mock of the SimbaRAG API for frontend work on the Cowork UI. +// Speaks the contract in docs/cowork-ui-plan.md: serves dist/ with an SPA +// catch-all, stubs auth + conversation CRUD + artifacts, and streams +// contract-shaped SSE from /api/conversation/stream-query. +// +// yarn build && node test-fixtures/cowork/mock-server.mjs [port] +// +// Seed localStorage access_token/refresh_token with any value to get past +// the auth gate. The stream scenario is picked by a keyword in the query: +// "parallel" two interleaved calls, one with an artifact +// "error" a call that fails, then an answer +// "legacy" old-server events: {type:"tool_start",tool} / {type:"tool_end",tool} +// "plain" no tools, just a streamed answer +// anything else: two sequential calls, then a streamed answer +// A conversation with id "seeded" exists up front with saved tool messages. +// Every *.json in this directory with `kind` and `data` is loaded as an +// artifact (`id` defaults to the file name), so per-kind fixtures can be +// dropped in alongside this file. +// MOCK_DELAY_SCALE=0.2 speeds the stream up; 0 makes it instant. + +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import crypto from "node:crypto"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const distDir = path.resolve(here, "../../dist"); +const port = Number(process.argv[2] ?? process.env.PORT ?? 5173); +const delayScale = Number(process.env.MOCK_DELAY_SCALE ?? 1); + +const MIME = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".woff2": "font/woff2", + ".webmanifest": "application/manifest+json", +}; + +const now = () => new Date().toISOString(); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms * delayScale)); +const uuid = () => crypto.randomUUID(); + +// ---------------------------------------------------------------- state + +/** @type {Map} full Artifact records by id */ +const artifacts = new Map(); +/** @type {Map} */ +const conversations = new Map(); + +function addArtifact(conversationId, tool, a) { + const record = { + id: a.id ?? uuid(), + kind: a.kind, + title: a.title, + data: a.data ?? {}, + source_ref: a.source_ref ?? null, + tool: a.tool ?? tool, + conversation_id: conversationId, + message_id: a.message_id ?? null, + created_at: a.created_at ?? now(), + }; + artifacts.set(record.id, record); + return record; +} + +const refOf = (a) => (a ? { id: a.id, kind: a.kind, title: a.title } : null); + +const SPENDING = { + kind: "ynab.spending_report", + title: "October spending by category", + data: { + start_date: "2026-10-01", + end_date: "2026-10-12", + group_by: "category", + rows: [ + { key: "Groceries", amount: -412.18, count: 9 }, + { key: "Dining Out", amount: -186.4, count: 6 }, + { key: "Cat Supplies", amount: -74.99, count: 2 }, + ], + total: -673.57, + }, + source_ref: { system: "ynab" }, +}; + +const RECIPE = { + kind: "mealie.recipe", + title: "Miso Salmon", + data: { + slug: "miso-salmon", + name: "Miso Salmon", + description: "Weeknight salmon glazed with white miso.", + servings: 2, + prep_time: "10 min", + cook_time: "12 min", + total_time: "22 min", + ingredients: ["2 salmon fillets", "2 tbsp white miso", "1 tbsp mirin"], + instructions: [{ text: "Whisk the glaze." }, { text: "Broil 10–12 minutes." }], + tags: ["dinner", "fish"], + }, + source_ref: { system: "mealie", id: "miso-salmon" }, +}; + +function newConversation(id = uuid(), name = "New Conversation") { + const c = { id, name, created_at: now(), updated_at: now(), messages: [] }; + conversations.set(id, c); + return c; +} + +function pushMessage(c, msg) { + const m = { id: uuid(), created_at: now(), image_key: null, ...msg }; + c.messages.push(m); + c.updated_at = now(); + return m; +} + +// Saved tool messages as GET /api/conversation/ returns them: the header +// line in `text`, plus tool/label/artifact. +function seed() { + const c = newConversation("seeded", "Budget check (seeded)"); + pushMessage(c, { speaker: "user", text: "How much did we spend on food this month?" }); + const spend = addArtifact(c.id, "ynab_spending_report", SPENDING); + pushMessage(c, { + speaker: "tool", + text: "[get_current_date result]", + tool: "get_current_date", + label: "Checking today's date…", + artifact: null, + }); + pushMessage(c, { + speaker: "tool", + text: "[ynab_spending_report result]", + tool: "ynab_spending_report", + label: "Building spending report…", + artifact: refOf(spend), + }); + pushMessage(c, { + speaker: "simba", + text: "You've spent **$598.58** on food so far in October: $412.18 on groceries and $186.40 dining out.", + }); + // A pre-v2 saved tool message: no tool/label/artifact fields at all. + pushMessage(c, { speaker: "user", text: "Anything on the web about miso salmon?" }); + pushMessage(c, { speaker: "tool", text: "[web_search result]" }); + pushMessage(c, { speaker: "simba", text: "Plenty — broil it for 10–12 minutes." }); +} + +function loadFixtureArtifacts() { + for (const f of fs.readdirSync(here)) { + if (!f.endsWith(".json")) continue; + try { + const a = JSON.parse(fs.readFileSync(path.join(here, f), "utf8")); + if (a && a.kind && a.data) { + addArtifact(a.conversation_id ?? "seeded", a.tool ?? "fixture", { + id: path.basename(f, ".json"), + ...a, + }); + } + } catch (err) { + console.warn(`skipping fixture ${f}: ${err.message}`); + } + } +} + +seed(); +loadFixtureArtifacts(); + +// ---------------------------------------------------------------- SSE scenarios + +const LABELS = { + get_current_date: "Checking today's date…", + ynab_spending_report: "Building spending report…", + mealie_get_recipe: "Opening recipe…", + obsidian_search_notes: "Searching notes…", + web_search: "Searching the web…", + simba_search: "Searching Simba's records…", +}; +const label = (tool) => LABELS[tool] ?? `Using ${tool}…`; + +/** + * Each step is [delayMs, event | fn(ctx) => event]. Tool steps are expanded + * into contract-shaped events; ctx persists tool messages and artifacts. + */ +function scenario(query) { + const q = query.toLowerCase(); + const answer = (text) => + text.match(/\S+\s*/g).map((w) => [60, { type: "content", delta: w }]); + + if (q.includes("plain")) { + return [[300, { type: "thinking" }], ...answer("Meow. Nothing to look up for that one.")]; + } + if (q.includes("legacy")) { + return [ + [300, { type: "tool_start", tool: "web_search" }], + [1200, { type: "tool_end", tool: "web_search" }], + [200, { type: "tool_start", tool: "simba_search" }], + [900, { type: "tool_end", tool: "simba_search" }], + ...answer("An older server sent those tool events without run ids."), + ]; + } + if (q.includes("parallel")) { + return [ + [300, { start: "r1", tool: "ynab_spending_report", input: { start_date: "2026-10-01", end_date: "2026-10-12", group_by: "category" } }], + [80, { start: "r2", tool: "mealie_get_recipe", input: { slug: "miso-salmon" } }], + [900, { end: "r2", artifact: RECIPE }], + [1100, { end: "r1", artifact: SPENDING }], + ...answer("Food spending is **$598.58** this month, and I pulled up the miso salmon recipe for tonight."), + ]; + } + if (q.includes("error")) { + return [ + [300, { start: "e1", tool: "obsidian_search_notes", input: { query: "vet appointment", limit: 5 } }], + [800, { end: "e1", status: "error" }], + [200, { start: "e2", tool: "web_search", input: { query: "cat vet near me open sunday" } }], + [1000, { end: "e2" }], + ...answer("I couldn't search your notes, but a couple of vets nearby are open on Sundays."), + ]; + } + return [ + [300, { type: "thinking" }], + [200, { type: "thinking_delta", delta: "Need today's date before the budget lookup." }], + [300, { start: "s1", tool: "get_current_date", input: {} }], + [400, { end: "s1" }], + [200, { start: "s2", tool: "ynab_spending_report", input: { month: "2026-10", group_by: "category", include_hidden_categories_with_a_long_name: true } }], + [1500, { end: "s2" }], + ...answer("You've spent **$598.58** on food so far in October: $412.18 on groceries and $186.40 dining out."), + ]; +} + +async function streamQuery(req, res, body) { + const c = conversations.get(body.conversation_id) ?? newConversation(body.conversation_id); + pushMessage(c, { speaker: "user", text: body.query, image_key: body.image_key ?? null }); + if (c.name === "New Conversation") c.name = body.query.slice(0, 40); + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + let closed = false; + res.on("close", () => (closed = true)); + const send = (event) => !closed && res.write(`data: ${JSON.stringify(event)}\n\n`); + + const runs = new Map(); // key -> {run_id, tool, started} + let text = ""; + for (const [delay, step] of scenario(body.query)) { + await sleep(delay); + if (closed) return; + if (step.start) { + const run = { run_id: uuid(), tool: step.tool, started: Date.now() }; + runs.set(step.start, run); + send({ type: "tool_start", run_id: run.run_id, tool: run.tool, label: label(run.tool), input: step.input ?? {} }); + } else if (step.end) { + const run = runs.get(step.end); + const status = step.status ?? "ok"; + const msg = pushMessage(c, { + speaker: "tool", + text: `[${run.tool} result]`, + tool: run.tool, + label: label(run.tool), + artifact: null, + }); + let ref = null; + if (step.artifact && status === "ok") { + ref = refOf(addArtifact(c.id, run.tool, { ...step.artifact, message_id: msg.id })); + msg.artifact = ref; + } + send({ + type: "tool_end", + run_id: run.run_id, + tool: run.tool, + label: label(run.tool), + status, + ms: Date.now() - run.started, + artifact: ref, + }); + } else { + if (step.type === "tool_end") { + pushMessage(c, { speaker: "tool", text: `[${step.tool} result]` }); + } + if (step.type === "content") text += step.delta; + send(step); + } + } + pushMessage(c, { speaker: "simba", text }); + send({ type: "response", message: text }); + res.end("data: [DONE]\n\n"); +} + +// ---------------------------------------------------------------- HTTP + +const json = (res, status, body) => { + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(body)); +}; + +const readBody = (req) => + new Promise((resolve) => { + let raw = ""; + req.on("data", (chunk) => (raw += chunk)); + req.on("end", () => { + try { + resolve(raw ? JSON.parse(raw) : {}); + } catch { + resolve({}); + } + }); + }); + +const summary = (c) => ({ id: c.id, name: c.name, created_at: c.created_at, updated_at: c.updated_at }); + +async function api(req, res, url) { + const p = url.pathname.replace(/\/+$/, "") || "/"; + const m = req.method; + + if (p === "/api/user/me") { + return json(res, 200, { id: "u1", username: "ryan", email: "ryan@example.com", is_admin: true }); + } + if (p === "/api/user/refresh") return json(res, 200, { access_token: "mock-access-token" }); + if (p === "/api/user/oidc/login" || p === "/api/user/login") return json(res, 200, {}); + if (p === "/api/user/admin/users") return json(res, 200, []); + if (p.startsWith("/api/scheduled")) return json(res, 200, []); + + if (p === "/api/conversation/stream-query" && m === "POST") { + return streamQuery(req, res, await readBody(req)); + } + if (p === "/api/conversation/query" && m === "POST") { + return json(res, 200, { response: "meow (mock)" }); + } + if (p === "/api/conversation/upload-image" || p === "/api/conversation/upload") { + return json(res, 200, { kind: "image", image_key: `mock/${uuid()}.png` }); + } + if (p.startsWith("/api/conversation/image/")) { + return json(res, 200, { url: "/favicon.ico" }); + } + if (p === "/api/conversation") { + if (m === "POST") return json(res, 200, summary(newConversation())); + const search = (url.searchParams.get("search") ?? "").toLowerCase(); + let list = [...conversations.values()] + .filter((c) => c.name.toLowerCase().includes(search)) + .sort((a, b) => b.updated_at.localeCompare(a.updated_at)) + .map(summary); + const limit = url.searchParams.get("limit"); + if (limit != null) { + const offset = Number(url.searchParams.get("offset") ?? 0); + list = list.slice(offset, offset + Number(limit)); + } + return json(res, 200, list); + } + const conv = p.match(/^\/api\/conversation\/([^/]+)$/); + if (conv) { + const c = conversations.get(conv[1]); + if (!c) return json(res, 404, { error: "conversation not found" }); + if (m === "DELETE") { + conversations.delete(c.id); + return json(res, 200, { success: true }); + } + if (m === "PATCH") { + c.name = (await readBody(req)).name ?? c.name; + return json(res, 200, summary(c)); + } + return json(res, 200, { + ...summary(c), + messages: c.messages.map(({ id, text, speaker, created_at, image_key, tool, label, artifact }) => + speaker === "tool" + ? { id, text, speaker, created_at, image_key, ...(tool ? { tool, label, artifact } : {}) } + : { id, text, speaker, created_at, image_key }, + ), + }); + } + + if (p === "/api/artifacts") { + const cid = url.searchParams.get("conversation_id"); + return json( + res, + 200, + [...artifacts.values()] + .filter((a) => !cid || a.conversation_id === cid) + .map((a) => ({ ...refOf(a), tool: a.tool, message_id: a.message_id, created_at: a.created_at })), + ); + } + const art = p.match(/^\/api\/artifacts\/([^/]+)$/); + if (art) { + const a = artifacts.get(art[1]); + return a ? json(res, 200, a) : json(res, 404, { error: "not found" }); + } + + return json(res, 404, { error: `mock: no route for ${m} ${p}` }); +} + +function serveStatic(res, pathname) { + const file = path.join(distDir, path.normalize(pathname)); + if (file.startsWith(distDir) && fs.existsSync(file) && fs.statSync(file).isFile()) { + res.writeHead(200, { "Content-Type": MIME[path.extname(file)] ?? "application/octet-stream" }); + return fs.createReadStream(file).pipe(res); + } + // SPA catch-all, like app.py. + const index = path.join(distDir, "index.html"); + if (!fs.existsSync(index)) { + res.writeHead(500); + return res.end("dist/index.html missing: run `yarn build` first"); + } + res.writeHead(200, { "Content-Type": MIME[".html"] }); + fs.createReadStream(index).pipe(res); +} + +http + .createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + try { + if (url.pathname.startsWith("/api/")) return await api(req, res, url); + return serveStatic(res, decodeURIComponent(url.pathname)); + } catch (err) { + console.error(err); + if (!res.headersSent) json(res, 500, { error: String(err) }); + } + }) + .listen(port, () => console.log(`mock SimbaRAG on http://localhost:${port}`));