Adapt frontend

This commit is contained in:
Yiorgis Gozadinos 2026-02-20 11:03:53 +02:00
parent a7850e2210
commit 09e6324add
No known key found for this signature in database
3 changed files with 95 additions and 258 deletions

View file

@ -17,27 +17,27 @@ import {
useMemo, useMemo,
useState, useState,
} from "react"; } from "react";
import { BrainIcon, FilterIcon } from "../lib/icons"; import { FilterIcon } from "../lib/icons";
import type { ChatSessionState } from "../lib/sessionStorage"; import type { RAGState } from "../lib/sessionStorage";
import { import {
createSession, createSession,
deriveCitationsHistory,
getActiveSessionId, getActiveSessionId,
getSession, getSession,
normalizeChatState, normalizeRAGState,
updateSessionMessages, updateSessionMessages,
} from "../lib/sessionStorage"; } from "../lib/sessionStorage";
import CitationBlock from "./CitationBlock"; import CitationBlock from "./CitationBlock";
import ContextPanel from "./ContextPanel";
import DbInfo from "./DbInfo"; import DbInfo from "./DbInfo";
import DocumentFilter from "./DocumentFilter"; import DocumentFilter from "./DocumentFilter";
import SessionManager from "./SessionManager"; import SessionManager from "./SessionManager";
// Must match AGUI_STATE_KEY from haiku.rag.agents.chat // Must match state_namespace from haiku.rag.skills.rag
const AGUI_STATE_KEY = "haiku.rag.chat"; const AGUI_STATE_KEY = "rag";
// AG-UI state is namespaced under AGUI_STATE_KEY // AG-UI state is namespaced under AGUI_STATE_KEY
interface AgentState { interface AgentState {
[AGUI_STATE_KEY]?: ChatSessionState; [AGUI_STATE_KEY]?: RAGState;
} }
// biome-ignore lint/suspicious/noExplicitAny: CopilotKit message objects vary at runtime // biome-ignore lint/suspicious/noExplicitAny: CopilotKit message objects vary at runtime
@ -165,6 +165,10 @@ function ToolCallIndicator({
return "Ask"; return "Ask";
case "get_document": case "get_document":
return "Document"; return "Document";
case "analyze":
return "Analyze";
case "research":
return "Research";
default: default:
return toolName; return toolName;
} }
@ -174,36 +178,18 @@ function ToolCallIndicator({
switch (toolName) { switch (toolName) {
case "search": { case "search": {
const query = args.query as string; const query = args.query as string;
const docName = args.document_name as string | undefined; return <span className="tool-query">{query}</span>;
return (
<>
<span className="tool-query">{query}</span>
{docName && (
<span className="tool-context">
{" "}
in <em>{docName}</em>
</span>
)}
</>
);
} }
case "ask": { case "ask": {
const question = args.question as string; const question = args.question as string;
const docName = args.document_name as string | undefined; return <span className="tool-query">{question}</span>;
return (
<>
<span className="tool-query">{question}</span>
{docName && (
<span className="tool-context">
{" "}
from <em>{docName}</em>
</span>
)}
</>
);
} }
case "get_document": case "get_document":
return <span className="tool-query">{args.query as string}</span>; return <span className="tool-query">{args.query as string}</span>;
case "analyze":
return <span className="tool-query">{args.question as string}</span>;
case "research":
return <span className="tool-query">{args.question as string}</span>;
default: default:
return <span>Processing...</span>; return <span>Processing...</span>;
} }
@ -231,7 +217,7 @@ function ToolCallIndicator({
} }
// Context for sharing chat state with the message view // Context for sharing chat state with the message view
const ChatStateContext = createContext<ChatSessionState | null>(null); const ChatStateContext = createContext<RAGState | null>(null);
// Wildcard tool call renderer for all server-side tools // Wildcard tool call renderer for all server-side tools
const toolCallRenderers = [ const toolCallRenderers = [
@ -258,7 +244,8 @@ function MessageViewWithCitations({
messages: any[]; messages: any[];
isRunning: boolean; isRunning: boolean;
}) { }) {
const chatState = useContext(ChatStateContext); const ragState = useContext(ChatStateContext);
const citationsHistory = ragState ? deriveCitationsHistory(ragState) : [];
const cursor = isRunning ? ( const cursor = isRunning ? (
<div key="cursor" className="streaming-cursor"> <div key="cursor" className="streaming-cursor">
@ -271,7 +258,7 @@ function MessageViewWithCitations({
return ( return (
<CopilotChatMessageView messages={messages} isRunning={isRunning}> <CopilotChatMessageView messages={messages} isRunning={isRunning}>
{({ messageElements }) => { {({ messageElements }) => {
if (!chatState?.citations_history?.length) { if (!citationsHistory.length) {
return ( return (
<> <>
{messageElements} {messageElements}
@ -284,9 +271,9 @@ function MessageViewWithCitations({
// message (tool messages produce nothing). We correlate elements with // message (tool messages produce nothing). We correlate elements with
// messages to inject CitationBlocks after the right assistant responses. // messages to inject CitationBlocks after the right assistant responses.
// //
// Both search and ask tools append to citations_history in order, // Both search and ask tools append to citations via qa_history,
// so after each assistant text response that followed tool calls, // so after each assistant text response that followed tool calls,
// we inject the next citations_history entry. // we inject the next citations entry.
const result: React.ReactNode[] = []; const result: React.ReactNode[] = [];
let citIdx = 0; let citIdx = 0;
let seenToolCalls = false; let seenToolCalls = false;
@ -317,10 +304,10 @@ function MessageViewWithCitations({
} }
// After an assistant text response that followed tool calls, // After an assistant text response that followed tool calls,
// inject the next citations_history entry (one per turn) // inject the next citations entry (one per turn)
if (msg.role === "assistant" && msg.content && seenToolCalls) { if (msg.role === "assistant" && msg.content && seenToolCalls) {
if (citIdx < chatState.citations_history.length) { if (citIdx < citationsHistory.length) {
const citations = chatState.citations_history[citIdx]; const citations = citationsHistory[citIdx];
if (citations?.length) { if (citations?.length) {
result.push( result.push(
<CitationBlock <CitationBlock
@ -358,8 +345,9 @@ function ChatContentInner({
sessionId: string; sessionId: string;
onSessionChange: (id: string) => void; onSessionChange: (id: string) => void;
}) { }) {
const [contextOpen, setContextOpen] = useState(false);
const [filterOpen, setFilterOpen] = useState(false); const [filterOpen, setFilterOpen] = useState(false);
// Track selected document names locally (frontend-only)
const [selectedDocuments, setSelectedDocuments] = useState<string[]>([]);
const { agent } = useAgent({ const { agent } = useAgent({
agentId: "chat_agent", agentId: "chat_agent",
@ -376,23 +364,10 @@ function ChatContentInner({
agent.threadId = sessionId; agent.threadId = sessionId;
}, [agent, sessionId]); }, [agent, sessionId]);
const chatState = normalizeChatState( const ragState = normalizeRAGState(
(agent.state as AgentState)?.[AGUI_STATE_KEY], (agent.state as AgentState)?.[AGUI_STATE_KEY],
); );
const mergeChatState = (partial: Partial<ChatSessionState>) => {
const current = normalizeChatState(
(agent.state as AgentState)?.[AGUI_STATE_KEY],
);
agent.setState({
...agent.state,
[AGUI_STATE_KEY]: {
...current,
...partial,
},
});
};
// Restore session from localStorage when agent reference changes. // Restore session from localStorage when agent reference changes.
// useAgent returns a provisional agent initially, then the real agent // useAgent returns a provisional agent initially, then the real agent
// after runtime connects — re-run restore each time so messages stick. // after runtime connects — re-run restore each time so messages stick.
@ -400,9 +375,9 @@ function ChatContentInner({
if (agent.messages.length > 0) return; if (agent.messages.length > 0) return;
const session = getSession(sessionId); const session = getSession(sessionId);
if (!session) return; if (!session) return;
if (session.chatState) { if (session.ragState) {
agent.setState({ agent.setState({
[AGUI_STATE_KEY]: normalizeChatState(session.chatState), [AGUI_STATE_KEY]: normalizeRAGState(session.ragState),
}); });
} }
if (session.messages.length > 0) { if (session.messages.length > 0) {
@ -412,21 +387,21 @@ function ChatContentInner({
}, [agent, sessionId]); }, [agent, sessionId]);
// Persist messages and state to localStorage. // Persist messages and state to localStorage.
// Read chatState from agent.state at effect time (not render time) so that // Read ragState from agent.state at effect time (not render time) so that
// restore and persist effects in the same commit see consistent state. // restore and persist effects in the same commit see consistent state.
// biome-ignore lint/correctness/useExhaustiveDependencies: JSON.stringify tracks content changes // biome-ignore lint/correctness/useExhaustiveDependencies: JSON.stringify tracks content changes
useEffect(() => { useEffect(() => {
if (sessionId && agent.messages.length > 0) { if (sessionId && agent.messages.length > 0) {
const currentChatState = normalizeChatState( const currentRagState = normalizeRAGState(
(agent.state as AgentState)?.[AGUI_STATE_KEY], (agent.state as AgentState)?.[AGUI_STATE_KEY],
); );
updateSessionMessages( updateSessionMessages(
sessionId, sessionId,
serializeMessages(agent.messages), serializeMessages(agent.messages),
currentChatState, currentRagState,
); );
} }
}, [JSON.stringify(agent.messages), chatState, sessionId]); }, [JSON.stringify(agent.messages), ragState, sessionId]);
// biome-ignore lint/correctness/useExhaustiveDependencies: stable identity via agent ref // biome-ignore lint/correctness/useExhaustiveDependencies: stable identity via agent ref
const messages = useMemo( const messages = useMemo(
@ -458,24 +433,29 @@ function ChatContentInner({
} }
}, [agent, ck]); }, [agent, ck]);
const sessionContext = chatState.session_context;
const documentFilter = chatState.document_filter;
const initialContext = chatState.initial_context ?? "";
// Context is locked after first message (qa_history has entries)
const isContextLocked = (chatState.qa_history?.length ?? 0) > 0;
const handleFilterApply = (selected: string[]) => { const handleFilterApply = (selected: string[]) => {
mergeChatState({ document_filter: selected }); setSelectedDocuments(selected);
}; // Convert selected document names to SQL filter for the backend
const filter =
const handleInitialContextChange = (value: string) => { selected.length > 0
if (isContextLocked) return; ? selected
mergeChatState({ initial_context: value || null }); .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 ( return (
<ChatStateContext.Provider value={chatState}> <ChatStateContext.Provider value={ragState}>
<div className="chat-wrapper"> <div className="chat-wrapper">
<div className="chat-container"> <div className="chat-container">
<div className="chat-header"> <div className="chat-header">
@ -485,36 +465,19 @@ function ChatContentInner({
/> />
<button <button
type="button" type="button"
className={`header-btn ${documentFilter.length > 0 ? "has-content" : ""}`} className={`header-btn ${selectedDocuments.length > 0 ? "has-content" : ""}`}
onClick={() => setFilterOpen(true)} onClick={() => setFilterOpen(true)}
title={ title={
documentFilter.length > 0 selectedDocuments.length > 0
? `Filtering: ${documentFilter.length} document(s)` ? `Filtering: ${selectedDocuments.length} document(s)`
: "Filter documents" : "Filter documents"
} }
> >
<FilterIcon /> <FilterIcon />
{documentFilter.length > 0 {selectedDocuments.length > 0
? `Filter (${documentFilter.length})` ? `Filter (${selectedDocuments.length})`
: "Filter"} : "Filter"}
</button> </button>
<button
type="button"
className={`header-btn ${initialContext || sessionContext?.summary ? "has-content" : ""}`}
onClick={() => setContextOpen(true)}
title={
isContextLocked
? sessionContext?.summary
? "View session context"
: "No session context yet"
: initialContext
? "Edit initial context"
: "Set initial context"
}
>
<BrainIcon />
Memory
</button>
</div> </div>
<div className="chat-content"> <div className="chat-content">
<CopilotChatView <CopilotChatView
@ -535,18 +498,10 @@ function ChatContentInner({
<DbInfo /> <DbInfo />
</div> </div>
</div> </div>
<ContextPanel
isOpen={contextOpen}
onClose={() => setContextOpen(false)}
sessionContext={sessionContext}
initialContext={initialContext}
onInitialContextChange={handleInitialContextChange}
isLocked={isContextLocked}
/>
<DocumentFilter <DocumentFilter
isOpen={filterOpen} isOpen={filterOpen}
onClose={() => setFilterOpen(false)} onClose={() => setFilterOpen(false)}
selected={documentFilter} selected={selectedDocuments}
onApply={handleFilterApply} onApply={handleFilterApply}
/> />
</ChatStateContext.Provider> </ChatStateContext.Provider>

View file

@ -1,131 +0,0 @@
"use client";
import { useCallback, useEffect, useId, useState } from "react";
import { formatRelativeTime } from "../lib/format";
import { BrainIcon } from "../lib/icons";
import type { SessionContext } from "../lib/sessionStorage";
interface ContextPanelProps {
isOpen: boolean;
onClose: () => void;
sessionContext: SessionContext | null;
initialContext?: string;
onInitialContextChange?: (value: string) => void;
isLocked?: boolean;
}
export default function ContextPanel({
isOpen,
onClose,
sessionContext,
initialContext = "",
onInitialContextChange,
isLocked = false,
}: ContextPanelProps) {
const titleId = useId();
const [localValue, setLocalValue] = useState(initialContext);
useEffect(() => {
if (isOpen) {
setLocalValue(initialContext);
}
}, [isOpen, initialContext]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
}
},
[onClose],
);
const handleSave = useCallback(() => {
onInitialContextChange?.(localValue);
onClose();
}, [localValue, onInitialContextChange, onClose]);
if (!isOpen) {
return null;
}
const hasSessionContext = sessionContext?.summary?.trim();
// Show edit mode when: not locked AND no session context yet
const isEditMode = !isLocked && !hasSessionContext;
return (
<div
className="context-modal-overlay"
onClick={onClose}
onKeyDown={handleKeyDown}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: modal content wrapper */}
<div
className="context-modal"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<div className="context-modal-header">
<div className="context-modal-icon">
<BrainIcon size={24} strokeWidth={1.5} />
</div>
<h2 id={titleId} className="context-modal-title">
{isEditMode ? "Initial Context" : "Session Context"}
</h2>
</div>
<p className="context-modal-description">
{isEditMode
? "Set background context to guide the conversation. This will be locked after you send your first message."
: "This is what the assistant has learned from your conversation so far. It uses this context to provide more relevant answers."}
</p>
{isEditMode ? (
<textarea
className="context-textarea"
placeholder="Enter any background context or instructions for the assistant..."
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
/>
) : hasSessionContext ? (
<div className="context-content">{sessionContext.summary}</div>
) : (
<div className="context-empty">
<div className="context-empty-icon">
<BrainIcon size={24} strokeWidth={1.5} />
</div>
<div className="context-empty-text">
No context yet. Ask some questions to build context.
</div>
</div>
)}
<div className="context-footer">
<span className="context-timestamp">
{sessionContext?.last_updated
? `Last updated: ${formatRelativeTime(sessionContext.last_updated)}`
: ""}
</span>
<div className="context-footer-buttons">
<button
type="button"
className="context-btn context-btn-close"
onClick={onClose}
>
{isEditMode ? "Cancel" : "Close"}
</button>
{isEditMode && (
<button
type="button"
className="context-btn context-btn-save"
onClick={handleSave}
>
Save
</button>
)}
</div>
</div>
</div>
</div>
);
}

View file

@ -9,26 +9,33 @@ export interface Citation {
content: string; content: string;
} }
export interface QAResponse { export interface QAHistoryEntry {
question: string; question: string;
answer: string; answer: string;
confidence: number;
citations: Citation[]; citations: Citation[];
} }
export interface SessionContext { export interface DocumentInfo {
summary: string; id: string;
last_updated: string | null; title: string;
uri: string;
created: string;
} }
export interface ChatSessionState { export interface ResearchEntry {
initial_context: string | null; question: string;
title: string;
executive_summary: string;
}
// Matches RAGState from the backend skill
export interface RAGState {
citations: Citation[]; citations: Citation[];
citations_history: Citation[][]; qa_history: QAHistoryEntry[];
qa_history: QAResponse[]; document_filter: string | null;
session_context: SessionContext | null; searches: Record<string, unknown[]>;
document_filter: string[]; documents: DocumentInfo[];
citation_registry: Record<string, number>; reports: ResearchEntry[];
} }
export interface StoredMessage { export interface StoredMessage {
@ -42,7 +49,7 @@ export interface StoredSession {
id: string; id: string;
title: string; title: string;
messages: StoredMessage[]; messages: StoredMessage[];
chatState: ChatSessionState; ragState: RAGState;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@ -50,18 +57,24 @@ export interface StoredSession {
const SESSIONS_KEY = "haiku.rag.sessions"; const SESSIONS_KEY = "haiku.rag.sessions";
const ACTIVE_SESSION_KEY = "haiku.rag.activeSession"; const ACTIVE_SESSION_KEY = "haiku.rag.activeSession";
export function normalizeChatState(state?: ChatSessionState): ChatSessionState { export function normalizeRAGState(state?: Partial<RAGState>): RAGState {
return { return {
initial_context: state?.initial_context ?? null,
citations: state?.citations ?? [], citations: state?.citations ?? [],
citations_history: state?.citations_history ?? [],
qa_history: state?.qa_history ?? [], qa_history: state?.qa_history ?? [],
session_context: state?.session_context ?? null, document_filter: state?.document_filter ?? null,
document_filter: state?.document_filter ?? [], searches: state?.searches ?? {},
citation_registry: state?.citation_registry ?? {}, documents: state?.documents ?? [],
reports: state?.reports ?? [],
}; };
} }
// Derive per-turn citation arrays from qa_history
export function deriveCitationsHistory(state: RAGState): Citation[][] {
return state.qa_history
.filter((entry) => entry.citations?.length > 0)
.map((entry) => entry.citations);
}
export function getAllSessions(): StoredSession[] { export function getAllSessions(): StoredSession[] {
const raw = localStorage.getItem(SESSIONS_KEY); const raw = localStorage.getItem(SESSIONS_KEY);
if (!raw) return []; if (!raw) return [];
@ -90,7 +103,7 @@ export function createSession(): StoredSession {
id: crypto.randomUUID(), id: crypto.randomUUID(),
title: "New Session", title: "New Session",
messages: [], messages: [],
chatState: normalizeChatState(), ragState: normalizeRAGState(),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@ -115,7 +128,7 @@ export function saveSession(session: StoredSession): void {
export function updateSessionMessages( export function updateSessionMessages(
id: string, id: string,
messages: StoredMessage[], messages: StoredMessage[],
chatState: ChatSessionState, ragState: RAGState,
): void { ): void {
const sessions = getAllSessions(); const sessions = getAllSessions();
const idx = sessions.findIndex((s) => s.id === id); const idx = sessions.findIndex((s) => s.id === id);
@ -123,7 +136,7 @@ export function updateSessionMessages(
const session = sessions[idx]; const session = sessions[idx];
session.messages = messages; session.messages = messages;
session.chatState = chatState; session.ragState = ragState;
session.updatedAt = new Date().toISOString(); session.updatedAt = new Date().toISOString();
// Derive title from first user message // Derive title from first user message