Files
Jyotisha/frontend/src/components/rectification-agentic-chat.tsx
T

384 lines
16 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 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 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;
}>;
const savedSentinel = /<!--AYANAM_RECTIFICATION_SAVED:(\d{2}:\d{2})-->/;
type AgenticRectificationRequest = Readonly<
| { action: "opening" }
| { action: "message"; message: string }
>;
export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
const {
sessionId,
initialMessages,
models,
selectedModelId,
onSelectModel,
onMessagesChange,
onCompleted,
pendingConsultationQuestion,
onPendingChange,
onProfileIncomplete,
onSaved,
} = 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 [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 [acceptingTime, setAcceptingTime] = useState<string | null>(null);
const [suggestions, setSuggestions] = useState<string[]>([]);
const composer = useRef<HTMLTextAreaElement>(null);
const conversationEnd = useRef<HTMLDivElement>(null);
const keyCounter = useRef(0);
const openingStarted = useRef(false);
const setPending = useCallback((value: boolean) => {
setBusy(value);
onPendingChange?.(value);
}, [onPendingChange]);
useEffect(() => {
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
conversationEnd.current?.scrollIntoView({
behavior: busy || reduceMotion ? "auto" : "smooth",
block: "end",
});
}, [busy, error, messages.length, savedTime]);
const send = useCallback(async (request: AgenticRectificationRequest, showUserMessage = true) => {
const trimmed = request.action === "message" ? request.message.trim() : "";
if ((request.action === "message" && !trimmed) || busy) 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}`;
setMessages((current) => [
...current,
...(showUserMessage && request.action === "message"
? [{ role: "user", text: trimmed, renderKey: userRenderKey, state: "settled" } satisfies RenderMessage]
: []),
{ role: "assistant", text: "", renderKey: assistantRenderKey, state: "thinking" },
]);
setDraft("");
let raw = "";
let streamedSavedStatus: "accepted" | "confirmed" | null = null;
try {
const response = await fetch("/api/rectification/agent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
requestId,
sessionId,
modelId: selectedModelId,
history,
action: request.action,
...(request.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((item) => item.renderKey !== assistantRenderKey));
if (payload?.code === "profile_incomplete") {
onProfileIncomplete?.();
return;
}
if (response.status === 402) setError(`咨询点数不足:${message}`);
else 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: string; text?: string; message?: string; result?: CandidateResult };
try {
event = JSON.parse(line) as { type: string; text?: string; message?: string; result?: CandidateResult };
} catch {
continue;
}
if (event.type === "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 === "error") {
streamFailed = true;
setError(event.message || "生时校正暂时不可用,请稍后再试。");
} else if (event.type === "done") {
completed = true;
}
}
}
const parsed = parseAgentReply(raw, "general");
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)
: current.filter((message) => message.renderKey !== assistantRenderKey));
setSuggestions(succeeded ? parsed.suggestions : []);
if (succeeded) {
onMessagesChange?.([
...settledMessages,
...(request.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");
}
} catch {
setError("生时校正暂时不可用,请稍后再试。");
setMessages((current) => current.filter((message) => message.renderKey !== assistantRenderKey));
} finally {
setPending(false);
}
}, [busy, messages, onCompleted, onMessagesChange, onProfileIncomplete, onSaved, savedStatus, 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]);
const acceptCandidate = useCallback(async (time: string) => {
if (!candidateResult || acceptingTime) 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 payload = await response.json().catch(() => null);
if (!response.ok || payload?.ok !== true) throw new Error(payload?.message || payload?.error || "暂时无法采用该候选时间");
const status = payload.status === "confirmed" ? "confirmed" : "accepted";
setCandidateResult((current) => current ? { ...current, selectedTime: payload.saved_time, selectionStatus: status } : current);
setSavedTime(payload.saved_time);
setSavedStatus(status);
onSaved?.(payload.saved_time, status);
} 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]);
async function submit(event: React.FormEvent) {
event.preventDefault();
await send({ action: "message", message: draft });
}
const canSend = !busy;
return (
<>
<section className="conversation" aria-label="生时校正对话" aria-busy={busy}>
<div className="message-list" aria-live="polite">
{messages.map((message) => <ChatMessageRow key={message.renderKey} message={message} />)}
{candidateResult?.selectionAllowed && candidateResult.candidates.length > 0 && (
<section className="rectification-candidates" aria-label="生时校正候选时间">
<div className="rectification-candidates-heading">
<strong>{candidateResult.confirmationAllowed ? "已通过确认门" : "请选择校正采用时间"}</strong>
<span></span>
</div>
<div className="rectification-candidate-list">
{candidateResult.candidates.map((candidate) => {
const selected = candidateResult.selectedTime === candidate.time;
return (
<div className={`rectification-candidate${selected ? " is-selected" : ""}`} key={`${candidateResult.resultId}-${candidate.time}`}>
<div>
<strong>{candidate.time}</strong>
<span> {candidate.relative_support}%</span>
</div>
<div className="rectification-support" aria-hidden="true"><i style={{ width: `${candidate.relative_support}%` }} /></div>
<Button
type="button"
variant={selected ? "secondary" : "outline"}
disabled={Boolean(candidateResult.selectedTime) || Boolean(acceptingTime)}
onClick={() => void acceptCandidate(candidate.time)}
>
{selected ? "已采用" : acceptingTime === candidate.time ? "保存中…" : `采用 ${candidate.time}`}
</Button>
</div>
);
})}
</div>
</section>
)}
{savedTime && (
<p className="rectification-saved" role="status">
{savedStatus === "confirmed" ? "已确认校正时间" : "校正采用时间"}{savedTime}使
</p>
)}
{error && <p className="error-message" role="alert">{error}</p>}
<div ref={conversationEnd} />
</div>
</section>
<div className="composer-wrap">
{suggestions.length > 0 && !busy && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{suggestions.map((question) => (
<button key={question} type="button" onClick={() => void send({ action: "message", message: question })}>{question}</button>
))}
</div>
)}
<form className="composer" onSubmit={submit}>
<Textarea
ref={composer}
aria-label="继续描述你的经历或回答"
value={draft}
disabled={!canSend}
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() || !canSend} 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>
</>
);
}