Files
Jyotisha/frontend/src/components/rectification-agentic-chat.tsx
T
Jesse_Chen c8af18e90a
Independent Staging Quality Gate / validate (push) Failing after 9m39s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(rectification): show the house table and hide the activity fold
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:11:51 +08:00

645 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
</>
);
}