fix(web): persist thinking, title sessions distinctly, and send follow-ups from the answer

Thinking disappeared on failure and never reached session storage. Keep the
sanitized chain on disk and on errors, and regroup the sidebar around reports,
charts, favorites, and dated history titles.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-22 09:10:47 +08:00
co-authored by Cursor
parent 649ba32034
commit 59559d4b24
27 changed files with 741 additions and 179 deletions
+68 -4
View File
@@ -1,9 +1,26 @@
import type { ConsultationDomain } from "./consultation-domain-registry.ts";
import { consultationDomainDefinition } from "./consultation-domain-registry.ts";
import type { ConsultationEntrypoint } from "./consultation-entrypoint.ts";
import {
isGeneralDailyFortuneQuestion,
isRectificationHandoffQuestion,
} from "./consultation-entrypoint.ts";
export type ReplyTheme = ConsultationDomain;
import type { ConsultationReplyMetadata } from "./consultation-reply-metadata.ts";
const GENERIC_SESSION_TITLES = new Set([
"新对话",
"深入看今日",
"从今日问起",
"查看今日运势",
"生时校正",
"再次校正",
"一般占星咨询",
"深入看今日主题咨询",
]);
function readTitle(value: string): string | undefined {
const title = value.replace(/\s+/g, " ").trim();
if (!title || /[\d\p{P}\p{S}]/u.test(title)) return undefined;
@@ -37,10 +54,57 @@ export function parseAgentReply(value: string, metadata?: ConsultationReplyMetad
return { text: parsed.text, title: metadata?.title ?? parsed.title };
}
export function resolveSessionTitle(question: string, modelTitle?: string): string {
if (modelTitle && modelTitle !== "一般占星咨询") return modelTitle;
export type SessionTitleOptions = {
readonly entrypoint?: ConsultationEntrypoint | null;
readonly theme?: ConsultationDomain;
readonly at?: Date;
readonly existingTitles?: readonly string[];
};
export function isGenericSessionTitle(title: string): boolean {
const text = title.replace(/\s+/g, " ").trim();
if (!text) return true;
if (GENERIC_SESSION_TITLES.has(text)) return true;
return /^(?:深入看今日|从今日问起|查看今日运势|生时校正|再次校正)/.test(text);
}
function clipTitle(value: string, maxChars = 14): string {
const characters = Array.from(value);
return characters.length > maxChars ? `${characters.slice(0, maxChars).join("")}` : value;
}
function datedSessionTitle(at: Date, suffix: string): string {
return `${at.getMonth() + 1}${at.getDate()}日 · ${suffix}`;
}
function uniquifySessionTitle(title: string, existingTitles: readonly string[], at: Date): string {
if (!existingTitles.includes(title)) return title;
const hours = String(at.getHours()).padStart(2, "0");
const minutes = String(at.getMinutes()).padStart(2, "0");
return `${title} ${hours}:${minutes}`;
}
export function resolveSessionTitle(
question: string,
modelTitle?: string,
options: SessionTitleOptions = {},
): string {
const at = options.at ?? new Date();
const existingTitles = options.existingTitles ?? [];
if (modelTitle && !isGenericSessionTitle(modelTitle)) {
return uniquifySessionTitle(clipTitle(modelTitle), existingTitles, at);
}
if (options.entrypoint === "daily_starlanguage" || isGeneralDailyFortuneQuestion(question)) {
return uniquifySessionTitle(datedSessionTitle(at, "今日节奏"), existingTitles, at);
}
if (options.entrypoint === "birth_time_rectification" || isRectificationHandoffQuestion(question)) {
return uniquifySessionTitle(datedSessionTitle(at, "生时校正"), existingTitles, at);
}
const normalized = question.replace(/\s+/g, " ").trim().replace(/[?!,;:]+$/u, "");
if (!normalized) return "新对话";
const characters = Array.from(normalized);
return characters.length > 14 ? `${characters.slice(0, 14).join("")}` : normalized;
if (options.theme && options.theme !== "general") {
const label = consultationDomainDefinition(options.theme).label;
return uniquifySessionTitle(`${label} · ${clipTitle(normalized, 10)}`, existingTitles, at);
}
return uniquifySessionTitle(clipTitle(normalized), existingTitles, at);
}
+7
View File
@@ -15,6 +15,13 @@ export function activityCompletedTrail(steps: readonly string[]): string | undef
return `已完成:${steps.slice(-ACTIVITY_COMPLETED_TRAIL_LIMIT).join(" · ")}`;
}
export function activityCompletedSteps(trail: string | undefined): string[] {
if (!trail) return [];
const body = trail.replace(/^已完成:/, "").trim();
if (!body) return [];
return body.split(" · ").map((step) => step.trim()).filter(Boolean);
}
export function nextActivityView(
previous: AgentActivityView | undefined,
next: Omit<AgentActivityView, "startedAt">,
@@ -19,6 +19,7 @@ const chatMessageSchema = z.object({
// is strict and a client running the previous bundle still sends them; rejecting the
// whole write would lose that user's message rather than a dead field.
suggestions: z.array(z.string().max(200)).max(3).optional(),
thinkingText: z.string().max(4_000).optional(),
techniqueTruth: z.string().max(120).optional(),
agentExecutionReceipt: agentExecutionReceiptSchema.optional(),
workflowReceipt: workflowReceiptSchema.optional(),
@@ -34,11 +35,14 @@ const chatSessionWriteObjectSchema = z.object({
updated_at: z.string().datetime(),
}).strict();
function limitTranscriptSize<Output extends { messages: Array<{ text: string }> }>(
function limitTranscriptSize<Output extends { messages: Array<{ text: string; thinkingText?: string }> }>(
schema: z.ZodType<Output>,
): z.ZodType<Output> {
return schema.superRefine((value, context) => {
const totalChars = value.messages.reduce((sum, message) => sum + message.text.length, 0);
const totalChars = value.messages.reduce(
(sum, message) => sum + message.text.length + (message.thinkingText?.length ?? 0),
0,
);
if (totalChars > CHAT_SESSION_MAX_TOTAL_MESSAGE_CHARS) {
context.addIssue({
code: z.ZodIssueCode.custom,
@@ -86,6 +90,7 @@ export type ChatSessionWrite = Readonly<{
role: "user" | "assistant";
text: string;
suggestions?: readonly string[];
thinkingText?: string;
techniqueTruth?: string;
agentExecutionReceipt?: AgentExecutionReceipt;
workflowReceipt?: WorkflowReceipt;
@@ -0,0 +1,69 @@
import type { ConsultationDomain } from "./consultation-domain-registry.ts";
import type { ConsultationEntrypoint } from "./consultation-entrypoint.ts";
import { isGeneralDailyFortuneQuestion } from "./consultation-entrypoint.ts";
export const CONVERSATION_FOLLOW_UP_LIMIT = 3;
export const CONVERSATION_FOLLOW_UP_MAX_CHARS = 18;
function compact(value: string) {
return value.normalize("NFKC").replace(/\s+/gu, " ").trim();
}
function clipFollowUp(value: string) {
const characters = Array.from(compact(value).replace(/[?!]+$/u, ""));
if (characters.length === 0) return "";
return characters.length > CONVERSATION_FOLLOW_UP_MAX_CHARS
? `${characters.slice(0, CONVERSATION_FOLLOW_UP_MAX_CHARS).join("")}`
: characters.join("");
}
function pushFollowUp(target: string[], candidate: string, question: string) {
const next = clipFollowUp(candidate);
if (!next) return;
if (next === compact(question).replace(/[?!]+$/u, "")) return;
if (target.includes(next)) return;
if (target.length >= CONVERSATION_FOLLOW_UP_LIMIT) return;
target.push(next);
}
export function deriveConsultationFollowUps(input: Readonly<{
question: string;
answer: string;
theme?: ConsultationDomain;
entrypoint?: ConsultationEntrypoint | null;
}>): string[] {
const question = compact(input.question);
const answer = compact(input.answer);
if (answer.length < 40) return [];
const followUps: string[] = [];
const daily = input.entrypoint === "daily_starlanguage" || isGeneralDailyFortuneQuestion(question);
if (daily) {
if (/适合推进|推进/.test(answer)) pushFollowUp(followUps, "今天最该先推进哪一件", question);
if (/避开|留意|注意|先放/.test(answer)) pushFollowUp(followUps, "这周哪些事最好先放一放", question);
if (/节奏|趋势|阶段/.test(answer)) pushFollowUp(followUps, "这股节奏大概还会持续多久", question);
}
if (/事业|工作|职业|岗位/.test(answer)) {
pushFollowUp(followUps, "接下来工作上更适合怎么安排", question);
}
if (/关系|感情|伴侣|婚姻/.test(answer)) {
pushFollowUp(followUps, "这段关系里最该先看清什么", question);
}
if (/财富|钱|收入|财务|花钱/.test(answer)) {
pushFollowUp(followUps, "钱的安排上现在更该注意什么", question);
}
if (/压力|身体|睡眠|情绪/.test(answer)) {
pushFollowUp(followUps, "近期压力可以从哪件事先卸", question);
}
if (/迁居|搬家|海外|置业/.test(answer)) {
pushFollowUp(followUps, "搬家或置业现在适合推进吗", question);
}
if (followUps.length === 0 && input.theme === "career") {
pushFollowUp(followUps, "如果只做一件,工作上该先做什么", question);
}
return followUps.slice(0, CONVERSATION_FOLLOW_UP_LIMIT);
}
+7 -3
View File
@@ -250,7 +250,7 @@ type StreamAgentResponseOptions = EventOptions & {
headers?: HeadersInit;
onFirstActivity?: () => void | Promise<void>;
onFirstOutput?: () => void | Promise<void>;
onComplete?: (output: string, receipt: AgentExecutionReceipt) => void | Promise<void>;
onComplete?: (output: string, receipt: AgentExecutionReceipt, thinkingText?: string) => void | Promise<void>;
onError?: (error: unknown, emitted: boolean, output: string) => void | Promise<void>;
onCancel?: (emitted: boolean) => void | Promise<void>;
};
@@ -273,6 +273,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
let firstActivity = false;
let firstOutput = false;
let fullOutput = "";
let fullThinking = "";
const startedAt = new Map<string, number>();
// A retry reuses these counters so a failure in either attempt is recorded once.
const toolErrors = { seen: 0 };
@@ -327,7 +328,10 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
}
if (chunk.type === "reasoning-delta" && typeof chunk.payload?.text === "string") {
const thinking = sanitizePublicThinkingText(chunk.payload.text);
if (thinking) send(controller, { type: "thinking.delta", text: thinking });
if (thinking) {
fullThinking = `${fullThinking}${thinking}`.slice(0, 4_000);
send(controller, { type: "thinking.delta", text: thinking });
}
}
}
await outputText(visible.finish(""));
@@ -373,7 +377,7 @@ export function streamAgentResponse(options: StreamAgentResponseOptions) {
if (options.state.modelFinishReason === "length") throw new Error("answer_truncated");
settling = true;
const receipt = agentExecutionReceiptSchema.parse(options.receipt());
await options.onComplete?.(fullOutput, receipt);
await options.onComplete?.(fullOutput, receipt, fullThinking || undefined);
settled = true;
settling = false;
send(controller, { type: "run.completed", receipt });