doubao-cli 0.2.4 → 0.3.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 +18 -4
- package/package.json +2 -1
- package/src/automation.mjs +8 -2
- package/src/cdp.mjs +53 -7
- package/src/cli.mjs +51 -5
- package/src/models.mjs +200 -0
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
|
+
|
|
7
9
|
```bash
|
|
8
|
-
npm install --global doubao-cli
|
|
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
|
+
|
|
16
|
+
```bash
|
|
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,8 @@ 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. Exact display names and these aliases are supported: `auto`, `turbo`, `pro`, `orange`, `gemini`, `gpt`, `sol`, plus the stable ids printed by `doubao models`.
|
|
49
|
+
|
|
35
50
|
CDP is unauthenticated but bound to `127.0.0.1`. Quit and relaunch Doubao normally when automation is no longer needed.
|
|
36
51
|
|
|
37
52
|
## How it works
|
|
@@ -39,8 +54,9 @@ CDP is unauthenticated but bound to `127.0.0.1`. Quit and relaunch Doubao normal
|
|
|
39
54
|
- Session ids and titles are read directly from Doubao's local IndexedDB cache.
|
|
40
55
|
- The current session is recovered from Chromium's local session store.
|
|
41
56
|
- Opening a session uses Doubao's registered `doubao://doubaoapp/open-url` deep-link router.
|
|
57
|
+
- Model discovery and selection use the renderer's semantic menu attributes and native CDP input events.
|
|
42
58
|
|
|
43
|
-
No UI coordinates, image recognition, Cookie extraction, or private credential copying are involved.
|
|
59
|
+
No hard-coded UI coordinates, image recognition, Cookie extraction, or private credential copying are involved.
|
|
44
60
|
|
|
45
61
|
## Limits
|
|
46
62
|
|
|
@@ -48,8 +64,6 @@ Message read/send uses stable DOM test ids in the authenticated Doubao renderer
|
|
|
48
64
|
|
|
49
65
|
## Development
|
|
50
66
|
|
|
51
|
-
Requires macOS and Node.js 22 or newer.
|
|
52
|
-
|
|
53
67
|
```bash
|
|
54
68
|
npm test
|
|
55
69
|
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doubao-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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"
|
package/src/automation.mjs
CHANGED
|
@@ -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)
|
|
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)
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 <
|
|
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
|
+
}
|