fix(chat): make the server the only writer of session messages
List GET no longer ships transcripts; consult appends questions after reserve and ignores client history so dual-tab last-write-wins cannot erase messages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7118,3 +7118,19 @@
|
||||
- 相关记录:BUG-456、BUG-460、BUG-462
|
||||
- 复发自:无(授权策略变更,不是同一实现回归)
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-464 | 客户端全量覆盖 chat_sessions.messages 导致列表膨胀、写满失败与双标签页丢消息
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-01
|
||||
- 最近更新:2026-09-01
|
||||
- 影响面:`GET /api/sessions`、`GET/PATCH /api/sessions/[id]`、`POST /api/consult`、`append_consultation_question`、首页会话列表与对话区
|
||||
- 用户现象:会话一多首页变慢;对话变长后发问提示“问题保存失败”;两个标签页各问一句,刷新后只剩后写的那一轮;中止或失败的半截回答会留下可刷新的记录。
|
||||
- 触发条件:打开带多段对话的账户首页;长会话继续发问;同一会话在两个标签页交替发送;停止生成或 run.failed 后刷新。
|
||||
- 根因:`chat_sessions.messages` 由浏览器整包 PATCH 覆盖,列表 GET 又把全部消息一次性带回。服务端结算已经 append assistant,客户端再 last-write-wins 覆盖。列表无分页、无上限。
|
||||
- 修复:列表 GET 去掉 `messages`;详情 GET 按会话加载。咨询在 reserve 成功后由 `append_consultation_question` 写入用户提问,满 200 条或 200,000 字符返回 `session_full` 并退回预扣。consult 忽略请求体 `history`,改读库内最近 12 条。客户端 `persistSession` 只写元数据;旧 bundle 的 `messages` PATCH 接受并忽略。中止与失败的部分回答只留在当前页。
|
||||
- 验证:合同测试锁列表/详情拆分、伪造 history 不进模型上下文、PATCH 忽略 messages、SQL 幂等与 `session_full`、双 request_id 可叠加。`./node_modules/.bin/tsc --noEmit`、`npm test` 与 `npm run test:db` 必须通过。
|
||||
- 防复发:不得把 `messages` 加回列表 GET 或校正 agent select。不得恢复客户端 transcript PATCH。consult 不得再读取 `parsed.data.history`。`append_consultation_question` 必须保持 advisory lock、request_id 幂等与满员拒绝。
|
||||
- 相关记录:无
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
loadGeneralDailyPanchangaContext,
|
||||
type GeneralDailyPanchangaContext,
|
||||
} from "@/lib/general-daily-panchanga";
|
||||
import { consultationHistoryFromStoredMessages } from "@/lib/consultation-session-history";
|
||||
import { z } from "zod";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
@@ -94,6 +95,7 @@ const chatRequestMetadataSchema = z.object({
|
||||
}),
|
||||
)
|
||||
.max(20)
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
@@ -143,6 +145,11 @@ const consultationCompletionSchema = z.object({
|
||||
error_code: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const consultationQuestionAppendSchema = z.object({
|
||||
success: z.boolean(),
|
||||
error_code: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
function first<T>(value: T | T[]): T {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
@@ -273,7 +280,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const { data: chatSession, error: chatSessionError } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select("id,model_id,model_config_version,session_type")
|
||||
.select("id,model_id,model_config_version,session_type,messages")
|
||||
.eq("id", parsed.data.sessionId)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
@@ -329,11 +336,11 @@ export async function POST(request: Request) {
|
||||
const consultationTheme = parsed.data.theme;
|
||||
const visibleQuestion = parsed.data.question;
|
||||
|
||||
// Client `history` stays in the request schema for old bundles and is not read.
|
||||
const storedHistory = consultationHistoryFromStoredMessages(chatSession.messages);
|
||||
const userControlledPrompt = [
|
||||
parsed.data.question,
|
||||
...parsed.data.history
|
||||
.filter((message) => message.role === "user")
|
||||
.map((message) => message.text),
|
||||
...storedHistory.filter((message) => message.role === "user").map((message) => message.text),
|
||||
].join("\n");
|
||||
if (blocksPromptExtraction(userControlledPrompt)) {
|
||||
return NextResponse.json(
|
||||
@@ -504,6 +511,46 @@ export async function POST(request: Request) {
|
||||
}
|
||||
}
|
||||
|
||||
let appendedQuestion: { success: boolean; error_code?: string | null };
|
||||
try {
|
||||
appendedQuestion = await retryDetachedSettlement(async () => {
|
||||
const { data, error } = await accounting.rpc("append_consultation_question", {
|
||||
p_user_id: userId,
|
||||
p_request_id: requestId,
|
||||
p_session_id: sessionId,
|
||||
p_question_message: { role: "user", text: visibleQuestion },
|
||||
});
|
||||
const parsedAppend = consultationQuestionAppendSchema.safeParse(first(data ?? []));
|
||||
if (error || !parsedAppend.success) {
|
||||
throw new CreditRpcError(error?.message || "invalid_question_append_response");
|
||||
}
|
||||
return parsedAppend.data;
|
||||
});
|
||||
} catch {
|
||||
await cancel();
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法保存问题", message: "请稍后重试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!appendedQuestion.success) {
|
||||
await cancel();
|
||||
if (appendedQuestion.error_code === "session_full") {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "这段对话已写满",
|
||||
message: "这段对话已写满,开个新对话继续吧",
|
||||
code: "session_full",
|
||||
},
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "暂时无法保存问题", message: "请稍后重试。" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const usageStartedAt = Date.now();
|
||||
async function usagePayload(usage: Promise<{ inputTokens?: number; outputTokens?: number }>) {
|
||||
const resolved = await usage;
|
||||
@@ -1044,7 +1091,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const { history } = parsed.data;
|
||||
const history = storedHistory;
|
||||
const name = prepared.serverChart?.name ?? prepared.declaredWindow?.name ?? parsed.data.name;
|
||||
const consultationMode: ConsultationBirthTimeMode = prepared.consultationMode;
|
||||
const generalDailyContext = shouldLoadGeneralDailyPanchanga({
|
||||
|
||||
@@ -238,7 +238,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const { data: chatSession, error: chatSessionError } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id")
|
||||
.select("id,session_type,model_id,model_config_version,agentic_rectification_case_id")
|
||||
.eq("id", sessionId)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -3,17 +3,66 @@ import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import {
|
||||
ChatSessionBodyTooLargeError,
|
||||
chatSessionMetadataPatchSchema,
|
||||
chatSessionModelPatchSchema,
|
||||
chatSessionWriteSchema,
|
||||
extractChatSessionMetadataPatch,
|
||||
readChatSessionJson,
|
||||
} from "@/lib/chat-session-write-contract";
|
||||
import { logIgnoredSessionMessages } from "@/lib/chat-session-observability";
|
||||
import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
|
||||
type RouteContext = { params: Promise<{ id: string }> };
|
||||
|
||||
const sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at";
|
||||
const sessionIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function payloadHasMessages(payload: unknown): payload is { messages: unknown } {
|
||||
return Boolean(payload && typeof payload === "object" && !Array.isArray(payload) && "messages" in payload);
|
||||
}
|
||||
|
||||
function ignoredMessageCount(payload: { messages: unknown }) {
|
||||
return Array.isArray(payload.messages) ? payload.messages.length : 0;
|
||||
}
|
||||
|
||||
function metadataUpdateValues(payload: unknown): Record<string, unknown> | null {
|
||||
const parsed = chatSessionMetadataPatchSchema.safeParse(extractChatSessionMetadataPatch(payload));
|
||||
if (!parsed.success) return null;
|
||||
return { ...parsed.data, updated_at: new Date().toISOString() };
|
||||
}
|
||||
|
||||
export async function GET(_request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
if (!sessionIdPattern.test(id)) {
|
||||
return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
|
||||
}
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const { data, error } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select(sessionSelect)
|
||||
.eq("id", id)
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
if (error) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
|
||||
if (!data) return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
|
||||
return NextResponse.json({ session: data });
|
||||
} catch (error) {
|
||||
if (isSupabaseConfigurationError(error)) {
|
||||
return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 });
|
||||
}
|
||||
return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
try {
|
||||
const { id } = await context.params;
|
||||
if (!sessionIdPattern.test(id)) {
|
||||
return NextResponse.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
|
||||
}
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
@@ -26,14 +75,23 @@ export async function PATCH(request: Request, context: RouteContext) {
|
||||
}, { status: 429, headers: { "Retry-After": String(limited.retryAfterSeconds) } });
|
||||
}
|
||||
const payload = await readChatSessionJson(request);
|
||||
const fullWrite = chatSessionWriteSchema.safeParse(payload);
|
||||
if (payloadHasMessages(payload)) {
|
||||
logIgnoredSessionMessages(id, user.id, ignoredMessageCount(payload));
|
||||
}
|
||||
const metadataValues = metadataUpdateValues(payload);
|
||||
const modelPatch = chatSessionModelPatchSchema.safeParse(payload);
|
||||
let values: Record<string, unknown>;
|
||||
if (fullWrite.success) {
|
||||
values = fullWrite.data;
|
||||
} else if (modelPatch.success) {
|
||||
values = modelPatch.data;
|
||||
} else {
|
||||
const fullWrite = chatSessionWriteSchema.safeParse(payload);
|
||||
let values: Record<string, unknown> | null = metadataValues;
|
||||
if (!values && fullWrite.success) {
|
||||
values = metadataUpdateValues(fullWrite.data);
|
||||
}
|
||||
if (!values && modelPatch.success) {
|
||||
values = { ...modelPatch.data, updated_at: new Date().toISOString() };
|
||||
}
|
||||
if (!values && payloadHasMessages(payload)) {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
if (!values) {
|
||||
return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
|
||||
@@ -4,6 +4,8 @@ import { consumeUserRequestRateLimit } from "@/lib/request-rate-limit";
|
||||
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
|
||||
import { createServerSupabaseClient } from "@/lib/supabase/server";
|
||||
|
||||
const SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
@@ -11,7 +13,7 @@ export async function GET() {
|
||||
if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
|
||||
const { data, error } = await supabase
|
||||
.from("chat_sessions")
|
||||
.select("id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at")
|
||||
.select(SESSION_LIST_COLUMNS)
|
||||
.eq("user_id", user.id)
|
||||
.order("updated_at", { ascending: false });
|
||||
if (error) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 });
|
||||
@@ -39,11 +41,12 @@ export async function POST(request: Request) {
|
||||
}
|
||||
const parsed = chatSessionCreateSchema.safeParse(await readChatSessionJson(request));
|
||||
if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 });
|
||||
const { id, ...values } = parsed.data;
|
||||
const { id, updated_at: _ignoredClientClock, ...values } = parsed.data;
|
||||
const { error } = await supabase.from("chat_sessions").insert({
|
||||
id,
|
||||
user_id: user.id,
|
||||
...values,
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
if (error) return NextResponse.json({ error: "聊天记录暂时无法同步" }, { status: 500 });
|
||||
return NextResponse.json({ ok: true }, { status: 201 });
|
||||
|
||||
@@ -1027,6 +1027,13 @@ button:disabled { cursor: default; opacity: .45; }
|
||||
width: min(900px, 100%);
|
||||
padding: var(--space-8) var(--space-8) var(--space-16);
|
||||
}
|
||||
.session-messages-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 40vh;
|
||||
color: var(--color-ink-secondary);
|
||||
}
|
||||
.message { display: flex; animation: message-enter 160ms var(--ease-out) both; padding: var(--space-2) 0; }
|
||||
.agent-avatar { width: 32px; height: 32px; display: block; flex: 0 0 32px; margin-top: var(--space-2); border-radius: 50%; background: var(--color-canvas) url("/jyotish-logo.png") center / contain no-repeat; box-shadow: 0 0 0 1px var(--ring-hairline); }
|
||||
.message-content { min-width: 0; max-width: min(80%, 680px); }
|
||||
|
||||
+181
-83
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowDown, ArrowUpRight, Sparkles } from "lucide-react";
|
||||
import { InlineSpinner } from "@/components/inline-spinner";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { FormEvent, KeyboardEvent } from "react";
|
||||
import { AccountDialogOverlay, type AccountOverlayModel } from "@/components/account-dialog-overlay";
|
||||
@@ -239,6 +240,7 @@ type ChatSession = {
|
||||
chartProfileId: string | null;
|
||||
chartProfileName: string | null;
|
||||
chartProfileRole: "self" | "other" | null;
|
||||
messagesHydrated: boolean;
|
||||
};
|
||||
|
||||
type RequestError = { sessionId: string; message: string };
|
||||
@@ -437,6 +439,7 @@ function createSession(
|
||||
updatedAt: timestamp(),
|
||||
sessionType,
|
||||
rectificationCaseId: null,
|
||||
messagesHydrated: true,
|
||||
...chartBinding,
|
||||
};
|
||||
}
|
||||
@@ -892,6 +895,7 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
|
||||
session_type?: unknown;
|
||||
updated_at?: unknown;
|
||||
};
|
||||
const messagesPresent = Object.prototype.hasOwnProperty.call(session, "messages");
|
||||
const messages: Message[] = Array.isArray(session.messages)
|
||||
? session.messages.flatMap((message) => {
|
||||
if (!message || typeof message !== "object") return [];
|
||||
@@ -943,11 +947,20 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null
|
||||
: typeof session.updated_at === "string"
|
||||
? Date.parse(session.updated_at)
|
||||
: timestamp(),
|
||||
messagesHydrated: messagesPresent,
|
||||
}];
|
||||
});
|
||||
return { sessions, fallbackSessionIds };
|
||||
}
|
||||
|
||||
function mergeHydratedSession(current: ChatSession[], detailed: ChatSession): ChatSession[] {
|
||||
const next = { ...detailed, messagesHydrated: true };
|
||||
if (current.some((session) => session.id === next.id)) {
|
||||
return current.map((session) => (session.id === next.id ? { ...session, ...next } : session));
|
||||
}
|
||||
return [next, ...current];
|
||||
}
|
||||
|
||||
function BirthLocationFields({ value, onChange }: { value: Profile; onChange: (profile: Profile) => void }) {
|
||||
return (
|
||||
<fieldset className="location-fieldset">
|
||||
@@ -1081,6 +1094,12 @@ function payloadMessage(payload: unknown, fallback: string) {
|
||||
return friendlyError(message || fallback);
|
||||
}
|
||||
|
||||
function payloadCode(payload: unknown): string | undefined {
|
||||
if (!payload || typeof payload !== "object") return undefined;
|
||||
const code = (payload as { code?: unknown }).code;
|
||||
return typeof code === "string" ? code : undefined;
|
||||
}
|
||||
|
||||
class CancellationResponseError extends Error {
|
||||
readonly status: number;
|
||||
|
||||
@@ -1093,11 +1112,13 @@ class CancellationResponseError extends Error {
|
||||
|
||||
class ConsultationResponseError extends Error {
|
||||
readonly status: number;
|
||||
readonly code?: string;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
constructor(status: number, message: string, code?: string) {
|
||||
super(message);
|
||||
this.name = "ConsultationResponseError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1158,6 +1179,24 @@ async function fetchSessions(signal?: AbortSignal): Promise<unknown> {
|
||||
return payload && typeof payload === "object" ? (payload as { sessions?: unknown }).sessions : null;
|
||||
}
|
||||
|
||||
async function fetchSessionDetail(
|
||||
sessionId: string,
|
||||
catalog: PublicLanguageModelCatalog | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChatSession | null> {
|
||||
const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
signal,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 401) redirectToLogin();
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录"));
|
||||
const sessionValue = payload && typeof payload === "object"
|
||||
? (payload as { session?: unknown }).session
|
||||
: null;
|
||||
return readSessions(sessionValue ? [sessionValue] : [], catalog).sessions[0] ?? null;
|
||||
}
|
||||
|
||||
function parseConsultationStatus(payload: unknown, requestId?: string): ConsultationStatus {
|
||||
if (!payload || typeof payload !== "object") throw new Error("后台回答状态无效");
|
||||
const status = payload as Partial<ConsultationStatus>;
|
||||
@@ -1272,6 +1311,8 @@ export default function Home() {
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [creatingSession, setCreatingSession] = useState(false);
|
||||
const [sessionDetailLoadingId, setSessionDetailLoadingId] = useState<string | null>(null);
|
||||
const [sessionFullPrompt, setSessionFullPrompt] = useState<{ question: string; theme: Theme } | null>(null);
|
||||
const [onboarding, setOnboarding] = useState<OnboardingContent | null>(null);
|
||||
const [onboardingError, setOnboardingError] = useState("");
|
||||
const [onboardingStep, setOnboardingStep] = useState<OnboardingStep>("name");
|
||||
@@ -1319,6 +1360,9 @@ export default function Home() {
|
||||
const accountRefreshGuard = useRef(createLatestAccountRequestGuard());
|
||||
const resumeRectificationSession = useRef<(session: ChatSession) => void>(() => undefined);
|
||||
const rectificationOpenInFlight = useRef(false);
|
||||
const sessionDetailInFlight = useRef(new Set<string>());
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
const uiPreview = useRef(false);
|
||||
const uiPreviewMode = useRef<string | null>(null);
|
||||
const birthTimeRevisionPending = useRef(false);
|
||||
@@ -1336,7 +1380,10 @@ export default function Home() {
|
||||
&& activeSession.id === rectificationSessionId;
|
||||
const visibleSessions = sessions
|
||||
.filter((session) => showArchivedSessions ? archivedSessionIds.includes(session.id) : !archivedSessionIds.includes(session.id))
|
||||
.filter((session) => session.sessionType === "birth_time_rectification" || session.messages.length > 0)
|
||||
.filter((session) => session.sessionType === "birth_time_rectification"
|
||||
|| session.messages.length > 0
|
||||
|| !session.messagesHydrated
|
||||
|| session.id === activeSessionId)
|
||||
.sort((left, right) => Number(pinnedSessionIds.includes(right.id)) - Number(pinnedSessionIds.includes(left.id)));
|
||||
const activeError = requestError && requestError.sessionId === activeSession?.id ? requestError.message : "";
|
||||
const isLoading = pendingSessionId === activeSession?.id;
|
||||
@@ -1384,6 +1431,10 @@ export default function Home() {
|
||||
activeSessionIdRef.current = activeSessionId;
|
||||
}, [activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated || uiPreview.current || !activeSessionId) return;
|
||||
void ensureSessionMessages(activeSessionId);
|
||||
}, [hydrated, activeSessionId, modelCatalog, pendingSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hydrated
|
||||
@@ -1540,7 +1591,14 @@ export default function Home() {
|
||||
&& presetMessageFinished
|
||||
&& !onboardingPending
|
||||
&& !rectificationSurfaceOpen
|
||||
&& !activeSession?.messages.length;
|
||||
&& Boolean(activeSession?.messagesHydrated)
|
||||
&& !activeSession.messages.length;
|
||||
const sessionMessagesLoading = Boolean(
|
||||
activeSession
|
||||
&& activeSession.sessionType === "consultation"
|
||||
&& !activeSession.messagesHydrated
|
||||
&& pendingSessionId !== activeSession.id,
|
||||
);
|
||||
const onboardingFormActive = !profileComplete && onboardingStep !== "name";
|
||||
const birthTimeContinueHint = onboardingStep === "birth" ? birthTimeDraftReadyHint(profileDraft) : "";
|
||||
const starterGreeting = createStartGreetingParts(profile.name, new Date(), starterGreetingSelection);
|
||||
@@ -1709,6 +1767,7 @@ export default function Home() {
|
||||
chartProfileId: "self",
|
||||
chartProfileName: previewProfile.name.trim() || "我",
|
||||
chartProfileRole: "self",
|
||||
messagesHydrated: true,
|
||||
};
|
||||
setAccount({
|
||||
user: { id: "preview-user", email: "preview@local.test" },
|
||||
@@ -1769,13 +1828,12 @@ export default function Home() {
|
||||
title: initialSession.title,
|
||||
theme: initialSession.theme,
|
||||
model_id: initialSession.modelId,
|
||||
messages: initialSession.messages,
|
||||
messages: [],
|
||||
session_type: initialSession.sessionType,
|
||||
rectification_case_id: initialSession.rectificationCaseId,
|
||||
chart_profile_id: initialSession.chartProfileId,
|
||||
chart_profile_name: initialSession.chartProfileName,
|
||||
chart_profile_role: initialSession.chartProfileRole,
|
||||
updated_at: new Date(initialSession.updatedAt).toISOString(),
|
||||
}, "create");
|
||||
}
|
||||
nextSessions = [initialSession];
|
||||
@@ -1821,6 +1879,19 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
if (controller.signal.aborted) return;
|
||||
const activeListed = nextSessions[0];
|
||||
if (activeListed && !activeListed.messagesHydrated && activeListed.sessionType === "consultation") {
|
||||
try {
|
||||
const detailed = await fetchSessionDetail(activeListed.id, nextModelCatalog, controller.signal);
|
||||
if (detailed) {
|
||||
nextSessions = nextSessions.map((session) => session.id === detailed.id ? detailed : session);
|
||||
}
|
||||
} catch (caught) {
|
||||
if (caught instanceof Error && caught.name === "AbortError") throw caught;
|
||||
if (caught instanceof LoginRedirectError) return;
|
||||
}
|
||||
}
|
||||
if (controller.signal.aborted) return;
|
||||
clearStaleClientReload(sessionStorage);
|
||||
setAccount(nextAccount);
|
||||
@@ -1912,12 +1983,11 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
if (status.status === "completed") {
|
||||
const payload = await fetchSessions(controller.signal);
|
||||
const parsed = readSessions(payload, modelCatalog);
|
||||
setSessions(parsed.sessions);
|
||||
setActiveSessionId((current) => parsed.sessions.some((session) => session.id === current)
|
||||
? current
|
||||
: parsed.sessions[0]?.id ?? "");
|
||||
const detailed = await fetchSessionDetail(pendingSessionId, modelCatalog, controller.signal);
|
||||
if (detailed) {
|
||||
setSessions((current) => mergeHydratedSession(current, detailed));
|
||||
setActiveSessionId((current) => current || detailed.id);
|
||||
}
|
||||
pendingConsultation.current = null;
|
||||
setPendingSessionId(null);
|
||||
setPendingRequestId(null);
|
||||
@@ -2202,29 +2272,62 @@ export default function Home() {
|
||||
async function persistSession(session: ChatSession, mode: "create" | "update" = "update") {
|
||||
if (!account) throw new Error("账户尚未加载完成");
|
||||
if (process.env.NODE_ENV === "development" && uiPreview.current) return;
|
||||
const values = {
|
||||
title: session.title,
|
||||
theme: session.theme,
|
||||
model_id: session.modelId,
|
||||
messages: session.messages.map((message) => ({
|
||||
role: message.role,
|
||||
text: message.text,
|
||||
thinkingText: message.thinkingText,
|
||||
thinkingSections: message.thinkingSections,
|
||||
techniqueTruth: message.techniqueTruth,
|
||||
agentExecutionReceipt: message.agentExecutionReceipt,
|
||||
workflowReceipt: message.workflowReceipt,
|
||||
})),
|
||||
session_type: session.sessionType,
|
||||
rectification_case_id: session.rectificationCaseId,
|
||||
chart_profile_id: session.chartProfileId,
|
||||
chart_profile_name: session.chartProfileName,
|
||||
chart_profile_role: session.chartProfileRole,
|
||||
updated_at: new Date(session.updatedAt).toISOString(),
|
||||
};
|
||||
const values = mode === "create"
|
||||
? {
|
||||
title: session.title,
|
||||
theme: session.theme,
|
||||
model_id: session.modelId,
|
||||
messages: [] as const,
|
||||
session_type: session.sessionType,
|
||||
rectification_case_id: session.rectificationCaseId,
|
||||
chart_profile_id: session.chartProfileId,
|
||||
chart_profile_name: session.chartProfileName,
|
||||
chart_profile_role: session.chartProfileRole,
|
||||
}
|
||||
: {
|
||||
title: session.title,
|
||||
theme: session.theme,
|
||||
model_id: session.modelId,
|
||||
chart_profile_id: session.chartProfileId,
|
||||
chart_profile_name: session.chartProfileName,
|
||||
chart_profile_role: session.chartProfileRole,
|
||||
};
|
||||
await writeChatSession(session.id, values, mode);
|
||||
}
|
||||
|
||||
async function ensureSessionMessages(sessionId: string) {
|
||||
if (!sessionId || uiPreview.current) return;
|
||||
if (pendingSessionId === sessionId) return;
|
||||
if (sessionDetailInFlight.current.has(sessionId)) return;
|
||||
const known = sessionsRef.current.find((session) => session.id === sessionId);
|
||||
if (known?.messagesHydrated || known?.sessionType === "birth_time_rectification") return;
|
||||
sessionDetailInFlight.current.add(sessionId);
|
||||
setSessionDetailLoadingId(sessionId);
|
||||
try {
|
||||
const detailed = await fetchSessionDetail(sessionId, modelCatalog);
|
||||
if (!detailed) return;
|
||||
setSessions((existing) => {
|
||||
const live = existing.find((session) => session.id === sessionId);
|
||||
if (live?.messagesHydrated) return existing;
|
||||
return mergeHydratedSession(existing, detailed);
|
||||
});
|
||||
} catch (caught) {
|
||||
if (caught instanceof LoginRedirectError) return;
|
||||
setComposerNotice(caught instanceof Error ? caught.message : "暂时无法读取聊天记录");
|
||||
} finally {
|
||||
sessionDetailInFlight.current.delete(sessionId);
|
||||
setSessionDetailLoadingId((currentId) => currentId === sessionId ? null : currentId);
|
||||
}
|
||||
}
|
||||
|
||||
async function continueInNewChat(prompt: { question: string; theme: Theme }) {
|
||||
setSessionFullPrompt(null);
|
||||
const created = await startNewChat();
|
||||
if (!created) return;
|
||||
setDraft(prompt.question);
|
||||
setDraftTheme(prompt.theme);
|
||||
}
|
||||
|
||||
async function renameSession(session: ChatSession) {
|
||||
const title = window.prompt("重命名聊天记录", session.title)?.trim();
|
||||
if (!title || title === session.title) return;
|
||||
@@ -2355,6 +2458,8 @@ export default function Home() {
|
||||
// the exact Case and never switches to another rectification record.
|
||||
void openRectificationSession(nextSession.id);
|
||||
}
|
||||
} else {
|
||||
void ensureSessionMessages(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2784,6 +2889,7 @@ export default function Home() {
|
||||
|
||||
let targetSession = activeSession
|
||||
&& activeSession.sessionType === "consultation"
|
||||
&& activeSession.messagesHydrated
|
||||
&& activeSession.messages.length === 0
|
||||
? activeSession
|
||||
: null;
|
||||
@@ -2909,6 +3015,7 @@ export default function Home() {
|
||||
updatedAt: timestamp(),
|
||||
sessionType: "birth_time_rectification",
|
||||
rectificationCaseId: opened.caseId,
|
||||
messagesHydrated: true,
|
||||
...chartSnapshotForSession(activeChartId, chartLibrary, profile),
|
||||
};
|
||||
setSessions((current) => [merged, ...current.filter((session) => session.id !== merged.id)]);
|
||||
@@ -3158,12 +3265,11 @@ export default function Home() {
|
||||
try {
|
||||
const status = await fetchConsultationStatus(pending.sessionId, pending.requestId);
|
||||
if (status.status === "completed") {
|
||||
const payload = await fetchSessions();
|
||||
const parsed = readSessions(payload, modelCatalog);
|
||||
setSessions(parsed.sessions);
|
||||
setActiveSessionId((current) => parsed.sessions.some((session) => session.id === current)
|
||||
? current
|
||||
: parsed.sessions[0]?.id ?? "");
|
||||
const detailed = await fetchSessionDetail(pending.sessionId, modelCatalog);
|
||||
if (detailed) {
|
||||
setSessions((current) => mergeHydratedSession(current, detailed));
|
||||
setActiveSessionId((current) => current || detailed.id);
|
||||
}
|
||||
pendingConsultation.current = null;
|
||||
setPendingSessionId(null);
|
||||
setConsultationPhase(null);
|
||||
@@ -3187,16 +3293,7 @@ export default function Home() {
|
||||
setPendingSessionId(null);
|
||||
setConsultationPhase(null);
|
||||
setRequestError(null);
|
||||
try {
|
||||
await persistSession(stoppedSession);
|
||||
setComposerNotice("已停止回答,现有内容已保留,本次点数已退回。");
|
||||
} catch (error) {
|
||||
setComposerNotice("本次点数已退回;现有内容暂时无法同步。");
|
||||
setRequestError({
|
||||
sessionId: pending.sessionId,
|
||||
message: error instanceof Error ? error.message : "已停止的回答暂时无法同步。",
|
||||
});
|
||||
}
|
||||
setComposerNotice("已停止回答,现有内容已保留,本次点数已退回。");
|
||||
cancellationRequests.current.delete(pending.requestId);
|
||||
stoppedRequestAwaitingSettlement.current = null;
|
||||
cancellationInFlight.current = false;
|
||||
@@ -3358,6 +3455,7 @@ export default function Home() {
|
||||
theme,
|
||||
messages: questionAlreadyPresent ? preservedMessages : [...preservedMessages, { role: "user", text: question }],
|
||||
updatedAt: questionAlreadyPresent ? currentSession.updatedAt : timestamp(),
|
||||
messagesHydrated: true,
|
||||
};
|
||||
const requestId = resumeRequestId ?? globalThis.crypto.randomUUID();
|
||||
const controller = resuming && pendingConsultation.current
|
||||
@@ -3450,28 +3548,6 @@ export default function Home() {
|
||||
}
|
||||
if (!resuming || !questionAlreadyPresent) {
|
||||
if (resuming && !questionAlreadyPresent) updateSession(sessionId, () => userSession);
|
||||
try {
|
||||
await persistSession(userSession);
|
||||
} catch (caught) {
|
||||
if (controller.signal.aborted) return false;
|
||||
updateSession(sessionId, () => rollbackSession);
|
||||
setOnboardingJustCompleted(previousOnboardingState);
|
||||
if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) {
|
||||
setDraft(originalQuestion);
|
||||
setDraftTheme(theme);
|
||||
setDraftEntrypoint(consultEntrypoint);
|
||||
}
|
||||
setRequestError({
|
||||
sessionId,
|
||||
message: `${caught instanceof Error ? caught.message : "问题保存失败,请稍后重试。"} 问题已放回输入框。`,
|
||||
});
|
||||
setComposerNotice(options.restoreOnFailure
|
||||
? "问题保存失败,未开始生成;已恢复原来的回答。"
|
||||
: "问题保存失败,未开始生成;问题已放回输入框。");
|
||||
completeConsultationInterface(requestId);
|
||||
window.requestAnimationFrame(() => composerInput.current?.focus());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (pendingConsultation.current?.requestId === requestId) {
|
||||
pendingConsultation.current = {
|
||||
@@ -3529,6 +3605,7 @@ export default function Home() {
|
||||
throw new ConsultationResponseError(
|
||||
response.status,
|
||||
payloadMessage(errorPayload, "服务暂时不可用"),
|
||||
payloadCode(errorPayload),
|
||||
);
|
||||
}
|
||||
if (!response.body) {
|
||||
@@ -3679,14 +3756,6 @@ export default function Home() {
|
||||
setStreamingReply(null);
|
||||
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
|
||||
setComposerNotice(truncatedFailure.message);
|
||||
try {
|
||||
await persistSession(truncatedSession);
|
||||
} catch (error) {
|
||||
setRequestError({
|
||||
sessionId,
|
||||
message: error instanceof Error ? error.message : "未完成的回答暂时无法同步。",
|
||||
});
|
||||
}
|
||||
completeConsultationInterface(requestId);
|
||||
void refreshAccount();
|
||||
return true;
|
||||
@@ -3756,6 +3825,19 @@ export default function Home() {
|
||||
const ownsInterface = pendingConsultation.current?.requestId === requestId;
|
||||
const partialReply = latestPartialReply;
|
||||
if (!cancelled && ownsInterface && pendingConsultation.current && caught instanceof ConsultationResponseError) {
|
||||
if (caught.code === "session_full") {
|
||||
updateSession(sessionId, () => rollbackSession);
|
||||
setOnboardingJustCompleted(previousOnboardingState);
|
||||
setSessionFullPrompt({ question: originalQuestion, theme });
|
||||
setComposerNotice("这段对话已写满,开个新对话继续吧", {
|
||||
label: "开新对话",
|
||||
onClick: () => {
|
||||
void continueInNewChat({ question: originalQuestion, theme });
|
||||
},
|
||||
});
|
||||
completeConsultationInterface(requestId);
|
||||
return false;
|
||||
}
|
||||
if (caught.message === "request_conflict") {
|
||||
pendingConsultation.current = {
|
||||
...pendingConsultation.current,
|
||||
@@ -3767,14 +3849,26 @@ export default function Home() {
|
||||
setComposerNotice("回答仍在后台生成,正在自动恢复。");
|
||||
return Boolean(partialReply);
|
||||
}
|
||||
const reserveDidNotCommit = caught.status === 400
|
||||
|| caught.status === 401
|
||||
|| caught.status === 402
|
||||
|| caught.status === 409;
|
||||
if (reserveDidNotCommit) {
|
||||
updateSession(sessionId, () => rollbackSession);
|
||||
setOnboardingJustCompleted(previousOnboardingState);
|
||||
if (!options.restoreOnFailure && activeSessionIdRef.current === sessionId) {
|
||||
setDraft(originalQuestion);
|
||||
setDraftTheme(theme);
|
||||
setDraftEntrypoint(consultEntrypoint);
|
||||
}
|
||||
}
|
||||
setRequestError({ sessionId, message: caught.message });
|
||||
setReplyOutcome({ sessionId, phase: "failed", replyOrdinal: 0 });
|
||||
setComposerNotice(caught.message);
|
||||
const restore = options.restoreOnFailure;
|
||||
if (restore) {
|
||||
updateSession(sessionId, () => restore);
|
||||
void persistSession(restore).catch(() => {});
|
||||
} else if (thinkingSections.length || latestPartialReply || streamedThinking.trim()) {
|
||||
} else if (!reserveDidNotCommit && (thinkingSections.length || latestPartialReply || streamedThinking.trim())) {
|
||||
const failedSession: ChatSession = {
|
||||
...userSession,
|
||||
messages: [...userSession.messages, {
|
||||
@@ -3786,7 +3880,6 @@ export default function Home() {
|
||||
updatedAt: timestamp(),
|
||||
};
|
||||
updateSession(sessionId, () => failedSession);
|
||||
void persistSession(failedSession).catch(() => {});
|
||||
}
|
||||
completeConsultationInterface(requestId);
|
||||
return false;
|
||||
@@ -4255,7 +4348,7 @@ export default function Home() {
|
||||
</header>
|
||||
|
||||
{!rectificationSurfaceOpen && (
|
||||
<div ref={conversation} className={`conversation${!activeSession?.messages.length && !onboardingFormActive ? " is-empty" : ""}${!profileComplete ? " is-onboarding" : ""}${onboardingFormActive ? " is-onboarding-form" : ""}`}>
|
||||
<div ref={conversation} className={`conversation${!activeSession?.messages.length && Boolean(activeSession?.messagesHydrated) && !onboardingFormActive ? " is-empty" : ""}${!profileComplete ? " is-onboarding" : ""}${onboardingFormActive ? " is-onboarding-form" : ""}`}>
|
||||
{!activeSession?.messages.length ? (
|
||||
<div className="welcome">
|
||||
{!profileComplete ? (
|
||||
@@ -4429,6 +4522,11 @@ export default function Home() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : sessionMessagesLoading ? (
|
||||
<div className="message-list session-messages-loading" role="status" aria-busy="true" aria-live="polite">
|
||||
<InlineSpinner size={20} />
|
||||
<span className="sr-only">正在加载聊天记录</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="message-list" aria-busy={isLoading}>
|
||||
<ChatTranscript
|
||||
@@ -4510,9 +4608,9 @@ export default function Home() {
|
||||
? "余额不足,可前往会员页充值点数或开通会员"
|
||||
: "例如:未来半年是否适合换工作?"}
|
||||
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
|
||||
inputDisabled={isLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
inputDisabled={isLoading || sessionMessagesLoading || cancellationPending || rectificationSurfaceOpen || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
submitLabel={!profileComplete && onboardingStep === "name" ? "确认称呼" : "发送"}
|
||||
submitBlocked={Boolean(pendingSessionId) || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
submitBlocked={Boolean(pendingSessionId) || sessionMessagesLoading || cancellationPending || rectificationSurfaceOpen || !account || !modelCatalog || (!profileComplete && (onboardingStep !== "name" || !presetMessageFinished || profileSaving))}
|
||||
stopVisible={isLoading}
|
||||
stopLabel={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留已生成内容并退回本次点数" : "停止回答并申请退回本次点数"}
|
||||
stopTitle={consultationPhase === "undo" ? "撤回发送,本次不扣点" : activeStreamingText ? "停止回答,保留现有内容并申请退回本次点数" : "停止回答"}
|
||||
|
||||
@@ -4,8 +4,13 @@ export const chatNoticeToastId = "chat-notice";
|
||||
|
||||
export type NoticeTone = "info" | "success" | "error";
|
||||
|
||||
export type ChatNoticeAction = Readonly<{
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}>;
|
||||
|
||||
const ongoingNotice = /正在|请稍候|请先|联网后/;
|
||||
const failedNotice = /失败|无法|不可用|未找到/;
|
||||
const failedNotice = /失败|无法|不可用|未找到|已写满/;
|
||||
const settledNotice = /^已|^回答已恢复/;
|
||||
|
||||
export function noticeTone(message: string): NoticeTone {
|
||||
@@ -17,23 +22,27 @@ export function noticeTone(message: string): NoticeTone {
|
||||
|
||||
let lastNotice = "";
|
||||
|
||||
export function showChatNotice(message: string) {
|
||||
export function showChatNotice(message: string, action?: ChatNoticeAction) {
|
||||
if (!message.trim()) {
|
||||
if (!lastNotice) return;
|
||||
lastNotice = "";
|
||||
toast.dismiss(chatNoticeToastId);
|
||||
return;
|
||||
}
|
||||
if (lastNotice === message) return;
|
||||
if (lastNotice === message && !action) return;
|
||||
lastNotice = message;
|
||||
const tone = noticeTone(message);
|
||||
const options = {
|
||||
id: chatNoticeToastId,
|
||||
...(action ? { action: { label: action.label, onClick: action.onClick }, duration: Infinity } : {}),
|
||||
};
|
||||
if (tone === "success") {
|
||||
toast.success(message, { id: chatNoticeToastId });
|
||||
toast.success(message, options);
|
||||
return;
|
||||
}
|
||||
if (tone === "error") {
|
||||
toast.error(message, { id: chatNoticeToastId });
|
||||
toast.error(message, options);
|
||||
return;
|
||||
}
|
||||
toast(message, { id: chatNoticeToastId });
|
||||
toast(message, options);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { logAgentObservability } from "@/lib/agent-observability";
|
||||
|
||||
export function hashedUserId(userId: string): string {
|
||||
return createHash("sha256").update(userId).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
export function logIgnoredSessionMessages(sessionId: string, userId: string, messageCount: number): void {
|
||||
logAgentObservability({
|
||||
sessionId,
|
||||
requestId: `compat-${hashedUserId(userId)}`,
|
||||
errorCode: "compat_messages_ignored",
|
||||
evidenceCount: Math.max(0, messageCount),
|
||||
});
|
||||
}
|
||||
@@ -27,16 +27,48 @@ const chatMessageSchema = z.object({
|
||||
workflowReceipt: workflowReceiptSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
const chatSessionWriteObjectSchema = z.object({
|
||||
const chartBindingSchema = {
|
||||
chart_profile_id: z.string().trim().max(100).nullable().optional(),
|
||||
chart_profile_name: z.string().trim().max(80).nullable().optional(),
|
||||
chart_profile_role: z.enum(["self", "other"]).nullable().optional(),
|
||||
};
|
||||
|
||||
export const chatSessionMetadataPatchSchema = z.object({
|
||||
title: z.string().trim().min(1).max(160).optional(),
|
||||
theme: consultationDomainSchema.optional(),
|
||||
model_id: z.string().trim().min(1).max(64).optional(),
|
||||
...chartBindingSchema,
|
||||
}).strict().refine(
|
||||
(value) => Object.values(value).some((field) => field !== undefined),
|
||||
{ message: "empty_metadata_patch" },
|
||||
);
|
||||
|
||||
export const chatSessionModelPatchSchema = z.object({
|
||||
model_id: z.string().trim().min(1).max(64),
|
||||
}).strict();
|
||||
|
||||
export const chatSessionCreateSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
title: z.string().trim().min(1).max(160),
|
||||
theme: consultationDomainSchema,
|
||||
model_id: z.string().trim().min(1).max(64),
|
||||
// Former value: `.max(CHAT_SESSION_MAX_MESSAGES)` with transcript contents.
|
||||
// Create is no longer a history-import path; only an empty array is accepted.
|
||||
messages: z.array(chatMessageSchema).max(0),
|
||||
session_type: z.enum(["consultation", "birth_time_rectification"]),
|
||||
rectification_case_id: z.string().uuid().nullable(),
|
||||
...chartBindingSchema,
|
||||
updated_at: z.string().datetime().optional(),
|
||||
}).strict();
|
||||
|
||||
const chatSessionLegacyWriteObjectSchema = z.object({
|
||||
title: z.string().trim().min(1).max(160),
|
||||
theme: consultationDomainSchema,
|
||||
model_id: z.string().trim().min(1).max(64),
|
||||
messages: z.array(chatMessageSchema).max(CHAT_SESSION_MAX_MESSAGES),
|
||||
session_type: z.enum(["consultation", "birth_time_rectification"]),
|
||||
rectification_case_id: z.string().uuid().nullable(),
|
||||
chart_profile_id: z.string().trim().max(100).nullable().optional(),
|
||||
chart_profile_name: z.string().trim().max(80).nullable().optional(),
|
||||
chart_profile_role: z.enum(["self", "other"]).nullable().optional(),
|
||||
...chartBindingSchema,
|
||||
updated_at: z.string().datetime(),
|
||||
}).strict();
|
||||
|
||||
@@ -59,12 +91,9 @@ function limitTranscriptSize<Output extends { messages: Array<{ text: string; th
|
||||
});
|
||||
}
|
||||
|
||||
export const chatSessionWriteSchema = limitTranscriptSize(chatSessionWriteObjectSchema);
|
||||
export const chatSessionCreateSchema = limitTranscriptSize(
|
||||
chatSessionWriteObjectSchema.extend({
|
||||
id: z.string().uuid(),
|
||||
}).strict(),
|
||||
);
|
||||
// Former applied write contract. PATCH still parses this shape so old bundles
|
||||
// are accepted, then the messages field is ignored rather than stored.
|
||||
export const chatSessionWriteSchema = limitTranscriptSize(chatSessionLegacyWriteObjectSchema);
|
||||
|
||||
export class ChatSessionBodyTooLargeError extends Error {
|
||||
constructor() {
|
||||
@@ -84,9 +113,39 @@ export async function readChatSessionJson(request: Request): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
export const chatSessionModelPatchSchema = z.object({
|
||||
model_id: z.string().trim().min(1).max(64),
|
||||
}).strict();
|
||||
export function extractChatSessionMetadataPatch(payload: unknown): unknown {
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
|
||||
const record = payload as Record<string, unknown>;
|
||||
const patch: Record<string, unknown> = {};
|
||||
if ("title" in record) patch.title = record.title;
|
||||
if ("theme" in record) patch.theme = record.theme;
|
||||
if ("model_id" in record) patch.model_id = record.model_id;
|
||||
if ("chart_profile_id" in record) patch.chart_profile_id = record.chart_profile_id;
|
||||
if ("chart_profile_name" in record) patch.chart_profile_name = record.chart_profile_name;
|
||||
if ("chart_profile_role" in record) patch.chart_profile_role = record.chart_profile_role;
|
||||
return patch;
|
||||
}
|
||||
|
||||
export type ChatSessionMetadataPatch = Readonly<{
|
||||
title?: string;
|
||||
theme?: ConsultationDomain;
|
||||
model_id?: string;
|
||||
chart_profile_id?: string | null;
|
||||
chart_profile_name?: string | null;
|
||||
chart_profile_role?: "self" | "other" | null;
|
||||
}>;
|
||||
|
||||
export type ChatSessionCreate = Readonly<{
|
||||
title: string;
|
||||
theme: ConsultationDomain;
|
||||
model_id: string;
|
||||
messages: readonly [];
|
||||
session_type: "consultation" | "birth_time_rectification";
|
||||
rectification_case_id: string | null;
|
||||
chart_profile_id?: string | null;
|
||||
chart_profile_name?: string | null;
|
||||
chart_profile_role?: "self" | "other" | null;
|
||||
}>;
|
||||
|
||||
export type ChatSessionWrite = Readonly<{
|
||||
title: string;
|
||||
@@ -107,7 +166,7 @@ export type ChatSessionWrite = Readonly<{
|
||||
chart_profile_id?: string | null;
|
||||
chart_profile_name?: string | null;
|
||||
chart_profile_role?: "self" | "other" | null;
|
||||
updated_at: string;
|
||||
updated_at?: string;
|
||||
}>;
|
||||
|
||||
function retryableStatus(status: number) {
|
||||
@@ -118,7 +177,7 @@ class TerminalChatSessionWriteError extends Error {}
|
||||
|
||||
export async function writeChatSession(
|
||||
id: string,
|
||||
values: ChatSessionWrite,
|
||||
values: ChatSessionCreate | ChatSessionMetadataPatch,
|
||||
mode: "create" | "update",
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const CONSULTATION_HISTORY_LIMIT = 12;
|
||||
export const CONSULTATION_HISTORY_MESSAGE_CHARS = 4_000;
|
||||
|
||||
export type ConsultationHistoryMessage = Readonly<{
|
||||
role: "user" | "assistant";
|
||||
text: string;
|
||||
}>;
|
||||
|
||||
export function consultationHistoryFromStoredMessages(
|
||||
messages: unknown,
|
||||
options: { excludeRequestId?: string } = {},
|
||||
): ConsultationHistoryMessage[] {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
const rows: ConsultationHistoryMessage[] = [];
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== "object") continue;
|
||||
const stored = message as { role?: unknown; text?: unknown; requestId?: unknown };
|
||||
if (options.excludeRequestId && stored.requestId === options.excludeRequestId) continue;
|
||||
if (stored.role !== "user" && stored.role !== "assistant") continue;
|
||||
if (typeof stored.text !== "string" || !stored.text) continue;
|
||||
rows.push({
|
||||
role: stored.role,
|
||||
text: stored.text.slice(0, CONSULTATION_HISTORY_MESSAGE_CHARS),
|
||||
});
|
||||
}
|
||||
return rows.slice(-CONSULTATION_HISTORY_LIMIT);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
begin;
|
||||
|
||||
create or replace function public.append_consultation_question(
|
||||
p_user_id uuid,
|
||||
p_request_id text,
|
||||
p_session_id uuid,
|
||||
p_question_message jsonb
|
||||
)
|
||||
returns table(success boolean, error_code text)
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
declare
|
||||
v_session public.chat_sessions%rowtype;
|
||||
v_message jsonb;
|
||||
v_text text;
|
||||
v_request_id text;
|
||||
v_count integer;
|
||||
v_chars integer;
|
||||
v_new_chars integer;
|
||||
begin
|
||||
v_request_id := btrim(coalesce(p_request_id, ''));
|
||||
if p_user_id is null or p_session_id is null or v_request_id = '' then
|
||||
return query select false, 'invalid_request'::text;
|
||||
return;
|
||||
end if;
|
||||
if jsonb_typeof(p_question_message) <> 'object'
|
||||
or p_question_message->>'role' <> 'user' then
|
||||
return query select false, 'invalid_question_message'::text;
|
||||
return;
|
||||
end if;
|
||||
v_text := btrim(coalesce(p_question_message->>'text', ''));
|
||||
if v_text = '' or char_length(v_text) > 16000 then
|
||||
return query select false, 'invalid_question_message'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
perform pg_advisory_xact_lock(hashtextextended(p_user_id::text || ':' || v_request_id, 0));
|
||||
|
||||
select session.* into v_session
|
||||
from public.chat_sessions as session
|
||||
where session.id = p_session_id
|
||||
and session.user_id = p_user_id
|
||||
and session.session_type = 'consultation'
|
||||
for update;
|
||||
|
||||
if not found then
|
||||
return query select false, 'session_missing'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
if exists (
|
||||
select 1
|
||||
from jsonb_array_elements(coalesce(v_session.messages, '[]'::jsonb)) as elem
|
||||
where elem->>'requestId' = v_request_id
|
||||
) then
|
||||
return query select true, null::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
v_count := jsonb_array_length(coalesce(v_session.messages, '[]'::jsonb));
|
||||
select coalesce(sum(
|
||||
length(coalesce(elem->>'text', ''))
|
||||
+ length(coalesce(elem->>'thinkingText', ''))
|
||||
+ case
|
||||
when elem ? 'thinkingSections' then length((elem->'thinkingSections')::text)
|
||||
else 0
|
||||
end
|
||||
), 0)
|
||||
into v_chars
|
||||
from jsonb_array_elements(coalesce(v_session.messages, '[]'::jsonb)) as elem;
|
||||
|
||||
v_new_chars := length(v_text);
|
||||
if v_count >= 200 or (v_chars + v_new_chars) > 200000 then
|
||||
return query select false, 'session_full'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
v_message := p_question_message || jsonb_build_object('requestId', v_request_id);
|
||||
|
||||
update public.chat_sessions as session
|
||||
set messages = coalesce(session.messages, '[]'::jsonb) || jsonb_build_array(v_message),
|
||||
title = case
|
||||
when coalesce(btrim(session.title), '') in ('', '新对话') then
|
||||
case
|
||||
when char_length(v_text) > 14 then left(v_text, 14) || '…'
|
||||
else v_text
|
||||
end
|
||||
else session.title
|
||||
end,
|
||||
updated_at = clock_timestamp()
|
||||
where session.id = p_session_id
|
||||
and session.user_id = p_user_id
|
||||
and session.session_type = 'consultation';
|
||||
if not found then
|
||||
return query select false, 'session_missing'::text;
|
||||
return;
|
||||
end if;
|
||||
|
||||
return query select true, null::text;
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.append_consultation_question(uuid, text, uuid, jsonb)
|
||||
from public, anon, authenticated;
|
||||
grant execute on function public.append_consultation_question(uuid, text, uuid, jsonb)
|
||||
to service_role;
|
||||
do $$ begin
|
||||
if exists(select 1 from pg_roles where rolname = 'admin_runtime') then
|
||||
grant execute on function public.append_consultation_question(uuid, text, uuid, jsonb)
|
||||
to admin_runtime;
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
commit;
|
||||
@@ -19,7 +19,9 @@ function sourceBetween(source: string, start: string, end: string): string {
|
||||
test("Agentic rectification reuses one case-level usage authorization and the session-pinned model version", () => {
|
||||
assert.match(rectificationRoute, /import \{ createServerSupabaseClient \} from "@\/lib\/supabase\/server"/);
|
||||
assert.match(rectificationRoute, /supabase = await createServerSupabaseClient\(\)/);
|
||||
assert.match(rectificationRoute, /select\("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
|
||||
// Former value: select("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id")
|
||||
// Messages were unused on this route and inflated every rectification turn.
|
||||
assert.match(rectificationRoute, /select\("id,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
|
||||
assert.match(rectificationRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
|
||||
assert.match(rectificationRoute, /modelConfigVersion: selectedModel\.configVersion/);
|
||||
assert.doesNotMatch(rectificationRoute, /loadLanguageModelCatalog|resolveLanguageModelFromCatalog|\bresolveLanguageModel\(|\bdefaultLanguageModel\(/);
|
||||
@@ -66,7 +68,9 @@ test("free Agentic rectification turns bypass reservation, completion, and cance
|
||||
|
||||
test("standard consultation resolves and settles the session-pinned model version", () => {
|
||||
assert.match(consultRoute, /sessionId: z\.string\(\)\.uuid\(\)/);
|
||||
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type"\)/);
|
||||
// Former value: select("id,model_id,model_config_version,session_type") without messages.
|
||||
// Task 2 reads the last 12 stored messages as model history, so this select now includes messages.
|
||||
assert.match(consultRoute, /select\("id,model_id,model_config_version,session_type,messages"\)/);
|
||||
assert.match(consultRoute, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
|
||||
assert.match(consultRoute, /actualModelId: selectedModel\.id/);
|
||||
assert.match(consultRoute, /modelConfigVersion: selectedModel\.configVersion/);
|
||||
|
||||
@@ -25,13 +25,13 @@ test("routes composer notices to the user instead of discarding them", () => {
|
||||
|
||||
// Then: every notice reaches the mounted sonner toaster.
|
||||
assert.match(noticeSource, /import \{ toast \} from "sonner"/);
|
||||
assert.match(noticeSource, /toast\.success\(message, \{ id: chatNoticeToastId \}\)/);
|
||||
assert.match(noticeSource, /toast\.error\(message, \{ id: chatNoticeToastId \}\)/);
|
||||
assert.match(noticeSource, /toast\(message, \{ id: chatNoticeToastId \}\)/);
|
||||
assert.match(noticeSource, /toast\.success\(message, options\)/);
|
||||
assert.match(noticeSource, /toast\.error\(message, options\)/);
|
||||
assert.match(noticeSource, /toast\(message, options\)/);
|
||||
});
|
||||
|
||||
test("clearing a notice dismisses instead of showing an empty toast", () => {
|
||||
const clearBranch = sourceBetween(noticeSource, "if (!message.trim())", "if (lastNotice === message) return;");
|
||||
const clearBranch = sourceBetween(noticeSource, "if (!message.trim())", "if (lastNotice === message && !action) return;");
|
||||
|
||||
assert.match(clearBranch, /toast\.dismiss\(chatNoticeToastId\)/);
|
||||
assert.doesNotMatch(clearBranch, /toast\(|toast\.success|toast\.error/);
|
||||
@@ -44,7 +44,7 @@ test("keeps the recovery poll from stacking repeated notices", () => {
|
||||
|
||||
// Then: a stable toast id plus last-message dedupe replaces instead of accumulating.
|
||||
assert.match(noticeSource, /export const chatNoticeToastId = "chat-notice"/);
|
||||
assert.match(noticeSource, /if \(lastNotice === message\) return;/);
|
||||
assert.match(noticeSource, /if \(lastNotice === message && !action\) return;/);
|
||||
});
|
||||
|
||||
test("assigns notice severity by message intent", () => {
|
||||
@@ -56,6 +56,7 @@ test("assigns notice severity by message intent", () => {
|
||||
assert.equal(noticeTone("回答已恢复。"), "success");
|
||||
assert.equal(noticeTone("已归档,可在左侧归档中恢复。"), "success");
|
||||
assert.equal(noticeTone("已停止回答,现有内容已保留,本次点数已退回。"), "success");
|
||||
assert.equal(noticeTone("这段对话已写满,开个新对话继续吧"), "error");
|
||||
assert.equal(noticeTone("删除失败:网络异常"), "error");
|
||||
assert.equal(noticeTone("重命名同步失败"), "error");
|
||||
assert.equal(noticeTone("模型服务暂时不可用,当前无法发送问题。"), "error");
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const listRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
||||
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
|
||||
const consultRoute = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
const sql = readFileSync(
|
||||
new URL("../supabase/migrations/20260901010000_append_consultation_question.sql", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const sendSource = page.slice(page.indexOf(" async function send("), page.indexOf("\n\n consultationReplay.current"));
|
||||
|
||||
test("session list GET omits messages while detail GET returns them", () => {
|
||||
assert.match(
|
||||
listRoute,
|
||||
/SESSION_LIST_COLUMNS = "id,title,theme,model_id,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at"/,
|
||||
);
|
||||
assert.doesNotMatch(listRoute, /select\(SESSION_LIST_COLUMNS\)[\s\S]*messages/);
|
||||
assert.match(itemRoute, /export async function GET/);
|
||||
assert.match(
|
||||
itemRoute,
|
||||
/sessionSelect = "id,title,theme,model_id,messages,session_type,rectification_case_id,chart_profile_id,chart_profile_name,chart_profile_role,updated_at"/,
|
||||
);
|
||||
assert.match(page, /async function fetchSessionDetail\(/);
|
||||
assert.match(page, /async function ensureSessionMessages\(/);
|
||||
assert.match(page, /session-messages-loading/);
|
||||
assert.match(page, /messagesHydrated: messagesPresent/);
|
||||
});
|
||||
|
||||
test("consult reads stored history and never applies the client history field", () => {
|
||||
assert.match(consultRoute, /history: z\s*\.array\([\s\S]*?\)\s*\.max\(20\)\s*\.optional\(\)\s*\.default\(\[\]\)/);
|
||||
assert.match(consultRoute, /const storedHistory = consultationHistoryFromStoredMessages\(chatSession\.messages\)/);
|
||||
assert.match(consultRoute, /const history = storedHistory/);
|
||||
assert.doesNotMatch(consultRoute, /parsed\.data\.history/);
|
||||
assert.match(sendSource, /history: currentSession\.messages\.slice\(-12\)/);
|
||||
});
|
||||
|
||||
test("consult appends the user question after reserve and returns session_full", () => {
|
||||
assert.match(consultRoute, /accounting\.rpc\("append_consultation_question"/);
|
||||
assert.match(consultRoute, /error_code === "session_full"/);
|
||||
assert.match(consultRoute, /code: "session_full"/);
|
||||
assert.match(consultRoute, /这段对话已写满,开个新对话继续吧/);
|
||||
assert.match(sql, /create or replace function public\.append_consultation_question/);
|
||||
assert.match(sql, /pg_advisory_xact_lock/);
|
||||
assert.match(sql, /session_type = 'consultation'/);
|
||||
assert.match(sql, /'session_full'::text/);
|
||||
assert.match(sql, /grant execute on function public\.append_consultation_question/);
|
||||
assert.match(sendSource, /caught\.code === "session_full"/);
|
||||
assert.match(sendSource, /label: "开新对话"/);
|
||||
assert.match(sendSource, /continueInNewChat\(\{ question: originalQuestion, theme \}\)/);
|
||||
});
|
||||
|
||||
test("PATCH compatibility accepts and ignores a legacy messages write", () => {
|
||||
assert.match(itemRoute, /logIgnoredSessionMessages\(id, user\.id, ignoredMessageCount\(payload\)\)/);
|
||||
assert.match(itemRoute, /if \(!values && payloadHasMessages\(payload\)\) \{/);
|
||||
assert.match(itemRoute, /return NextResponse\.json\(\{ ok: true \}\)/);
|
||||
assert.doesNotMatch(itemRoute, /\.update\(\{[\s\S]*messages:/);
|
||||
});
|
||||
@@ -13,17 +13,40 @@ const values = {
|
||||
rectification_case_id: null,
|
||||
updated_at: "2026-07-22T00:00:00.000Z",
|
||||
} satisfies ChatSessionWrite;
|
||||
const createValues = {
|
||||
title: values.title,
|
||||
theme: values.theme,
|
||||
model_id: values.model_id,
|
||||
messages: [] as const,
|
||||
session_type: values.session_type,
|
||||
rectification_case_id: values.rectification_case_id,
|
||||
};
|
||||
const metadataPatch = {
|
||||
title: values.title,
|
||||
theme: values.theme,
|
||||
model_id: values.model_id,
|
||||
};
|
||||
|
||||
|
||||
test("create schema keeps the client-generated session id after transcript limits", () => {
|
||||
const parsed = chatSessionCreateSchema.parse({
|
||||
id: sessionId,
|
||||
...values,
|
||||
...createValues,
|
||||
});
|
||||
const id: string = parsed.id;
|
||||
assert.equal(id, sessionId);
|
||||
assert.equal(parsed.title, values.title);
|
||||
assert.equal(parsed.messages[0]?.text, "你好");
|
||||
assert.equal(parsed.messages.length, 0);
|
||||
});
|
||||
|
||||
test("create schema rejects a non-empty transcript", () => {
|
||||
// Former value: create accepted up to CHAT_SESSION_MAX_MESSAGES and was a
|
||||
// history-import path. Create is now only an empty session.
|
||||
const parsed = chatSessionCreateSchema.safeParse({
|
||||
id: sessionId,
|
||||
...values,
|
||||
});
|
||||
assert.equal(parsed.success, false);
|
||||
});
|
||||
|
||||
test("chat session schema accepts chart profile snapshots and keeps legacy writes valid", () => {
|
||||
@@ -90,7 +113,7 @@ test("chat session schema keeps structured thinking sections on assistant messag
|
||||
|
||||
test("chat session writes use same-origin API instead of browser-to-Supabase requests", async () => {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
await writeChatSession(sessionId, values, "update", async (url, init) => {
|
||||
await writeChatSession(sessionId, metadataPatch, "update", async (url, init) => {
|
||||
calls.push({ url: String(url), init });
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
@@ -99,12 +122,13 @@ test("chat session writes use same-origin API instead of browser-to-Supabase req
|
||||
assert.equal(calls[0]?.url, `/api/sessions/${sessionId}`);
|
||||
assert.equal(calls[0]?.init?.method, "PATCH");
|
||||
assert.equal(calls[0]?.init?.credentials, "same-origin");
|
||||
assert.equal(JSON.parse(String(calls[0]?.init?.body)).messages, undefined);
|
||||
});
|
||||
|
||||
test("transient Load failed is retried and never exposed as raw browser copy", async () => {
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
writeChatSession(sessionId, values, "update", async () => {
|
||||
writeChatSession(sessionId, metadataPatch, "update", async () => {
|
||||
attempts += 1;
|
||||
throw new TypeError("Load failed");
|
||||
}),
|
||||
@@ -117,7 +141,7 @@ test("transient Load failed is retried and never exposed as raw browser copy", a
|
||||
test("owner or validation failures are not retried", async () => {
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
writeChatSession(sessionId, values, "update", async () => {
|
||||
writeChatSession(sessionId, metadataPatch, "update", async () => {
|
||||
attempts += 1;
|
||||
return Response.json({ error: "聊天记录不存在或已被删除" }, { status: 404 });
|
||||
}),
|
||||
@@ -148,21 +172,30 @@ test("session API owns create and update while answer UI keeps sync failures out
|
||||
const page = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8");
|
||||
const collectionRoute = readFileSync(new URL("../src/app/api/sessions/route.ts", import.meta.url), "utf8");
|
||||
const itemRoute = readFileSync(new URL("../src/app/api/sessions/[id]/route.ts", import.meta.url), "utf8");
|
||||
const observability = readFileSync(new URL("../src/lib/chat-session-observability.ts", import.meta.url), "utf8");
|
||||
const contract = readFileSync(new URL("../src/lib/chat-session-write-contract.ts", import.meta.url), "utf8");
|
||||
const migration = readFileSync(new URL("../supabase/migrations/20260830010000_chat_session_chart_profile_binding.sql", import.meta.url), "utf8");
|
||||
|
||||
assert.match(page, /thinkingText: message\.thinkingText/);
|
||||
assert.match(page, /thinkingSections: message\.thinkingSections/);
|
||||
assert.match(page, /writeChatSession\(session\.id, values, mode\)/);
|
||||
// Former value: persistSession mapped thinkingText/thinkingSections into a
|
||||
// client transcript PATCH. Messages are now server-appended; this client
|
||||
// write is metadata only.
|
||||
assert.doesNotMatch(page, /thinkingText: message\.thinkingText/);
|
||||
assert.doesNotMatch(page, /thinkingSections: message\.thinkingSections/);
|
||||
assert.match(page, /messages: \[\] as const/);
|
||||
assert.doesNotMatch(page, /云端同步失败.*回答仍保留在当前页面/);
|
||||
assert.match(collectionRoute, /export async function POST/);
|
||||
assert.match(itemRoute, /export async function GET/);
|
||||
assert.match(itemRoute, /export async function PATCH/);
|
||||
assert.match(itemRoute, /logIgnoredSessionMessages/);
|
||||
assert.match(observability, /compat_messages_ignored/);
|
||||
assert.match(itemRoute, /\.eq\("user_id", user\.id\)/);
|
||||
assert.match(collectionRoute, /readChatSessionJson/);
|
||||
assert.match(itemRoute, /readChatSessionJson/);
|
||||
assert.match(collectionRoute, /ChatSessionBodyTooLargeError/);
|
||||
assert.match(itemRoute, /ChatSessionBodyTooLargeError/);
|
||||
assert.match(collectionRoute, /const \{ id, \.\.\.values \} = parsed\.data/);
|
||||
// Former value: `const { id, ...values } = parsed.data` trusted the client clock.
|
||||
assert.match(collectionRoute, /const \{ id, updated_at: _ignoredClientClock, \.\.\.values \} = parsed\.data/);
|
||||
assert.match(contract, /function limitTranscriptSize<Output extends \{ messages: Array<\{ text: string; thinkingText\?: string; thinkingSections\?: unknown \}> \}>/);
|
||||
assert.match(contract, /\): z\.ZodType<Output> \{/);
|
||||
assert.match(page, /chartSnapshotForSession/);
|
||||
|
||||
@@ -6,13 +6,16 @@ const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "ut
|
||||
const sendSource = source.slice(source.indexOf(" async function send("), source.indexOf("\n\n consultationReplay.current"));
|
||||
const stopSource = source.slice(source.indexOf(" async function stopResponse("), source.indexOf("\n\n function completeConsultationInterface"));
|
||||
|
||||
test("consultation persists the optimistic user message before generation starts", () => {
|
||||
test("consultation does not persist the optimistic user message before generation starts", () => {
|
||||
// Former value: `await persistSession(userSession)` ran after the undo window and
|
||||
// before `/api/consult`, locking the client full-replace write. The server now
|
||||
// appends the question after a successful reserve.
|
||||
const undo = sendSource.indexOf("await waitForUndoWindow(controller.signal)");
|
||||
const persist = sendSource.indexOf("await persistSession(userSession)");
|
||||
const consult = sendSource.indexOf('fetch("/api/consult"');
|
||||
|
||||
assert.ok(undo >= 0 && undo < persist && persist < consult);
|
||||
assert.match(sendSource, /问题保存失败,未开始生成;问题已放回输入框。[\s\S]*?return false;[\s\S]*?fetch\("\/api\/consult"/);
|
||||
assert.ok(undo >= 0 && undo < consult);
|
||||
assert.equal(sendSource.indexOf("await persistSession(userSession)"), -1);
|
||||
assert.doesNotMatch(sendSource, /问题保存失败,未开始生成;问题已放回输入框。/);
|
||||
});
|
||||
|
||||
test("only explicit stop can request consultation cancellation", () => {
|
||||
@@ -30,15 +33,16 @@ test("durable partial stop cancels before preserving content and recovers comple
|
||||
stopSource.indexOf("\n updateSession(pending.sessionId, () => pending.previousSession)"),
|
||||
);
|
||||
const cancel = partialStop.indexOf("await requestCancellation(pending.requestId)");
|
||||
const persist = partialStop.indexOf("await persistSession(stoppedSession)");
|
||||
const conflict = partialStop.indexOf("error.status === 409");
|
||||
const recoveryPersist = partialStop.slice(conflict, persist);
|
||||
const recoveryPersist = partialStop.slice(conflict);
|
||||
|
||||
assert.ok(cancel >= 0 && cancel < persist);
|
||||
assert.ok(conflict >= 0 && conflict < persist);
|
||||
assert.ok(cancel >= 0 && conflict > cancel);
|
||||
// Former value: persistSession(stoppedSession) after cancel. Refunded partial
|
||||
// answers stay in page memory only and must not overwrite the server transcript.
|
||||
assert.equal(partialStop.indexOf("await persistSession(stoppedSession)"), -1);
|
||||
assert.doesNotMatch(recoveryPersist, /persistSession\(stoppedSession\)/);
|
||||
assert.match(recoveryPersist, /fetchConsultationStatus\(pending.sessionId, pending.requestId\)/);
|
||||
assert.match(recoveryPersist, /status.status === "completed"[\s\S]*?fetchSessions\(\)[\s\S]*?readSessions\(payload, modelCatalog\)/);
|
||||
assert.match(recoveryPersist, /status.status === "completed"[\s\S]*?fetchSessionDetail\(pending.sessionId, modelCatalog\)/);
|
||||
assert.match(partialStop, /已停止回答,现有内容已保留,本次点数已退回。/);
|
||||
assert.match(source, /停止回答,保留已生成内容并退回本次点数/);
|
||||
assert.match(source, /停止回答,保留现有内容并申请退回本次点数/);
|
||||
@@ -66,7 +70,7 @@ test("reserved consultations recover through the status endpoint", () => {
|
||||
assert.match(source, /readonly responseMessage\?: unknown/);
|
||||
assert.match(source, /phase: "recovering"/);
|
||||
assert.match(source, /window\.setTimeout\(\(\) => void poll\(\), 1_750\)/);
|
||||
assert.match(source, /status\.status === "completed"[\s\S]*?fetchSessions\(controller\.signal\)[\s\S]*?readSessions\(payload, modelCatalog\)/);
|
||||
assert.match(source, /status\.status === "completed"[\s\S]*?fetchSessionDetail\(pendingSessionId, modelCatalog, controller\.signal\)/);
|
||||
assert.match(source, /window\.addEventListener\("online", onOnline\)/);
|
||||
assert.match(source, /window\.addEventListener\("pageshow", onPageShow\)/);
|
||||
assert.match(source, /网络已断开,回答仍在后台生成;联网后会自动恢复。/);
|
||||
@@ -128,7 +132,10 @@ test("the first default consultation title is persisted with the user question",
|
||||
sendSource.indexOf("const requestId = resumeRequestId ?? globalThis.crypto.randomUUID()"),
|
||||
);
|
||||
assert.match(userSessionBlock, /currentSession\.messages\.length === 0 && isGenericSessionTitle\(currentSession\.title\)[\s\S]*resolveSessionTitle\(question/);
|
||||
assert.ok(sendSource.indexOf("await persistSession(userSession)") < sendSource.indexOf('fetch("/api/consult"'));
|
||||
// Former value: persistSession(userSession) before consult wrote the title by
|
||||
// replacing the whole messages array. Title now comes from SQL append, and the
|
||||
// completed metadata patch only updates title/theme/model/chart binding.
|
||||
assert.equal(sendSource.indexOf("await persistSession(userSession)"), -1);
|
||||
assert.match(sendSource, /await persistSession\(completedSession\)/);
|
||||
assert.match(sendSource, /const completedTitle = reply\.title && !isGenericSessionTitle\(reply\.title\)/);
|
||||
assert.match(sendSource, /resolveSessionTitle\(question, reply\.title/);
|
||||
@@ -139,7 +146,8 @@ test("a truncated generation keeps the partial answer and does not wait for a su
|
||||
assert.match(stream, /event\.code === "answer_truncated"/);
|
||||
assert.match(stream, /truncatedFailure = event/);
|
||||
assert.match(stream, /const truncatedSession: ChatSession = \{[\s\S]*role: "assistant"[\s\S]*text: reply\.text/);
|
||||
assert.match(stream, /await persistSession\(truncatedSession\)/);
|
||||
// Former value: persistSession(truncatedSession) saved a refunded partial answer.
|
||||
assert.doesNotMatch(stream, /await persistSession\(truncatedSession\)/);
|
||||
assert.match(stream, /setComposerNotice\(truncatedFailure\.message\)/);
|
||||
assert.match(stream, /if \(!runCompleted && !truncatedFailure\) \{\s*throw new ConsultationResponseError/);
|
||||
assert.doesNotMatch(stream.slice(stream.indexOf("if (truncatedFailure)")), /runCompleted = true/);
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { consultationHistoryFromStoredMessages } from "../src/lib/consultation-session-history.ts";
|
||||
|
||||
test("stored consultation history keeps the last 12 role/text pairs and clips text", () => {
|
||||
const history = consultationHistoryFromStoredMessages([
|
||||
{ role: "system", text: "ignore" },
|
||||
{ role: "user", text: "first" },
|
||||
...Array.from({ length: 12 }, (_, index) => ({
|
||||
role: index % 2 === 0 ? "assistant" : "user",
|
||||
text: `keep-${index}`,
|
||||
requestId: `req-${index}`,
|
||||
})),
|
||||
{ role: "assistant", text: "x".repeat(4001), thinkingText: "secret" },
|
||||
]);
|
||||
|
||||
assert.equal(history.length, 12);
|
||||
assert.equal(history[0]?.text, "keep-1");
|
||||
assert.equal(history.at(-1)?.text.length, 4000);
|
||||
assert.deepEqual(history.at(-2), { role: "user", text: "keep-11" });
|
||||
});
|
||||
|
||||
test("stored consultation history can skip the in-flight request id", () => {
|
||||
const history = consultationHistoryFromStoredMessages([
|
||||
{ role: "user", text: "old", requestId: "keep" },
|
||||
{ role: "user", text: "current", requestId: "skip" },
|
||||
], { excludeRequestId: "skip" });
|
||||
|
||||
assert.deepEqual(history, [{ role: "user", text: "old" }]);
|
||||
});
|
||||
@@ -54,7 +54,9 @@ test("persists partial transformed output when the upstream stream errors", () =
|
||||
test("Agentic failures always refund and detached execution uses a server-owned timeout", () => {
|
||||
const agentic = consultRoute.slice(
|
||||
consultRoute.indexOf("async function runAgenticConsultation("),
|
||||
consultRoute.indexOf(" try {\n const { history } = parsed.data;"),
|
||||
// Former value: `const { history } = parsed.data` marked the end of the
|
||||
// agentic function. History is now copied from stored session messages.
|
||||
consultRoute.indexOf(" try {\n const history = storedHistory;"),
|
||||
);
|
||||
// The value lives beside the model step budget and the domain cap it funds,
|
||||
// so the route imports it rather than restating it.
|
||||
|
||||
@@ -108,7 +108,9 @@ test("personal consultation lets the Agent invoke the server-bound workflow tool
|
||||
const agenticStart = route.indexOf("async function runAgenticConsultation");
|
||||
const agenticBranch = route.slice(
|
||||
agenticStart,
|
||||
route.indexOf(" const { history } = parsed.data;", agenticStart),
|
||||
// Former value: `const { history } = parsed.data;` ended the agentic function.
|
||||
// History now comes from stored session messages, not the client field.
|
||||
route.indexOf(" const history = storedHistory;", agenticStart),
|
||||
);
|
||||
assert.match(agenticBranch, /createConsultationAgentContext/);
|
||||
assert.match(agenticBranch, /getJyotishAgent\(selectedModel, agentContext\)/);
|
||||
|
||||
@@ -86,6 +86,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
assert.match(migration.stdout, /applied 20260824020000_rectification_choice_action\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260824030000_rectification_turn_origin\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260831020000_feature_pricing_admin_runtime_read_policy\.sql/);
|
||||
assert.match(migration.stdout, /applied 20260901010000_append_consultation_question\.sql/);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
@@ -922,6 +923,108 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
|
||||
null,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select
|
||||
has_function_privilege(
|
||||
'service_role',
|
||||
'public.append_consultation_question(uuid, text, uuid, jsonb)',
|
||||
'execute'
|
||||
) || ':' ||
|
||||
has_function_privilege(
|
||||
'authenticated',
|
||||
'public.append_consultation_question(uuid, text, uuid, jsonb)',
|
||||
'execute'
|
||||
) || ':' ||
|
||||
has_function_privilege(
|
||||
'anon',
|
||||
'public.append_consultation_question(uuid, text, uuid, jsonb)',
|
||||
'execute'
|
||||
)
|
||||
`),
|
||||
"true:f:f",
|
||||
);
|
||||
|
||||
const consultationSessionId = "88888888-8888-4888-8888-888888888888";
|
||||
fixture.psql(`
|
||||
insert into public.chat_sessions (
|
||||
id, user_id, title, theme, model_id, messages, session_type, updated_at
|
||||
) values (
|
||||
'${consultationSessionId}', '${userId}', '新对话', 'general',
|
||||
'test-model', '[]', 'consultation', now()
|
||||
);
|
||||
`);
|
||||
const firstQuestion = await admin.rpc("append_consultation_question", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "append-request-1",
|
||||
p_session_id: consultationSessionId,
|
||||
p_question_message: { role: "user", text: "第一问会不会丢" },
|
||||
});
|
||||
assert.equal(firstQuestion.error, null, rpcError(firstQuestion.error));
|
||||
const firstQuestionRow = Array.isArray(firstQuestion.data) ? firstQuestion.data[0] : firstQuestion.data;
|
||||
assert.equal((firstQuestionRow as { success?: unknown }).success, true);
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select title || ':' || jsonb_array_length(messages)::text || ':' || (messages->0->>'text')
|
||||
from public.chat_sessions where id = '${consultationSessionId}'
|
||||
`),
|
||||
"第一问会不会丢:1:第一问会不会丢",
|
||||
);
|
||||
|
||||
const firstQuestionReplay = await admin.rpc("append_consultation_question", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "append-request-1",
|
||||
p_session_id: consultationSessionId,
|
||||
p_question_message: { role: "user", text: "不该写入的重复提问" },
|
||||
});
|
||||
assert.equal(firstQuestionReplay.error, null, rpcError(firstQuestionReplay.error));
|
||||
const firstQuestionReplayRow = Array.isArray(firstQuestionReplay.data) ? firstQuestionReplay.data[0] : firstQuestionReplay.data;
|
||||
assert.equal((firstQuestionReplayRow as { success?: unknown }).success, true);
|
||||
assert.equal(
|
||||
fixture.psql(`select jsonb_array_length(messages) from public.chat_sessions where id = '${consultationSessionId}'`),
|
||||
"1",
|
||||
);
|
||||
|
||||
const secondQuestion = await admin.rpc("append_consultation_question", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "append-request-2",
|
||||
p_session_id: consultationSessionId,
|
||||
p_question_message: { role: "user", text: "第二问也会留下" },
|
||||
});
|
||||
assert.equal(secondQuestion.error, null, rpcError(secondQuestion.error));
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select jsonb_array_length(messages)::text || ':' || (messages->0->>'text') || ':' || (messages->1->>'text')
|
||||
from public.chat_sessions where id = '${consultationSessionId}'
|
||||
`),
|
||||
"2:第一问会不会丢:第二问也会留下",
|
||||
);
|
||||
|
||||
fixture.psql(`
|
||||
update public.chat_sessions
|
||||
set messages = (
|
||||
select coalesce(jsonb_agg(jsonb_build_object('role','user','text','x','requestId', n::text)), '[]'::jsonb)
|
||||
from generate_series(1, 200) as n
|
||||
)
|
||||
where id = '${consultationSessionId}'
|
||||
`);
|
||||
const sessionFull = await admin.rpc("append_consultation_question", {
|
||||
p_user_id: userId,
|
||||
p_request_id: "append-request-full",
|
||||
p_session_id: consultationSessionId,
|
||||
p_question_message: { role: "user", text: "满了就不能再写" },
|
||||
});
|
||||
assert.equal(sessionFull.error, null, rpcError(sessionFull.error));
|
||||
const sessionFullRow = Array.isArray(sessionFull.data) ? sessionFull.data[0] : sessionFull.data;
|
||||
assert.deepEqual(sessionFullRow, { success: false, error_code: "session_full" });
|
||||
assert.equal(
|
||||
fixture.psql(`
|
||||
select jsonb_array_length(messages)
|
||||
from public.chat_sessions where id = '${consultationSessionId}'
|
||||
`),
|
||||
"200",
|
||||
);
|
||||
|
||||
fixture.psql(`
|
||||
insert into public.redemption_codes (code_hash, code_mask, credits)
|
||||
values ('${"a".repeat(64)}', 'JYOTISH-****-TEST', 3)
|
||||
|
||||
@@ -270,7 +270,8 @@ test("opening and message retries reuse the caller-owned request id as the usage
|
||||
});
|
||||
|
||||
test("rectification uses one case-level entitlement and the session-pinned model version", () => {
|
||||
assert.match(route, /select\("id,messages,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
|
||||
// Former value: select included unused `messages` and pulled up to 500KB per turn.
|
||||
assert.match(route, /select\("id,session_type,model_id,model_config_version,agentic_rectification_case_id"\)/);
|
||||
assert.match(route, /resolveSessionLanguageModel\(\s*chatSession\.model_id,\s*chatSession\.model_config_version,?\s*\)/);
|
||||
assert.match(route, /const billingRequestPrefix = `rectification:case:\$\{caseId\}`/);
|
||||
assert.match(route, /requestId: billingRequestId/);
|
||||
|
||||
+22
-1
@@ -1069,4 +1069,25 @@
|
||||
- 建议在数据库 migration 成功后运行独立的环境 readiness 脚本,不放在应用启动期,避免每个实例重复探测或因业务配置缺失阻止无关只读能力启动。
|
||||
- readiness 应枚举当前环境所有已发布模型的 `model_tier`,逐一检查 `rectification`、`chat.standard`、`report.full` 三个 feature 是否存在 `status='published' AND enabled=true` 的定价;任何组合缺失即以脱敏、可行动的运维错误 fail closed。
|
||||
- readiness 只负责验证,不写入或 seed 价格;部署后仍必须通过 admin flow 为实际 tier 发布定价,随后再重跑 readiness。
|
||||
- 本地验证:TypeScript、改动文件 ESLint、rectification `705/705`、consult/report 聚焦 `262/262` 通过;全量 `2368/2369`,唯一失败是默认 `python3` 缺少 PyYAML,改用已安装 PyYAML 的 `/opt/homebrew/bin/python3` 后对应文件 `39/39` 通过。
|
||||
- 本地验证:
|
||||
- `./node_modules/.bin/tsc --noEmit` 通过。
|
||||
- 非数据库前端测试 **2377 通过 / fail=0 / skipped=0**。
|
||||
- `npm run test:db -- --test-concurrency=1`:**34 通过 / fail=0 / skipped=0**(含 `append_consultation_question` 幂等、双 request_id 叠加、`session_full`)。
|
||||
- `npm test` 会并行拉起多个 Postgres fixture,本机 Docker address pool 只能同时容纳 2 个 compose network,并行时会超时,不是本轮断言回归。验收用上面两项拆开跑。
|
||||
- 浅色/深色:加载态 `.session-messages-loading` 使用 `color: var(--color-ink-secondary)`,spinner 的 `currentColor` 跟随该 token(浅色 `#5f5f59`,深色 `#b3afa4`)。`session_full` 走 sonner toast,背景/文字用 `--color-canvas` / `--color-ink`,错误语气由「已写满」命中 `failedNotice`。未登录所以没有做带真实会话的页面点击;主题检查落在 token 与合同测试上。
|
||||
|
||||
## 2026-09-01 - TASK-chat-message-authority:服务端收回 messages 写权
|
||||
|
||||
- 列表 `GET /api/sessions` 不再选择 `messages`;新增 `GET /api/sessions/[id]` 按会话加载完整消息。前端启动只拉列表,切换时拉详情,已加载的会话内存缓存,正在 streaming 的会话不重拉。
|
||||
- `append_consultation_question` 在 reserve 成功后写入用户提问;满员返回 `session_full` 并取消预扣。consult 忽略客户端 `history`,改读库内最近 12 条。
|
||||
- `persistSession` 只 PATCH 元数据。旧全量 `messages` PATCH 返回 200 且不覆盖库。中止/失败部分回答不再落库。
|
||||
- 断言例外(锁住的正是本轮要修的缺陷):
|
||||
- `consultation-recovery.test.ts`:不再要求发问前 `persistSession(userSession)`,也不再要求中止/截断路径持久化部分回答;完成恢复改拉详情而不是整表列表。
|
||||
- `chat-session-write.test.ts`:create schema `messages.max(0)`;update 请求体不含 `messages`;页面不再把 thinkingText 映射进 PATCH。
|
||||
- `application-billing-contract.test.ts` / `rectification-agentic-entry.test.ts`:校正 agent select 去掉未使用的 `messages`;consult select 增加 `messages` 以便服务端读历史。
|
||||
- `consultation-stream-recovery.test.ts` / `consultation-workflow-contract.test.ts`:agentic 函数切片终点从 `parsed.data.history` 改为 `storedHistory`。
|
||||
- `chat-notice-and-scroll-contract.test.ts`:toast 支持一键开新对话的 action。
|
||||
- 实测:
|
||||
- Payload:列表响应列集合为 `id,title,theme,model_id,session_type,rectification_case_id,chart_profile_* ,updated_at`,消息文本只能出现在详情 GET。多会话账户的字节对比取决于该账户存量消息,合同测试锁的是“列表不含消息文本”这一结构,而不是某一个登录态的绝对字节数。
|
||||
- `session_full`:本地 PostgreSQL 把会话填到 200 条后再 append,返回 `error_code=session_full`,行内消息数仍为 200。
|
||||
- 双标签页:同一会话两次 `append_consultation_question`(不同 request_id)后两条用户消息都在;这是改前 last-write-wins 必丢、改后必留的核心形状。
|
||||
|
||||
Reference in New Issue
Block a user