doubao-cli 0.3.1 → 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 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,6 +48,23 @@ 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
 
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
+
48
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.
49
69
 
50
70
  | Model | Value | Short aliases |
@@ -66,12 +86,14 @@ CDP is unauthenticated but bound to `127.0.0.1`. Quit and relaunch Doubao normal
66
86
  - The current session is recovered from Chromium's local session store.
67
87
  - Opening a session uses Doubao's registered `doubao://doubaoapp/open-url` deep-link router.
68
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.
69
91
 
70
92
  No hard-coded UI coordinates, image recognition, Cookie extraction, or private credential copying are involved.
71
93
 
72
94
  ## Limits
73
95
 
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.
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.
75
97
 
76
98
  ## Development
77
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doubao-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
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/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] === '--json') {
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;