doubao-cli 0.3.1 → 0.4.1

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 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
@@ -41,10 +44,31 @@ Every data-returning command supports `--json`. Select a non-default local profi
41
44
 
42
45
  Message automation requires Doubao to be launched with local Chrome DevTools Protocol enabled:
43
46
 
44
- Quit any running Doubao process first, then run `doubao cdp launch`. The equivalent manual command is `open -a /Applications/Doubao.app --args --remote-debugging-port=9225`.
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`.
45
52
 
46
53
  Set `DOUBAO_CDP_ENDPOINT` if using another port. `sessions send --wait` waits for and returns the completed assistant reply.
47
54
 
55
+ ### New sessions and attachments
56
+
57
+ `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:
58
+
59
+ ```bash
60
+ doubao sessions create "Start a new task" --model gpt-5.6-sol --wait --json
61
+ ```
62
+
63
+ 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:
64
+
65
+ ```bash
66
+ doubao sessions create "Summarize these" --attach ./brief.pdf --attach ./notes.md --wait
67
+ doubao sessions send 38439138239851266 "Review this spreadsheet" --attach ./data.xlsx --wait
68
+ ```
69
+
70
+ Use `--` before message text that contains CLI option names, for example `doubao sessions create -- "Explain --model literally"`.
71
+
48
72
  `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.
49
73
 
50
74
  | Model | Value | Short aliases |
@@ -66,12 +90,14 @@ CDP is unauthenticated but bound to `127.0.0.1`. Quit and relaunch Doubao normal
66
90
  - The current session is recovered from Chromium's local session store.
67
91
  - Opening a session uses Doubao's registered `doubao://doubaoapp/open-url` deep-link router.
68
92
  - Model discovery and selection use the renderer's semantic menu attributes and native CDP input events.
93
+ - New sessions use Doubao's blank `/chat` route and return the id assigned after the first confirmed send.
94
+ - Attachments are transferred into the renderer through its drop-upload path; file contents and credentials are never printed.
69
95
 
70
96
  No hard-coded UI coordinates, image recognition, Cookie extraction, or private credential copying are involved.
71
97
 
72
98
  ## Limits
73
99
 
74
- Message read/send uses stable DOM test ids in the authenticated Doubao renderer over localhost CDP. A Doubao update can change these selectors. The CLI verifies that the exact user message appears in the target conversation before reporting success.
100
+ 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.
75
101
 
76
102
  ## Development
77
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doubao-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Programmatic control for local Doubao desktop sessions on macOS",
5
5
  "author": "Fullstop000 <fullstop1005@gmail.com>",
6
6
  "type": "module",
@@ -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
+ }
@@ -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
- return role && parts.length ? { role, text: parts.join('\\n') } : null;
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
- export async function sendMessage(id, message, options = {}) {
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
- openConversation(id);
75
- return withChatClient(async (client) => {
76
- await waitForConversation(client, id, Math.min(timeoutMs, 15_000));
77
- const selectedModel = options.model ? await selectModelFromClient(client, options.model) : null;
78
- const before = await readFromClient(client);
79
- const matchingUserCountBefore = before.filter((item) => item.role === 'user' && item.text === message).length;
80
- const encodedMessage = JSON.stringify(message);
81
-
82
- const draft = await client.evaluate(`(async () => {
83
- const editor = document.querySelector(${JSON.stringify(CHAT_INPUT)});
84
- if (!editor) throw new Error('Doubao message editor was not found');
85
- editor.focus();
86
- document.execCommand('selectAll', false, null);
87
- document.execCommand('insertText', false, ${encodedMessage});
88
- await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
89
- const button = document.querySelector(${JSON.stringify(SEND_BUTTON)});
90
- if (!button || button.disabled) throw new Error('Doubao send button is unavailable');
91
- const text = (editor.innerText || '').replace(/\\n$/, '');
92
- button.click();
93
- return text;
94
- })()`);
95
- if (draft !== message) throw new Error('Doubao editor did not accept the complete message');
96
-
97
- const deadline = Date.now() + timeoutMs;
98
- let messages = before;
99
- while (Date.now() < deadline) {
100
- messages = await readFromClient(client);
101
- const matchingUserCount = messages.filter((item) => item.role === 'user' && item.text === message).length;
102
- if (matchingUserCount > matchingUserCountBefore) break;
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
- const matchingUserMessages = messages.filter((item) => item.role === 'user' && item.text === message);
106
- const sent = matchingUserMessages.length > matchingUserCountBefore ? matchingUserMessages.at(-1) : null;
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
- let stableText = '';
114
- let stablePolls = 0;
115
- while (Date.now() < deadline) {
116
- messages = await readFromClient(client);
117
- const reply = replyAfterLastUserMessage(messages, message);
118
- const generating = await client.evaluate(`[
119
- ...document.querySelectorAll('[data-testid="chat_input_local_break_button"], [data-testid="chat_input_end_button"]'),
120
- ].some((element) => {
121
- const style = getComputedStyle(element);
122
- const rect = element.getBoundingClientRect();
123
- return style.display !== 'none'
124
- && style.visibility !== 'hidden'
125
- && Number(style.opacity) !== 0
126
- && rect.width > 0
127
- && rect.height > 0;
128
- })`);
129
- if (reply?.text && reply.text === stableText && !generating) stablePolls += 1;
130
- else stablePolls = 0;
131
- stableText = reply?.text || '';
132
- if (reply && stablePolls >= 2) {
133
- return { conversationId: id, ...(selectedModel ? { model: selectedModel.name } : {}), sent, reply };
134
- }
135
- await delay(500);
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
- throw new Error(`Doubao reply did not complete within ${timeoutMs} ms`);
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/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}. Restart Doubao with --remote-debugging-port=9225.`);
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,9 +1,10 @@
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
- import { openConversation, readConversation, sendMessage } from './automation.mjs';
7
+ import { createConversation, openConversation, readConversation, sendMessage } from './automation.mjs';
7
8
  import { currentModel, listModels, selectModel } from './models.mjs';
8
9
 
9
10
  const DEFAULT_APP = '/Applications/Doubao.app';
@@ -14,14 +15,15 @@ const HELP = `Usage:
14
15
  doubao profiles [--json]
15
16
  doubao sessions list [--profile <name>] [--json]
16
17
  doubao sessions current [--profile <name>] [--json]
18
+ doubao sessions create [message] [--attach <path>] [--model <model>] [--wait] [--timeout <seconds>] [--json]
17
19
  doubao sessions open <conversation-id>
18
20
  doubao sessions read <conversation-id> [--limit <count>] [--json]
19
- doubao sessions send <conversation-id> <message> [--model <model>] [--wait] [--timeout <seconds>] [--json]
21
+ doubao sessions send <conversation-id> <message> [--attach <path>] [--model <model>] [--wait] [--timeout <seconds>] [--json]
20
22
  doubao models [--json]
21
23
  doubao model [--json]
22
24
  doubao model select <model> [--json]
23
25
  doubao cdp status [--json]
24
- doubao cdp launch [--json]
26
+ doubao cdp launch [--yes] [--json]
25
27
  doubao capabilities [--json]
26
28
 
27
29
  Environment:
@@ -30,17 +32,24 @@ Environment:
30
32
  DOUBAO_CDP_ENDPOINT CDP endpoint (default: http://127.0.0.1:9225)
31
33
  `;
32
34
 
33
- function parseOptions(argv) {
35
+ export function parseOptions(argv) {
34
36
  const args = [];
35
37
  let profile;
36
38
  let json = false;
39
+ let yes = false;
37
40
  let wait = false;
38
41
  let timeoutSeconds = 120;
39
42
  let limit = 20;
40
43
  let model;
44
+ const attachments = [];
41
45
  for (let index = 0; index < argv.length; index += 1) {
42
- if (argv[index] === '--json') {
46
+ if (argv[index] === '--') {
47
+ args.push(...argv.slice(index + 1));
48
+ break;
49
+ } else if (argv[index] === '--json') {
43
50
  json = true;
51
+ } else if (argv[index] === '--yes') {
52
+ yes = true;
44
53
  } else if (argv[index] === '--wait') {
45
54
  wait = true;
46
55
  } else if (argv[index] === '--profile') {
@@ -59,11 +68,16 @@ function parseOptions(argv) {
59
68
  model = argv[index + 1];
60
69
  if (!model) throw new Error('--model requires a value');
61
70
  index += 1;
71
+ } else if (argv[index] === '--attach') {
72
+ const attachment = argv[index + 1];
73
+ if (!attachment || attachment.startsWith('--')) throw new Error('--attach requires a file path');
74
+ attachments.push(attachment);
75
+ index += 1;
62
76
  } else {
63
77
  args.push(argv[index]);
64
78
  }
65
79
  }
66
- return { args, profile, json, wait, timeoutMs: timeoutSeconds * 1000, limit, model };
80
+ return { args, profile, json, yes, wait, timeoutMs: timeoutSeconds * 1000, limit, model, attachments };
67
81
  }
68
82
 
69
83
  function output(value, json) {
@@ -79,9 +93,71 @@ function appVersion(appPath) {
79
93
  return result.status === 0 ? result.stdout.trim() : null;
80
94
  }
81
95
 
82
- function appRunning(appPath) {
96
+ function appProcessPattern(appPath) {
83
97
  const executable = path.join(appPath, 'Contents', 'MacOS', 'Doubao');
84
- return spawnSync('/usr/bin/pgrep', ['-f', executable]).status === 0;
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'}`);
85
161
  }
86
162
 
87
163
  function validateId(value) {
@@ -94,7 +170,7 @@ function sessionWithTitle(profilePath, id) {
94
170
  }
95
171
 
96
172
  export async function main(argv) {
97
- const { args, profile: requestedProfile, json, wait, timeoutMs, limit, model } = parseOptions(argv);
173
+ const { args, profile: requestedProfile, json, yes, wait, timeoutMs, limit, model, attachments } = parseOptions(argv);
98
174
  const [command, subcommand, operand] = args;
99
175
  const dataDir = getDataDir();
100
176
 
@@ -127,8 +203,10 @@ export async function main(argv) {
127
203
  listSessions: true,
128
204
  detectCurrentSession: true,
129
205
  openSession: true,
206
+ createSessions: cdp.available,
130
207
  readMessages: cdp.available,
131
208
  sendMessages: cdp.available,
209
+ uploadAttachments: cdp.available,
132
210
  selectModels: cdp.available,
133
211
  cdp,
134
212
  note: cdp.available
@@ -141,8 +219,10 @@ export async function main(argv) {
141
219
  console.log('sessions list\tyes');
142
220
  console.log('sessions current\tyes');
143
221
  console.log('sessions open\tyes');
222
+ console.log(`sessions create\t${capabilities.createSessions ? 'yes' : 'no'}`);
144
223
  console.log(`messages read\t${capabilities.readMessages ? 'yes' : 'no'}`);
145
224
  console.log(`messages send\t${capabilities.sendMessages ? 'yes' : 'no'}`);
225
+ console.log(`attachments upload\t${capabilities.uploadAttachments ? 'yes' : 'no'}`);
146
226
  console.log(`models select\t${capabilities.selectModels ? 'yes' : 'no'}`);
147
227
  console.log(`note\t${capabilities.note}`);
148
228
  }
@@ -199,17 +279,22 @@ export async function main(argv) {
199
279
  if (command === 'cdp' && subcommand === 'launch') {
200
280
  const existing = await cdpStatus();
201
281
  if (existing.available) {
282
+ await waitForAutomationReady();
202
283
  if (json) output(existing, true);
203
284
  else console.log(`available\tyes\nendpoint\t${existing.endpoint}`);
204
285
  return;
205
286
  }
206
287
  const appPath = process.env.DOUBAO_APP || DEFAULT_APP;
207
- if (appRunning(appPath)) throw new Error('Doubao is already running without CDP. Quit it completely, then run this command again.');
208
288
  const endpoint = new URL(existing.endpoint);
209
289
  if (endpoint.hostname !== '127.0.0.1' && endpoint.hostname !== 'localhost') {
210
290
  throw new Error('cdp launch only supports a localhost DOUBAO_CDP_ENDPOINT');
211
291
  }
212
292
  const port = endpoint.port || '9225';
293
+ const restarted = appRunning(appPath);
294
+ if (restarted) {
295
+ await confirmCdpRestart({ json, yes });
296
+ await quitAppForCdp(appPath);
297
+ }
213
298
  const result = spawnSync('/usr/bin/open', ['-a', appPath, '--args', `--remote-debugging-port=${port}`], { encoding: 'utf8' });
214
299
  if (result.status !== 0) throw new Error(result.stderr.trim() || 'failed to launch Doubao with CDP');
215
300
  let launched = existing;
@@ -218,8 +303,10 @@ export async function main(argv) {
218
303
  launched = await cdpStatus();
219
304
  }
220
305
  if (!launched.available) throw new Error(`Doubao launched, but CDP did not become available at ${existing.endpoint}`);
221
- if (json) output(launched, true);
222
- else console.log(`available\tyes\nendpoint\t${launched.endpoint}`);
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'}`);
223
310
  return;
224
311
  }
225
312
 
@@ -269,6 +356,25 @@ export async function main(argv) {
269
356
  return;
270
357
  }
271
358
 
359
+ if (subcommand === 'create') {
360
+ const message = args.slice(2).join(' ');
361
+ const result = await createConversation(message, {
362
+ attachments,
363
+ model,
364
+ timeoutMs,
365
+ waitForReply: wait,
366
+ });
367
+ if (json) output(result, true);
368
+ else {
369
+ console.log(`created\t${result.conversationId || 'draft'}`);
370
+ if (result.model) console.log(`model\t${result.model}`);
371
+ for (const attachment of result.attachments || []) console.log(`attachment\t${attachment.name}`);
372
+ if (result.sent) console.log(`sent\t${result.sent.text}`);
373
+ if (result.reply) console.log(`reply\t${result.reply.text.replaceAll('\n', '\\n')}`);
374
+ }
375
+ return;
376
+ }
377
+
272
378
  if (subcommand === 'open') {
273
379
  const id = validateId(operand);
274
380
  const url = openConversation(id);
@@ -288,11 +394,12 @@ export async function main(argv) {
288
394
  if (subcommand === 'send') {
289
395
  const id = validateId(operand);
290
396
  const message = args.slice(3).join(' ');
291
- const result = await sendMessage(id, message, { waitForReply: wait, timeoutMs, model });
397
+ const result = await sendMessage(id, message, { attachments, waitForReply: wait, timeoutMs, model });
292
398
  if (json) output(result, true);
293
399
  else {
294
400
  console.log(`sent\t${result.sent.text}`);
295
401
  if (result.model) console.log(`model\t${result.model}`);
402
+ for (const attachment of result.attachments || []) console.log(`attachment\t${attachment.name}`);
296
403
  if (result.reply) console.log(`reply\t${result.reply.text.replaceAll('\n', '\\n')}`);
297
404
  }
298
405
  return;