fix(rectification): restore message actions and retry receipts
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user