Files
Jyotisha/frontend/src/hooks/use-consultation-run.ts
T
Jesse_ChenandClaude Fable 5.1 ad9dba5c79 perf(chat): coalesce stream events per frame and pace text release
Every NDJSON event used to commit its own React update and re-parse the
whole partial answer through react-markdown, so long replies grew
quadratically slower. Stream events now land in a frame buffer that
flushes at most once per animation frame, releases answer and thinking
text at a steady pace with a twelve-frame catch-up, and settles
synchronously on completion, failure and abort. Streaming markdown is
split at the last completed block so only the tail is re-parsed each
frame. Applied to both the consultation hook and the rectification chat.

BUG-473

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-02 04:29:01 +00:00

1079 lines
42 KiB
TypeScript

"use client";
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { parseAgentReply, isGenericSessionTitle, resolveSessionTitle } from "@/lib/agent-reply";
import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mode";
import {
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
isGeneralDailyFortuneQuestion,
isRectificationHandoffQuestion,
type ConsultationEntrypoint,
} from "@/lib/consultation-entrypoint";
import {
grantBirthTimeConsultationConsent,
resolveBirthTimeConsultationRoute,
type BirthTimeConsultationConsentState,
} from "@/lib/birth-time-consultation-consent";
import { nextActivityView, activityCompletedTrail, type AgentActivityView } from "@/lib/chat-message-view";
import {
emptyConsultationTimeline,
reduceConsultationTimeline,
} from "@/lib/consultation-run-timeline";
import { createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "@/lib/consultation-agent-events";
import { createStreamFrameBuffer } from "@/lib/stream-frame-buffer";
import {
applyThinkingSectionProgress,
upsertThinkingSection,
type PublicThinkingSection,
} from "@/lib/consultation-thinking-plan";
import {
CONSULTATION_CHART_CALCULATION_LABEL,
CONSULTATION_COMPOSING_LABEL,
CONSULTATION_DONE_CHART_LABEL,
CONSULTATION_DONE_SKILL_LABEL,
CONSULTATION_EVIDENCE_VALIDATION_LABEL,
CONSULTATION_LOADING_METHOD_LABEL,
} from "@/lib/consultation-activity-labels";
import {
BALANCE_CHANGED_EVENT,
BALANCE_SYNC_KEY,
membershipHref,
} from "@/lib/membership";
import {
CancellationResponseError,
ConsultationResponseError,
ConsultationStatusError,
fetchActiveConsultationStatus,
fetchConsultationStatus,
fetchSessionDetail,
friendlyError,
LoginRedirectError,
mergeHydratedSession,
payloadCode,
payloadMessage,
waitForUndoWindow,
} from "@/lib/home-cloud-sync";
import { completedOnboardingTranscript, isProfileComplete, selectedBirthPlace } from "@/lib/home-profile";
import { pendingConsultationStorageKey, timestamp } from "@/lib/home-types";
import type {
Account,
AccountDialog,
ChatSession,
ConsultationStatus,
Message,
PendingConsultation,
Profile,
ReplyOutcome,
RequestError,
StoredPendingConsultation,
StreamingReply,
Theme,
} from "@/lib/home-types";
import type { ChatMessageFeedback } from "@/components/chat-message-actions";
import type { PublicLanguageModelCatalog } from "@/lib/public-models";
type ConversationAnchor = {
readonly anchored: boolean;
readonly anchorToLatest: () => void;
};
export type ConsultationRunParams = {
account: Account | null;
activeSession: ChatSession | undefined;
activeSessionIdRef: MutableRefObject<string>;
birthTimeConsultationConsent: BirthTimeConsultationConsentState;
cancellationFeedbackRequest: MutableRefObject<string | null>;
cancellationInFlight: MutableRefObject<boolean>;
cancellationPending: boolean;
cancellationRequests: MutableRefObject<Map<string, Promise<void>>>;
composerInput: RefObject<HTMLTextAreaElement | null>;
consultationRecoveryCheck: MutableRefObject<() => void>;
consultationRecoveryWakeup: MutableRefObject<() => void>;
consultationReplay: MutableRefObject<() => void>;
consultationReplayStarted: MutableRefObject<string | null>;
consultationStatusMissingCount: MutableRefObject<number>;
conversationAnchor: ConversationAnchor;
isLoading: boolean;
modelCatalog: PublicLanguageModelCatalog | null;
onboardingJustCompleted: boolean;
pendingConsultation: MutableRefObject<PendingConsultation | null>;
pendingSessionId: string | null;
profile: Profile;
router: { push: (href: string) => void };
sessions: ChatSession[];
setAccount: Dispatch<SetStateAction<Account | null>>;
setActiveSessionId: Dispatch<SetStateAction<string>>;
setCancellationPending: Dispatch<SetStateAction<boolean>>;
setConsultationPhase: Dispatch<SetStateAction<"undo" | "streaming" | "recovering" | null>>;
setDraft: (value: string) => void;
setDraftEntrypoint: (entrypoint: ConsultationEntrypoint | null) => void;
setDraftTheme: (theme: Theme | null) => void;
setMessageFeedback: Dispatch<SetStateAction<Record<string, ChatMessageFeedback>>>;
setOnboardingJustCompleted: Dispatch<SetStateAction<boolean>>;
setPendingRequestId: Dispatch<SetStateAction<string | null>>;
setPendingSessionId: Dispatch<SetStateAction<string | null>>;
setProfileNotice: Dispatch<SetStateAction<string>>;
setReplyOutcome: Dispatch<SetStateAction<ReplyOutcome | null>>;
setRequestError: Dispatch<SetStateAction<RequestError | null>>;
setSessionFullPrompt: Dispatch<SetStateAction<{ question: string; theme: Theme } | null>>;
setSessions: Dispatch<SetStateAction<ChatSession[]>>;
setStreamingReply: Dispatch<SetStateAction<StreamingReply | null>>;
startGreeting: string;
stoppedRequestAwaitingSettlement: MutableRefObject<string | null>;
stoppedSessionPersistence: MutableRefObject<Map<string, Promise<void>>>;
uiPreview: MutableRefObject<boolean>;
uiPreviewMode: MutableRefObject<string | null>;
persistSession: (session: ChatSession, mode?: "create" | "update") => Promise<void>;
updateSession: (sessionId: string, change: (session: ChatSession) => ChatSession) => void;
startNewChat: () => Promise<ChatSession | null>;
continueInNewChat: (prompt: { question: string; theme: Theme }) => Promise<void>;
refreshAccount: () => Promise<void>;
openAccountDialog: (dialog: AccountDialog, returnTarget?: HTMLButtonElement | null) => void;
openRectificationFromHomepage: (pendingConsultationQuestion?: string | null) => Promise<void>;
};
export function useConsultationRun(params: ConsultationRunParams) {
const {
account,
activeSession,
activeSessionIdRef,
birthTimeConsultationConsent,
cancellationFeedbackRequest,
cancellationInFlight,
cancellationPending,
cancellationRequests,
composerInput,
consultationRecoveryCheck,
consultationRecoveryWakeup,
consultationReplay,
consultationReplayStarted,
consultationStatusMissingCount,
conversationAnchor,
isLoading,
modelCatalog,
onboardingJustCompleted,
pendingConsultation,
pendingSessionId,
profile,
router,
sessions,
setAccount,
setActiveSessionId,
setCancellationPending,
setConsultationPhase,
setDraft,
setDraftEntrypoint,
setDraftTheme,
setMessageFeedback,
setOnboardingJustCompleted,
setPendingRequestId,
setPendingSessionId,
setProfileNotice,
setReplyOutcome,
setRequestError,
setSessionFullPrompt,
setSessions,
setStreamingReply,
startGreeting,
stoppedRequestAwaitingSettlement,
stoppedSessionPersistence,
uiPreview,
uiPreviewMode,
persistSession,
updateSession,
startNewChat,
continueInNewChat,
refreshAccount,
openAccountDialog,
openRectificationFromHomepage,
} = params;
function restoreConsultationRecovery(
session: ChatSession,
requestId: string,
stored?: StoredPendingConsultation | null,
) {
if (pendingConsultation.current) return;
const lastMessage = session.messages.at(-1);
const storedQuestion = stored?.question?.trim() ?? "";
const lastIsQuestion = lastMessage?.role === "user"
&& (!storedQuestion || lastMessage.text === storedQuestion);
const question = lastIsQuestion && lastMessage ? lastMessage.text : storedQuestion;
const optimisticSession = lastIsQuestion || !question
? session
: {
...session,
title: session.messages.length === 0 && isGenericSessionTitle(session.title)
? resolveSessionTitle(question, undefined, {
entrypoint: stored?.entrypoint,
theme: stored?.theme ?? session.theme,
existingTitles: sessions.filter((item) => item.id !== session.id).map((item) => item.title),
})
: session.title,
theme: stored?.theme ?? session.theme,
messages: [...session.messages, { role: "user" as const, text: question }],
updatedAt: timestamp(),
};
const previousSession = optimisticSession.messages.at(-1)?.role === "user"
? { ...optimisticSession, messages: optimisticSession.messages.slice(0, -1) }
: optimisticSession;
if (optimisticSession !== session) updateSession(session.id, () => optimisticSession);
pendingConsultation.current = {
requestId,
sessionId: session.id,
question,
entrypoint: stored?.entrypoint ?? null,
theme: stored?.theme ?? session.theme,
previousSession,
optimisticSession,
previousOnboardingState: false,
controller: new AbortController(),
cancelled: false,
phase: "recovering",
partialReply: "",
};
setPendingSessionId(session.id);
setPendingRequestId(requestId);
setActiveSessionId(session.id);
setConsultationPhase("recovering");
setStreamingReply({ sessionId: session.id, text: "" });
setComposerNotice(navigator.onLine
? "回答仍在后台生成,正在自动恢复。"
: "网络已断开,回答仍在后台生成;联网后会自动恢复。");
}
consultationRecoveryCheck.current = () => {
consultationRecoveryWakeup.current();
if (pendingConsultation.current || uiPreview.current) return;
void fetchActiveConsultationStatus()
.then((status) => {
if (status?.status !== "reserved") return;
const session = sessions.find((item) => item.id === status.sessionId);
if (session) restoreConsultationRecovery(session, status.requestId);
})
.catch(() => undefined);
};
async function requestCancellation(requestId: string) {
const existing = cancellationRequests.current.get(requestId);
if (existing) return existing;
const cancellation = (async () => {
const response = await fetch("/api/consult/cancel", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ requestId }),
keepalive: true,
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new CancellationResponseError(
response.status,
payloadMessage(payload, "暂时无法确认点数已退回"),
);
}
if (!payload || typeof payload !== "object") return;
const credits = "credits" in payload ? payload.credits : null;
if (typeof credits === "number") {
setAccount((current) => current ? { ...current, credits } : current);
}
})();
cancellationRequests.current.set(requestId, cancellation);
return cancellation;
}
async function confirmCancellation(requestId: string, sessionId: string, confirmedNotice: string) {
try {
await requestCancellation(requestId);
if (cancellationFeedbackRequest.current === requestId && activeSessionIdRef.current === sessionId) {
setComposerNotice(confirmedNotice);
}
} catch (error) {
if (cancellationFeedbackRequest.current === requestId && activeSessionIdRef.current === sessionId) {
setComposerNotice(error instanceof CancellationResponseError && error.status === 409
? "回答已完成结算,本次已计费;问题仍保留在输入框。"
: "问题已放回输入框;暂时无法确认点数状态,请稍后在账户中核对。");
setRequestError((current) => current?.sessionId === sessionId ? current : {
sessionId,
message: error instanceof Error ? error.message : "暂时无法确认点数状态。",
});
}
void refreshAccount();
}
}
async function stopResponse() {
const pending = pendingConsultation.current;
if (!pending || pending.cancelled) return;
setReplyOutcome({ sessionId: pending.sessionId, phase: "stopped", replyOrdinal: 0 });
const isPreview = process.env.NODE_ENV === "development" && uiPreview.current;
if (pending.phase !== "undo" && !isPreview) {
stoppedRequestAwaitingSettlement.current = pending.requestId;
cancellationInFlight.current = true;
setCancellationPending(true);
}
pendingConsultation.current = { ...pending, cancelled: true };
pending.controller.abort();
if (pending.partialReply) {
const stoppedSession: ChatSession = {
...pending.optimisticSession,
messages: [...pending.optimisticSession.messages, { role: "assistant", text: pending.partialReply }],
updatedAt: timestamp(),
};
if (isPreview) {
updateSession(pending.sessionId, () => stoppedSession);
setStreamingReply(null);
setPendingSessionId(null);
setConsultationPhase(null);
setRequestError(null);
setComposerNotice("已停止回答,现有内容已保留。");
if (pendingConsultation.current?.requestId === pending.requestId) {
pendingConsultation.current = null;
}
return;
}
setComposerNotice("正在停止回答并申请退回本次点数…");
try {
await requestCancellation(pending.requestId);
} catch (error) {
cancellationRequests.current.delete(pending.requestId);
stoppedRequestAwaitingSettlement.current = null;
cancellationInFlight.current = false;
setCancellationPending(false);
pendingConsultation.current = {
...pending,
controller: new AbortController(),
cancelled: false,
phase: "recovering",
};
setPendingSessionId(pending.sessionId);
setConsultationPhase("recovering");
setStreamingReply({ sessionId: pending.sessionId, text: pending.partialReply });
setRequestError(null);
if (error instanceof CancellationResponseError && error.status === 409) {
setComposerNotice("回答已完成,正在恢复服务端完整内容。");
try {
const status = await fetchConsultationStatus(pending.sessionId, pending.requestId);
if (status.status === "completed") {
const detailed = await fetchSessionDetail(pending.sessionId, modelCatalog);
if (detailed) {
setSessions((current) => mergeHydratedSession(current, detailed));
setActiveSessionId((current) => current || detailed.id);
}
pendingConsultation.current = null;
setPendingSessionId(null);
setConsultationPhase(null);
setStreamingReply(null);
setComposerNotice("回答已恢复,已显示在对话区末尾。");
void refreshAccount();
return;
}
} catch {
// The recovery poll retries status and session reload.
}
} else {
setComposerNotice("停止请求尚未确认,正在自动恢复后台回答。");
}
window.setTimeout(() => consultationRecoveryWakeup.current(), 0);
return;
}
updateSession(pending.sessionId, () => stoppedSession);
setStreamingReply(null);
setPendingSessionId(null);
setConsultationPhase(null);
setRequestError(null);
setComposerNotice("已停止回答,现有内容已保留,本次点数已退回。");
cancellationRequests.current.delete(pending.requestId);
stoppedRequestAwaitingSettlement.current = null;
cancellationInFlight.current = false;
setCancellationPending(false);
if (pendingConsultation.current?.requestId === pending.requestId) {
pendingConsultation.current = null;
}
void refreshAccount();
return;
}
updateSession(pending.sessionId, () => pending.previousSession);
setOnboardingJustCompleted(pending.previousOnboardingState);
setDraft(pending.question);
setDraftTheme(pending.theme);
setDraftEntrypoint(pending.entrypoint);
setStreamingReply(null);
setPendingSessionId(null);
setConsultationPhase(null);
setRequestError(null);
cancellationFeedbackRequest.current = pending.requestId;
setComposerNotice("已停止,问题已放回输入框,正在确认点数…");
window.requestAnimationFrame(() => composerInput.current?.focus());
if (pending.phase === "undo" || isPreview) {
if (pendingConsultation.current?.requestId === pending.requestId) {
pendingConsultation.current = null;
}
setComposerNotice("已停止,问题已放回输入框,本次未扣点。");
return;
}
await confirmCancellation(
pending.requestId,
pending.sessionId,
"已停止,问题已放回输入框,本次未扣点。",
);
cancellationRequests.current.delete(pending.requestId);
stoppedRequestAwaitingSettlement.current = null;
cancellationInFlight.current = false;
setCancellationPending(false);
if (pendingConsultation.current?.requestId === pending.requestId) pendingConsultation.current = null;
}
function completeConsultationInterface(requestId: string) {
if (pendingConsultation.current?.requestId !== requestId) return;
pendingConsultation.current = null;
if (consultationReplayStarted.current === requestId) consultationReplayStarted.current = null;
setStreamingReply(null);
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
}
async function send(
text: string,
requestedTheme?: Theme,
entrypoint: ConsultationEntrypoint | null = null,
consentGrantedForRequest: ConsultationBirthTimeMode | null = null,
targetSessionId: string | null = null,
options: {
resumeRequestId?: string;
sessionOverride?: ChatSession;
restoreOnFailure?: ChatSession;
} = {},
): Promise<boolean> {
const originalQuestion = text;
const question = text.trim();
const consultEntrypoint = entrypoint
?? (isRectificationHandoffQuestion(question)
? "birth_time_rectification" as const
: isGeneralDailyFortuneQuestion(question)
? "daily_starlanguage" as const
: null);
const resumeRequestId = options.resumeRequestId;
const resuming = Boolean(resumeRequestId);
const liveSession = targetSessionId
? sessions.find((session) => session.id === targetSessionId)
: activeSession;
const currentSession = options.sessionOverride ?? liveSession;
if (!question || !currentSession || !modelCatalog || !account) return false;
const rollbackSession = options.restoreOnFailure ?? liveSession ?? currentSession;
if (!resuming && (pendingSessionId || cancellationInFlight.current || pendingConsultation.current)) return false;
if (resuming) {
const pending = pendingConsultation.current;
if (!pending
|| pending.requestId !== resumeRequestId
|| pending.sessionId !== currentSession.id
|| pending.cancelled) return false;
}
if (!isProfileComplete(profile)) {
openAccountDialog("profile");
setProfileNotice("请先补充出生资料,才能进行星盘计算。");
return false;
}
if (consultEntrypoint === "birth_time_rectification") {
const previousUserQuestion = [...currentSession.messages]
.reverse()
.find((message) => message.role === "user")
?.text
.trim() ?? null;
const pendingQuestion = previousUserQuestion && !isRectificationHandoffQuestion(previousUserQuestion)
? previousUserQuestion
: null;
await openRectificationFromHomepage(pendingQuestion);
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
return false;
}
const birthPlace = selectedBirthPlace(profile);
if (!birthPlace) return false;
const theme = requestedTheme ?? currentSession.theme;
const sessionId = currentSession.id;
const consentForDecision = consentGrantedForRequest === "unverified_birth_time"
? grantBirthTimeConsultationConsent(
birthTimeConsultationConsent,
sessionId,
"unverified_birth_time",
)
: birthTimeConsultationConsent;
const initialConsultationRoute = resolveBirthTimeConsultationRoute(
profile,
consentForDecision,
sessionId,
);
const consultationRoute = initialConsultationRoute.kind === "choice"
? { kind: "consult" as const, mode: "general_no_birth_time" as const, time: null }
: initialConsultationRoute;
if (account.credits <= 0 && !account.activeSubscription) {
router.push(membershipHref("insufficient-credits"));
return false;
}
const [year, month, day] = profile.date.split("-").map(Number);
const [hour, minute] = consultationRoute.time?.split(":").map(Number) ?? [];
const lastMessage = currentSession.messages.at(-1);
const questionAlreadyPresent = lastMessage?.role === "user" && lastMessage.text === question;
const preservedMessages = questionAlreadyPresent
? currentSession.messages
: (onboardingJustCompleted && currentSession.messages.length === 0
? completedOnboardingTranscript(profile, startGreeting)
: currentSession.messages);
const userSession: ChatSession = {
...currentSession,
title: currentSession.messages.length === 0 && isGenericSessionTitle(currentSession.title)
? resolveSessionTitle(question, undefined, {
entrypoint: consultEntrypoint,
theme,
existingTitles: sessions.filter((item) => item.id !== currentSession.id).map((item) => item.title),
})
: currentSession.title,
theme,
messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }],
updatedAt: questionAlreadyPresent ? currentSession.updatedAt : timestamp(),
messagesHydrated: true,
};
const requestId = resumeRequestId ?? globalThis.crypto.randomUUID();
const controller = resuming && pendingConsultation.current
? pendingConsultation.current.controller
: new AbortController();
const previousOnboardingState = onboardingJustCompleted;
cancellationFeedbackRequest.current = null;
setRequestError(null);
setReplyOutcome(null);
if (!resuming) {
setComposerNotice("");
consultationStatusMissingCount.current = 0;
consultationReplayStarted.current = null;
setPendingSessionId(sessionId);
setPendingRequestId(requestId);
setConsultationPhase("undo");
pendingConsultation.current = {
requestId,
sessionId,
question: originalQuestion,
entrypoint: consultEntrypoint,
theme,
previousSession: rollbackSession,
optimisticSession: userSession,
previousOnboardingState,
controller,
cancelled: false,
phase: "undo",
partialReply: "",
};
try {
sessionStorage.setItem(pendingConsultationStorageKey, JSON.stringify({
sessionId,
requestId,
question: originalQuestion,
theme,
entrypoint: consultEntrypoint,
}));
} catch {
// Private-mode storage must not block send.
}
setOnboardingJustCompleted(false);
updateSession(sessionId, () => userSession);
conversationAnchor.anchorToLatest();
setDraft("");
setDraftTheme(null);
setDraftEntrypoint(null);
}
if (!resuming && process.env.NODE_ENV === "development" && uiPreview.current) {
setStreamingReply({ sessionId, text: "" });
if (uiPreviewMode.current === "partial") {
const partialReply = "已开始查看事业方向与关键时间,先给你一个阶段性的判断。";
if (pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = {
...pendingConsultation.current,
phase: "streaming",
partialReply,
};
}
setConsultationPhase("streaming");
setStreamingReply({ sessionId, text: partialReply });
}
await new Promise((resolve) => window.setTimeout(resolve, uiPreviewMode.current === "streaming" || uiPreviewMode.current === "partial" ? 15_000 : 800));
if (controller.signal.aborted) {
if (pendingConsultation.current?.requestId === requestId) pendingConsultation.current = null;
return false;
}
const previewReply = parseAgentReply([
"这是本地交互预览。正式对话会结合你的星盘证据继续分析。",
"<!--AYANAM_TITLE:事业方向与时间选择-->",
].join("\n"));
const previewSession: ChatSession = {
...userSession,
title: userSession.title,
messages: [...userSession.messages, {
role: "assistant",
text: previewReply.text,
}],
updatedAt: timestamp(),
};
updateSession(sessionId, () => previewSession);
completeConsultationInterface(requestId);
return true;
}
if (!resuming) {
await waitForUndoWindow(controller.signal);
if (controller.signal.aborted) return false;
}
if (!resuming || !questionAlreadyPresent) {
if (resuming && !questionAlreadyPresent) updateSession(sessionId, () => userSession);
}
if (pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = {
...pendingConsultation.current,
question: originalQuestion,
entrypoint: consultEntrypoint,
theme,
optimisticSession: userSession,
phase: "streaming",
};
setConsultationPhase("streaming");
}
setStreamingReply({ sessionId, text: "", timeline: [] });
let latestPartialReply = "";
let thinkingSections: PublicThinkingSection[] = [];
let streamedThinking = "";
let timelineState = emptyConsultationTimeline();
let currentActivity: AgentActivityView | undefined;
// Every stream event lands in `frames`; it commits at most once per animation
// frame and releases text at a steady pace. Nothing below calls
// setStreamingReply directly while the response body is being read.
const frames = createStreamFrameBuffer<null>({
initialMeta: null,
flush: (frame) => {
const partialReply = parseAgentReply(frame.answer).text;
latestPartialReply = partialReply;
thinkingSections = applyThinkingSectionProgress(thinkingSections, partialReply);
setStreamingReply({
sessionId,
text: partialReply,
thinkingText: frame.thinking.trim() || undefined,
thinkingSections: thinkingSections.length ? thinkingSections : undefined,
timeline: timelineState.rows,
activity: currentActivity,
});
if (partialReply && pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = { ...pendingConsultation.current, partialReply };
}
},
});
try {
const response = await fetch("/api/consult", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
requestId,
sessionId: currentSession.id,
modelId: currentSession.modelId,
name: profile.name,
consultationMode: consultationRoute.mode,
entrypoint: consultEntrypoint ?? undefined,
...(consultationRoute.mode === "general_no_birth_time" || consultationRoute.mode === "declared_birth_window" ? {} : {
year,
month,
day,
hour,
minute,
city: birthPlace.label,
lat: birthPlace.lat,
lon: birthPlace.lon,
tz: birthPlace.tz,
entryMode: "direct_chart" as const,
}),
theme,
question,
history: currentSession.messages.slice(-12).map((message) => ({
role: message.role,
text: message.text.slice(0, 4000),
})),
}),
signal: controller.signal,
});
if (!response.ok) {
const contentType = response.headers.get("content-type") ?? "";
const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() };
if (response.status === 401) window.location.assign("/login");
if (response.status === 402) router.push(membershipHref("insufficient-credits"));
throw new ConsultationResponseError(
response.status,
payloadMessage(errorPayload, "服务暂时不可用"),
payloadCode(errorPayload),
);
}
if (!response.body) {
throw new ConsultationResponseError(502, "浏览器未收到可读取的回答流");
}
let techniqueTruth = response.headers.get("x-jyotish-technique-truth") ?? "unknown";
let workflowReceipt: AgentExecutionReceipt["workflow"] = {
route: response.headers.get("x-jyotish-workflow-route") ?? "unknown",
status: response.headers.get("x-jyotish-workflow-status") ?? "unknown",
preciseTiming: response.headers.get("x-jyotish-precise-timing") ?? "unknown",
missingLayers: (response.headers.get("x-jyotish-missing-layers") ?? "none")
.split(",")
.map((item) => item.trim())
.filter((item) => item && item !== "none"),
};
let agentExecutionReceipt: AgentExecutionReceipt | undefined;
let runCompleted = false;
let truncatedFailure: Extract<ConsultationAgentPublicEvent, { type: "run.failed" }> | undefined;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let answer = "";
const updateActivity = (event: ConsultationAgentPublicEvent) => {
let activity: AgentActivityView | undefined;
if (event.type === "skill.started") {
activity = { phase: "loading-method", label: CONSULTATION_LOADING_METHOD_LABEL };
} else if (event.type === "skill.completed" || event.type === "tool.started") {
activity = {
phase: "chart-calculation",
label: CONSULTATION_CHART_CALCULATION_LABEL,
completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]),
};
} else if (event.type === "activity") {
activity = {
phase: event.phase,
label: event.label,
completedTrail: event.phase === "evidence-validation"
? activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL, CONSULTATION_DONE_CHART_LABEL])
: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL]),
};
} else if (event.type === "tool.completed") {
activity = {
phase: "evidence-validation",
label: CONSULTATION_EVIDENCE_VALIDATION_LABEL,
completedTrail: activityCompletedTrail([CONSULTATION_DONE_SKILL_LABEL, CONSULTATION_DONE_CHART_LABEL]),
};
} else if (event.type === "answer.delta") {
activity = { phase: "answer-composition", label: CONSULTATION_COMPOSING_LABEL };
}
if (activity) {
currentActivity = nextActivityView(currentActivity, activity);
frames.touch();
}
};
if ((response.headers.get("content-type") ?? "").includes("application/x-ndjson")) {
const parser = createNdjsonParser((event) => {
timelineState = reduceConsultationTimeline(timelineState, event);
frames.touch();
if (event.type === "answer.delta") {
answer += event.text;
frames.setAnswer(answer);
}
if (event.type === "thinking.delta" && typeof event.text === "string") {
streamedThinking += event.text;
frames.setThinking(streamedThinking);
}
if (event.type === "thinking.section") {
thinkingSections = applyThinkingSectionProgress(
upsertThinkingSection(thinkingSections, {
id: event.id,
title: event.title,
heading: event.heading,
steps: event.steps,
}),
parseAgentReply(answer).text,
);
}
if (event.type === "run.completed") {
runCompleted = true;
agentExecutionReceipt = event.receipt;
workflowReceipt = event.receipt.workflow;
techniqueTruth = event.receipt.techniqueTruth ?? "unknown";
}
if (event.type === "run.failed") {
if (event.code === "answer_truncated") {
truncatedFailure = event;
if (event.receipt) {
agentExecutionReceipt = event.receipt;
workflowReceipt = event.receipt.workflow;
techniqueTruth = event.receipt.techniqueTruth ?? techniqueTruth;
}
return;
}
throw new ConsultationResponseError(502, event.message);
}
updateActivity(event);
});
while (true) {
const { done, value } = await reader.read();
if (done) break;
parser.push(decoder.decode(value, { stream: true }));
}
parser.finish(decoder.decode());
frames.settle();
if (truncatedFailure) {
const reply = parseAgentReply(answer);
if (!reply.text) throw new ConsultationResponseError(502, truncatedFailure.message);
const truncatedSession: ChatSession = {
...userSession,
title: userSession.title,
messages: [...userSession.messages, {
role: "assistant",
text: reply.text,
...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}),
...(thinkingSections.length ? { thinkingSections } : {}),
techniqueTruth,
workflowReceipt,
agentExecutionReceipt,
}],
updatedAt: timestamp(),
};
updateSession(sessionId, () => truncatedSession);
setStreamingReply(null);
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
setComposerNotice(truncatedFailure.message);
completeConsultationInterface(requestId);
void refreshAccount();
return true;
}
if (!runCompleted && !truncatedFailure) {
throw new ConsultationResponseError(
502,
thinkingSections.length
? "这次还没有生成可显示的回答。思考步骤已保留,可以直接继续问。"
: "Agent 回答未完成,本次不会保存为成功咨询。",
);
}
} else {
while (true) {
const { done, value } = await reader.read();
if (done) break;
answer += decoder.decode(value, { stream: true });
frames.setAnswer(answer);
}
answer += decoder.decode();
frames.setAnswer(answer);
frames.settle();
}
if (controller.signal.aborted) return Boolean(latestPartialReply);
const reply = parseAgentReply(answer);
if (!reply.text) {
throw thinkingSections.length
? new ConsultationResponseError(502, "这次还没有生成可显示的回答。思考步骤已保留,可以直接继续问。")
: new Error("Agent 没有返回可显示的回答,请重试。");
}
const completedTitle = reply.title && !isGenericSessionTitle(reply.title)
? resolveSessionTitle(question, reply.title, {
entrypoint: consultEntrypoint,
theme,
existingTitles: sessions.filter((item) => item.id !== sessionId).map((item) => item.title),
})
: userSession.title;
const completedSession: ChatSession = {
...userSession,
title: completedTitle,
messages: [...userSession.messages, {
role: "assistant",
text: reply.text,
...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}),
...(thinkingSections.length ? { thinkingSections } : {}),
techniqueTruth,
workflowReceipt,
agentExecutionReceipt,
}],
updatedAt: timestamp(),
};
updateSession(sessionId, () => completedSession);
try {
await persistSession(completedSession);
} catch (error) {
setComposerNotice(error instanceof Error ? error.message : "回答已生成,但云端同步暂时失败。");
}
setReplyOutcome({
sessionId,
phase: "completed",
replyOrdinal: completedSession.messages.filter((message) => message.role === "assistant").length,
});
completeConsultationInterface(requestId);
void refreshAccount();
return true;
} catch (caught) {
// Whatever arrived before the failure is what gets kept, not just the
// part the pacing had released so far.
frames.settle();
const cancelled = controller.signal.aborted;
const ownsInterface = pendingConsultation.current?.requestId === requestId;
const partialReply = latestPartialReply;
if (!cancelled && ownsInterface && pendingConsultation.current && caught instanceof ConsultationResponseError) {
if (caught.code === "session_full") {
updateSession(sessionId, () => rollbackSession);
setOnboardingJustCompleted(previousOnboardingState);
setSessionFullPrompt({ question: originalQuestion, theme });
setComposerNotice("这段对话已写满,开个新对话继续吧", {
label: "开新对话",
onClick: () => {
void continueInNewChat({ question: originalQuestion, theme });
},
});
completeConsultationInterface(requestId);
return false;
}
if (caught.message === "request_conflict") {
pendingConsultation.current = {
...pendingConsultation.current,
phase: "recovering",
partialReply,
};
setConsultationPhase("recovering");
setRequestError(null);
setComposerNotice("回答仍在后台生成,正在自动恢复。");
return Boolean(partialReply);
}
const reserveDidNotCommit = caught.status === 400
|| caught.status === 401
|| caught.status === 402
|| caught.status === 409;
if (reserveDidNotCommit) {
updateSession(sessionId, () => rollbackSession);
setOnboardingJustCompleted(previousOnboardingState);
if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) {
setDraft(originalQuestion);
setDraftTheme(theme);
setDraftEntrypoint(consultEntrypoint);
}
}
setRequestError({ sessionId, message: caught.message });
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
setComposerNotice(caught.message);
const restore = options.restoreOnFailure;
if (restore) {
updateSession(sessionId, () => restore);
} else if (!reserveDidNotCommit && (thinkingSections.length || latestPartialReply || streamedThinking.trim())) {
const failedSession: ChatSession = {
...userSession,
messages: [...userSession.messages, {
role: "assistant",
text: latestPartialReply,
...(streamedThinking.trim() ? { thinkingText: streamedThinking.trim().slice(0, 4000) } : {}),
...(thinkingSections.length ? { thinkingSections } : {}),
}],
updatedAt: timestamp(),
};
updateSession(sessionId, () => failedSession);
}
completeConsultationInterface(requestId);
return false;
}
if (!cancelled && ownsInterface && pendingConsultation.current) {
pendingConsultation.current = {
...pendingConsultation.current,
phase: "recovering",
partialReply,
};
setConsultationPhase("recovering");
setRequestError(null);
setComposerNotice(navigator.onLine
? "连接中断,回答仍在后台生成,正在自动恢复。"
: "网络已断开,回答仍在后台生成;联网后会自动恢复。");
}
return Boolean(partialReply);
} finally {
frames.dispose();
cancellationRequests.current.delete(requestId);
const pending = pendingConsultation.current;
if (pending?.requestId !== requestId || pending.phase !== "recovering") {
completeConsultationInterface(requestId);
}
if (stoppedRequestAwaitingSettlement.current === requestId) {
const persistence = stoppedSessionPersistence.current.get(requestId);
if (persistence) {
await persistence;
stoppedSessionPersistence.current.delete(requestId);
}
stoppedRequestAwaitingSettlement.current = null;
cancellationInFlight.current = false;
setCancellationPending(false);
}
}
}
function regenerateLatestAnswer(renderKey: string) {
const session = activeSession;
if (!session || isLoading || cancellationPending || pendingConsultation.current) return;
const last = session.messages.at(-1);
if (last?.role !== "assistant" || last.text.trim() === "") return;
const previous = session.messages.at(-2);
if (previous?.role !== "user" || previous.text.trim() === "") return;
if (`message-${session.messages.length - 1}` !== renderKey) return;
const sessionOverride: ChatSession = {
...session,
messages: session.messages.slice(0, -1),
updatedAt: timestamp(),
};
setMessageFeedback((current) => {
const next = { ...current };
delete next[`${session.id}:${renderKey}`];
return next;
});
void send(previous.text, session.theme, null, null, session.id, {
sessionOverride,
restoreOnFailure: session,
});
}
consultationReplay.current = () => {
const pending = pendingConsultation.current;
if (!pending || pending.cancelled || pending.phase !== "recovering" || !pending.question.trim()) return;
if (consultationReplayStarted.current === pending.requestId) return;
consultationReplayStarted.current = pending.requestId;
setComposerNotice("后台尚未开始本次咨询,正在重新发起…");
void send(
pending.question,
pending.theme,
pending.entrypoint,
null,
pending.sessionId,
{ resumeRequestId: pending.requestId },
).then((started) => {
if (started || pendingConsultation.current?.requestId !== pending.requestId) return;
pendingConsultation.current = null;
setPendingSessionId(null);
setPendingRequestId(null);
setConsultationPhase(null);
setStreamingReply(null);
setRequestError({
sessionId: pending.sessionId,
message: "后台未找到本次咨询请求,请重新发送。",
});
setComposerNotice("后台未找到本次咨询请求,已停止恢复,请重新发送。");
});
};
return {
restoreConsultationRecovery,
requestCancellation,
confirmCancellation,
stopResponse,
completeConsultationInterface,
send,
regenerateLatestAnswer,
};
}