645 lines
25 KiB
TypeScript
645 lines
25 KiB
TypeScript
"use client";
|
||
|
||
import { ArrowUp } from "lucide-react";
|
||
import { useCallback, useEffect, useRef, useState } from "react";
|
||
import { parseAgentReply } 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 { vargaSentenceFromMethods } from "@/lib/rectification-varga-sentence";
|
||
import {
|
||
isPublicRectificationMethod,
|
||
isPublicRectificationTool,
|
||
} from "@/lib/rectification-agentic/v9/public-receipt";
|
||
import type { PublicLanguageModel } from "@/lib/public-models";
|
||
import { ChatMessageRow } from "./chat-message-row";
|
||
import {
|
||
ChatMessageActions,
|
||
toggleChatMessageFeedback,
|
||
} from "./chat-message-actions";
|
||
import { ModelSelector } from "./model-selector";
|
||
import { RectificationHouseTableView } from "./rectification-house-table";
|
||
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[];
|
||
tool_activities?: readonly Readonly<{
|
||
tool: string;
|
||
status: string;
|
||
methods?: 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;
|
||
completedReceipt?: CompletedActivityReceiptView;
|
||
failed?: boolean;
|
||
turnId?: string;
|
||
};
|
||
|
||
function completedReceiptFromPersisted(receipt: PersistedTurn["receipt"]): CompletedActivityReceiptView {
|
||
if (!receipt) return { steps: [], methods: [] };
|
||
if (Array.isArray(receipt.tool_activities)) {
|
||
let state = createRectificationActivityReceiptState();
|
||
for (const activity of receipt.tool_activities) {
|
||
if (!isPublicRectificationTool(activity.tool)
|
||
|| (activity.status !== "completed" && activity.status !== "failed")) continue;
|
||
state = reduceRectificationActivityReceipt(state, {
|
||
tool: activity.tool,
|
||
status: activity.status,
|
||
methods: activity.status === "completed" && Array.isArray(activity.methods)
|
||
? activity.methods.filter(isPublicRectificationMethod)
|
||
: [],
|
||
});
|
||
}
|
||
return receiptFromRectificationActivityState(state);
|
||
}
|
||
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 toggleChatMessageFeedback(current, requested);
|
||
}
|
||
|
||
function hasActivityReceipt(receipt: CompletedActivityReceiptView): boolean {
|
||
return receipt.steps.length > 0 || receipt.methods.length > 0 || Boolean(receipt.failedTool);
|
||
}
|
||
|
||
function persistedTurnFailed(turn: PersistedTurn): boolean {
|
||
return turn.status === "failed"
|
||
|| turn.status === "retryable"
|
||
|| turn.receipt?.status === "failed"
|
||
|| turn.receipt?.status === "degraded"
|
||
|| turn.receipt?.status === "blocked";
|
||
}
|
||
|
||
function messagesFromTurns(initialTurns: readonly PersistedTurn[]): RenderMessage[] {
|
||
return initialTurns.flatMap((turn, index): RenderMessage[] => {
|
||
const key = `persisted-${turn.id}-${index}`;
|
||
if (turn.role === "assistant") {
|
||
const failed = persistedTurnFailed(turn);
|
||
return [{
|
||
role: "assistant",
|
||
text: failed ? "" : turn.text ?? "",
|
||
renderKey: key,
|
||
state: turn.status === "completed" || failed ? "settled" : "thinking",
|
||
completedReceipt: completedReceiptFromPersisted(turn.receipt),
|
||
failed,
|
||
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 [acceptingCandidateId, setAcceptingCandidateId] = 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((message) => message.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((message) => message.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 = parseAgentReply(raw);
|
||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||
? { ...message, text: parsed.text, state: "streaming" }
|
||
: 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") 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
const parsed = parseAgentReply(raw);
|
||
const succeeded = completed && !streamFailed && Boolean(parsed.text);
|
||
setMessages((current) => current.flatMap((message): RenderMessage[] => {
|
||
if (message.renderKey !== assistantRenderKey) return [message];
|
||
if (succeeded) {
|
||
return [{
|
||
...message,
|
||
text: parsed.text,
|
||
state: "settled",
|
||
completedReceipt,
|
||
failed: false,
|
||
turnId: completedTurnId,
|
||
}];
|
||
}
|
||
if (streamFailed || hasActivityReceipt(completedReceipt)) {
|
||
return [{
|
||
...message,
|
||
text: "",
|
||
state: "settled",
|
||
completedReceipt,
|
||
failed: true,
|
||
}];
|
||
}
|
||
return [];
|
||
}));
|
||
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.flatMap((message): RenderMessage[] => {
|
||
if (message.renderKey !== assistantRenderKey) return [message];
|
||
return hasActivityReceipt(completedReceipt)
|
||
? [{
|
||
...message,
|
||
text: "",
|
||
state: "settled",
|
||
completedReceipt,
|
||
failed: true,
|
||
}]
|
||
: [];
|
||
}));
|
||
} 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 (candidateId: string) => {
|
||
if (!candidateResult || acceptingCandidateId || readonly) return;
|
||
setError("");
|
||
setAcceptingCandidateId(candidateId);
|
||
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,
|
||
requestId: globalThis.crypto.randomUUID(),
|
||
}),
|
||
},
|
||
);
|
||
const payload = await response.json().catch(() => null);
|
||
if (!response.ok || payload?.ok !== true || payload?.status !== "accepted") {
|
||
throw new Error(payload?.error || payload?.message || "暂时无法采用该候选时间");
|
||
}
|
||
setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionKind: "user_accepted" } : current);
|
||
setSavedTime(payload.saved_time);
|
||
setSavedStatus("accepted");
|
||
onSaved?.(payload.saved_time, "accepted");
|
||
onCompleted?.();
|
||
} catch (caught) {
|
||
setError(caught instanceof Error ? caught.message : "暂时无法采用该候选时间");
|
||
} finally {
|
||
setAcceptingCandidateId(null);
|
||
}
|
||
}, [acceptingCandidateId, 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"
|
||
&& !message.failed
|
||
&& 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"
|
||
&& !message.failed
|
||
&& 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 }
|
||
: message;
|
||
const vargaSentence = message.state === "settled" && !message.failed
|
||
? vargaSentenceFromMethods(message.completedReceipt?.methods)
|
||
: null;
|
||
return (
|
||
<div key={message.renderKey} className="rectification-message-wrap rectification-message-entry">
|
||
{message.state === "settled" && message.failed && (
|
||
<p className="rectification-activity-failure" role="status">
|
||
本轮处理未完成,已保留服务端记录的执行进度。
|
||
</p>
|
||
)}
|
||
{(!message.failed || Boolean(displayedMessage.text) || regenerating) && (
|
||
<ChatMessageRow
|
||
message={displayedMessage}
|
||
showActivity={displayedMessage.state === "thinking"}
|
||
vargaSentence={vargaSentence}
|
||
/>
|
||
)}
|
||
{showActions && !regenerating && (
|
||
<ChatMessageActions
|
||
feedback={feedback[message.renderKey]}
|
||
copied={copiedMessageKey === message.renderKey}
|
||
canRegenerate={canRegenerate}
|
||
onFeedback={(requested) => setFeedback((current) => ({
|
||
...current,
|
||
[message.renderKey]: toggleRectificationFeedback(current[message.renderKey], requested),
|
||
}))}
|
||
onCopy={() => void copyMessage(message)}
|
||
onRegenerate={() => void regenerateMessage(message)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
{candidateResult?.houseTable && (
|
||
<RectificationHouseTableView table={candidateResult.houseTable} />
|
||
)}
|
||
{candidateResult?.selectionAllowed && (
|
||
<section className="rectification-candidates" aria-label="生时校正候选时间">
|
||
<div className="rectification-candidates-heading">
|
||
<strong>当前可能的出生时间</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={candidate.candidateId}
|
||
disabled={selected || Boolean(acceptingCandidateId) || readonly}
|
||
onClick={() => void acceptCandidate(candidate.candidateId)}
|
||
>
|
||
<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 ? "已采用" : acceptingCandidateId === candidate.candidateId ? "正在采用…" : 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>
|
||
</>
|
||
);
|
||
}
|