Add chat sources and citations
This commit is contained in:
@@ -4,7 +4,7 @@ title: Modernize savings chat agent UI with AI Elements phases
|
|||||||
status: In Progress
|
status: In Progress
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-06-16 08:38'
|
created_date: '2026-06-16 08:38'
|
||||||
updated_date: '2026-06-16 08:46'
|
updated_date: '2026-06-16 08:57'
|
||||||
labels: []
|
labels: []
|
||||||
dependencies: []
|
dependencies: []
|
||||||
priority: high
|
priority: high
|
||||||
@@ -21,8 +21,8 @@ Implement the planned AI Elements-inspired savings chat agent UI in three sequen
|
|||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [x] #1 Phase 1 replaces the basic chat surface with reusable conversation, message, prompt input, and tool trace UI primitives
|
- [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
|
- [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
|
- [x] #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] #4 Each phase is covered by failing-first tests, verified after implementation, and committed separately
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
## Implementation Plan
|
## 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 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 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.
|
||||||
<!-- SECTION:NOTES:END -->
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -253,6 +253,14 @@ describe("savingsChatHistory", () => {
|
|||||||
resultSummary: "2 Umsätze, Saldo 100.00€, 1 Kategorien",
|
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",
|
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",
|
basis: "effective",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.answer).toBe("Agenten-Antwort");
|
expect(result.answer).toBe("Agenten-Antwort [1] [2]");
|
||||||
expect(result.toolTrace).toHaveLength(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 {
|
const generateCall = vi.mocked(generateText).mock.calls[0][0] as {
|
||||||
messages: Array<{ role: string; content: string }>;
|
messages: Array<{ role: string; content: string }>;
|
||||||
@@ -461,7 +493,7 @@ describe("savingsChat.sendMessage", () => {
|
|||||||
{ role: "user", content: "Wie sieht Februar aus?" },
|
{ role: "user", content: "Wie sieht Februar aus?" },
|
||||||
{
|
{
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "Agenten-Antwort",
|
content: "Agenten-Antwort [1] [2]",
|
||||||
toolTrace: [
|
toolTrace: [
|
||||||
{
|
{
|
||||||
name: "get_transactions",
|
name: "get_transactions",
|
||||||
@@ -474,6 +506,22 @@ describe("savingsChat.sendMessage", () => {
|
|||||||
resultSummary: "2 Umsätze, Saldo 2880.00€, 1 Kategorien",
|
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 {
|
} finally {
|
||||||
@@ -1382,7 +1430,7 @@ describe("savingsChat read-only agent tools", () => {
|
|||||||
basis: "effective",
|
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.model).toBe("gpt-5.4-mini");
|
||||||
expect(result.usedTransactions).toBe(2);
|
expect(result.usedTransactions).toBe(2);
|
||||||
expect(result.usedBalance).toEqual({ income: 3000, expenses: -120, balance: 2880 });
|
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",
|
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("RAW PAYLOAD");
|
||||||
expect(JSON.stringify(result.toolTrace)).not.toContain("private note");
|
expect(JSON.stringify(result.toolTrace)).not.toContain("private note");
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,12 @@ type ChatAskResult = {
|
|||||||
usedTransactions: number;
|
usedTransactions: number;
|
||||||
usedBalance: { income: number; expenses: number; balance: number };
|
usedBalance: { income: number; expenses: number; balance: number };
|
||||||
toolTrace: ToolTrace[];
|
toolTrace: ToolTrace[];
|
||||||
|
sources: ChatSource[];
|
||||||
|
citations: ChatCitation[];
|
||||||
};
|
};
|
||||||
type ToolTrace = { name: string; inputSummary: string; resultSummary: string };
|
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 TransactionTypeFilter = "income" | "expense";
|
||||||
type CategoryFilterStatus = "resolved" | "unresolved" | "ambiguous";
|
type CategoryFilterStatus = "resolved" | "unresolved" | "ambiguous";
|
||||||
type CategoryFilterDiagnostic = {
|
type CategoryFilterDiagnostic = {
|
||||||
@@ -287,6 +291,15 @@ const toolTraceValidator = v.object({
|
|||||||
inputSummary: v.string(),
|
inputSummary: v.string(),
|
||||||
resultSummary: 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);
|
const toolScopeValidator = v.object(contextArgsValidator);
|
||||||
|
|
||||||
@@ -1831,6 +1844,29 @@ export function buildToolTraceFromSteps(steps: unknown[]): ToolTrace[] {
|
|||||||
return trace;
|
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({
|
const transactionToolInputSchema = z.object({
|
||||||
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
|
from: z.string().optional().describe("Optionales Startdatum im Format YYYY-MM-DD."),
|
||||||
to: z.string().optional().describe("Optionales Enddatum 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,
|
tools: savingsTools,
|
||||||
stopWhen: stepCountIs(5),
|
stopWhen: stepCountIs(5),
|
||||||
});
|
});
|
||||||
|
const toolTrace = buildToolTraceFromSteps(result.steps);
|
||||||
|
const sources = buildSourcesFromToolTrace(toolTrace);
|
||||||
|
const citations = buildCitationsFromSources(sources);
|
||||||
return {
|
return {
|
||||||
model: modelName,
|
model: modelName,
|
||||||
answer: result.text,
|
answer: appendMissingCitationMarkers(result.text, citations),
|
||||||
usedTransactions: selectedSummary.totals.transactionCount,
|
usedTransactions: selectedSummary.totals.transactionCount,
|
||||||
usedBalance: {
|
usedBalance: {
|
||||||
income: selectedSummary.totals.income,
|
income: selectedSummary.totals.income,
|
||||||
expenses: selectedSummary.totals.expenses,
|
expenses: selectedSummary.totals.expenses,
|
||||||
balance: selectedSummary.totals.balance,
|
balance: selectedSummary.totals.balance,
|
||||||
},
|
},
|
||||||
toolTrace: buildToolTraceFromSteps(result.steps),
|
toolTrace,
|
||||||
|
sources,
|
||||||
|
citations,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
@@ -2114,6 +2155,8 @@ export const ask = action({
|
|||||||
balance: v.number(),
|
balance: v.number(),
|
||||||
}),
|
}),
|
||||||
toolTrace: v.array(toolTraceValidator),
|
toolTrace: v.array(toolTraceValidator),
|
||||||
|
sources: v.array(sourceValidator),
|
||||||
|
citations: v.array(citationValidator),
|
||||||
}),
|
}),
|
||||||
handler: async (ctx, args): Promise<ChatAskResult> => {
|
handler: async (ctx, args): Promise<ChatAskResult> => {
|
||||||
return await generateSavingsChatResponse(ctx, {
|
return await generateSavingsChatResponse(ctx, {
|
||||||
@@ -2145,6 +2188,8 @@ export const sendMessage = action({
|
|||||||
balance: v.number(),
|
balance: v.number(),
|
||||||
}),
|
}),
|
||||||
toolTrace: v.array(toolTraceValidator),
|
toolTrace: v.array(toolTraceValidator),
|
||||||
|
sources: v.array(sourceValidator),
|
||||||
|
citations: v.array(citationValidator),
|
||||||
}),
|
}),
|
||||||
handler: async (ctx, args): Promise<ChatAskResult> => {
|
handler: async (ctx, args): Promise<ChatAskResult> => {
|
||||||
const content = args.content.trim();
|
const content = args.content.trim();
|
||||||
@@ -2183,6 +2228,8 @@ export const sendMessage = action({
|
|||||||
sessionId: args.sessionId,
|
sessionId: args.sessionId,
|
||||||
content: response.answer,
|
content: response.answer,
|
||||||
toolTrace: response.toolTrace,
|
toolTrace: response.toolTrace,
|
||||||
|
sources: response.sources,
|
||||||
|
citations: response.citations,
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ const toolTraceValidator = v.object({
|
|||||||
inputSummary: v.string(),
|
inputSummary: v.string(),
|
||||||
resultSummary: 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"));
|
const chatRoleValidator = v.union(v.literal("user"), v.literal("assistant"));
|
||||||
|
|
||||||
@@ -20,6 +29,8 @@ const importMessageValidator = v.object({
|
|||||||
role: chatRoleValidator,
|
role: chatRoleValidator,
|
||||||
content: v.string(),
|
content: v.string(),
|
||||||
toolTrace: v.optional(v.array(toolTraceValidator)),
|
toolTrace: v.optional(v.array(toolTraceValidator)),
|
||||||
|
sources: v.optional(v.array(sourceValidator)),
|
||||||
|
citations: v.optional(v.array(citationValidator)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const sessionValidator = v.object({
|
const sessionValidator = v.object({
|
||||||
@@ -43,6 +54,8 @@ const messageValidator = v.object({
|
|||||||
content: v.string(),
|
content: v.string(),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
toolTrace: v.optional(v.array(toolTraceValidator)),
|
toolTrace: v.optional(v.array(toolTraceValidator)),
|
||||||
|
sources: v.optional(v.array(sourceValidator)),
|
||||||
|
citations: v.optional(v.array(citationValidator)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const promptMessageValidator = v.object({
|
const promptMessageValidator = v.object({
|
||||||
@@ -181,6 +194,8 @@ export const importLocalSession = mutation({
|
|||||||
content: message.content,
|
content: message.content,
|
||||||
createdAt: args.createdAt + index,
|
createdAt: args.createdAt + index,
|
||||||
...(message.toolTrace ? { toolTrace: message.toolTrace } : {}),
|
...(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"),
|
sessionId: v.id("chatSessions"),
|
||||||
content: v.string(),
|
content: v.string(),
|
||||||
toolTrace: v.optional(v.array(toolTraceValidator)),
|
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") }),
|
returns: v.object({ messageId: v.id("chatMessages") }),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -232,6 +249,8 @@ export const appendAssistantMessage = internalMutation({
|
|||||||
content: args.content,
|
content: args.content,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
...(args.toolTrace ? { toolTrace: args.toolTrace } : {}),
|
...(args.toolTrace ? { toolTrace: args.toolTrace } : {}),
|
||||||
|
...(args.sources ? { sources: args.sources } : {}),
|
||||||
|
...(args.citations ? { citations: args.citations } : {}),
|
||||||
});
|
});
|
||||||
await ctx.db.patch(args.sessionId, {
|
await ctx.db.patch(args.sessionId, {
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -15,6 +15,15 @@ const chatToolTrace = v.object({
|
|||||||
inputSummary: v.string(),
|
inputSummary: v.string(),
|
||||||
resultSummary: 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({
|
export default defineSchema({
|
||||||
...authTables,
|
...authTables,
|
||||||
@@ -201,5 +210,7 @@ export default defineSchema({
|
|||||||
content: v.string(),
|
content: v.string(),
|
||||||
createdAt: v.number(),
|
createdAt: v.number(),
|
||||||
toolTrace: v.optional(v.array(chatToolTrace)),
|
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"]),
|
}).index("by_user_session_created", ["userId", "sessionId", "createdAt"]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
AgentPromptInput,
|
AgentPromptInput,
|
||||||
type AgentChatMessage,
|
type AgentChatMessage,
|
||||||
} from "./AgentChat";
|
} from "./AgentChat";
|
||||||
import { buildReasoningSteps, getToolTraceSummary } from "./agentChatModel";
|
import {
|
||||||
|
buildSourcesFromToolTrace,
|
||||||
|
buildReasoningSteps,
|
||||||
|
getToolTraceSummary,
|
||||||
|
} from "./agentChatModel";
|
||||||
|
|
||||||
const assistantMessage: AgentChatMessage = {
|
const assistantMessage: AgentChatMessage = {
|
||||||
id: "assistant-1",
|
id: "assistant-1",
|
||||||
@@ -148,3 +152,46 @@ describe("AgentChat phase 2 reasoning disclosure", () => {
|
|||||||
expect(markup).toContain("data-status=\"active\"");
|
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(
|
||||||
|
<AgentMessage
|
||||||
|
message={{
|
||||||
|
id: "assistant-cited",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Die Miete war der groesste Posten [1].",
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
id: "tool-1",
|
||||||
|
title: "list_transactions",
|
||||||
|
description: "1 Mietumsatz gefunden",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
citations: [{ marker: "1", sourceId: "tool-1" }],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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\"");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ import {
|
|||||||
buildReasoningSteps,
|
buildReasoningSteps,
|
||||||
getToolTraceSummary,
|
getToolTraceSummary,
|
||||||
type AgentChatMessage,
|
type AgentChatMessage,
|
||||||
|
type AgentCitation,
|
||||||
type AgentReasoningStep,
|
type AgentReasoningStep,
|
||||||
|
type AgentSource,
|
||||||
type AgentToolTrace,
|
type AgentToolTrace,
|
||||||
} from "./agentChatModel";
|
} from "./agentChatModel";
|
||||||
export type { AgentChatMessage, AgentToolTrace } from "./agentChatModel";
|
export type { AgentChatMessage, AgentToolTrace } from "./agentChatModel";
|
||||||
@@ -80,15 +82,78 @@ export function AgentMessage({ message, className, ...props }: AgentMessageProps
|
|||||||
<p className="text-[0.7rem] font-medium uppercase text-muted-foreground">
|
<p className="text-[0.7rem] font-medium uppercase text-muted-foreground">
|
||||||
{isUser ? "User" : "Assistant"}
|
{isUser ? "User" : "Assistant"}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 whitespace-pre-wrap leading-6">{message.content}</p>
|
<p className="mt-1 whitespace-pre-wrap leading-6">
|
||||||
|
<MessageContentWithCitations
|
||||||
|
content={message.content}
|
||||||
|
citations={message.citations}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
{!isUser && message.toolTrace && message.toolTrace.length > 0 && (
|
{!isUser && message.toolTrace && message.toolTrace.length > 0 && (
|
||||||
<AgentToolTracePanel toolTrace={message.toolTrace} />
|
<AgentToolTracePanel toolTrace={message.toolTrace} />
|
||||||
)}
|
)}
|
||||||
|
{!isUser && message.sources && message.sources.length > 0 && (
|
||||||
|
<AgentSources sources={message.sources} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 <span key={`${part}-${index}`}>{part}</span>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="font-medium text-foreground"
|
||||||
|
aria-label={`Quelle ${citation.marker}: ${citation.sourceId}`}
|
||||||
|
data-source-id={citation.sourceId}
|
||||||
|
key={`${citation.sourceId}-${index}`}
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentSources({ sources }: { sources: AgentSource[] }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-md border bg-muted/20 p-2">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Quellen</p>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{sources.map((source) => (
|
||||||
|
<div
|
||||||
|
className="rounded-md bg-background/80 p-2 text-xs"
|
||||||
|
data-source-id={source.id}
|
||||||
|
key={source.id}
|
||||||
|
>
|
||||||
|
<p className="font-medium text-foreground">{source.title}</p>
|
||||||
|
{source.description && (
|
||||||
|
<p className="mt-1 text-muted-foreground">{source.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AgentToolTracePanel({ toolTrace }: { toolTrace: AgentToolTrace[] }) {
|
function AgentToolTracePanel({ toolTrace }: { toolTrace: AgentToolTrace[] }) {
|
||||||
const steps = buildReasoningSteps(toolTrace);
|
const steps = buildReasoningSteps(toolTrace);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,19 @@ export type AgentChatMessage = {
|
|||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
toolTrace?: AgentToolTrace[];
|
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 = {
|
export type AgentReasoningStep = {
|
||||||
@@ -31,3 +44,13 @@ export function buildReasoningSteps(toolTrace: AgentToolTrace[] | undefined): Ag
|
|||||||
status: "complete",
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,11 +20,22 @@ type ToolTrace = {
|
|||||||
inputSummary: string;
|
inputSummary: string;
|
||||||
resultSummary: string;
|
resultSummary: string;
|
||||||
};
|
};
|
||||||
|
type ChatSource = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
type ChatCitation = {
|
||||||
|
marker: string;
|
||||||
|
sourceId: string;
|
||||||
|
};
|
||||||
type UserChatMessage = { role: "user"; content: string };
|
type UserChatMessage = { role: "user"; content: string };
|
||||||
type AssistantChatMessage = {
|
type AssistantChatMessage = {
|
||||||
role: "assistant";
|
role: "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
toolTrace?: ToolTrace[];
|
toolTrace?: ToolTrace[];
|
||||||
|
sources?: ChatSource[];
|
||||||
|
citations?: ChatCitation[];
|
||||||
};
|
};
|
||||||
type ChatMessage = UserChatMessage | AssistantChatMessage;
|
type ChatMessage = UserChatMessage | AssistantChatMessage;
|
||||||
type LegacyChatSession = {
|
type LegacyChatSession = {
|
||||||
@@ -69,6 +80,46 @@ function normalizeToolTrace(value: unknown): ToolTrace[] | undefined {
|
|||||||
return trace.length > 0 ? trace : 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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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 {
|
function normalizeMessage(value: unknown): ChatMessage | null {
|
||||||
if (!value || typeof value !== "object") return null;
|
if (!value || typeof value !== "object") return null;
|
||||||
const candidate = value as Record<string, unknown>;
|
const candidate = value as Record<string, unknown>;
|
||||||
@@ -78,9 +129,15 @@ function normalizeMessage(value: unknown): ChatMessage | null {
|
|||||||
}
|
}
|
||||||
if (candidate.role === "assistant") {
|
if (candidate.role === "assistant") {
|
||||||
const toolTrace = normalizeToolTrace(candidate.toolTrace);
|
const toolTrace = normalizeToolTrace(candidate.toolTrace);
|
||||||
return toolTrace
|
const sources = normalizeSources(candidate.sources);
|
||||||
? { role: "assistant", content: candidate.content, toolTrace }
|
const citations = normalizeCitations(candidate.citations);
|
||||||
: { role: "assistant", content: candidate.content };
|
return {
|
||||||
|
role: "assistant",
|
||||||
|
content: candidate.content,
|
||||||
|
...(toolTrace ? { toolTrace } : {}),
|
||||||
|
...(sources ? { sources } : {}),
|
||||||
|
...(citations ? { citations } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -175,6 +232,12 @@ export function SavingsChatPage() {
|
|||||||
...(message.role === "assistant" && message.toolTrace
|
...(message.role === "assistant" && message.toolTrace
|
||||||
? { toolTrace: message.toolTrace }
|
? { toolTrace: message.toolTrace }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(message.role === "assistant" && message.sources
|
||||||
|
? { sources: message.sources }
|
||||||
|
: {}),
|
||||||
|
...(message.role === "assistant" && message.citations
|
||||||
|
? { citations: message.citations }
|
||||||
|
: {}),
|
||||||
})),
|
})),
|
||||||
[displayMessages],
|
[displayMessages],
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user