diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 92151c9..015df6b 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -130,8 +130,22 @@ jobs: name: website-build path: ${{ env.WEBSITE_DIR }} + - name: Resolve preview deploy eligibility + id: preview_eligibility + shell: bash + env: + CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CF_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + if [[ -n "${CF_API_TOKEN}" && -n "${CF_ACCOUNT_ID}" ]]; then + echo "can_deploy=true" >> "$GITHUB_OUTPUT" + else + echo "can_deploy=false" >> "$GITHUB_OUTPUT" + fi + - name: Deploy Preview to Cloudflare id: deploy + if: ${{ steps.preview_eligibility.outputs.can_deploy == 'true' }} working-directory: ${{ env.WEBSITE_DIR }} run: pnpm dlx @opennextjs/cloudflare deploy --env preview env: @@ -139,25 +153,57 @@ jobs: CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - name: Upsert PR comment with preview URL (no spam) + if: ${{ always() && (steps.deploy.outcome == 'success' || steps.deploy.outcome == 'skipped') && github.event.pull_request.head.repo.fork == false }} uses: actions/github-script@v8 env: MARKER: ${{ env.PREVIEW_COMMENT_MARKER }} DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} + DEPLOY_OUTCOME: ${{ steps.deploy.outcome }} + HEAD_IS_FORK: ${{ github.event.pull_request.head.repo.fork }} + HAS_CF_SECRETS: ${{ steps.preview_eligibility.outputs.can_deploy }} with: script: | const marker = process.env.MARKER; - const url = process.env.DEPLOYMENT_URL || "Check the Cloudflare dashboard for the preview URL."; + const url = process.env.DEPLOYMENT_URL || ""; + const deployOutcome = (process.env.DEPLOY_OUTCOME || "").toLowerCase(); + const isFork = (process.env.HEAD_IS_FORK || "").toLowerCase() === "true"; + const hasCfSecrets = (process.env.HAS_CF_SECRETS || "").toLowerCase() === "true"; + + const baseMeta = `- Branch: \`${context.payload.pull_request.head.ref}\` + - Commit: \`${context.sha}\``; + + let body; + if (deployOutcome === "success") { + body = `${marker} + ## 🚀 Preview Deployment Ready! + + Preview URL: ${url || "Check the Cloudflare dashboard for the preview URL."} - const body = `${marker} - ## 🚀 Preview Deployment Ready! + ${baseMeta} - Preview URL: ${url} + (This comment will be updated on new pushes.) + `; + } else if (!hasCfSecrets && isFork) { + body = `${marker} + ## ℹ️ Preview Deployment Skipped - - Branch: \`${context.payload.pull_request.head.ref}\` - - Commit: \`${context.sha}\` + This PR comes from a fork, and repository Cloudflare secrets are not exposed to \`pull_request\` workflows. - (This comment will be updated on new pushes.) - `; + ${baseMeta} + + (This comment will be updated on new pushes.) + `; + } else { + body = `${marker} + ## ℹ️ Preview Deployment Skipped + + Preview deployment was skipped because required Cloudflare secrets are missing. + + ${baseMeta} + + (This comment will be updated on new pushes.) + `; + } const { owner, repo } = context.repo; const issue_number = context.issue.number; diff --git a/README.md b/README.md index 0bc4cb5..13a356f 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Your AI conversations deserve a better clipboard. [![Gemini](https://img.shields.io/badge/Gemini-8E75B2?style=for-the-badge&logo=google&logoColor=white)](https://gemini.google.com) [![DeepSeek](https://img.shields.io/badge/DeepSeek-0066FF?style=for-the-badge&logo=deepseek&logoColor=white)](https://chat.deepseek.com) [![Grok](https://img.shields.io/badge/Grok-000000?style=for-the-badge&logo=x&logoColor=white)](https://grok.com) +[![Doubao](https://img.shields.io/badge/豆包-4e6ef2?style=for-the-badge&logoColor=white)](https://www.doubao.com) [![GitHub](https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com)
@@ -167,7 +168,7 @@ Then load the unpacked extension from `apps/browser-extension/dist/chrome-mv3-de ### Usage -1. Navigate to any supported platform (ChatGPT, Claude, Gemini, DeepSeek, Grok, GitHub) +1. Navigate to any supported platform (ChatGPT, Claude, Gemini, DeepSeek, Grok, Doubao, GitHub) 2. Start or open a conversation 3. Click the **CtxPort copy button** that appears in the chat, or press `Alt+Shift+C` 4. Paste your Context Bundle wherever you need it @@ -183,6 +184,7 @@ For sidebar list copy: hover over any conversation in the left sidebar to reveal - [x] Gemini support - [x] DeepSeek support - [x] Grok support +- [x] Doubao (豆包) support - [x] GitHub Issues & PRs support - [x] Sidebar list copy - [x] Multiple copy formats diff --git a/README_cn.md b/README_cn.md index 3904c37..fe79b9b 100644 --- a/README_cn.md +++ b/README_cn.md @@ -22,6 +22,7 @@ [![Gemini](https://img.shields.io/badge/Gemini-8E75B2?style=for-the-badge&logo=google&logoColor=white)](https://gemini.google.com) [![DeepSeek](https://img.shields.io/badge/DeepSeek-0066FF?style=for-the-badge&logo=deepseek&logoColor=white)](https://chat.deepseek.com) [![Grok](https://img.shields.io/badge/Grok-000000?style=for-the-badge&logo=x&logoColor=white)](https://grok.com) +[![Doubao](https://img.shields.io/badge/豆包-4e6ef2?style=for-the-badge&logoColor=white)](https://www.doubao.com) [![GitHub](https://img.shields.io/badge/GitHub-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com)
@@ -167,7 +168,7 @@ pnpm dev:ext ### 使用方法 -1. 打开任何支持的平台(ChatGPT, Claude, Gemini, DeepSeek, Grok, GitHub) +1. 打开任何支持的平台(ChatGPT, Claude, Gemini, DeepSeek, Grok, 豆包, GitHub) 2. 开始或打开一段对话 3. 点击对话中出现的 **CtxPort 复制按钮**,或按 `Alt+Shift+C` 4. 将 Context Bundle 粘贴到任何你需要的地方 @@ -183,6 +184,7 @@ pnpm dev:ext - [x] Gemini 支持 - [x] DeepSeek 支持 - [x] Grok 支持 +- [x] 豆包 (Doubao) 支持 - [x] GitHub Issues & PRs 支持 - [x] 侧边栏列表复制 - [x] 多种复制格式 diff --git a/apps/web/content/en/supported-platforms.mdx b/apps/web/content/en/supported-platforms.mdx index d3a3327..c238eb1 100644 --- a/apps/web/content/en/supported-platforms.mdx +++ b/apps/web/content/en/supported-platforms.mdx @@ -1,6 +1,6 @@ --- title: "Supported Platforms" -description: "CtxPort supported platforms: ChatGPT, Claude, Gemini, DeepSeek, Grok, and GitHub. Full feature matrix for each platform." +description: "CtxPort supported platforms: ChatGPT, Claude, Gemini, DeepSeek, Grok, Doubao, and GitHub. Full feature matrix for each platform." --- # Supported Platforms @@ -54,6 +54,16 @@ CtxPort works with the most popular AI chat platforms and GitHub. Here's what's - All four copy formats (Full, User Only, Code Only, Compact) - Context menu support +## Doubao + +**URL:** [www.doubao.com](https://www.doubao.com) + +- In-chat copy button +- Sidebar list copy (hover to copy without opening) +- Keyboard shortcut (Alt+Shift+C) +- All four copy formats (Full, User Only, Code Only, Compact) +- Context menu support + ## GitHub **URL:** [github.com](https://github.com) @@ -65,12 +75,12 @@ CtxPort works with the most popular AI chat platforms and GitHub. Here's what's ## Feature Support Matrix -| Feature | ChatGPT | Claude | Gemini | DeepSeek | Grok | GitHub | -|---------|---------|--------|--------|----------|------|--------| -| In-chat copy button | Yes | Yes | Yes | Yes | Yes | Yes | -| Sidebar list copy | Yes | Yes | — | — | — | — | -| Keyboard shortcut | Yes | Yes | Yes | Yes | Yes | Yes | -| Full format | Yes | Yes | Yes | Yes | Yes | Yes | -| User Only format | Yes | Yes | Yes | Yes | Yes | Yes | -| Code Only format | Yes | Yes | Yes | Yes | Yes | Yes | -| Compact format | Yes | Yes | Yes | Yes | Yes | Yes | +| Feature | ChatGPT | Claude | Gemini | DeepSeek | Grok | Doubao | GitHub | +|---------|---------|--------|--------|----------|------|--------|--------| +| In-chat copy button | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Sidebar list copy | Yes | Yes | — | — | — | Yes | — | +| Keyboard shortcut | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Full format | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| User Only format | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Code Only format | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Compact format | Yes | Yes | Yes | Yes | Yes | Yes | Yes | diff --git a/apps/web/content/zh/supported-platforms.mdx b/apps/web/content/zh/supported-platforms.mdx index 6fb91c9..2d81898 100644 --- a/apps/web/content/zh/supported-platforms.mdx +++ b/apps/web/content/zh/supported-platforms.mdx @@ -1,6 +1,6 @@ --- title: "支持平台" -description: "CtxPort 支持的平台:ChatGPT、Claude、Gemini、DeepSeek、Grok 和 GitHub。各平台功能支持详情。" +description: "CtxPort 支持的平台:ChatGPT、Claude、Gemini、DeepSeek、Grok、豆包和 GitHub。各平台功能支持详情。" --- # 支持平台 @@ -54,6 +54,16 @@ CtxPort 支持最主流的 AI 聊天平台和 GitHub。以下是各平台的功 - 四种复制格式(Full、User Only、Code Only、Compact) - 右键菜单支持 +## 豆包 + +**地址:** [www.doubao.com](https://www.doubao.com) + +- 对话内复制按钮 +- 侧边栏列表复制(悬停即可复制,无需打开对话) +- 键盘快捷键(Alt+Shift+C) +- 四种复制格式(Full、User Only、Code Only、Compact) +- 右键菜单支持 + ## GitHub **地址:** [github.com](https://github.com) @@ -65,12 +75,12 @@ CtxPort 支持最主流的 AI 聊天平台和 GitHub。以下是各平台的功 ## 功能支持对照表 -| 功能 | ChatGPT | Claude | Gemini | DeepSeek | Grok | GitHub | -|------|---------|--------|--------|----------|------|--------| -| 对话内复制按钮 | Yes | Yes | Yes | Yes | Yes | Yes | -| 侧边栏列表复制 | Yes | Yes | — | — | — | — | -| 键盘快捷键 | Yes | Yes | Yes | Yes | Yes | Yes | -| Full 格式 | Yes | Yes | Yes | Yes | Yes | Yes | -| User Only 格式 | Yes | Yes | Yes | Yes | Yes | Yes | -| Code Only 格式 | Yes | Yes | Yes | Yes | Yes | Yes | -| Compact 格式 | Yes | Yes | Yes | Yes | Yes | Yes | +| 功能 | ChatGPT | Claude | Gemini | DeepSeek | Grok | 豆包 | GitHub | +|------|---------|--------|--------|----------|------|------|--------| +| 对话内复制按钮 | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| 侧边栏列表复制 | Yes | Yes | — | — | — | Yes | — | +| 键盘快捷键 | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Full 格式 | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| User Only 格式 | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Code Only 格式 | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Compact 格式 | Yes | Yes | Yes | Yes | Yes | Yes | Yes | diff --git a/apps/web/src/components/home/landing-page.tsx b/apps/web/src/components/home/landing-page.tsx index 35789d3..d91b073 100644 --- a/apps/web/src/components/home/landing-page.tsx +++ b/apps/web/src/components/home/landing-page.tsx @@ -43,6 +43,7 @@ const PLATFORMS = [ "Gemini", "DeepSeek", "Grok", + "Doubao", "GitHub", ] as const; @@ -621,6 +622,10 @@ function PlatformsSection() { name: t("web.home.platforms.grok.name"), desc: t("web.home.platforms.grok.desc"), }, + { + name: t("web.home.platforms.doubao.name"), + desc: t("web.home.platforms.doubao.desc"), + }, { name: t("web.home.platforms.github.name"), desc: t("web.home.platforms.github.desc"), diff --git a/apps/web/src/components/structured-data.tsx b/apps/web/src/components/structured-data.tsx index b5658dd..42a8ce6 100644 --- a/apps/web/src/components/structured-data.tsx +++ b/apps/web/src/components/structured-data.tsx @@ -5,7 +5,7 @@ const softwareApp = { "@type": "SoftwareApplication", name: "CtxPort", description: - "Copy AI conversations as structured Markdown Context Bundles. One-click copy from ChatGPT, Claude, Gemini, DeepSeek, Grok and more.", + "Copy AI conversations as structured Markdown Context Bundles. One-click copy from ChatGPT, Claude, Gemini, DeepSeek, Grok, Doubao and more.", url: siteConfig.url, applicationCategory: "BrowserApplication", operatingSystem: "Chrome", diff --git a/packages/core-plugins/src/index.ts b/packages/core-plugins/src/index.ts index 6873007..0076938 100644 --- a/packages/core-plugins/src/index.ts +++ b/packages/core-plugins/src/index.ts @@ -25,6 +25,7 @@ export { chatgptPlugin, claudePlugin, deepseekPlugin, + doubaoPlugin, geminiPlugin, githubPlugin, grokPlugin, @@ -34,6 +35,7 @@ export { import { chatgptPlugin } from "./plugins/chatgpt/plugin"; import { claudePlugin } from "./plugins/claude/plugin"; import { deepseekPlugin } from "./plugins/deepseek/plugin"; +import { doubaoPlugin } from "./plugins/doubao/plugin"; import { geminiPlugin } from "./plugins/gemini/plugin"; import { githubPlugin } from "./plugins/github/plugin"; import { grokPlugin } from "./plugins/grok/plugin"; @@ -41,6 +43,7 @@ export const EXTENSION_HOST_PERMISSIONS = [ ...chatgptPlugin.urls.hosts, ...claudePlugin.urls.hosts, ...deepseekPlugin.urls.hosts, + ...doubaoPlugin.urls.hosts, ...geminiPlugin.urls.hosts, ...githubPlugin.urls.hosts, ...grokPlugin.urls.hosts, diff --git a/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts b/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts new file mode 100644 index 0000000..bfc2794 --- /dev/null +++ b/packages/core-plugins/src/plugins/doubao/__tests__/plugin.test.ts @@ -0,0 +1,481 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { PluginContext } from "../../../types"; +import { doubaoPlugin } from "../plugin"; + +describe("doubaoPlugin", () => { + describe("urls.match", () => { + it("matches doubao.com chat URL", () => { + expect(doubaoPlugin.urls.match("https://www.doubao.com/chat/12345")).toBe( + true, + ); + }); + + it("matches doubao.com root URL", () => { + expect(doubaoPlugin.urls.match("https://www.doubao.com/")).toBe(true); + }); + + it("does not match other domains", () => { + expect(doubaoPlugin.urls.match("https://chat.openai.com/")).toBe(false); + expect(doubaoPlugin.urls.match("https://doubao.com/")).toBe(false); + }); + }); + + describe("extract", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("throws on non-conversation URL", async () => { + const ctx: PluginContext = { + url: "https://www.doubao.com/", + document: {} as Document, + }; + await expect(doubaoPlugin.extract(ctx)).rejects.toThrow(); + }); + + it("extracts conversation from API responses", async () => { + const titleResponse = { + cmd: 1110, + downlink_body: { + get_conv_info_downlink_body: { + conversation_info: { name: "测试对话" }, + }, + }, + }; + + const chainResponse = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: [ + { + conversation_id: "123", + message_id: "m1", + sender_id: "u1", + user_type: 1, + status: 0, + content_type: 0, + content: '{"text":"你好"}', + content_status: 0, + index_in_conv: "1", + create_time: "1700000000", + thinking_content: "", + }, + { + conversation_id: "123", + message_id: "m2", + sender_id: "bot1", + user_type: 2, + status: 0, + content_type: 0, + content: "", + content_status: 0, + index_in_conv: "2", + create_time: "1700000001", + thinking_content: "", + content_block: [ + { + block_type: 10000, + block_id: "b1", + parent_id: "", + content: { + text_block: { text: "你好!有什么可以帮你的?" }, + }, + }, + ], + }, + ], + 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(titleResponse), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(chainResponse), + }); + }), + ); + + const ctx: PluginContext = { + url: "https://www.doubao.com/chat/conv_abc-123?foo=bar#history", + document: {} as Document, + }; + + const bundle = await doubaoPlugin.extract(ctx); + + expect(bundle.title).toBe("测试对话"); + expect(bundle.participants).toHaveLength(2); + expect(bundle.nodes).toHaveLength(2); + expect(bundle.nodes[0]!.content).toBe("你好"); + expect(bundle.nodes[0]!.participantId).toBe("user"); + expect(bundle.nodes[1]!.content).toBe("你好!有什么可以帮你的?"); + expect(bundle.nodes[1]!.participantId).toBe("assistant"); + expect(bundle.source.platform).toBe("doubao"); + }); + + it("groups consecutive same-role messages", 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: "第一段" } }, + }, + ], + }, + { + conversation_id: "123", + message_id: "m2", + sender_id: "bot1", + user_type: 2, + status: 0, + content_type: 0, + content: "", + content_status: 0, + index_in_conv: "2", + create_time: "1700000001", + thinking_content: "", + content_block: [ + { + block_type: 10000, + block_id: "b2", + parent_id: "", + content: { text_block: { text: "第二段" } }, + }, + ], + }, + ], + 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); + expect(bundle.nodes).toHaveLength(1); + expect(bundle.nodes[0]!.content).toBe("第一段\n第二段"); + }); + + it("handles JSON-encoded user content", async () => { + const chainResponse = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: [ + { + conversation_id: "123", + message_id: "m1", + sender_id: "u1", + user_type: 1, + status: 0, + content_type: 0, + content: '{"text":"这是用户消息"}', + content_status: 0, + index_in_conv: "1", + create_time: "1700000000", + thinking_content: "", + }, + ], + 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); + expect(bundle.nodes[0]!.content).toBe("这是用户消息"); + }); + + it("paginates when has_more is true", async () => { + // FETCH_LIMIT is 20 — must return >= 20 messages to trigger pagination + const makeMessages = ( + startIndex: number, + count: number, + userType: number, + ) => + Array.from({ length: count }, (_, i) => ({ + conversation_id: "123", + message_id: `m${startIndex + i}`, + sender_id: userType === 1 ? "u1" : "bot1", + user_type: userType, + status: 0, + content_type: 0, + content: userType === 1 ? `{"text":"msg-${startIndex + i}"}` : "", + content_status: 0, + index_in_conv: String(startIndex + i), + create_time: "1700000000", + thinking_content: "", + ...(userType === 2 + ? { + content_block: [ + { + block_type: 10000, + block_id: `b${startIndex + i}`, + parent_id: "", + content: { text_block: { text: `msg-${startIndex + i}` } }, + }, + ], + } + : {}), + })); + + // Page 1: indices 21-40 (20 user messages), has_more=true + const page1 = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: makeMessages(21, 20, 1), + has_more: true, + }, + }, + }; + // Page 2: indices 1-20 (20 assistant messages), has_more=false + const page2 = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: makeMessages(1, 20, 2), + has_more: false, + }, + }, + }; + + let chainCallCount = 0; + 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: {} }), + }); + } + chainCallCount++; + const page = chainCallCount === 1 ? page1 : page2; + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(page), + }); + }), + ); + + const ctx: PluginContext = { + url: "https://www.doubao.com/chat/123", + document: {} as Document, + }; + + const bundle = await doubaoPlugin.extract(ctx); + expect(chainCallCount).toBe(2); + // 20 assistant messages (indices 1-20) grouped into 1 node, + // then 20 user messages (indices 21-40) grouped into 1 node + expect(bundle.nodes).toHaveLength(2); + expect(bundle.nodes[0]!.participantId).toBe("assistant"); + expect(bundle.nodes[1]!.participantId).toBe("user"); + }); + + it("throws on API error response", async () => { + 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: false, status: 500 }); + }), + ); + + const ctx: PluginContext = { + url: "https://www.doubao.com/chat/123", + document: {} as Document, + }; + + await expect(doubaoPlugin.extract(ctx)).rejects.toMatchObject({ + code: "E-PARSE-005", + detail: "Doubao API responded with 500", + }); + }); + + it("falls back to raw content when not JSON", async () => { + const chainResponse = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: [ + { + conversation_id: "123", + message_id: "m1", + sender_id: "u1", + user_type: 1, + status: 0, + content_type: 0, + content: "纯文本内容", + content_status: 0, + index_in_conv: "1", + create_time: "1700000000", + thinking_content: "", + }, + ], + 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); + expect(bundle.nodes[0]!.content).toBe("纯文本内容"); + }); + }); + + describe("fetchById", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("fetches conversation by ID and builds correct URL", async () => { + const chainResponse = { + cmd: 3100, + downlink_body: { + pull_singe_chain_downlink_body: { + messages: [ + { + conversation_id: "456", + message_id: "m1", + sender_id: "u1", + user_type: 1, + status: 0, + content_type: 0, + content: '{"text":"fetchById 测试"}', + content_status: 0, + index_in_conv: "1", + create_time: "1700000000", + thinking_content: "", + }, + ], + 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: { + get_conv_info_downlink_body: { + conversation_info: { name: "远程对话" }, + }, + }, + }), + }); + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(chainResponse), + }); + }), + ); + + const bundle = await doubaoPlugin.fetchById!("456"); + + expect(bundle.title).toBe("远程对话"); + expect(bundle.nodes).toHaveLength(1); + expect(bundle.nodes[0]!.content).toBe("fetchById 测试"); + expect(bundle.source.url).toBe("https://www.doubao.com/chat/456"); + }); + }); +}); diff --git a/packages/core-plugins/src/plugins/doubao/plugin.ts b/packages/core-plugins/src/plugins/doubao/plugin.ts new file mode 100644 index 0000000..5a667ec --- /dev/null +++ b/packages/core-plugins/src/plugins/doubao/plugin.ts @@ -0,0 +1,296 @@ +import type { ContentBundle } from "@ctxport/core-schema"; +import { createAppError } from "@ctxport/core-schema"; +import type { Plugin, PluginContext } from "../../types"; +import { generateId } from "../../utils"; +import { createChatInjector } from "../shared/chat-injector"; +import type { + DoubaoChainResponse, + DoubaoConversationInfoResponse, + DoubaoMessage, +} from "./types"; + +const HOST_PATTERN = /^https:\/\/www\.doubao\.com\//i; +const CONVERSATION_PATTERN = + /^https?:\/\/www\.doubao\.com\/chat\/([a-zA-Z0-9_-]+)(?:[/?#]|$)/; + +const API_BASE = "https://www.doubao.com"; +const API_PARAMS = + "version_code=20800&language=zh&device_platform=web&aid=497858&real_aid=497858&pkg_type=release_version&samantha_web=1&use-olympus-account=1"; + +const FETCH_LIMIT = 20; +const MAX_PAGINATION_PAGES = 100; + +export const doubaoPlugin: Plugin = { + id: "doubao", + version: "1.0.0", + name: "豆包", + + urls: { + hosts: ["https://www.doubao.com/*"], + match: (url) => HOST_PATTERN.test(url), + }, + + async extract(ctx: PluginContext): Promise { + const conversationId = extractConversationId(ctx.url); + if (!conversationId) + throw createAppError("E-PARSE-001", "Not a Doubao conversation page"); + + return fetchAndParse(conversationId, ctx.url); + }, + + async fetchById(conversationId: string): Promise { + const url = `https://www.doubao.com/chat/${conversationId}`; + return fetchAndParse(conversationId, url); + }, + + injector: createChatInjector({ + platform: "doubao", + copyButtonSelectors: [ + // Right-aligned container in the header row (next to share button) + 'main div[class*="header-height"] > .justify-end', + ], + copyButtonPosition: "prepend", + listItemLinkSelector: 'nav a[href^="/chat/"]', + listItemIdPattern: /\/chat\/([a-zA-Z0-9_-]+)(?:[/?#]|$)/, + mainContentSelector: "main", + sidebarSelector: "nav", + }), + + theme: { + light: { + primary: "#4e6ef2", + secondary: "#eef1ff", + fg: "#ffffff", + secondaryFg: "#6366f1", + }, + dark: { + primary: "#4e6ef2", + secondary: "#1a1a2e", + fg: "#ffffff", + secondaryFg: "#a5b4fc", + }, + }, +}; + +// --- Internal helpers --- + +function extractConversationId(url: string): string | null { + const match = CONVERSATION_PATTERN.exec(url); + return match?.[1] ?? null; +} + +async function fetchAndParse( + conversationId: string, + url: string, +): Promise { + const [title, messages] = await Promise.all([ + fetchConversationTitle(conversationId), + fetchAllMessages(conversationId), + ]); + + return parseConversation(messages, title, url); +} + +// --- API: Conversation info --- + +async function fetchConversationTitle( + conversationId: string, +): Promise { + try { + const response = await fetch( + `${API_BASE}/im/conversation/info?${API_PARAMS}`, + { + method: "POST", + headers: { + "Content-Type": "application/json; encoding=utf-8", + Accept: "application/json, text/plain, */*", + "agw-js-conv": "str", + }, + credentials: "include", + body: JSON.stringify({ + cmd: 1110, + uplink_body: { + get_conv_info_uplink_body: { + conversation_id: conversationId, + ext: {}, + bot_id: "", + conversation_type: 3, + option: { need_bot_info: false }, + }, + }, + sequence_id: crypto.randomUUID(), + channel: 2, + version: "1", + }), + }, + ); + + if (!response.ok) return undefined; + + const data = (await response.json()) as DoubaoConversationInfoResponse; + return data.downlink_body?.get_conv_info_downlink_body?.conversation_info + ?.name; + } catch { + return undefined; + } +} + +// --- API: Fetch all messages with pagination --- + +async function fetchAllMessages( + conversationId: string, +): Promise { + const allMessages: DoubaoMessage[] = []; + let anchorIndex = Number.MAX_SAFE_INTEGER; + let hasMore = true; + + for (let page = 0; page < MAX_PAGINATION_PAGES && hasMore; page++) { + const response = await fetch(`${API_BASE}/im/chain/single?${API_PARAMS}`, { + method: "POST", + headers: { + "Content-Type": "application/json; encoding=utf-8", + Accept: "application/json, text/plain, */*", + "agw-js-conv": "str", + }, + credentials: "include", + body: JSON.stringify({ + cmd: 3100, + uplink_body: { + pull_singe_chain_uplink_body: { + conversation_id: conversationId, + anchor_index: anchorIndex, + conversation_type: 3, + direction: 1, + limit: FETCH_LIMIT, + ext: {}, + filter: { index_list: [] }, + }, + }, + sequence_id: crypto.randomUUID(), + channel: 2, + version: "1", + }), + }); + + if (!response.ok) { + throw createAppError( + "E-PARSE-005", + `Doubao API responded with ${response.status}`, + ); + } + + const data = (await response.json()) as DoubaoChainResponse; + const messages = + data.downlink_body?.pull_singe_chain_downlink_body?.messages ?? []; + + if (messages.length === 0) break; + + allMessages.push(...messages); + + // Find smallest index_in_conv for next pagination anchor + const indices = messages + .map((m) => Number.parseInt(m.index_in_conv, 10)) + .filter((n) => !Number.isNaN(n)); + if (indices.length === 0) break; + + const minIndex = Math.min(...indices); + if (minIndex >= anchorIndex) break; // no progress — avoid infinite loop + anchorIndex = minIndex; + + hasMore = + data.downlink_body?.pull_singe_chain_downlink_body?.has_more !== false && + messages.length >= FETCH_LIMIT; + } + + return allMessages; +} + +// --- Parse conversation into ContentBundle --- + +function extractMessageText(message: DoubaoMessage): string { + // Primary: content_block text (assistant messages) + if (message.content_block?.length) { + const texts = message.content_block.flatMap((block) => { + const text = block.content?.text_block?.text; + return text ? [text] : []; + }); + if (texts.length > 0) return texts.join("\n\n"); + } + // Fallback: top-level content field (user messages are JSON-encoded: {"text":"..."}) + if (message.content?.trim()) { + const raw = message.content.trim(); + try { + const parsed = JSON.parse(raw) as { text?: string }; + if (parsed.text) return parsed.text; + } catch { + // Not JSON — use raw content + } + return raw; + } + return ""; +} + +function parseConversation( + messages: DoubaoMessage[], + title: string | undefined, + url: string, +): ContentBundle { + // Sort by index_in_conv ascending (chronological) + const sorted = [...messages].sort( + (a, b) => + Number.parseInt(a.index_in_conv, 10) - + Number.parseInt(b.index_in_conv, 10), + ); + + // Group consecutive same-role messages + interface GroupedMessage { + role: "user" | "assistant"; + text: string; + } + + const grouped: GroupedMessage[] = []; + for (const message of sorted) { + const role = message.user_type === 1 ? "user" : "assistant"; + const text = extractMessageText(message); + if (!text) continue; + + const last = grouped[grouped.length - 1]; + if (last?.role === role) { + last.text = `${last.text}\n${text}`.trim(); + } else { + grouped.push({ role, text }); + } + } + + if (grouped.length === 0) { + throw createAppError( + "E-PARSE-005", + "No messages found in Doubao conversation", + ); + } + + const contentNodes: ContentBundle["nodes"] = grouped.map((msg, index) => ({ + id: generateId(), + participantId: msg.role === "user" ? "user" : "assistant", + content: msg.text, + order: index, + type: "message", + })); + + return { + id: generateId(), + title, + participants: [ + { id: "user", name: "User", role: "user" }, + { id: "assistant", name: "豆包", role: "assistant" }, + ], + nodes: contentNodes, + source: { + platform: "doubao", + url, + extractedAt: new Date().toISOString(), + pluginId: "doubao", + pluginVersion: "1.0.0", + }, + }; +} diff --git a/packages/core-plugins/src/plugins/doubao/types.ts b/packages/core-plugins/src/plugins/doubao/types.ts new file mode 100644 index 0000000..c36ffac --- /dev/null +++ b/packages/core-plugins/src/plugins/doubao/types.ts @@ -0,0 +1,61 @@ +/** Doubao IM chain single response — fetches conversation messages */ +export interface DoubaoChainResponse { + cmd: number; + sequence_id: string; + downlink_body?: { + pull_singe_chain_downlink_body?: { + messages?: DoubaoMessage[]; + has_more?: boolean; + }; + }; +} + +/** Doubao conversation info response */ +export interface DoubaoConversationInfoResponse { + cmd: number; + sequence_id: string; + downlink_body?: { + get_conv_info_downlink_body?: { + conversation_info?: { + conversation_id?: string; + name?: string; + create_time?: string; + update_time?: string; + }; + }; + }; +} + +/** Single message from Doubao IM chain */ +export interface DoubaoMessage { + conversation_id: string; + message_id: string; + sender_id: string; + /** 1 = user, 2 = bot/assistant */ + user_type: number; + status: number; + content_type: number; + content: string; + content_status: number; + /** Position in conversation (numeric string) */ + index_in_conv: string; + create_time: string; + thinking_content: string; + content_block?: DoubaoContentBlock[]; + section_id?: string; +} + +/** Content block within a Doubao message */ +export interface DoubaoContentBlock { + block_type: number; + block_id: string; + parent_id: string; + content?: { + text_block?: { + text: string; + icon_url?: string; + icon_url_dark?: string; + summary?: string; + }; + }; +} diff --git a/packages/core-plugins/src/plugins/index.ts b/packages/core-plugins/src/plugins/index.ts index ce00365..f0f8b7a 100644 --- a/packages/core-plugins/src/plugins/index.ts +++ b/packages/core-plugins/src/plugins/index.ts @@ -2,6 +2,7 @@ import { registerPlugin } from "../registry"; import { chatgptPlugin } from "./chatgpt/plugin"; import { claudePlugin } from "./claude/plugin"; import { deepseekPlugin } from "./deepseek/plugin"; +import { doubaoPlugin } from "./doubao/plugin"; import { geminiPlugin } from "./gemini/plugin"; import { githubPlugin } from "./github/plugin"; import { grokPlugin } from "./grok/plugin"; @@ -10,6 +11,7 @@ export function registerBuiltinPlugins(): void { registerPlugin(chatgptPlugin); registerPlugin(claudePlugin); registerPlugin(deepseekPlugin); + registerPlugin(doubaoPlugin); registerPlugin(geminiPlugin); registerPlugin(githubPlugin); registerPlugin(grokPlugin); @@ -18,6 +20,7 @@ export function registerBuiltinPlugins(): void { export { chatgptPlugin } from "./chatgpt/plugin"; export { claudePlugin } from "./claude/plugin"; export { deepseekPlugin } from "./deepseek/plugin"; +export { doubaoPlugin } from "./doubao/plugin"; export { geminiPlugin } from "./gemini/plugin"; export { githubPlugin } from "./github/plugin"; export { grokPlugin } from "./grok/plugin"; diff --git a/packages/shared-ui/src/i18n/locales/en.ts b/packages/shared-ui/src/i18n/locales/en.ts index ada5bb9..eec264e 100644 --- a/packages/shared-ui/src/i18n/locales/en.ts +++ b/packages/shared-ui/src/i18n/locales/en.ts @@ -185,6 +185,8 @@ const en = { "Copy conversations from chat.deepseek.com", "web.home.platforms.grok.name": "Grok", "web.home.platforms.grok.desc": "Copy conversations from grok.com", + "web.home.platforms.doubao.name": "Doubao", + "web.home.platforms.doubao.desc": "Copy conversations from www.doubao.com", "web.home.platforms.github.name": "GitHub Copilot", "web.home.platforms.github.desc": "Copy conversations from github.com Copilot chat", diff --git a/packages/shared-ui/src/i18n/locales/zh.ts b/packages/shared-ui/src/i18n/locales/zh.ts index 418986d..bc8dd1c 100644 --- a/packages/shared-ui/src/i18n/locales/zh.ts +++ b/packages/shared-ui/src/i18n/locales/zh.ts @@ -165,6 +165,8 @@ const zh: LocaleMessages = { "web.home.platforms.deepseek.desc": "支持 chat.deepseek.com", "web.home.platforms.grok.name": "Grok", "web.home.platforms.grok.desc": "支持 grok.com", + "web.home.platforms.doubao.name": "豆包", + "web.home.platforms.doubao.desc": "支持 www.doubao.com", "web.home.platforms.github.name": "GitHub Copilot", "web.home.platforms.github.desc": "支持 github.com Copilot 聊天",