Cowork C: activity timeline replaces tool pills #64
@@ -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/<id>`. */
|
||||
export type Artifact = ArtifactRef & {
|
||||
data: object;
|
||||
source_ref: ArtifactSourceRef | null;
|
||||
tool: string;
|
||||
conversation_id: string;
|
||||
message_id: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
@@ -1,10 +1,29 @@
|
||||
import { userService } from "./userService";
|
||||
import type { ArtifactRef } from "./artifactTypes";
|
||||
|
||||
export type ToolInput = Record<string, string | number | boolean>;
|
||||
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
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],
|
||||
["calendar", CalendarDays],
|
||||
["beli", Utensils],
|
||||
["get_current_date", Clock],
|
||||
];
|
||||
|
||||
const iconFor = (tool: string): LucideIcon =>
|
||||
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<boolean | null>(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 (
|
||||
<div className="flex justify-center message-enter">
|
||||
<div className="w-full max-w-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUserExpanded(!expanded)}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={listId}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between gap-2",
|
||||
"px-3 py-1.5 rounded-xl",
|
||||
"bg-leaf-pale border border-leaf-light/50",
|
||||
"text-xs text-leaf-dark",
|
||||
"hover:bg-leaf-light/20 transition-colors cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 min-w-0">
|
||||
{working ? (
|
||||
<LoaderCircle size={13} className="animate-spin shrink-0" />
|
||||
) : failed ? (
|
||||
<X size={13} className="text-red-500 shrink-0" />
|
||||
) : (
|
||||
<Check size={13} className="shrink-0" />
|
||||
)}
|
||||
<span className={cn("truncate", working && "italic")}>
|
||||
{working ? "Simba is working…" : summary}
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={13}
|
||||
className={cn(
|
||||
"text-leaf-dark/60 transition-transform duration-200 shrink-0",
|
||||
expanded && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<ul
|
||||
id={listId}
|
||||
className={cn(
|
||||
"mt-1 rounded-xl overflow-hidden",
|
||||
"bg-warm-white border border-sand-light",
|
||||
"divide-y divide-sand-light/60",
|
||||
)}
|
||||
>
|
||||
{rows.map((row) => (
|
||||
<TimelineRow
|
||||
key={row.run_id}
|
||||
row={row}
|
||||
onOpenArtifact={onOpenArtifact}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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 (
|
||||
<li className="flex items-center gap-2.5 px-3 py-2">
|
||||
<span
|
||||
className="w-6 h-6 shrink-0 rounded-lg bg-leaf-pale text-leaf-dark flex items-center justify-center"
|
||||
title={row.tool}
|
||||
>
|
||||
<Icon size={13} />
|
||||
</span>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13px] text-charcoal truncate">{row.label}</div>
|
||||
{inputSummary && (
|
||||
<div
|
||||
className="text-[11px] text-warm-gray font-mono truncate"
|
||||
title={inputSummary}
|
||||
>
|
||||
{inputSummary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{artifact && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenArtifact(artifact)}
|
||||
title={artifact.title}
|
||||
aria-label={`Open ${artifact.title}`}
|
||||
className={cn(
|
||||
"shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full",
|
||||
"bg-amber-pale border border-amber-soft/70 text-[11px] font-medium text-amber-dark",
|
||||
"hover:bg-amber-soft/40 transition-colors cursor-pointer",
|
||||
)}
|
||||
>
|
||||
<PanelRightOpen size={11} />
|
||||
Open
|
||||
</button>
|
||||
)}
|
||||
|
||||
<span className="shrink-0 flex items-center gap-1.5 text-[11px] text-warm-gray tabular-nums">
|
||||
{row.ms != null && row.status !== "running" && (
|
||||
<span>{formatDuration(row.ms)}</span>
|
||||
)}
|
||||
{row.status === "running" ? (
|
||||
<LoaderCircle
|
||||
size={14}
|
||||
className="animate-spin text-leaf"
|
||||
aria-label="Running"
|
||||
/>
|
||||
) : row.status === "error" ? (
|
||||
<X size={14} className="text-red-500" aria-label="Failed" />
|
||||
) : (
|
||||
<Check size={14} className="text-leaf" aria-label="Done" />
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
@@ -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) => {
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6">
|
||||
<div className="max-w-3xl mx-auto flex flex-col gap-6">
|
||||
{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 (
|
||||
<ActivityTimeline
|
||||
key={`tools-${rows[0].run_id}`}
|
||||
rows={rows}
|
||||
// The trailing block of an in-flight turn stays
|
||||
// "working" until the answer starts streaming.
|
||||
active={isLoading && end === messages.length}
|
||||
onOpenArtifact={handleOpenArtifact}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 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" ? (
|
||||
<ToolBubble key={index} text={msg.text} />
|
||||
) : msg.speaker === "simba" ? (
|
||||
msg.speaker === "simba" ? (
|
||||
<AnswerBubble key={index} text={msg.text} />
|
||||
) : (
|
||||
<QuestionBubble key={index} text={msg.text} image_key={msg.image_key} />
|
||||
@@ -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) && (
|
||||
<AnswerBubble text="" loading={true} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 }) => (
|
||||
<div className="flex justify-center message-enter">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full",
|
||||
"bg-leaf-pale border border-leaf-light/50",
|
||||
"text-xs text-leaf-dark italic",
|
||||
pulse && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
@@ -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<string, string> = {
|
||||
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 "[<tool> 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);
|
||||
|
||||
@@ -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<string, any>} full Artifact records by id */
|
||||
const artifacts = new Map();
|
||||
/** @type {Map<string, {id, name, created_at, updated_at, messages: any[]}>} */
|
||||
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/<id> 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}`));
|
||||
Reference in New Issue
Block a user