fix(rectification): render candidates and keep agent replies natural
Independent Staging Quality Gate / validate (push) Successful in 12m46s
Independent Staging Quality Gate / publish (push) Successful in 8m26s

This commit is contained in:
Jesse_Chen
2026-08-13 13:29:39 +08:00
parent 7a613f26db
commit 62d12c5c06
11 changed files with 246 additions and 62 deletions
@@ -2,8 +2,13 @@
import { ArrowUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { parseAgentReply } from "@/lib/agent-reply";
import { parseAgentReplyBody } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import {
isRecommendedRectificationCandidate,
parseRectificationCandidateResult,
type RectificationCandidateResult,
} from "@/lib/rectification-candidate-result";
import { membershipHref } from "@/lib/membership";
import {
isPublicRectificationMethod,
@@ -32,16 +37,7 @@ type PersistedTurn = Readonly<{
}> | null;
}>;
type CandidateResult = Readonly<{
resultId: string;
candidates: readonly Readonly<{ rank: number; time: string; relative_support: number; tied_minute_count: number }>[];
overallConfidence: "low" | "medium" | "high";
selectionAllowed: boolean;
confirmationAllowed: boolean;
representativeTime: string | null;
selectedTime: string | null;
selectionKind: string | null;
}> | null;
type CandidateResult = RectificationCandidateResult | null;
type RectificationAgenticChatProps = Readonly<{
caseId: string;
@@ -161,7 +157,6 @@ 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 [suggestions, setSuggestions] = useState<string[]>([]);
const conversation = useRef<HTMLElement>(null);
const composer = useRef<HTMLTextAreaElement>(null);
const keyCounter = useRef(0);
@@ -192,18 +187,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
);
if (!response.ok) return null;
const payload = await response.json().catch(() => null);
const latest = payload?.latest_result;
if (!latest || typeof latest.result_id !== "string") return null;
return {
resultId: latest.result_id,
candidates: Array.isArray(latest.candidates) ? latest.candidates : [],
overallConfidence: latest.overall_confidence === "high" || latest.overall_confidence === "medium" ? latest.overall_confidence : "low",
selectionAllowed: latest.selection_allowed === true,
confirmationAllowed: latest.confirmation_allowed === true,
representativeTime: typeof latest.representative_time === "string" ? latest.representative_time : null,
selectedTime: typeof latest.selected_time === "string" ? latest.selected_time : null,
selectionKind: typeof latest.selection_kind === "string" ? latest.selection_kind : null,
};
return parseRectificationCandidateResult(payload?.latest_result);
} catch {
return null;
}
@@ -221,7 +205,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
const trimmed = action === "message" ? messageText.trim() : "";
if ((action === "message" && !trimmed) || busy || readonly) return;
setError("");
setSuggestions([]);
setPending(true);
keyCounter.current += 1;
@@ -300,11 +283,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
if (typeof event.type !== "string") continue;
if (event.type === "answer.delta" && typeof event.text === "string") {
raw += event.text;
const parsed = parseAgentReply(raw, "general");
const parsed = parseAgentReplyBody(raw);
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: parsed.text, state: "streaming" }
: message));
setSuggestions(parsed.suggestions);
} else if (event.type === "run.failed") {
streamFailed = true;
} else if (event.type === "error") {
@@ -330,18 +312,17 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
}
}
const parsed = parseAgentReply(raw, "general");
const parsed = parseAgentReplyBody(raw);
const succeeded = completed && !streamFailed && Boolean(parsed.text);
setMessages((current) => succeeded
? current.map((message) => message.renderKey === assistantRenderKey
? { ...message, text: parsed.text, suggestions: parsed.suggestions, state: "settled", receiptActivity: liveActivity }
? { ...message, text: parsed.text, state: "settled", receiptActivity: liveActivity }
: message)
: current.filter((message) => message.renderKey !== assistantRenderKey));
setSuggestions(succeeded ? parsed.suggestions : []);
if (succeeded) {
onMessagesChange?.([
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
{ role: "assistant", text: parsed.text, suggestions: parsed.suggestions },
{ role: "assistant", text: parsed.text },
]);
onCompleted?.();
await loadCandidate().then((result) => {
@@ -444,7 +425,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
<div className="rectification-candidate-list">
{candidateResult.candidates.map((candidate) => {
const selected = candidateResult.selectedTime === candidate.time;
const recommended = !candidateResult.selectedTime && candidate.rank === 1;
const recommended = isRecommendedRectificationCandidate(candidateResult, candidate);
return (
<button
type="button"
@@ -458,7 +439,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
{selected && <span className="rectification-candidate-badge"></span>}
{recommended && <span className="rectification-candidate-badge"></span>}
</span>
<span className="rectification-candidate-support"> {candidate.relative_support}%</span>
<span className="rectification-candidate-support"> {candidate.relativeSupport}</span>
<span className="rectification-candidate-action">
{selected ? "已采用" : acceptingTime === candidate.time ? "保存中…" : candidateResult.selectedTime ? "改选为此时间" : "采用此时间"}
</span>
@@ -484,14 +465,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
</section>
<div className="composer-wrap">
{suggestions.length > 0 && !busy && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{suggestions.map((question) => (
<button key={question} type="button" onClick={() => void send("message", question)}>{question}</button>
))}
</div>
)}
<form className="composer" onSubmit={submit}>
<Textarea
ref={composer}