Files
Jyotisha/frontend/src/components/rectification-agentic-chat.tsx
T
Jesse_Chen 2e17ccad64
Independent Staging Quality Gate / validate (push) Failing after 30m22s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(rectification): restore message actions and retry receipts
2026-08-14 00:10:22 +08:00

646 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { ArrowUp, Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { parseAgentReplyBody } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import {
createRectificationActivityReceiptState,
receiptFromRectificationActivityState,
reduceRectificationActivityReceipt,
type CompletedActivityReceiptView,
} from "@/lib/rectification-activity-receipt";
import {
isRecommendedRectificationCandidate,
parseRectificationCandidateResult,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import { membershipHref } from "@/lib/membership";
import {
isPublicRectificationMethod,
isPublicRectificationTool,
type PublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
import { CompletedActivityReceipt } from "./completed-activity-receipt";
import { ModelSelector } from "./model-selector";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
type PersistedTurn = Readonly<{
id: string;
role: "user" | "assistant";
text: string | null;
status: string;
receipt?: Readonly<{
status: string;
phases: readonly string[];
tools: readonly string[];
methods?: readonly string[];
skill_name?: string;
skill_version?: string;
}> | null;
}>;
type CandidateResult = RectificationCandidateResult | null;
type RectificationAgenticChatProps = Readonly<{
caseId: string;
sessionId: string;
readonly: boolean;
shouldStartOpening: boolean;
initialTurns: readonly PersistedTurn[];
models: readonly PublicLanguageModel[];
selectedModelId: string;
onSelectModel: (modelId: string) => void;
onMessagesChange?: (messages: ChatMessage[]) => void;
onCompleted?: () => void;
onPendingChange?: (pending: boolean) => void;
onProfileIncomplete?: () => void;
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
pendingConsultationQuestion?: string | null;
onRestart?: () => void;
}>;
type RenderMessage = ChatMessageView & {
renderKey: string;
activeActivity?: PublicActivity;
completedReceipt?: CompletedActivityReceiptView;
turnId?: string;
};
type PublicActivity = Readonly<{
tool: PublicRectificationTool;
label: string;
}>;
const ACTIVE_TOOL_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
"rectification-read-case": "正在读取校正记录…",
"rectification-propose-evidence": "正在整理事件证据…",
"rectification-confirm-evidence": "正在确认事件证据…",
"rectification-revise-evidence": "正在修订事件证据…",
"rectification-compare-candidates": "正在比较候选时间…",
"rectification-read-diagnostics": "正在检查候选稳健性…",
"rectification-offer-candidates": "正在生成候选建议…",
"rectification-accept-candidate": "正在采用候选时间…",
"rectification-confirm-birth-time": "正在确认校正时间…",
"rectification-close-case": "正在完成校正记录…",
};
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
if (!receipt) return { steps: [], methods: [] };
return {
steps: [...new Set((receipt.tools ?? []).filter(isPublicRectificationTool))],
methods: [...new Set((receipt.methods ?? []).filter(isPublicRectificationMethod))],
};
}
export function toggleRectificationFeedback(
current: "up" | "down" | undefined,
requested: "up" | "down",
): "up" | "down" | undefined {
return current === requested ? undefined : requested;
}
function activityPhase(tool: PublicRectificationTool): NonNullable<ChatMessageView["activity"]>["phase"] {
if (tool === "rectification-compare-candidates" || tool === "rectification-read-diagnostics") {
return "chart-calculation";
}
return "evidence-validation";
}
function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessage[] {
return initialTurns.flatMap((turn, index): RenderMessage[] => {
const key = `persisted-${turn.id}-${index}`;
if (turn.role === "assistant") {
return [{
role: "assistant",
text: turn.text ?? "",
renderKey: key,
state: turn.status === "completed" ? "settled" : "thinking",
completedReceipt: completedReceiptFromPersisted(turn.receipt),
turnId: turn.id,
}];
}
return [{
role: "user",
text: turn.text ?? "",
renderKey: key,
state: "settled",
}];
});
}
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const {
caseId,
sessionId,
readonly,
shouldStartOpening,
initialTurns,
models,
selectedModelId,
onSelectModel,
onMessagesChange,
onCompleted,
onPendingChange,
onProfileIncomplete,
onSaved,
pendingConsultationQuestion,
onRestart,
} = props;
const [messages, setMessages] = useState<RenderMessage[]>(() => messagesFromTurns(initialTurns));
const [draft, setDraft] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [savedTime, setSavedTime] = useState<string | null>(null);
const [savedStatus, setSavedStatus] = useState<"accepted" | "confirmed" | null>(null);
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
const [acceptingTime, setAcceptingTime] = useState<string | null>(null);
const [feedback, setFeedback] = useState<Record<string, "up" | "down" | undefined>>({});
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
const conversation = useRef<HTMLElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const keyCounter = useRef(0);
const openingStarted = useRef(false);
const setPending = useCallback((value: boolean) => {
setBusy(value);
onPendingChange?.(value);
}, [onPendingChange]);
useEffect(() => {
const container = conversation.current;
if (!container) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
container.scrollTo({
top: container.scrollHeight,
behavior: busy || reduceMotion ? "auto" : "smooth",
});
}, [busy, error, messages, savedTime]);
// Candidate snapshot comes from the persisted Candidate Snapshot API, never
// from parsing agent text or hidden sentinels.
const loadCandidate = useCallback(async (): Promise<CandidateResult> => {
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}?sessionId=${encodeURIComponent(sessionId)}`,
{ cache: "no-store" },
);
if (!response.ok) return null;
const payload = await response.json().catch(() => null);
return parseRectificationCandidateResult(payload?.latest_result);
} catch {
return null;
}
}, [caseId, sessionId]);
useEffect(() => {
let active = true;
void loadCandidate().then((result) => {
if (active) setCandidateResult(result);
});
return () => { active = false; };
}, [loadCandidate]);
const send = useCallback(async (action: "opening" | "message", messageText: string) => {
const trimmed = action === "message" ? messageText.trim() : "";
if ((action === "message" && !trimmed) || busy || readonly) return;
setError("");
setPending(true);
keyCounter.current += 1;
const requestId = globalThis.crypto.randomUUID();
const turnKey = keyCounter.current;
const userRenderKey = `v9-user-${turnKey}`;
const assistantRenderKey = `v9-assistant-${turnKey}`;
setMessages((current) => [
...current,
...(action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" },
]);
setDraft("");
let raw = "";
let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
let completedTurnId: string | undefined;
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
caseId,
sessionId,
requestId,
action,
modelId: selectedModelId,
...(action === "message" ? { message: trimmed } : {}),
}),
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
const message = payload?.message || payload?.error || `请求失败(${response.status}`;
setMessages((current) => current.filter((item) => item.renderKey !== assistantRenderKey));
if (payload?.code === "profile_incomplete") {
onProfileIncomplete?.();
return;
}
if (response.status === 402) {
window.location.assign(membershipHref("rectification"));
return;
}
if (response.status === 401) setError("请先登录。");
else setError(message);
return;
}
if (!response.body) {
setMessages((current) => current.filter((item) => item.renderKey !== assistantRenderKey));
setError("服务暂时不可用,请稍后再试。");
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let completed = false;
let streamFailed = false;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.trim()) continue;
let event: {
type?: unknown;
status?: unknown;
text?: unknown;
message?: unknown;
tool?: unknown;
methods?: unknown;
turnId?: unknown;
};
try {
event = JSON.parse(line) as typeof event;
} catch {
continue;
}
if (typeof event.type !== "string") continue;
if (event.type === "answer.delta" && typeof event.text === "string") {
raw += event.text;
const parsed = parseAgentReplyBody(raw);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: parsed.text, state: "streaming", activeActivity: undefined }
: message));
} else if (event.type === "run.failed") {
streamFailed = true;
} else if (event.type === "error") {
streamFailed = true;
setError(typeof event.message === "string" ? event.message : "生时校正暂时不可用,请稍后再试。");
} else if (event.type === "run.completed") {
completed = true;
if (typeof event.turnId === "string") completedTurnId = event.turnId;
} else if (event.type === "tool.activity") {
const tool = isPublicRectificationTool(event.tool) ? event.tool : null;
if (!tool) continue;
if (event.status === "started") {
const activeActivity = { tool, label: ACTIVE_TOOL_LABELS[tool] } satisfies PublicActivity;
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? { ...message, activeActivity }
: message));
continue;
}
if (event.status !== "completed" && event.status !== "failed") continue;
activityReceiptState = reduceRectificationActivityReceipt(activityReceiptState, {
tool,
status: event.status,
methods: event.status === "completed" && Array.isArray(event.methods)
? event.methods.filter(isPublicRectificationMethod)
: [],
});
completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
activeActivity: message.activeActivity?.tool === tool ? undefined : message.activeActivity,
}
: message));
}
}
}
const parsed = parseAgentReplyBody(raw);
const succeeded = completed && !streamFailed && Boolean(parsed.text);
setMessages((current) => succeeded
? current.map((message) => message.renderKey === assistantRenderKey
? {
...message,
text: parsed.text,
state: "settled",
activeActivity: undefined,
completedReceipt,
turnId: completedTurnId,
}
: message)
: current.filter((message) => message.renderKey !== assistantRenderKey));
if (!succeeded && completedReceipt.failedTool) {
setError(completedReceipt.failedTool === "rectification-compare-candidates"
? "候选比较未完成,当前进度已保留。"
: "本轮处理未完成,当前进度已保留。请稍后再试。");
}
if (succeeded) {
onMessagesChange?.([
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
{ role: "assistant", text: parsed.text },
]);
onCompleted?.();
await loadCandidate().then((result) => {
if (result) setCandidateResult(result);
});
}
} catch {
setError("生时校正暂时不可用,请稍后再试。");
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
} finally {
setPending(false);
}
}, [busy, caseId, loadCandidate, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
useEffect(() => {
if (readonly || openingStarted.current || !shouldStartOpening) return;
openingStarted.current = true;
void send("opening", "");
}, [readonly, send, shouldStartOpening]);
const acceptCandidate = useCallback(async (time: string) => {
if (!candidateResult || acceptingTime || readonly) return;
setError("");
setAcceptingTime(time);
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/candidates/accept`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
sessionId,
resultId: candidateResult.resultId,
candidateId: time,
requestId: globalThis.crypto.randomUUID(),
}),
},
);
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok !== true) {
throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间");
}
const status = payload.status === "confirmed" ? "confirmed" : "accepted";
setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionKind: status === "confirmed" ? "engine_confirmed" : "user_accepted" } : current);
setSavedTime(payload.saved_time);
setSavedStatus(status);
onSaved?.(payload.saved_time, status);
onCompleted?.();
} catch (caught) {
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
} finally {
setAcceptingTime(null);
}
}, [acceptingTime, candidateResult, caseId, onCompleted, onSaved, readonly, sessionId]);
async function copyMessage(message: RenderMessage) {
try {
await navigator.clipboard.writeText(message.text);
setCopiedMessageKey(message.renderKey);
window.setTimeout(() => setCopiedMessageKey((current) => (
current === message.renderKey ? null : current
)), 1_500);
} catch {
// Clipboard permission failures must not interrupt the conversation.
}
}
async function regenerateMessage(message: RenderMessage) {
if (!message.turnId || regeneratingMessageKey || busy || readonly) return;
setError("");
setRegeneratingMessageKey(message.renderKey);
const previousText = message.text;
try {
const response = await fetch(
`/api/rectification/cases/${encodeURIComponent(caseId)}/turns/${encodeURIComponent(message.turnId)}/regenerate`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
sessionId,
requestId: globalThis.crypto.randomUUID(),
}),
},
);
const payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok !== true || typeof payload.assistantMessage !== "string") {
throw new Error(payload?.message || payload?.error || "暂时无法重新生成回答");
}
setMessages((current) => current.map((item) => item.renderKey === message.renderKey
? { ...item, text: payload.assistantMessage, state: "settled" }
: item));
} catch (caught) {
setMessages((current) => current.map((item) => item.renderKey === message.renderKey
? { ...item, text: previousText, state: "settled" }
: item));
setError(caught instanceof Error ? caught.message : "暂时无法重新生成回答");
} finally {
setRegeneratingMessageKey((current) => current === message.renderKey ? null : current);
}
}
async function submit(event: React.FormEvent) {
event.preventDefault();
await send("message", draft);
}
const latestRegeneratableKey = [...messages]
.reverse()
.find((message) => (
message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.turnId)
&& Boolean(message.text)
))?.renderKey;
const canSend = !busy && !readonly && !regeneratingMessageKey;
return (
<>
<section ref={conversation} className="conversation" aria-label="生时校正对话" aria-busy={busy || regeneratingMessageKey !== null}>
<div className="message-list" aria-live="polite">
{pendingConsultationQuestion?.trim() && (
<p className="rectification-pending-note">
{pendingConsultationQuestion.trim()}
</p>
)}
{messages.map((message) => {
const showActions = message.role === "assistant"
&& message.state === "settled"
&& Boolean(message.text);
const regenerating = regeneratingMessageKey === message.renderKey;
const canRegenerate = message.renderKey === latestRegeneratableKey
&& !busy
&& !readonly
&& regeneratingMessageKey === null;
const displayedMessage = regenerating
? { ...message, text: "", state: "thinking" as const, activeActivity: undefined }
: message;
return (
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
{message.state === "settled" && message.completedReceipt && (
<CompletedActivityReceipt receipt={message.completedReceipt} />
)}
<ChatMessageRow
message={{
...displayedMessage,
activity: displayedMessage.activeActivity
? { phase: activityPhase(displayedMessage.activeActivity.tool), label: displayedMessage.activeActivity.label }
: undefined,
}}
showActivity={displayedMessage.state === "thinking" || Boolean(displayedMessage.activeActivity)}
/>
{showActions && !regenerating && (
<div className="rectification-message-actions" aria-label="Agent 回答操作">
<button
aria-label="赞"
aria-pressed={feedback[message.renderKey] === "up"}
className={feedback[message.renderKey] === "up" ? "is-active" : ""}
title="赞"
type="button"
onClick={() => setFeedback((current) => ({
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], "up"),
}))}
>
<ThumbsUp aria-hidden="true" />
</button>
<button
aria-label="踩"
aria-pressed={feedback[message.renderKey] === "down"}
className={feedback[message.renderKey] === "down" ? "is-active" : ""}
title="踩"
type="button"
onClick={() => setFeedback((current) => ({
...current,
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], "down"),
}))}
>
<ThumbsDown aria-hidden="true" />
</button>
<button aria-label="复制回答" title="复制" type="button" onClick={() => void copyMessage(message)}>
{copiedMessageKey === message.renderKey
? <Check aria-hidden="true" />
: <Copy aria-hidden="true" />}
</button>
<button
aria-label="重新生成回答"
disabled={!canRegenerate}
title={canRegenerate ? "重新生成" : "只能重新生成最近一条回答"}
type="button"
onClick={() => void regenerateMessage(message)}
>
<RotateCcw aria-hidden="true" />
</button>
</div>
)}
</div>
);
})}
{candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && (
<section className="rectification-candidates" aria-label="生时校正候选时间">
<div className="rectification-candidates-heading">
<strong>{candidateResult.confirmationAllowed ? "确认校正时间" : "当前可能的出生时间"}</strong>
<span></span>
<span></span>
</div>
<div className="rectification-candidate-list">
{candidateResult.candidates.map((candidate) => {
const selected = candidateResult.selectedTime === candidate.time;
const recommended = isRecommendedRectificationCandidate(candidateResult, candidate);
return (
<button
type="button"
className={`rectification-candidate${selected ? " is-selected" : ""}`}
key={`${candidateResult.resultId}-${candidate.time}`}
disabled={selected || Boolean(acceptingTime) || readonly}
onClick={() => void acceptCandidate(candidate.time)}
>
<span className="rectification-candidate-time">
<strong>{candidate.time}</strong>
{selected && <span className="rectification-candidate-badge"></span>}
{recommended && <span className="rectification-candidate-badge"></span>}
</span>
<span className="rectification-candidate-support"> {candidate.relativeSupport}</span>
<span className="rectification-candidate-action">
{selected ? "已采用" : acceptingTime === candidate.time ? "正在采用…" : candidateResult.selectedTime ? "改选为此时间" : "采用此时间"}
</span>
</button>
);
})}
</div>
</section>
)}
{savedTime && (
<p className="rectification-saved" role="status">
{savedStatus === "confirmed" ? "已确认校正时间" : "校正采用时间"}{savedTime}使
</p>
)}
{error && <p className="error-message" role="alert">{error}</p>}
{readonly && (
<div className="rectification-terminal-actions">
<p className="rectification-terminal-note"></p>
<Button type="button" onClick={onRestart}></Button>
</div>
)}
</div>
</section>
<div className="composer-wrap">
<form className="composer" onSubmit={submit}>
<Textarea
ref={composer}
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
value={draft}
disabled={!canSend}
placeholder={readonly
? "该校正已结束,只能查看历史;需要再次校正请新建。"
: "继续说你记得的人生经历,或回答刚才的问题…"}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
}
}}
/>
<Button aria-label="发送" disabled={!draft.trim() || !canSend} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
</form>
<div className="composer-footer">
<ModelSelector
models={models}
selectedModelId={selectedModelId}
disabled={busy || readonly}
onSelect={onSelectModel}
/>
</div>
</div>
</>
);
}