doubao-cli 0.4.0 → 0.4.2
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 +6 -2
- package/package.json +1 -1
- package/src/attachments.mjs +65 -18
- package/src/automation.mjs +14 -4
- package/src/cdp.mjs +1 -1
- package/src/cli.mjs +81 -8
package/README.md
CHANGED
|
@@ -44,7 +44,11 @@ Every data-returning command supports `--json`. Select a non-default local profi
|
|
|
44
44
|
|
|
45
45
|
Message automation requires Doubao to be launched with local Chrome DevTools Protocol enabled:
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
```bash
|
|
48
|
+
doubao cdp launch
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
If Doubao is already running without CDP, the command asks for confirmation before quitting it and relaunching with the debugging port enabled. Scripts and `--json` mode never prompt; pass `doubao cdp launch --yes` to confirm the restart explicitly. The command returns only after both the CDP endpoint and authenticated chat renderer are ready. The equivalent manual sequence is to quit Doubao completely and run `open -a /Applications/Doubao.app --args --remote-debugging-port=9225`.
|
|
48
52
|
|
|
49
53
|
Set `DOUBAO_CDP_ENDPOINT` if using another port. `sessions send --wait` waits for and returns the completed assistant reply.
|
|
50
54
|
|
|
@@ -93,7 +97,7 @@ No hard-coded UI coordinates, image recognition, Cookie extraction, or private c
|
|
|
93
97
|
|
|
94
98
|
## Limits
|
|
95
99
|
|
|
96
|
-
Message read/send and attachment upload use stable DOM
|
|
100
|
+
Message read/send and attachment upload use stable DOM attributes in the authenticated Doubao renderer over localhost CDP. A Doubao update can change these selectors. The CLI treats image previews and file cards separately, waits for their respective upload completion signals, and verifies both the exact user message and sent attachment count 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.
|
|
97
101
|
|
|
98
102
|
## Development
|
|
99
103
|
|
package/package.json
CHANGED
package/src/attachments.mjs
CHANGED
|
@@ -2,7 +2,9 @@ import fs from 'node:fs/promises';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
const DROP_AREA = '[data-testid="file_drop_area"]';
|
|
5
|
+
const ATTACHMENT_AREA = '[data-testid="attachment_area"]';
|
|
5
6
|
const ATTACHMENT_ITEM = '[data-testid="attachment_area"] [data-testid="attachment_file_item"]';
|
|
7
|
+
const IMAGE_ITEM = '[data-testid="attachment_area"] [data-testid="mdbox_image"]';
|
|
6
8
|
const MAX_FILE_COUNT = 50;
|
|
7
9
|
const MAX_FILE_BYTES = 100 * 1024 * 1024;
|
|
8
10
|
const CHUNK_BYTES = 384 * 1024;
|
|
@@ -98,20 +100,51 @@ async function dispatchDrop(client, stateKey) {
|
|
|
98
100
|
})()`);
|
|
99
101
|
}
|
|
100
102
|
|
|
101
|
-
|
|
103
|
+
export function attachmentUploadReady(state, expected) {
|
|
104
|
+
return state.files.length === expected.files
|
|
105
|
+
&& state.files.every((item) => item.available)
|
|
106
|
+
&& state.images.length === expected.images
|
|
107
|
+
&& state.images.every((item) => item.loaded)
|
|
108
|
+
&& !state.progressing;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function waitForUploads(client, files, timeoutMs) {
|
|
112
|
+
const expected = {
|
|
113
|
+
files: files.filter((file) => !file.type.startsWith('image/')).length,
|
|
114
|
+
images: files.filter((file) => file.type.startsWith('image/')).length,
|
|
115
|
+
};
|
|
102
116
|
const deadline = Date.now() + timeoutMs;
|
|
103
117
|
while (Date.now() < deadline) {
|
|
104
118
|
const state = await client.evaluate(`(() => {
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
119
|
+
const area = document.querySelector(${JSON.stringify(ATTACHMENT_AREA)});
|
|
120
|
+
const fileItems = ${expected.files}
|
|
121
|
+
? [...document.querySelectorAll(${JSON.stringify(ATTACHMENT_ITEM)})].slice(-${expected.files})
|
|
122
|
+
: [];
|
|
123
|
+
const imageItems = ${expected.images}
|
|
124
|
+
? [...document.querySelectorAll(${JSON.stringify(IMAGE_ITEM)})]
|
|
125
|
+
.filter((item) => !item.closest('[data-testid="attachment_file_item"]'))
|
|
126
|
+
.slice(-${expected.images})
|
|
127
|
+
: [];
|
|
128
|
+
return {
|
|
129
|
+
files: fileItems.map((item) => ({
|
|
130
|
+
available: item.getAttribute('data-available') === 'true',
|
|
131
|
+
name: (item.querySelector('[data-testid="message_nested_content_file_name"]')?.innerText || '').trim(),
|
|
132
|
+
status: (item.querySelector('[data-testid="message_nested_content_file_subtitle"]')?.innerText || '').trim(),
|
|
133
|
+
})),
|
|
134
|
+
images: imageItems.map((item) => {
|
|
135
|
+
const image = item.querySelector('img[alt="image"]');
|
|
136
|
+
return { loaded: Boolean(image?.complete && image?.naturalWidth > 0) };
|
|
137
|
+
}),
|
|
138
|
+
progressing: /(?:^|\\s)\\d{1,3}%(?:\\s|$)/u.test(area?.innerText || ''),
|
|
139
|
+
status: (area?.innerText || '').trim(),
|
|
140
|
+
};
|
|
111
141
|
})()`);
|
|
112
|
-
if (state
|
|
113
|
-
const failed = state.find((item) => /(?:失败|不支持|超出|error|failed)/iu.test(item.status));
|
|
142
|
+
if (attachmentUploadReady(state, expected)) return;
|
|
143
|
+
const failed = state.files.find((item) => /(?:失败|不支持|超出|error|failed)/iu.test(item.status));
|
|
114
144
|
if (failed) throw new Error(`Doubao failed to upload attachment ${failed.name || ''}: ${failed.status}`.trim());
|
|
145
|
+
if (/(?:失败|不支持|超出|error|failed)/iu.test(state.status)) {
|
|
146
|
+
throw new Error(`Doubao failed to upload an attachment: ${state.status}`);
|
|
147
|
+
}
|
|
115
148
|
await delay(250);
|
|
116
149
|
}
|
|
117
150
|
throw new Error(`Doubao attachments did not finish uploading within ${timeoutMs} ms`);
|
|
@@ -123,13 +156,33 @@ async function clearComposerAttachments(client) {
|
|
|
123
156
|
item.querySelector('[data-testid="message_nested_content_file_delete"]')
|
|
124
157
|
?.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
125
158
|
}
|
|
159
|
+
const area = document.querySelector(${JSON.stringify(ATTACHMENT_AREA)});
|
|
160
|
+
const imageItems = [...document.querySelectorAll(${JSON.stringify(IMAGE_ITEM)})]
|
|
161
|
+
.filter((item) => !item.closest('[data-testid="attachment_file_item"]'));
|
|
162
|
+
for (const image of imageItems) {
|
|
163
|
+
let container = image.parentElement;
|
|
164
|
+
while (container && container !== area) {
|
|
165
|
+
const deleteButton = [...container.children]
|
|
166
|
+
.find((child) => /(?:^|\\s)delete-btn-[^\\s]+/u.test(child.className || ''));
|
|
167
|
+
if (deleteButton) {
|
|
168
|
+
deleteButton.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
container = container.parentElement;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
126
174
|
})()`);
|
|
127
175
|
}
|
|
128
176
|
|
|
129
177
|
export async function uploadAttachmentsFromClient(client, filePaths, options = {}) {
|
|
130
178
|
const files = await resolveAttachmentFiles(filePaths);
|
|
131
179
|
if (!files.length) return [];
|
|
132
|
-
const existingAttachmentCount = await client.evaluate(`
|
|
180
|
+
const existingAttachmentCount = await client.evaluate(`(() => {
|
|
181
|
+
const files = document.querySelectorAll(${JSON.stringify(ATTACHMENT_ITEM)}).length;
|
|
182
|
+
const images = [...document.querySelectorAll(${JSON.stringify(IMAGE_ITEM)})]
|
|
183
|
+
.filter((item) => !item.closest('[data-testid="attachment_file_item"]')).length;
|
|
184
|
+
return files + images;
|
|
185
|
+
})()`);
|
|
133
186
|
if (existingAttachmentCount) {
|
|
134
187
|
throw new Error('Doubao composer already contains draft attachments; remove them before using --attach');
|
|
135
188
|
}
|
|
@@ -139,19 +192,13 @@ export async function uploadAttachmentsFromClient(client, filePaths, options = {
|
|
|
139
192
|
await stageFiles(client, files, stateKey);
|
|
140
193
|
const dropped = await dispatchDrop(client, stateKey);
|
|
141
194
|
if (dropped.length !== files.length) throw new Error('Doubao did not accept every attachment from the drop event');
|
|
142
|
-
let uploaded;
|
|
143
195
|
try {
|
|
144
|
-
|
|
196
|
+
await waitForUploads(client, files, timeoutMs);
|
|
145
197
|
} catch (error) {
|
|
146
198
|
await clearComposerAttachments(client).catch(() => {});
|
|
147
199
|
throw error;
|
|
148
200
|
}
|
|
149
|
-
return
|
|
150
|
-
name: item.name || files[index].name,
|
|
151
|
-
path: files[index].path,
|
|
152
|
-
size: files[index].size,
|
|
153
|
-
type: files[index].type,
|
|
154
|
-
}));
|
|
201
|
+
return files;
|
|
155
202
|
} finally {
|
|
156
203
|
await client.evaluate(`delete globalThis[${JSON.stringify(stateKey)}]`).catch(() => {});
|
|
157
204
|
}
|
package/src/automation.mjs
CHANGED
|
@@ -78,8 +78,14 @@ const READ_MESSAGES_EXPRESSION = `(() => [...document.querySelectorAll('[data-te
|
|
|
78
78
|
const attachments = [...element.querySelectorAll('[data-testid="message_nested_content_file_name"]')]
|
|
79
79
|
.map((part) => (part.innerText || '').trim())
|
|
80
80
|
.filter(Boolean);
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
const images = element.querySelectorAll('[data-plugin-identifier="block_type:10052"] img').length;
|
|
82
|
+
return role && (parts.length || attachments.length || images)
|
|
83
|
+
? {
|
|
84
|
+
role,
|
|
85
|
+
text: parts.join('\\n'),
|
|
86
|
+
...(attachments.length ? { attachments } : {}),
|
|
87
|
+
...(images ? { images } : {}),
|
|
88
|
+
}
|
|
83
89
|
: null;
|
|
84
90
|
})
|
|
85
91
|
.filter(Boolean))()`;
|
|
@@ -109,12 +115,16 @@ export function attachmentsConfirmed(before, after, attachments) {
|
|
|
109
115
|
const beforeCounts = attachmentCounts(before);
|
|
110
116
|
const afterCounts = attachmentCounts(after);
|
|
111
117
|
const expectedCounts = new Map();
|
|
112
|
-
for (const attachment of attachments) {
|
|
118
|
+
for (const attachment of attachments.filter((item) => !item.type?.startsWith('image/'))) {
|
|
113
119
|
expectedCounts.set(attachment.name, (expectedCounts.get(attachment.name) || 0) + 1);
|
|
114
120
|
}
|
|
115
|
-
|
|
121
|
+
const filesConfirmed = [...expectedCounts].every(([name, expected]) => (
|
|
116
122
|
(afterCounts.get(name) || 0) - (beforeCounts.get(name) || 0) >= expected
|
|
117
123
|
));
|
|
124
|
+
const expectedImages = attachments.filter((item) => item.type?.startsWith('image/')).length;
|
|
125
|
+
const beforeImages = before.reduce((total, item) => total + (item.role === 'user' ? item.images || 0 : 0), 0);
|
|
126
|
+
const afterImages = after.reduce((total, item) => total + (item.role === 'user' ? item.images || 0 : 0), 0);
|
|
127
|
+
return filesConfirmed && afterImages - beforeImages >= expectedImages;
|
|
118
128
|
}
|
|
119
129
|
|
|
120
130
|
export async function readConversation(id, options = {}) {
|
package/src/cdp.mjs
CHANGED
|
@@ -140,7 +140,7 @@ export class CdpClient {
|
|
|
140
140
|
export async function withChatClient(callback, endpoint = cdpEndpoint()) {
|
|
141
141
|
const status = await cdpStatus(endpoint);
|
|
142
142
|
if (!status.available) {
|
|
143
|
-
throw new Error(`Doubao CDP is unavailable at ${endpoint}.
|
|
143
|
+
throw new Error(`Doubao CDP is unavailable at ${endpoint}. Run "doubao cdp launch" to restart Doubao with CDP enabled.`);
|
|
144
144
|
}
|
|
145
145
|
const target = await findChatTarget(endpoint);
|
|
146
146
|
const client = await new CdpClient(target.webSocketDebuggerUrl).connect();
|
package/src/cli.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
4
5
|
import { currentSession, getDataDir, listSessions, resolveProfile } from './storage.mjs';
|
|
5
6
|
import { cdpStatus } from './cdp.mjs';
|
|
6
7
|
import { createConversation, openConversation, readConversation, sendMessage } from './automation.mjs';
|
|
@@ -22,7 +23,7 @@ const HELP = `Usage:
|
|
|
22
23
|
doubao model [--json]
|
|
23
24
|
doubao model select <model> [--json]
|
|
24
25
|
doubao cdp status [--json]
|
|
25
|
-
doubao cdp launch [--json]
|
|
26
|
+
doubao cdp launch [--yes] [--json]
|
|
26
27
|
doubao capabilities [--json]
|
|
27
28
|
|
|
28
29
|
Environment:
|
|
@@ -35,6 +36,7 @@ export function parseOptions(argv) {
|
|
|
35
36
|
const args = [];
|
|
36
37
|
let profile;
|
|
37
38
|
let json = false;
|
|
39
|
+
let yes = false;
|
|
38
40
|
let wait = false;
|
|
39
41
|
let timeoutSeconds = 120;
|
|
40
42
|
let limit = 20;
|
|
@@ -46,6 +48,8 @@ export function parseOptions(argv) {
|
|
|
46
48
|
break;
|
|
47
49
|
} else if (argv[index] === '--json') {
|
|
48
50
|
json = true;
|
|
51
|
+
} else if (argv[index] === '--yes') {
|
|
52
|
+
yes = true;
|
|
49
53
|
} else if (argv[index] === '--wait') {
|
|
50
54
|
wait = true;
|
|
51
55
|
} else if (argv[index] === '--profile') {
|
|
@@ -73,7 +77,7 @@ export function parseOptions(argv) {
|
|
|
73
77
|
args.push(argv[index]);
|
|
74
78
|
}
|
|
75
79
|
}
|
|
76
|
-
return { args, profile, json, wait, timeoutMs: timeoutSeconds * 1000, limit, model, attachments };
|
|
80
|
+
return { args, profile, json, yes, wait, timeoutMs: timeoutSeconds * 1000, limit, model, attachments };
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
function output(value, json) {
|
|
@@ -89,9 +93,71 @@ function appVersion(appPath) {
|
|
|
89
93
|
return result.status === 0 ? result.stdout.trim() : null;
|
|
90
94
|
}
|
|
91
95
|
|
|
92
|
-
function
|
|
96
|
+
function appProcessPattern(appPath) {
|
|
93
97
|
const executable = path.join(appPath, 'Contents', 'MacOS', 'Doubao');
|
|
94
|
-
|
|
98
|
+
const escaped = executable.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
99
|
+
return `^${escaped}([[:space:]]|$)`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function appRunning(appPath) {
|
|
103
|
+
return spawnSync('/usr/bin/pgrep', ['-f', appProcessPattern(appPath)]).status === 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function waitForAppExit(appPath, timeoutMs) {
|
|
107
|
+
const deadline = Date.now() + timeoutMs;
|
|
108
|
+
while (Date.now() < deadline) {
|
|
109
|
+
if (!appRunning(appPath)) return true;
|
|
110
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
111
|
+
}
|
|
112
|
+
return !appRunning(appPath);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function quitAppForCdp(appPath) {
|
|
116
|
+
const result = spawnSync('/usr/bin/osascript', [
|
|
117
|
+
'-e',
|
|
118
|
+
'tell application id "com.bot.pc.doubao" to quit',
|
|
119
|
+
], { encoding: 'utf8' });
|
|
120
|
+
if (result.status === 0 && await waitForAppExit(appPath, 5000)) return;
|
|
121
|
+
|
|
122
|
+
const terminated = spawnSync('/usr/bin/pkill', ['-TERM', '-f', appProcessPattern(appPath)], { encoding: 'utf8' });
|
|
123
|
+
if (terminated.status !== 0 && terminated.status !== 1) {
|
|
124
|
+
throw new Error(terminated.stderr.trim() || result.stderr.trim() || 'failed to stop Doubao before enabling CDP');
|
|
125
|
+
}
|
|
126
|
+
if (!await waitForAppExit(appPath, 10_000)) {
|
|
127
|
+
throw new Error('Doubao did not quit after SIGTERM. Quit it manually, then run "doubao cdp launch" again.');
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function confirmCdpRestart({ json, yes }) {
|
|
132
|
+
if (yes) return;
|
|
133
|
+
if (json || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
134
|
+
throw new Error('Doubao must restart to enable CDP. Re-run "doubao cdp launch --yes" to confirm.');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const prompt = createInterface({ input: process.stdin, output: process.stdout });
|
|
138
|
+
try {
|
|
139
|
+
const answer = await prompt.question('Doubao must restart to enable CDP. Continue? [y/N] ');
|
|
140
|
+
if (!/^(?:y|yes)$/iu.test(answer.trim())) {
|
|
141
|
+
throw new Error('CDP launch cancelled; Doubao was not restarted.');
|
|
142
|
+
}
|
|
143
|
+
} finally {
|
|
144
|
+
prompt.close();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function waitForAutomationReady(timeoutMs = 20_000) {
|
|
149
|
+
const deadline = Date.now() + timeoutMs;
|
|
150
|
+
let lastError;
|
|
151
|
+
while (Date.now() < deadline) {
|
|
152
|
+
try {
|
|
153
|
+
const result = await listModels();
|
|
154
|
+
if (result.models.length) return;
|
|
155
|
+
} catch (error) {
|
|
156
|
+
lastError = error;
|
|
157
|
+
}
|
|
158
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`Doubao CDP is listening, but the chat renderer is not ready: ${lastError?.message || 'timed out'}`);
|
|
95
161
|
}
|
|
96
162
|
|
|
97
163
|
function validateId(value) {
|
|
@@ -104,7 +170,7 @@ function sessionWithTitle(profilePath, id) {
|
|
|
104
170
|
}
|
|
105
171
|
|
|
106
172
|
export async function main(argv) {
|
|
107
|
-
const { args, profile: requestedProfile, json, wait, timeoutMs, limit, model, attachments } = parseOptions(argv);
|
|
173
|
+
const { args, profile: requestedProfile, json, yes, wait, timeoutMs, limit, model, attachments } = parseOptions(argv);
|
|
108
174
|
const [command, subcommand, operand] = args;
|
|
109
175
|
const dataDir = getDataDir();
|
|
110
176
|
|
|
@@ -213,17 +279,22 @@ export async function main(argv) {
|
|
|
213
279
|
if (command === 'cdp' && subcommand === 'launch') {
|
|
214
280
|
const existing = await cdpStatus();
|
|
215
281
|
if (existing.available) {
|
|
282
|
+
await waitForAutomationReady();
|
|
216
283
|
if (json) output(existing, true);
|
|
217
284
|
else console.log(`available\tyes\nendpoint\t${existing.endpoint}`);
|
|
218
285
|
return;
|
|
219
286
|
}
|
|
220
287
|
const appPath = process.env.DOUBAO_APP || DEFAULT_APP;
|
|
221
|
-
if (appRunning(appPath)) throw new Error('Doubao is already running without CDP. Quit it completely, then run this command again.');
|
|
222
288
|
const endpoint = new URL(existing.endpoint);
|
|
223
289
|
if (endpoint.hostname !== '127.0.0.1' && endpoint.hostname !== 'localhost') {
|
|
224
290
|
throw new Error('cdp launch only supports a localhost DOUBAO_CDP_ENDPOINT');
|
|
225
291
|
}
|
|
226
292
|
const port = endpoint.port || '9225';
|
|
293
|
+
const restarted = appRunning(appPath);
|
|
294
|
+
if (restarted) {
|
|
295
|
+
await confirmCdpRestart({ json, yes });
|
|
296
|
+
await quitAppForCdp(appPath);
|
|
297
|
+
}
|
|
227
298
|
const result = spawnSync('/usr/bin/open', ['-a', appPath, '--args', `--remote-debugging-port=${port}`], { encoding: 'utf8' });
|
|
228
299
|
if (result.status !== 0) throw new Error(result.stderr.trim() || 'failed to launch Doubao with CDP');
|
|
229
300
|
let launched = existing;
|
|
@@ -232,8 +303,10 @@ export async function main(argv) {
|
|
|
232
303
|
launched = await cdpStatus();
|
|
233
304
|
}
|
|
234
305
|
if (!launched.available) throw new Error(`Doubao launched, but CDP did not become available at ${existing.endpoint}`);
|
|
235
|
-
|
|
236
|
-
|
|
306
|
+
await waitForAutomationReady();
|
|
307
|
+
const launchResult = { ...launched, launched: true, restarted };
|
|
308
|
+
if (json) output(launchResult, true);
|
|
309
|
+
else console.log(`available\tyes\nendpoint\t${launched.endpoint}\nrestarted\t${restarted ? 'yes' : 'no'}`);
|
|
237
310
|
return;
|
|
238
311
|
}
|
|
239
312
|
|