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 === "" || 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 "";
+ yield "span>\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");