"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 state_namespace from haiku.rag.skills.rag 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 ( ); } function CheckIcon() { return ( ); } function SearchIcon() { return ( ); } function MessageIcon() { return ( ); } function FileIcon() { return ( ); } function ToolCallIndicator({ toolName, status, args, }: { toolName: string; status: string; args: Record; }) { const isComplete = status === "complete"; const getToolIcon = () => { switch (toolName) { case "search": return ; case "get_document": return ; case "execute_skill": case "execute_code": case "cite": return ; default: return ; } }; const getToolLabel = () => { switch (toolName) { case "search": return "Search"; case "get_document": return "Document"; case "execute_skill": return "Skill"; case "execute_code": return "Code"; case "cite": return "Cite"; case "list_documents": return "Documents"; default: return toolName; } }; const getDescription = () => { switch (toolName) { case "execute_skill": { const skill = args.skill_name as string | undefined; const request = args.request as string | undefined; return ( {skill ? `${skill}: ` : ""} {request ?? "Processing..."} ); } case "search": { const query = args.query as string; return {query}; } case "get_document": return {args.query as string}; case "execute_code": { const code = args.code as string | undefined; return ( {code ? code.slice(0, 80) : "Running code..."} ); } case "cite": return Registering citations; case "list_documents": return Listing documents; default: return Processing...; } }; return ( {isComplete ? : } {getToolIcon()} {getToolLabel()} {isComplete ? "Done" : "Working..."} {getDescription()} ); } // Render an activity message from a skill sub-agent tool call/result function ActivityIndicator({ message, isComplete, }: { // biome-ignore lint/suspicious/noExplicitAny: AG-UI activity message shape message: any; isComplete: boolean; }) { const content = message.content ?? {}; const toolName = content.tool_name ?? "tool"; let args: Record = {}; if (content.args) { try { args = typeof content.args === "string" ? JSON.parse(content.args) : content.args; } catch { // ignore parse errors } } return ( ); } // Context for sharing chat state with the message view const ChatStateContext = createContext(null); // Wildcard tool call renderer for all server-side tools const toolCallRenderers = [ defineToolCallRenderer({ name: "*", render: ({ name, args, result }) => ( } /> ), }), ]; // 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) : []; // Collect completed tool_call_ids from skill_tool_result activity messages const completedToolCallIds = useMemo(() => { const ids = new Set(); for (const msg of messages) { if ( msg.role === "activity" && msg.activityType === "skill_tool_result" && msg.content?.tool_call_id ) { ids.add(msg.content.tool_call_id); } } return ids; }, [messages]); const cursor = isRunning ? ( ) : null; // CopilotChatMessageView renders one element per user/assistant message. // We interleave activity indicators (skill sub-agent tool calls) and // optionally inject CitationBlocks after assistant responses that // followed tool calls. return ( {({ 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; } // Activity messages are not rendered by CopilotKit — // render them ourselves without consuming messageElements if (msg.role === "activity") { if (msg.activityType === "skill_tool_call") { const toolCallId = msg.content?.tool_call_id; result.push( , ); } continue; } 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( , ); } seenToolCalls = false; } } while (elemIdx < messageElements.length) { result.push(messageElements[elemIdx]); elemIdx++; } return ( <> {result} {cursor} > ); }} ); } 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([]); 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); if (!session) return; if (session.ragState) { agent.setState({ [AGUI_STATE_KEY]: normalizeRAGState(session.ragState), }); } if (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, keeping the last occurrence. // haiku.skills 0.10.0+ sends activity snapshots with the same id // and replace=true — CopilotKit doesn't deduplicate these, so we // must do it to avoid React duplicate key warnings. // biome-ignore lint/correctness/useExhaustiveDependencies: stable identity via agent ref const messages = useMemo(() => { const seen = new Map(); 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 ( 0 ? "has-content" : ""}`} onClick={() => setFilterOpen(true)} title={ selectedDocuments.length > 0 ? `Filtering: ${selectedDocuments.length} document(s)` : "Filter documents" } > {selectedDocuments.length > 0 ? `Filter (${selectedDocuments.length})` : "Filter"} {({ scrollView, input }) => ( {scrollView} {input} )} setFilterOpen(false)} selected={selectedDocuments} onApply={handleFilterApply} /> ); } export default function Chat() { const [activeSessionId, setActiveSessionId] = useState(null); useEffect(() => { let id = getActiveSessionId(); if (!id) { id = createSession().id; } setActiveSessionId(id); }, []); if (!activeSessionId) return null; return ( ); }