refactor: make birth time rectification conversational
This commit is contained in:
@@ -1,337 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUp, Check, Copy, RotateCcw, Square, ThumbsDown, ThumbsUp } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ChatMessageRow } from "./chat-message-row.tsx";
|
||||
import { AppLoadingIndicator } from "./app-loading-indicator.tsx";
|
||||
import { ModelSelector } from "./model-selector.tsx";
|
||||
import { Button } from "./ui/button.tsx";
|
||||
import { Textarea } from "./ui/textarea.tsx";
|
||||
import type { PublicLanguageModel } from "../lib/public-models.ts";
|
||||
import {
|
||||
RectificationV4Panel,
|
||||
type RectificationV4Continuation,
|
||||
} from "./rectification-v4-panel.tsx";
|
||||
import {
|
||||
type ConversationalRectificationMessage,
|
||||
type ConversationalRectificationStoredMessage,
|
||||
type ConversationalRectificationController,
|
||||
} from "../hooks/use-conversational-rectification.ts";
|
||||
import type { ConversationalRectificationTurn } from "../lib/conversational-rectification/contracts.ts";
|
||||
import type { PublicLanguageModel } from "../lib/public-models.ts";
|
||||
|
||||
type SurfaceProps = Readonly<{
|
||||
controller: ConversationalRectificationController;
|
||||
openingAssistantText?: string;
|
||||
export type ConversationalBirthTimeRectificationProps = Readonly<{
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
continuationPending?: boolean;
|
||||
onContinueOriginalQuestion?: (question: string) => void;
|
||||
}>;
|
||||
|
||||
const ANSWER_UNDO_WINDOW_MS = 2_500;
|
||||
|
||||
function safely(request: Promise<unknown>) {
|
||||
void request.catch(() => undefined);
|
||||
}
|
||||
|
||||
export function ConversationalRectificationSurface({
|
||||
controller,
|
||||
openingAssistantText = "",
|
||||
models,
|
||||
selectedModelId,
|
||||
onSelectModel,
|
||||
pendingConsultationQuestion,
|
||||
continuationPending = false,
|
||||
onContinueOriginalQuestion,
|
||||
}: SurfaceProps) {
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
const conversationEnd = useRef<HTMLDivElement>(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 undoTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [submission, setSubmission] = useState<Readonly<{
|
||||
text: string;
|
||||
phase: "undo" | "generating";
|
||||
turnVersion: number;
|
||||
}> | null>(null);
|
||||
const turn = controller.turn;
|
||||
const messageCount = controller.messages?.length ?? 0;
|
||||
const latestMessageText = controller.messages?.[messageCount - 1]?.text ?? turn?.narrative ?? "";
|
||||
const latestAssistantKey = [...(controller.messages ?? [])]
|
||||
.reverse()
|
||||
.find((message) => message.role === "assistant")?.renderKey
|
||||
?? `assistant-${turn?.turnVersion ?? 0}`;
|
||||
useEffect(() => () => {
|
||||
if (undoTimer.current) clearTimeout(undoTimer.current);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
conversationEnd.current?.scrollIntoView({
|
||||
behavior: controller.pending || submission !== null || reduceMotion ? "auto" : "smooth",
|
||||
block: "end",
|
||||
});
|
||||
}, [controller.error, controller.pending, latestMessageText, messageCount, submission, turn?.turnVersion]);
|
||||
const pendingQuestion = turn?.status === "completed"
|
||||
? turn.pendingConsultationQuestion
|
||||
: turn?.pendingConsultationQuestion ?? pendingConsultationQuestion ?? null;
|
||||
|
||||
if (!turn) {
|
||||
return (
|
||||
<section className="conversational-rectification" aria-busy={controller.pending} aria-label="生时校正对话">
|
||||
{openingAssistantText
|
||||
? <ChatMessageRow message={{
|
||||
role: "assistant",
|
||||
text: openingAssistantText,
|
||||
renderKey: "rectification-opening-assistant",
|
||||
state: "streaming",
|
||||
}} />
|
||||
: <div className="conversational-loading" aria-live="polite" role="status">
|
||||
<AppLoadingIndicator title="正在建立校正记录…" detail="正在加载校正进度,准备第一条问题。" />
|
||||
</div>}
|
||||
{controller.error && <p className="form-error" role="alert">{controller.error}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const canAnswer = turn.actions.includes("answer") && turn.status !== "abandoned" && turn.status !== "completed";
|
||||
const canConfirm = turn.actions.includes("confirm")
|
||||
&& turn.candidate.status === "ready_for_confirmation"
|
||||
&& Boolean(turn.candidate.representativeTime);
|
||||
const canContinue = turn.actions.includes("continue_original_question")
|
||||
&& Boolean(pendingQuestion)
|
||||
&& Boolean(onContinueOriginalQuestion);
|
||||
const busy = controller.pending || submission !== null;
|
||||
const submit = () => {
|
||||
const text = controller.draft.trim();
|
||||
if (!canAnswer || !text || busy) return;
|
||||
controller.setDraft("");
|
||||
setSubmission({ text, phase: "undo", turnVersion: turn.turnVersion });
|
||||
undoTimer.current = setTimeout(async () => {
|
||||
undoTimer.current = null;
|
||||
setSubmission({ text, phase: "generating", turnVersion: turn.turnVersion });
|
||||
try {
|
||||
await controller.answer(undefined, text);
|
||||
} catch {
|
||||
controller.setDraft(text);
|
||||
} finally {
|
||||
setSubmission(null);
|
||||
composer.current?.focus();
|
||||
}
|
||||
}, ANSWER_UNDO_WINDOW_MS);
|
||||
};
|
||||
const copyMessage = async (message: ConversationalRectificationMessage) => {
|
||||
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.
|
||||
}
|
||||
};
|
||||
const regenerateMessage = async (messageKey: string) => {
|
||||
setRegeneratingMessageKey(messageKey);
|
||||
try {
|
||||
await controller.regenerate();
|
||||
} finally {
|
||||
setRegeneratingMessageKey((current) => current === messageKey ? null : current);
|
||||
}
|
||||
};
|
||||
const undoSubmission = () => {
|
||||
if (submission?.phase !== "undo") return;
|
||||
if (undoTimer.current) clearTimeout(undoTimer.current);
|
||||
undoTimer.current = null;
|
||||
controller.setDraft(submission.text);
|
||||
setSubmission(null);
|
||||
requestAnimationFrame(() => composer.current?.focus());
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rectification-chat" aria-busy={busy} aria-label="生时校正对话">
|
||||
<div className="message-list rectification-message-list">
|
||||
<span className="sr-only" aria-live="polite">{submission?.phase === "undo" ? "消息已发送,可以撤回修改" : controller.pending ? "Jyotisha 正在核对经历" : ""}</span>
|
||||
{(controller.messages ?? [{
|
||||
role: "assistant" as const,
|
||||
text: turn.narrative,
|
||||
renderKey: `assistant-${turn.turnVersion}`,
|
||||
}]).map((message) => (
|
||||
<div className="rectification-message-entry" key={message.renderKey}>
|
||||
<ChatMessageRow
|
||||
message={regeneratingMessageKey === message.renderKey
|
||||
? { role: "assistant", text: "", renderKey: message.renderKey, state: "thinking" }
|
||||
: {
|
||||
role: message.role,
|
||||
text: message.text,
|
||||
renderKey: message.renderKey,
|
||||
state: "settled",
|
||||
}}
|
||||
/>
|
||||
{message.role === "assistant" && regeneratingMessageKey !== message.renderKey && (
|
||||
<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]: current[message.renderKey] === "up" ? undefined : "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]: current[message.renderKey] === "down" ? undefined : "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={busy || message.renderKey !== latestAssistantKey || !canAnswer}
|
||||
title={message.renderKey === latestAssistantKey ? "重跑" : "只能重跑最新回答"}
|
||||
type="button"
|
||||
onClick={() => safely(regenerateMessage(message.renderKey))}
|
||||
>
|
||||
<RotateCcw aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{submission && turn.turnVersion === submission.turnVersion && (
|
||||
<ChatMessageRow
|
||||
message={{ role: "user", text: submission.text, renderKey: "pending-evidence", state: "settled" }}
|
||||
/>
|
||||
)}
|
||||
{controller.pending && canAnswer && regeneratingMessageKey === null && (
|
||||
<ChatMessageRow message={{ role: "assistant", text: "", renderKey: "rectification-thinking", state: "thinking" }} />
|
||||
)}
|
||||
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
|
||||
<div ref={conversationEnd} />
|
||||
</div>
|
||||
|
||||
{(canAnswer || canConfirm || canContinue) && <div className="composer-wrap rectification-composer-wrap">
|
||||
{(canConfirm || canContinue) && (
|
||||
<div className="composer-suggestions" aria-label="生时校正操作">
|
||||
{canConfirm && (
|
||||
<button
|
||||
aria-label={`确认将 ${turn.candidate.representativeTime} 设为当前排盘时间;当前分钟尚未验证`}
|
||||
disabled={busy}
|
||||
type="button"
|
||||
onClick={() => safely(controller.confirm(turn.candidate.representativeTime ?? undefined))}
|
||||
>
|
||||
确认采用 {turn.candidate.representativeTime}(尚未验证)
|
||||
</button>
|
||||
)}
|
||||
{canContinue && (
|
||||
<button
|
||||
disabled={busy || continuationPending}
|
||||
type="button"
|
||||
onClick={() => onContinueOriginalQuestion?.(pendingQuestion!)}
|
||||
>
|
||||
{continuationPending ? "正在继续回答…" : "返回原问题"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canAnswer && <>
|
||||
{controller.correctionTarget && (
|
||||
<div className="rectification-correction-target" role="status">
|
||||
<p>正在更正:{controller.correctionTarget.dateLabel} · {controller.correctionTarget.summary}</p>
|
||||
<button disabled={busy} type="button" onClick={() => controller.cancelEvidenceCorrection()}>
|
||||
取消更正
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<form className="composer" onSubmit={(event) => { event.preventDefault(); submit(); }}>
|
||||
<label className="sr-only" htmlFor="conversational-rectification-answer">
|
||||
{controller.correctionTarget ? "输入更正后的经历" : "回答生时校正问题"}
|
||||
</label>
|
||||
<Textarea
|
||||
id="conversational-rectification-answer"
|
||||
ref={composer}
|
||||
autoFocus
|
||||
disabled={busy}
|
||||
maxLength={4_000}
|
||||
placeholder={controller.correctionTarget
|
||||
? "例如:更正为 2020 年 11 月离职"
|
||||
: "像聊天一样回答即可,例如:2018 年 6 月去了上海工作"}
|
||||
rows={2}
|
||||
value={controller.draft}
|
||||
onChange={(event) => controller.setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{submission?.phase === "undo" ? (
|
||||
<Button aria-label="撤回发送,本次不计入校正" title="撤回发送" size="icon" type="button" onClick={undoSubmission}>
|
||||
<Square aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button aria-label={busy ? "正在核对" : "发送"} disabled={busy || !controller.draft.trim()} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
<div className="composer-footer">
|
||||
<ModelSelector
|
||||
models={models}
|
||||
selectedModelId={selectedModelId}
|
||||
disabled={busy}
|
||||
onSelect={onSelectModel}
|
||||
/>
|
||||
</div>
|
||||
</>}
|
||||
</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type ConversationalBirthTimeRectificationProps = Readonly<{
|
||||
initialTurn?: ConversationalRectificationTurn | null;
|
||||
initialMessages?: readonly ConversationalRectificationStoredMessage[];
|
||||
openingAssistantText?: string;
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
continuationPending?: boolean;
|
||||
onTurn?: (
|
||||
turn: ConversationalRectificationTurn,
|
||||
messages: readonly ConversationalRectificationMessage[],
|
||||
) => void;
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
|
||||
}>;
|
||||
|
||||
export function ConversationalBirthTimeRectification(props: ConversationalBirthTimeRectificationProps) {
|
||||
return (
|
||||
<RectificationV4Panel
|
||||
pendingConsultationQuestion={props.pendingConsultationQuestion}
|
||||
continuationPending={props.continuationPending}
|
||||
onPendingChange={props.onPendingChange}
|
||||
onContinueOriginalQuestion={props.onContinueOriginalQuestion}
|
||||
/>
|
||||
);
|
||||
return <RectificationV4Panel {...props} />;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowUp, Check, Pause, Play, Square } from "lucide-react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRectificationV4 } from "@/hooks/use-rectification-v4";
|
||||
import type { CandidateCluster, LifeEventRevision } from "@/lib/rectification-v4/contracts";
|
||||
import { AppLoadingIndicator } from "./app-loading-indicator";
|
||||
import type { ChatMessageView } from "@/lib/chat-message-view";
|
||||
import type { PublicLanguageModel } from "@/lib/public-models";
|
||||
import type { RectificationV4ApiResponse } from "@/lib/rectification-v4/contracts";
|
||||
import { ChatMessageRow } from "./chat-message-row";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
import { Button } from "./ui/button";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
const phaseCopy = {
|
||||
extracting_evidence: "正在整理经历",
|
||||
scoring_candidates: "正在比较候选时间",
|
||||
checking_robustness: "正在做稳定性复核",
|
||||
planning_question: "正在准备下一步问题",
|
||||
collecting_evidence: "正在准备下一步问题",
|
||||
complete: "正在整理结果",
|
||||
} as const;
|
||||
|
||||
function minutes(time: string) {
|
||||
const [hour = 0, minute = 0] = time.split(":").map(Number);
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
function inCluster(time: string, cluster: CandidateCluster) {
|
||||
const value = minutes(time);
|
||||
const start = minutes(cluster.startTime);
|
||||
const end = minutes(cluster.endTime);
|
||||
return end >= start ? value >= start && value <= end : value >= start || value <= end;
|
||||
}
|
||||
|
||||
function latestEvents(events: readonly LifeEventRevision[]) {
|
||||
const latest = new Map<string, LifeEventRevision>();
|
||||
for (const event of events) {
|
||||
const current = latest.get(event.eventId);
|
||||
if (!current || current.revision < event.revision) latest.set(event.eventId, event);
|
||||
}
|
||||
return [...latest.values()].sort((left, right) => left.dateRange.start.localeCompare(right.dateRange.start));
|
||||
}
|
||||
|
||||
function eventText(event: LifeEventRevision) {
|
||||
return `${event.dateRange.label} · ${event.summary}`;
|
||||
}
|
||||
|
||||
export type RectificationV4Continuation = Readonly<{
|
||||
protocol: "rectification-evidence-v4";
|
||||
question: string;
|
||||
@@ -50,155 +19,234 @@ export type RectificationV4Continuation = Readonly<{
|
||||
acceptedRange: Readonly<{ start: string; end: string }>;
|
||||
}>;
|
||||
|
||||
export function RectificationV4Panel(props: Readonly<{
|
||||
type RectificationV4PanelProps = Readonly<{
|
||||
models: readonly PublicLanguageModel[];
|
||||
selectedModelId: string;
|
||||
onSelectModel: (modelId: string) => void;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
continuationPending?: boolean;
|
||||
onPendingChange?: (pending: boolean) => void;
|
||||
onContinueOriginalQuestion?: (continuation: RectificationV4Continuation) => void;
|
||||
}>) {
|
||||
}>;
|
||||
|
||||
export function rectificationV4ChatMessages(
|
||||
data: RectificationV4ApiResponse | null,
|
||||
processing: boolean,
|
||||
pendingConsultationQuestion?: string | null,
|
||||
): readonly ChatMessageView[] {
|
||||
if (!data) {
|
||||
return [{
|
||||
role: "assistant",
|
||||
text: "",
|
||||
renderKey: "rectification-loading",
|
||||
state: "thinking",
|
||||
}];
|
||||
}
|
||||
|
||||
const messages: ChatMessageView[] = [];
|
||||
if (pendingConsultationQuestion?.trim()) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: `我先陪你把出生时间范围核对清楚,之后再回到你原来的问题:“${pendingConsultationQuestion.trim()}”`,
|
||||
renderKey: "rectification-pending-consultation",
|
||||
state: "settled",
|
||||
});
|
||||
}
|
||||
|
||||
for (const turn of data.turns) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: turn.question,
|
||||
renderKey: `rectification-question-${turn.id}`,
|
||||
state: "settled",
|
||||
});
|
||||
if (turn.answer) {
|
||||
messages.push({
|
||||
role: "user",
|
||||
text: turn.answer,
|
||||
renderKey: `rectification-answer-${turn.id}`,
|
||||
state: "settled",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const caseValue = data.case;
|
||||
const primary = caseValue.latestSnapshot?.clusters[0];
|
||||
if (caseValue.acceptedRange) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: `候选范围已保存为 ${caseValue.acceptedRange.start}–${caseValue.acceptedRange.end}。这是校正得到的候选范围,原出生时间没有被自动改写。`,
|
||||
renderKey: `rectification-accepted-${caseValue.version}`,
|
||||
state: "settled",
|
||||
});
|
||||
} else if (caseValue.status === "range_ready" && primary) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: `根据目前这些经历,可以先把范围稳定缩小到 ${primary.startTime}–${primary.endTime}。这是候选范围,不是已确认的出生分钟;你可以保存它,也可以继续补充经历。`,
|
||||
renderKey: `rectification-range-${caseValue.version}`,
|
||||
state: "settled",
|
||||
});
|
||||
}
|
||||
|
||||
if (!processing && caseValue.currentQuestion && !caseValue.acceptedRange) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: caseValue.currentQuestion.prompt,
|
||||
renderKey: `rectification-current-${caseValue.currentQuestion.id}`,
|
||||
state: "settled",
|
||||
});
|
||||
}
|
||||
|
||||
if (processing) {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: "",
|
||||
renderKey: `rectification-processing-${data.job?.id ?? caseValue.version}`,
|
||||
state: "thinking",
|
||||
});
|
||||
} else if (caseValue.status === "paused") {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: "进度已经保存。准备好后,我们可以从这里继续。",
|
||||
renderKey: `rectification-paused-${caseValue.version}`,
|
||||
state: "settled",
|
||||
});
|
||||
} else if (caseValue.status === "abandoned") {
|
||||
messages.push({
|
||||
role: "assistant",
|
||||
text: "这次校正已经结束,原出生时间没有被改写。",
|
||||
renderKey: `rectification-abandoned-${caseValue.version}`,
|
||||
state: "settled",
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
export function RectificationV4Panel(props: RectificationV4PanelProps) {
|
||||
const controller = useRectificationV4({
|
||||
pendingConsultationQuestion: props.pendingConsultationQuestion,
|
||||
onPendingChange: props.onPendingChange,
|
||||
});
|
||||
const [draft, setDraft] = useState("");
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
const data = controller.data;
|
||||
const caseValue = data?.case;
|
||||
const snapshot = caseValue?.latestSnapshot;
|
||||
const primary = snapshot?.clusters[0];
|
||||
const allEvents = useMemo(() => latestEvents(data?.events ?? []), [data?.events]);
|
||||
const eventById = useMemo(() => new Map(allEvents.map((event) => [event.eventId, event])), [allEvents]);
|
||||
const evidence = useMemo(() => {
|
||||
if (!snapshot || !primary) return { supporting: [] as LifeEventRevision[], conflicting: [] as LifeEventRevision[] };
|
||||
const candidates = snapshot.candidates.filter((candidate) => inCluster(candidate.time, primary));
|
||||
const supporting = new Set(candidates.flatMap((candidate) => candidate.supportingEventIds));
|
||||
const conflicting = new Set(candidates.flatMap((candidate) => candidate.conflictingEventIds));
|
||||
return {
|
||||
supporting: [...supporting].map((id) => eventById.get(id)).filter((event): event is LifeEventRevision => Boolean(event)),
|
||||
conflicting: [...conflicting].map((id) => eventById.get(id)).filter((event): event is LifeEventRevision => Boolean(event)),
|
||||
};
|
||||
}, [eventById, primary, snapshot]);
|
||||
|
||||
if (controller.loading) {
|
||||
return <section className="rectification-v4-panel"><AppLoadingIndicator title="正在打开生时校正" detail="正在恢复已经保存的进度" /></section>;
|
||||
}
|
||||
if (!caseValue) {
|
||||
return <section className="rectification-v4-panel" role="alert"><p>{controller.error || "暂时无法打开生时校正。"}</p></section>;
|
||||
}
|
||||
|
||||
const processing = caseValue.status === "processing" || ["pending", "processing"].includes(controller.job?.status ?? "");
|
||||
const phase = controller.job?.phase ?? caseValue.phase;
|
||||
const canAnswer = Boolean(caseValue.currentQuestion) && !processing && ["awaiting_answer", "range_ready"].includes(caseValue.status);
|
||||
const accepted = caseValue.acceptedRange;
|
||||
const conversationEnd = useRef<HTMLDivElement>(null);
|
||||
const caseValue = controller.data?.case;
|
||||
const processing = Boolean(caseValue && (
|
||||
caseValue.status === "processing"
|
||||
|| ["pending", "processing"].includes(controller.job?.status ?? "")
|
||||
));
|
||||
const messages = rectificationV4ChatMessages(
|
||||
controller.data,
|
||||
processing,
|
||||
props.pendingConsultationQuestion,
|
||||
);
|
||||
const canAnswer = Boolean(caseValue?.currentQuestion)
|
||||
&& !processing
|
||||
&& !controller.pending
|
||||
&& ["awaiting_answer", "range_ready"].includes(caseValue?.status ?? "");
|
||||
const canAcceptRange = caseValue?.status === "range_ready"
|
||||
&& Boolean(caseValue.latestSnapshot?.canAcceptRange)
|
||||
&& !caseValue.acceptedRange;
|
||||
const handoff = controller.handoff;
|
||||
const canContinue = Boolean(accepted && handoff?.status === "pending" && props.onContinueOriginalQuestion);
|
||||
const canContinue = Boolean(
|
||||
caseValue?.acceptedRange
|
||||
&& handoff?.status === "pending"
|
||||
&& props.onContinueOriginalQuestion,
|
||||
);
|
||||
const showControls = Boolean(caseValue && caseValue.status !== "abandoned" && (
|
||||
canAnswer || canAcceptRange || canContinue || caseValue.status === "paused"
|
||||
));
|
||||
|
||||
useEffect(() => {
|
||||
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
conversationEnd.current?.scrollIntoView({
|
||||
behavior: processing || controller.pending || reduceMotion ? "auto" : "smooth",
|
||||
block: "end",
|
||||
});
|
||||
}, [controller.error, controller.pending, messages.length, processing]);
|
||||
|
||||
async function submit(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
const answer = draft.trim();
|
||||
if (!answer || !canAnswer) return;
|
||||
const result = await controller.answer(answer);
|
||||
const result = await controller.answer(answer, props.selectedModelId || null);
|
||||
if (result) setDraft("");
|
||||
}
|
||||
|
||||
function continueOriginalQuestion() {
|
||||
if (!caseValue?.acceptedRange || !handoff) return;
|
||||
props.onContinueOriginalQuestion?.({
|
||||
protocol: "rectification-evidence-v4",
|
||||
question: handoff.question,
|
||||
caseId: caseValue.id,
|
||||
caseVersion: caseValue.version,
|
||||
acceptedRange: caseValue.acceptedRange,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rectification-v4-panel" aria-busy={processing || controller.pending}>
|
||||
<header className="rectification-v4-header">
|
||||
<div>
|
||||
<p className="rectification-v4-eyebrow">生时校正 · 事件证据法</p>
|
||||
<h2>先核对经历,再比较候选时间</h2>
|
||||
<p>当前只在 {caseValue.calculationSpec.candidateRange.start}–{caseValue.calculationSpec.candidateRange.end} 内比较。这里显示的是候选范围,不是已确认的出生分钟。</p>
|
||||
</div>
|
||||
{caseValue.status === "paused" ? (
|
||||
<Button type="button" variant="outline" disabled={controller.pending} onClick={() => void controller.resume()}><Play aria-hidden="true" />继续</Button>
|
||||
) : caseValue.status !== "abandoned" && !accepted ? (
|
||||
<Button type="button" variant="outline" disabled={processing || controller.pending} onClick={() => void controller.pause()}><Pause aria-hidden="true" />暂停</Button>
|
||||
) : null}
|
||||
</header>
|
||||
<section className="rectification-chat" aria-label="生时校正对话" aria-busy={processing || controller.pending}>
|
||||
<div className="message-list" aria-live="polite">
|
||||
{messages.map((message) => <ChatMessageRow key={message.renderKey} message={message} />)}
|
||||
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
|
||||
<div ref={conversationEnd} />
|
||||
</div>
|
||||
|
||||
{props.pendingConsultationQuestion && (
|
||||
<aside className="rectification-v4-context">
|
||||
<b>原问题已保留</b>
|
||||
<p>{props.pendingConsultationQuestion}</p>
|
||||
</aside>
|
||||
)}
|
||||
{showControls && (
|
||||
<div className="composer-wrap">
|
||||
{(canAcceptRange || canContinue || caseValue?.status === "paused") && (
|
||||
<div className="composer-suggestions" aria-label="生时校正操作">
|
||||
{canAcceptRange && (
|
||||
<button type="button" disabled={controller.pending} onClick={() => void controller.acceptRange()}>
|
||||
保存这个候选范围
|
||||
</button>
|
||||
)}
|
||||
{canContinue && (
|
||||
<button type="button" disabled={props.continuationPending} onClick={continueOriginalQuestion}>
|
||||
{props.continuationPending ? "正在回到原问题…" : "带着候选范围继续原问题"}
|
||||
</button>
|
||||
)}
|
||||
{caseValue?.status === "paused" && (
|
||||
<button type="button" disabled={controller.pending} onClick={() => void controller.resume()}>
|
||||
继续校正
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{processing && (
|
||||
<div className="rectification-v4-processing" role="status">
|
||||
<AppLoadingIndicator title={phaseCopy[phase]} detail="回答已经保存,计算在后台继续" />
|
||||
<p>回答已经保存。你可以离开此页,稍后回来继续。</p>
|
||||
{canAnswer && (
|
||||
<form className="composer" onSubmit={submit}>
|
||||
<Textarea
|
||||
ref={composer}
|
||||
aria-label="继续描述你的经历"
|
||||
value={draft}
|
||||
disabled={controller.pending}
|
||||
placeholder="继续说你记得的人生经历…"
|
||||
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() || controller.pending} size="icon" type="submit">
|
||||
<ArrowUp aria-hidden="true" />
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="composer-footer">
|
||||
<ModelSelector
|
||||
models={props.models}
|
||||
selectedModelId={props.selectedModelId}
|
||||
disabled={controller.pending || processing}
|
||||
onSelect={props.onSelectModel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshot && primary && !processing && (
|
||||
<article className="rectification-v4-result">
|
||||
<p className="rectification-v4-eyebrow">当前结果</p>
|
||||
<div className="rectification-v4-ranges">
|
||||
<div><span>主要候选范围</span><strong>{primary.startTime}–{primary.endTime}</strong></div>
|
||||
{snapshot.clusters[1] && <div><span>次级候选范围</span><strong>{snapshot.clusters[1].startTime}–{snapshot.clusters[1].endTime}</strong></div>}
|
||||
</div>
|
||||
<div className="rectification-v4-evidence-grid">
|
||||
<div>
|
||||
<h3>支持这个范围的经历</h3>
|
||||
{evidence.supporting.length > 0 ? <ul>{evidence.supporting.map((event) => <li key={event.id}>{eventText(event)}</li>)}</ul> : <p>现有经历提供了初步支持,但还需要更多不同领域的事件。</p>}
|
||||
</div>
|
||||
<div>
|
||||
<h3>仍有冲突或区分力不足</h3>
|
||||
{evidence.conflicting.length > 0 ? <ul>{evidence.conflicting.map((event) => <li key={event.id}>{eventText(event)}</li>)}</ul> : <p>暂未发现明确冲突;范围仍会随新增事件变化。</p>}
|
||||
</div>
|
||||
</div>
|
||||
<p className="rectification-v4-uncertainty">系统只保存通过邻近分钟、逐项排除和日期敏感性复核的范围;不会把峰值分钟当作真实出生时间。</p>
|
||||
<div className="rectification-v4-actions">
|
||||
{caseValue.currentQuestion && !accepted && <Button type="button" variant="outline" onClick={() => composer.current?.focus()}>继续补充</Button>}
|
||||
{snapshot.canAcceptRange && !accepted && <Button type="button" disabled={controller.pending} onClick={() => void controller.acceptRange()}><Check aria-hidden="true" />保存这个范围</Button>}
|
||||
{accepted && <span className="rectification-v4-saved"><Check aria-hidden="true" />已保存 {accepted.start}–{accepted.end}</span>}
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
|
||||
{caseValue.status === "paused" && <p className="rectification-v4-notice">进度已保存。继续后会从下一道问题开始。</p>}
|
||||
{caseValue.status === "abandoned" && <p className="rectification-v4-notice">本次校正已结束,现有出生时间没有被改写。</p>}
|
||||
{controller.error && <p className="error-message" role="alert">{controller.error}</p>}
|
||||
|
||||
{canAnswer && (
|
||||
<form className="rectification-v4-composer" onSubmit={submit}>
|
||||
<label htmlFor="rectification-v4-answer">{caseValue.currentQuestion?.prompt}</label>
|
||||
<Textarea
|
||||
id="rectification-v4-answer"
|
||||
ref={composer}
|
||||
value={draft}
|
||||
disabled={controller.pending}
|
||||
placeholder="例如:2015 年高中毕业后复读一年,2016 年再次毕业。"
|
||||
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() || controller.pending} size="icon" type="submit"><ArrowUp aria-hidden="true" /></Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<footer className="rectification-v4-footer">
|
||||
{canContinue && accepted && handoff && (
|
||||
<Button type="button" disabled={props.continuationPending} onClick={() => props.onContinueOriginalQuestion?.({
|
||||
protocol: "rectification-evidence-v4",
|
||||
question: handoff.question,
|
||||
caseId: caseValue.id,
|
||||
caseVersion: caseValue.version,
|
||||
acceptedRange: accepted,
|
||||
})}>
|
||||
{props.continuationPending ? "正在回到原问题…" : "带着候选范围继续原问题"}
|
||||
</Button>
|
||||
)}
|
||||
{accepted && handoff?.status === "in_progress" && <span className="rectification-v4-saved">原问题正在另一设备继续回答</span>}
|
||||
{accepted && handoff?.status === "consumed" && <span className="rectification-v4-saved"><Check aria-hidden="true" />原问题已继续回答</span>}
|
||||
{!accepted && caseValue.status !== "abandoned" && (
|
||||
<button type="button" disabled={processing || controller.pending} onClick={() => void controller.abandon()}><Square aria-hidden="true" />结束本次校正</button>
|
||||
)}
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user