Add chat sources and citations

This commit is contained in:
Matthias
2026-06-16 10:58:15 +02:00
parent 85af9d7078
commit 9f17d4d1e1
9 changed files with 356 additions and 13 deletions

View File

@@ -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(
<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\"");
});
});

View File

@@ -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
<p className="text-[0.7rem] font-medium uppercase text-muted-foreground">
{isUser ? "User" : "Assistant"}
</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 && (
<AgentToolTracePanel toolTrace={message.toolTrace} />
)}
{!isUser && message.sources && message.sources.length > 0 && (
<AgentSources sources={message.sources} />
)}
</div>
</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[] }) {
const steps = buildReasoningSteps(toolTrace);

View File

@@ -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,
}));
}

View File

@@ -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<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 {
if (!value || typeof value !== "object") return null;
const candidate = value as Record<string, unknown>;
@@ -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],
);