Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions cli-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand All @@ -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": [
Expand Down Expand Up @@ -16833,4 +16852,4 @@
"modulePath": "zsxq/topics.js",
"sourceFile": "zsxq/topics.js"
}
]
]
7 changes: 6 additions & 1 deletion clis/doubao/ask.js
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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);
Expand Down
65 changes: 65 additions & 0 deletions clis/doubao/ask.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
12 changes: 9 additions & 3 deletions clis/doubao/read.js
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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;
Expand Down
49 changes: 49 additions & 0 deletions clis/doubao/read.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
11 changes: 9 additions & 2 deletions clis/doubao/send.js
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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',
Expand Down
48 changes: 48 additions & 0 deletions clis/doubao/send.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
41 changes: 36 additions & 5 deletions clis/doubao/utils.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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/<id> 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 `
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 19 additions & 2 deletions clis/doubao/utils.test.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
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');
});
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', () => {
Expand Down
4 changes: 4 additions & 0 deletions docs/adapters/browser/doubao.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id|url>` 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`