feat: add safe consultation cancellation

This commit is contained in:
Jesse_Chen
2026-07-17 11:57:28 +08:00
parent 95efa12b11
commit 2e193f594e
17 changed files with 1225 additions and 229 deletions
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { runCreditRpc } from "@/lib/consultation-billing";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
export const runtime = "nodejs";
const cancelRequestSchema = z.object({
requestId: z.string().uuid(),
});
export async function POST(request: Request) {
let supabase: Awaited<ReturnType<typeof createServerSupabaseClient>>;
let accounting: ReturnType<typeof createAdminSupabaseClient>;
try {
supabase = await createServerSupabaseClient();
accounting = createAdminSupabaseClient();
} catch {
return NextResponse.json(
{ error: "服务尚未配置", message: "请先配置 Supabase 环境变量。" },
{ status: 503 },
);
}
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json(
{ error: "请先登录", message: "登录后才能取消咨询。" },
{ status: 401 },
);
}
const parsed = cancelRequestSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json(
{ error: "取消请求格式不正确" },
{ status: 400 },
);
}
try {
const result = await runCreditRpc(
accounting,
"cancel_consultation_credit",
user.id,
parsed.data.requestId,
);
if (result.success) {
return NextResponse.json({ cancelled: true, credits: result.credits });
}
if (result.error_code === "request_completed") {
return NextResponse.json(
{ cancelled: false, credits: result.credits, message: "回答已经完成,本次咨询已计费。" },
{ status: 409 },
);
}
if (result.error_code === "rate_limited") {
return NextResponse.json(
{ cancelled: false, credits: result.credits, message: "取消请求过于频繁,请稍后再试。" },
{ status: 429 },
);
}
return NextResponse.json(
{ cancelled: false, credits: result.credits, message: "暂时无法取消本次咨询。" },
{ status: 503 },
);
} catch (error) {
console.error("[billing] cancellation RPC failed", error);
return NextResponse.json(
{ cancelled: false, message: "暂时无法取消本次咨询。" },
{ status: 503 },
);
}
}
+49 -147
View File
@@ -9,14 +9,17 @@ import {
languageModelSettings,
} from "@/mastra/model";
import { blocksPromptExtraction } from "@/lib/consult-safety";
import { CreditRpcError, runCreditRpc } from "@/lib/consultation-billing";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { streamTextResponse } from "@/lib/stream-text-response";
import { z } from "zod";
export const runtime = "nodejs";
export const maxDuration = 60;
const chatRequestSchema = consultationInputSchema.extend({
requestId: z.string().uuid(),
name: z.string().trim().max(80).optional().default(""),
history: z.array(z.object({
role: z.enum(["user", "assistant"]),
@@ -24,17 +27,6 @@ const chatRequestSchema = consultationInputSchema.extend({
})).max(20).default([]),
});
type StreamHooks = {
onComplete?: () => Promise<void>;
onError?: (error: unknown) => Promise<void>;
};
type CreditRpcResult = {
success?: boolean;
credits?: number;
error_code?: string;
};
function currentTimeContext(now = new Date()) {
const chinaTime = new Date(now.getTime() + 8 * 60 * 60 * 1000)
.toISOString()
@@ -43,65 +35,6 @@ function currentTimeContext(now = new Date()) {
return `服务端当前时间(权威):${now.toISOString()};中国标准时间(UTC+8):${chinaTime}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
}
function streamTextResponse(
stream: AsyncIterable<string>,
mode: "engine" | "mastra",
hooks: StreamHooks = {},
) {
const iterator = stream[Symbol.asyncIterator]();
const encoder = new TextEncoder();
let settled = false;
let emitted = false;
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await iterator.next();
if (done) {
settled = true;
if (!emitted) {
const error = new Error("empty_stream");
await hooks.onError?.(error);
controller.error(error);
return;
}
await hooks.onComplete?.();
controller.close();
return;
}
if (/\S/.test(value)) emitted = true;
controller.enqueue(encoder.encode(value));
} catch (error) {
if (!settled) {
settled = true;
await hooks.onError?.(error);
}
controller.error(error);
}
},
async cancel() {
const refundBeforeOutput = !settled && !emitted;
settled = true;
try {
await iterator.return?.();
} finally {
if (refundBeforeOutput) {
await hooks.onError?.(new Error("stream_cancelled_before_output"));
}
}
},
});
return new Response(body, {
headers: {
"cache-control": "no-cache, no-transform",
"content-type": "text/plain; charset=utf-8",
"x-accel-buffering": "no",
"x-ayanam-mode": mode,
},
});
}
async function* staticTextStream(text: string) {
yield text;
}
@@ -202,77 +135,14 @@ export async function POST(request: Request) {
}
const userId = user.id;
const requestId = crypto.randomUUID();
let refunded = false;
let refundInFlight: Promise<void> | null = null;
async function performRefund() {
if (refunded) return;
if (refundInFlight) return refundInFlight;
refundInFlight = (async () => {
let lastError = "unknown_refund_error";
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
const { data, error } = await accounting.rpc("refund_credit", {
p_user_id: userId,
p_request_id: requestId,
});
const result = Array.isArray(data) ? data[0] : data;
if (!error && result?.success) {
refunded = true;
return;
}
lastError = error?.message || result?.error_code || "refund_rejected";
} catch (error) {
lastError = error instanceof Error ? error.message : "refund_request_failed";
}
if (attempt < 3) {
await new Promise((resolve) => setTimeout(resolve, attempt * 150));
}
}
console.error(`[billing] refund failed for ${requestId}: ${lastError}`);
})();
try {
await refundInFlight;
} finally {
refundInFlight = null;
}
}
async function refund() {
await performRefund();
}
let reserveResult: CreditRpcResult | null = null;
let reserveErrorMessage = "";
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
const { data, error } = await accounting.rpc("reserve_credit", {
p_user_id: userId,
p_request_id: requestId,
});
const result = (Array.isArray(data) ? data[0] : data) as CreditRpcResult | null;
if (!error && result) {
reserveResult = result;
break;
}
reserveErrorMessage = error?.message || "empty_reservation_response";
} catch (error) {
reserveErrorMessage = error instanceof Error ? error.message : "reservation_request_failed";
}
if (attempt < 3) {
await new Promise((resolve) => setTimeout(resolve, attempt * 150));
}
}
if (!reserveResult) {
await performRefund();
const requestId = parsed.data.requestId;
let reserveResult;
try {
reserveResult = await runCreditRpc(accounting, "begin_consultation_credit", userId, requestId);
} catch (error) {
console.error(`[billing] reservation failed for ${requestId}`, error);
return NextResponse.json(
{ error: "暂时无法确认咨询点数", message: reserveErrorMessage || "请稍后重试。" },
{ error: "暂时无法确认咨询点数", message: "请稍后重试。" },
{ status: 503 },
);
}
@@ -288,13 +158,37 @@ export async function POST(request: Request) {
);
}
async function cancel() {
try {
await runCreditRpc(accounting, "cancel_consultation_credit", userId, requestId);
} catch (error) {
console.error(`[billing] cancellation failed for ${requestId}`, error);
}
}
async function complete() {
const result = await runCreditRpc(accounting, "complete_consultation_credit", userId, requestId);
if (!result.success) throw new CreditRpcError(result.error_code || "completion_rejected");
}
let settlement: Promise<void> | null = null;
function settle(action: () => Promise<void>) {
settlement ??= action();
return settlement;
}
try {
const { history, name, ...toolInput } = parsed.data;
const { history, name } = parsed.data;
const toolInput = consultationInputSchema.parse(parsed.data);
if (!languageModelSettings.configured) {
const evidence = await runConsultationWorkflow(toolInput);
return streamTextResponse(staticTextStream(engineSummary(evidence)), "engine", {
onError: refund,
return streamTextResponse(staticTextStream(engineSummary(evidence)), {
mode: "engine",
requestId,
onComplete: () => settle(complete),
onError: (_error, emitted) => settle(emitted ? complete : cancel),
onCancel: (emitted) => settle(emitted ? complete : cancel),
});
}
@@ -313,12 +207,20 @@ export async function POST(request: Request) {
].filter(Boolean).join("\n"),
},
]);
return streamTextResponse(result.textStream, "mastra", {
onComplete: () => recordModelUsage(accounting, userId, requestId, result.totalUsage),
onError: refund,
const completeAndRecordUsage = async () => {
await complete();
void recordModelUsage(accounting, userId, requestId, result.totalUsage);
};
const settleInterrupted = (emitted: boolean) => settle(emitted ? completeAndRecordUsage : cancel);
return streamTextResponse(result.textStream, {
mode: "mastra",
requestId,
onComplete: () => settle(completeAndRecordUsage),
onError: (_error, emitted) => settleInterrupted(emitted),
onCancel: settleInterrupted,
});
} catch (error) {
await refund();
await cancel();
const message = error instanceof Error ? error.message : "咨询服务暂时不可用";
return NextResponse.json(
{
+3
View File
@@ -337,7 +337,10 @@ button:disabled { cursor: default; opacity: .45; }
.composer:focus-within { border-color: var(--color-action); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-action) 15%, transparent); }
.composer textarea { min-width: 0; min-height: 44px; max-height: 128px; flex: 1; resize: none; padding: 11px 0 8px; border: 0; outline: 0; background: transparent; color: var(--color-ink); line-height: 1.5; font-size: 16px; }
.composer button { width: 44px; height: 44px; display: grid; flex: 0 0 auto; place-items: center; border: 0; color: var(--color-on-dark); cursor: pointer; transition: background-color 120ms ease-out, transform 120ms ease-out; border-radius: var(--radius-md); background: var(--color-action); }
.composer .composer-stop { background: var(--color-ink); }
.composer .composer-stop:not(:disabled):hover { background: var(--color-ink-strong); }
.composer-wrap > p { width: min(760px, 100%); margin: 6px auto 0; color: var(--color-ink-tertiary); text-align: center; margin-top: var(--space-2); font-size: 12px; }
.composer-wrap > p.composer-notice { display: block; color: var(--color-action-hover); }
.profile-overlay { position: fixed; z-index: 20; inset: 0; display: flex; justify-content: flex-end; opacity: 0; visibility: hidden; transition: opacity 180ms ease-out, visibility 0s linear 180ms; background: var(--color-scrim); }
.profile-dialog { height: 100dvh; overflow-y: auto; border-left: 1px solid var(--color-border); transform: translateX(24px); transition: transform 180ms var(--ease-out); width: min(560px, 100%); padding: var(--space-8); border-color: var(--color-border); background: var(--color-canvas); box-shadow: var(--shadow-elevated); }
+367 -70
View File
@@ -1,16 +1,18 @@
"use client";
import Link from "next/link";
import { ArrowUp, ArrowUpRight, ChevronRight, Menu, Minus, Plus, Sparkles, X } from "lucide-react";
import { FormEvent, KeyboardEvent, useEffect, useRef, useState } from "react";
import { ArrowUp, ArrowUpRight, ChevronRight, Menu, Minus, Plus, Sparkles, Square, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { FormEvent, KeyboardEvent } from "react";
import { ChatMessageContent } from "@/components/chat-message-content";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { chinaLocations, type ProvinceNode } from "@/data/china-locations";
import { parseAgentReply, type ReplyTheme } from "@/lib/agent-reply";
import { keepFocusWithin } from "@/lib/focus-trap";
import { createBrowserSupabaseClient } from "@/lib/supabase/client";
type Theme = "career" | "marriage" | "timing" | "general";
type Theme = ReplyTheme;
type Message = { role: "user" | "assistant"; text: string; suggestions?: string[] };
type Profile = {
name: string;
@@ -30,6 +32,20 @@ type OnboardingSuggestion = { theme: Exclude<Theme, "general">; text: string };
type OnboardingContent = { greeting: string; suggestions: OnboardingSuggestion[] };
type OnboardingStep = "name" | "birth" | "place";
type GreetingPeriod = "morning" | "noon" | "afternoon" | "evening" | "late-night";
type PendingConsultation = {
readonly requestId: string;
readonly sessionId: string;
readonly question: string;
readonly theme: Theme;
readonly previousSession: ChatSession;
readonly optimisticSession: ChatSession;
readonly previousOnboardingState: boolean;
readonly controller: AbortController;
readonly cancelled: boolean;
readonly phase: "undo" | "streaming";
readonly partialReply: string;
};
const undoWindowMs = 2_500;
const china = chinaLocations.country;
const themes: Array<{ id: Exclude<Theme, "general">; label: string; prompt: string }> = [
@@ -197,26 +213,6 @@ function readOnboarding(value: unknown): OnboardingContent | null {
return greeting.length >= 8 && suggestions.length === 3 ? { greeting, suggestions } : null;
}
function fallbackSuggestions(theme: Theme) {
if (theme === "career") return ["我更适合怎样的职业路径?", "未来一年事业上要避开什么?", "我该如何发挥自己的优势?"];
if (theme === "marriage") return ["我在关系里容易重复什么模式?", "怎样的伴侣更适合我?", "未来一年关系上要注意什么?"];
if (theme === "timing") return ["接下来最值得把握的阶段是什么?", "哪些时期更适合主动行动?", "我现在应该优先准备什么?"];
return themes.map((item) => item.prompt);
}
function parseAgentReply(value: string, theme: Theme) {
let suggestions: string[] = [];
const text = value.replace(/<!--AYANAM_SUGGESTIONS:(\[[\s\S]*?\])-->/g, (_, json: string) => {
try {
suggestions = readSuggestions(JSON.parse(json));
} catch {
suggestions = [];
}
return "";
}).trim();
return { text, suggestions: suggestions.length === 3 ? suggestions : fallbackSuggestions(theme) };
}
function readProfile(value: unknown): Profile {
if (!value || typeof value !== "object") return emptyProfile;
const profile = value as Partial<Profile> & {
@@ -345,11 +341,6 @@ function OnboardingChatMessage({ role, text, streaming = false, length = text.le
);
}
function sessionTitle(question: string) {
const normalized = question.replace(/\s+/g, " ").trim();
return normalized.length > 22 ? `${normalized.slice(0, 22)}` : normalized;
}
function isProfileComplete(profile: Profile) {
return missingProfileStep(profile) === null;
}
@@ -367,6 +358,28 @@ function payloadMessage(payload: unknown, fallback: string) {
return friendlyError(message || fallback);
}
class CancellationResponseError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.name = "CancellationResponseError";
this.status = status;
}
}
function waitForUndoWindow(signal: AbortSignal) {
return new Promise<void>((resolve) => {
const finish = () => {
window.clearTimeout(timer);
signal.removeEventListener("abort", finish);
resolve();
};
const timer = window.setTimeout(finish, undoWindowMs);
signal.addEventListener("abort", finish, { once: true });
});
}
async function fetchAccount(signal?: AbortSignal): Promise<Account> {
const response = await fetch("/api/account", { signal, cache: "no-store" });
if (response.status === 401) {
@@ -395,6 +408,10 @@ export default function Home() {
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState("");
const [draft, setDraft] = useState("");
const [draftTheme, setDraftTheme] = useState<Theme | null>(null);
const [composerNotice, setComposerNotice] = useState("");
const [consultationPhase, setConsultationPhase] = useState<"undo" | "streaming" | null>(null);
const [cancellationPending, setCancellationPending] = useState(false);
const [pendingSessionId, setPendingSessionId] = useState<string | null>(null);
const [streamingReply, setStreamingReply] = useState<StreamingReply | null>(null);
const [requestError, setRequestError] = useState<RequestError | null>(null);
@@ -416,12 +433,24 @@ export default function Home() {
const closeButton = useRef<HTMLButtonElement>(null);
const redeemInput = useRef<HTMLInputElement>(null);
const composerInput = useRef<HTMLTextAreaElement>(null);
const pendingConsultation = useRef<PendingConsultation | null>(null);
const cancellationRequests = useRef(new Map<string, Promise<void>>());
const cancellationFeedbackRequest = useRef<string | null>(null);
const cancellationInFlight = useRef(false);
const stoppedRequestAwaitingSettlement = useRef<string | null>(null);
const stoppedSessionPersistence = useRef(new Map<string, Promise<void>>());
const activeSessionIdRef = useRef("");
const uiPreview = useRef(false);
const uiPreviewMode = useRef<string | null>(null);
const activeSession = sessions.find((session) => session.id === activeSessionId) ?? sessions[0];
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
const isLoading = pendingSessionId === activeSession?.id;
const activeStreamingText = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.text : "";
useEffect(() => {
activeSessionIdRef.current = activeSessionId;
}, [activeSessionId]);
const activeSuggestions = activeSession?.messages.reduce((latest, message) => message.role === "assistant" && message.suggestions?.length ? message.suggestions : latest, [] as string[]) ?? [];
const accountId = account?.user.id;
const profileComplete = isProfileComplete(profile);
@@ -452,6 +481,7 @@ export default function Home() {
: null;
if (previewMode) {
uiPreview.current = true;
uiPreviewMode.current = previewMode;
if (previewMode === "error") {
setAccountError("连接云端服务超时。请检查网络后重试,或返回登录页重新建立会话。");
setHydrated(true);
@@ -468,7 +498,7 @@ export default function Home() {
cityCode: "110000-city",
districtCode: "110101",
};
const previewMessages: Message[] = previewMode === "conversation"
const previewMessages: Message[] = previewMode === "conversation" || previewMode === "streaming" || previewMode === "partial"
? [
{ role: "user", text: "未来半年是否适合换工作?" },
{ role: "assistant", text: "可以先看职业方向、关键时间。\n同时评估现实风险。\n此处只展示本地预览,\n不调用真实星盘。", suggestions: ["先看事业方向", "再看关键时间", "评估现实风险"] },
@@ -719,6 +749,8 @@ export default function Home() {
setSessions((current) => [nextSession, ...current]);
setActiveSessionId(nextSession.id);
setDraft("");
setDraftTheme(null);
setComposerNotice("");
setRequestError(null);
try {
await persistSession(nextSession);
@@ -899,9 +931,135 @@ export default function Home() {
}
}
function chooseSuggestedQuestion(question: string, theme?: Theme) {
if (pendingSessionId || cancellationInFlight.current) return;
setDraft(question);
setDraftTheme(theme ?? null);
setComposerNotice("");
window.requestAnimationFrame(() => composerInput.current?.focus());
}
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;
const isPreview = process.env.NODE_ENV === "development" && uiPreview.current;
if (pending.phase === "streaming" && !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(),
};
updateSession(pending.sessionId, () => stoppedSession);
setStreamingReply(null);
setPendingSessionId(null);
setConsultationPhase(null);
setRequestError(null);
setComposerNotice("已停止回答。模型已开始生成,本次将计费,现有内容已保留。");
if (!isPreview) {
const persistence = persistSession(stoppedSession).catch((error) => {
setRequestError({
sessionId: pending.sessionId,
message: error instanceof Error ? error.message : "已停止的回答暂时无法同步。",
});
});
stoppedSessionPersistence.current.set(pending.requestId, persistence);
}
if (!isPreview) {
void refreshAccount();
} else if (pendingConsultation.current?.requestId === pending.requestId) {
pendingConsultation.current = null;
}
return;
}
updateSession(pending.sessionId, () => pending.previousSession);
setOnboardingJustCompleted(pending.previousOnboardingState);
setDraft(pending.question);
setDraftTheme(pending.theme);
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,
"已停止,问题已放回输入框,本次未扣点。",
);
}
async function send(text: string, requestedTheme?: Theme) {
const originalQuestion = text;
const question = text.trim();
if (!question || !activeSession || pendingSessionId || !account) return;
if (!question || !activeSession || pendingSessionId || cancellationInFlight.current || pendingConsultation.current || !account) return;
if (account.credits <= 0) {
openAccount(true);
@@ -930,50 +1088,96 @@ export default function Home() {
: currentSession.messages;
const userSession: ChatSession = {
...currentSession,
title: currentSession.messages.length === 0 ? sessionTitle(question) : currentSession.title,
title: currentSession.title,
theme,
messages: [...preservedMessages, { role: "user", text: question }],
updatedAt: timestamp(),
};
const requestId = globalThis.crypto.randomUUID();
const controller = new AbortController();
const previousOnboardingState = onboardingJustCompleted;
cancellationFeedbackRequest.current = null;
setRequestError(null);
setComposerNotice("");
setPendingSessionId(sessionId);
setConsultationPhase("undo");
pendingConsultation.current = {
requestId,
sessionId,
question: originalQuestion,
theme,
previousSession: currentSession,
optimisticSession: userSession,
previousOnboardingState,
controller,
cancelled: false,
phase: "undo",
partialReply: "",
};
setOnboardingJustCompleted(false);
updateSession(sessionId, () => userSession);
setDraft("");
setDraftTheme(null);
if (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;
}
const previewReply = parseAgentReply([
"这是本地交互预览。正式对话会结合你的星盘证据继续分析。",
'<!--AYANAM_SUGGESTIONS:["继续梳理方向","查看时间窗口","评估现实行动"]-->',
"<!--AYANAM_TITLE:事业方向与时间选择-->",
].join("\n"), theme);
const previewSession: ChatSession = {
...userSession,
title: currentSession.messages.length === 0 && previewReply.title ? previewReply.title : userSession.title,
messages: [...userSession.messages, {
role: "assistant",
text: "这是本地交互预览。正式对话会结合你的星盘证据继续分析。",
suggestions: ["继续梳理方向", "查看时间窗口", "评估现实行动"],
text: previewReply.text,
suggestions: previewReply.suggestions,
}],
updatedAt: timestamp(),
};
updateSession(sessionId, () => previewSession);
setDraft("");
setStreamingReply(null);
setPendingSessionId(null);
setConsultationPhase(null);
pendingConsultation.current = null;
return;
}
try {
await persistSession(userSession);
} catch (caught) {
setRequestError({
sessionId,
message: `${caught instanceof Error ? caught.message : "消息未能保存到云端。"} 输入内容已保留,可直接重新发送。`,
});
setPendingSessionId(null);
return;
await waitForUndoWindow(controller.signal);
if (controller.signal.aborted) return;
if (pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = {
...pendingConsultation.current,
phase: "streaming",
};
setConsultationPhase("streaming");
}
setOnboardingJustCompleted(false);
updateSession(sessionId, () => userSession);
setDraft("");
setStreamingReply({ sessionId, text: "" });
let latestPartialReply = "";
try {
const response = await fetch("/api/consult", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
requestId,
name: profile.name,
year,
month,
@@ -991,6 +1195,7 @@ export default function Home() {
text: message.text.slice(0, 4000),
})),
}),
signal: controller.signal,
});
if (!response.ok) {
const contentType = response.headers.get("content-type") ?? "";
@@ -1001,7 +1206,6 @@ export default function Home() {
}
if (!response.body) throw new Error("浏览器未收到可读取的回答流");
setAccount((current) => current ? { ...current, credits: Math.max(0, current.credits - 1) } : current);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let answer = "";
@@ -1010,15 +1214,25 @@ export default function Home() {
const { done, value } = await reader.read();
if (done) break;
answer += decoder.decode(value, { stream: true });
setStreamingReply({ sessionId, text: answer.split("<!--AYANAM_SUGGESTIONS:", 1)[0] });
const partialReply = parseAgentReply(answer, theme).text;
latestPartialReply = partialReply;
setStreamingReply({ sessionId, text: partialReply });
if (partialReply && pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = {
...pendingConsultation.current,
partialReply,
};
}
}
answer += decoder.decode();
if (controller.signal.aborted) return;
if (!answer.trim()) throw new Error("Agent 没有返回内容,请重试。");
const reply = parseAgentReply(answer, theme);
if (!reply.text) throw new Error("Agent 没有返回可显示的回答,请重试。");
const completedSession: ChatSession = {
...userSession,
title: currentSession.messages.length === 0 && reply.title ? reply.title : userSession.title,
messages: [...userSession.messages, { role: "assistant", text: reply.text, suggestions: reply.suggestions }],
updatedAt: timestamp(),
};
@@ -1033,14 +1247,74 @@ export default function Home() {
}
void refreshAccount();
} catch (caught) {
setRequestError({
sessionId,
message: `${caught instanceof Error ? caught.message : "服务暂时不可用,请稍后重试。"} 本次提问已保留在对话中,可稍后重新提问。`,
});
void refreshAccount();
const cancelled = controller.signal.aborted;
const ownsInterface = pendingConsultation.current?.requestId === requestId;
const partialReply = latestPartialReply;
if (ownsInterface && !partialReply) {
updateSession(sessionId, () => currentSession);
setOnboardingJustCompleted(previousOnboardingState);
if (activeSessionIdRef.current === sessionId) {
setDraft(originalQuestion);
setDraftTheme(theme);
}
if (!cancelled) {
setRequestError({
sessionId,
message: `${caught instanceof Error ? caught.message : "服务暂时不可用,请稍后重试。"} 问题已放回输入框。`,
});
cancellationFeedbackRequest.current = requestId;
if (activeSessionIdRef.current === sessionId) {
setComposerNotice("问题已放回输入框,正在确认点数…");
}
}
}
if (ownsInterface && !partialReply) {
await confirmCancellation(
requestId,
sessionId,
"问题已放回输入框,本次未扣点。",
);
} else if (!cancelled && ownsInterface) {
const interruptedSession: ChatSession = {
...userSession,
messages: [...userSession.messages, { role: "assistant", text: partialReply }],
updatedAt: timestamp(),
};
updateSession(sessionId, () => interruptedSession);
try {
await persistSession(interruptedSession);
setRequestError({
sessionId,
message: "回答中途断开,已保留生成内容;本次已开始生成并计费。",
});
} catch (persistError) {
setRequestError({
sessionId,
message: `${persistError instanceof Error ? persistError.message : "云端同步失败"} 已计费的部分回答仍保留在当前页面,请复制保存。`,
});
}
if (activeSessionIdRef.current === sessionId) {
setComposerNotice("回答中途断开,已保留现有内容,本次已计费。");
}
}
} finally {
setStreamingReply(null);
setPendingSessionId(null);
cancellationRequests.current.delete(requestId);
if (pendingConsultation.current?.requestId === requestId) {
pendingConsultation.current = null;
setStreamingReply(null);
setPendingSessionId(null);
setConsultationPhase(null);
}
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);
}
}
}
@@ -1050,7 +1324,7 @@ export default function Home() {
if (onboardingStep === "name" && presetMessageFinished) void saveOnboardingName();
return;
}
void send(draft);
void send(draft, draftTheme ?? undefined);
}
function handleComposerKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
@@ -1095,7 +1369,7 @@ export default function Home() {
<button className="sidebar-backdrop" tabIndex={-1} aria-label="关闭聊天记录" type="button" onClick={() => setMobileSidebarOpen(false)} />
<aside className="sidebar" ref={sidebar} id="chat-sidebar" aria-label="对话导航" inert={profileOpen}>
<div className="brand-row"><span className="brand-mark" aria-hidden="true" /><strong>Jyotisha</strong><button className="sidebar-close" ref={sidebarCloseButton} aria-label="关闭聊天记录" type="button" onClick={() => setMobileSidebarOpen(false)}><X aria-hidden="true" /></button></div>
<button className="new-chat" type="button" onClick={() => void startNewChat()} disabled={!hydrated || !account || creatingSession || Boolean(pendingSessionId)}><Plus aria-hidden="true" /> {creatingSession ? "正在创建" : "新对话"}</button>
<button className="new-chat" type="button" onClick={() => void startNewChat()} disabled={!hydrated || !account || creatingSession || Boolean(pendingSessionId) || cancellationPending}><Plus aria-hidden="true" /> {creatingSession ? "正在创建" : "新对话"}</button>
<nav className="session-nav" aria-label="聊天记录">
<span className="sidebar-label"></span>
<div className="session-list">
@@ -1104,7 +1378,13 @@ export default function Home() {
className={session.id === activeSession?.id ? "is-active" : ""}
key={session.id}
type="button"
onClick={() => { setActiveSessionId(session.id); setDraft(""); setMobileSidebarOpen(false); }}
onClick={() => {
setActiveSessionId(session.id);
setDraft("");
setComposerNotice("");
setMobileSidebarOpen(false);
}}
disabled={Boolean(pendingSessionId) || cancellationPending}
aria-current={session.id === activeSession?.id ? "page" : undefined}
>
<span>{session.title}</span>
@@ -1127,7 +1407,7 @@ export default function Home() {
<button className="mobile-menu" ref={mobileMenuTrigger} aria-label="打开聊天记录" aria-controls="chat-sidebar" aria-expanded={mobileSidebarOpen} type="button" onClick={() => setMobileSidebarOpen(true)}><Menu aria-hidden="true" /></button>
<div>
<strong>{activeSession?.title || "新对话"}</strong>
<span><i className={`status ${isLoading ? "status-loading" : "status-idle"}`} />{isLoading ? (activeStreamingText ? "正在回答" : "正在核对星盘信息") : "基于星盘证据回答"}</span>
<span><i className={`status ${isLoading ? "status-loading" : "status-idle"}`} />{isLoading ? (consultationPhase === "undo" ? "即将发送,可撤回" : activeStreamingText ? "正在回答" : "正在核对星盘信息") : "基于星盘证据回答"}</span>
</div>
<button className="credit-button" type="button" onClick={() => openAccount(account?.credits === 0)} aria-label={account ? `余额 ${account.credits} 点,打开账户与兑换码` : accountError || "读取余额中"}>
<Sparkles className="credit-icon" aria-hidden="true" />
@@ -1183,14 +1463,14 @@ export default function Home() {
{!profileComplete && onboardingStep === "name" && accountError && <p className="form-error onboarding-inline-error" role="alert">{accountError}</p>}
{profileComplete && presetMessageFinished && (onboardingPending ? (
{profileComplete && presetMessageFinished && !draft.trim() && (onboardingPending ? (
<div className="starter-loading" role="status"></div>
) : (
<div className="starter-list" aria-label="Jyotisha 推荐的初始问题">
{(onboarding?.suggestions ?? themes.map((item) => ({ theme: item.id, text: item.prompt }))).map((item) => {
const theme = themes.find((candidate) => candidate.id === item.theme);
return (
<button key={`${item.theme}-${item.text}`} type="button" disabled={!hydrated || Boolean(pendingSessionId) || !account} onClick={() => void send(item.text, item.theme)}>
<button key={`${item.theme}-${item.text}`} type="button" disabled={!hydrated || Boolean(pendingSessionId) || cancellationPending || !account} onClick={() => chooseSuggestedQuestion(item.text, item.theme)}>
<span className="starter-content"><b>{theme?.label || "开始"}</b><span>{item.text}</span></span>
<ArrowUpRight className="starter-arrow" aria-hidden="true" />
</button>
@@ -1231,10 +1511,10 @@ export default function Home() {
</div>
<div className="composer-wrap">
{activeSuggestions.length > 0 && (
{activeSuggestions.length > 0 && !draft.trim() && !isLoading && !cancellationPending && (
<div className="composer-suggestions" aria-label="推荐继续提问">
{activeSuggestions.map((question) => (
<button key={question} type="button" disabled={Boolean(pendingSessionId) || !account} onClick={() => void send(question)}>{question}</button>
<button key={question} type="button" disabled={!account || cancellationPending} onClick={() => chooseSuggestedQuestion(question)}>{question}</button>
))}
</div>
)}
@@ -1253,16 +1533,33 @@ export default function Home() {
: "例如:未来半年是否适合换工作?"}
rows={1}
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
disabled={!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving)}
disabled={isLoading || cancellationPending || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onChange={(event) => {
setDraft(event.target.value);
setDraftTheme(null);
setComposerNotice("");
}}
onKeyDown={handleComposerKeyDown}
/>
<Button aria-label={!profileComplete ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || !account || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
{isLoading ? (
<Button
className="composer-stop"
aria-label={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容" : "停止回答并申请退回本次点数"}
title={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,本次已开始计费" : "停止回答"}
size="icon"
type="button"
onClick={() => void stopResponse()}
>
<Square aria-hidden="true" />
</Button>
) : (
<Button aria-label={!profileComplete ? "确认称呼" : "发送"} disabled={!draft.trim() || Boolean(pendingSessionId) || cancellationPending || !account || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))} size="icon" type="submit">
<ArrowUp aria-hidden="true" />
</Button>
)}
</form>
<p>{!profileComplete && onboardingStep === "name" ? "Enter 确认称呼" : "Enter 发送 · Shift + Enter 换行"}</p>
<p className={composerNotice || consultationPhase === "undo" ? "composer-notice" : undefined} role={composerNotice || consultationPhase === "undo" ? "status" : undefined}>{composerNotice || (consultationPhase === "undo" ? "已加入发送队列,2.5 秒内可免费撤回。" : !profileComplete && onboardingStep === "name" ? "Enter 确认称呼" : "Enter 发送 · Shift + Enter 换行")}</p>
</div>
</section>