doubao-cli 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/package.json +1 -1
- package/src/attachments.mjs +158 -0
- package/src/automation.mjs +196 -63
- package/src/cli.mjs +41 -7
package/README.md
CHANGED
|
@@ -24,10 +24,13 @@ doubao status
|
|
|
24
24
|
doubao profiles
|
|
25
25
|
doubao sessions list
|
|
26
26
|
doubao sessions current
|
|
27
|
+
doubao sessions create
|
|
28
|
+
doubao sessions create "summarize the attachment" --attach ./report.pdf --model pro --wait
|
|
27
29
|
doubao sessions open 38439138239851266
|
|
28
30
|
doubao sessions read 38439138239851266 --limit 5
|
|
29
31
|
doubao sessions send 38439138239851266 "hello"
|
|
30
32
|
doubao sessions send 38439138239851266 "hello" --wait
|
|
33
|
+
doubao sessions send 38439138239851266 "compare these files" --attach ./one.pdf --attach ./two.pdf --wait
|
|
31
34
|
doubao models
|
|
32
35
|
doubao model
|
|
33
36
|
doubao model select doubao-2.1-turbo
|
|
@@ -45,7 +48,35 @@ Quit any running Doubao process first, then run `doubao cdp launch`. The equival
|
|
|
45
48
|
|
|
46
49
|
Set `DOUBAO_CDP_ENDPOINT` if using another port. `sessions send --wait` waits for and returns the completed assistant reply.
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
### New sessions and attachments
|
|
52
|
+
|
|
53
|
+
`sessions create` opens a clean composer. A numeric conversation id does not exist until the first message is sent, so `sessions create` without a message returns `conversationId: null`. Create and persist a session in one command by providing its first message:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
doubao sessions create "Start a new task" --model gpt-5.6-sol --wait --json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Attach one or more local files by repeating `--attach`. The CLI validates each path, transfers the file through the authenticated renderer, waits for Doubao to finish uploading it, and only then sends the message:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
doubao sessions create "Summarize these" --attach ./brief.pdf --attach ./notes.md --wait
|
|
63
|
+
doubao sessions send 38439138239851266 "Review this spreadsheet" --attach ./data.xlsx --wait
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Use `--` before message text that contains CLI option names, for example `doubao sessions create -- "Explain --model literally"`.
|
|
67
|
+
|
|
68
|
+
`models` reads the choices currently exposed by the desktop app. `model select` changes the active model, and `sessions send --model` selects a model before sending.
|
|
69
|
+
|
|
70
|
+
| Model | Value | Short aliases |
|
|
71
|
+
| --- | --- | --- |
|
|
72
|
+
| 自动 | `auto` | `自动` |
|
|
73
|
+
| 豆包 2.1 Turbo | `doubao-2.1-turbo` | `turbo` |
|
|
74
|
+
| 豆包 2.1 Pro | `doubao-2.1-pro` | `pro` |
|
|
75
|
+
| Orange 5.0 | `orange-5.0` | `orange` |
|
|
76
|
+
| Gemini 3.7 Flash | `gemini-3.7-flash` | `gemini` |
|
|
77
|
+
| GPT-5.6 Sol | `gpt-5.6-sol` | `gpt`, `sol` |
|
|
78
|
+
|
|
79
|
+
Use the value, exact display name, or a short alias anywhere `<model>` is accepted. Run `doubao models` to verify the choices exposed by the installed Doubao version.
|
|
49
80
|
|
|
50
81
|
CDP is unauthenticated but bound to `127.0.0.1`. Quit and relaunch Doubao normally when automation is no longer needed.
|
|
51
82
|
|
|
@@ -55,12 +86,14 @@ CDP is unauthenticated but bound to `127.0.0.1`. Quit and relaunch Doubao normal
|
|
|
55
86
|
- The current session is recovered from Chromium's local session store.
|
|
56
87
|
- Opening a session uses Doubao's registered `doubao://doubaoapp/open-url` deep-link router.
|
|
57
88
|
- Model discovery and selection use the renderer's semantic menu attributes and native CDP input events.
|
|
89
|
+
- New sessions use Doubao's blank `/chat` route and return the id assigned after the first confirmed send.
|
|
90
|
+
- Attachments are transferred into the renderer through its drop-upload path; file contents and credentials are never printed.
|
|
58
91
|
|
|
59
92
|
No hard-coded UI coordinates, image recognition, Cookie extraction, or private credential copying are involved.
|
|
60
93
|
|
|
61
94
|
## Limits
|
|
62
95
|
|
|
63
|
-
Message read/send
|
|
96
|
+
Message read/send and attachment upload use stable DOM test ids in the authenticated Doubao renderer over localhost CDP. A Doubao update can change these selectors. The CLI verifies that uploads finish and the exact user message appears in the target conversation before reporting success. The CLI currently accepts up to 50 attachments per command and files up to 100 MiB each; the Doubao service can impose stricter type or size limits.
|
|
64
97
|
|
|
65
98
|
## Development
|
|
66
99
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const DROP_AREA = '[data-testid="file_drop_area"]';
|
|
5
|
+
const ATTACHMENT_ITEM = '[data-testid="attachment_area"] [data-testid="attachment_file_item"]';
|
|
6
|
+
const MAX_FILE_COUNT = 50;
|
|
7
|
+
const MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
8
|
+
const CHUNK_BYTES = 384 * 1024;
|
|
9
|
+
|
|
10
|
+
const MIME_TYPES = new Map([
|
|
11
|
+
['.csv', 'text/csv'],
|
|
12
|
+
['.doc', 'application/msword'],
|
|
13
|
+
['.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
|
14
|
+
['.epub', 'application/epub+zip'],
|
|
15
|
+
['.gif', 'image/gif'],
|
|
16
|
+
['.jpeg', 'image/jpeg'],
|
|
17
|
+
['.jpg', 'image/jpeg'],
|
|
18
|
+
['.json', 'application/json'],
|
|
19
|
+
['.md', 'text/markdown'],
|
|
20
|
+
['.mobi', 'application/x-mobipocket-ebook'],
|
|
21
|
+
['.pdf', 'application/pdf'],
|
|
22
|
+
['.png', 'image/png'],
|
|
23
|
+
['.ppt', 'application/vnd.ms-powerpoint'],
|
|
24
|
+
['.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
|
25
|
+
['.txt', 'text/plain'],
|
|
26
|
+
['.webp', 'image/webp'],
|
|
27
|
+
['.xls', 'application/vnd.ms-excel'],
|
|
28
|
+
['.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
function delay(milliseconds) {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function attachmentMimeType(filePath) {
|
|
36
|
+
return MIME_TYPES.get(path.extname(filePath).toLocaleLowerCase('en-US')) || 'application/octet-stream';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function resolveAttachmentFiles(filePaths) {
|
|
40
|
+
if (!Array.isArray(filePaths) || !filePaths.length) return [];
|
|
41
|
+
if (filePaths.length > MAX_FILE_COUNT) throw new Error(`at most ${MAX_FILE_COUNT} attachments can be uploaded at once`);
|
|
42
|
+
|
|
43
|
+
const files = [];
|
|
44
|
+
for (const input of filePaths) {
|
|
45
|
+
const resolvedPath = path.resolve(input);
|
|
46
|
+
let stat;
|
|
47
|
+
try {
|
|
48
|
+
stat = await fs.stat(resolvedPath);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new Error(`attachment does not exist: ${resolvedPath}`);
|
|
51
|
+
}
|
|
52
|
+
if (!stat.isFile()) throw new Error(`attachment is not a regular file: ${resolvedPath}`);
|
|
53
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error(`attachment exceeds the 100 MiB CLI limit: ${resolvedPath}`);
|
|
54
|
+
files.push({
|
|
55
|
+
path: resolvedPath,
|
|
56
|
+
name: path.basename(resolvedPath),
|
|
57
|
+
size: stat.size,
|
|
58
|
+
type: attachmentMimeType(resolvedPath),
|
|
59
|
+
lastModified: Math.trunc(stat.mtimeMs),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return files;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function stageFiles(client, files, stateKey) {
|
|
66
|
+
await client.evaluate(`globalThis[${JSON.stringify(stateKey)}] = []`);
|
|
67
|
+
for (const [index, file] of files.entries()) {
|
|
68
|
+
await client.evaluate(`globalThis[${JSON.stringify(stateKey)}].push({
|
|
69
|
+
name: ${JSON.stringify(file.name)},
|
|
70
|
+
type: ${JSON.stringify(file.type)},
|
|
71
|
+
lastModified: ${file.lastModified},
|
|
72
|
+
chunks: [],
|
|
73
|
+
})`);
|
|
74
|
+
const bytes = await fs.readFile(file.path);
|
|
75
|
+
for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) {
|
|
76
|
+
const base64 = bytes.subarray(offset, offset + CHUNK_BYTES).toString('base64');
|
|
77
|
+
await client.evaluate(`globalThis[${JSON.stringify(stateKey)}][${index}].chunks.push(
|
|
78
|
+
Uint8Array.from(atob(${JSON.stringify(base64)}), (character) => character.charCodeAt(0)),
|
|
79
|
+
)`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function dispatchDrop(client, stateKey) {
|
|
85
|
+
return client.evaluate(`(() => {
|
|
86
|
+
const records = globalThis[${JSON.stringify(stateKey)}];
|
|
87
|
+
const target = document.querySelector(${JSON.stringify(DROP_AREA)})?.parentElement;
|
|
88
|
+
if (!target) throw new Error('Doubao attachment drop target was not found');
|
|
89
|
+
const transfer = new DataTransfer();
|
|
90
|
+
for (const record of records) {
|
|
91
|
+
transfer.items.add(new File(record.chunks, record.name, {
|
|
92
|
+
type: record.type,
|
|
93
|
+
lastModified: record.lastModified,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
target.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: transfer }));
|
|
97
|
+
return [...transfer.files].map((file) => ({ name: file.name, size: file.size, type: file.type }));
|
|
98
|
+
})()`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function waitForUploads(client, expectedCount, timeoutMs) {
|
|
102
|
+
const deadline = Date.now() + timeoutMs;
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
const state = await client.evaluate(`(() => {
|
|
105
|
+
const items = [...document.querySelectorAll(${JSON.stringify(ATTACHMENT_ITEM)})].slice(-${expectedCount});
|
|
106
|
+
return items.map((item) => ({
|
|
107
|
+
available: item.getAttribute('data-available') === 'true',
|
|
108
|
+
name: (item.querySelector('[data-testid="message_nested_content_file_name"]')?.innerText || '').trim(),
|
|
109
|
+
status: (item.querySelector('[data-testid="message_nested_content_file_subtitle"]')?.innerText || '').trim(),
|
|
110
|
+
}));
|
|
111
|
+
})()`);
|
|
112
|
+
if (state.length === expectedCount && state.every((item) => item.available)) return state;
|
|
113
|
+
const failed = state.find((item) => /(?:失败|不支持|超出|error|failed)/iu.test(item.status));
|
|
114
|
+
if (failed) throw new Error(`Doubao failed to upload attachment ${failed.name || ''}: ${failed.status}`.trim());
|
|
115
|
+
await delay(250);
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`Doubao attachments did not finish uploading within ${timeoutMs} ms`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function clearComposerAttachments(client) {
|
|
121
|
+
await client.evaluate(`(() => {
|
|
122
|
+
for (const item of document.querySelectorAll(${JSON.stringify(ATTACHMENT_ITEM)})) {
|
|
123
|
+
item.querySelector('[data-testid="message_nested_content_file_delete"]')
|
|
124
|
+
?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
125
|
+
}
|
|
126
|
+
})()`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export async function uploadAttachmentsFromClient(client, filePaths, options = {}) {
|
|
130
|
+
const files = await resolveAttachmentFiles(filePaths);
|
|
131
|
+
if (!files.length) return [];
|
|
132
|
+
const existingAttachmentCount = await client.evaluate(`document.querySelectorAll(${JSON.stringify(ATTACHMENT_ITEM)}).length`);
|
|
133
|
+
if (existingAttachmentCount) {
|
|
134
|
+
throw new Error('Doubao composer already contains draft attachments; remove them before using --attach');
|
|
135
|
+
}
|
|
136
|
+
const timeoutMs = options.timeoutMs || 60_000;
|
|
137
|
+
const stateKey = `__doubaoCliUpload_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
|
138
|
+
try {
|
|
139
|
+
await stageFiles(client, files, stateKey);
|
|
140
|
+
const dropped = await dispatchDrop(client, stateKey);
|
|
141
|
+
if (dropped.length !== files.length) throw new Error('Doubao did not accept every attachment from the drop event');
|
|
142
|
+
let uploaded;
|
|
143
|
+
try {
|
|
144
|
+
uploaded = await waitForUploads(client, files.length, timeoutMs);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
await clearComposerAttachments(client).catch(() => {});
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
return uploaded.map((item, index) => ({
|
|
150
|
+
name: item.name || files[index].name,
|
|
151
|
+
path: files[index].path,
|
|
152
|
+
size: files[index].size,
|
|
153
|
+
type: files[index].type,
|
|
154
|
+
}));
|
|
155
|
+
} finally {
|
|
156
|
+
await client.evaluate(`delete globalThis[${JSON.stringify(stateKey)}]`).catch(() => {});
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/automation.mjs
CHANGED
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { uploadAttachmentsFromClient } from './attachments.mjs';
|
|
2
3
|
import { withChatClient } from './cdp.mjs';
|
|
3
4
|
import { selectModelFromClient } from './models.mjs';
|
|
4
5
|
|
|
5
6
|
const CHAT_INPUT = '[data-testid="chat_input_input"] [contenteditable="true"]';
|
|
6
7
|
const SEND_BUTTON = '[data-testid="chat_input_send_button"]';
|
|
8
|
+
const CREATE_BUTTON = '[data-testid="create_conversation_button"]';
|
|
9
|
+
const CREATE_OFFICE_TASK = '[data-testid="create_office_task_button"]';
|
|
7
10
|
|
|
8
11
|
function delay(milliseconds) {
|
|
9
12
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
10
13
|
}
|
|
11
14
|
|
|
15
|
+
function remainingMilliseconds(deadline, timeoutMs) {
|
|
16
|
+
const remaining = deadline - Date.now();
|
|
17
|
+
if (remaining <= 0) throw new Error(`Doubao operation did not complete within ${timeoutMs} ms`);
|
|
18
|
+
return remaining;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
export function conversationDeepLink(id) {
|
|
13
22
|
const webUrl = `https://www.doubao.com/chat/${id}`;
|
|
14
23
|
return `doubao://doubaoapp/open-url?url=${encodeURIComponent(webUrl)}`;
|
|
@@ -21,6 +30,10 @@ export function openConversation(id) {
|
|
|
21
30
|
return url;
|
|
22
31
|
}
|
|
23
32
|
|
|
33
|
+
export function conversationIdFromUrl(url) {
|
|
34
|
+
return /\/chat\/(\d{12,24})(?:[?#]|$)/u.exec(url || '')?.[1] || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
async function waitForConversation(client, id, timeoutMs) {
|
|
25
38
|
const deadline = Date.now() + timeoutMs;
|
|
26
39
|
while (Date.now() < deadline) {
|
|
@@ -31,6 +44,29 @@ async function waitForConversation(client, id, timeoutMs) {
|
|
|
31
44
|
throw new Error(`Doubao did not open conversation ${id} within ${timeoutMs} ms`);
|
|
32
45
|
}
|
|
33
46
|
|
|
47
|
+
async function waitForBlankConversation(client, timeoutMs) {
|
|
48
|
+
const deadline = Date.now() + timeoutMs;
|
|
49
|
+
while (Date.now() < deadline) {
|
|
50
|
+
const state = await client.evaluate(`({
|
|
51
|
+
href: location.href,
|
|
52
|
+
ready: Boolean(document.querySelector(${JSON.stringify(CHAT_INPUT)})),
|
|
53
|
+
})`);
|
|
54
|
+
if (state?.ready && /\/chat(?:[?#]|$)/u.test(state.href)) return state.href;
|
|
55
|
+
await delay(100);
|
|
56
|
+
}
|
|
57
|
+
throw new Error(`Doubao did not open a blank conversation within ${timeoutMs} ms`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function waitForConversationId(client, timeoutMs) {
|
|
61
|
+
const deadline = Date.now() + timeoutMs;
|
|
62
|
+
while (Date.now() < deadline) {
|
|
63
|
+
const id = conversationIdFromUrl(await client.evaluate('location.href'));
|
|
64
|
+
if (id) return id;
|
|
65
|
+
await delay(100);
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`Doubao did not assign a conversation id within ${timeoutMs} ms`);
|
|
68
|
+
}
|
|
69
|
+
|
|
34
70
|
const READ_MESSAGES_EXPRESSION = `(() => [...document.querySelectorAll('[data-testid="union_message"]')]
|
|
35
71
|
.map((element) => {
|
|
36
72
|
const role = element.querySelector('[data-testid="send_message"]')
|
|
@@ -39,7 +75,12 @@ const READ_MESSAGES_EXPRESSION = `(() => [...document.querySelectorAll('[data-te
|
|
|
39
75
|
const parts = [...element.querySelectorAll('[data-testid="message_text_content"]')]
|
|
40
76
|
.map((part) => (part.innerText || '').trim())
|
|
41
77
|
.filter(Boolean);
|
|
42
|
-
|
|
78
|
+
const attachments = [...element.querySelectorAll('[data-testid="message_nested_content_file_name"]')]
|
|
79
|
+
.map((part) => (part.innerText || '').trim())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
return role && (parts.length || attachments.length)
|
|
82
|
+
? { role, text: parts.join('\\n'), ...(attachments.length ? { attachments } : {}) }
|
|
83
|
+
: null;
|
|
43
84
|
})
|
|
44
85
|
.filter(Boolean))()`;
|
|
45
86
|
|
|
@@ -55,6 +96,27 @@ export function replyAfterLastUserMessage(messages, message) {
|
|
|
55
96
|
return null;
|
|
56
97
|
}
|
|
57
98
|
|
|
99
|
+
function attachmentCounts(messages) {
|
|
100
|
+
const counts = new Map();
|
|
101
|
+
for (const item of messages) {
|
|
102
|
+
if (item.role !== 'user') continue;
|
|
103
|
+
for (const name of item.attachments || []) counts.set(name, (counts.get(name) || 0) + 1);
|
|
104
|
+
}
|
|
105
|
+
return counts;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function attachmentsConfirmed(before, after, attachments) {
|
|
109
|
+
const beforeCounts = attachmentCounts(before);
|
|
110
|
+
const afterCounts = attachmentCounts(after);
|
|
111
|
+
const expectedCounts = new Map();
|
|
112
|
+
for (const attachment of attachments) {
|
|
113
|
+
expectedCounts.set(attachment.name, (expectedCounts.get(attachment.name) || 0) + 1);
|
|
114
|
+
}
|
|
115
|
+
return [...expectedCounts].every(([name, expected]) => (
|
|
116
|
+
(afterCounts.get(name) || 0) - (beforeCounts.get(name) || 0) >= expected
|
|
117
|
+
));
|
|
118
|
+
}
|
|
119
|
+
|
|
58
120
|
export async function readConversation(id, options = {}) {
|
|
59
121
|
const timeoutMs = options.timeoutMs || 10_000;
|
|
60
122
|
openConversation(id);
|
|
@@ -65,75 +127,146 @@ export async function readConversation(id, options = {}) {
|
|
|
65
127
|
});
|
|
66
128
|
}
|
|
67
129
|
|
|
68
|
-
|
|
69
|
-
const timeoutMs = options.timeoutMs || 120_000;
|
|
70
|
-
const waitForReply = options.waitForReply || false;
|
|
130
|
+
function validateMessage(message) {
|
|
71
131
|
if (typeof message !== 'string' || !message.trim()) throw new Error('message cannot be empty');
|
|
72
132
|
if (message.length > 100_000) throw new Error('message exceeds the 100000 character limit');
|
|
133
|
+
}
|
|
73
134
|
|
|
74
|
-
|
|
75
|
-
return
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
135
|
+
function publicAttachments(attachments) {
|
|
136
|
+
return attachments.map(({ name, size, type }) => ({ name, size, type }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function prepareComposer(client, options, timeoutMs) {
|
|
140
|
+
const selectedModel = options.model ? await selectModelFromClient(client, options.model) : null;
|
|
141
|
+
const attachments = options.attachments?.length
|
|
142
|
+
? await uploadAttachmentsFromClient(client, options.attachments, { timeoutMs: Math.min(timeoutMs, 60_000) })
|
|
143
|
+
: [];
|
|
144
|
+
return { selectedModel, attachments };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function sendFromClient(client, requestedId, message, options, prepared) {
|
|
148
|
+
const { selectedModel, attachments } = prepared;
|
|
149
|
+
const timeoutMs = options.timeoutMs || 120_000;
|
|
150
|
+
const waitForReply = options.waitForReply || false;
|
|
151
|
+
const before = await readFromClient(client);
|
|
152
|
+
const matchingUserCountBefore = before.filter((item) => item.role === 'user' && item.text === message).length;
|
|
153
|
+
const encodedMessage = JSON.stringify(message);
|
|
154
|
+
|
|
155
|
+
const draft = await client.evaluate(`(async () => {
|
|
156
|
+
const editor = document.querySelector(${JSON.stringify(CHAT_INPUT)});
|
|
157
|
+
if (!editor) throw new Error('Doubao message editor was not found');
|
|
158
|
+
editor.focus();
|
|
159
|
+
document.execCommand('selectAll', false, null);
|
|
160
|
+
document.execCommand('insertText', false, ${encodedMessage});
|
|
161
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
162
|
+
const button = document.querySelector(${JSON.stringify(SEND_BUTTON)});
|
|
163
|
+
if (!button || button.disabled) throw new Error('Doubao send button is unavailable');
|
|
164
|
+
const text = (editor.innerText || '').replace(/\\n$/, '');
|
|
165
|
+
button.click();
|
|
166
|
+
return text;
|
|
167
|
+
})()`);
|
|
168
|
+
if (draft !== message) throw new Error('Doubao editor did not accept the complete message');
|
|
169
|
+
|
|
170
|
+
const deadline = Date.now() + timeoutMs;
|
|
171
|
+
let messages = before;
|
|
172
|
+
while (Date.now() < deadline) {
|
|
173
|
+
messages = await readFromClient(client);
|
|
174
|
+
const matchingUserCount = messages.filter((item) => item.role === 'user' && item.text === message).length;
|
|
175
|
+
if (matchingUserCount > matchingUserCountBefore) break;
|
|
176
|
+
await delay(250);
|
|
177
|
+
}
|
|
178
|
+
const matchingUserMessages = messages.filter((item) => item.role === 'user' && item.text === message);
|
|
179
|
+
const sent = matchingUserMessages.length > matchingUserCountBefore ? matchingUserMessages.at(-1) : null;
|
|
180
|
+
if (!sent) throw new Error(`Doubao did not confirm a new sent message within ${timeoutMs} ms`);
|
|
181
|
+
|
|
182
|
+
if (attachments.length) {
|
|
183
|
+
while (Date.now() < deadline && !attachmentsConfirmed(before, messages, attachments)) {
|
|
103
184
|
await delay(250);
|
|
185
|
+
messages = await readFromClient(client);
|
|
104
186
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (!sent) throw new Error(`Doubao did not confirm a new sent message within ${timeoutMs} ms`);
|
|
108
|
-
|
|
109
|
-
if (!waitForReply) {
|
|
110
|
-
return { conversationId: id, ...(selectedModel ? { model: selectedModel.name } : {}), sent, reply: null };
|
|
187
|
+
if (!attachmentsConfirmed(before, messages, attachments)) {
|
|
188
|
+
throw new Error(`Doubao did not confirm the sent attachments within ${timeoutMs} ms`);
|
|
111
189
|
}
|
|
190
|
+
}
|
|
112
191
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
192
|
+
const conversationId = requestedId || await waitForConversationId(client, Math.min(5000, Math.max(1, deadline - Date.now())));
|
|
193
|
+
const baseResult = {
|
|
194
|
+
conversationId,
|
|
195
|
+
...(selectedModel ? { model: selectedModel.name } : {}),
|
|
196
|
+
...(attachments.length ? { attachments: publicAttachments(attachments) } : {}),
|
|
197
|
+
sent,
|
|
198
|
+
};
|
|
199
|
+
if (!waitForReply) return { ...baseResult, reply: null };
|
|
200
|
+
|
|
201
|
+
let stableText = '';
|
|
202
|
+
let stablePolls = 0;
|
|
203
|
+
while (Date.now() < deadline) {
|
|
204
|
+
messages = await readFromClient(client);
|
|
205
|
+
const reply = replyAfterLastUserMessage(messages, message);
|
|
206
|
+
const generating = await client.evaluate(`[
|
|
207
|
+
...document.querySelectorAll('[data-testid="chat_input_local_break_button"], [data-testid="chat_input_end_button"]'),
|
|
208
|
+
].some((element) => {
|
|
209
|
+
const style = getComputedStyle(element);
|
|
210
|
+
const rect = element.getBoundingClientRect();
|
|
211
|
+
return style.display !== 'none'
|
|
212
|
+
&& style.visibility !== 'hidden'
|
|
213
|
+
&& Number(style.opacity) !== 0
|
|
214
|
+
&& rect.width > 0
|
|
215
|
+
&& rect.height > 0;
|
|
216
|
+
})`);
|
|
217
|
+
if (reply?.text && reply.text === stableText && !generating) stablePolls += 1;
|
|
218
|
+
else stablePolls = 0;
|
|
219
|
+
stableText = reply?.text || '';
|
|
220
|
+
if (reply && stablePolls >= 2) return { ...baseResult, reply };
|
|
221
|
+
await delay(500);
|
|
222
|
+
}
|
|
223
|
+
throw new Error(`Doubao reply did not complete within ${timeoutMs} ms`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function sendMessage(id, message, options = {}) {
|
|
227
|
+
const timeoutMs = options.timeoutMs || 120_000;
|
|
228
|
+
const deadline = Date.now() + timeoutMs;
|
|
229
|
+
validateMessage(message);
|
|
230
|
+
|
|
231
|
+
openConversation(id);
|
|
232
|
+
return withChatClient(async (client) => {
|
|
233
|
+
await waitForConversation(client, id, Math.min(remainingMilliseconds(deadline, timeoutMs), 15_000));
|
|
234
|
+
const prepared = await prepareComposer(client, options, remainingMilliseconds(deadline, timeoutMs));
|
|
235
|
+
return sendFromClient(client, id, message, {
|
|
236
|
+
...options,
|
|
237
|
+
timeoutMs: remainingMilliseconds(deadline, timeoutMs),
|
|
238
|
+
}, prepared);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function createConversation(message, options = {}) {
|
|
243
|
+
const timeoutMs = options.timeoutMs || 120_000;
|
|
244
|
+
const deadline = Date.now() + timeoutMs;
|
|
245
|
+
const hasMessage = typeof message === 'string' && message.length > 0;
|
|
246
|
+
if (hasMessage) validateMessage(message);
|
|
247
|
+
|
|
248
|
+
return withChatClient(async (client) => {
|
|
249
|
+
const createSelector = await client.evaluate(`document.querySelector(${JSON.stringify(CREATE_BUTTON)})
|
|
250
|
+
? ${JSON.stringify(CREATE_BUTTON)}
|
|
251
|
+
: document.querySelector(${JSON.stringify(CREATE_OFFICE_TASK)}) ? ${JSON.stringify(CREATE_OFFICE_TASK)} : null`);
|
|
252
|
+
if (!createSelector) throw new Error('Doubao new conversation button was not found');
|
|
253
|
+
await client.click(createSelector);
|
|
254
|
+
const route = await waitForBlankConversation(client, Math.min(remainingMilliseconds(deadline, timeoutMs), 15_000));
|
|
255
|
+
const prepared = await prepareComposer(client, options, remainingMilliseconds(deadline, timeoutMs));
|
|
256
|
+
if (!hasMessage) {
|
|
257
|
+
return {
|
|
258
|
+
conversationId: null,
|
|
259
|
+
created: true,
|
|
260
|
+
persisted: false,
|
|
261
|
+
route,
|
|
262
|
+
...(prepared.selectedModel ? { model: prepared.selectedModel.name } : {}),
|
|
263
|
+
...(prepared.attachments.length ? { attachments: publicAttachments(prepared.attachments) } : {}),
|
|
264
|
+
};
|
|
136
265
|
}
|
|
137
|
-
|
|
266
|
+
const result = await sendFromClient(client, null, message, {
|
|
267
|
+
...options,
|
|
268
|
+
timeoutMs: remainingMilliseconds(deadline, timeoutMs),
|
|
269
|
+
}, prepared);
|
|
270
|
+
return { ...result, created: true, persisted: true };
|
|
138
271
|
});
|
|
139
272
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { currentSession, getDataDir, listSessions, resolveProfile } from './storage.mjs';
|
|
5
5
|
import { cdpStatus } from './cdp.mjs';
|
|
6
|
-
import { openConversation, readConversation, sendMessage } from './automation.mjs';
|
|
6
|
+
import { createConversation, openConversation, readConversation, sendMessage } from './automation.mjs';
|
|
7
7
|
import { currentModel, listModels, selectModel } from './models.mjs';
|
|
8
8
|
|
|
9
9
|
const DEFAULT_APP = '/Applications/Doubao.app';
|
|
@@ -14,9 +14,10 @@ const HELP = `Usage:
|
|
|
14
14
|
doubao profiles [--json]
|
|
15
15
|
doubao sessions list [--profile <name>] [--json]
|
|
16
16
|
doubao sessions current [--profile <name>] [--json]
|
|
17
|
+
doubao sessions create [message] [--attach <path>] [--model <model>] [--wait] [--timeout <seconds>] [--json]
|
|
17
18
|
doubao sessions open <conversation-id>
|
|
18
19
|
doubao sessions read <conversation-id> [--limit <count>] [--json]
|
|
19
|
-
doubao sessions send <conversation-id> <message> [--model <model>] [--wait] [--timeout <seconds>] [--json]
|
|
20
|
+
doubao sessions send <conversation-id> <message> [--attach <path>] [--model <model>] [--wait] [--timeout <seconds>] [--json]
|
|
20
21
|
doubao models [--json]
|
|
21
22
|
doubao model [--json]
|
|
22
23
|
doubao model select <model> [--json]
|
|
@@ -30,7 +31,7 @@ Environment:
|
|
|
30
31
|
DOUBAO_CDP_ENDPOINT CDP endpoint (default: http://127.0.0.1:9225)
|
|
31
32
|
`;
|
|
32
33
|
|
|
33
|
-
function parseOptions(argv) {
|
|
34
|
+
export function parseOptions(argv) {
|
|
34
35
|
const args = [];
|
|
35
36
|
let profile;
|
|
36
37
|
let json = false;
|
|
@@ -38,8 +39,12 @@ function parseOptions(argv) {
|
|
|
38
39
|
let timeoutSeconds = 120;
|
|
39
40
|
let limit = 20;
|
|
40
41
|
let model;
|
|
42
|
+
const attachments = [];
|
|
41
43
|
for (let index = 0; index < argv.length; index += 1) {
|
|
42
|
-
if (argv[index] === '--
|
|
44
|
+
if (argv[index] === '--') {
|
|
45
|
+
args.push(...argv.slice(index + 1));
|
|
46
|
+
break;
|
|
47
|
+
} else if (argv[index] === '--json') {
|
|
43
48
|
json = true;
|
|
44
49
|
} else if (argv[index] === '--wait') {
|
|
45
50
|
wait = true;
|
|
@@ -59,11 +64,16 @@ function parseOptions(argv) {
|
|
|
59
64
|
model = argv[index + 1];
|
|
60
65
|
if (!model) throw new Error('--model requires a value');
|
|
61
66
|
index += 1;
|
|
67
|
+
} else if (argv[index] === '--attach') {
|
|
68
|
+
const attachment = argv[index + 1];
|
|
69
|
+
if (!attachment || attachment.startsWith('--')) throw new Error('--attach requires a file path');
|
|
70
|
+
attachments.push(attachment);
|
|
71
|
+
index += 1;
|
|
62
72
|
} else {
|
|
63
73
|
args.push(argv[index]);
|
|
64
74
|
}
|
|
65
75
|
}
|
|
66
|
-
return { args, profile, json, wait, timeoutMs: timeoutSeconds * 1000, limit, model };
|
|
76
|
+
return { args, profile, json, wait, timeoutMs: timeoutSeconds * 1000, limit, model, attachments };
|
|
67
77
|
}
|
|
68
78
|
|
|
69
79
|
function output(value, json) {
|
|
@@ -94,7 +104,7 @@ function sessionWithTitle(profilePath, id) {
|
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
export async function main(argv) {
|
|
97
|
-
const { args, profile: requestedProfile, json, wait, timeoutMs, limit, model } = parseOptions(argv);
|
|
107
|
+
const { args, profile: requestedProfile, json, wait, timeoutMs, limit, model, attachments } = parseOptions(argv);
|
|
98
108
|
const [command, subcommand, operand] = args;
|
|
99
109
|
const dataDir = getDataDir();
|
|
100
110
|
|
|
@@ -127,8 +137,10 @@ export async function main(argv) {
|
|
|
127
137
|
listSessions: true,
|
|
128
138
|
detectCurrentSession: true,
|
|
129
139
|
openSession: true,
|
|
140
|
+
createSessions: cdp.available,
|
|
130
141
|
readMessages: cdp.available,
|
|
131
142
|
sendMessages: cdp.available,
|
|
143
|
+
uploadAttachments: cdp.available,
|
|
132
144
|
selectModels: cdp.available,
|
|
133
145
|
cdp,
|
|
134
146
|
note: cdp.available
|
|
@@ -141,8 +153,10 @@ export async function main(argv) {
|
|
|
141
153
|
console.log('sessions list\tyes');
|
|
142
154
|
console.log('sessions current\tyes');
|
|
143
155
|
console.log('sessions open\tyes');
|
|
156
|
+
console.log(`sessions create\t${capabilities.createSessions ? 'yes' : 'no'}`);
|
|
144
157
|
console.log(`messages read\t${capabilities.readMessages ? 'yes' : 'no'}`);
|
|
145
158
|
console.log(`messages send\t${capabilities.sendMessages ? 'yes' : 'no'}`);
|
|
159
|
+
console.log(`attachments upload\t${capabilities.uploadAttachments ? 'yes' : 'no'}`);
|
|
146
160
|
console.log(`models select\t${capabilities.selectModels ? 'yes' : 'no'}`);
|
|
147
161
|
console.log(`note\t${capabilities.note}`);
|
|
148
162
|
}
|
|
@@ -269,6 +283,25 @@ export async function main(argv) {
|
|
|
269
283
|
return;
|
|
270
284
|
}
|
|
271
285
|
|
|
286
|
+
if (subcommand === 'create') {
|
|
287
|
+
const message = args.slice(2).join(' ');
|
|
288
|
+
const result = await createConversation(message, {
|
|
289
|
+
attachments,
|
|
290
|
+
model,
|
|
291
|
+
timeoutMs,
|
|
292
|
+
waitForReply: wait,
|
|
293
|
+
});
|
|
294
|
+
if (json) output(result, true);
|
|
295
|
+
else {
|
|
296
|
+
console.log(`created\t${result.conversationId || 'draft'}`);
|
|
297
|
+
if (result.model) console.log(`model\t${result.model}`);
|
|
298
|
+
for (const attachment of result.attachments || []) console.log(`attachment\t${attachment.name}`);
|
|
299
|
+
if (result.sent) console.log(`sent\t${result.sent.text}`);
|
|
300
|
+
if (result.reply) console.log(`reply\t${result.reply.text.replaceAll('\n', '\\n')}`);
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
272
305
|
if (subcommand === 'open') {
|
|
273
306
|
const id = validateId(operand);
|
|
274
307
|
const url = openConversation(id);
|
|
@@ -288,11 +321,12 @@ export async function main(argv) {
|
|
|
288
321
|
if (subcommand === 'send') {
|
|
289
322
|
const id = validateId(operand);
|
|
290
323
|
const message = args.slice(3).join(' ');
|
|
291
|
-
const result = await sendMessage(id, message, { waitForReply: wait, timeoutMs, model });
|
|
324
|
+
const result = await sendMessage(id, message, { attachments, waitForReply: wait, timeoutMs, model });
|
|
292
325
|
if (json) output(result, true);
|
|
293
326
|
else {
|
|
294
327
|
console.log(`sent\t${result.sent.text}`);
|
|
295
328
|
if (result.model) console.log(`model\t${result.model}`);
|
|
329
|
+
for (const attachment of result.attachments || []) console.log(`attachment\t${attachment.name}`);
|
|
296
330
|
if (result.reply) console.log(`reply\t${result.reply.text.replaceAll('\n', '\\n')}`);
|
|
297
331
|
}
|
|
298
332
|
return;
|