feat(rectification): start from an offer card in the consultation, come back to it (BUG-1247~1251)
TASK-rectification-in-chat-step1-20261006, direct execution.
- The homepage 生时校正 pill and the text trigger (isRectificationHandoffQuestion)
are gone. The consult agent decides (RECTIFICATION_OFFER_RULE, one
definition) and calls offer-birth-time-rectification; the server decides
whether a card may show (self, product on, no case in progress,
feature_pricing price). The offer is stored with the answer and travels on
run.completed; the card hangs under that answer.
- Opening from the card binds the offer to the case in the consultation's own
messages (no table change); GET/POST /api/rectification/cases/[caseId]/source
find the source and go back: one divider (time changed or not), earlier
answers captioned 按校正前的时间, and a 按新时间重新看 button that sends only
when clicked. Auto-return after the post-adopt check or a terminal case, only
for work done in this view.
- Model history skips the divider and notes answers written with the old time.
Full suite 4987 / fail 24, identical to 6ec8efd5 (4973 / 24), no test name
lost; tsc 0; lint 0 errors; build keeps / Static, first-load gzip +0.20%.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017eEAG8HD3mm8gsKXgk8uU8
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
328f919d5d
commit
6ad0bea320
@@ -14,20 +14,13 @@ import { AppLoadingIndicator } from "@/components/app-loading-indicator";
|
||||
import { HomeBootstrapError } from "@/components/home-bootstrap-error";
|
||||
import { HomeOnboardingShellSlot } from "@/components/home-onboarding-shell-slot";
|
||||
import { BirthSkyReveal } from "@/components/birth-sky/birth-sky-reveal";
|
||||
import {
|
||||
rectificationEntryLabels,
|
||||
resolveRectificationEntryAction,
|
||||
} from "@/lib/rectification-entry";
|
||||
import {
|
||||
composerLocksAsRectification,
|
||||
ordinaryComposerPlaceholder,
|
||||
rectificationOrdinaryComposerReopen,
|
||||
} from "@/lib/rectification-session-composer-guard";
|
||||
import { ConversationalBirthTimeRectification } from "@/components/conversational-birth-time-rectification";
|
||||
import {
|
||||
declaredBirthTime,
|
||||
RECTIFICATION_OPENING_LABEL,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
import { declaredBirthTime } from "@/lib/rectification-surface-state";
|
||||
import {
|
||||
toggleChatMessageFeedback,
|
||||
type ChatMessageFeedback,
|
||||
@@ -51,7 +44,6 @@ import { StarterEntries, StarterGreeting } from "@/components/starter-home";
|
||||
import {
|
||||
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
|
||||
isGeneralDailyFortuneQuestion,
|
||||
isRectificationHandoffQuestion,
|
||||
type ConsultationEntrypoint,
|
||||
} from "@/lib/consultation-entrypoint";
|
||||
import { assistantIntentCopy } from "@/lib/birth-time-intake-model";
|
||||
@@ -294,6 +286,7 @@ export default function Home() {
|
||||
onCopy() {},
|
||||
onRegenerate() {},
|
||||
onFollowUp() {},
|
||||
onStartRectification() {},
|
||||
});
|
||||
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
|
||||
const sessionDetailInFlight = useRef(new Set<string>());
|
||||
@@ -379,17 +372,6 @@ export default function Home() {
|
||||
: activeReplyOutcome?.phase ?? "idle";
|
||||
const replyAnnouncement = chatReplyAnnouncement(replyPhase, activeReplyOutcome?.replyOrdinal ?? 0);
|
||||
const accountId = account?.user.id;
|
||||
const rectificationCardAction = resolveRectificationEntryAction(
|
||||
rectificationEntrySummary ?? {
|
||||
hasResumableCase: false,
|
||||
hasTerminalCaseWithTime: false,
|
||||
latestResumable: null,
|
||||
latestTerminal: null,
|
||||
},
|
||||
);
|
||||
const rectificationCardLabel = rectificationLoading
|
||||
? RECTIFICATION_OPENING_LABEL
|
||||
: rectificationEntryLabels[rectificationCardAction];
|
||||
const rectificationDeclaration = { declaredTime: declaredBirthTime(profile), birthDate: profile.date || null };
|
||||
const rectificationErrorMessage = rectificationError === "profile_incomplete"
|
||||
? "服务端未能读取完整出生资料,请重新确认并保存后再开始生时校正。"
|
||||
@@ -432,7 +414,7 @@ export default function Home() {
|
||||
|
||||
const {
|
||||
setRectificationHeaderSlot, rectificationPanel, resumeRectificationSession,
|
||||
openRectificationFromHomepage, openRectificationSession, reopenRectificationSession,
|
||||
openRectificationFromChat, openRectificationSession, reopenRectificationSession,
|
||||
} = useRectificationSurface({
|
||||
account, activeSessionIdRef, chartLibrary, creatingSession, modelCatalog,
|
||||
persistSession, profile, rectification, setRectification,
|
||||
@@ -753,7 +735,7 @@ export default function Home() {
|
||||
startNewChat,
|
||||
continueInNewChat,
|
||||
refreshAccount,
|
||||
openAccountDialog, openRectificationFromHomepage, openRectificationSession,
|
||||
openAccountDialog, openRectificationSession,
|
||||
onboardingPaywallShown, setOnboardingPaywallOpen,
|
||||
});
|
||||
const jumpToLatestVisible = !rectificationSurfaceOpen
|
||||
@@ -1035,6 +1017,9 @@ export default function Home() {
|
||||
onFollowUp(question) {
|
||||
void send(question, activeSession?.theme);
|
||||
},
|
||||
onStartRectification(sourceSessionId) {
|
||||
void openRectificationFromChat(sourceSessionId);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1320,11 +1305,6 @@ export default function Home() {
|
||||
dailyStarlanguageQuestion={dailyStarlanguageQuestion}
|
||||
productEntrypointsDisabled={productEntrypointsDisabled}
|
||||
startDailyStarlanguageConsultation={startDailyStarlanguageConsultation}
|
||||
rectificationCardLabel={rectificationCardLabel}
|
||||
rectificationLoading={rectificationLoading}
|
||||
rectificationMutationPending={rectificationMutationPending}
|
||||
openRectificationFromHomepage={openRectificationFromHomepage}
|
||||
rectificationEntrySummary={rectificationEntrySummary}
|
||||
/>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { inputBlockedCopy, moderate, MODERATION_OUTPUT_REPLACED_COPY } from "@/l
|
||||
import { recordModerationEvent } from "@/lib/moderation/log";
|
||||
import { consultationDomainSchema } from "@/lib/consultation-domain-registry";
|
||||
import { parseAgentReply } from "@/lib/agent-reply";
|
||||
import { revisitQuestionForOffer, sessionHasRectificationOffer } from "@/lib/rectification-in-chat";
|
||||
import { decideRectificationOffer } from "@/lib/rectification-in-chat-server";
|
||||
import { createConsultationReplyMetadata } from "@/lib/consultation-reply-metadata";
|
||||
import {
|
||||
ConsultationPlanValidationError,
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
natalUserTurnShape,
|
||||
natalConsultationThinkingPlan,
|
||||
type PublicThinkingSection,
|
||||
RECTIFICATION_OFFER_RULE,
|
||||
} from "@/lib/consultation-thinking-plan";
|
||||
import {
|
||||
AGENT_MAX_STEPS,
|
||||
@@ -66,6 +69,7 @@ import {
|
||||
consultationNatalPrepareStep,
|
||||
consultationWindowPrepareStep,
|
||||
createConsultationAgentContext,
|
||||
type RectificationOfferHook,
|
||||
createWindowConsultationAgentContext,
|
||||
precomputeWindowConsultation,
|
||||
windowPrecomputedPacketMessage,
|
||||
@@ -240,7 +244,7 @@ function generalNoMinuteInstruction(hasPublicDaily: boolean) {
|
||||
}
|
||||
|
||||
function declaredWindowInstruction() {
|
||||
return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。这只是粗看。";
|
||||
return "当前是声明出生窗口咨询,没有单一出生分钟。如需个人结构结论,必须调用 run-jyotish-window-consultation。探针时刻不是出生时间,不得把 00:00、时段中点、中午或任一探针写成出生分钟。只能根据稳定层作方向性回答;上升或宫位若在窗口内变化,列出可能星座,不得写成「你的上升是 X」。精确应期一律不可用。这只是粗看。" + RECTIFICATION_OFFER_RULE;
|
||||
}
|
||||
|
||||
function usesPublicDailyGeneralAgent(
|
||||
@@ -867,6 +871,8 @@ export async function POST(request: Request) {
|
||||
techniqueTruth,
|
||||
workflowReceipt,
|
||||
...(agentExecutionReceipt ? { agentExecutionReceipt } : {}),
|
||||
// BUG-1248: the offer card hangs under this answer, also after a reload.
|
||||
...(latencyState?.rectificationOffer ? { rectificationOffer: latencyState.rectificationOffer } : {}),
|
||||
};
|
||||
const actualUsage = await usagePayload(usage);
|
||||
const completion = await retryDetachedSettlement(async () => {
|
||||
@@ -950,6 +956,17 @@ export async function POST(request: Request) {
|
||||
// evidence of having done so.
|
||||
const state = createConsultationRuntimeState({ plannedSteps: AGENT_MAX_STEPS });
|
||||
latencyState = state;
|
||||
// C2/C3 (BUG-1248): the offer tool asks the server, never decides itself.
|
||||
const rectificationOffer: RectificationOfferHook = {
|
||||
decide: () => decideRectificationOffer({
|
||||
accounting,
|
||||
userId,
|
||||
subjectRole: prepared.subject?.role === "other" ? "other" : "self",
|
||||
modelId: selectedModel.id,
|
||||
}),
|
||||
alreadyOffered: sessionHasRectificationOffer(chatSession?.messages),
|
||||
revisitQuestion: revisitQuestionForOffer(chatSession?.messages, visibleQuestion, requestId),
|
||||
};
|
||||
const hooks = createConsultationRuntimeHooks(state);
|
||||
const usages: Promise<Usage>[] = [];
|
||||
const agentStartedAt = Date.now();
|
||||
@@ -1272,6 +1289,7 @@ export async function POST(request: Request) {
|
||||
declaredWindow: prepared.declaredWindow,
|
||||
abortSignal: agentAbortSignal,
|
||||
state,
|
||||
rectificationOffer,
|
||||
});
|
||||
const agent = getWindowJyotishAgent(selectedModel, agentContext);
|
||||
const retry = async () => {
|
||||
@@ -1409,6 +1427,7 @@ export async function POST(request: Request) {
|
||||
serverChart: prepared.serverChart,
|
||||
abortSignal: agentAbortSignal,
|
||||
state,
|
||||
rectificationOffer,
|
||||
});
|
||||
const agent = getJyotishAgent(selectedModel, agentContext);
|
||||
const result = await streamWithOverflowRetry(agent, natalStreamOptions);
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { jsonForSupabaseSetupFailure } from "@/lib/api/service-unavailable";
|
||||
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { findRectificationSource, returnToRectificationSource } from "@/lib/rectification-in-chat-server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
type RouteContext = { params: Promise<{ caseId: string }> };
|
||||
|
||||
/**
|
||||
* GET /api/rectification/cases/[caseId]/source — the consultation this case
|
||||
* was started from, or { source: null } (homepage / history cases).
|
||||
* POST /api/rectification/cases/[caseId]/source — go back: appends the return
|
||||
* divider to that consultation once and answers where to go.
|
||||
* (TASK-rectification-in-chat-step1-20261006 C4/C5, BUG-1249/1250)
|
||||
*/
|
||||
async function clients(label: string) {
|
||||
try {
|
||||
return { supabase: await createServerSupabaseClient(), accounting: createAdminSupabaseClient() };
|
||||
} catch (error) {
|
||||
return { failure: jsonForSupabaseSetupFailure(error, label) };
|
||||
}
|
||||
}
|
||||
|
||||
async function caller(supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>) {
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
return error || !user ? null : user;
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
const opened = await clients("GET /api/rectification/cases/[caseId]/source");
|
||||
if ("failure" in opened) return opened.failure;
|
||||
const user = await caller(opened.supabase);
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const { caseId } = await context.params;
|
||||
if (!z.string().uuid().safeParse(caseId).success) {
|
||||
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
|
||||
}
|
||||
const source = await findRectificationSource(opened.supabase, user.id, caseId).catch(() => null);
|
||||
return NextResponse.json({
|
||||
source: source ? { sessionId: source.sessionId, question: source.question, returned: Boolean(source.returned) } : null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(_request: Request, context: RouteContext) {
|
||||
const opened = await clients("POST /api/rectification/cases/[caseId]/source");
|
||||
if ("failure" in opened) return opened.failure;
|
||||
const user = await caller(opened.supabase);
|
||||
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const { caseId } = await context.params;
|
||||
if (!z.string().uuid().safeParse(caseId).success) {
|
||||
return NextResponse.json({ error: "请求内容不正确", code: "invalid_case_id" }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
const result = await returnToRectificationSource({
|
||||
client: opened.supabase,
|
||||
accounting: opened.accounting,
|
||||
userId: user.id,
|
||||
caseId,
|
||||
});
|
||||
if (!result) {
|
||||
return NextResponse.json({ error: "找不到这次校正对应的对话", code: "source_not_found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ sessionId: result.sessionId, record: result.record });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "暂时没能回到刚才的对话,请稍后再试", code: "return_failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { parseOpenRectificationCaseRequest } from "@/lib/rectification-agentic/v9/open-request";
|
||||
import { prepareCaseSegmentChecks } from "@/lib/rectification-agentic/v9/segment-checks";
|
||||
import { resolveRectificationProductDomain } from "@/lib/rectification-agentic/v9/product-domain";
|
||||
import { bindRectificationSource } from "@/lib/rectification-in-chat-server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
@@ -78,6 +79,18 @@ export async function POST(request: Request) {
|
||||
if (response.shouldStartOpening) {
|
||||
await prepareCaseSegmentChecks({ accounting: accounting as never, userId: user.id, caseId: response.caseId });
|
||||
}
|
||||
// C4 (BUG-1249): a case started from a consultation's offer card is bound
|
||||
// to that card, so the case can go back there. Best effort: the case is
|
||||
// already open, and a failed bind only loses the way back.
|
||||
if (parsed.intent === "homepage" && parsed.sourceSessionId) {
|
||||
await bindRectificationSource({
|
||||
client: supabase,
|
||||
userId: user.id,
|
||||
sourceSessionId: parsed.sourceSessionId,
|
||||
caseId: response.caseId,
|
||||
rectificationSessionId: response.sessionId,
|
||||
}).catch(() => false);
|
||||
}
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
return serviceErrorResponse(error);
|
||||
|
||||
@@ -6463,3 +6463,52 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
|
||||
.settings-modal { height: 100dvh; max-height: 100dvh; }
|
||||
}
|
||||
}
|
||||
|
||||
/* Rectification reached from a consultation (BUG-1248/1250). The offer card
|
||||
sits under the answer that offered it, at the same inset as the follow-ups;
|
||||
the return divider is a quiet centred line, not a message; answers written
|
||||
before the birth time changed get one caption above them. */
|
||||
.rectification-offer-card {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
justify-items: start;
|
||||
margin: var(--space-2) 0 var(--space-3) var(--assistant-content-inset);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-canvas-muted);
|
||||
}
|
||||
.rectification-offer-card p {
|
||||
margin: 0;
|
||||
color: var(--color-ink-secondary);
|
||||
font-size: var(--type-body-sm);
|
||||
line-height: 1.6;
|
||||
}
|
||||
/* The divider's entry carries no actions row, so it needs no trailing gap of its own. */
|
||||
.message-entry--divider {
|
||||
padding: 0;
|
||||
}
|
||||
.rectification-return-divider {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
justify-items: center;
|
||||
margin: var(--space-4) 0;
|
||||
text-align: center;
|
||||
}
|
||||
.rectification-return-divider__line {
|
||||
margin: 0;
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.message-before-change {
|
||||
margin: 0 0 var(--space-1) var(--assistant-content-inset);
|
||||
color: var(--color-ink-tertiary);
|
||||
font-size: var(--type-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rectification-return-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
@@ -28,12 +28,20 @@ import {
|
||||
noteUnsplitListRender,
|
||||
} from "@/lib/home-streaming-render-probe";
|
||||
import { memo, useEffect, useState, type MutableRefObject } from "react";
|
||||
import {
|
||||
BeforeBirthTimeChangeLabel,
|
||||
RectificationOfferCard,
|
||||
RectificationReturnDivider,
|
||||
} from "@/components/rectification-in-chat-cards";
|
||||
import { lastBirthTimeChangeIndex } from "@/lib/rectification-in-chat";
|
||||
|
||||
export type ChatTranscriptActions = Readonly<{
|
||||
onFeedback: (feedbackKey: string, requested: ChatMessageFeedback) => void;
|
||||
onCopy: (feedbackKey: string, text: string) => void;
|
||||
onRegenerate: (renderKey: string) => void;
|
||||
onFollowUp: (question: string) => void;
|
||||
/** The offer card under an answer starts rectification for this consultation (BUG-1248). */
|
||||
onStartRectification: (sourceSessionId: string) => void;
|
||||
}>;
|
||||
|
||||
export type ChatTranscriptProps = Readonly<{
|
||||
@@ -89,15 +97,18 @@ function MessageEntry({
|
||||
productEntrypointsDisabled,
|
||||
actionsRef,
|
||||
}: MessageEntryProps) {
|
||||
// The return divider (BUG-1250) is a marker, not an answer: no row, no actions.
|
||||
const returnRecord = message.rectificationReturn;
|
||||
const showActions = message.role === "assistant"
|
||||
&& message.state === "settled"
|
||||
&& Boolean(message.text);
|
||||
&& Boolean(message.text)
|
||||
&& !returnRecord;
|
||||
const feedbackKey = `${sessionId}:${message.renderKey}`;
|
||||
// 不满意原因 opens right after a 👎 in this view; stored 👎 ratings do not reopen it.
|
||||
const [reasonOpen, setReasonOpen] = useState(false);
|
||||
const latestRegeneratableKey = !loading && !cancellationPending
|
||||
? [...views].reverse().find((item) => (
|
||||
item.role === "assistant" && item.state === "settled" && Boolean(item.text)
|
||||
item.role === "assistant" && item.state === "settled" && Boolean(item.text) && !item.rectificationReturn
|
||||
))?.renderKey
|
||||
: undefined;
|
||||
const previousQuestion = views[index - 1]?.role === "user" ? views[index - 1]?.text : "";
|
||||
@@ -112,8 +123,23 @@ function MessageEntry({
|
||||
entrypoint: isGeneralDailyFortuneQuestion(previousQuestion) ? "daily_starlanguage" : null,
|
||||
})
|
||||
: [];
|
||||
if (returnRecord) {
|
||||
return (
|
||||
<div className="message-entry message-entry--divider">
|
||||
<RectificationReturnDivider
|
||||
record={returnRecord}
|
||||
disabled={productEntrypointsDisabled || loading || cancellationPending}
|
||||
onRevisit={(question) => actionsRef.current.onFollowUp(question)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// P2: answers written before the birth time changed keep their text and get one line above.
|
||||
const beforeBirthTimeChange = message.role === "assistant" && lastBirthTimeChangeIndex(views) > index;
|
||||
const offer = sessionType === "consultation" && message.state === "settled" ? message.rectificationOffer : undefined;
|
||||
return (
|
||||
<div className="message-entry">
|
||||
{beforeBirthTimeChange ? <BeforeBirthTimeChangeLabel /> : null}
|
||||
<ChatMessageRow message={message} />
|
||||
{showActions && (
|
||||
<ChatMessageActions
|
||||
@@ -134,6 +160,13 @@ function MessageEntry({
|
||||
{showActions && reasonOpen && (
|
||||
<ReplyDownReason feedbackKey={feedbackKey} onClose={() => setReasonOpen(false)} />
|
||||
)}
|
||||
{offer ? (
|
||||
<RectificationOfferCard
|
||||
offer={offer}
|
||||
disabled={productEntrypointsDisabled || loading || cancellationPending}
|
||||
onStart={() => actionsRef.current.onStartRectification(sessionId)}
|
||||
/>
|
||||
) : null}
|
||||
<ConversationFollowUps
|
||||
questions={followUps}
|
||||
disabled={productEntrypointsDisabled}
|
||||
|
||||
@@ -40,7 +40,8 @@ export type RectificationChatPanel = Readonly<{
|
||||
initialTurns: readonly PersistedRectificationTurn[];
|
||||
/** The Case snapshot read together with the turns before the surface mounted; null when hydration failed. */
|
||||
initialSnapshot: RectificationCaseSnapshotPayload | null;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
canReturnToSource?: boolean;
|
||||
onReturnToSource?: () => void;
|
||||
headerSlot: HTMLElement | null;
|
||||
onMessagesChange?: (messages: ChatMessage[]) => void;
|
||||
onCompleted?: () => void;
|
||||
|
||||
@@ -66,6 +66,9 @@ import {
|
||||
repairRectificationQuestion,
|
||||
} from "@/lib/rectification-chat-question-repair";
|
||||
import { deriveRectificationChatView } from "@/lib/rectification-chat-view";
|
||||
import { useRectificationAutoReturn } from "@/hooks/use-rectification-return";
|
||||
import { RECTIFICATION_RETURN_BUTTON_LABEL } from "@/lib/rectification-in-chat";
|
||||
import { isTerminalRectificationStatus } from "@/lib/rectification-entry";
|
||||
|
||||
/** Same ceiling as the main chat composer, so both surfaces count down the same way. */
|
||||
const RECTIFICATION_COMPOSER_MAX_LENGTH = 500;
|
||||
@@ -92,7 +95,9 @@ type RectificationAgenticChatProps = Readonly<{
|
||||
onOpenBilling?: (options?: { source?: string }) => void;
|
||||
onSaved?: (time: string, status: "accepted" | "confirmed") => void;
|
||||
onOpeningConsumed?: () => void;
|
||||
pendingConsultationQuestion?: string | null;
|
||||
/** This case was started from a consultation that it can go back to (BUG-1250). */
|
||||
canReturnToSource?: boolean;
|
||||
onReturnToSource?: () => void;
|
||||
onRestart?: () => void;
|
||||
headerSlot: HTMLElement | null;
|
||||
}>;
|
||||
@@ -124,7 +129,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
onOpenBilling,
|
||||
onSaved,
|
||||
onOpeningConsumed,
|
||||
pendingConsultationQuestion,
|
||||
canReturnToSource = false,
|
||||
onReturnToSource,
|
||||
onRestart,
|
||||
headerSlot,
|
||||
} = props;
|
||||
@@ -437,6 +443,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
questionRetryAttempts,
|
||||
savedTime,
|
||||
});
|
||||
useRectificationAutoReturn({
|
||||
enabled: canReturnToSource,
|
||||
ready: questionGap === "verified_idle" || isTerminalRectificationStatus(caseStatus ?? ""),
|
||||
busy,
|
||||
onReturn: onReturnToSource,
|
||||
});
|
||||
// Same render-time store update as seededTurns: the lock must be current
|
||||
// before this paint (BUG-620). A ref write here trips react-hooks/refs.
|
||||
if (nextOfferLock !== selectionOfferLock) {
|
||||
@@ -552,11 +564,6 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
{resultNotice && canRecompare && !historyReadonly && (
|
||||
<Button type="button" variant="outline" disabled={busy} onClick={() => void recompareHistoricalResult({ canRecompare, historyReadonly, busy, send })}>重新比较</Button>
|
||||
)}
|
||||
{pendingConsultationQuestion?.trim() && (
|
||||
<p className="rectification-pending-note">
|
||||
先陪你核对出生时间范围;结束后新建对话,按采用的时间再问:“{pendingConsultationQuestion.trim()}”
|
||||
</p>
|
||||
)}
|
||||
{conversationState === "empty" && (
|
||||
<div className="rectification-empty-state">
|
||||
<p>{RECTIFICATION_EMPTY_COPY}</p>
|
||||
@@ -645,6 +652,13 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
{conversationAnchor.latestBelowFold && (
|
||||
<JumpToLatestButton onClick={conversationAnchor.anchorToLatest} />
|
||||
)}
|
||||
{canReturnToSource && (
|
||||
<div className="rectification-return-row">
|
||||
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={onReturnToSource}>
|
||||
{RECTIFICATION_RETURN_BUTTON_LABEL}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ChatComposer
|
||||
inputRef={composer}
|
||||
value={draft}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
RECTIFICATION_BEFORE_CHANGE_LABEL,
|
||||
RECTIFICATION_OFFER_CONTINUE_LABEL,
|
||||
RECTIFICATION_OFFER_OPENED_COPY,
|
||||
RECTIFICATION_OFFER_START_LABEL,
|
||||
rectificationOfferCardCopy,
|
||||
rectificationReturnDividerText,
|
||||
rectificationRevisitLabel,
|
||||
type RectificationOfferRecord,
|
||||
type RectificationReturnRecord,
|
||||
} from "@/lib/rectification-in-chat";
|
||||
|
||||
/**
|
||||
* The card under a consultation answer that offered birth-time rectification
|
||||
* (BUG-1248). The server decided it may be shown and wrote the price; the
|
||||
* click opens the case (the charge comes with the first answered question).
|
||||
* A card whose case is open says so and offers to go back in; a card whose
|
||||
* case has returned is gone (the divider below says what happened).
|
||||
*/
|
||||
export function RectificationOfferCard({
|
||||
offer,
|
||||
disabled,
|
||||
onStart,
|
||||
}: Readonly<{ offer: RectificationOfferRecord; disabled: boolean; onStart: () => void }>) {
|
||||
if (offer.status === "returned") return null;
|
||||
const opened = offer.status === "opened";
|
||||
return (
|
||||
<div className="rectification-offer-card" role="group" aria-label="核对出生时间">
|
||||
<p>{opened ? RECTIFICATION_OFFER_OPENED_COPY : rectificationOfferCardCopy(offer)}</p>
|
||||
<Button type="button" variant={opened ? "outline" : "default"} size="sm" disabled={disabled} onClick={onStart}>
|
||||
{opened ? RECTIFICATION_OFFER_CONTINUE_LABEL : RECTIFICATION_OFFER_START_LABEL}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the reader came back from rectification (BUG-1250). Re-asking with the
|
||||
* new time is a button and an ordinary consultation turn: nothing is sent, and
|
||||
* nothing is charged, until it is clicked.
|
||||
*/
|
||||
export function RectificationReturnDivider({
|
||||
record,
|
||||
disabled,
|
||||
onRevisit,
|
||||
}: Readonly<{ record: RectificationReturnRecord; disabled: boolean; onRevisit: (question: string) => void }>) {
|
||||
return (
|
||||
<div className="rectification-return-divider" role="note">
|
||||
<p className="rectification-return-divider__line">{rectificationReturnDividerText(record)}</p>
|
||||
{record.adopted ? (
|
||||
<Button type="button" variant="outline" size="sm" disabled={disabled} onClick={() => onRevisit(record.sourceQuestion)}>
|
||||
{rectificationRevisitLabel(record.sourceQuestion)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The small line above an answer written with the birth time before the change (P2). */
|
||||
export function BeforeBirthTimeChangeLabel() {
|
||||
return <p className="message-before-change">{RECTIFICATION_BEFORE_CHANGE_LABEL}</p>;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Clock3, MoonStar } from "lucide-react";
|
||||
import { useCurrentSubject } from "@/lib/current-subject";
|
||||
import type { RectificationEntrySummary } from "@/lib/rectification-entry";
|
||||
import { MoonStar } from "lucide-react";
|
||||
|
||||
/**
|
||||
* The empty state is split across two slots on purpose.
|
||||
@@ -46,26 +44,14 @@ export function StarterGreeting({ starterGreeting, dailyTrend = null, dailyTrend
|
||||
);
|
||||
}
|
||||
|
||||
/* The line under the pills only appears when there is something to do about
|
||||
it: a non-self subject cannot start a rectification. A resumable case says
|
||||
nothing here any more (2026-09-30, product: 「这个提示可以去掉」); the case
|
||||
is still in the history list. */
|
||||
export function rectificationEntryHint(rectificationForSelf: boolean): string | null {
|
||||
if (!rectificationForSelf) return "生时校正暂时只支持本人。";
|
||||
return null;
|
||||
}
|
||||
|
||||
/* The homepage has no 生时校正 pill any more (2026-10-07, BUG-1247): a
|
||||
rectification starts from a consultation, when the agent offers it under an
|
||||
answer. Old cases stay in the history list. */
|
||||
export type StarterEntriesProps = {
|
||||
readonly natalMinuteAvailable: boolean;
|
||||
readonly dailyStarlanguageQuestion: string;
|
||||
readonly productEntrypointsDisabled: boolean;
|
||||
readonly startDailyStarlanguageConsultation: () => void;
|
||||
readonly rectificationCardLabel: string;
|
||||
readonly rectificationLoading: boolean;
|
||||
readonly rectificationMutationPending: boolean;
|
||||
readonly openRectificationFromHomepage: () => void;
|
||||
/** Still passed by the page; the hint no longer reads it (2026-09-30). */
|
||||
readonly rectificationEntrySummary: RectificationEntrySummary | null;
|
||||
};
|
||||
|
||||
export function StarterEntries({
|
||||
@@ -73,15 +59,8 @@ export function StarterEntries({
|
||||
dailyStarlanguageQuestion,
|
||||
productEntrypointsDisabled,
|
||||
startDailyStarlanguageConsultation,
|
||||
rectificationCardLabel,
|
||||
rectificationLoading,
|
||||
rectificationMutationPending,
|
||||
openRectificationFromHomepage,
|
||||
}: StarterEntriesProps) {
|
||||
const subjectId = useCurrentSubject();
|
||||
const rectificationForSelf = subjectId === "self";
|
||||
const dailyLabel = natalMinuteAvailable ? "今日星语" : "每日运势";
|
||||
const rectificationHint = rectificationEntryHint(rectificationForSelf);
|
||||
|
||||
return (
|
||||
<div className="starter-entries">
|
||||
@@ -96,19 +75,7 @@ export function StarterEntries({
|
||||
<MoonStar aria-hidden="true" />
|
||||
<span>{dailyLabel}</span>
|
||||
</button>
|
||||
<button
|
||||
className="starter-entry"
|
||||
type="button"
|
||||
aria-label={rectificationCardLabel}
|
||||
data-opening={rectificationLoading ? "true" : undefined}
|
||||
disabled={productEntrypointsDisabled || rectificationLoading || rectificationMutationPending || !rectificationForSelf}
|
||||
onClick={() => void openRectificationFromHomepage()}
|
||||
>
|
||||
<Clock3 aria-hidden="true" />
|
||||
<span>生时校正</span>
|
||||
</button>
|
||||
</div>
|
||||
{rectificationHint && <p className="starter-entry-hint">{rectificationHint}</p>}
|
||||
{!natalMinuteAvailable && (
|
||||
<p className="starter-entry-boundary">出生时间不足以支持的判断我会说明,不会补造具体分钟。</p>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { ConsultationBirthTimeMode } from "@/lib/consultation-birth-time-mo
|
||||
import {
|
||||
GENERAL_NO_MINUTE_DAILY_FORTUNE_QUESTION,
|
||||
isGeneralDailyFortuneQuestion,
|
||||
isRectificationHandoffQuestion,
|
||||
type ConsultationEntrypoint,
|
||||
} from "@/lib/consultation-entrypoint";
|
||||
import {
|
||||
@@ -72,6 +71,7 @@ import {
|
||||
SESSION_NOT_CONSULTATION_CODE,
|
||||
} from "@/lib/rectification-session-composer-guard";
|
||||
import { isUnsavedEmptyConsultation } from "@/lib/session-list-filter";
|
||||
import type { RectificationOfferRecord } from "@/lib/rectification-in-chat";
|
||||
import type {
|
||||
Account,
|
||||
AccountDialog,
|
||||
@@ -154,7 +154,6 @@ export type ConsultationRunParams = {
|
||||
/** Set once the redeem paywall has been shown on this page (TASK-birth-sky-polish-20260928 P4). */
|
||||
onboardingPaywallShown?: MutableRefObject<boolean>;
|
||||
setOnboardingPaywallOpen?: (open: boolean) => void;
|
||||
openRectificationFromHomepage: (pendingConsultationQuestion?: string | null, sourceSessionId?: string | null) => Promise<void>;
|
||||
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
@@ -215,7 +214,6 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
openAccountDialog,
|
||||
onboardingPaywallShown,
|
||||
setOnboardingPaywallOpen,
|
||||
openRectificationFromHomepage,
|
||||
openRectificationSession,
|
||||
} = params;
|
||||
// First refusal for credits on this page → redeem paywall; later ones → billing (P4, BUG-1080).
|
||||
@@ -523,12 +521,12 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
): Promise<boolean> {
|
||||
const originalQuestion = text;
|
||||
const question = text.trim();
|
||||
// No text decides rectification any more (BUG-1247, C1): the agent offers
|
||||
// it under an answer and the user starts it from that card.
|
||||
const consultEntrypoint = entrypoint
|
||||
?? (isRectificationHandoffQuestion(question)
|
||||
? "birth_time_rectification" as const
|
||||
: isGeneralDailyFortuneQuestion(question)
|
||||
? "daily_starlanguage" as const
|
||||
: null);
|
||||
?? (isGeneralDailyFortuneQuestion(question)
|
||||
? "daily_starlanguage" as const
|
||||
: null);
|
||||
const resumeRequestId = options.resumeRequestId;
|
||||
const resuming = Boolean(resumeRequestId);
|
||||
if (!resuming && subjectDeleted) {
|
||||
@@ -588,33 +586,6 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
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;
|
||||
if (isUnsavedEmptyConsultation(currentSession)) {
|
||||
try {
|
||||
await persistSession(currentSession, "create");
|
||||
} catch (caught) {
|
||||
setRequestError({
|
||||
sessionId: currentSession.id,
|
||||
message: caught instanceof Error ? caught.message : "来源对话未能保存到云端。",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
await openRectificationFromHomepage(pendingQuestion, currentSession.id);
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
return false;
|
||||
}
|
||||
|
||||
const birthPlace = selectedBirthPlace(profile);
|
||||
if (!birthPlace) return false;
|
||||
|
||||
@@ -902,6 +873,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
.filter((item) => item && item !== "none"),
|
||||
};
|
||||
let agentExecutionReceipt: AgentExecutionReceipt | undefined;
|
||||
let rectificationOffer: RectificationOfferRecord | undefined;
|
||||
let runCompleted = false;
|
||||
let truncatedFailure: Extract<ConsultationAgentPublicEvent, { type: "run.failed" }> | undefined;
|
||||
const reader = response.body.getReader();
|
||||
@@ -980,6 +952,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
}
|
||||
if (event.type === "run.completed") {
|
||||
runCompleted = true;
|
||||
rectificationOffer = event.rectificationOffer;
|
||||
if (event.responseKind === "smalltalk") {
|
||||
responseKind = "smalltalk";
|
||||
} else if (event.receipt) {
|
||||
@@ -1087,6 +1060,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
techniqueTruth,
|
||||
workflowReceipt,
|
||||
agentExecutionReceipt,
|
||||
...(rectificationOffer ? { rectificationOffer } : {}),
|
||||
}),
|
||||
}],
|
||||
updatedAt: timestamp(),
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/** How long the last line stays on screen before going back (BUG-1250). */
|
||||
export const RECTIFICATION_AUTO_RETURN_DELAY_MS = 2_500;
|
||||
|
||||
/**
|
||||
* C5 (TASK-rectification-in-chat-step1-20261006, BUG-1250): a case started
|
||||
* from a consultation goes back there by itself once it is done — the
|
||||
* post-adopt check finished, or the case ended. Only a case the reader worked
|
||||
* on in this view returns by itself: opening a finished case from history
|
||||
* keeps it on screen (the reader came to look at it), and the manual
|
||||
* 「回到刚才的对话」 button is always there while a source exists.
|
||||
*/
|
||||
export function useRectificationAutoReturn(input: {
|
||||
enabled: boolean;
|
||||
ready: boolean;
|
||||
busy: boolean;
|
||||
onReturn?: () => void;
|
||||
}) {
|
||||
const actedInView = useRef(false);
|
||||
const fired = useRef(false);
|
||||
const onReturn = useRef(input.onReturn);
|
||||
useEffect(() => {
|
||||
onReturn.current = input.onReturn;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (input.busy) actedInView.current = true;
|
||||
}, [input.busy]);
|
||||
useEffect(() => {
|
||||
if (!input.enabled || !input.ready || input.busy || !actedInView.current || fired.current) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
fired.current = true;
|
||||
onReturn.current?.();
|
||||
}, RECTIFICATION_AUTO_RETURN_DELAY_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [input.enabled, input.ready, input.busy]);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
|
||||
|
||||
import { resolveSessionTitle } from "@/lib/agent-reply";
|
||||
import { fetchSessionDetail } from "@/lib/home-cloud-sync";
|
||||
import { fetchSessionDetail, mergeHydratedSession } from "@/lib/home-cloud-sync";
|
||||
import { rectificationOpenIdentity } from "@/lib/rectification-session-open";
|
||||
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
|
||||
import { writeSessionUrl } from "@/lib/chat-session-url";
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type RectificationCaseSnapshotPayload,
|
||||
} from "@/lib/rectification-surface-state";
|
||||
import { notifyError } from "@/lib/notify";
|
||||
import { bindOfferToCase } from "@/lib/rectification-in-chat";
|
||||
|
||||
export type RectificationShellState = {
|
||||
sessionId: string | null;
|
||||
@@ -167,7 +168,9 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
const setRectificationErrorSessionId = fieldSetter(setRectification, "errorSessionId");
|
||||
|
||||
const [rectificationHeaderSlot, setRectificationHeaderSlot] = useState<HTMLElement | null>(null);
|
||||
const [rectificationPendingQuestion, setRectificationPendingQuestion] = useState<string | null>(null);
|
||||
// The consultation this case was started from (BUG-1249/1250); null for
|
||||
// cases started from history or before 2026-10-07.
|
||||
const [rectificationSource, setRectificationSource] = useState<{ caseId: string; sessionId: string } | null>(null);
|
||||
const [rectificationReadonly, setRectificationReadonly] = useState(false);
|
||||
const [rectificationShouldStartOpening, setRectificationShouldStartOpening] = useState(false);
|
||||
const [rectificationTurns, setRectificationTurns] = useState<PersistedRectificationTurn[]>([]);
|
||||
@@ -204,7 +207,6 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
async function openRectificationCase(
|
||||
intent: "homepage" | "session" | "new",
|
||||
exactSessionId: string | null,
|
||||
pendingConsultationQuestion: string | null,
|
||||
sourceSessionId: string | null = null,
|
||||
) {
|
||||
if (!account || !modelCatalog || creatingSession || rectificationLoading || rectificationOpenInFlight.current
|
||||
@@ -217,7 +219,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
sessionSelectionSource.current = "user";
|
||||
setRectificationSessionId(null);
|
||||
setRectificationCaseId(null);
|
||||
setRectificationPendingQuestion(null);
|
||||
setRectificationSource(null);
|
||||
setOnboardingStep(missingStep);
|
||||
setComposerNotice("请先完成出生资料,再开始生时校正。");
|
||||
return null;
|
||||
@@ -312,11 +314,22 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
// the reader never sees an empty transcript or an empty question area
|
||||
// while they load. Past the deadline the surface opens with what arrived
|
||||
// and its own retry path fills in the rest.
|
||||
// The source lookup (BUG-1249) runs alongside the hydration, not after it.
|
||||
const sourceRead = readRectificationSource(opened.caseId);
|
||||
const hydration = await hydrateRectificationCase(opened.caseId, opened.sessionId, {
|
||||
timeoutMs: RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS,
|
||||
});
|
||||
const source = await sourceRead;
|
||||
if (sourceSessionId) {
|
||||
// The server bound the offer card to this case; mirror it so the card
|
||||
// under that answer stops offering a new start.
|
||||
updateSession(sourceSessionId, (session) => {
|
||||
const bound = bindOfferToCase(session.messages, { caseId: opened.caseId, rectificationSessionId: opened.sessionId });
|
||||
return bound ? { ...session, messages: bound as Message[] } : session;
|
||||
});
|
||||
}
|
||||
|
||||
setRectificationPendingQuestion(pendingConsultationQuestion);
|
||||
setRectificationSource(source ?? (sourceSessionId ? { caseId: opened.caseId, sessionId: sourceSessionId } : null));
|
||||
setDraft("");
|
||||
setDraftTheme(null);
|
||||
setDraftEntrypoint(null);
|
||||
@@ -354,18 +367,55 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openRectificationFromHomepage(
|
||||
pendingConsultationQuestion: string | null = null,
|
||||
sourceSessionId: string | null = null,
|
||||
) {
|
||||
/** C4: the offer card under a consultation answer starts (or resumes) the case. */
|
||||
async function openRectificationFromChat(sourceSessionId: string) {
|
||||
if (rectificationLimitedToSelf()) return;
|
||||
await openRectificationCase("homepage", null, pendingConsultationQuestion, sourceSessionId);
|
||||
await openRectificationCase("homepage", null, sourceSessionId);
|
||||
}
|
||||
|
||||
async function openRectificationSession(exactSessionId: string) {
|
||||
await openRectificationCase("session", exactSessionId, null);
|
||||
}
|
||||
|
||||
async function readRectificationSource(caseId: string): Promise<{ caseId: string; sessionId: string } | null> {
|
||||
try {
|
||||
const response = await fetch(`/api/rectification/cases/${caseId}/source`, { cache: "no-store" });
|
||||
if (!response.ok) return null;
|
||||
const payload = await response.json().catch(() => null) as { source?: { sessionId?: unknown } | null } | null;
|
||||
const sessionId = payload?.source?.sessionId;
|
||||
return typeof sessionId === "string" && sessionId ? { caseId, sessionId } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* C5/C6 (BUG-1250): the server appends the return divider to the source
|
||||
* consultation once; then that consultation is reloaded from the server and
|
||||
* made active, which closes the rectification surface.
|
||||
*/
|
||||
async function returnToRectificationSource() {
|
||||
const source = rectificationSource;
|
||||
if (!source || !modelCatalog) return;
|
||||
try {
|
||||
const response = await fetch(`/api/rectification/cases/${source.caseId}/source`, { method: "POST" });
|
||||
const payload = await response.json().catch(() => null) as { sessionId?: unknown; error?: unknown } | null;
|
||||
if (!response.ok || typeof payload?.sessionId !== "string") {
|
||||
notifyError(typeof payload?.error === "string" ? payload.error : "暂时没能回到刚才的对话,请稍后再试");
|
||||
return;
|
||||
}
|
||||
const sessionId = payload.sessionId;
|
||||
const detailed = await fetchSessionDetail(sessionId, modelCatalog).catch(() => null);
|
||||
if (detailed) setSessions((current) => mergeHydratedSession(current, detailed));
|
||||
void refreshAccount();
|
||||
activeSessionIdRef.current = sessionId;
|
||||
setActiveSessionId(sessionId);
|
||||
if (!uiPreview.current) writeSessionUrl(sessionId, "push");
|
||||
} catch {
|
||||
notifyError("暂时没能回到刚才的对话,请稍后再试");
|
||||
}
|
||||
}
|
||||
|
||||
async function reopenRectificationSession(exactSessionId: string) {
|
||||
setRectificationError("");
|
||||
setRectificationErrorSessionId(null);
|
||||
@@ -381,7 +431,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
setRectificationError("profile_incomplete");
|
||||
setRectificationSessionId(null);
|
||||
setRectificationCaseId(null);
|
||||
setRectificationPendingQuestion(null);
|
||||
setRectificationSource(null);
|
||||
const missingStep = missingProfileStep(profile);
|
||||
if (missingStep) {
|
||||
setOnboardingStep(missingStep);
|
||||
@@ -418,7 +468,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
shouldStartOpening: rectificationShouldStartOpening,
|
||||
initialTurns: rectificationTurns,
|
||||
initialSnapshot: rectificationSnapshot,
|
||||
pendingConsultationQuestion: rectificationPendingQuestion,
|
||||
canReturnToSource: Boolean(rectificationSource && rectificationSource.caseId === rectificationCaseId),
|
||||
onReturnToSource: () => void returnToRectificationSource(),
|
||||
headerSlot: rectificationHeaderSlot,
|
||||
onMessagesChange: handleRectificationMessagesChange,
|
||||
onCompleted: () => {
|
||||
@@ -441,7 +492,7 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
refreshRectificationEntrySummary,
|
||||
refreshRectificationCase,
|
||||
openRectificationCase,
|
||||
openRectificationFromHomepage,
|
||||
openRectificationFromChat,
|
||||
openRectificationSession,
|
||||
reopenRectificationSession,
|
||||
startNewRectification,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { consultationDomainDefinition } from "./consultation-domain-registry.ts"
|
||||
import type { ConsultationEntrypoint } from "./consultation-entrypoint.ts";
|
||||
import {
|
||||
isGeneralDailyFortuneQuestion,
|
||||
isRectificationHandoffQuestion,
|
||||
} from "./consultation-entrypoint.ts";
|
||||
import { shanghaiDateParts } from "./session-shanghai-clock.ts";
|
||||
|
||||
@@ -100,7 +99,7 @@ export function resolveSessionTitle(
|
||||
if (options.entrypoint === "daily_starlanguage" || isGeneralDailyFortuneQuestion(question)) {
|
||||
return datedSessionTitle(at, "今日节奏");
|
||||
}
|
||||
if (options.entrypoint === "birth_time_rectification" || isRectificationHandoffQuestion(question)) {
|
||||
if (options.entrypoint === "birth_time_rectification") {
|
||||
return "生时校正";
|
||||
}
|
||||
const normalized = question.replace(/\s+/g, " ").trim().replace(/[??!!。.,,;;::]+$/u, "");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentActivityTraceItem } from "./agent-activity-trace.ts";
|
||||
import type { AgentExecutionReceipt, PublicActivityPhase, WorkflowReceipt } from "./consultation-agent-events.ts";
|
||||
import type { PublicThinkingSection } from "./consultation-thinking-plan.ts";
|
||||
import type { RectificationOfferRecord, RectificationReturnRecord } from "./rectification-in-chat.ts";
|
||||
import {
|
||||
consultationTimelineFromSettled,
|
||||
type ConsultationTimelineRow,
|
||||
@@ -62,6 +63,10 @@ export type ChatMessage = {
|
||||
readonly techniqueTruth?: string;
|
||||
readonly agentExecutionReceipt?: AgentExecutionReceipt;
|
||||
readonly workflowReceipt?: WorkflowReceipt;
|
||||
/** This answer offered birth-time rectification; the card hangs under it (BUG-1248). */
|
||||
readonly rectificationOffer?: RectificationOfferRecord;
|
||||
/** A divider written when the user came back from rectification (BUG-1250); never a model turn. */
|
||||
readonly rectificationReturn?: RectificationReturnRecord;
|
||||
};
|
||||
|
||||
export type ChatMessageView = ChatMessage & {
|
||||
@@ -85,7 +90,7 @@ export function settledChatMessageViews(
|
||||
...message,
|
||||
renderKey: `message-${index}`,
|
||||
state: "settled" as const,
|
||||
...(message.role === "assistant" && message.responseKind !== "smalltalk"
|
||||
...(message.role === "assistant" && message.responseKind !== "smalltalk" && !message.rectificationReturn
|
||||
? { timeline: consultationTimelineFromSettled(message) }
|
||||
: {}),
|
||||
}));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { consultationDomainSchema, type ConsultationDomain } from "./consultation-domain-registry.ts";
|
||||
import { publicThinkingSectionSchema } from "./consultation-thinking-plan.ts";
|
||||
import { rectificationOfferRecordSchema } from "./rectification-in-chat.ts";
|
||||
|
||||
export const publicActivityPhaseSchema = z.enum([
|
||||
// The first byte of a consultation stream, before the turn is classified or
|
||||
@@ -142,6 +143,8 @@ const runCompletedSchema = z.object({
|
||||
type: z.literal("run.completed"),
|
||||
receipt: agentExecutionReceiptSchema.optional(),
|
||||
responseKind: z.literal("smalltalk").optional(),
|
||||
/** The answer offered birth-time rectification (BUG-1248); the card hangs under it. */
|
||||
rectificationOffer: rectificationOfferRecordSchema.optional(),
|
||||
}).strict();
|
||||
// A failure is the case the receipt is most needed for, so it carries the same
|
||||
// allowlisted receipt a completed run does. It stays optional because the
|
||||
|
||||
@@ -35,11 +35,6 @@ export function isGeneralDailyFortuneQuestion(question: string): boolean {
|
||||
return /^(?:请帮我看一下)?今天的运势/.test(text) || /^今日运势/.test(text);
|
||||
}
|
||||
|
||||
export function isRectificationHandoffQuestion(question: string): boolean {
|
||||
const text = compactQuestion(question);
|
||||
return /^(?:先完成)?(?:出生时间校正|生时校正|生时矫正)(?:\s*[((]生时(?:校正|矫正)[))])?$/.test(text);
|
||||
}
|
||||
|
||||
export function shouldLoadGeneralDailyPanchanga(input: Readonly<{
|
||||
consultationMode?: ConsultationQuestionInput["consultationMode"];
|
||||
entrypoint?: ConsultationEntrypoint;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BIRTH_TIME_CHANGED_HISTORY_NOTE, isRectificationReturnMessage, lastBirthTimeChangeIndex } from "./rectification-in-chat.ts";
|
||||
|
||||
export const CONSULTATION_HISTORY_LIMIT = 12;
|
||||
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 12_000;
|
||||
export const CONSULTATION_HISTORY_SYSTEM_RESERVE_TOKENS = 60_000;
|
||||
@@ -108,8 +110,12 @@ export function storedConsultationTurns(
|
||||
): StoredTurn[] {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
const rows: StoredTurn[] = [];
|
||||
// C7 (BUG-1251): answers written before the birth time changed are read
|
||||
// with a note, and the return divider itself is never a model turn.
|
||||
const changedAt = lastBirthTimeChangeIndex(messages);
|
||||
messages.forEach((message, index) => {
|
||||
if (!message || typeof message !== "object") return;
|
||||
if (isRectificationReturnMessage(message)) return;
|
||||
const stored = message as { role?: unknown; text?: unknown; requestId?: unknown; responseKind?: unknown };
|
||||
if (options.excludeRequestId && stored.requestId === options.excludeRequestId) return;
|
||||
if (stored.role !== "user" && stored.role !== "assistant") return;
|
||||
@@ -117,7 +123,9 @@ export function storedConsultationTurns(
|
||||
rows.push({
|
||||
index,
|
||||
role: stored.role,
|
||||
text: stored.text,
|
||||
text: stored.role === "assistant" && index < changedAt
|
||||
? `${BIRTH_TIME_CHANGED_HISTORY_NOTE}\n${stored.text}`
|
||||
: stored.text,
|
||||
requestId: typeof stored.requestId === "string" ? stored.requestId : null,
|
||||
...(stored.responseKind === "smalltalk" ? { responseKind: "smalltalk" as const } : {}),
|
||||
});
|
||||
@@ -170,7 +178,15 @@ export function consultationHistoryWindow(
|
||||
droppedCount += 1;
|
||||
}
|
||||
const summaryText = summary?.text.trim() || null;
|
||||
return { tail: kept, summaryText, droppedCount };
|
||||
// The summary only covers turns up to its index; when the birth time changed
|
||||
// after that, its conclusions came from the earlier time too (C7).
|
||||
const summaryBeforeChange = summary && Array.isArray(messages)
|
||||
&& summary.throughMessageIndex < lastBirthTimeChangeIndex(messages);
|
||||
return {
|
||||
tail: kept,
|
||||
summaryText: summaryText && summaryBeforeChange ? `${BIRTH_TIME_CHANGED_HISTORY_NOTE}\n${summaryText}` : summaryText,
|
||||
droppedCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function consultationHistoryFromStoredMessages(
|
||||
|
||||
@@ -444,6 +444,7 @@ export function natalAnswerShapeBody(): string {
|
||||
"正文写成段落,段落之间空一行(单个换行在界面上不分段)。只有用户要求列举,或确有三个以上候选时间窗时才用列表,且不用加粗标签加一句的格式。",
|
||||
"首轮正文不写 ##,不写「这周可以做」「盘上依据」「时间怎么看」这类节名,也不用加粗短语冒充小标题。",
|
||||
PLAIN_SPEECH_RULE,
|
||||
RECTIFICATION_OFFER_RULE,
|
||||
"把每句话括号里的内容删掉,不懂占星的人也要能看懂:不起「外X内Y」这类格局名,不把大运写成推着或收尾的角色,不写「去当某颗星的样子」,不把星名当形容词的主人;空宫不单独下结论,要说它的宫主落在哪。名词只用中文、全篇一个叫法(罗睺、计都;大运、子运、小运),不写英文或梵文音译(sade sati、vargottama、rupas 之类),力量只说强、中、弱。",
|
||||
"不要写「先回答你的问题」这个标题,不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
|
||||
].join("");
|
||||
@@ -453,6 +454,13 @@ export function natalAnswerShapeInstruction(): string {
|
||||
return `${ANSWER_FROM_EVIDENCE}${natalAnswerShapeBody()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* C2 (TASK-rectification-in-chat-step1-20261006, BUG-1248): when "推得不准"
|
||||
* may be the birth time, the agent offers rectification through the offer
|
||||
* tool; it is never decided from keywords. Defined here once.
|
||||
*/
|
||||
export const RECTIFICATION_OFFER_RULE = "出生时间核对:用户说你推得不准时,先分清是读法问题还是时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。用户说出过去某件具体的事和盘对不上、说自己的出生时间是估的或记不准、或明确想核对出生时间,才在写回答之前调用 offer-birth-time-rectification,再按工具结果在正文里用一句话提一下(不写价格和题数,卡片上有)。同一次对话提议过、用户没接,就不再主动提;用户自己再要求时调用并传 userAsked=true。工具说不能提供时(比如问的是家人),照工具说明用一句话说清楚。";
|
||||
|
||||
/** D4 / BUG-1166: the user corrects an earlier reading; acknowledge it, never defend the reading. */
|
||||
export const FOLLOW_UP_CORRECTION_RULE = "用户纠正了前面的判断(比如说父亲其实不常在身边):先认下这件事,再只说盘上真正对得上的那几条、原来哪句读偏了;盘本身分不出的,直说「盘本身分不出这一点」,不把普通配置说成必然;不为原判断辩护。";
|
||||
|
||||
@@ -470,6 +478,7 @@ export function followUpAnswerShapeInstruction(): string {
|
||||
"这是追问轮:本会话前面已经写过完整解读。第一句就回答用户这句话(是非题先答「是」「不是」或「看情况:……」,再给盘上的依据),讲清楚为准,不限字数;只引与这句话直接相关的盘面事实,够说明就停;用人话说,盘上依据放括号里;名词只用中文、不写英文或梵文音译;不写二级标题,不写行动清单(用户问了才给);不重述上一轮已经写过的结构;读心按人设里的读心三条:答完再说、用「可能」「多半」、一句话,不把用户的问题改写成需求,不替用户总结他想要什么。",
|
||||
PLAIN_SPEECH_RULE,
|
||||
FOLLOW_UP_CORRECTION_RULE,
|
||||
RECTIFICATION_OFFER_RULE,
|
||||
CAREER_FOLLOW_UP_RULE,
|
||||
"只有用户明确换了领域或要求完整看一下,才回到首轮形状:几段人话,多人就分段落、段首点名,不写二级标题。",
|
||||
"不要写「统一参数与原始结构」,不要写技法审计表,不要写思考过程清单,不要复述内部 JSON 字段。",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseRectificationOfferRecord, parseRectificationReturnRecord } from "@/lib/rectification-in-chat";
|
||||
import { entrySummaryFromResponse, type RectificationEntrySummary } from "@/lib/rectification-entry";
|
||||
import { BOOT_CHART_PROFILES_PATH, BOOT_MODELS_PATH, BOOT_READ_INIT, withBootFallback } from "@/lib/boot-early-reads";
|
||||
import { writeChatSession } from "@/lib/chat-session-write-contract";
|
||||
@@ -254,6 +255,17 @@ export function readSessionListPage(value: unknown): SessionListPage {
|
||||
};
|
||||
}
|
||||
|
||||
/** The offer card / return divider of rectification reached from a consultation (BUG-1248/1250). */
|
||||
function rectificationLinkFields(stored: Message): Partial<Pick<Message, "rectificationOffer" | "rectificationReturn">> {
|
||||
if (stored.role !== "assistant") return {};
|
||||
const offer = parseRectificationOfferRecord(stored.rectificationOffer);
|
||||
const returned = parseRectificationReturnRecord(stored.rectificationReturn);
|
||||
return {
|
||||
...(offer ? { rectificationOffer: offer } : {}),
|
||||
...(returned ? { rectificationReturn: returned } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null): SessionReadResult {
|
||||
if (!Array.isArray(value)) return { sessions: [], fallbackSessionIds: [] };
|
||||
const fallbackSessionIds: string[] = [];
|
||||
@@ -291,6 +303,7 @@ export function readSessions(value: unknown, catalog: PublicLanguageModelCatalog
|
||||
...(typeof stored.techniqueTruth === "string" ? { techniqueTruth: stored.techniqueTruth } : {}),
|
||||
...(stored.agentExecutionReceipt ? { agentExecutionReceipt: stored.agentExecutionReceipt } : {}),
|
||||
...(stored.workflowReceipt ? { workflowReceipt: stored.workflowReceipt } : {}),
|
||||
...rectificationLinkFields(stored),
|
||||
}];
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -20,20 +20,6 @@ export type RectificationEntrySummary = Readonly<{
|
||||
}> | null;
|
||||
}>;
|
||||
|
||||
export type RectificationCardAction = "start" | "restart";
|
||||
|
||||
export const rectificationEntryLabels: Readonly<Record<RectificationCardAction, string>> = {
|
||||
start: "开始新的生时校正",
|
||||
restart: "再次校正",
|
||||
};
|
||||
|
||||
export function resolveRectificationEntryAction(
|
||||
summary: RectificationEntrySummary,
|
||||
): RectificationCardAction {
|
||||
if (summary.hasTerminalCaseWithTime) return "restart";
|
||||
return "start";
|
||||
}
|
||||
|
||||
export type OpenRectificationDisposition = "created" | "resumed" | "readonly";
|
||||
|
||||
export type OpenRectificationCaseResponse = Readonly<{
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import "server-only";
|
||||
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import { resolveFeaturePricing } from "@/lib/feature-pricing";
|
||||
import { isProductEnabled } from "@/lib/product-access";
|
||||
import { getRectificationEntrySummary } from "@/lib/rectification-agentic/v9/case-service";
|
||||
import { loadV9CaseDossier } from "@/lib/rectification-agentic/v9/tool-service";
|
||||
import {
|
||||
appendRectificationReturn,
|
||||
bindOfferToCase,
|
||||
decideRectificationOfferWith,
|
||||
offerForCase,
|
||||
sourceFromSessionRows,
|
||||
type RectificationOfferDecision,
|
||||
type RectificationReturnRecord,
|
||||
type RectificationSource,
|
||||
} from "@/lib/rectification-in-chat";
|
||||
|
||||
/**
|
||||
* Server side of rectification reached from a consultation (BUG-1247~1251).
|
||||
* Reads and writes the consultation session's messages with the caller's own
|
||||
* client (owner RLS; `messages` is an owner-updatable column), never the
|
||||
* admin client.
|
||||
*/
|
||||
|
||||
/** C3: whether the offer card may be shown, decided by the server and never by the model. */
|
||||
export async function decideRectificationOffer(input: {
|
||||
accounting: SupabaseClient;
|
||||
userId: string;
|
||||
subjectRole: "self" | "other";
|
||||
modelId: string;
|
||||
}): Promise<RectificationOfferDecision> {
|
||||
return decideRectificationOfferWith({
|
||||
subjectRole: input.subjectRole,
|
||||
productEnabled: () => isProductEnabled("rectification"),
|
||||
hasResumableCase: async () => (await getRectificationEntrySummary(input.accounting, input.userId)).hasResumableCase,
|
||||
creditCost: async () => (await resolveFeaturePricing(input.accounting, "rectification", input.modelId)).credit_cost,
|
||||
});
|
||||
}
|
||||
|
||||
type SessionRow = { id: string; messages: unknown; session_type?: string | null };
|
||||
|
||||
async function readConsultationSession(client: SupabaseClient, userId: string, sessionId: string): Promise<SessionRow | null> {
|
||||
const { data, error } = await client
|
||||
.from("chat_sessions")
|
||||
.select("id,messages,session_type")
|
||||
.eq("id", sessionId)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
if (error || !data) return null;
|
||||
const row = data as SessionRow;
|
||||
return (row.session_type ?? "consultation") === "consultation" ? row : null;
|
||||
}
|
||||
|
||||
async function writeMessages(client: SupabaseClient, userId: string, sessionId: string, messages: unknown[]): Promise<boolean> {
|
||||
const { error } = await client
|
||||
.from("chat_sessions")
|
||||
.update({ messages })
|
||||
.eq("id", sessionId)
|
||||
.eq("user_id", userId);
|
||||
return !error;
|
||||
}
|
||||
|
||||
/** C4: after a case opens from a consultation, bind the offer card to it. Best effort. */
|
||||
export async function bindRectificationSource(input: {
|
||||
client: SupabaseClient;
|
||||
userId: string;
|
||||
sourceSessionId: string;
|
||||
caseId: string;
|
||||
rectificationSessionId: string;
|
||||
}): Promise<boolean> {
|
||||
const row = await readConsultationSession(input.client, input.userId, input.sourceSessionId);
|
||||
if (!row) return false;
|
||||
const next = bindOfferToCase(row.messages, { caseId: input.caseId, rectificationSessionId: input.rectificationSessionId });
|
||||
if (!next) return Boolean(offerForCase(row.messages, input.caseId));
|
||||
return writeMessages(input.client, input.userId, row.id, next);
|
||||
}
|
||||
|
||||
/** How many recent consultations are searched for the session that started a case. */
|
||||
const SOURCE_SEARCH_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* The consultation that started this case. Binding writes that session, so it
|
||||
* is among the most recently updated ones; a case started from the homepage or
|
||||
* from history has none.
|
||||
*/
|
||||
export async function findRectificationSource(
|
||||
client: SupabaseClient,
|
||||
userId: string,
|
||||
caseId: string,
|
||||
): Promise<RectificationSource | null> {
|
||||
const { data, error } = await client
|
||||
.from("chat_sessions")
|
||||
.select("id,messages,session_type")
|
||||
.eq("user_id", userId)
|
||||
.eq("session_type", "consultation")
|
||||
.order("updated_at", { ascending: false })
|
||||
.limit(SOURCE_SEARCH_LIMIT);
|
||||
if (error || !Array.isArray(data)) return null;
|
||||
return sourceFromSessionRows(data as SessionRow[], caseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* C5/C6: going back appends one divider to the consultation (idempotent) and
|
||||
* marks the offer returned. Whether the time changed is read from the case,
|
||||
* never from the browser.
|
||||
*/
|
||||
export async function returnToRectificationSource(input: {
|
||||
client: SupabaseClient;
|
||||
accounting: SupabaseClient;
|
||||
userId: string;
|
||||
caseId: string;
|
||||
now?: () => Date;
|
||||
}): Promise<(RectificationSource & { record: RectificationReturnRecord }) | null> {
|
||||
const source = await findRectificationSource(input.client, input.userId, input.caseId);
|
||||
if (!source) return null;
|
||||
if (source.returned) return { ...source, record: source.returned };
|
||||
const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId);
|
||||
const accepted = dossier.case.acceptedTime?.slice(0, 5) ?? null;
|
||||
const row = await readConsultationSession(input.client, input.userId, source.sessionId);
|
||||
if (!row) return null;
|
||||
const appended = appendRectificationReturn(row.messages, {
|
||||
caseId: input.caseId,
|
||||
adopted: Boolean(accepted),
|
||||
clock: accepted,
|
||||
returnedAt: (input.now?.() ?? new Date()).toISOString(),
|
||||
});
|
||||
if (!appended) {
|
||||
const again = await findRectificationSource(input.client, input.userId, input.caseId);
|
||||
return again?.returned ? { ...again, record: again.returned } : null;
|
||||
}
|
||||
if (!await writeMessages(input.client, input.userId, row.id, appended.messages)) return null;
|
||||
return { ...source, returned: appended.record, record: appended.record };
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* Birth-time rectification reached from an ordinary consultation
|
||||
* (TASK-rectification-in-chat-step1-20261006, BUG-1247~1251).
|
||||
*
|
||||
* The link between a consultation and the rectification it started lives in
|
||||
* the consultation session's own messages, so no table changes:
|
||||
* - the assistant answer that offered rectification carries `rectificationOffer`
|
||||
* (written with the answer by `complete_consultation_response`), which the
|
||||
* server moves to `opened` (with the case id) when the user starts it and to
|
||||
* `returned` when the user comes back;
|
||||
* - coming back appends one divider message carrying `rectificationReturn`.
|
||||
* The divider is never a model turn; earlier answers get a note when the birth
|
||||
* time changed (C7). Shared by the server and the browser; no server imports.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
|
||||
export const RECTIFICATION_OFFER_TOOL_ID = "offer-birth-time-rectification";
|
||||
|
||||
/** Rough number of questions a case asks before it can deliver (offline replay averaged 7.8). */
|
||||
export const RECTIFICATION_OFFER_QUESTION_ESTIMATE = 8;
|
||||
|
||||
export const rectificationOfferRecordSchema = z.object({
|
||||
status: z.enum(["offered", "opened", "returned"]),
|
||||
creditCost: z.number().int().positive().max(1000),
|
||||
questionEstimate: z.number().int().positive().max(50),
|
||||
/** The question to look at again with the new time. */
|
||||
sourceQuestion: z.string().trim().min(1).max(500),
|
||||
offeredAt: z.string().min(1).max(40),
|
||||
caseId: z.string().uuid().optional(),
|
||||
rectificationSessionId: z.string().uuid().optional(),
|
||||
}).strict();
|
||||
|
||||
export type RectificationOfferRecord = z.infer<typeof rectificationOfferRecordSchema>;
|
||||
|
||||
export const rectificationReturnRecordSchema = z.object({
|
||||
caseId: z.string().uuid(),
|
||||
adopted: z.boolean(),
|
||||
/** HH:MM when adopted, otherwise null. */
|
||||
clock: z.string().regex(/^\d{2}:\d{2}$/).nullable(),
|
||||
sourceQuestion: z.string().trim().min(1).max(500),
|
||||
returnedAt: z.string().min(1).max(40),
|
||||
}).strict();
|
||||
|
||||
export type RectificationReturnRecord = z.infer<typeof rectificationReturnRecordSchema>;
|
||||
|
||||
export function parseRectificationOfferRecord(value: unknown): RectificationOfferRecord | null {
|
||||
const parsed = rectificationOfferRecordSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
export function parseRectificationReturnRecord(value: unknown): RectificationReturnRecord | null {
|
||||
const parsed = rectificationReturnRecordSchema.safeParse(value);
|
||||
return parsed.success ? parsed.data : null;
|
||||
}
|
||||
|
||||
/** C3: the server's answer to "may the offer card be shown". */
|
||||
export type RectificationOfferDecision =
|
||||
| Readonly<{ ok: true; creditCost: number; questionEstimate: number }>
|
||||
| Readonly<{ ok: false; reason: "not_self" | "product_disabled" | "case_in_progress" | "pricing_unavailable" }>;
|
||||
|
||||
// ---- copy (VOICE.md「生时校正并入对话」) ----
|
||||
|
||||
export function rectificationOfferCardCopy(offer: Pick<RectificationOfferRecord, "creditCost" | "questionEstimate">): string {
|
||||
return `可以用几道过去经历的题,核对一下你的出生时间。大约 ${offer.questionEstimate} 题,会用掉 ${offer.creditCost} 点;开始答第一题时才扣。`;
|
||||
}
|
||||
|
||||
export const RECTIFICATION_OFFER_START_LABEL = "开始核对出生时间";
|
||||
export const RECTIFICATION_OFFER_CONTINUE_LABEL = "继续核对出生时间";
|
||||
export const RECTIFICATION_OFFER_OPENED_COPY = "出生时间核对已经开始。";
|
||||
export const RECTIFICATION_RETURN_BUTTON_LABEL = "回到刚才的对话";
|
||||
export const RECTIFICATION_BEFORE_CHANGE_LABEL = "按校正前的时间";
|
||||
|
||||
export function rectificationReturnDividerText(record: Pick<RectificationReturnRecord, "adopted" | "clock">): string {
|
||||
return record.adopted && record.clock
|
||||
? `出生时间改为 ${record.clock}(生时校正)`
|
||||
: "出生时间没有改";
|
||||
}
|
||||
|
||||
export function rectificationRevisitLabel(question: string): string {
|
||||
const clipped = question.length > 24 ? `${question.slice(0, 24)}…` : question;
|
||||
return `按新时间重新看:「${clipped}」`;
|
||||
}
|
||||
|
||||
/** C7: the note the model reads in front of answers written with the earlier birth time. */
|
||||
export const BIRTH_TIME_CHANGED_HISTORY_NOTE = "(以下回答按校正前的出生时间,不要沿用其中的盘面判断)";
|
||||
|
||||
// ---- pure helpers over a stored messages array ----
|
||||
|
||||
type StoredMessage = Record<string, unknown>;
|
||||
|
||||
function asMessages(value: unknown): StoredMessage[] | null {
|
||||
if (!Array.isArray(value)) return null;
|
||||
return value.map((item) => (item && typeof item === "object" && !Array.isArray(item) ? item as StoredMessage : {}));
|
||||
}
|
||||
|
||||
export function isRectificationReturnMessage(message: unknown): boolean {
|
||||
return Boolean(message && typeof message === "object"
|
||||
&& parseRectificationReturnRecord((message as StoredMessage).rectificationReturn));
|
||||
}
|
||||
|
||||
/** Any offer, in any state, in this session (C2: offer at most once unless the user asks). */
|
||||
export function sessionHasRectificationOffer(messages: unknown): boolean {
|
||||
return (asMessages(messages) ?? []).some((message) => parseRectificationOfferRecord(message.rectificationOffer));
|
||||
}
|
||||
|
||||
/**
|
||||
* The question to look at again: the latest user question before the turn
|
||||
* that offered rectification, or that turn's own question when it is the
|
||||
* first one. `excludeRequestId` skips the in-flight turn's stored question.
|
||||
*/
|
||||
export function revisitQuestionForOffer(messages: unknown, currentQuestion: string, excludeRequestId?: string): string {
|
||||
const stored = asMessages(messages) ?? [];
|
||||
for (let index = stored.length - 1; index >= 0; index -= 1) {
|
||||
const message = stored[index]!;
|
||||
if (excludeRequestId && message.requestId === excludeRequestId) continue;
|
||||
if (message.role === "user" && typeof message.text === "string" && message.text.trim()) {
|
||||
return message.text.trim().slice(0, 500);
|
||||
}
|
||||
}
|
||||
return currentQuestion.trim().slice(0, 500);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the latest still-offered card as opened for this case. Returns the
|
||||
* new array, or null when there is nothing to bind (no offer, or this case is
|
||||
* already bound).
|
||||
*/
|
||||
export function bindOfferToCase(
|
||||
messages: unknown,
|
||||
binding: { caseId: string; rectificationSessionId: string },
|
||||
): StoredMessage[] | null {
|
||||
const stored = asMessages(messages);
|
||||
if (!stored) return null;
|
||||
if (stored.some((message) => parseRectificationOfferRecord(message.rectificationOffer)?.caseId === binding.caseId)) {
|
||||
return null;
|
||||
}
|
||||
for (let index = stored.length - 1; index >= 0; index -= 1) {
|
||||
const offer = parseRectificationOfferRecord(stored[index]!.rectificationOffer);
|
||||
if (!offer || offer.status !== "offered") continue;
|
||||
const next = [...stored];
|
||||
next[index] = {
|
||||
...stored[index],
|
||||
rectificationOffer: { ...offer, status: "opened", caseId: binding.caseId, rectificationSessionId: binding.rectificationSessionId },
|
||||
};
|
||||
return next;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The offer bound to this case, if this session holds one. */
|
||||
export function offerForCase(messages: unknown, caseId: string): RectificationOfferRecord | null {
|
||||
for (const message of asMessages(messages) ?? []) {
|
||||
const offer = parseRectificationOfferRecord(message.rectificationOffer);
|
||||
if (offer?.caseId === caseId) return offer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the return divider once per case and marks its offer returned.
|
||||
* Returns null when the divider is already there (idempotent) or the session
|
||||
* does not hold an offer for this case.
|
||||
*/
|
||||
export function appendRectificationReturn(
|
||||
messages: unknown,
|
||||
input: { caseId: string; adopted: boolean; clock: string | null; returnedAt: string },
|
||||
): { messages: StoredMessage[]; record: RectificationReturnRecord } | null {
|
||||
const stored = asMessages(messages);
|
||||
if (!stored) return null;
|
||||
const offer = offerForCase(stored, input.caseId);
|
||||
if (!offer) return null;
|
||||
if (stored.some((message) => parseRectificationReturnRecord(message.rectificationReturn)?.caseId === input.caseId)) {
|
||||
return null;
|
||||
}
|
||||
const record: RectificationReturnRecord = {
|
||||
caseId: input.caseId,
|
||||
adopted: input.adopted && Boolean(input.clock),
|
||||
clock: input.adopted ? input.clock : null,
|
||||
sourceQuestion: offer.sourceQuestion,
|
||||
returnedAt: input.returnedAt,
|
||||
};
|
||||
const marked = stored.map((message) => {
|
||||
const current = parseRectificationOfferRecord(message.rectificationOffer);
|
||||
return current?.caseId === input.caseId
|
||||
? { ...message, rectificationOffer: { ...current, status: "returned" } }
|
||||
: message;
|
||||
});
|
||||
return {
|
||||
messages: [...marked, { role: "assistant", text: rectificationReturnDividerText(record), rectificationReturn: record }],
|
||||
record,
|
||||
};
|
||||
}
|
||||
|
||||
/** Index of the last divider that changed the birth time, or -1. */
|
||||
export function lastBirthTimeChangeIndex(messages: readonly unknown[]): number {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = messages[index];
|
||||
const record = message && typeof message === "object"
|
||||
? parseRectificationReturnRecord((message as StoredMessage).rectificationReturn)
|
||||
: null;
|
||||
if (record?.adopted) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* C3: the offer decision with its reads injected (the server wires the real
|
||||
* product switch, entry summary and feature pricing). Order matters: a
|
||||
* non-self subject never reaches the database.
|
||||
*/
|
||||
export async function decideRectificationOfferWith(input: {
|
||||
subjectRole: "self" | "other";
|
||||
productEnabled: () => Promise<boolean>;
|
||||
hasResumableCase: () => Promise<boolean>;
|
||||
creditCost: () => Promise<number>;
|
||||
questionEstimate?: number;
|
||||
}): Promise<RectificationOfferDecision> {
|
||||
if (input.subjectRole !== "self") return { ok: false, reason: "not_self" };
|
||||
if (!await input.productEnabled().catch(() => false)) return { ok: false, reason: "product_disabled" };
|
||||
// An unreadable summary does not block the offer: opening the case re-checks
|
||||
// for a conflict and says so.
|
||||
if (await input.hasResumableCase().catch(() => false)) return { ok: false, reason: "case_in_progress" };
|
||||
try {
|
||||
const creditCost = await input.creditCost();
|
||||
if (!Number.isInteger(creditCost) || creditCost <= 0) return { ok: false, reason: "pricing_unavailable" };
|
||||
return { ok: true, creditCost, questionEstimate: input.questionEstimate ?? RECTIFICATION_OFFER_QUESTION_ESTIMATE };
|
||||
} catch {
|
||||
return { ok: false, reason: "pricing_unavailable" };
|
||||
}
|
||||
}
|
||||
|
||||
export type RectificationSource = Readonly<{
|
||||
sessionId: string;
|
||||
question: string;
|
||||
returned: RectificationReturnRecord | null;
|
||||
}>;
|
||||
|
||||
/** The consultation (among the given rows) whose offer is bound to this case. */
|
||||
export function sourceFromSessionRows(
|
||||
rows: ReadonlyArray<{ id: string; messages: unknown }>,
|
||||
caseId: string,
|
||||
): RectificationSource | null {
|
||||
for (const row of rows) {
|
||||
const offer = offerForCase(row.messages, caseId);
|
||||
if (!offer) continue;
|
||||
const returned = (asMessages(row.messages) ?? [])
|
||||
.map((message) => parseRectificationReturnRecord(message.rectificationReturn))
|
||||
.find((record) => record?.caseId === caseId) ?? null;
|
||||
return { sessionId: row.id, question: offer.sourceQuestion, returned };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
export const RECTIFICATION_OPEN_HYDRATE_TIMEOUT_MS = BOOTSTRAP_PREPARE_TIMEOUT_MS;
|
||||
|
||||
/** Static entry copy while `/cases/open` and hydration are in flight. No spinner. */
|
||||
export const RECTIFICATION_OPENING_LABEL = "正在打开…";
|
||||
export const RECTIFICATION_SIDEBAR_OPENING_NOTE = "打开中";
|
||||
|
||||
/** Question recovery: one immediate refetch after a turn, then this many timed retries. */
|
||||
|
||||
@@ -313,7 +313,11 @@ export async function collectAgentPublicEvents(stream: ChunkStream | Iterable<Ch
|
||||
}
|
||||
}
|
||||
flushPlan();
|
||||
events.push({ type: "run.completed", receipt: agentExecutionReceiptSchema.parse(options.receipt()) });
|
||||
events.push({
|
||||
type: "run.completed",
|
||||
receipt: agentExecutionReceiptSchema.parse(options.receipt()),
|
||||
...(options.state?.rectificationOffer ? { rectificationOffer: options.state.rectificationOffer } : {}),
|
||||
});
|
||||
return events.map((event) => consultationAgentPublicEventSchema.parse(event));
|
||||
}
|
||||
|
||||
@@ -1106,7 +1110,11 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
|
||||
settled = true;
|
||||
settling = false;
|
||||
await sideEvent;
|
||||
send(controller, { type: "run.completed", receipt });
|
||||
send(controller, {
|
||||
type: "run.completed",
|
||||
receipt,
|
||||
...(options.state.rectificationOffer ? { rectificationOffer: options.state.rectificationOffer } : {}),
|
||||
});
|
||||
if (!disconnected) controller.close();
|
||||
} catch (error) {
|
||||
if (settled) return;
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
RECTIFICATION_OFFER_TOOL_ID,
|
||||
type RectificationOfferDecision,
|
||||
type RectificationOfferRecord,
|
||||
} from "../lib/rectification-in-chat.ts";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { createTool } from "@mastra/core/tools";
|
||||
import { z } from "zod";
|
||||
@@ -269,6 +274,8 @@ export type ConsultationRuntimeState = {
|
||||
* still has to state which method a receipt describes.
|
||||
*/
|
||||
jyotishSkillBound: boolean;
|
||||
/** Set when this turn's answer offered birth-time rectification (BUG-1248); persisted with the answer. */
|
||||
rectificationOffer?: RectificationOfferRecord;
|
||||
skillReferenceReadCount: number;
|
||||
methodologySectionCount: number;
|
||||
consultationToolStarted: boolean;
|
||||
@@ -439,6 +446,20 @@ export function consultationStepBudgetReceipt(state: ConsultationRuntimeState) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* C2/C3 (TASK-rectification-in-chat-step1-20261006, BUG-1248): what the offer
|
||||
* tool needs from the route. `decide` is the server check (self, product on,
|
||||
* no case in progress, price); `alreadyOffered` comes from this session's
|
||||
* stored messages; `revisitQuestion` is the question to look at again with
|
||||
* the new time.
|
||||
*/
|
||||
export type RectificationOfferHook = Readonly<{
|
||||
decide: () => Promise<RectificationOfferDecision>;
|
||||
alreadyOffered: boolean;
|
||||
revisitQuestion: string;
|
||||
now?: () => Date;
|
||||
}>;
|
||||
|
||||
export type ConsultationAgentContext = Readonly<{
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
@@ -455,6 +476,7 @@ export type ConsultationAgentContext = Readonly<{
|
||||
serverChart: ServerChartConsultation;
|
||||
abortSignal?: AbortSignal;
|
||||
state: ConsultationRuntimeState;
|
||||
rectificationOffer?: RectificationOfferHook;
|
||||
runWorkflow?: typeof runConsultationWorkflow;
|
||||
// The domain loop budgets itself against this clock, so tests can drive the
|
||||
// deadline without waiting for it.
|
||||
@@ -475,6 +497,7 @@ export type WindowConsultationAgentContext = Readonly<{
|
||||
declaredWindow: DeclaredBirthWindowConsultation;
|
||||
abortSignal?: AbortSignal;
|
||||
state: ConsultationRuntimeState;
|
||||
rectificationOffer?: RectificationOfferHook;
|
||||
now?: () => number;
|
||||
fetchWindowChart?: typeof fetchDeclaredWindowChart;
|
||||
runRangeReading?: typeof runV9RangeReading;
|
||||
@@ -1015,6 +1038,46 @@ const evidenceLookupInputSchema = z.object({
|
||||
section: z.enum(EVIDENCE_LOOKUP_SECTIONS),
|
||||
}).strict();
|
||||
|
||||
const RECTIFICATION_OFFER_REFUSAL_NOTES: Readonly<Record<Exclude<RectificationOfferDecision, { ok: true }>["reason"], string>> = {
|
||||
not_self: "问的不是用户本人的盘,生时校正暂时只支持本人。正文用一句话照实说明(例:「给家人校正暂时还不支持」),不出卡。",
|
||||
product_disabled: "生时校正暂时没有开放。正文不要提校正。",
|
||||
case_in_progress: "用户已经有一次没做完的生时校正。正文用一句话告诉他可以在历史对话里接着做。",
|
||||
pricing_unavailable: "生时校正这会儿提供不了。正文不要提校正。",
|
||||
};
|
||||
|
||||
/**
|
||||
* The offer card tool (C3, BUG-1248). It only records the offer for this turn;
|
||||
* the server decides whether a card may be shown and the card itself carries
|
||||
* the question count and price. Never calculates, never opens a case.
|
||||
*/
|
||||
function createRectificationOfferTool(ctx: Readonly<{ state: ConsultationRuntimeState; rectificationOffer?: RectificationOfferHook }>) {
|
||||
return createTool({
|
||||
id: RECTIFICATION_OFFER_TOOL_ID,
|
||||
description: "Offer birth-time rectification under this answer. Call it before writing the answer, only when the user says a concrete past event does not fit the chart, says the birth time is an estimate or unsure, or asks to check the birth time; never for a disagreement with an interpretation. Pass userAsked=true only when the user asked for it in this turn. The result says whether a card will be shown and what to say in one sentence.",
|
||||
inputSchema: z.object({ userAsked: z.boolean().optional() }).strict(),
|
||||
execute: async (input) => {
|
||||
const hook = ctx.rectificationOffer;
|
||||
if (!hook) return { offered: false as const, reason: "unavailable", note: RECTIFICATION_OFFER_REFUSAL_NOTES.product_disabled };
|
||||
if (ctx.state.rectificationOffer) {
|
||||
return { offered: true as const, note: "提议卡已经在了。正文用一句话提一下就好,不写价格和题数。" };
|
||||
}
|
||||
if (hook.alreadyOffered && input.userAsked !== true) {
|
||||
return { offered: false as const, reason: "already_offered_in_session", note: "这次对话里已经提议过一次,用户没接。不再主动提校正,正常回答。" };
|
||||
}
|
||||
const decision = await hook.decide();
|
||||
if (!decision.ok) return { offered: false as const, reason: decision.reason, note: RECTIFICATION_OFFER_REFUSAL_NOTES[decision.reason] };
|
||||
ctx.state.rectificationOffer = {
|
||||
status: "offered",
|
||||
creditCost: decision.creditCost,
|
||||
questionEstimate: decision.questionEstimate,
|
||||
sourceQuestion: hook.revisitQuestion,
|
||||
offeredAt: (hook.now?.() ?? new Date()).toISOString(),
|
||||
};
|
||||
return { offered: true as const, note: "你的回答下面会出现一张提议卡,写着大约几题、几点。正文最后一段必须有一句提到它(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数,不催。" };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
let calculation: Promise<ConsultationModelEvidenceView> | null = null;
|
||||
// Request-scoped: filled when this request's calculation finishes, read by
|
||||
@@ -1216,6 +1279,7 @@ export function createConsultationTools(ctx: ConsultationAgentContext) {
|
||||
return {
|
||||
"run-jyotish-consultation": consultationTool,
|
||||
[CONSULTATION_EVIDENCE_LOOKUP_TOOL_ID]: lookupTool,
|
||||
...(ctx.rectificationOffer ? { [RECTIFICATION_OFFER_TOOL_ID]: createRectificationOfferTool(ctx) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1375,7 +1439,10 @@ export function createWindowConsultationTools(ctx: WindowConsultationAgentContex
|
||||
}).strict(),
|
||||
execute: async (input, context) => executeWindowConsultation(ctx, input, context as WindowToolExecuteContext),
|
||||
});
|
||||
return { "run-jyotish-window-consultation": windowTool };
|
||||
return {
|
||||
"run-jyotish-window-consultation": windowTool,
|
||||
...(ctx.rectificationOffer ? { [RECTIFICATION_OFFER_TOOL_ID]: createRectificationOfferTool(ctx) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user