doubao-cli 0.2.4 → 0.3.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
@@ -4,8 +4,17 @@ Programmatic access to local sessions in the macOS Doubao desktop app.
4
4
 
5
5
  ## Install
6
6
 
7
+ Requires macOS, Node.js 22 or newer, and the Doubao desktop app.
8
+
9
+ ```bash
10
+ npm install --global doubao-cli@latest
11
+ doubao --version
12
+ ```
13
+
14
+ Upgrade an existing installation with the same command. To run without a global install:
15
+
7
16
  ```bash
8
- npm install --global doubao-cli
17
+ npx --yes doubao-cli@latest status
9
18
  ```
10
19
 
11
20
  ## Commands
@@ -19,6 +28,10 @@ doubao sessions open 38439138239851266
19
28
  doubao sessions read 38439138239851266 --limit 5
20
29
  doubao sessions send 38439138239851266 "hello"
21
30
  doubao sessions send 38439138239851266 "hello" --wait
31
+ doubao models
32
+ doubao model
33
+ doubao model select doubao-2.1-turbo
34
+ doubao sessions send 38439138239851266 "hello" --model gpt-5.6-sol --wait
22
35
  doubao cdp status
23
36
  doubao cdp launch
24
37
  doubao capabilities
@@ -32,6 +45,19 @@ Quit any running Doubao process first, then run `doubao cdp launch`. The equival
32
45
 
33
46
  Set `DOUBAO_CDP_ENDPOINT` if using another port. `sessions send --wait` waits for and returns the completed assistant reply.
34
47
 
48
+ `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
+
50
+ | Model | Value | Short aliases |
51
+ | --- | --- | --- |
52
+ | 自动 | `auto` | `自动` |
53
+ | 豆包 2.1 Turbo | `doubao-2.1-turbo` | `turbo` |
54
+ | 豆包 2.1 Pro | `doubao-2.1-pro` | `pro` |
55
+ | Orange 5.0 | `orange-5.0` | `orange` |
56
+ | Gemini 3.7 Flash | `gemini-3.7-flash` | `gemini` |
57
+ | GPT-5.6 Sol | `gpt-5.6-sol` | `gpt`, `sol` |
58
+
59
+ Use the value, exact display name, or a short alias anywhere `<model>` is accepted. Run `doubao models` to verify the choices exposed by the installed Doubao version.
60
+
35
61
  CDP is unauthenticated but bound to `127.0.0.1`. Quit and relaunch Doubao normally when automation is no longer needed.
36
62
 
37
63
  ## How it works
@@ -39,8 +65,9 @@ CDP is unauthenticated but bound to `127.0.0.1`. Quit and relaunch Doubao normal
39
65
  - Session ids and titles are read directly from Doubao's local IndexedDB cache.
40
66
  - The current session is recovered from Chromium's local session store.
41
67
  - Opening a session uses Doubao's registered `doubao://doubaoapp/open-url` deep-link router.
68
+ - Model discovery and selection use the renderer's semantic menu attributes and native CDP input events.
42
69
 
43
- No UI coordinates, image recognition, Cookie extraction, or private credential copying are involved.
70
+ No hard-coded UI coordinates, image recognition, Cookie extraction, or private credential copying are involved.
44
71
 
45
72
  ## Limits
46
73
 
@@ -48,8 +75,6 @@ Message read/send uses stable DOM test ids in the authenticated Doubao renderer
48
75
 
49
76
  ## Development
50
77
 
51
- Requires macOS and Node.js 22 or newer.
52
-
53
78
  ```bash
54
79
  npm test
55
80
  ```
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "doubao-cli",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "Programmatic control for local Doubao desktop sessions on macOS",
5
+ "author": "Fullstop000 <fullstop1005@gmail.com>",
5
6
  "type": "module",
6
7
  "bin": {
7
8
  "doubao": "bin/doubao.mjs"
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
2
  import { withChatClient } from './cdp.mjs';
3
+ import { selectModelFromClient } from './models.mjs';
3
4
 
4
5
  const CHAT_INPUT = '[data-testid="chat_input_input"] [contenteditable="true"]';
5
6
  const SEND_BUTTON = '[data-testid="chat_input_send_button"]';
@@ -73,6 +74,7 @@ export async function sendMessage(id, message, options = {}) {
73
74
  openConversation(id);
74
75
  return withChatClient(async (client) => {
75
76
  await waitForConversation(client, id, Math.min(timeoutMs, 15_000));
77
+ const selectedModel = options.model ? await selectModelFromClient(client, options.model) : null;
76
78
  const before = await readFromClient(client);
77
79
  const matchingUserCountBefore = before.filter((item) => item.role === 'user' && item.text === message).length;
78
80
  const encodedMessage = JSON.stringify(message);
@@ -104,7 +106,9 @@ export async function sendMessage(id, message, options = {}) {
104
106
  const sent = matchingUserMessages.length > matchingUserCountBefore ? matchingUserMessages.at(-1) : null;
105
107
  if (!sent) throw new Error(`Doubao did not confirm a new sent message within ${timeoutMs} ms`);
106
108
 
107
- if (!waitForReply) return { conversationId: id, sent, reply: null };
109
+ if (!waitForReply) {
110
+ return { conversationId: id, ...(selectedModel ? { model: selectedModel.name } : {}), sent, reply: null };
111
+ }
108
112
 
109
113
  let stableText = '';
110
114
  let stablePolls = 0;
@@ -125,7 +129,9 @@ export async function sendMessage(id, message, options = {}) {
125
129
  if (reply?.text && reply.text === stableText && !generating) stablePolls += 1;
126
130
  else stablePolls = 0;
127
131
  stableText = reply?.text || '';
128
- if (reply && stablePolls >= 2) return { conversationId: id, sent, reply };
132
+ if (reply && stablePolls >= 2) {
133
+ return { conversationId: id, ...(selectedModel ? { model: selectedModel.name } : {}), sent, reply };
134
+ }
129
135
  await delay(500);
130
136
  }
131
137
  throw new Error(`Doubao reply did not complete within ${timeoutMs} ms`);
package/src/cdp.mjs CHANGED
@@ -25,13 +25,17 @@ export async function cdpStatus(endpoint = cdpEndpoint()) {
25
25
  }
26
26
  }
27
27
 
28
- export async function findChatTarget(endpoint = cdpEndpoint()) {
29
- const targets = await fetchJson(`${endpoint}/json/list`);
30
- const target = targets.find(
31
- (item) => item.type === 'page' && /^(?:doubao|chrome):\/\/doubao-chat\/chat(?:\/|$)/u.test(item.url),
32
- );
33
- if (!target?.webSocketDebuggerUrl) throw new Error(`no Doubao chat page found at ${endpoint}`);
34
- return target;
28
+ export async function findChatTarget(endpoint = cdpEndpoint(), timeoutMs = 5000) {
29
+ const deadline = Date.now() + timeoutMs;
30
+ do {
31
+ const targets = await fetchJson(`${endpoint}/json/list`);
32
+ const target = targets.find(
33
+ (item) => item.type === 'page' && /^(?:doubao|chrome):\/\/doubao-chat\/chat(?:\/|$)/u.test(item.url),
34
+ );
35
+ if (target?.webSocketDebuggerUrl) return target;
36
+ await new Promise((resolve) => setTimeout(resolve, 100));
37
+ } while (Date.now() < deadline);
38
+ throw new Error(`no Doubao chat page found at ${endpoint}`);
35
39
  }
36
40
 
37
41
  export class CdpClient {
@@ -86,6 +90,48 @@ export class CdpClient {
86
90
  return result.result?.value;
87
91
  }
88
92
 
93
+ async click(selector) {
94
+ const point = await this.evaluate(`(() => {
95
+ const element = document.querySelector(${JSON.stringify(selector)});
96
+ if (!element) throw new Error('click target was not found');
97
+ element.scrollIntoView({ block: 'center', inline: 'center' });
98
+ const rect = element.getBoundingClientRect();
99
+ if (!rect.width || !rect.height) throw new Error('click target is not visible');
100
+ return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
101
+ })()`);
102
+ await this.send('Input.dispatchMouseEvent', {
103
+ type: 'mousePressed',
104
+ x: point.x,
105
+ y: point.y,
106
+ button: 'left',
107
+ clickCount: 1,
108
+ });
109
+ await this.send('Input.dispatchMouseEvent', {
110
+ type: 'mouseReleased',
111
+ x: point.x,
112
+ y: point.y,
113
+ button: 'left',
114
+ clickCount: 1,
115
+ });
116
+ }
117
+
118
+ async pressEscape() {
119
+ await this.send('Input.dispatchKeyEvent', {
120
+ type: 'rawKeyDown',
121
+ key: 'Escape',
122
+ code: 'Escape',
123
+ windowsVirtualKeyCode: 27,
124
+ nativeVirtualKeyCode: 53,
125
+ });
126
+ await this.send('Input.dispatchKeyEvent', {
127
+ type: 'keyUp',
128
+ key: 'Escape',
129
+ code: 'Escape',
130
+ windowsVirtualKeyCode: 27,
131
+ nativeVirtualKeyCode: 53,
132
+ });
133
+ }
134
+
89
135
  close() {
90
136
  this.socket?.close();
91
137
  }
package/src/cli.mjs CHANGED
@@ -4,6 +4,7 @@ import { spawnSync } from 'node:child_process';
4
4
  import { currentSession, getDataDir, listSessions, resolveProfile } from './storage.mjs';
5
5
  import { cdpStatus } from './cdp.mjs';
6
6
  import { openConversation, readConversation, sendMessage } from './automation.mjs';
7
+ import { currentModel, listModels, selectModel } from './models.mjs';
7
8
 
8
9
  const DEFAULT_APP = '/Applications/Doubao.app';
9
10
  const CLI_VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
@@ -15,7 +16,10 @@ const HELP = `Usage:
15
16
  doubao sessions current [--profile <name>] [--json]
16
17
  doubao sessions open <conversation-id>
17
18
  doubao sessions read <conversation-id> [--limit <count>] [--json]
18
- doubao sessions send <conversation-id> <message> [--wait] [--timeout <seconds>] [--json]
19
+ doubao sessions send <conversation-id> <message> [--model <model>] [--wait] [--timeout <seconds>] [--json]
20
+ doubao models [--json]
21
+ doubao model [--json]
22
+ doubao model select <model> [--json]
19
23
  doubao cdp status [--json]
20
24
  doubao cdp launch [--json]
21
25
  doubao capabilities [--json]
@@ -33,6 +37,7 @@ function parseOptions(argv) {
33
37
  let wait = false;
34
38
  let timeoutSeconds = 120;
35
39
  let limit = 20;
40
+ let model;
36
41
  for (let index = 0; index < argv.length; index += 1) {
37
42
  if (argv[index] === '--json') {
38
43
  json = true;
@@ -50,11 +55,15 @@ function parseOptions(argv) {
50
55
  limit = Number(argv[index + 1]);
51
56
  if (!Number.isInteger(limit) || limit <= 0 || limit > 1000) throw new Error('--limit requires an integer from 1 to 1000');
52
57
  index += 1;
58
+ } else if (argv[index] === '--model') {
59
+ model = argv[index + 1];
60
+ if (!model) throw new Error('--model requires a value');
61
+ index += 1;
53
62
  } else {
54
63
  args.push(argv[index]);
55
64
  }
56
65
  }
57
- return { args, profile, json, wait, timeoutMs: timeoutSeconds * 1000, limit };
66
+ return { args, profile, json, wait, timeoutMs: timeoutSeconds * 1000, limit, model };
58
67
  }
59
68
 
60
69
  function output(value, json) {
@@ -85,7 +94,7 @@ function sessionWithTitle(profilePath, id) {
85
94
  }
86
95
 
87
96
  export async function main(argv) {
88
- const { args, profile: requestedProfile, json, wait, timeoutMs, limit } = parseOptions(argv);
97
+ const { args, profile: requestedProfile, json, wait, timeoutMs, limit, model } = parseOptions(argv);
89
98
  const [command, subcommand, operand] = args;
90
99
  const dataDir = getDataDir();
91
100
 
@@ -120,6 +129,7 @@ export async function main(argv) {
120
129
  openSession: true,
121
130
  readMessages: cdp.available,
122
131
  sendMessages: cdp.available,
132
+ selectModels: cdp.available,
123
133
  cdp,
124
134
  note: cdp.available
125
135
  ? 'Message automation is available through the authenticated Doubao renderer over local CDP.'
@@ -133,11 +143,46 @@ export async function main(argv) {
133
143
  console.log('sessions open\tyes');
134
144
  console.log(`messages read\t${capabilities.readMessages ? 'yes' : 'no'}`);
135
145
  console.log(`messages send\t${capabilities.sendMessages ? 'yes' : 'no'}`);
146
+ console.log(`models select\t${capabilities.selectModels ? 'yes' : 'no'}`);
136
147
  console.log(`note\t${capabilities.note}`);
137
148
  }
138
149
  return;
139
150
  }
140
151
 
152
+ if (command === 'models') {
153
+ const result = await listModels();
154
+ if (json) output(result, true);
155
+ else {
156
+ console.log('SELECTED\tID\tMODEL');
157
+ for (const item of result.models) console.log(`${item.selected ? '*' : ''}\t${item.id}\t${item.name}`);
158
+ if (result.reasoning) console.log(`reasoning\t${result.reasoning}`);
159
+ }
160
+ return;
161
+ }
162
+
163
+ if (command === 'model' && (!subcommand || subcommand === 'current')) {
164
+ const result = await currentModel();
165
+ if (json) output(result, true);
166
+ else {
167
+ console.log(`model\t${result.name}`);
168
+ console.log(`id\t${result.id}`);
169
+ if (result.reasoning) console.log(`reasoning\t${result.reasoning}`);
170
+ }
171
+ return;
172
+ }
173
+
174
+ if (command === 'model' && subcommand === 'select') {
175
+ const requestedModel = args.slice(2).join(' ');
176
+ const result = await selectModel(requestedModel);
177
+ if (json) output(result, true);
178
+ else {
179
+ console.log(`model\t${result.name}`);
180
+ console.log(`changed\t${result.changed ? 'yes' : 'no'}`);
181
+ if (result.reasoning) console.log(`reasoning\t${result.reasoning}`);
182
+ }
183
+ return;
184
+ }
185
+
141
186
  if (command === 'cdp' && subcommand === 'status') {
142
187
  const status = await cdpStatus();
143
188
  if (json) output(status, true);
@@ -168,7 +213,7 @@ export async function main(argv) {
168
213
  const result = spawnSync('/usr/bin/open', ['-a', appPath, '--args', `--remote-debugging-port=${port}`], { encoding: 'utf8' });
169
214
  if (result.status !== 0) throw new Error(result.stderr.trim() || 'failed to launch Doubao with CDP');
170
215
  let launched = existing;
171
- for (let attempt = 0; attempt < 30 && !launched.available; attempt += 1) {
216
+ for (let attempt = 0; attempt < 120 && !launched.available; attempt += 1) {
172
217
  await new Promise((resolve) => setTimeout(resolve, 250));
173
218
  launched = await cdpStatus();
174
219
  }
@@ -243,10 +288,11 @@ export async function main(argv) {
243
288
  if (subcommand === 'send') {
244
289
  const id = validateId(operand);
245
290
  const message = args.slice(3).join(' ');
246
- const result = await sendMessage(id, message, { waitForReply: wait, timeoutMs });
291
+ const result = await sendMessage(id, message, { waitForReply: wait, timeoutMs, model });
247
292
  if (json) output(result, true);
248
293
  else {
249
294
  console.log(`sent\t${result.sent.text}`);
295
+ if (result.model) console.log(`model\t${result.model}`);
250
296
  if (result.reply) console.log(`reply\t${result.reply.text.replaceAll('\n', '\\n')}`);
251
297
  }
252
298
  return;
package/src/models.mjs ADDED
@@ -0,0 +1,200 @@
1
+ import { withChatClient } from './cdp.mjs';
2
+
3
+ const MODEL_TRIGGER = '[data-valid-btn="model-select-action-btn"]';
4
+ const MODEL_OPTION = '[role="menuitem"][data-slot="dropdown-menu-item"]';
5
+
6
+ const MODEL_IDS = new Map([
7
+ ['自动', 'auto'],
8
+ ['豆包 2.1 Turbo', 'doubao-2.1-turbo'],
9
+ ['豆包 2.1 Pro', 'doubao-2.1-pro'],
10
+ ['Orange 5.0', 'orange-5.0'],
11
+ ['Gemini 3.7 Flash', 'gemini-3.7-flash'],
12
+ ['GPT-5.6 Sol', 'gpt-5.6-sol'],
13
+ ]);
14
+
15
+ const ALIASES = new Map([
16
+ ['auto', '自动'],
17
+ ['自动', '自动'],
18
+ ['turbo', '豆包 2.1 Turbo'],
19
+ ['doubao turbo', '豆包 2.1 Turbo'],
20
+ ['doubao 2.1 turbo', '豆包 2.1 Turbo'],
21
+ ['pro', '豆包 2.1 Pro'],
22
+ ['doubao pro', '豆包 2.1 Pro'],
23
+ ['doubao 2.1 pro', '豆包 2.1 Pro'],
24
+ ['orange', 'Orange 5.0'],
25
+ ['orange 5.0', 'Orange 5.0'],
26
+ ['gemini', 'Gemini 3.7 Flash'],
27
+ ['gemini flash', 'Gemini 3.7 Flash'],
28
+ ['gemini 3.7 flash', 'Gemini 3.7 Flash'],
29
+ ['gpt', 'GPT-5.6 Sol'],
30
+ ['sol', 'GPT-5.6 Sol'],
31
+ ['gpt sol', 'GPT-5.6 Sol'],
32
+ ['gpt 5.6 sol', 'GPT-5.6 Sol'],
33
+ ]);
34
+
35
+ function delay(milliseconds) {
36
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
37
+ }
38
+
39
+ export function normalizeModelName(value) {
40
+ return String(value || '')
41
+ .trim()
42
+ .toLocaleLowerCase('en-US')
43
+ .replace(/[_-]+/gu, ' ')
44
+ .replace(/\s+/gu, ' ');
45
+ }
46
+
47
+ export function modelId(name) {
48
+ return MODEL_IDS.get(name) || normalizeModelName(name).replaceAll(' ', '-');
49
+ }
50
+
51
+ export function resolveModelName(value, availableNames) {
52
+ const normalized = normalizeModelName(value);
53
+ if (!normalized) throw new Error('model cannot be empty');
54
+ const exact = availableNames.find((name) => normalizeModelName(name) === normalized);
55
+ const alias = ALIASES.get(normalized);
56
+ const resolved = exact || (alias && availableNames.includes(alias) ? alias : null);
57
+ if (resolved) return resolved;
58
+ throw new Error(`unknown model "${value}". Available models: ${availableNames.join(', ')}`);
59
+ }
60
+
61
+ async function waitFor(client, expression, timeoutMs = 3000, errorMessage = 'Doubao model menu did not respond') {
62
+ const deadline = Date.now() + timeoutMs;
63
+ while (Date.now() < deadline) {
64
+ const value = await client.evaluate(expression);
65
+ if (value) return value;
66
+ await delay(50);
67
+ }
68
+ throw new Error(errorMessage);
69
+ }
70
+
71
+ async function closeModelMenu(client, menuId) {
72
+ await client.pressEscape();
73
+ await waitFor(client, `(() => {
74
+ const trigger = document.querySelector(${JSON.stringify(MODEL_TRIGGER)});
75
+ const controlledId = ${JSON.stringify(menuId || '')} || trigger?.getAttribute('aria-controls');
76
+ const menu = controlledId ? document.getElementById(controlledId) : null;
77
+ return trigger?.getAttribute('data-state') !== 'open'
78
+ && (!menu || menu.getAttribute('data-state') !== 'open') ? true : null;
79
+ })()`, 1500).catch(() => {});
80
+ await delay(50);
81
+ }
82
+
83
+ export async function currentModelFromClient(client) {
84
+ const state = await waitFor(client, `(() => {
85
+ const trigger = document.querySelector(${JSON.stringify(MODEL_TRIGGER)});
86
+ const button = trigger?.querySelector(':scope > button');
87
+ if (!button) return null;
88
+ const lines = (button.innerText || button.textContent || '')
89
+ .split('\\n')
90
+ .map((line) => line.trim())
91
+ .filter(Boolean);
92
+ return { name: lines[0] || null, reasoning: lines.slice(1).join(' ') || null };
93
+ })()`, 5000, 'Doubao model selector was not found');
94
+ if (!state?.name) throw new Error('Doubao current model could not be read');
95
+ return { id: modelId(state.name), ...state };
96
+ }
97
+
98
+ async function openModelMenu(client) {
99
+ for (let attempt = 0; attempt < 2; attempt += 1) {
100
+ await closeModelMenu(client);
101
+ await client.click(MODEL_TRIGGER);
102
+ try {
103
+ return await waitFor(client, `(() => {
104
+ const trigger = document.querySelector(${JSON.stringify(MODEL_TRIGGER)});
105
+ const menuId = trigger?.getAttribute('aria-controls');
106
+ const menu = menuId ? document.getElementById(menuId) : null;
107
+ return menu?.getAttribute('data-state') === 'open' ? menuId : null;
108
+ })()`, 1500);
109
+ } catch (error) {
110
+ await closeModelMenu(client);
111
+ if (attempt === 1) throw error;
112
+ }
113
+ }
114
+ throw new Error('Doubao model menu did not respond');
115
+ }
116
+
117
+ async function readOpenOptions(client, menuId) {
118
+ const options = await client.evaluate(`(() => {
119
+ const menu = document.getElementById(${JSON.stringify(menuId)});
120
+ if (!menu) return [];
121
+ return [...menu.querySelectorAll(${JSON.stringify(MODEL_OPTION)})]
122
+ .map((item, index) => {
123
+ const label = item.querySelector('span.shrink-0') || item.querySelector('span');
124
+ const name = (label?.innerText || label?.textContent || '').trim();
125
+ return name ? { index, name, selected: Boolean(item.querySelector(':scope > svg')) } : null;
126
+ })
127
+ .filter(Boolean);
128
+ })()`);
129
+ if (!options?.length) throw new Error('Doubao model menu contains no model options');
130
+ return options;
131
+ }
132
+
133
+ export async function listModelsFromClient(client) {
134
+ const current = await currentModelFromClient(client);
135
+ let menuId;
136
+ try {
137
+ menuId = await openModelMenu(client);
138
+ const options = await readOpenOptions(client, menuId);
139
+ return {
140
+ current: current.name,
141
+ reasoning: current.reasoning,
142
+ models: options.map(({ name, selected }) => ({ id: modelId(name), name, selected: selected || name === current.name })),
143
+ };
144
+ } finally {
145
+ if (menuId) await closeModelMenu(client, menuId);
146
+ }
147
+ }
148
+
149
+ export async function selectModelFromClient(client, value) {
150
+ const before = await currentModelFromClient(client);
151
+ let menuId;
152
+ let marker;
153
+ try {
154
+ menuId = await openModelMenu(client);
155
+ const options = await readOpenOptions(client, menuId);
156
+ const name = resolveModelName(value, options.map((option) => option.name));
157
+ if (name === before.name) return { ...before, changed: false };
158
+
159
+ marker = `doubao-cli-${Date.now()}-${Math.random().toString(16).slice(2)}`;
160
+ const marked = await client.evaluate(`(() => {
161
+ const menu = document.getElementById(${JSON.stringify(menuId)});
162
+ const item = [...(menu?.querySelectorAll(${JSON.stringify(MODEL_OPTION)}) || [])]
163
+ .find((candidate) => {
164
+ const label = candidate.querySelector('span.shrink-0') || candidate.querySelector('span');
165
+ return (label?.innerText || label?.textContent || '').trim() === ${JSON.stringify(name)};
166
+ });
167
+ if (!item) return false;
168
+ item.setAttribute('data-doubao-cli-model-option', ${JSON.stringify(marker)});
169
+ return true;
170
+ })()`);
171
+ if (!marked) throw new Error(`Doubao model option "${name}" disappeared`);
172
+ await client.click(`[data-doubao-cli-model-option="${marker}"]`);
173
+ const selected = await waitFor(client, `(() => {
174
+ const trigger = document.querySelector(${JSON.stringify(MODEL_TRIGGER)});
175
+ const button = trigger?.querySelector(':scope > button');
176
+ const firstLine = (button?.innerText || button?.textContent || '').split('\\n')[0].trim();
177
+ return firstLine === ${JSON.stringify(name)} ? true : null;
178
+ })()`);
179
+ if (!selected) throw new Error(`Doubao did not select model "${name}"`);
180
+ return { ...(await currentModelFromClient(client)), changed: true };
181
+ } finally {
182
+ if (marker) {
183
+ await client.evaluate(`document.querySelector('[data-doubao-cli-model-option=${JSON.stringify(marker)}]')
184
+ ?.removeAttribute('data-doubao-cli-model-option')`).catch(() => {});
185
+ }
186
+ if (menuId) await closeModelMenu(client, menuId);
187
+ }
188
+ }
189
+
190
+ export async function currentModel() {
191
+ return withChatClient((client) => currentModelFromClient(client));
192
+ }
193
+
194
+ export async function listModels() {
195
+ return withChatClient((client) => listModelsFromClient(client));
196
+ }
197
+
198
+ export async function selectModel(value) {
199
+ return withChatClient((client) => selectModelFromClient(client, value));
200
+ }