From f40741b49b76f19d373da13db436830c496279b7 Mon Sep 17 00:00:00 2001 From: QiMing Date: Thu, 14 May 2026 09:38:30 +0800 Subject: [PATCH 1/2] fix(doubao): copy button not appearing and heading level conflict - Add fallback DOM selectors for copy button injection - Add floating copy button as 8s timeout safety net - Implement dynamic heading demotion: find highest heading level in content, shift all headings to nest strictly below ## level - Protect code fence content from heading demotion - Cap shifted heading level at 6 (max ATX level) Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 2 + apps/browser-extension/src/components/app.tsx | 16 +- docs/others/doubao-heading-demotion.md | 49 ++ .../plugins/doubao/__tests__/plugin.test.ts | 590 ++++++++++++++++++ .../core-plugins/src/plugins/doubao/plugin.ts | 99 ++- 5 files changed, 754 insertions(+), 2 deletions(-) create mode 100644 docs/others/doubao-heading-demotion.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ac0c88e..976f374 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,3 +21,5 @@ * correct SVG attribute casing in Logo component ([01a213c](https://github.com/nicepkg/ctxport/commit/01a213c69eb7980fb8d0b48c2c879b7069a47039)) * correct zip output path in release workflow ([56be20f](https://github.com/nicepkg/ctxport/commit/56be20f2c67834f5128711c2b393988638aaf792)) +* **doubao:** fix copy button not appearing due to fragile DOM selector ([ab1f1d0](../../commit/ab1f1d0)) +* **doubao:** fix heading level conflict — content headings now dynamically demoted to nest below `## User`/`## Assistant` section headers ([ab1f1d0](../../commit/ab1f1d0)) diff --git a/apps/browser-extension/src/components/app.tsx b/apps/browser-extension/src/components/app.tsx index 1b75458..52c668a 100644 --- a/apps/browser-extension/src/components/app.tsx +++ b/apps/browser-extension/src/components/app.tsx @@ -37,10 +37,14 @@ export default function App() { if (!plugin) return; if (plugin.injector) { + let copyButtonRendered = false; + plugin.injector.inject( { url, document }, { renderCopyButton: (container) => { + copyButtonRendered = true; + setShowFloatingCopy(false); const root = createRoot(container); root.render(); }, @@ -53,7 +57,17 @@ export default function App() { }, ); - cleanupRef.current = () => plugin.injector?.cleanup(); + // Safety net: if copy button isn't injected after 8s, show floating button + const fallbackTimer = setTimeout(() => { + if (!copyButtonRendered) { + setShowFloatingCopy(true); + } + }, 8000); + + cleanupRef.current = () => { + clearTimeout(fallbackTimer); + plugin.injector?.cleanup(); + }; } else { // No injector — show floating copy button as fallback setShowFloatingCopy(true); diff --git a/docs/others/doubao-heading-demotion.md b/docs/others/doubao-heading-demotion.md new file mode 100644 index 0000000..9637df4 --- /dev/null +++ b/docs/others/doubao-heading-demotion.md @@ -0,0 +1,49 @@ +# 豆包 Heading 降级策略 + +## 问题 + +豆包 AI 的回答中常包含 Markdown 标题(如 `# 解答`、`### 证明`、`#### 步骤1`),而 CtxPort 输出格式使用 `## User` / `## Assistant`(二级标题)作为会话角色分隔符。 + +旧方案盲降一级:`# → ##`,导致原本的一级标题降为二级后与 `## Assistant` 同级,文档层级错乱。 + +## 策略 + +**动态检测 + 精确降级**:先扫描内容找到最高级别标题,计算需要降多少级才能让所有内容标题严格嵌套在 `##` 下方。 + +``` +内容最高标题 降级量 效果 +────────────────────────────────────────── +# (一级) +2 # → ###, ## → ####, ### → ##### +## (二级) +1 ## → ###, ### → #### +### (三级及以上) 0 不变 +``` + +核心实现位于 `packages/core-plugins/src/plugins/doubao/plugin.ts`: + +1. `findMinHeadingLevel(text)` — 扫描非代码块区域,返回最高(数字最小)标题级别 +2. `demoteHeadings(text)` — 计算 `shift = SECTION_LEVEL + 1 - minLevel`,对所有 ATX 标题应用偏移 +3. 代码块(fenced code block)内的 `#` 字符不受影响 + +## 示例 + +输入: +```markdown +# 💡解答 +### 证明: +已知条件... + +#### 步骤1:选取合适的 ε +``` + +输出(在 `## Assistant` 下方): +```markdown +## Assistant + +### 💡解答 +##### 证明: +已知条件... + +###### 步骤1:选取合适的 ε +``` + +所有内容标题从 `###` 开始,正确嵌套在二级标题下。 diff --git a/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts b/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts index bfc2794..23364ea 100644 --- a/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts +++ b/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts @@ -478,4 +478,594 @@ describe("doubaoPlugin", () => { expect(bundle.source.url).toBe("https://www.doubao.com/chat/456"); }); }); + + describe("heading demotion", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("shifts all headings to nest below ## level when content has # H1", async () => { + const chainResponse = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: [ + { + conversation_id: "123", + message_id: "m1", + sender_id: "bot1", + user_type: 2, + status: 0, + content_type: 0, + content: "", + content_status: 0, + index_in_conv: "1", + create_time: "1700000000", + thinking_content: "", + content_block: [ + { + block_type: 10000, + block_id: "b1", + parent_id: "", + content: { + text_block: { + text: [ + "# 一级标题", + "正文内容", + "## 二级标题", + "更多内容", + "### 三级标题", + "最深内容", + ].join("\n"), + }, + }, + }, + ], + }, + ], + has_more: false, + }, + }, + }; + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + if (url.includes("/im/conversation/info")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(chainResponse), + }); + }), + ); + + const ctx: PluginContext = { + url: "https://www.doubao.com/chat/123", + document: {} as Document, + }; + + const bundle = await doubaoPlugin.extract(ctx); + const content = bundle.nodes[0]!.content; + + // minLevel=1 (#), shift=2 → #→###, ##→####, ###→##### + expect(content).toContain("### 一级标题"); + expect(content).toContain("#### 二级标题"); + expect(content).toContain("##### 三级标题"); + // No original-level headings left outside fences + expect(content).toContain("正文内容"); + }); + + it("preserves headings inside fenced code blocks", async () => { + const chainResponse = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: [ + { + conversation_id: "123", + message_id: "m1", + sender_id: "bot1", + user_type: 2, + status: 0, + content_type: 0, + content: "", + content_status: 0, + index_in_conv: "1", + create_time: "1700000000", + thinking_content: "", + content_block: [ + { + block_type: 10000, + block_id: "b1", + parent_id: "", + content: { + text_block: { + text: [ + "# 外面标题", + "", + "```md", + "# 代码块内标题不应降级", + "## 也不应降级", + "```", + "", + "## 外面小标题", + ].join("\n"), + }, + }, + }, + ], + }, + ], + has_more: false, + }, + }, + }; + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + if (url.includes("/im/conversation/info")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(chainResponse), + }); + }), + ); + + const ctx: PluginContext = { + url: "https://www.doubao.com/chat/123", + document: {} as Document, + }; + + const bundle = await doubaoPlugin.extract(ctx); + const content = bundle.nodes[0]!.content; + + // minLevel=1 (#), shift=2 → #→###, ##→#### + expect(content).toContain("### 外面标题"); + expect(content).toContain("#### 外面小标题"); + // Inside fence preserved + expect(content).toContain("# 代码块内标题不应降级"); + expect(content).toContain("## 也不应降级"); + }); + + it("shifts by 1 when highest heading is ## (level 2)", async () => { + const chainResponse = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: [ + { + conversation_id: "123", + message_id: "m1", + sender_id: "bot1", + user_type: 2, + status: 0, + content_type: 0, + content: "", + content_status: 0, + index_in_conv: "1", + create_time: "1700000000", + thinking_content: "", + content_block: [ + { + block_type: 10000, + block_id: "b1", + parent_id: "", + content: { + text_block: { + text: [ + "## 最高二级", + "正文", + "### 三级标题", + "更多正文", + ].join("\n"), + }, + }, + }, + ], + }, + ], + has_more: false, + }, + }, + }; + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + if (url.includes("/im/conversation/info")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(chainResponse), + }); + }), + ); + + const ctx: PluginContext = { + url: "https://www.doubao.com/chat/123", + document: {} as Document, + }; + + const bundle = await doubaoPlugin.extract(ctx); + const content = bundle.nodes[0]!.content; + + // minLevel=2 (##), shift=1 → ##→###, ###→#### + expect(content).toContain("### 最高二级"); + expect(content).toContain("#### 三级标题"); + }); + + it("leaves headings unchanged when already below ## level", async () => { + const chainResponse = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: [ + { + conversation_id: "123", + message_id: "m1", + sender_id: "bot1", + user_type: 2, + status: 0, + content_type: 0, + content: "", + content_status: 0, + index_in_conv: "1", + create_time: "1700000000", + thinking_content: "", + content_block: [ + { + block_type: 10000, + block_id: "b1", + parent_id: "", + content: { + text_block: { + text: [ + "### 三级标题", + "正文", + "#### 四级标题", + ].join("\n"), + }, + }, + }, + ], + }, + ], + has_more: false, + }, + }, + }; + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + if (url.includes("/im/conversation/info")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(chainResponse), + }); + }), + ); + + const ctx: PluginContext = { + url: "https://www.doubao.com/chat/123", + document: {} as Document, + }; + + const bundle = await doubaoPlugin.extract(ctx); + const content = bundle.nodes[0]!.content; + + // minLevel=3, shift=0 → unchanged + expect(content).toContain("### 三级标题"); + expect(content).toContain("#### 四级标题"); + }); + + it("leaves 7+ hashes unchanged (not valid ATX headings)", async () => { + const chainResponse = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: [ + { + conversation_id: "123", + message_id: "m1", + sender_id: "bot1", + user_type: 2, + status: 0, + content_type: 0, + content: "", + content_status: 0, + index_in_conv: "1", + create_time: "1700000000", + thinking_content: "", + content_block: [ + { + block_type: 10000, + block_id: "b1", + parent_id: "", + content: { + text_block: { + text: [ + "####### 七个井号不是标题", + "## 正常二级", + ].join("\n"), + }, + }, + }, + ], + }, + ], + has_more: false, + }, + }, + }; + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + if (url.includes("/im/conversation/info")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(chainResponse), + }); + }), + ); + + const ctx: PluginContext = { + url: "https://www.doubao.com/chat/123", + document: {} as Document, + }; + + const bundle = await doubaoPlugin.extract(ctx); + const content = bundle.nodes[0]!.content; + + // minLevel=2 (##), shift=1 → ##→###; 7+ hashes unchanged + expect(content).toContain("####### 七个井号不是标题"); + expect(content).toContain("### 正常二级"); + }); + + it("leaves content unchanged when there are no headings", async () => { + const chainResponse = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: [ + { + conversation_id: "123", + message_id: "m1", + sender_id: "bot1", + user_type: 2, + status: 0, + content_type: 0, + content: "", + content_status: 0, + index_in_conv: "1", + create_time: "1700000000", + thinking_content: "", + content_block: [ + { + block_type: 10000, + block_id: "b1", + parent_id: "", + content: { + text_block: { + text: [ + "这是普通文本", + "没有任何标题", + "只有段落和换行", + ].join("\n"), + }, + }, + }, + ], + }, + ], + has_more: false, + }, + }, + }; + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + if (url.includes("/im/conversation/info")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(chainResponse), + }); + }), + ); + + const ctx: PluginContext = { + url: "https://www.doubao.com/chat/123", + document: {} as Document, + }; + + const bundle = await doubaoPlugin.extract(ctx); + const content = bundle.nodes[0]!.content; + + // minLevel=Infinity (no headings), shift=0 → completely unchanged + expect(content).toContain("这是普通文本"); + expect(content).toContain("没有任何标题"); + expect(content).toContain("只有段落和换行"); + }); + + it("leaves content unchanged when headings only appear inside code fences", async () => { + const chainResponse = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: [ + { + conversation_id: "123", + message_id: "m1", + sender_id: "bot1", + user_type: 2, + status: 0, + content_type: 0, + content: "", + content_status: 0, + index_in_conv: "1", + create_time: "1700000000", + thinking_content: "", + content_block: [ + { + block_type: 10000, + block_id: "b1", + parent_id: "", + content: { + text_block: { + text: [ + "这是一段说明", + "", + "```markdown", + "# 代码块内的标题", + "## 另一个标题", + "```", + "", + "后面也没有标题", + ].join("\n"), + }, + }, + }, + ], + }, + ], + has_more: false, + }, + }, + }; + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + if (url.includes("/im/conversation/info")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(chainResponse), + }); + }), + ); + + const ctx: PluginContext = { + url: "https://www.doubao.com/chat/123", + document: {} as Document, + }; + + const bundle = await doubaoPlugin.extract(ctx); + const content = bundle.nodes[0]!.content; + + // minLevel=Infinity (headings only in fences), shift=0 → unchanged + expect(content).toContain("# 代码块内的标题"); + expect(content).toContain("## 另一个标题"); + expect(content).toContain("这是一段说明"); + }); + + it("caps heading level at 6 when shift would exceed it", async () => { + const chainResponse = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: [ + { + conversation_id: "123", + message_id: "m1", + sender_id: "bot1", + user_type: 2, + status: 0, + content_type: 0, + content: "", + content_status: 0, + index_in_conv: "1", + create_time: "1700000000", + thinking_content: "", + content_block: [ + { + block_type: 10000, + block_id: "b1", + parent_id: "", + content: { + text_block: { + text: [ + "# 一级标题", + "正文", + "##### 五级标题", + "更多正文", + ].join("\n"), + }, + }, + }, + ], + }, + ], + has_more: false, + }, + }, + }; + + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + if (url.includes("/im/conversation/info")) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ cmd: 1110, downlink_body: {} }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(chainResponse), + }); + }), + ); + + const ctx: PluginContext = { + url: "https://www.doubao.com/chat/123", + document: {} as Document, + }; + + const bundle = await doubaoPlugin.extract(ctx); + const content = bundle.nodes[0]!.content; + + // minLevel=1 (#), shift=2 → #→###, #####→###### (capped at 6, not 7) + expect(content).toContain("### 一级标题"); + expect(content).toContain("###### 五级标题"); + // Should NOT have 7+ hashes + expect(content).not.toContain("#######"); + }); + }); }); diff --git a/packages/core-plugins/src/plugins/doubao/plugin.ts b/packages/core-plugins/src/plugins/doubao/plugin.ts index 5a667ec..58dacc2 100644 --- a/packages/core-plugins/src/plugins/doubao/plugin.ts +++ b/packages/core-plugins/src/plugins/doubao/plugin.ts @@ -48,6 +48,12 @@ export const doubaoPlugin: Plugin = { copyButtonSelectors: [ // Right-aligned container in the header row (next to share button) 'main div[class*="header-height"] > .justify-end', + // Fallback: broader header area in main + 'main [class*="header"] .justify-end', + // Fallback: any div with header class containing flex-end area + 'div[class*="header"] > .justify-end', + // Fallback: standard HTML header element with action area + 'header .justify-end', ], copyButtonPosition: "prepend", listItemLinkSelector: 'nav a[href^="/chat/"]', @@ -205,6 +211,97 @@ async function fetchAllMessages( return allMessages; } +// --- Markdown heading demotion --- + +/** Section header level in output markdown (## = level 2) */ +const SECTION_LEVEL = 2; + +/** + * Find the highest (smallest number) ATX heading level in text, + * skipping fenced code blocks. Returns Infinity if no headings found. + */ +function findMinHeadingLevel(text: string): number { + const fencePattern = /```[\s\S]*?```/g; + const headingPattern = /^[ \t]{0,3}(#{1,6})\s/gm; + let minLevel = Infinity; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = fencePattern.exec(text)) !== null) { + // Scan non-fence text before this fence + const segment = text.slice(lastIndex, match.index); + let hm: RegExpExecArray | null; + while ((hm = headingPattern.exec(segment)) !== null) { + const level = hm[1]!.length; + if (level < minLevel) minLevel = level; + } + lastIndex = fencePattern.lastIndex; + } + // Scan text after last fence + const remaining = text.slice(lastIndex); + let hm: RegExpExecArray | null; + headingPattern.lastIndex = 0; + while ((hm = headingPattern.exec(remaining)) !== null) { + const level = hm[1]!.length; + if (level < minLevel) minLevel = level; + } + + return minLevel; +} + +/** + * Demote all ATX headings so that the highest heading in the content + * starts at SECTION_LEVEL + 1 (i.e., ###), ensuring all content + * headings nest properly under the ## User / ## Assistant section headers. + * + * Examples: + * Content has `# H1` → shift=2 → `#`→`###`, `###`→`#####` + * Content has `## H2` → shift=1 → `##`→`###`, `###`→`####` + * Content has `### H3` → shift=0 → unchanged (already below ##) + */ +function demoteHeadings(text: string): string { + const minLevel = findMinHeadingLevel(text); + + // How many levels to shift down so all headings sit below ## + const shift = + minLevel <= SECTION_LEVEL ? SECTION_LEVEL + 1 - minLevel : 0; + + if (shift === 0) return text; + + // Split into fence / non-fence segments, shift headings in non-fence parts + const fencePattern = /```[\s\S]*?```/g; + const segments: { text: string; isFence: boolean }[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = fencePattern.exec(text)) !== null) { + if (match.index > lastIndex) { + segments.push({ + text: text.slice(lastIndex, match.index), + isFence: false, + }); + } + segments.push({ text: match[0], isFence: true }); + lastIndex = fencePattern.lastIndex; + } + if (lastIndex < text.length) { + segments.push({ text: text.slice(lastIndex), isFence: false }); + } + + return segments + .map((seg) => { + if (seg.isFence) return seg.text; + return seg.text.replace( + /^([ \t]{0,3})(#{1,6})(\s)/gm, + (_full, ws: string, hashes: string, sp: string) => { + const newLevel = Math.min(hashes.length + shift, 6); + return ws + "#".repeat(newLevel) + sp; + }, + ); + }) + .join(""); +} + // --- Parse conversation into ContentBundle --- function extractMessageText(message: DoubaoMessage): string { @@ -272,7 +369,7 @@ function parseConversation( const contentNodes: ContentBundle["nodes"] = grouped.map((msg, index) => ({ id: generateId(), participantId: msg.role === "user" ? "user" : "assistant", - content: msg.text, + content: demoteHeadings(msg.text), order: index, type: "message", })); From 7ca81f1962a5c4576963442889a77f908a18df1e Mon Sep 17 00:00:00 2001 From: QiMing Date: Thu, 14 May 2026 09:49:08 +0800 Subject: [PATCH 2/2] style: fix prettier formatting in doubao plugin and tests Co-Authored-By: Claude Opus 4.7 --- .../src/plugins/doubao/__tests__/plugin.test.ts | 15 ++++++--------- .../core-plugins/src/plugins/doubao/plugin.ts | 5 ++--- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts b/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts index 23364ea..042239d 100644 --- a/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts +++ b/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts @@ -734,11 +734,9 @@ describe("doubaoPlugin", () => { parent_id: "", content: { text_block: { - text: [ - "### 三级标题", - "正文", - "#### 四级标题", - ].join("\n"), + text: ["### 三级标题", "正文", "#### 四级标题"].join( + "\n", + ), }, }, }, @@ -804,10 +802,9 @@ describe("doubaoPlugin", () => { parent_id: "", content: { text_block: { - text: [ - "####### 七个井号不是标题", - "## 正常二级", - ].join("\n"), + text: ["####### 七个井号不是标题", "## 正常二级"].join( + "\n", + ), }, }, }, diff --git a/packages/core-plugins/src/plugins/doubao/plugin.ts b/packages/core-plugins/src/plugins/doubao/plugin.ts index 58dacc2..d348a9d 100644 --- a/packages/core-plugins/src/plugins/doubao/plugin.ts +++ b/packages/core-plugins/src/plugins/doubao/plugin.ts @@ -53,7 +53,7 @@ export const doubaoPlugin: Plugin = { // Fallback: any div with header class containing flex-end area 'div[class*="header"] > .justify-end', // Fallback: standard HTML header element with action area - 'header .justify-end', + "header .justify-end", ], copyButtonPosition: "prepend", listItemLinkSelector: 'nav a[href^="/chat/"]', @@ -263,8 +263,7 @@ function demoteHeadings(text: string): string { const minLevel = findMinHeadingLevel(text); // How many levels to shift down so all headings sit below ## - const shift = - minLevel <= SECTION_LEVEL ? SECTION_LEVEL + 1 - minLevel : 0; + const shift = minLevel <= SECTION_LEVEL ? SECTION_LEVEL + 1 - minLevel : 0; if (shift === 0) return text;