doubao-cli 0.4.1 → 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 CHANGED
@@ -97,7 +97,7 @@ No hard-coded UI coordinates, image recognition, Cookie extraction, or private c
97
97
 
98
98
  ## Limits
99
99
 
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.
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.
101
101
 
102
102
  ## Development
103
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doubao-cli",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Programmatic control for local Doubao desktop sessions on macOS",
5
5
  "author": "Fullstop000 <fullstop1005@gmail.com>",
6
6
  "type": "module",
@@ -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
- async function waitForUploads(client, expectedCount, timeoutMs) {
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 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
- }));
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.length === expectedCount && state.every((item) => item.available)) return 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(`document.querySelectorAll(${JSON.stringify(ATTACHMENT_ITEM)}).length`);
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
- uploaded = await waitForUploads(client, files.length, timeoutMs);
196
+ await waitForUploads(client, files, timeoutMs);
145
197
  } catch (error) {
146
198
  await clearComposerAttachments(client).catch(() => {});
147
199
  throw error;
148
200
  }
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
- }));
201
+ return files;
155
202
  } finally {
156
203
  await client.evaluate(`delete globalThis[${JSON.stringify(stateKey)}]`).catch(() => {});
157
204
  }
@@ -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
- return role && (parts.length || attachments.length)
82
- ? { role, text: parts.join('\\n'), ...(attachments.length ? { attachments } : {}) }
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
- return [...expectedCounts].every(([name, expected]) => (
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 = {}) {