From a23b50406c3c673a5e2f9348ddbe40cc12e4fa29 Mon Sep 17 00:00:00 2001 From: hanxuanliang Date: Sat, 11 Apr 2026 20:48:37 +0800 Subject: [PATCH] feat(doubao): add thread targeting for ask send read --- cli-manifest.json | 23 +++++++++++- clis/doubao/ask.js | 7 +++- clis/doubao/ask.test.js | 65 +++++++++++++++++++++++++++++++++ clis/doubao/read.js | 12 ++++-- clis/doubao/read.test.js | 49 +++++++++++++++++++++++++ clis/doubao/send.js | 11 +++++- clis/doubao/send.test.js | 48 ++++++++++++++++++++++++ clis/doubao/utils.js | 41 ++++++++++++++++++--- clis/doubao/utils.test.js | 21 ++++++++++- docs/adapters/browser/doubao.md | 4 ++ 10 files changed, 266 insertions(+), 15 deletions(-) create mode 100644 clis/doubao/ask.test.js create mode 100644 clis/doubao/read.test.js create mode 100644 clis/doubao/send.test.js diff --git a/cli-manifest.json b/cli-manifest.json index fea0100f5..f1f341cf6 100644 --- a/cli-manifest.json +++ b/cli-manifest.json @@ -4223,6 +4223,12 @@ "positional": true, "help": "Prompt to send" }, + { + "name": "thread", + "type": "str", + "required": false, + "help": "Conversation ID (numeric or full URL)" + }, { "name": "timeout", "type": "str", @@ -4381,7 +4387,14 @@ "domain": "www.doubao.com", "strategy": "cookie", "browser": true, - "args": [], + "args": [ + { + "name": "thread", + "type": "str", + "required": false, + "help": "Conversation ID (numeric or full URL)" + } + ], "columns": [ "Role", "Text" @@ -4405,6 +4418,12 @@ "required": true, "positional": true, "help": "Message to send" + }, + { + "name": "thread", + "type": "str", + "required": false, + "help": "Conversation ID (numeric or full URL)" } ], "columns": [ @@ -16833,4 +16852,4 @@ "modulePath": "zsxq/topics.js", "sourceFile": "zsxq/topics.js" } -] \ No newline at end of file +] diff --git a/clis/doubao/ask.js b/clis/doubao/ask.js index 3d7c63936..ef50a7522 100644 --- a/clis/doubao/ask.js +++ b/clis/doubao/ask.js @@ -1,5 +1,5 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { DOUBAO_DOMAIN, getDoubaoTranscriptLines, getDoubaoVisibleTurns, sendDoubaoMessage, waitForDoubaoResponse } from './utils.js'; +import { DOUBAO_DOMAIN, getDoubaoTranscriptLines, getDoubaoVisibleTurns, navigateToConversation, parseDoubaoConversationId, sendDoubaoMessage, waitForDoubaoResponse } from './utils.js'; export const askCommand = cli({ site: 'doubao', name: 'ask', @@ -11,12 +11,17 @@ export const askCommand = cli({ timeoutSeconds: 180, args: [ { name: 'text', required: true, positional: true, help: 'Prompt to send' }, + { name: 'thread', required: false, help: 'Conversation ID (numeric or full URL)' }, { name: 'timeout', required: false, help: 'Max seconds to wait (default: 60)', default: '60' }, ], columns: ['Role', 'Text'], func: async (page, kwargs) => { const text = kwargs.text; + const thread = typeof kwargs.thread === 'string' ? kwargs.thread.trim() : ''; const timeout = parseInt(kwargs.timeout, 10) || 60; + if (thread) { + await navigateToConversation(page, parseDoubaoConversationId(thread)); + } const beforeTurns = await getDoubaoVisibleTurns(page); const beforeLines = await getDoubaoTranscriptLines(page); await sendDoubaoMessage(page, text); diff --git a/clis/doubao/ask.test.js b/clis/doubao/ask.test.js new file mode 100644 index 000000000..21cbd0c54 --- /dev/null +++ b/clis/doubao/ask.test.js @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getDoubaoVisibleTurns: vi.fn(), + getDoubaoTranscriptLines: vi.fn(), + navigateToConversation: vi.fn(), + sendDoubaoMessage: vi.fn(), + waitForDoubaoResponse: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual('./utils.js'); + return { + ...actual, + getDoubaoVisibleTurns: mocks.getDoubaoVisibleTurns, + getDoubaoTranscriptLines: mocks.getDoubaoTranscriptLines, + navigateToConversation: mocks.navigateToConversation, + sendDoubaoMessage: mocks.sendDoubaoMessage, + waitForDoubaoResponse: mocks.waitForDoubaoResponse, + }; +}); + +import { askCommand } from './ask.js'; + +function createPageMock() { + return { + wait: vi.fn().mockResolvedValue(undefined), + }; +} + +describe('doubao ask --thread', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getDoubaoVisibleTurns.mockResolvedValue([]); + mocks.getDoubaoTranscriptLines.mockResolvedValue([]); + mocks.sendDoubaoMessage.mockResolvedValue('button'); + mocks.waitForDoubaoResponse.mockResolvedValue('继续'); + }); + + it('navigates to the requested conversation id before sending', async () => { + const page = createPageMock(); + + await askCommand.func(page, { + text: '继续', + thread: 'https://www.doubao.com/chat/1234567890123', + timeout: '60', + }); + + expect(mocks.navigateToConversation).toHaveBeenCalledWith(page, '1234567890123'); + expect(mocks.sendDoubaoMessage).toHaveBeenCalledWith(page, '继续'); + }); + + it('rejects malformed thread ids before sending', async () => { + const page = createPageMock(); + + await expect(askCommand.func(page, { + text: '继续', + thread: '123', + timeout: '60', + })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); + + expect(mocks.navigateToConversation).not.toHaveBeenCalled(); + expect(mocks.sendDoubaoMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/clis/doubao/read.js b/clis/doubao/read.js index a3a883b9d..fbc0d51af 100644 --- a/clis/doubao/read.js +++ b/clis/doubao/read.js @@ -1,5 +1,5 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { DOUBAO_DOMAIN, getDoubaoVisibleTurns } from './utils.js'; +import { DOUBAO_DOMAIN, getDoubaoVisibleTurns, navigateToConversation, parseDoubaoConversationId } from './utils.js'; export const readCommand = cli({ site: 'doubao', name: 'read', @@ -8,9 +8,15 @@ export const readCommand = cli({ strategy: Strategy.COOKIE, browser: true, navigateBefore: false, - args: [], + args: [ + { name: 'thread', required: false, help: 'Conversation ID (numeric or full URL)' }, + ], columns: ['Role', 'Text'], - func: async (page) => { + func: async (page, kwargs) => { + const thread = typeof kwargs.thread === 'string' ? kwargs.thread.trim() : ''; + if (thread) { + await navigateToConversation(page, parseDoubaoConversationId(thread)); + } const turns = await getDoubaoVisibleTurns(page); if (turns.length > 0) return turns; diff --git a/clis/doubao/read.test.js b/clis/doubao/read.test.js new file mode 100644 index 000000000..5cd35eb1d --- /dev/null +++ b/clis/doubao/read.test.js @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getDoubaoVisibleTurns: vi.fn(), + navigateToConversation: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual('./utils.js'); + return { + ...actual, + getDoubaoVisibleTurns: mocks.getDoubaoVisibleTurns, + navigateToConversation: mocks.navigateToConversation, + }; +}); + +import { readCommand } from './read.js'; + +describe('doubao read --thread', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.getDoubaoVisibleTurns.mockResolvedValue([ + { Role: 'Assistant', Text: '这是指定会话' }, + ]); + }); + + it('navigates to the requested conversation id before reading', async () => { + const page = {}; + + const result = await readCommand.func(page, { + thread: 'https://www.doubao.com/chat/1234567890123', + }); + + expect(mocks.navigateToConversation).toHaveBeenCalledWith(page, '1234567890123'); + expect(result).toEqual([ + { Role: 'Assistant', Text: '这是指定会话' }, + ]); + }); + + it('rejects malformed thread ids before reading', async () => { + const page = {}; + + await expect(readCommand.func(page, { + thread: '123', + })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); + + expect(mocks.navigateToConversation).not.toHaveBeenCalled(); + }); +}); diff --git a/clis/doubao/send.js b/clis/doubao/send.js index d1bb46c51..6d450b6a6 100644 --- a/clis/doubao/send.js +++ b/clis/doubao/send.js @@ -1,5 +1,5 @@ import { cli, Strategy } from '@jackwener/opencli/registry'; -import { DOUBAO_DOMAIN, sendDoubaoMessage } from './utils.js'; +import { DOUBAO_DOMAIN, navigateToConversation, parseDoubaoConversationId, sendDoubaoMessage } from './utils.js'; export const sendCommand = cli({ site: 'doubao', name: 'send', @@ -8,10 +8,17 @@ export const sendCommand = cli({ strategy: Strategy.COOKIE, browser: true, navigateBefore: false, - args: [{ name: 'text', required: true, positional: true, help: 'Message to send' }], + args: [ + { name: 'text', required: true, positional: true, help: 'Message to send' }, + { name: 'thread', required: false, help: 'Conversation ID (numeric or full URL)' }, + ], columns: ['Status', 'SubmittedBy', 'InjectedText'], func: async (page, kwargs) => { const text = kwargs.text; + const thread = typeof kwargs.thread === 'string' ? kwargs.thread.trim() : ''; + if (thread) { + await navigateToConversation(page, parseDoubaoConversationId(thread)); + } const submittedBy = await sendDoubaoMessage(page, text); return [{ Status: 'Success', diff --git a/clis/doubao/send.test.js b/clis/doubao/send.test.js new file mode 100644 index 000000000..900416194 --- /dev/null +++ b/clis/doubao/send.test.js @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + navigateToConversation: vi.fn(), + sendDoubaoMessage: vi.fn(), +})); + +vi.mock('./utils.js', async () => { + const actual = await vi.importActual('./utils.js'); + return { + ...actual, + navigateToConversation: mocks.navigateToConversation, + sendDoubaoMessage: mocks.sendDoubaoMessage, + }; +}); + +import { sendCommand } from './send.js'; + +describe('doubao send --thread', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.sendDoubaoMessage.mockResolvedValue('button'); + }); + + it('navigates to the requested conversation id before sending', async () => { + const page = {}; + + await sendCommand.func(page, { + text: '补充一句', + thread: '1234567890123', + }); + + expect(mocks.navigateToConversation).toHaveBeenCalledWith(page, '1234567890123'); + expect(mocks.sendDoubaoMessage).toHaveBeenCalledWith(page, '补充一句'); + }); + + it('rejects malformed thread ids before sending', async () => { + const page = {}; + + await expect(sendCommand.func(page, { + text: '补充一句', + thread: '123', + })).rejects.toMatchObject({ code: 'INVALID_INPUT' }); + + expect(mocks.navigateToConversation).not.toHaveBeenCalled(); + expect(mocks.sendDoubaoMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/clis/doubao/utils.js b/clis/doubao/utils.js index 525292dac..72ed9d448 100644 --- a/clis/doubao/utils.js +++ b/clis/doubao/utils.js @@ -1,3 +1,5 @@ +import { CliError } from '@jackwener/opencli/errors'; + export const DOUBAO_DOMAIN = 'www.doubao.com'; export const DOUBAO_CHAT_URL = 'https://www.doubao.com/chat'; export const DOUBAO_NEW_CHAT_URL = 'https://www.doubao.com/chat/new-thread/create-by-msg'; @@ -608,9 +610,30 @@ export async function getDoubaoConversationList(page) { Url: `${DOUBAO_CHAT_URL}/${item.id}`, })); } +function buildInvalidDoubaoThreadError() { + return new CliError('INVALID_INPUT', 'Invalid Doubao thread id or URL', 'Pass a numeric conversation ID or a full https://www.doubao.com/chat/ URL.'); +} export function parseDoubaoConversationId(input) { - const match = input.match(/(\d{10,})/); - return match ? match[1] : input; + const raw = typeof input === 'string' ? input.trim() : ''; + if (!raw) { + throw buildInvalidDoubaoThreadError(); + } + if (/^\d{10,}$/.test(raw)) { + return raw; + } + let parsedUrl; + try { + parsedUrl = new URL(raw); + } + catch { + throw buildInvalidDoubaoThreadError(); + } + const pathname = parsedUrl.pathname.replace(/\/+$/, ''); + const match = pathname.match(/^\/chat\/(\d{10,})$/); + if (parsedUrl.origin === 'https://www.doubao.com' && match) { + return match[1]; + } + throw buildInvalidDoubaoThreadError(); } function getConversationDetailScript() { return ` @@ -651,9 +674,17 @@ function getConversationDetailScript() { export async function navigateToConversation(page, conversationId) { const url = `${DOUBAO_CHAT_URL}/${conversationId}`; const currentUrl = await page.evaluate('window.location.href').catch(() => ''); - if (typeof currentUrl === 'string' && currentUrl.includes(`/chat/${conversationId}`)) { - await page.wait(1); - return; + if (typeof currentUrl === 'string') { + try { + const current = new URL(currentUrl); + if (current.origin === 'https://www.doubao.com' && current.pathname.replace(/\/+$/, '') === `/chat/${conversationId}`) { + await page.wait(1); + return; + } + } + catch { + // Ignore malformed current URLs and fall through to explicit navigation. + } } await page.goto(url, { waitUntil: 'load', settleMs: 3000 }); await page.wait(2); diff --git a/clis/doubao/utils.test.js b/clis/doubao/utils.test.js index 6101060e6..8dda24ed1 100644 --- a/clis/doubao/utils.test.js +++ b/clis/doubao/utils.test.js @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { mergeTranscriptSnapshots, parseDoubaoConversationId } from './utils.js'; +import { describe, expect, it, vi } from 'vitest'; +import { mergeTranscriptSnapshots, navigateToConversation, parseDoubaoConversationId } from './utils.js'; describe('parseDoubaoConversationId', () => { it('extracts the numeric id from a full conversation URL', () => { expect(parseDoubaoConversationId('https://www.doubao.com/chat/1234567890123')).toBe('1234567890123'); @@ -7,6 +7,23 @@ describe('parseDoubaoConversationId', () => { it('keeps a raw id unchanged', () => { expect(parseDoubaoConversationId('1234567890123')).toBe('1234567890123'); }); + it('rejects partial numeric ids', () => { + expect(() => parseDoubaoConversationId('123')).toThrowError('Invalid Doubao thread id or URL'); + }); + it('rejects non-doubao chat urls', () => { + expect(() => parseDoubaoConversationId('https://example.com/chat/1234567890123')).toThrowError('Invalid Doubao thread id or URL'); + }); +}); +describe('navigateToConversation', () => { + it('does not treat a longer current conversation id as an exact match', async () => { + const page = { + evaluate: vi.fn().mockResolvedValue('https://www.doubao.com/chat/12345678901234'), + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), + }; + await navigateToConversation(page, '1234567890123'); + expect(page.goto).toHaveBeenCalledWith('https://www.doubao.com/chat/1234567890123', { waitUntil: 'load', settleMs: 3000 }); + }); }); describe('mergeTranscriptSnapshots', () => { it('extends the transcript when the next snapshot overlaps with the tail', () => { diff --git a/docs/adapters/browser/doubao.md b/docs/adapters/browser/doubao.md index 2e257f663..772877ed6 100644 --- a/docs/adapters/browser/doubao.md +++ b/docs/adapters/browser/doubao.md @@ -28,12 +28,16 @@ Browser adapter for [Doubao Chat](https://www.doubao.com/chat). opencli doubao status opencli doubao new opencli doubao send "帮我总结这段文档" +opencli doubao send --thread 1234567890123 "补充一句" opencli doubao read +opencli doubao read --thread https://www.doubao.com/chat/1234567890123 opencli doubao ask "请写一个 Python 快速排序示例" --timeout 90 +opencli doubao ask --thread 1234567890123 "继续刚才那个思路" ``` ## Notes - The adapter targets the web chat page at `https://www.doubao.com/chat` +- `send`, `read`, and `ask` accept `--thread ` to continue an existing conversation explicitly - `new` first tries the visible "New Chat / 新对话" button, then falls back to the new-thread route - `ask` uses DOM polling, so very long generations may need a larger `--timeout`