diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 580cedfa..cf823a73 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -5355,3 +5355,20 @@ - 复发自:BUG-197(不确定入口恢复后仍用五档时段加备注,备注从未进入扫描窗) - 修复版本:8ed6118b +## BUG-354 | 咨询 thinking 抢占正文额度,思考过程被清洗成英文碎片且没有回答 + +- 状态:resolved +- 首次发现:2026-08-22 +- 最近更新:2026-08-22 +- 影响面:`POST /api/consult`、`streamAgentResponse`、咨询页思考/分析 UI、组答 `maxOutputTokens` +- 用户现象:咨询页灰色「思考过程」出现断裂英文碎片(如 `Let me at the.`、`The for`),正文为空;流在技法审计表标题处因 `finish_reason=length` 结束。 +- 触发条件:网页个人咨询,尤其是多领域 Level 2 组答;供应商默认打开 thinking,且 `max_tokens` 与可见正文共用。 +- 根因:BUG-346 在 8192 可见预算不变的情况下重新打开 provider thinking。Flash 的 `reasoning_content` 占满额度后 `content` 为空。公开 `thinking.delta` 又按 token 清洗英文,把连贯 CoT 剪成碎片。Skill 体积走的是输入上下文,不是这次截断的原因。 +- 修复:咨询与纠正组答改回 `thinking: disabled`。组答正文预算改为独立的 16384。计算仍一次完成,不按领域再开模型。服务器用已执行领域、`required_blocks` 和 `must_use_layers` 生成 `thinking.section` 步骤树。`finish_reason=length` 且已有正文时关 thinking 续写一次;两次仍空或不增长才 `answer_truncated`,不扣点。咨询页按领域渲染可折叠「思考」和「分析」,不再把模型 CoT 当主思考通道。 +- 验证:`frontend/tests/consultation-workflow-contract.test.ts` 锁定 thinking disabled 与 16384 正文预算;`frontend/tests/consultation-agentic-runtime.test.ts` 锁定 `reasoning-delta` 不进公开流、`length` 续写可完成、不增长则截断;`frontend/tests/consultation-thinking-plan.test.ts` 锁定中文步骤树不含工具 id;`frontend/tests/chat-stream-layout.test.ts` 锁定思考/分析 UI。 +- 防复发:咨询组答不得再打开 provider thinking 来充当思考 UI。打开 thinking 必须同时拆开或加大正文预算,且不得把 `reasoning-delta` 经逐 token 清洗后当作用户可见思考过程。`finish_reason=length` 且半截正文必须续写,不得直接 `run.completed`。步骤树只使用中文产品文案,禁止把 `run-jyotish-*`、JSON 键或 Skill 正文送进思考 UI。 +- 相关记录:BUG-277、BUG-305、BUG-340、BUG-345、BUG-346、BUG-347 +- 复发自:BUG-305(thinking 与正文抢 `max_tokens`)、BUG-346(8192 预算不变就重新打开 thinking) +- 修复版本:待发布 + + diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts index ed563592..3eee9de5 100644 --- a/frontend/src/app/api/consult/route.ts +++ b/frontend/src/app/api/consult/route.ts @@ -36,6 +36,7 @@ import { createServerSupabaseClient } from "@/lib/supabase/server"; import { streamTextResponse } from "@/lib/stream-text-response"; import { streamAgentResponse } from "@/lib/stream-agent-response"; import type { AgentExecutionReceipt, WorkflowReceipt } from "@/lib/consultation-agent-events"; +import { consultationContinuePrompt, type PublicThinkingSection } from "@/lib/consultation-thinking-plan"; import { AGENT_MAX_STEPS, AGENT_TIMEOUT_MS, @@ -501,6 +502,7 @@ export async function POST(request: Request) { workflowReceipt: WorkflowReceipt, agentExecutionReceipt?: AgentExecutionReceipt, thinkingText?: string, + thinkingSections?: PublicThinkingSection[], ): Promise { try { const reply = parseAgentReply( @@ -513,6 +515,7 @@ export async function POST(request: Request) { role: "assistant" as const, text: reply.text, ...(persistedThinking ? { thinkingText: persistedThinking } : {}), + ...(thinkingSections?.length ? { thinkingSections } : {}), techniqueTruth, workflowReceipt, ...(agentExecutionReceipt ? { agentExecutionReceipt } : {}), @@ -724,6 +727,15 @@ export async function POST(request: Request) { usages.push(retried.totalUsage); return retried.fullStream; }; + const continueAfterLength = async (output: string) => { + const continued = await agent.stream([ + ...baseMessages, + { role: "assistant" as const, content: output }, + { role: "user" as const, content: consultationContinuePrompt(output) }, + ], streamOptions); + usages.push(continued.totalUsage); + return continued.fullStream; + }; const executionReceipt = (): AgentExecutionReceipt => ({ runId: requestId, runtime: "mastra-agentic", @@ -748,6 +760,7 @@ export async function POST(request: Request) { stream: result.fullStream, requireTool: false, retryForAnswer, + continueAfterLength, continueAfterDisconnect: true, transformText: createBirthTimeModeOutputGuard( generalDailyContext ? "general_no_birth_time" : consultationMode, @@ -758,13 +771,14 @@ export async function POST(request: Request) { headers: { "x-jyotish-birth-time-mode": consultationMode }, onFirstActivity: markFirstActivity, onFirstOutput: markFirstText, - onComplete: (output, agentExecutionReceipt, thinkingText) => settleRun(() => completeResponse( + onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse( output, mergeUsage(usages), generalDailyContext ? "public-panchanga-only" : "not-applicable", workflowReceipt, agentExecutionReceipt, thinkingText, + thinkingSections, ), undefined), onError: (error) => settleRun( cancel, @@ -812,6 +826,15 @@ export async function POST(request: Request) { usages.push(retried.totalUsage); return retried.fullStream; }; + const continueAfterLength = async (output: string) => { + const continued = await agent.stream([ + ...baseMessages, + { role: "assistant" as const, content: output }, + { role: "user" as const, content: consultationContinuePrompt(output) }, + ], streamOptions); + usages.push(continued.totalUsage); + return continued.fullStream; + }; const executionReceipt = (): AgentExecutionReceipt => ({ runId: requestId, runtime: "mastra-agentic", @@ -837,6 +860,7 @@ export async function POST(request: Request) { requireTool: true, retry, retryForAnswer, + continueAfterLength, continueAfterDisconnect: true, transformText: createBirthTimeModeOutputGuard(consultationMode, false), toolStatus: () => workflowStatus(state.workflowReceipt?.status), @@ -844,13 +868,14 @@ export async function POST(request: Request) { headers: { "x-jyotish-birth-time-mode": consultationMode }, onFirstActivity: markFirstActivity, onFirstOutput: markFirstText, - onComplete: (output, agentExecutionReceipt, thinkingText) => settleRun(() => completeResponse( + onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse( output, mergeUsage(usages), state.techniqueTruth ?? "declared-window", state.workflowReceipt ?? workflowReceipt, agentExecutionReceipt, thinkingText, + thinkingSections, ), undefined), onError: (error) => settleRun( cancel, @@ -900,6 +925,15 @@ export async function POST(request: Request) { usages.push(retried.totalUsage); return retried.fullStream; }; + const continueAfterLength = async (output: string) => { + const continued = await agent.stream([ + ...baseMessages, + { role: "assistant" as const, content: output }, + { role: "user" as const, content: consultationContinuePrompt(output) }, + ], streamOptions); + usages.push(continued.totalUsage); + return continued.fullStream; + }; const executionReceipt = (): AgentExecutionReceipt => ({ runId: requestId, runtime: "mastra-agentic", @@ -925,6 +959,7 @@ export async function POST(request: Request) { requireTool: true, retry, retryForAnswer, + continueAfterLength, continueAfterDisconnect: true, transformText: (text) => createBirthTimeModeOutputGuard( consultationMode, @@ -935,13 +970,14 @@ export async function POST(request: Request) { headers: { "x-jyotish-birth-time-mode": consultationMode }, onFirstActivity: markFirstActivity, onFirstOutput: markFirstText, - onComplete: (output, agentExecutionReceipt, thinkingText) => settleRun(() => completeResponse( + onComplete: (output, agentExecutionReceipt, thinkingText, thinkingSections) => settleRun(() => completeResponse( output, mergeUsage(usages), state.techniqueTruth ?? "unknown", state.workflowReceipt ?? workflowReceipt, agentExecutionReceipt, thinkingText, + thinkingSections, ), undefined), onError: (error) => settleRun( cancel, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ca82a8f6..ca5e35f5 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -844,6 +844,37 @@ button:disabled { cursor: default; opacity: .45; } line-height: 1.55; white-space: pre-wrap; } +.consultation-step-tree__intent { + margin: 0 0 var(--space-2); + color: var(--color-ink-secondary); + font-size: 13px; + line-height: 1.5; +} +.agent-thinking-marker.is-pending { + background: var(--color-canvas-muted); + box-shadow: inset 0 0 0 1px var(--color-border); +} +.agent-thinking-step.is-more { + color: var(--color-ink-tertiary); +} +.consultation-thinking-report { + display: grid; + gap: var(--space-5); +} +.consultation-report-block { + display: grid; + gap: var(--space-2); +} +.consultation-report-analysis__label { + margin: 0 0 var(--space-2); + color: var(--color-ink); + font-size: 13px; + font-weight: 600; + line-height: 1.5; +} +.consultation-report-analysis .message-answer { + margin-top: 0; +} .rectification-message-entry { min-width: 0; } .message-actions { display: flex; diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 110780d9..5de50be7 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -106,6 +106,12 @@ import { type AgentExecutionReceipt, type ConsultationAgentPublicEvent, } from "@/lib/consultation-agent-events"; +import { + applyThinkingSectionProgress, + parsePublicThinkingSections, + upsertThinkingSection, + type PublicThinkingSection, +} from "@/lib/consultation-thinking-plan"; import { CONSULTATION_CHART_CALCULATION_LABEL, CONSULTATION_COMPOSING_LABEL, @@ -220,7 +226,13 @@ type ReplyOutcome = { readonly phase: Extract; readonly replyOrdinal: number; }; -type StreamingReply = { sessionId: string; text: string; activity?: AgentActivityView; thinkingText?: string }; +type StreamingReply = { + sessionId: string; + text: string; + activity?: AgentActivityView; + thinkingText?: string; + thinkingSections?: PublicThinkingSection[]; +}; type BirthPlace = { label: string; lat: number; @@ -780,10 +792,12 @@ function readSessions(value: unknown, catalog: PublicLanguageModelCatalog | null const thinkingText = typeof stored.thinkingText === "string" && stored.thinkingText.trim() ? stored.thinkingText.slice(0, 4000) : undefined; + const thinkingSections = parsePublicThinkingSections(stored.thinkingSections); return [{ role: stored.role, text: stored.text.slice(0, 12000), ...(thinkingText ? { thinkingText } : {}), + ...(thinkingSections.length ? { thinkingSections } : {}), ...(typeof stored.techniqueTruth === "string" ? { techniqueTruth: stored.techniqueTruth } : {}), ...(stored.agentExecutionReceipt ? { agentExecutionReceipt: stored.agentExecutionReceipt } : {}), ...(stored.workflowReceipt ? { workflowReceipt: stored.workflowReceipt } : {}), @@ -1197,6 +1211,9 @@ export default function Home() { const activeStreamingThinking = streamingReply && streamingReply.sessionId === activeSession?.id ? streamingReply.thinkingText : undefined; + const activeStreamingSections = streamingReply && streamingReply.sessionId === activeSession?.id + ? streamingReply.thinkingSections + : undefined; const activeReplyOutcome = replyOutcome && replyOutcome.sessionId === activeSession?.id ? replyOutcome : null; const replyPhase: ChatReplyPhase = isLoading ? consultationPhase === "recovering" ? "recovering" : "generating" @@ -2015,6 +2032,7 @@ export default function Home() { role: message.role, text: message.text, thinkingText: message.thinkingText, + thinkingSections: message.thinkingSections, techniqueTruth: message.techniqueTruth, agentExecutionReceipt: message.agentExecutionReceipt, workflowReceipt: message.workflowReceipt, @@ -3249,7 +3267,7 @@ export default function Home() { } setStreamingReply({ sessionId, text: "" }); let latestPartialReply = ""; - let thinking = ""; + let thinkingSections: PublicThinkingSection[] = []; try { const response = await fetch("/api/consult", { method: "POST", @@ -3314,10 +3332,11 @@ export default function Home() { const updateStreamingAnswer = (activity?: AgentActivityView) => { const partialReply = parseAgentReply(answer).text; latestPartialReply = partialReply; + thinkingSections = applyThinkingSectionProgress(thinkingSections, partialReply); setStreamingReply((current) => ({ sessionId, text: partialReply, - thinkingText: current?.sessionId === sessionId ? current.thinkingText : undefined, + thinkingSections: thinkingSections.length ? thinkingSections : undefined, activity: activity ? nextActivityView(current?.sessionId === sessionId ? current.activity : undefined, activity) : current?.sessionId === sessionId ? current.activity : undefined, @@ -3359,12 +3378,20 @@ export default function Home() { if ((response.headers.get("content-type") ?? "").includes("application/x-ndjson")) { const parser = createNdjsonParser((event) => { if (event.type === "answer.delta") answer += event.text; - if (event.type === "thinking.delta") { - thinking = `${thinking}${event.text}`.slice(0, 4_000); + if (event.type === "thinking.section") { + thinkingSections = applyThinkingSectionProgress( + upsertThinkingSection(thinkingSections, { + id: event.id, + title: event.title, + heading: event.heading, + steps: event.steps, + }), + parseAgentReply(answer).text, + ); setStreamingReply((current) => ({ sessionId, text: current?.sessionId === sessionId ? current.text : parseAgentReply(answer).text, - thinkingText: thinking, + thinkingSections, activity: current?.sessionId === sessionId ? current.activity : undefined, })); } @@ -3403,7 +3430,7 @@ export default function Home() { messages: [...userSession.messages, { role: "assistant", text: reply.text, - thinkingText: thinking || undefined, + ...(thinkingSections.length ? { thinkingSections } : {}), techniqueTruth, workflowReceipt, agentExecutionReceipt, @@ -3429,8 +3456,8 @@ export default function Home() { if (!runCompleted && !truncatedFailure) { throw new ConsultationResponseError( 502, - thinking.trim() - ? "这次还没有生成可显示的回答。思考过程已保留,可以直接继续问。" + thinkingSections.length + ? "这次还没有生成可显示的回答。思考步骤已保留,可以直接继续问。" : "Agent 回答未完成,本次不会保存为成功咨询。", ); } @@ -3446,8 +3473,8 @@ export default function Home() { if (controller.signal.aborted) return Boolean(latestPartialReply); const reply = parseAgentReply(answer); if (!reply.text) { - throw thinking.trim() - ? new ConsultationResponseError(502, "这次还没有生成可显示的回答。思考过程已保留,可以直接继续问。") + throw thinkingSections.length + ? new ConsultationResponseError(502, "这次还没有生成可显示的回答。思考步骤已保留,可以直接继续问。") : new Error("Agent 没有返回可显示的回答,请重试。"); } @@ -3464,7 +3491,7 @@ export default function Home() { messages: [...userSession.messages, { role: "assistant", text: reply.text, - thinkingText: thinking || undefined, + ...(thinkingSections.length ? { thinkingSections } : {}), techniqueTruth, workflowReceipt, agentExecutionReceipt, @@ -3508,13 +3535,13 @@ export default function Home() { if (restore) { updateSession(sessionId, () => restore); void persistSession(restore).catch(() => {}); - } else if (thinking.trim() || latestPartialReply) { + } else if (thinkingSections.length || latestPartialReply) { const failedSession: ChatSession = { ...userSession, messages: [...userSession.messages, { role: "assistant", text: latestPartialReply, - thinkingText: thinking.trim() || undefined, + ...(thinkingSections.length ? { thinkingSections } : {}), }], updatedAt: timestamp(), }; @@ -3950,7 +3977,7 @@ export default function Home() { ) : (
- {chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity, activeStreamingThinking).map((message, index, views) => { + {chatMessageViews(activeSession.messages, isLoading, activeStreamingText, activeStreamingActivity, activeStreamingThinking, activeStreamingSections).map((message, index, views) => { const showActions = message.role === "assistant" && message.state === "settled" && Boolean(message.text); diff --git a/frontend/src/components/chat-message-row.tsx b/frontend/src/components/chat-message-row.tsx index 32e6ff98..6f7df95b 100644 --- a/frontend/src/components/chat-message-row.tsx +++ b/frontend/src/components/chat-message-row.tsx @@ -3,6 +3,7 @@ import { AgentActivityStatus } from "@/components/agent-activity-status"; import { prefetchOnIdle } from "@/components/chat-chunk-prefetch"; import { ChatMessageContent } from "@/components/chat-message-content"; +import { ConsultationThinkingReport } from "@/components/consultation-thinking-report"; import type { ChatMessageView } from "@/lib/chat-message-view"; import { useEffect, useLayoutEffect, useRef } from "react"; @@ -60,7 +61,9 @@ export function ChatMessageRow({ const activityLabel = message.activity?.label ?? (message.state === "thinking" ? "正在处理…" : undefined); const hasAnswer = Boolean(message.text.trim()); - const showThinkingPanel = showActivity || Boolean(message.thinkingText?.trim()); + const thinkingSections = message.thinkingSections ?? []; + const showReport = thinkingSections.length > 0; + const showThinkingPanel = !showReport && (showActivity || Boolean(message.thinkingText?.trim())); useEntryEffect(() => { const row = messageRow.current; @@ -97,6 +100,18 @@ export function ChatMessageRow({
{message.role === "assistant" ? ( <> + {showReport && ( + + )} {showThinkingPanel && ( )} - {message.text && ( + {!showReport && message.text && ( , + isFirst: boolean, +): string { + const own = slices[section.heading] ?? ""; + const wrap = section.id === "close" ? (slices[REPORT_HEADING.wrap] ?? "") : ""; + const lead = isFirst || section.id === "foundation" || section.id === "answer" || section.id === "window" + ? preamble + : ""; + return [lead, own, wrap].filter((part) => part.trim()).join("\n\n"); +} + +export function ConsultationThinkingReport({ + sections, + answer, + live = false, + liveLabel, + liveState, + startedAt, + auditRows, + vargaSentence, +}: Readonly<{ + sections: readonly PublicThinkingSection[]; + answer: string; + live?: boolean; + liveLabel?: string; + liveState?: "working" | "searching" | "solving" | "listening" | "composing" | "shaping"; + startedAt?: number; + auditRows?: readonly TechniqueAuditRow[]; + vargaSentence?: string | null; +}>) { + const progressed = applyThinkingSectionProgress(sections, answer); + const headings = [ + ...progressed.map((section) => section.heading), + REPORT_HEADING.wrap, + ]; + const { preamble, slices } = splitAnswerByHeadings(answer, headings); + + return ( +
+ {progressed.map((section, index) => { + const active = section.steps.some((step) => step.status === "active"); + const analysis = analysisForSection(section, preamble, slices, index === 0); + const last = index === progressed.length - 1; + return ( +
+ + {analysis.trim() ? ( +
+

分析

+ +
+ ) : null} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/thinking-step-tree.tsx b/frontend/src/components/thinking-step-tree.tsx new file mode 100644 index 00000000..c893e1ce --- /dev/null +++ b/frontend/src/components/thinking-step-tree.tsx @@ -0,0 +1,132 @@ +"use client"; + +import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; +import { Check } from "lucide-react"; +import type { OrbState } from "thinking-orbs"; + +import { prefetchOnIdle } from "@/components/chat-chunk-prefetch"; +import { activityElapsedLabel } from "@/lib/chat-message-view"; +import { + visibleThinkingSteps, + type PublicThinkingStep, + type ThinkingStepStatus, +} from "@/lib/consultation-thinking-plan"; + +const importThinkingOrb = () => import("thinking-orbs"); + +const ThinkingOrb = dynamic(async () => (await importThinkingOrb()).ThinkingOrb, { + loading: () => ( +