From 9f17d4d1e170bb5c17c2cad1c56def4d1ce5029a Mon Sep 17 00:00:00 2001 From: Matthias Date: Tue, 16 Jun 2026 10:58:15 +0200 Subject: [PATCH] Add chat sources and citations --- ...s-chat-agent-UI-with-AI-Elements-phases.md | 10 ++- convex/savingsChat.test.ts | 70 ++++++++++++++++++- convex/savingsChat.ts | 51 +++++++++++++- convex/savingsChatHistory.ts | 19 +++++ convex/schema.ts | 11 +++ src/components/chat/AgentChat.test.tsx | 49 ++++++++++++- src/components/chat/AgentChat.tsx | 67 +++++++++++++++++- src/components/chat/agentChatModel.ts | 23 ++++++ src/pages/SavingsChatPage.tsx | 69 +++++++++++++++++- 9 files changed, 356 insertions(+), 13 deletions(-) diff --git a/backlog/tasks/task-10 - Modernize-savings-chat-agent-UI-with-AI-Elements-phases.md b/backlog/tasks/task-10 - Modernize-savings-chat-agent-UI-with-AI-Elements-phases.md index 794bc59..2f71601 100644 --- a/backlog/tasks/task-10 - Modernize-savings-chat-agent-UI-with-AI-Elements-phases.md +++ b/backlog/tasks/task-10 - Modernize-savings-chat-agent-UI-with-AI-Elements-phases.md @@ -4,7 +4,7 @@ title: Modernize savings chat agent UI with AI Elements phases status: In Progress assignee: [] created_date: '2026-06-16 08:38' -updated_date: '2026-06-16 08:46' +updated_date: '2026-06-16 08:57' labels: [] dependencies: [] priority: high @@ -21,8 +21,8 @@ Implement the planned AI Elements-inspired savings chat agent UI in three sequen - [x] #1 Phase 1 replaces the basic chat surface with reusable conversation, message, prompt input, and tool trace UI primitives - [x] #2 Phase 2 adds a safe reasoning/work-progress disclosure derived from existing tool traces, without exposing hidden chain-of-thought -- [ ] #3 Phase 3 adds structured source/citation support through stored assistant metadata and visible UI affordances -- [ ] #4 Each phase is covered by failing-first tests, verified after implementation, and committed separately +- [x] #3 Phase 3 adds structured source/citation support through stored assistant metadata and visible UI affordances +- [x] #4 Each phase is covered by failing-first tests, verified after implementation, and committed separately ## Implementation Plan @@ -43,4 +43,8 @@ Implement the planned AI Elements-inspired savings chat agent UI in three sequen Phase 1 complete: added AgentChat primitives for conversation, message rendering, prompt input, and tool trace disclosure; integrated SavingsChatPage. Verification: npx vitest src/components/chat/AgentChat.test.tsx --run, npx eslint targeted chat/page files, npm run build (Vite chunk-size warning only). Phase 2 complete locally: added safe work-progress/reasoning disclosure derived from toolTrace result summaries, removed raw inputSummary display from the disclosure, and added active progress state while a response is pending. Verification: npx vitest src/components/chat/AgentChat.test.tsx --run (9 tests), targeted eslint, npm run build (Vite chunk-size warning only). Spec subagent review approved. + +Phase 3 complete locally: added optional sources/citations metadata to chat messages, preserved it through import/list/append history flows, generated private finance sources from executed tool traces in savings chat responses, and rendered source lists plus inline citation markers in AgentChat. Verification: npx vitest src/components/chat/AgentChat.test.tsx convex/savingsChat.test.ts --run (37 tests), targeted eslint, npm run build (Vite chunk-size warning only). + +Phase 3 review follow-up: addressed spec blocker by generating citation metadata from tool-derived sources and appending visible citation markers to live ask/sendMessage answers. Re-verified: npx vitest src/components/chat/AgentChat.test.tsx convex/savingsChat.test.ts --run (37 tests), targeted eslint, npm run build. Spec re-review approved. diff --git a/convex/savingsChat.test.ts b/convex/savingsChat.test.ts index 0b8ba1d..7625275 100644 --- a/convex/savingsChat.test.ts +++ b/convex/savingsChat.test.ts @@ -253,6 +253,14 @@ describe("savingsChatHistory", () => { resultSummary: "2 Umsätze, Saldo 100.00€, 1 Kategorien", }, ], + sources: [ + { + id: "tool-1", + title: "summarize_spending", + description: "2 Umsätze, Saldo 100.00€, 1 Kategorien", + }, + ], + citations: [{ marker: "1", sourceId: "tool-1" }], }, ], }); @@ -282,6 +290,14 @@ describe("savingsChatHistory", () => { resultSummary: "2 Umsätze, Saldo 100.00€, 1 Kategorien", }, ], + sources: [ + { + id: "tool-1", + title: "summarize_spending", + description: "2 Umsätze, Saldo 100.00€, 1 Kategorien", + }, + ], + citations: [{ marker: "1", sourceId: "tool-1" }], }, ]); @@ -440,8 +456,24 @@ describe("savingsChat.sendMessage", () => { basis: "effective", }); - expect(result.answer).toBe("Agenten-Antwort"); + expect(result.answer).toBe("Agenten-Antwort [1] [2]"); expect(result.toolTrace).toHaveLength(2); + expect(result.sources).toEqual([ + { + id: "tool-1", + title: "get_transactions", + description: "2 Umsätze, Saldo 2880.00€, vollständig", + }, + { + id: "tool-2", + title: "summarize_spending", + description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien", + }, + ]); + expect(result.citations).toEqual([ + { marker: "1", sourceId: "tool-1" }, + { marker: "2", sourceId: "tool-2" }, + ]); const generateCall = vi.mocked(generateText).mock.calls[0][0] as { messages: Array<{ role: string; content: string }>; @@ -461,7 +493,7 @@ describe("savingsChat.sendMessage", () => { { role: "user", content: "Wie sieht Februar aus?" }, { role: "assistant", - content: "Agenten-Antwort", + content: "Agenten-Antwort [1] [2]", toolTrace: [ { name: "get_transactions", @@ -474,6 +506,22 @@ describe("savingsChat.sendMessage", () => { resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien", }, ], + sources: [ + { + id: "tool-1", + title: "get_transactions", + description: "2 Umsätze, Saldo 2880.00€, vollständig", + }, + { + id: "tool-2", + title: "summarize_spending", + description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien", + }, + ], + citations: [ + { marker: "1", sourceId: "tool-1" }, + { marker: "2", sourceId: "tool-2" }, + ], }, ]); } finally { @@ -1382,7 +1430,7 @@ describe("savingsChat read-only agent tools", () => { basis: "effective", }); - expect(result.answer).toBe("Agenten-Antwort"); + expect(result.answer).toBe("Agenten-Antwort [1] [2]"); expect(result.model).toBe("gpt-5.4-mini"); expect(result.usedTransactions).toBe(2); expect(result.usedBalance).toEqual({ income: 3000, expenses: -120, balance: 2880 }); @@ -1398,6 +1446,22 @@ describe("savingsChat read-only agent tools", () => { resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien", }, ]); + expect(result.sources).toEqual([ + { + id: "tool-1", + title: "get_transactions", + description: "2 Umsätze, Saldo 2880.00€, vollständig", + }, + { + id: "tool-2", + title: "summarize_spending", + description: "2 Umsätze, Saldo 2880.00€, 1 Kategorien", + }, + ]); + expect(result.citations).toEqual([ + { marker: "1", sourceId: "tool-1" }, + { marker: "2", sourceId: "tool-2" }, + ]); expect(JSON.stringify(result.toolTrace)).not.toContain("RAW PAYLOAD"); expect(JSON.stringify(result.toolTrace)).not.toContain("private note"); diff --git a/convex/savingsChat.ts b/convex/savingsChat.ts index 5dc796d..f94ae0b 100644 --- a/convex/savingsChat.ts +++ b/convex/savingsChat.ts @@ -46,8 +46,12 @@ type ChatAskResult = { usedTransactions: number; usedBalance: { income: number; expenses: number; balance: number }; toolTrace: ToolTrace[]; + sources: ChatSource[]; + citations: ChatCitation[]; }; type ToolTrace = { name: string; inputSummary: string; resultSummary: string }; +type ChatSource = { id: string; title: string; description?: string }; +type ChatCitation = { marker: string; sourceId: string }; type TransactionTypeFilter = "income" | "expense"; type CategoryFilterStatus = "resolved" | "unresolved" | "ambiguous"; type CategoryFilterDiagnostic = { @@ -287,6 +291,15 @@ const toolTraceValidator = v.object({ inputSummary: v.string(), resultSummary: v.string(), }); +const sourceValidator = v.object({ + id: v.string(), + title: v.string(), + description: v.optional(v.string()), +}); +const citationValidator = v.object({ + marker: v.string(), + sourceId: v.string(), +}); const toolScopeValidator = v.object(contextArgsValidator); @@ -1831,6 +1844,29 @@ export function buildToolTraceFromSteps(steps: unknown[]): ToolTrace[] { return trace; } +export function buildSourcesFromToolTrace(toolTrace: ToolTrace[]): ChatSource[] { + return toolTrace.map((trace, index) => ({ + id: `tool-${index + 1}`, + title: trace.name, + description: trace.resultSummary, + })); +} + +export function buildCitationsFromSources(sources: ChatSource[]): ChatCitation[] { + return sources.map((source, index) => ({ + marker: `${index + 1}`, + sourceId: source.id, + })); +} + +function appendMissingCitationMarkers(answer: string, citations: ChatCitation[]): string { + const missingMarkers = citations + .map((citation) => citation.marker) + .filter((marker) => !answer.includes(`[${marker}]`)); + if (missingMarkers.length === 0) return answer; + return `${answer.trimEnd()} ${missingMarkers.map((marker) => `[${marker}]`).join(" ")}`; +} + const transactionToolInputSchema = z.object({ from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."), to: z.string().optional().describe("Optionales Enddatum im Format YYYY-MM-DD."), @@ -2073,16 +2109,21 @@ async function generateSavingsChatResponse( tools: savingsTools, stopWhen: stepCountIs(5), }); + const toolTrace = buildToolTraceFromSteps(result.steps); + const sources = buildSourcesFromToolTrace(toolTrace); + const citations = buildCitationsFromSources(sources); return { model: modelName, - answer: result.text, + answer: appendMissingCitationMarkers(result.text, citations), usedTransactions: selectedSummary.totals.transactionCount, usedBalance: { income: selectedSummary.totals.income, expenses: selectedSummary.totals.expenses, balance: selectedSummary.totals.balance, }, - toolTrace: buildToolTraceFromSteps(result.steps), + toolTrace, + sources, + citations, }; } catch (error) { lastError = error; @@ -2114,6 +2155,8 @@ export const ask = action({ balance: v.number(), }), toolTrace: v.array(toolTraceValidator), + sources: v.array(sourceValidator), + citations: v.array(citationValidator), }), handler: async (ctx, args): Promise => { return await generateSavingsChatResponse(ctx, { @@ -2145,6 +2188,8 @@ export const sendMessage = action({ balance: v.number(), }), toolTrace: v.array(toolTraceValidator), + sources: v.array(sourceValidator), + citations: v.array(citationValidator), }), handler: async (ctx, args): Promise => { const content = args.content.trim(); @@ -2183,6 +2228,8 @@ export const sendMessage = action({ sessionId: args.sessionId, content: response.answer, toolTrace: response.toolTrace, + sources: response.sources, + citations: response.citations, }); return response; }, diff --git a/convex/savingsChatHistory.ts b/convex/savingsChatHistory.ts index 0d4d4f5..e9e9d8b 100644 --- a/convex/savingsChatHistory.ts +++ b/convex/savingsChatHistory.ts @@ -13,6 +13,15 @@ const toolTraceValidator = v.object({ inputSummary: v.string(), resultSummary: v.string(), }); +const sourceValidator = v.object({ + id: v.string(), + title: v.string(), + description: v.optional(v.string()), +}); +const citationValidator = v.object({ + marker: v.string(), + sourceId: v.string(), +}); const chatRoleValidator = v.union(v.literal("user"), v.literal("assistant")); @@ -20,6 +29,8 @@ const importMessageValidator = v.object({ role: chatRoleValidator, content: v.string(), toolTrace: v.optional(v.array(toolTraceValidator)), + sources: v.optional(v.array(sourceValidator)), + citations: v.optional(v.array(citationValidator)), }); const sessionValidator = v.object({ @@ -43,6 +54,8 @@ const messageValidator = v.object({ content: v.string(), createdAt: v.number(), toolTrace: v.optional(v.array(toolTraceValidator)), + sources: v.optional(v.array(sourceValidator)), + citations: v.optional(v.array(citationValidator)), }); const promptMessageValidator = v.object({ @@ -181,6 +194,8 @@ export const importLocalSession = mutation({ content: message.content, createdAt: args.createdAt + index, ...(message.toolTrace ? { toolTrace: message.toolTrace } : {}), + ...(message.sources ? { sources: message.sources } : {}), + ...(message.citations ? { citations: message.citations } : {}), }); } @@ -219,6 +234,8 @@ export const appendAssistantMessage = internalMutation({ sessionId: v.id("chatSessions"), content: v.string(), toolTrace: v.optional(v.array(toolTraceValidator)), + sources: v.optional(v.array(sourceValidator)), + citations: v.optional(v.array(citationValidator)), }, returns: v.object({ messageId: v.id("chatMessages") }), handler: async (ctx, args) => { @@ -232,6 +249,8 @@ export const appendAssistantMessage = internalMutation({ content: args.content, createdAt: now, ...(args.toolTrace ? { toolTrace: args.toolTrace } : {}), + ...(args.sources ? { sources: args.sources } : {}), + ...(args.citations ? { citations: args.citations } : {}), }); await ctx.db.patch(args.sessionId, { updatedAt: now, diff --git a/convex/schema.ts b/convex/schema.ts index ee30cf6..c9ab57e 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -15,6 +15,15 @@ const chatToolTrace = v.object({ inputSummary: v.string(), resultSummary: v.string(), }); +const chatSource = v.object({ + id: v.string(), + title: v.string(), + description: v.optional(v.string()), +}); +const chatCitation = v.object({ + marker: v.string(), + sourceId: v.string(), +}); export default defineSchema({ ...authTables, @@ -201,5 +210,7 @@ export default defineSchema({ content: v.string(), createdAt: v.number(), toolTrace: v.optional(v.array(chatToolTrace)), + sources: v.optional(v.array(chatSource)), + citations: v.optional(v.array(chatCitation)), }).index("by_user_session_created", ["userId", "sessionId", "createdAt"]), }); diff --git a/src/components/chat/AgentChat.test.tsx b/src/components/chat/AgentChat.test.tsx index 7a4b73b..078ad04 100644 --- a/src/components/chat/AgentChat.test.tsx +++ b/src/components/chat/AgentChat.test.tsx @@ -6,7 +6,11 @@ import { AgentPromptInput, type AgentChatMessage, } from "./AgentChat"; -import { buildReasoningSteps, getToolTraceSummary } from "./agentChatModel"; +import { + buildSourcesFromToolTrace, + buildReasoningSteps, + getToolTraceSummary, +} from "./agentChatModel"; const assistantMessage: AgentChatMessage = { id: "assistant-1", @@ -148,3 +152,46 @@ describe("AgentChat phase 2 reasoning disclosure", () => { expect(markup).toContain("data-status=\"active\""); }); }); + +describe("AgentChat phase 3 sources and inline citations", () => { + test("builds private finance sources from tool traces", () => { + expect(buildSourcesFromToolTrace(assistantMessage.toolTrace)).toEqual([ + { + id: "tool-1", + title: "summarize_transactions", + description: "12 Umsaetze zusammengefasst", + }, + { + id: "tool-2", + title: "list_transactions", + description: "1 Treffer", + }, + ]); + }); + + test("renders source list and inline citation markers for assistant messages", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Quellen"); + expect(markup).toContain("list_transactions"); + expect(markup).toContain("1 Mietumsatz gefunden"); + expect(markup).toContain("[1]"); + expect(markup).toContain("data-source-id=\"tool-1\""); + }); +}); diff --git a/src/components/chat/AgentChat.tsx b/src/components/chat/AgentChat.tsx index 026fef2..c98089d 100644 --- a/src/components/chat/AgentChat.tsx +++ b/src/components/chat/AgentChat.tsx @@ -11,7 +11,9 @@ import { buildReasoningSteps, getToolTraceSummary, type AgentChatMessage, + type AgentCitation, type AgentReasoningStep, + type AgentSource, type AgentToolTrace, } from "./agentChatModel"; export type { AgentChatMessage, AgentToolTrace } from "./agentChatModel"; @@ -80,15 +82,78 @@ export function AgentMessage({ message, className, ...props }: AgentMessageProps

{isUser ? "User" : "Assistant"}

-

{message.content}

+

+ +

{!isUser && message.toolTrace && message.toolTrace.length > 0 && ( )} + {!isUser && message.sources && message.sources.length > 0 && ( + + )} ); } +function MessageContentWithCitations({ + content, + citations, +}: { + content: string; + citations?: AgentCitation[]; +}) { + if (!citations || citations.length === 0) return content; + const citationByMarker = new Map(citations.map((citation) => [citation.marker, citation])); + const parts = content.split(/(\[\d+\])/g); + + return ( + <> + {parts.map((part, index) => { + const marker = part.match(/^\[(\d+)\]$/)?.[1]; + const citation = marker ? citationByMarker.get(marker) : undefined; + if (!citation) return {part}; + + return ( + + {part} + + ); + })} + + ); +} + +function AgentSources({ sources }: { sources: AgentSource[] }) { + return ( +
+

Quellen

+
+ {sources.map((source) => ( +
+

{source.title}

+ {source.description && ( +

{source.description}

+ )} +
+ ))} +
+
+ ); +} + function AgentToolTracePanel({ toolTrace }: { toolTrace: AgentToolTrace[] }) { const steps = buildReasoningSteps(toolTrace); diff --git a/src/components/chat/agentChatModel.ts b/src/components/chat/agentChatModel.ts index 58a7960..098f454 100644 --- a/src/components/chat/agentChatModel.ts +++ b/src/components/chat/agentChatModel.ts @@ -9,6 +9,19 @@ export type AgentChatMessage = { role: "user" | "assistant"; content: string; toolTrace?: AgentToolTrace[]; + sources?: AgentSource[]; + citations?: AgentCitation[]; +}; + +export type AgentSource = { + id: string; + title: string; + description?: string; +}; + +export type AgentCitation = { + marker: string; + sourceId: string; }; export type AgentReasoningStep = { @@ -31,3 +44,13 @@ export function buildReasoningSteps(toolTrace: AgentToolTrace[] | undefined): Ag status: "complete", })); } + +export function buildSourcesFromToolTrace(toolTrace: AgentToolTrace[] | undefined): AgentSource[] { + if (!toolTrace || toolTrace.length === 0) return []; + + return toolTrace.map((tool, index) => ({ + id: `tool-${index + 1}`, + title: tool.name, + description: tool.resultSummary, + })); +} diff --git a/src/pages/SavingsChatPage.tsx b/src/pages/SavingsChatPage.tsx index 9616294..d3622c9 100644 --- a/src/pages/SavingsChatPage.tsx +++ b/src/pages/SavingsChatPage.tsx @@ -20,11 +20,22 @@ type ToolTrace = { inputSummary: string; resultSummary: string; }; +type ChatSource = { + id: string; + title: string; + description?: string; +}; +type ChatCitation = { + marker: string; + sourceId: string; +}; type UserChatMessage = { role: "user"; content: string }; type AssistantChatMessage = { role: "assistant"; content: string; toolTrace?: ToolTrace[]; + sources?: ChatSource[]; + citations?: ChatCitation[]; }; type ChatMessage = UserChatMessage | AssistantChatMessage; type LegacyChatSession = { @@ -69,6 +80,46 @@ function normalizeToolTrace(value: unknown): ToolTrace[] | undefined { return trace.length > 0 ? trace : undefined; } +function normalizeSources(value: unknown): ChatSource[] | undefined { + if (!Array.isArray(value)) return undefined; + const sources = value.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const candidate = item as Record; + if (typeof candidate.id !== "string" || typeof candidate.title !== "string") return []; + if ( + candidate.description !== undefined && + typeof candidate.description !== "string" + ) { + return []; + } + + return [ + { + id: candidate.id, + title: candidate.title, + ...(candidate.description ? { description: candidate.description } : {}), + }, + ]; + }); + + return sources.length > 0 ? sources : undefined; +} + +function normalizeCitations(value: unknown): ChatCitation[] | undefined { + if (!Array.isArray(value)) return undefined; + const citations = value.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const candidate = item as Record; + if (typeof candidate.marker !== "string" || typeof candidate.sourceId !== "string") { + return []; + } + + return [{ marker: candidate.marker, sourceId: candidate.sourceId }]; + }); + + return citations.length > 0 ? citations : undefined; +} + function normalizeMessage(value: unknown): ChatMessage | null { if (!value || typeof value !== "object") return null; const candidate = value as Record; @@ -78,9 +129,15 @@ function normalizeMessage(value: unknown): ChatMessage | null { } if (candidate.role === "assistant") { const toolTrace = normalizeToolTrace(candidate.toolTrace); - return toolTrace - ? { role: "assistant", content: candidate.content, toolTrace } - : { role: "assistant", content: candidate.content }; + const sources = normalizeSources(candidate.sources); + const citations = normalizeCitations(candidate.citations); + return { + role: "assistant", + content: candidate.content, + ...(toolTrace ? { toolTrace } : {}), + ...(sources ? { sources } : {}), + ...(citations ? { citations } : {}), + }; } return null; @@ -175,6 +232,12 @@ export function SavingsChatPage() { ...(message.role === "assistant" && message.toolTrace ? { toolTrace: message.toolTrace } : {}), + ...(message.role === "assistant" && message.sources + ? { sources: message.sources } + : {}), + ...(message.role === "assistant" && message.citations + ? { citations: message.citations } + : {}), })), [displayMessages], );