feat(rectification): stream agent execution and redesign entry routing
Replace the Direct Agentic textStream relay with a durable V9 agent runtime:
- agentic-rectification.ts: short boundary-only system prompt (no gate->scan
->score->diagnostics copy); pins skills/jyotish-birth-time-rectification;
per-action bounded maxSteps (opening/read-only 6, evidence 8, rescore 12,
accept/confirm 6) with a hard ceiling and repeated-tool-call detection.
- rectification-v9-tools.ts: ten Case-ref tools (read-case, propose/confirm/
revise-evidence, compare-candidates, read-diagnostics, offer-candidates,
accept-candidate, confirm-birth-time, close-case). Inputs are minimal refs
only; RPC-backed evidence ledger, fingerprint cache reuse, receipts, and
accepted!=confirmed semantics; confirm requires gate + grounded consent.
- /api/rectification/agent: caseId/sessionId/requestId/action/message; exact
Case<->Session binding verified server-side; client history never overrides
the durable dossier; pending turn -> completed/failed/retryable; consumes
result.fullStream and emits allowlisted NDJSON only (reasoning/raw/provider
metadata/tool payloads/birth data/scores never forwarded); first-turn real
skill.started/skill.loaded gate with one controlled retry; billing bound to
rectification:case:{caseId}.
- New forward migration 20260813010000_agentic_rectification_v9_agent_api.sql:
case dossier/compute, turn finalize, fingerprint-cached candidate persist,
case-scoped accept, consent-gated confirm, guarded transitions,
needs_rebaseline profile guard, run_phases receipt table, and the
rectification_runtime_version feature flag (v9 default, legacy read-only).
- Frontend: homepage/sidebar entry routing now uses the server Case open API
(openRectificationFromHomepage/openRectificationSession/startNewRectification)
with exact sessionId/caseId and server-owned shouldStartOpening; CTA driven
by entry-summary; chat restores from persisted turns, candidate cards from
the Candidate Snapshot API, activity from real NDJSON + persisted receipts;
direct durable candidate-accept endpoint for the UI cards.
This commit is contained in:
@@ -2,22 +2,40 @@
|
||||
|
||||
import type { PublicLanguageModel } from "../lib/public-models.ts";
|
||||
import type { ChatMessage } from "../lib/chat-message-view.ts";
|
||||
import { AgenticRectificationChat } from "./rectification-agentic-chat.tsx";
|
||||
import { RectificationAgenticChat } from "./rectification-agentic-chat.tsx";
|
||||
|
||||
export type PersistedRectificationTurn = Readonly<{
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
text: string | null;
|
||||
status: string;
|
||||
receipt?: Readonly<{
|
||||
status: string;
|
||||
phases: readonly string[];
|
||||
tools: readonly string[];
|
||||
skill_name?: string;
|
||||
skill_version?: string;
|
||||
}> | null;
|
||||
}>;
|
||||
|
||||
export type ConversationalBirthTimeRectificationProps = Readonly<{
|
||||
caseId: string;
|
||||
sessionId: string;
|
||||
initialMessages: readonly ChatMessage[];
|
||||
readonly: boolean;
|
||||
shouldStartOpening: boolean;
|
||||
initialTurns: readonly PersistedRectificationTurn[];
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
onMessagesChange?: (messages: ChatMessage[]) => void;
|
||||
onCompleted?: () => void;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
onProfileIncomplete?: () => void;
|
||||
onSaved?: (time: string) => void;
|
||||
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
onRestart?: () => void;
|
||||
}>;
|
||||
|
||||
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
|
||||
return <AgenticRectificationChat {...props} />;
|
||||
return <RectificationAgenticChat {...props} />;
|
||||
}
|
||||
|
||||
@@ -5,85 +5,142 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { parseAgentReply } from "@/lib/agent-reply";
|
||||
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
|
||||
import { membershipHref } from "@/lib/membership";
|
||||
import { PUBLIC_RECTIFICATION_PHASES, type PublicRectificationPhase } from "@/lib/rectification-agentic/v9/public-receipt";
|
||||
import type { PublicLanguageModel } from "@/lib/public-models";
|
||||
import { ChatMessageRow } from "./chat-message-row";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { Button } from "./ui/button";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
type AgenticRectificationChatProps = Readonly<{
|
||||
sessionId: string;
|
||||
initialMessages: readonly ChatMessage[];
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
onMessagesChange?: (messages: ChatMessage[]) => void;
|
||||
onCompleted?: () => void;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
onProfileIncomplete?: () => void;
|
||||
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
|
||||
type PersistedTurn = Readonly<{
|
||||
id: string;
|
||||
role: "user" | "assistant";
|
||||
text: string | null;
|
||||
status: string;
|
||||
receipt?: Readonly<{
|
||||
status: string;
|
||||
phases: readonly string[];
|
||||
tools: readonly string[];
|
||||
skill_name?: string;
|
||||
skill_version?: string;
|
||||
}> | null;
|
||||
}>;
|
||||
|
||||
type RenderMessage = ChatMessageView;
|
||||
|
||||
type CandidateResult = Readonly<{
|
||||
resultId: string;
|
||||
candidates: readonly Readonly<{ rank: number; time: string; relative_support: number; tied_minute_count: number }>[];
|
||||
overallConfidence: "low" | "medium" | "high";
|
||||
marginPercent: number | null;
|
||||
selectionAllowed: boolean;
|
||||
confirmationAllowed: boolean;
|
||||
representativeTime: string | null;
|
||||
selectedTime: string | null;
|
||||
selectionStatus: "accepted" | "confirmed" | null;
|
||||
selectionKind: string | null;
|
||||
}> | 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;
|
||||
}>;
|
||||
|
||||
const savedSentinel = /<!--AYANAM_RECTIFICATION_SAVED:(\d{2}:\d{2})-->/;
|
||||
type RenderMessage = ChatMessageView & {
|
||||
renderKey: string;
|
||||
activity?: readonly string[];
|
||||
receiptStatus?: string;
|
||||
};
|
||||
|
||||
type AgenticRectificationRequest = Readonly<
|
||||
| { action: "opening" }
|
||||
| { action: "message"; message: string }
|
||||
>;
|
||||
const PHASE_LABELS: Readonly<Partial<Record<PublicRectificationPhase, string>>> = {
|
||||
"run.started": "开始本轮执行",
|
||||
"skill.started": "正在加载专用方法",
|
||||
"skill.loaded": "专用方法已加载",
|
||||
"case.loaded": "已读取校正记录",
|
||||
"evidence.proposed": "记录了一条事件草稿",
|
||||
"evidence.confirmed": "事件已确认",
|
||||
"candidates.comparing": "正在比较候选时间",
|
||||
"candidates.updated": "候选已更新",
|
||||
"diagnostics.completed": "稳健性诊断完成",
|
||||
"candidate.accepted": "已采用候选时间",
|
||||
"birth_time.confirmed": "校正时间已确认",
|
||||
"run.completed": "本轮完成",
|
||||
"run.failed": "本轮失败",
|
||||
};
|
||||
|
||||
export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
function isPublicPhase(value: unknown): value is PublicRectificationPhase {
|
||||
return typeof value === "string" && (PUBLIC_RECTIFICATION_PHASES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function labelForPhase(phase: string): string {
|
||||
return isPublicPhase(phase) ? (PHASE_LABELS[phase] ?? phase) : phase;
|
||||
}
|
||||
|
||||
function activityFromReceipt(receipt: PersistedTurn["receipt"]): string[] {
|
||||
if (!receipt) return [];
|
||||
const phases = (receipt.phases ?? []).filter(isPublicPhase);
|
||||
return phases.map(labelForPhase).slice(0, 12);
|
||||
}
|
||||
|
||||
export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const {
|
||||
caseId,
|
||||
sessionId,
|
||||
initialMessages,
|
||||
readonly,
|
||||
shouldStartOpening,
|
||||
initialTurns,
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelectModel,
|
||||
onMessagesChange,
|
||||
onCompleted,
|
||||
pendingConsultationQuestion,
|
||||
onPendingChange,
|
||||
onProfileIncomplete,
|
||||
onSaved,
|
||||
pendingConsultationQuestion,
|
||||
onRestart,
|
||||
} = props;
|
||||
const pendingQuestion = pendingConsultationQuestion?.trim();
|
||||
const [messages, setMessages] = useState<RenderMessage[]>(() => [
|
||||
...initialMessages.map((message, index) => ({
|
||||
...message,
|
||||
renderKey: `agentic-message-${index}`,
|
||||
state: "settled" as const,
|
||||
})),
|
||||
...(initialMessages.length === 0 && pendingQuestion ? [{
|
||||
role: "assistant" as const,
|
||||
text: `我先陪你把出生时间范围核对清楚,之后再回到你原来的问题:“${pendingQuestion}”`,
|
||||
renderKey: "agentic-pending-consultation",
|
||||
state: "settled" as const,
|
||||
}] : []),
|
||||
]);
|
||||
|
||||
const [messages, setMessages] = useState<RenderMessage[]>(() =>
|
||||
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",
|
||||
activity: activityFromReceipt(turn.receipt),
|
||||
receiptStatus: turn.receipt?.status,
|
||||
}];
|
||||
}
|
||||
return [{
|
||||
role: "user",
|
||||
text: turn.text ?? "",
|
||||
renderKey: key,
|
||||
state: "settled",
|
||||
}];
|
||||
}),
|
||||
);
|
||||
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>(null);
|
||||
const [candidateResult, setCandidateResult] = useState<CandidateResult>(null);
|
||||
const [acceptingTime, setAcceptingTime] = useState<string | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
const conversation = useRef<HTMLElement>(null);
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
const keyCounter = useRef(0);
|
||||
const openingStarted = useRef(false);
|
||||
|
||||
@@ -102,49 +159,77 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
});
|
||||
}, [busy, error, messages, savedTime]);
|
||||
|
||||
const send = useCallback(async (request: AgenticRectificationRequest, showUserMessage = true) => {
|
||||
const trimmed = request.action === "message" ? request.message.trim() : "";
|
||||
if ((request.action === "message" && !trimmed) || busy) return;
|
||||
// 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);
|
||||
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,
|
||||
};
|
||||
} 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("");
|
||||
setSuggestions([]);
|
||||
setPending(true);
|
||||
|
||||
keyCounter.current += 1;
|
||||
const requestId = globalThis.crypto.randomUUID();
|
||||
const settledMessages = messages
|
||||
.filter((message) => message.state === "settled")
|
||||
.map((message) => ({
|
||||
role: message.role,
|
||||
text: message.text,
|
||||
...(message.suggestions ? { suggestions: message.suggestions } : {}),
|
||||
}));
|
||||
const history = settledMessages.map((message) => ({ role: message.role, text: message.text }));
|
||||
const turnKey = keyCounter.current;
|
||||
const userRenderKey = `agentic-user-${turnKey}`;
|
||||
const assistantRenderKey = `agentic-assistant-${turnKey}`;
|
||||
const userRenderKey = `v9-user-${turnKey}`;
|
||||
const assistantRenderKey = `v9-assistant-${turnKey}`;
|
||||
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
...(showUserMessage && request.action === "message"
|
||||
...(action === "message"
|
||||
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
|
||||
: []),
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" },
|
||||
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking", activity: [] },
|
||||
]);
|
||||
setDraft("");
|
||||
|
||||
let raw = "";
|
||||
let streamedSavedStatus: "accepted" | "confirmed" | null = null;
|
||||
let liveActivity: string[] = [];
|
||||
const activitySet = new Set<string>();
|
||||
try {
|
||||
const response = await fetch("/api/rectification/agent", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
requestId,
|
||||
caseId,
|
||||
sessionId,
|
||||
requestId,
|
||||
action,
|
||||
modelId: selectedModelId,
|
||||
history,
|
||||
action: request.action,
|
||||
...(request.action === "message" ? { message: trimmed } : {}),
|
||||
...(action === "message" ? { message: trimmed } : {}),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -164,7 +249,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
return;
|
||||
}
|
||||
if (!response.body) {
|
||||
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
|
||||
setMessages((current) => current.filter((item) => item.renderKey !== assistantRenderKey));
|
||||
setError("服务暂时不可用,请稍后再试。");
|
||||
return;
|
||||
}
|
||||
@@ -182,33 +267,35 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
let event: { type: string; text?: string; message?: string; result?: CandidateResult };
|
||||
let event: { type?: unknown; text?: unknown; message?: unknown };
|
||||
try {
|
||||
event = JSON.parse(line) as { type: string; text?: string; message?: string; result?: CandidateResult };
|
||||
event = JSON.parse(line) as { type?: unknown; text?: unknown; message?: unknown };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (event.type === "delta" && typeof event.text === "string") {
|
||||
if (typeof event.type !== "string") continue;
|
||||
if (event.type === "answer.delta" && typeof event.text === "string") {
|
||||
raw += event.text;
|
||||
const parsed = parseAgentReply(raw, "general");
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? { ...message, text: parsed.text, state: "streaming" }
|
||||
: message));
|
||||
setSuggestions(parsed.suggestions);
|
||||
const saved = raw.match(savedSentinel);
|
||||
if (saved) setSavedTime(saved[1]);
|
||||
} else if (event.type === "candidates" && event.result) {
|
||||
setCandidateResult(event.result);
|
||||
if (event.result.selectedTime && event.result.selectionStatus) {
|
||||
setSavedTime(event.result.selectedTime);
|
||||
streamedSavedStatus = event.result.selectionStatus;
|
||||
setSavedStatus(event.result.selectionStatus);
|
||||
}
|
||||
} else if (event.type === "run.failed") {
|
||||
streamFailed = true;
|
||||
} else if (event.type === "error") {
|
||||
streamFailed = true;
|
||||
setError(event.message || "生时校正暂时不可用,请稍后再试。");
|
||||
} else if (event.type === "done") {
|
||||
setError(typeof event.message === "string" ? event.message : "生时校正暂时不可用,请稍后再试。");
|
||||
} else if (event.type === "run.completed") {
|
||||
completed = true;
|
||||
} else if (isPublicPhase(event.type)) {
|
||||
if (!activitySet.has(event.type)) {
|
||||
activitySet.add(event.type);
|
||||
liveActivity = [...liveActivity, labelForPhase(event.type)];
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? { ...message, activity: liveActivity }
|
||||
: message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,22 +304,19 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
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" }
|
||||
? { ...message, text: parsed.text, suggestions: parsed.suggestions, state: "settled", activity: liveActivity }
|
||||
: message)
|
||||
: current.filter((message) => message.renderKey !== assistantRenderKey));
|
||||
setSuggestions(succeeded ? parsed.suggestions : []);
|
||||
if (succeeded) {
|
||||
onMessagesChange?.([
|
||||
...settledMessages,
|
||||
...(request.action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
|
||||
...(action === "message" ? [{ role: "user" as const, text: trimmed }] : []),
|
||||
{ role: "assistant", text: parsed.text, suggestions: parsed.suggestions },
|
||||
]);
|
||||
onCompleted?.();
|
||||
}
|
||||
const saved = raw.match(savedSentinel);
|
||||
if (saved) {
|
||||
setSavedTime(saved[1]);
|
||||
onSaved?.(saved[1], streamedSavedStatus ?? savedStatus ?? "accepted");
|
||||
await loadCandidate().then((result) => {
|
||||
if (result) setCandidateResult(result);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
setError("生时校正暂时不可用,请稍后再试。");
|
||||
@@ -240,72 +324,78 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}, [busy, messages, onCompleted, onMessagesChange, onProfileIncomplete, onSaved, savedStatus, selectedModelId, sessionId, setPending]);
|
||||
}, [busy, caseId, loadCandidate, onCompleted, onMessagesChange, onProfileIncomplete, readonly, selectedModelId, sessionId, setPending]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
void fetch(`/api/rectification/agent?sessionId=${encodeURIComponent(sessionId)}`)
|
||||
.then((response) => response.ok ? response.json() : null)
|
||||
.then((payload) => {
|
||||
const result = payload?.result as CandidateResult | null | undefined;
|
||||
if (!active || !result) return;
|
||||
setCandidateResult(result);
|
||||
if (result.selectedTime && result.selectionStatus) {
|
||||
setSavedTime(result.selectedTime);
|
||||
setSavedStatus(result.selectionStatus);
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => { active = false; };
|
||||
}, [sessionId]);
|
||||
if (readonly || openingStarted.current || !shouldStartOpening) return;
|
||||
openingStarted.current = true;
|
||||
void send("opening", "");
|
||||
}, [readonly, send, shouldStartOpening]);
|
||||
|
||||
const acceptCandidate = useCallback(async (time: string) => {
|
||||
if (!candidateResult || acceptingTime) return;
|
||||
if (!candidateResult || acceptingTime || readonly) return;
|
||||
setError("");
|
||||
setAcceptingTime(time);
|
||||
try {
|
||||
const response = await fetch("/api/rectification/agent", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "accept_candidate",
|
||||
sessionId,
|
||||
resultId: candidateResult.resultId,
|
||||
time,
|
||||
}),
|
||||
});
|
||||
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?.message || payload?.error || "暂时无法采用该候选时间");
|
||||
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, selectionStatus: status } : current);
|
||||
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, onSaved, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialMessages.length > 0 || openingStarted.current) return;
|
||||
openingStarted.current = true;
|
||||
void send({ action: "opening" }, false);
|
||||
}, [initialMessages.length, send]);
|
||||
}, [acceptingTime, candidateResult, caseId, onCompleted, onSaved, readonly, sessionId]);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
await send({ action: "message", message: draft });
|
||||
await send("message", draft);
|
||||
}
|
||||
|
||||
const canSend = !busy;
|
||||
const canSend = !busy && !readonly;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section ref={conversation} className="conversation" aria-label="生时校正对话" aria-busy={busy}>
|
||||
<div className="message-list" aria-live="polite">
|
||||
{messages.map((message) => <ChatMessageRow key={message.renderKey} message={message} />)}
|
||||
{pendingConsultationQuestion?.trim() && (
|
||||
<p className="rectification-pending-note">
|
||||
先陪你核对出生时间范围,之后会回到你原来的问题:“{pendingConsultationQuestion.trim()}”
|
||||
</p>
|
||||
)}
|
||||
{messages.map((message) => (
|
||||
<div key={message.renderKey} className="rectification-message-wrap">
|
||||
<ChatMessageRow message={message} />
|
||||
{message.activity && message.activity.length > 0 && (
|
||||
<details className="rectification-activity">
|
||||
<summary>本轮做了什么</summary>
|
||||
<ol>
|
||||
{message.activity.map((step) => <li key={step}>{step}</li>)}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && (
|
||||
<section className="rectification-candidates" aria-label="生时校正候选时间">
|
||||
<div className="rectification-candidates-heading">
|
||||
@@ -322,7 +412,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
type="button"
|
||||
className={`rectification-candidate${selected ? " is-selected" : ""}`}
|
||||
key={`${candidateResult.resultId}-${candidate.time}`}
|
||||
disabled={selected || Boolean(acceptingTime)}
|
||||
disabled={selected || Boolean(acceptingTime) || readonly}
|
||||
onClick={() => void acceptCandidate(candidate.time)}
|
||||
>
|
||||
<span className="rectification-candidate-time">
|
||||
@@ -346,6 +436,12 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
</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>
|
||||
|
||||
@@ -353,7 +449,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
{suggestions.length > 0 && !busy && (
|
||||
<div className="composer-suggestions" aria-label="推荐继续提问">
|
||||
{suggestions.map((question) => (
|
||||
<button key={question} type="button" onClick={() => void send({ action: "message", message: question })}>{question}</button>
|
||||
<button key={question} type="button" onClick={() => void send("message", question)}>{question}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -361,10 +457,12 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
<form className="composer" onSubmit={submit}>
|
||||
<Textarea
|
||||
ref={composer}
|
||||
aria-label="继续描述你的经历或回答"
|
||||
aria-label={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"}
|
||||
value={draft}
|
||||
disabled={!canSend}
|
||||
placeholder="继续说你记得的人生经历,或回答刚才的问题…"
|
||||
placeholder={readonly
|
||||
? "该校正已结束,只能查看历史;需要再次校正请新建。"
|
||||
: "继续说你记得的人生经历,或回答刚才的问题…"}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
@@ -382,7 +480,7 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
|
||||
<ModelSelector
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
disabled={busy}
|
||||
disabled={busy || readonly}
|
||||
onSelect={onSelectModel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user