feat: persist rectification analysis traces

This commit is contained in:
Jesse_Chen
2026-07-30 03:15:06 +08:00
parent 29374ded2a
commit ee10fb4d8a
19 changed files with 1084 additions and 32 deletions
@@ -2,11 +2,12 @@
import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AgentActivityStatus } from "@/components/agent-activity-status";
import { useRectificationV4 } from "@/hooks/use-rectification-v4";
import type { ChatMessageView } from "@/lib/chat-message-view";
import type { PublicLanguageModel } from "@/lib/public-models";
import type { RectificationV4ApiResponse } from "@/lib/rectification-v4/contracts";
import { ChatMessageRow } from "./chat-message-row";
import type { RectificationAnalysisItem, RectificationAnalysisTrace, RectificationV4ApiResponse } from "@/lib/rectification-v4/contracts";
import { AgentAvatar, ChatMessageRow } from "./chat-message-row";
import { ModelSelector } from "./model-selector";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
@@ -29,6 +30,115 @@ type RectificationV4PanelProps = Readonly<{
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
}>;
type RectificationChatMessageView = ChatMessageView & Readonly<{
analysisTrace?: RectificationAnalysisTrace;
}>;
const phaseLabels = {
collecting_evidence: "正在准备继续收集经历…",
extracting_evidence: "正在整理你刚才提到的经历…",
scoring_candidates: "正在扫描候选时间…",
checking_robustness: "正在检查候选范围的稳定性…",
planning_question: "正在选择下一条最有信息量的问题…",
reasoning: "正在结合上下文决定下一步…",
rendering: "正在组织下一条回复…",
complete: "分析已完成",
} as const;
export function rectificationPhaseLabel(
phase: NonNullable<RectificationV4ApiResponse["job"]>["phase"],
): string {
return phaseLabels[phase];
}
function durationLabel(durationMs: number | null): string | null {
if (durationMs === null || durationMs < 0) return null;
return durationMs < 1_000 ? `${durationMs} 毫秒` : `${(durationMs / 1_000).toFixed(1)}`;
}
function publicStatusLabel(status: string): string {
return ({
completed: "已完成",
succeeded: "已完成",
running: "进行中",
failed: "未完成",
skipped: "已跳过",
legacy: "历史记录",
} as Record<string, string>)[status] ?? "已记录";
}
function RectificationAnalysisDetails({ trace }: Readonly<{ trace: RectificationAnalysisTrace }>) {
return (
<details className="rectification-analysis">
<summary>
<span></span>
<small>{publicStatusLabel(trace.status)}</small>
</summary>
<div className="rectification-analysis-content">
{trace.stages.length > 0 && (
<section aria-label="执行阶段">
<h4></h4>
<ol>
{trace.stages.map((stage, index) => {
const duration = durationLabel(stage.durationMs);
return (
<li key={`${stage.phase}-${index}`}>
<span>{stage.label}</span>
<small>{publicStatusLabel(stage.status)}{duration ? ` · ${duration}` : ""}</small>
</li>
);
})}
</ol>
</section>
)}
{trace.toolCalls.length > 0 && (
<section aria-label="实际调用">
<h4></h4>
<ul>
{trace.toolCalls.map((toolCall, index) => {
const duration = durationLabel(toolCall.durationMs);
return (
<li key={`${toolCall.category}-${index}`}>
<span>{toolCall.label}</span>
<small>{publicStatusLabel(toolCall.outcome)}{duration ? ` · ${duration}` : ""}</small>
</li>
);
})}
</ul>
</section>
)}
{trace.techniques.length > 0 && (
<section aria-label="实际使用的技法">
<h4>使</h4>
<p>{trace.techniques.join("、")}</p>
</section>
)}
{trace.reasoningSource === "provider_summary" && trace.reasoningSummary && (
<section aria-label="推理摘要">
<h4></h4>
<p>{trace.reasoningSummary}</p>
</section>
)}
</div>
</details>
);
}
function RectificationMessageRow({ message }: Readonly<{ message: RectificationChatMessageView }>) {
if (message.state !== "thinking" || !message.text) return <ChatMessageRow message={message} />;
return (
<article className="message message-assistant" aria-label="Jyotisha 正在分析">
<AgentAvatar />
<div className="message-content">
<div className="message-bubble">
<AgentActivityStatus state="working" label={message.text} />
</div>
</div>
</article>
);
}
export function toggleRectificationFeedback(
current: "up" | "down" | undefined,
requested: "up" | "down",
@@ -55,7 +165,7 @@ export function rectificationV4ChatMessages(
data: RectificationV4ApiResponse | null,
processing: boolean,
pendingConsultationQuestion?: string | null,
): readonly ChatMessageView[] {
): readonly RectificationChatMessageView[] {
if (!data) {
return [{
role: "assistant",
@@ -65,7 +175,13 @@ export function rectificationV4ChatMessages(
}];
}
const messages: ChatMessageView[] = [];
const messages: RectificationChatMessageView[] = [];
const analysis = data.case.deploymentMode === "v5_agent"
? (data as RectificationV4ApiResponse & {
readonly analysis?: readonly RectificationAnalysisItem[];
}).analysis ?? []
: [];
const analysisBySourceTurnId = new Map(analysis.map((item) => [item.sourceTurnId, item.trace]));
if (pendingConsultationQuestion?.trim()) {
messages.push({
role: "assistant",
@@ -75,12 +191,14 @@ export function rectificationV4ChatMessages(
});
}
let previousTurnId: string | null = null;
for (const turn of data.turns) {
messages.push({
role: "assistant",
text: turn.question,
renderKey: `rectification-question-${turn.id}`,
state: "settled",
analysisTrace: previousTurnId ? analysisBySourceTurnId.get(previousTurnId) : undefined,
});
if (turn.answer) {
messages.push({
@@ -90,16 +208,20 @@ export function rectificationV4ChatMessages(
state: "settled",
});
}
previousTurnId = turn.id;
}
const caseValue = data.case;
const primary = caseValue.latestSnapshot?.clusters[0];
const latestTurnTrace = analysisBySourceTurnId.get(data.turns.at(-1)?.id ?? "");
const terminalTrace = caseValue.currentQuestion ? undefined : latestTurnTrace;
if (caseValue.acceptedRange) {
messages.push({
role: "assistant",
text: `候选范围已保存为 ${caseValue.acceptedRange.start}${caseValue.acceptedRange.end}。这是校正得到的候选范围,原出生时间没有被自动改写。`,
renderKey: `rectification-accepted-${caseValue.version}`,
state: "settled",
analysisTrace: terminalTrace,
});
} else if (caseValue.status === "range_ready" && primary) {
messages.push({
@@ -107,6 +229,7 @@ export function rectificationV4ChatMessages(
text: `根据目前这些经历,可以先把范围稳定缩小到 ${primary.startTime}${primary.endTime}。这是候选范围,不是已确认的出生分钟;你可以保存它,也可以继续补充经历。`,
renderKey: `rectification-range-${caseValue.version}`,
state: "settled",
analysisTrace: terminalTrace,
});
}
@@ -116,13 +239,14 @@ export function rectificationV4ChatMessages(
text: caseValue.currentQuestion.prompt,
renderKey: `rectification-current-${caseValue.currentQuestion.id}`,
state: "settled",
analysisTrace: latestTurnTrace,
});
}
if (processing) {
messages.push({
role: "assistant",
text: "",
text: rectificationPhaseLabel(data.job?.phase ?? caseValue.phase),
renderKey: `rectification-processing-${data.job?.id ?? caseValue.version}`,
state: "thinking",
});
@@ -132,6 +256,7 @@ export function rectificationV4ChatMessages(
text: "进度已经保存。准备好后,我们可以从这里继续。",
renderKey: `rectification-paused-${caseValue.version}`,
state: "settled",
analysisTrace: terminalTrace,
});
} else if (caseValue.status === "abandoned") {
messages.push({
@@ -139,6 +264,7 @@ export function rectificationV4ChatMessages(
text: "这次校正已经结束,原出生时间没有被改写。",
renderKey: `rectification-abandoned-${caseValue.version}`,
state: "settled",
analysisTrace: terminalTrace,
});
}
@@ -254,9 +380,12 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
});
return (
<div className="rectification-message-entry" key={message.renderKey}>
<ChatMessageRow message={regenerating
<RectificationMessageRow message={regenerating
? { ...message, text: "", state: "thinking" }
: message} />
{message.role === "assistant" && message.state === "settled" && message.analysisTrace && (
<RectificationAnalysisDetails trace={message.analysisTrace} />
)}
{showActions && !regenerating && (
<div className="rectification-message-actions" aria-label="Agent 回答操作">
<button