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
1079 lines
42 KiB
TypeScript
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,
|
|
};
|
|
}
|