fix(rectification): restore message actions and retry receipts
Independent Staging Quality Gate / validate (push) Failing after 30m22s
Independent Staging Quality Gate / publish (push) Has been skipped

This commit is contained in:
Jesse_Chen
2026-08-14 00:10:22 +08:00
parent e8952dcc75
commit 2e17ccad64
17 changed files with 1225 additions and 60 deletions
@@ -1,17 +1,12 @@
"use client";
import { Check, ChevronDown } from "lucide-react";
import type { CompletedActivityReceiptView } from "@/lib/rectification-activity-receipt";
import type {
PublicRectificationMethod,
PublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
export type CompletedActivityReceiptView = Readonly<{
steps: readonly PublicRectificationTool[];
methods: readonly PublicRectificationMethod[];
failedTool?: PublicRectificationTool;
}>;
const TOOL_LABELS: Readonly<Record<PublicRectificationTool, string>> = {
"rectification-read-case": "读取校正记录",
"rectification-propose-evidence": "整理事件证据",
@@ -1,9 +1,15 @@
"use client";
import { ArrowUp } from "lucide-react";
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,
@@ -13,15 +19,11 @@ import { membershipHref } from "@/lib/membership";
import {
isPublicRectificationMethod,
isPublicRectificationTool,
type PublicRectificationMethod,
type PublicRectificationTool,
} from "@/lib/rectification-agentic/v9/public-receipt";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
import {
CompletedActivityReceipt,
type CompletedActivityReceiptView,
} from "./completed-activity-receipt";
import { CompletedActivityReceipt } from "./completed-activity-receipt";
import { ModelSelector } from "./model-selector";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
@@ -65,6 +67,7 @@ type RenderMessage = ChatMessageView & {
renderKey: string;
activeActivity?: PublicActivity;
completedReceipt?: CompletedActivityReceiptView;
turnId?: string;
};
type PublicActivity = Readonly<{
@@ -93,6 +96,14 @@ function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): Compl
};
}
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";
@@ -110,6 +121,7 @@ function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessag
renderKey: key,
state: turn.status === "completed" ? "settled" : "thinking",
completedReceipt: completedReceiptFromPersisted(turn.receipt),
turnId: turn.id,
}];
}
return [{
@@ -148,6 +160,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
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);
@@ -214,10 +229,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
setDraft("");
let raw = "";
let completedReceipt: CompletedActivityReceiptView = { steps: [], methods: [] };
const completedSteps = new Set<PublicRectificationTool>();
const completedMethods = new Set<PublicRectificationMethod>();
let failedTool: PublicRectificationTool | undefined;
let activityReceiptState = createRectificationActivityReceiptState();
let completedReceipt = receiptFromRectificationActivityState(activityReceiptState);
let completedTurnId: string | undefined;
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
@@ -273,6 +287,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
message?: unknown;
tool?: unknown;
methods?: unknown;
turnId?: unknown;
};
try {
event = JSON.parse(line) as typeof event;
@@ -293,6 +308,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
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;
@@ -303,21 +319,15 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
: message));
continue;
}
if (event.status === "completed") {
completedSteps.add(tool);
if (Array.isArray(event.methods)) {
for (const method of event.methods) {
if (isPublicRectificationMethod(method)) completedMethods.add(method);
}
}
} else if (event.status === "failed") {
failedTool = tool;
}
completedReceipt = {
steps: [...completedSteps],
methods: [...completedMethods],
...(failedTool ? { failedTool } : {}),
};
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,
@@ -338,11 +348,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
state: "settled",
activeActivity: undefined,
completedReceipt,
turnId: completedTurnId,
}
: message)
: current.filter((message) => message.renderKey !== assistantRenderKey));
if (!succeeded && failedTool) {
setError(failedTool === "rectification-compare-candidates"
if (!succeeded && completedReceipt.failedTool) {
setError(completedReceipt.failedTool === "rectification-compare-candidates"
? "候选比较未完成,当前进度已保留。"
: "本轮处理未完成,当前进度已保留。请稍后再试。");
}
@@ -405,38 +416,149 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}, [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 canSend = !busy && !readonly;
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}>
<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) => (
<div key={message.renderKey} className="rectification-message-wrap">
{message.state === "settled" && message.completedReceipt && (
<CompletedActivityReceipt receipt={message.completedReceipt} />
)}
<ChatMessageRow
message={{
...message,
activity: message.activeActivity
? { phase: activityPhase(message.activeActivity.tool), label: message.activeActivity.label }
: undefined,
}}
showActivity={message.state === "thinking" || Boolean(message.activeActivity)}
/>
</div>
))}
{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">