haiku.rag/app/frontend/components/Chat.tsx
Yiorgis Gozadinos 53bbf52697
Round-trip the capability's whole state through the browser
The session store rebuilt the rag namespace from four known keys, so the
evidence record never survived a turn, let alone a reload from localStorage.
Compaction then ran with an empty ledger: earlier evidence was replaced by
receipts retaining nothing, while the citations already in citation_index kept
the UI looking correct.

The UI still names the fields it reads, but everything else in the namespace
passes through untouched, and seeding the namespace no longer replaces sibling
namespaces.
2026-08-13 15:20:02 +03:00

487 lines
12 KiB
TypeScript

"use client";
import {
CopilotChatMessageView,
CopilotChatView,
CopilotKitProvider,
defineToolCallRenderer,
UseAgentUpdate,
useAgent,
useCopilotKit,
} from "@copilotkit/react-core/v2";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { FilterIcon } from "../lib/icons";
import type { RAGState } from "../lib/sessionStorage";
import {
createSession,
getActiveSessionId,
getLatestCitations,
getSession,
normalizeRAGState,
updateSessionMessages,
} from "../lib/sessionStorage";
import CitationBlock from "./CitationBlock";
import DbInfo from "./DbInfo";
import DocumentFilter from "./DocumentFilter";
import SessionManager from "./SessionManager";
// Must match RAGCapability.state_namespace.
const AGUI_STATE_KEY = "rag";
// AG-UI state is namespaced under AGUI_STATE_KEY
interface AgentState {
[AGUI_STATE_KEY]?: RAGState;
}
// biome-ignore lint/suspicious/noExplicitAny: CopilotKit message objects vary at runtime
function serializeMessages(messages: any[]): any[] {
return JSON.parse(JSON.stringify(messages));
}
function SpinnerIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="tool-spinner"
>
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
);
}
function CheckIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
function SearchIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</svg>
);
}
function MessageIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
</svg>
);
}
function ToolCallIndicator({
toolName,
status,
args,
}: {
toolName: string;
status: string;
args: Record<string, unknown>;
}) {
const isComplete = status === "complete";
const getToolIcon = () => {
switch (toolName) {
case "rag_search":
return <SearchIcon />;
case "rag_cite":
return <MessageIcon />;
default:
return <SearchIcon />;
}
};
const getToolLabel = () => {
switch (toolName) {
case "rag_search":
return "Search";
case "rag_cite":
return "Cite";
default:
return toolName;
}
};
const getDescription = () => {
switch (toolName) {
case "rag_search": {
const query = args.query as string;
return <span className="tool-query">{query}</span>;
}
case "rag_cite":
return <span className="tool-query">Registering citations</span>;
default:
return <span>Processing...</span>;
}
};
return (
<div className={`tool-call-card ${isComplete ? "complete" : "loading"}`}>
<div className="tool-status-icon">
{isComplete ? <CheckIcon /> : <SpinnerIcon />}
</div>
<div className="tool-content">
<div className="tool-header">
<span className="tool-badge">
{getToolIcon()}
{getToolLabel()}
</span>
<span className="tool-status-text">
{isComplete ? "Done" : "Working..."}
</span>
</div>
<div className="tool-description">{getDescription()}</div>
</div>
</div>
);
}
// Context for sharing chat state with the message view
const ChatStateContext = createContext<RAGState | null>(null);
// Wildcard tool call renderer for all server-side tools
const toolCallRenderers = [
defineToolCallRenderer({
name: "*",
render: ({ name, args, result }) => (
<ToolCallIndicator
toolName={name}
status={result !== undefined ? "complete" : "loading"}
args={(args ?? {}) as Record<string, unknown>}
/>
),
}),
];
// Custom message view that injects CitationBlocks after assistant responses.
// Uses CopilotChatMessageView's children render prop to post-process the
// rendered message elements and inject citations at the right positions.
function MessageViewWithCitations({
messages = [],
isRunning = false,
}: {
// biome-ignore lint/suspicious/noExplicitAny: AG-UI Message type is a broad union
messages?: any[];
isRunning?: boolean;
}) {
const ragState = useContext(ChatStateContext);
const latestCitations = ragState ? getLatestCitations(ragState) : [];
const cursor = isRunning ? (
<div key="cursor" className="streaming-cursor">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
) : null;
// CopilotChatMessageView renders one element per user/assistant message.
// Inject CitationBlocks after assistant responses that
// followed tool calls.
return (
<CopilotChatMessageView messages={messages} isRunning={isRunning}>
{({ messageElements }) => {
const result: React.ReactNode[] = [];
let elemIdx = 0;
let seenToolCalls = false;
for (const msg of messages) {
if (msg.role === "user") {
seenToolCalls = false;
}
if (
msg.role === "assistant" &&
Array.isArray(msg.toolCalls) &&
msg.toolCalls.length > 0
) {
seenToolCalls = true;
}
if (msg.role !== "user" && msg.role !== "assistant") continue;
if (elemIdx < messageElements.length) {
result.push(messageElements[elemIdx]);
elemIdx++;
}
// After an assistant text response that followed tool calls,
// show citations from the latest turn
if (msg.role === "assistant" && msg.content && seenToolCalls) {
if (latestCitations.length > 0) {
result.push(
<CitationBlock
key={`citations-${msg.id}`}
citations={latestCitations}
/>,
);
}
seenToolCalls = false;
}
}
while (elemIdx < messageElements.length) {
result.push(messageElements[elemIdx]);
elemIdx++;
}
return (
<>
{result}
{cursor}
</>
);
}}
</CopilotChatMessageView>
);
}
MessageViewWithCitations.Cursor = CopilotChatMessageView.Cursor;
function ChatContentInner({
sessionId,
onSessionChange,
}: {
sessionId: string;
onSessionChange: (id: string) => void;
}) {
const [filterOpen, setFilterOpen] = useState(false);
// Track selected document names locally (frontend-only)
const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);
const { agent } = useAgent({
agentId: "chat_agent",
updates: [
UseAgentUpdate.OnMessagesChanged,
UseAgentUpdate.OnStateChanged,
UseAgentUpdate.OnRunStatusChanged,
],
});
const { copilotkit: ck } = useCopilotKit();
// Set threadId (CopilotChat normally does this in its connect effect)
useEffect(() => {
agent.threadId = sessionId;
}, [agent, sessionId]);
const ragState = normalizeRAGState(
(agent.state as AgentState)?.[AGUI_STATE_KEY],
);
// Restore session from localStorage when agent reference changes.
// useAgent returns a provisional agent initially, then the real agent
// after runtime connects — re-run restore each time so messages stick.
useEffect(() => {
if (agent.messages.length > 0) return;
const session = getSession(sessionId);
// Seed state for the capability; the backend replaces it after each run.
agent.setState({
...agent.state,
[AGUI_STATE_KEY]: normalizeRAGState(session?.ragState),
});
if (session && session.messages.length > 0) {
// biome-ignore lint/suspicious/noExplicitAny: AG-UI Message type is a broad union
agent.setMessages(session.messages as any[]);
}
}, [agent, sessionId]);
// Persist messages and state to localStorage.
// Read ragState from agent.state at effect time (not render time) so that
// restore and persist effects in the same commit see consistent state.
// biome-ignore lint/correctness/useExhaustiveDependencies: JSON.stringify tracks content changes
useEffect(() => {
if (sessionId && agent.messages.length > 0) {
const currentRagState = normalizeRAGState(
(agent.state as AgentState)?.[AGUI_STATE_KEY],
);
updateSessionMessages(
sessionId,
serializeMessages(agent.messages),
currentRagState,
);
}
}, [JSON.stringify(agent.messages), ragState, sessionId]);
// Deduplicate messages by id to avoid React duplicate key warnings.
// biome-ignore lint/correctness/useExhaustiveDependencies: stable identity via agent ref
const messages = useMemo(() => {
const seen = new Map<string, number>();
const msgs = agent.messages;
for (let i = 0; i < msgs.length; i++) {
const id = msgs[i].id;
if (id) seen.set(id, i);
}
return msgs.filter((msg, i) => !msg.id || seen.get(msg.id) === i);
}, [JSON.stringify(agent.messages)]);
const onSubmitMessage = useCallback(
async (text: string) => {
agent.addMessage({
id: crypto.randomUUID(),
role: "user",
content: text,
});
try {
await ck.runAgent({ agent });
} catch (error) {
console.error("runAgent failed", error);
}
},
[agent, ck],
);
const onStop = useCallback(() => {
try {
ck.stopAgent({ agent });
} catch {
agent.abortRun();
}
}, [agent, ck]);
const handleFilterApply = (selected: string[]) => {
setSelectedDocuments(selected);
// Convert selected document names to SQL filter for the backend
const filter =
selected.length > 0
? selected
.map(
(name) =>
`(title LIKE '%${name.replace(/'/g, "''")}%' OR uri LIKE '%${name.replace(/'/g, "''")}%')`,
)
.join(" OR ")
: null;
agent.setState({
...agent.state,
[AGUI_STATE_KEY]: {
...ragState,
document_filter: filter,
},
});
};
return (
<ChatStateContext.Provider value={ragState}>
<div className="chat-wrapper">
<div className="chat-container">
<div className="chat-header">
<SessionManager
activeSessionId={sessionId}
onSessionChange={onSessionChange}
/>
<button
type="button"
className={`header-btn ${selectedDocuments.length > 0 ? "has-content" : ""}`}
onClick={() => setFilterOpen(true)}
title={
selectedDocuments.length > 0
? `Filtering: ${selectedDocuments.length} document(s)`
: "Filter documents"
}
>
<FilterIcon />
{selectedDocuments.length > 0
? `Filter (${selectedDocuments.length})`
: "Filter"}
</button>
</div>
<div className="chat-content">
<CopilotChatView
messageView={MessageViewWithCitations}
messages={messages}
isRunning={agent.isRunning}
onSubmitMessage={onSubmitMessage}
onStop={onStop}
>
{({ scrollView, input }) => (
<div className="chat-layout">
<div className="chat-scroll-area">{scrollView}</div>
<div className="chat-input-area">{input}</div>
</div>
)}
</CopilotChatView>
</div>
<DbInfo />
</div>
</div>
<DocumentFilter
isOpen={filterOpen}
onClose={() => setFilterOpen(false)}
selected={selectedDocuments}
onApply={handleFilterApply}
/>
</ChatStateContext.Provider>
);
}
export default function Chat() {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
useEffect(() => {
let id = getActiveSessionId();
if (!id) {
id = createSession().id;
}
setActiveSessionId(id);
}, []);
if (!activeSessionId) return null;
return (
<CopilotKitProvider
key={activeSessionId}
runtimeUrl="/api/copilotkit"
useSingleEndpoint
renderToolCalls={toolCallRenderers}
>
<ChatContentInner
sessionId={activeSessionId}
onSessionChange={setActiveSessionId}
/>
</CopilotKitProvider>
);
}