Add chat sources and citations
This commit is contained in:
@@ -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\"");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user