diff --git a/App/backend/src/services/onboarding-insight-service.ts b/App/backend/src/services/onboarding-insight-service.ts index 062722e9..1f9bf322 100644 --- a/App/backend/src/services/onboarding-insight-service.ts +++ b/App/backend/src/services/onboarding-insight-service.ts @@ -32,7 +32,6 @@ const MAX_BALANCED_QUERIES = 84; const MAX_PREFERENCE_LLM_QUERIES = 24; const DEFAULT_LLM_TIMEOUT_MS = 90_000; const DEFAULT_LLM_MAX_TOKENS = 2_000; -const MEMMY_ACCOUNT_AGENT_CHAT_THINKING_BUDGET = 500; const MAX_GENERATED_OUTPUT_CHARS = 12_000; const GENERATED_REPORT_OPEN = ""; const GENERATED_REPORT_CLOSE = ""; @@ -1003,10 +1002,19 @@ function parseGeneratedFirstReport( } function findGeneratedReportOpen(output: string): { index: number; marker: string } | null { - if (output.startsWith(GENERATED_REPORT_ALIAS_OPEN)) { - return { index: 0, marker: GENERATED_REPORT_ALIAS_OPEN }; + let first: { index: number; marker: string } | null = null; + for (const marker of GENERATED_REPORT_OPEN_MARKERS) { + let index = output.indexOf(marker); + while (index >= 0) { + const lineStart = output.lastIndexOf("\n", index - 1) + 1; + if (!output.slice(lineStart, index).trim() && (!first || index < first.index)) { + first = { index, marker }; + break; + } + index = output.indexOf(marker, index + marker.length); + } } - return findFirstGeneratedMarker(output, [GENERATED_REPORT_OPEN]); + return first; } function findGeneratedTaskContext( @@ -1237,8 +1245,10 @@ function renderFallbackTrajectory(input: { } class FirstReportStreamParser { - private mode: "prefix" | "report" | "hidden" | "plain" = "prefix"; + private mode: "prefix" | "report" | "hidden" = "prefix"; private buffer = ""; + private visibleSource = ""; + private emittedVisibleChars = 0; push(delta: string): string[] { if (this.mode === "hidden") { @@ -1247,48 +1257,39 @@ class FirstReportStreamParser { this.buffer += delta; if (this.mode === "prefix") { const candidate = this.buffer.trimStart(); - const reportOpen = findLeadingGeneratedMarker(candidate, GENERATED_REPORT_OPEN_MARKERS); - if (!candidate || (!reportOpen && isGeneratedMarkerPrefix(candidate, GENERATED_REPORT_OPEN_MARKERS))) { + if (!candidate) { return []; } + const reportOpen = findGeneratedReportOpen(candidate); if (!reportOpen) { - this.mode = "plain"; - return this.drainVisibleText([ - GENERATED_TASK_CONTEXT_OPEN, - ...GENERATED_REPORT_CLOSE_MARKERS, - GENERATED_JSON_FENCE_OPEN, - GENERATED_NAKED_JSON_OPEN - ]); + return []; } this.mode = "report"; - this.buffer = candidate.slice(reportOpen.length); + this.buffer = candidate.slice(reportOpen.index + reportOpen.marker.length); } - return this.mode === "plain" - ? this.drainVisibleText([ - GENERATED_TASK_CONTEXT_OPEN, - ...GENERATED_REPORT_CLOSE_MARKERS, - GENERATED_JSON_FENCE_OPEN, - GENERATED_NAKED_JSON_OPEN - ]) - : this.drainVisibleText([ - ...GENERATED_REPORT_CLOSE_MARKERS, - GENERATED_TASK_CONTEXT_OPEN, - GENERATED_JSON_FENCE_OPEN, - GENERATED_NAKED_JSON_OPEN - ]); + return this.drainVisibleText([ + ...GENERATED_REPORT_CLOSE_MARKERS, + GENERATED_TASK_CONTEXT_OPEN, + GENERATED_JSON_FENCE_OPEN, + GENERATED_NAKED_JSON_OPEN + ]); } finish(): string[] { - if (this.mode === "prefix" || this.mode === "report" || this.mode === "plain") { + if (this.mode === "prefix") { + this.buffer = ""; + this.visibleSource = ""; + return []; + } + if (this.mode === "report") { const remainder = this.buffer; this.buffer = ""; const aliasBoundary = findGeneratedTaskContextAliasBoundary(remainder); if (aliasBoundary) { const report = remainder.slice(0, aliasBoundary.index); - return report ? [report] : []; + return this.visibleText(report, true); } const internalMarkers = [ - ...(this.mode === "prefix" ? GENERATED_REPORT_OPEN_MARKERS : []), ...GENERATED_REPORT_CLOSE_MARKERS, GENERATED_TASK_CONTEXT_OPEN, GENERATED_TASK_CONTEXT_ALIAS_OPEN, @@ -1296,7 +1297,7 @@ class FirstReportStreamParser { GENERATED_NAKED_JSON_OPEN ]; const isPartialInternalMarker = isGeneratedMarkerPrefix(remainder, internalMarkers); - return remainder && !isPartialInternalMarker ? [remainder] : []; + return remainder && !isPartialInternalMarker ? this.visibleText(remainder, true) : []; } return []; } @@ -1314,18 +1315,29 @@ class FirstReportStreamParser { const report = this.buffer.slice(0, boundary.index); this.buffer = ""; this.mode = "hidden"; - return report ? [report] : []; + return this.visibleText(report, true); } if (boundary) { const report = this.buffer.slice(0, boundary.index); this.buffer = this.buffer.slice(boundary.index); - return report ? [report] : []; + return this.visibleText(report); } const retainedMarkers = [...delimiters, GENERATED_TASK_CONTEXT_ALIAS_OPEN]; const retainedChars = Math.max(...retainedMarkers.map((marker) => matchingDelimiterSuffixLength(this.buffer, marker))); const report = this.buffer.slice(0, this.buffer.length - retainedChars); this.buffer = this.buffer.slice(this.buffer.length - retainedChars); - return report ? [report] : []; + return this.visibleText(report); + } + + private visibleText(text: string, finished = false): string[] { + this.visibleSource += text; + const sanitized = stripRawHtmlTags(this.visibleSource, true); + const delta = sanitized.slice(this.emittedVisibleChars); + this.emittedVisibleChars = sanitized.length; + if (finished) { + this.visibleSource = ""; + } + return delta ? [delta] : []; } } @@ -1371,10 +1383,6 @@ function findFirstGeneratedMarker( return first; } -function findLeadingGeneratedMarker(value: string, markers: readonly string[]): string | null { - return markers.find((marker) => value.startsWith(marker)) ?? null; -} - function isGeneratedMarkerPrefix(value: string, markers: readonly string[]): boolean { return markers.some((marker) => marker.startsWith(value)); } @@ -1928,6 +1936,7 @@ function buildLlmMessages(input: OnboardingInsightGenerationInput): Array<{ role "『最近项目记忆』说明最新会话来自哪个 Agent、用户目标、已做事项、已验证结果、当前状态、仍待处理内容。workspacePath 有值时必须写清项目具体路径。只写当前有效结论,不展开冗长历史。", "『接下来可以做』只列证据支持且尚未完成的 0-3 条待办,按执行顺序排列。第一条应是当前最小且可立即执行的下一步;任务已完成或没有明确待办时,直接说明暂时没有明确待办,不要补通用建议。", "正文长度要求:中文 300-500 字,英文 180-300 words。重点是准确提炼最近一个项目现场,不要扩展成跨项目年度总结。", + "报告正文只允许使用 Markdown,不得包含任何原始 HTML 标签或样式。不要输出思考过程、执行计划、要求确认、Prompt 复述或起草说明。", "你必须一次输出两个区块,严格使用以下顺序和标签;标签前后不要添加其他文字:", `${GENERATED_REPORT_OPEN}\n这里放给用户看的 Markdown 报告正文\n${GENERATED_REPORT_CLOSE}`, `${GENERATED_TASK_CONTEXT_OPEN}\n这里放一个合法 JSON 对象\n${GENERATED_TASK_CONTEXT_CLOSE}`, @@ -2089,10 +2098,7 @@ function openAiCompatibleThinkingControlFields( provider === "memmy_account" && model.includes("agent_chat") ) { - return { - enable_thinking: true, - thinking_budget: MEMMY_ACCOUNT_AGENT_CHAT_THINKING_BUDGET - }; + return { enable_thinking: false }; } if (provider === "dashscope" || baseUrl.includes("dashscope") || model.includes("qwen")) { @@ -2323,10 +2329,60 @@ function extractLlmDelta(body: unknown): string | null { } function sanitizeGeneratedReport(report: string | null): string | null { - const trimmed = stripActionCopyFromReport(report ?? "").trim(); + const trimmed = stripActionCopyFromReport(stripRawHtmlTags(report ?? "")).trim(); return trimmed ? trimmed.slice(0, 4_000) : null; } +function stripRawHtmlTags(value: string, dropTrailingPartial = false): string { + let output = ""; + let codeTicks = 0; + for (let index = 0; index < value.length;) { + if (value[index] === "`") { + let end = index + 1; + while (value[end] === "`") { + end += 1; + } + const ticks = end - index; + if (!codeTicks) { + codeTicks = ticks; + } else if (ticks >= codeTicks) { + codeTicks = 0; + } + output += value.slice(index, end); + index = end; + continue; + } + if (codeTicks || value[index] !== "<") { + output += value[index]; + index += 1; + continue; + } + if (value.startsWith("", index + 4); + if (commentEnd < 0) { + return dropTrailingPartial ? output : `${output}${value.slice(index)}`; + } + index = commentEnd + 3; + continue; + } + const tag = /^<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^>\n]*|\/?)>/.exec(value.slice(index)); + if (tag) { + index += tag[0].length; + continue; + } + const remainder = value.slice(index); + if (dropTrailingPartial && ( + remainder === "<" || remainder === "\n]*)?$/.test(remainder) + )) { + return output; + } + output += "<"; + index += 1; + } + return output; +} + function stripActionCopyFromReport(report: string): string { const reportBody = report.split(/\[\s*MEMMY_ACTIONS_JSON\s*\]/i, 1)[0] ?? report; const paragraphs = reportBody diff --git a/App/backend/src/services/tests/onboarding-insight-service.test.ts b/App/backend/src/services/tests/onboarding-insight-service.test.ts index 4eff53a0..df7d25c9 100644 --- a/App/backend/src/services/tests/onboarding-insight-service.test.ts +++ b/App/backend/src/services/tests/onboarding-insight-service.test.ts @@ -461,9 +461,9 @@ describe("onboarding insight service", () => { throw new Error("generateReport not used"); }, async *streamReport() { - yield "Hi,"; + yield "Hi,"; yield "我已经开始读你的最近任务。\r\n"; - yield "## 接下来可以做\n1. 先验证记忆已完成摘要和索引。"; + yield "## 接下来可以做\n1. 先验证记忆已完成摘要和索引。"; } }, memoryWriter: { write }, @@ -506,6 +506,71 @@ describe("onboarding insight service", () => { })); }); + it("drops model planning text before the report envelope from the stream and final report", async () => { + const reportText = "Hi Jiang,\n\n## 你的偏好\n- 使用中文。"; + const service = createOnboardingInsightService({ + samplers: [sampler("codex", "Codex", [query("codex", "1", "生成初见报告")])], + reportGenerator: { + async generateReport() { + throw new Error("generateReport not used"); + }, + async *streamReport() { + yield "好的,我会严格按照你的要求,不暴露 homePathName。\n"; + yield `${reportText}`; + yield ""; + } + }, + now: () => 100 + }); + + const events = await collectStreamEvents(service.streamReport({ locale: "zh-CN" })); + const visibleText = events + .filter((event): event is { type: "chunk"; delta: string } => + Boolean(event && typeof event === "object" && (event as { type?: unknown }).type === "chunk")) + .map((event) => event.delta) + .join(""); + const done = events.find((event) => + event && typeof event === "object" && (event as { type?: unknown }).type === "done" + ) as { response: { reportMarkdown: string } } | undefined; + + expect(visibleText).toBe(reportText); + expect(visibleText).not.toContain("严格按照你的要求"); + expect(visibleText).not.toContain("homePathName"); + expect(done?.response.reportMarkdown).toBe(reportText); + }); + + it("removes raw HTML split across streamed report chunks while preserving its text", async () => { + const service = createOnboardingInsightService({ + samplers: [sampler("codex", "Codex", [query("codex", "1", "生成初见报告")])], + reportGenerator: { + async generateReport() { + throw new Error("generateReport not used"); + }, + async *streamReport() { + yield "Hi Jiang,\n\n以上内容依据现有证据整理"; + yield "\n\n## 接下来可以做\n暂时没有明确待办。"; + } + }, + now: () => 100 + }); + + const events = await collectStreamEvents(service.streamReport({ locale: "zh-CN" })); + const visibleText = events + .filter((event): event is { type: "chunk"; delta: string } => + Boolean(event && typeof event === "object" && (event as { type?: unknown }).type === "chunk")) + .map((event) => event.delta) + .join(""); + const done = events.find((event) => + event && typeof event === "object" && (event as { type?: unknown }).type === "done" + ) as { response: { reportMarkdown: string } } | undefined; + + expect(visibleText).toContain("以上内容依据现有证据整理"); + expect(visibleText).not.toContain(" { let finishWrite = () => undefined; const write = vi.fn(() => new Promise((resolve) => { @@ -707,7 +772,7 @@ describe("onboarding insight service", () => { ) as { response: { reportMarkdown: string } } | undefined; expect(report.reportMarkdown).toBe(reportText); - expect(visibleText).toBe(reportText); + expect(visibleText).toBe(""); expect(done?.response.reportMarkdown).toBe(reportText); } ); @@ -739,7 +804,7 @@ describe("onboarding insight service", () => { event && typeof event === "object" && (event as { type?: unknown }).type === "done" ) as { response: { reportMarkdown: string } } | undefined; - expect(visibleText).toBe(reportText); + expect(visibleText).toBe(""); expect(done?.response.reportMarkdown).toBe(reportText); }); @@ -795,7 +860,7 @@ describe("onboarding insight service", () => { throw new Error("generateReport not used"); }, async *streamReport() { - yield "## 最近项目记忆\n正文先展示。"; + yield "## 最近项目记忆\n正文先展示。"; yield "\n{"; yield `${JSON.stringify(taskContext).slice(1)}`; } @@ -832,9 +897,9 @@ describe("onboarding insight service", () => { throw new Error("generateReport not used"); }, async *streamReport() { - yield "报告包含["; + yield "报告包含["; yield "普通说明],"; - yield "仍然应该正常显示。"; + yield "仍然应该正常显示。"; } }, now: () => 100 @@ -995,8 +1060,8 @@ describe("onboarding insight service", () => { const body = JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body)); expect(body.model).toBe("agent_chat"); expect(body.max_tokens).toBe(2000); - expect(body.enable_thinking).toBe(true); - expect(body.thinking_budget).toBe(500); + expect(body.enable_thinking).toBe(false); + expect(body).not.toHaveProperty("thinking_budget"); expect(body).not.toHaveProperty("reasoning_effort"); expect(body.messages[0].content).not.toContain("保持 4-6 个短段落"); expect(body.messages[0].content).toContain("latestConversation 是所有已扫描 Agent 中时间最新的一个会话"); @@ -1010,6 +1075,8 @@ describe("onboarding insight service", () => { expect(body.messages[0].content).toContain("不得把名字替换成“这个线索”"); expect(body.messages[0].content).toContain("有值时要自然说明用户最近更常用中文还是英文"); expect(body.messages[0].content).toContain("不要生成按钮、行动卡片、CTA"); + expect(body.messages[0].content).toContain("不得包含任何原始 HTML 标签或样式"); + expect(body.messages[0].content).toContain("不要输出思考过程、执行计划、要求确认、Prompt 复述或起草说明"); expect(body.messages[0].content).not.toContain("[MEMMY_ACTIONS_JSON]"); const userPayload = JSON.parse(String(body.messages[1].content)); expect(userPayload.reportGoal.primary).toBe("user_preferences_latest_project_memory_and_actionable_todos");