zames_pro 2.73.0 → 2.73.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/CHANGELOG.md +12 -0
- package/README.md +55 -41
- package/dist/agent-loop.js +2 -0
- package/dist/i18n/common.js +2 -2
- package/dist/i18n/qwen.js +55 -8
- package/dist/index.js +159 -90
- package/dist/providers.js +1 -1
- package/dist/qwen-browser.js +106 -31
- package/package.json +4 -1
- package/scripts/qwen-smoke.mts +189 -0
- package/scripts/render-how-it-works.mts +129 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.73.2] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- docs(readme): document Qwen everywhere and refresh the diagram
|
|
15
|
+
|
|
16
|
+
## [2.73.1] - 2026-10-11
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- **qwen:** live provider switch, verified send, server-busy handling
|
|
21
|
+
|
|
10
22
|
## [2.73.0] - 2026-10-11
|
|
11
23
|
|
|
12
24
|
### Added
|
package/README.md
CHANGED
|
@@ -63,27 +63,31 @@ zames does not call the model API. It launches a headless Chromium with a
|
|
|
63
63
|
persistent profile, signs in to a chat web app like a human, types the task
|
|
64
64
|
into the chat box, and reads the answer back.
|
|
65
65
|
|
|
66
|
-
Two chat backends are available, switched with `/provider <deepseek|qwen
|
|
67
|
-
|
|
68
|
-
**
|
|
69
|
-
|
|
66
|
+
Two chat backends are available, switched with `/provider <deepseek|qwen>`:
|
|
67
|
+
**DeepSeek** (`chat.deepseek.com`, the default) and **Qwen** (`chat.qwen.ai`).
|
|
68
|
+
The switch is **live** — zames closes the current backend and opens the other
|
|
69
|
+
one in place, no restart needed. Each keeps its own persistent profile
|
|
70
|
+
(`~/.zames/profile` and `~/.zames/qwen-profile`), so switching never signs the
|
|
71
|
+
other one out.
|
|
70
72
|
|
|
71
73
|
<p align="center">
|
|
72
74
|
<img src="https://raw.githubusercontent.com/Viqto0r/zames_pro/master/docs/how-it-works.png" alt="how zames works" width="860">
|
|
73
75
|
</p>
|
|
74
76
|
|
|
75
77
|
In text: you type a task → zames sends the system prompt plus the task into
|
|
76
|
-
the
|
|
77
|
-
the tool locally, feeds the result back, and prints the
|
|
78
|
+
the active chat web app (DeepSeek or Qwen) → the model answers → zames parses
|
|
79
|
+
the tool call, runs the tool locally, feeds the result back, and prints the
|
|
80
|
+
final answer for you.
|
|
78
81
|
|
|
79
82
|
Key pieces:
|
|
80
83
|
|
|
81
84
|
- **The answer is read from the raw network stream** (SSE), not the rendered
|
|
82
85
|
DOM, so tool-call JSON with template strings and escapes survives intact.
|
|
83
|
-
- **A persistent profile** (`~/.zames/profile`
|
|
84
|
-
|
|
85
|
-
-
|
|
86
|
-
|
|
86
|
+
- **A persistent per-provider profile** (`~/.zames/profile` for DeepSeek,
|
|
87
|
+
`~/.zames/qwen-profile` for Qwen) keeps you signed in; the headless
|
|
88
|
+
User-Agent is patched so the site's CDN does not 403 the login.
|
|
89
|
+
- **A send throttle** (15 s on DeepSeek, ~1 s on Qwen, which tolerates rapid
|
|
90
|
+
sends) keeps the web chat's rate limit happy during long tool-heavy runs.
|
|
87
91
|
- **The agent is sandboxed** to the directory it was started in — no tool can
|
|
88
92
|
read or write above it.
|
|
89
93
|
|
|
@@ -174,7 +178,7 @@ zames can drive either **DeepSeek** (`chat.deepseek.com`, the default) or
|
|
|
174
178
|
**Qwen** (`chat.qwen.ai`):
|
|
175
179
|
|
|
176
180
|
```bash
|
|
177
|
-
/provider qwen # switch to Qwen (persisted)
|
|
181
|
+
/provider qwen # switch to Qwen (persisted) — applied live, no restart
|
|
178
182
|
/provider deepseek # switch back
|
|
179
183
|
/provider status # show the active provider / model / login state
|
|
180
184
|
/provider login # (re)run the sign-in check
|
|
@@ -184,15 +188,18 @@ zames can drive either **DeepSeek** (`chat.deepseek.com`, the default) or
|
|
|
184
188
|
|
|
185
189
|
Notes:
|
|
186
190
|
|
|
187
|
-
- The
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
+
- The switch is **live**: zames closes the current backend and opens the other
|
|
192
|
+
one in place, so no restart is needed. The two backends use **separate
|
|
193
|
+
persistent profiles** (`~/.zames/profile` for DeepSeek, `~/.zames/qwen-profile`
|
|
194
|
+
for Qwen), so switching never signs the other one out.
|
|
195
|
+
- A chat id belongs to ONE backend, so the new provider opens a **fresh chat**.
|
|
196
|
+
If the previous chat had messages they are summarized and posted as a
|
|
197
|
+
handoff with the next task; an empty chat is switched silently.
|
|
191
198
|
- Qwen works in **guest mode** without an account, but a guest is limited to a
|
|
192
199
|
handful of messages per day; for long agent runs sign in (email / Google /
|
|
193
200
|
GitHub — no phone required). zames auto-logins from the saved credentials or
|
|
194
201
|
asks in the terminal, exactly like DeepSeek. `/model` changes the Qwen model
|
|
195
|
-
at runtime
|
|
202
|
+
at runtime.
|
|
196
203
|
- File attachments on Qwen go through the mode menu's **Upload attachment**
|
|
197
204
|
item; DeepSeek uses its own paperclip.
|
|
198
205
|
- `/doctor` shows the active provider and the UI health of that provider's
|
|
@@ -343,9 +350,10 @@ Codex CLI:
|
|
|
343
350
|
(zames-export-<stamp>.md by default).
|
|
344
351
|
- /doctor — diagnose node, git, config, browser, clipboard, MCP and the
|
|
345
352
|
active chat provider (it also reports that provider's UI health).
|
|
346
|
-
- /provider <deepseek|qwen> — switch the chat backend (
|
|
347
|
-
/provider status shows the active provider/model/login; /provider
|
|
348
|
-
re-runs the sign-in check. /model [id] lists or picks a Qwen model at
|
|
353
|
+
- /provider <deepseek|qwen> — switch the chat backend (applied live, no
|
|
354
|
+
restart); /provider status shows the active provider/model/login; /provider
|
|
355
|
+
login re-runs the sign-in check. /model [id] lists or picks a Qwen model at
|
|
356
|
+
runtime.
|
|
349
357
|
- /add-dir <path> — validate an extra directory (the sandbox is fixed at
|
|
350
358
|
startup; relaunch with --dir to write there).
|
|
351
359
|
- /resume <n> (after /chats) and /resume-id <id> — open a chat and PRINT its
|
|
@@ -582,30 +590,33 @@ zames не вызывает API модели. Он запускает headless C
|
|
|
582
590
|
профилем, входит в веб-чат как человек, печатает задачу в поле чата и читает
|
|
583
591
|
ответ обратно.
|
|
584
592
|
|
|
585
|
-
Доступны два бэкенда, переключаются командой `/provider <deepseek|qwen
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
593
|
+
Доступны два бэкенда, переключаются командой `/provider <deepseek|qwen>`:
|
|
594
|
+
**DeepSeek** (`chat.deepseek.com`, по умолчанию) и **Qwen** (`chat.qwen.ai`).
|
|
595
|
+
Переключение **живое** — zames закрывает текущий бэкенд и открывает другой на
|
|
596
|
+
месте, без перезапуска. У каждого свой постоянный профиль
|
|
597
|
+
(`~/.zames/profile` и `~/.zames/qwen-profile`), поэтому переключение не
|
|
598
|
+
разлогинивает другой.
|
|
589
599
|
|
|
590
600
|
<p align="center">
|
|
591
601
|
<img src="https://raw.githubusercontent.com/Viqto0r/zames_pro/master/docs/how-it-works.png" alt="как работает zames" width="860">
|
|
592
602
|
</p>
|
|
593
603
|
|
|
594
604
|
Словами: вы вводите задачу → zames отправляет системный промпт и задачу в
|
|
595
|
-
веб-чат DeepSeek → модель отвечает → zames разбирает
|
|
596
|
-
выполняет его локально, возвращает результат обратно и
|
|
597
|
-
ответ.
|
|
605
|
+
активный веб-чат (DeepSeek или Qwen) → модель отвечает → zames разбирает
|
|
606
|
+
вызов инструмента, выполняет его локально, возвращает результат обратно и
|
|
607
|
+
печатает финальный ответ.
|
|
598
608
|
|
|
599
609
|
Ключевые детали:
|
|
600
610
|
|
|
601
611
|
- **Ответ читается из сырого сетевого потока** (SSE), а не из отрендеренного
|
|
602
612
|
DOM, поэтому JSON вызова инструмента с шаблонными строками и экранированием
|
|
603
613
|
остаётся целым.
|
|
604
|
-
- **Постоянный
|
|
605
|
-
|
|
606
|
-
на вход.
|
|
607
|
-
- **Троттлинг отправки** (15 с
|
|
608
|
-
|
|
614
|
+
- **Постоянный профиль на каждого провайдера** (`~/.zames/profile` у DeepSeek,
|
|
615
|
+
`~/.zames/qwen-profile` у Qwen) держит вас залогиненным; User-Agent
|
|
616
|
+
headless-браузера подменяется, чтобы CDN сайта не отдавал 403 на вход.
|
|
617
|
+
- **Троттлинг отправки** (15 с у DeepSeek, ~1 с у Qwen, который спокойно
|
|
618
|
+
переносит частые отправки) щадит рейт-лимит веб-чата во время долгих
|
|
619
|
+
прогонов с инструментами.
|
|
609
620
|
- **Агент запесочен** в директорию запуска — ни один инструмент не может
|
|
610
621
|
читать или писать выше неё.
|
|
611
622
|
|
|
@@ -698,7 +709,7 @@ zames умеет работать с **DeepSeek** (`chat.deepseek.com`, по у
|
|
|
698
709
|
**Qwen** (`chat.qwen.ai`):
|
|
699
710
|
|
|
700
711
|
```bash
|
|
701
|
-
/provider qwen # переключиться на Qwen (сохраняется)
|
|
712
|
+
/provider qwen # переключиться на Qwen (сохраняется) — применяется сразу
|
|
702
713
|
/provider deepseek # вернуться обратно
|
|
703
714
|
/provider status # активный провайдер / модель / статус входа
|
|
704
715
|
/provider login # повторно пройти проверку входа
|
|
@@ -708,15 +719,17 @@ zames умеет работать с **DeepSeek** (`chat.deepseek.com`, по у
|
|
|
708
719
|
|
|
709
720
|
Замечания:
|
|
710
721
|
|
|
711
|
-
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
722
|
+
- Переключение **живое**: zames закрывает текущий бэкенд и открывает другой на
|
|
723
|
+
месте, перезапуск не нужен. У бэкендов **разные постоянные профили**
|
|
724
|
+
(`~/.zames/profile` у DeepSeek, `~/.zames/qwen-profile` у Qwen), поэтому
|
|
725
|
+
переключение не разлогинивает другой.
|
|
726
|
+
- id чата принадлежит ОДНОМУ бэкенду, поэтому новый провайдер открывает
|
|
727
|
+
**свежий чат**. Если в прошлом чате были сообщения, они сжимаются и
|
|
728
|
+
подставляются как handoff в следующую задачу; пустой чат переключается молча.
|
|
715
729
|
- Qwen работает в **гостевом режиме** без аккаунта, но гостю доступно всего
|
|
716
730
|
несколько сообщений в день; для долгих прогонов войдите (email / Google /
|
|
717
731
|
GitHub — телефон не нужен). zames сам логинится по сохранённым кредам или
|
|
718
|
-
спросит в терминале, как и с DeepSeek. `/model` меняет модель Qwen на
|
|
719
|
-
(без перезапуска).
|
|
732
|
+
спросит в терминале, как и с DeepSeek. `/model` меняет модель Qwen на лету.
|
|
720
733
|
- Вложения в Qwen идут через пункт **Upload attachment** в меню режима; в
|
|
721
734
|
DeepSeek — своя скрепка.
|
|
722
735
|
- `/doctor` показывает активный провайдер и состояние UI его страницы.
|
|
@@ -872,9 +885,10 @@ zames --task "..." --output-format jsonl \
|
|
|
872
885
|
(по умолчанию zames-export-<stamp>.md).
|
|
873
886
|
- /doctor — диагностика node, git, конфига, браузера, буфера обмена, MCP и
|
|
874
887
|
активного провайдера чата (плюс состояние UI его страницы).
|
|
875
|
-
- /provider <deepseek|qwen> — сменить бэкенд чата (
|
|
876
|
-
/provider status показывает активный провайдер/модель/вход;
|
|
877
|
-
повторно проверяет вход. /model [id] покажет или выберет
|
|
888
|
+
- /provider <deepseek|qwen> — сменить бэкенд чата (применяется сразу, без
|
|
889
|
+
перезапуска); /provider status показывает активный провайдер/модель/вход;
|
|
890
|
+
/provider login повторно проверяет вход. /model [id] покажет или выберет
|
|
891
|
+
модель Qwen на лету.
|
|
878
892
|
- /add-dir <path> — проверить дополнительную директорию (песочница
|
|
879
893
|
фиксируется при старте; для записи туда перезапустите с --dir).
|
|
880
894
|
- /resume <n> (после /chats) и /resume-id <id> — открыть чат и НАПЕЧАТАТЬ его
|
package/dist/agent-loop.js
CHANGED
|
@@ -547,9 +547,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
547
547
|
(justRanTool
|
|
548
548
|
? 'You stopped after a tool call and wrote plain text. '
|
|
549
549
|
: 'You wrote plain text without a tool call. ') +
|
|
550
|
+
'Plain text is a PROTOCOL VIOLATION — the operator never sees it. ' +
|
|
550
551
|
'The task is not finished. Reply with EXACTLY one JSON tool-call ' +
|
|
551
552
|
'object, no text before or after, for example: ' +
|
|
552
553
|
'{\"tool\": \"Bash\", \"args\": {\"command\": \"...\"}}. ' +
|
|
554
|
+
'Do NOT explain, plan or narrate — call a tool. ' +
|
|
553
555
|
'If the task is really done — call respond with the final message.';
|
|
554
556
|
continue;
|
|
555
557
|
}
|
package/dist/i18n/common.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Common UI strings: app/common, status, tasks, messages, diff, context, undo, cd.
|
|
2
2
|
export const commonMessages = {
|
|
3
3
|
'app.tagline': {
|
|
4
|
-
ru: 'агент поверх chat.deepseek.com через Playwright',
|
|
5
|
-
en: 'coding agent over chat.deepseek.com via Playwright',
|
|
4
|
+
ru: 'агент поверх chat.deepseek.com / chat.qwen.ai через Playwright',
|
|
5
|
+
en: 'coding agent over chat.deepseek.com / chat.qwen.ai via Playwright',
|
|
6
6
|
},
|
|
7
7
|
'common.yes': { ru: 'да', en: 'yes' },
|
|
8
8
|
'common.no': { ru: 'нет', en: 'no' },
|
package/dist/i18n/qwen.js
CHANGED
|
@@ -26,10 +26,6 @@ export const qwenMessages = {
|
|
|
26
26
|
ru: 'Qwen: войти не удалось. Войдите вручную в окне браузера (запустите с --headed) и повторите.',
|
|
27
27
|
en: 'Qwen: sign-in failed. Sign in manually in the browser window (launch with --headed) and retry.',
|
|
28
28
|
},
|
|
29
|
-
'qwen.attach_unsupported': {
|
|
30
|
-
ru: 'Qwen: вложения пока не поддерживаются — файлы не отправлены.',
|
|
31
|
-
en: 'Qwen: attachments are not supported yet — files were not sent.',
|
|
32
|
-
},
|
|
33
29
|
'qwen.guest_limit': {
|
|
34
30
|
ru: 'Qwen: исчерпан суточный лимит гостя. Войдите в аккаунт (email / Google / GitHub) командой /provider login или запустите с --headed.',
|
|
35
31
|
en: 'Qwen: the guest daily limit is exhausted. Sign in (email / Google / GitHub) with /provider login, or launch with --headed.',
|
|
@@ -55,16 +51,24 @@ export const qwenMessages = {
|
|
|
55
51
|
en: 'Failed to fetch the Qwen model list: {v}',
|
|
56
52
|
},
|
|
57
53
|
'provider.switched': {
|
|
58
|
-
ru: 'Провайдер чата: {v}.
|
|
59
|
-
en: 'Chat provider: {v}.
|
|
54
|
+
ru: 'Провайдер чата: {v}.',
|
|
55
|
+
en: 'Chat provider: {v}.',
|
|
56
|
+
},
|
|
57
|
+
'provider.switching': {
|
|
58
|
+
ru: 'Переключаю провайдера на {v}: закрываю старый браузер и открываю {v}...',
|
|
59
|
+
en: 'Switching provider to {v}: closing the old browser and opening {v}...',
|
|
60
|
+
},
|
|
61
|
+
'provider.switch_failed': {
|
|
62
|
+
ru: '✖ Не удалось открыть браузер {v}: {error}. Остаюсь на прежнем провайдере; проверь вход и попробуй снова.',
|
|
63
|
+
en: '✖ Could not open the {v} browser: {error}. Staying on the previous provider; check the login and retry.',
|
|
60
64
|
},
|
|
61
65
|
'provider.usage': {
|
|
62
66
|
ru: 'Использование: /provider <deepseek|qwen> | /provider login | /provider status',
|
|
63
67
|
en: 'Usage: /provider <deepseek|qwen> | /provider login | /provider status',
|
|
64
68
|
},
|
|
65
69
|
'provider.handoff_saved': {
|
|
66
|
-
ru: 'Контекст текущего чата сжат и сохранён для {v}. Он подхватится
|
|
67
|
-
en: 'The current chat was summarized and saved for {v}. It will be carried over
|
|
70
|
+
ru: 'Контекст текущего чата сжат и сохранён для {v}. Он подхватится в следующей задаче.',
|
|
71
|
+
en: 'The current chat was summarized and saved for {v}. It will be carried over with the next task.',
|
|
68
72
|
},
|
|
69
73
|
'provider.handoff_used': {
|
|
70
74
|
ru: '📎 Подхватил контекст предыдущего провайдера (carried-over summary).',
|
|
@@ -82,4 +86,47 @@ export const qwenMessages = {
|
|
|
82
86
|
ru: 'Провайдер: {v}, модель: {m}, вход: {auth}',
|
|
83
87
|
en: 'Provider: {v}, model: {m}, login: {auth}',
|
|
84
88
|
},
|
|
89
|
+
// Qwen-specific statuses. The DeepSeek path had these under `ds.*`; using
|
|
90
|
+
// the SAME keys from QwenBrowser broke provider encapsulation (a Qwen notice
|
|
91
|
+
// was labelled with DeepSeek wording), so they live here instead.
|
|
92
|
+
'qwen.rate_limit_wait': {
|
|
93
|
+
ru: '⏳ {provider}: «слишком часто». Жду {min} мин ({attempt}/{max}) и повторю отправку...',
|
|
94
|
+
en: '⏳ {provider}: "too frequent". Waiting {min} min ({attempt}/{max}), then resending...',
|
|
95
|
+
},
|
|
96
|
+
'qwen.server_busy_wait': {
|
|
97
|
+
ru: '⏳ {provider}: сервер занят. Жду {sec}с ({attempt}/{max}) и повторю...',
|
|
98
|
+
en: '⏳ {provider}: server busy. Waiting {sec}s ({attempt}/{max}), then retrying...',
|
|
99
|
+
},
|
|
100
|
+
'qwen.incomplete_retry': {
|
|
101
|
+
ru: '⏳ {provider} оборвал ответ — переотправляю задачу ({attempt}/{max})...',
|
|
102
|
+
en: '⏳ {provider} truncated the answer — resending the task ({attempt}/{max})...',
|
|
103
|
+
},
|
|
104
|
+
'qwen.ask_retry': {
|
|
105
|
+
ru: '⚠ Отправка не удалась (попытка {attempt}/{max} на этот запрос): {error} — повторяю...',
|
|
106
|
+
en: '⚠ Send failed (attempt {attempt}/{max} for this request): {error} — retrying...',
|
|
107
|
+
},
|
|
108
|
+
'qwen.ask_failed': {
|
|
109
|
+
ru: '✖ Не удалось получить ответ от {provider} после {max} попыток: {error}',
|
|
110
|
+
en: '✖ Could not get an answer from {provider} after {max} attempts: {error}',
|
|
111
|
+
},
|
|
112
|
+
'qwen.attach_read_failed': {
|
|
113
|
+
ru: '⚠ не удалось прочитать вложение {name}: {error}',
|
|
114
|
+
en: '⚠ could not read the attachment {name}: {error}',
|
|
115
|
+
},
|
|
116
|
+
'qwen.attach_no_input': {
|
|
117
|
+
ru: '⚠ не найдено поле загрузки файлов — вложения не прикреплены',
|
|
118
|
+
en: '⚠ file upload field not found — the attachments were not attached',
|
|
119
|
+
},
|
|
120
|
+
'qwen.input_missing': {
|
|
121
|
+
ru: '✖ Не найдено поле ввода на странице {provider}. Запусти /debug-dom и поправь INPUT_SELECTORS.',
|
|
122
|
+
en: '✖ The {provider} input field was not found. Run /debug-dom and fix INPUT_SELECTORS.',
|
|
123
|
+
},
|
|
124
|
+
'qwen.send_no_new_answer': {
|
|
125
|
+
ru: '✖ Новый ответ не получен — на странице остался прежний текст. Возможно, сообщение не отправилось (кнопка отправки не нажалась). Проверь чат {provider} вручную.',
|
|
126
|
+
en: '✖ No new answer received — the page still shows the previous text. The message may not have been sent (the send button was not pressed). Check the {provider} chat manually.',
|
|
127
|
+
},
|
|
128
|
+
'qwen.send_button_missing': {
|
|
129
|
+
ru: '✖ Кнопка отправки Qwen не найдена или недоступна — сообщение не отправлено. Проверь чат вручную.',
|
|
130
|
+
en: '✖ The Qwen send button was not found or is disabled — the message was not sent. Check the chat manually.',
|
|
131
|
+
},
|
|
85
132
|
};
|
package/dist/index.js
CHANGED
|
@@ -613,6 +613,46 @@ function providerAnswerTimeoutMs() {
|
|
|
613
613
|
function browserProviderUrl() {
|
|
614
614
|
return providerChatUrlPrefix(config.provider);
|
|
615
615
|
}
|
|
616
|
+
/**
|
|
617
|
+
* Build the browser for a provider. Called at startup AND on a live
|
|
618
|
+
* `/provider` switch, so both paths construct the class the same way. The
|
|
619
|
+
* two backends differ in every layer (selectors, SSE format, chat-id scheme),
|
|
620
|
+
* so the choice is a class, not a flag; the shared timings live in
|
|
621
|
+
* config.browser and only Qwen-only knobs are overridden.
|
|
622
|
+
*/
|
|
623
|
+
function createProviderBrowser(provider) {
|
|
624
|
+
const persistAuth = (username, password) => {
|
|
625
|
+
config.browser.auth.username = username;
|
|
626
|
+
config.browser.auth.password = password;
|
|
627
|
+
try {
|
|
628
|
+
writeConfigValue('home', 'browser.auth.username', username);
|
|
629
|
+
writeConfigValue('home', 'browser.auth.password', password);
|
|
630
|
+
}
|
|
631
|
+
catch { }
|
|
632
|
+
};
|
|
633
|
+
return provider === 'qwen'
|
|
634
|
+
? new QwenBrowser({
|
|
635
|
+
headless,
|
|
636
|
+
debug,
|
|
637
|
+
channel: config.browserChannel,
|
|
638
|
+
...config.browser,
|
|
639
|
+
// Qwen-specific overrides (see config.qwen): no 15s DeepSeek
|
|
640
|
+
// throttle, a longer cold-start answer timeout.
|
|
641
|
+
minSendIntervalMs: config.qwen.minSendIntervalMs,
|
|
642
|
+
answerTimeoutMs: config.qwen.answerTimeoutMs,
|
|
643
|
+
model: config.qwen.model,
|
|
644
|
+
locale: currentLocale,
|
|
645
|
+
onAuthSave: persistAuth,
|
|
646
|
+
})
|
|
647
|
+
: new DeepSeekBrowser({
|
|
648
|
+
headless,
|
|
649
|
+
debug,
|
|
650
|
+
channel: config.browserChannel,
|
|
651
|
+
...config.browser,
|
|
652
|
+
locale: currentLocale,
|
|
653
|
+
onAuthSave: persistAuth,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
616
656
|
/**
|
|
617
657
|
* Print the dialogue of the just-opened chat into the terminal. Called by
|
|
618
658
|
* /resume, /resume-id and on startup when a previous session is restored, so
|
|
@@ -784,47 +824,10 @@ async function main() {
|
|
|
784
824
|
// class choice, not a flag. The shared timings live in config.browser; only
|
|
785
825
|
// the model is provider-specific. A provider switch takes effect on the
|
|
786
826
|
// next launch (the operator is told to restart).
|
|
787
|
-
const provider
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
debug,
|
|
792
|
-
channel: config.browserChannel,
|
|
793
|
-
...config.browser,
|
|
794
|
-
// Qwen-specific overrides (see config.qwen): no 15s DeepSeek
|
|
795
|
-
// throttle, a longer cold-start answer timeout.
|
|
796
|
-
minSendIntervalMs: config.qwen.minSendIntervalMs,
|
|
797
|
-
answerTimeoutMs: config.qwen.answerTimeoutMs,
|
|
798
|
-
model: config.qwen.model,
|
|
799
|
-
locale: currentLocale,
|
|
800
|
-
onAuthSave: (username, password) => {
|
|
801
|
-
config.browser.auth.username = username;
|
|
802
|
-
config.browser.auth.password = password;
|
|
803
|
-
try {
|
|
804
|
-
writeConfigValue('home', 'browser.auth.username', username);
|
|
805
|
-
writeConfigValue('home', 'browser.auth.password', password);
|
|
806
|
-
}
|
|
807
|
-
catch { }
|
|
808
|
-
},
|
|
809
|
-
})
|
|
810
|
-
: new DeepSeekBrowser({
|
|
811
|
-
headless,
|
|
812
|
-
debug,
|
|
813
|
-
channel: config.browserChannel,
|
|
814
|
-
...config.browser,
|
|
815
|
-
locale: currentLocale,
|
|
816
|
-
// Persist credentials the operator typed in the terminal so the next
|
|
817
|
-
// launch can re-login silently after a logout.
|
|
818
|
-
onAuthSave: (username, password) => {
|
|
819
|
-
config.browser.auth.username = username;
|
|
820
|
-
config.browser.auth.password = password;
|
|
821
|
-
try {
|
|
822
|
-
writeConfigValue('home', 'browser.auth.username', username);
|
|
823
|
-
writeConfigValue('home', 'browser.auth.password', password);
|
|
824
|
-
}
|
|
825
|
-
catch { }
|
|
826
|
-
},
|
|
827
|
-
});
|
|
827
|
+
// `let`, not `const`: a live /provider switch disposes this instance and
|
|
828
|
+
// builds the other one IN PLACE (see the /provider block in the main loop),
|
|
829
|
+
// so the URL changes without restarting zames.
|
|
830
|
+
let browser = createProviderBrowser(asProviderId(config.provider));
|
|
828
831
|
const bootSpinner = mod.createSpinner(currentLocale);
|
|
829
832
|
bootSpinner.thinking();
|
|
830
833
|
try {
|
|
@@ -2062,69 +2065,135 @@ async function main() {
|
|
|
2062
2065
|
console.log(theme.system(t('provider.switched', { v: arg })));
|
|
2063
2066
|
continue;
|
|
2064
2067
|
}
|
|
2065
|
-
//
|
|
2066
|
-
//
|
|
2067
|
-
//
|
|
2068
|
-
//
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
const
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
agent: true,
|
|
2078
|
-
timeout: providerAnswerTimeoutMs(),
|
|
2079
|
-
});
|
|
2080
|
-
const trimmed = String(answer || '').trim();
|
|
2081
|
-
if (mod.commands.isUsableCompactSummary(trimmed))
|
|
2082
|
-
summary = trimmed;
|
|
2068
|
+
// Is there anything worth carrying over? A FRESH/EMPTY chat has no
|
|
2069
|
+
// context to summarize, so switching it is a pure URL/backend change:
|
|
2070
|
+
// summarizing an empty chat used to post a useless "please compact"
|
|
2071
|
+
// request into a brand-new chat (a real complaint). We only build a
|
|
2072
|
+
// handoff when the chat actually has messages.
|
|
2073
|
+
let chatHasMessages = false;
|
|
2074
|
+
if (browser.fetchChatMessages) {
|
|
2075
|
+
const cid = await browser.getCurrentChatId().catch(() => null);
|
|
2076
|
+
if (cid) {
|
|
2077
|
+
const all = await browser.fetchChatMessages(cid).catch(() => []);
|
|
2078
|
+
chatHasMessages =
|
|
2079
|
+
mod.commands.trimRestoredMessages(all, 0).length > 0;
|
|
2083
2080
|
}
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
//
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2081
|
+
}
|
|
2082
|
+
if (chatHasMessages) {
|
|
2083
|
+
// CARRY-OVER: a chat id only exists on its own backend, so switching
|
|
2084
|
+
// providers loses the conversation. Summarize the CURRENT chat (still
|
|
2085
|
+
// on the OLD browser object) and persist a handoff for the target; it
|
|
2086
|
+
// is posted as the first message once the new provider is live.
|
|
2087
|
+
if (editor)
|
|
2088
|
+
editor.lock(t('msg.input_locked'));
|
|
2089
|
+
try {
|
|
2090
|
+
const spin = editor || mod.createSpinner(currentLocale);
|
|
2091
|
+
spin.thinking();
|
|
2092
|
+
let summary = '';
|
|
2093
|
+
try {
|
|
2094
|
+
const answer = await browser.ask(mod.commands.buildCompactPrompt(currentLocale), {
|
|
2095
|
+
agent: true,
|
|
2096
|
+
timeout: providerAnswerTimeoutMs(),
|
|
2097
|
+
});
|
|
2098
|
+
const trimmed = String(answer || '').trim();
|
|
2099
|
+
if (mod.commands.isUsableCompactSummary(trimmed))
|
|
2100
|
+
summary = trimmed;
|
|
2101
|
+
}
|
|
2102
|
+
catch (e) {
|
|
2103
|
+
if (debug)
|
|
2104
|
+
console.error('handoff summary failed:', e.message);
|
|
2105
|
+
}
|
|
2106
|
+
// Fall back to the LOCAL history when the model summary is unusable.
|
|
2107
|
+
if (!summary && browser.fetchChatMessages) {
|
|
2108
|
+
const cid = await browser.getCurrentChatId().catch(() => null);
|
|
2109
|
+
const all = cid
|
|
2110
|
+
? await browser.fetchChatMessages(cid).catch(() => [])
|
|
2111
|
+
: [];
|
|
2112
|
+
const msgs = mod.commands.trimRestoredMessages(all, 0);
|
|
2113
|
+
if (msgs.length) {
|
|
2114
|
+
summary = mod.commands.formatRestoredHistory(msgs, {
|
|
2115
|
+
limit: 40,
|
|
2116
|
+
});
|
|
2117
|
+
}
|
|
2118
|
+
}
|
|
2119
|
+
spin.stop();
|
|
2120
|
+
if (summary) {
|
|
2121
|
+
saveProviderHandoff({
|
|
2122
|
+
provider: arg,
|
|
2123
|
+
workdir: currentWorkdir,
|
|
2124
|
+
summary,
|
|
2125
|
+
task: lastTaskText || undefined,
|
|
2126
|
+
createdAt: new Date().toISOString(),
|
|
2127
|
+
});
|
|
2128
|
+
console.log(theme.system(t('provider.handoff_saved', { v: arg })));
|
|
2129
|
+
}
|
|
2130
|
+
else {
|
|
2131
|
+
console.log(theme.warn(t('provider.handoff_empty')));
|
|
2097
2132
|
}
|
|
2098
2133
|
}
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
provider: arg,
|
|
2103
|
-
workdir: currentWorkdir,
|
|
2104
|
-
summary,
|
|
2105
|
-
task: lastTaskText || undefined,
|
|
2106
|
-
createdAt: new Date().toISOString(),
|
|
2107
|
-
});
|
|
2108
|
-
console.log(theme.system(t('provider.handoff_saved', { v: arg })));
|
|
2109
|
-
}
|
|
2110
|
-
else {
|
|
2111
|
-
console.log(theme.warn(t('provider.handoff_empty')));
|
|
2134
|
+
finally {
|
|
2135
|
+
if (editor)
|
|
2136
|
+
editor.unlock();
|
|
2112
2137
|
}
|
|
2113
2138
|
}
|
|
2114
|
-
finally {
|
|
2115
|
-
if (editor)
|
|
2116
|
-
editor.unlock();
|
|
2117
|
-
}
|
|
2118
2139
|
// Tag the current session with the OLD provider before switching.
|
|
2119
2140
|
if (currentChatId)
|
|
2120
2141
|
saveLastChat(currentChatId, currentWorkdir);
|
|
2142
|
+
// Persist the choice FIRST so a crash mid-switch still remembers it.
|
|
2121
2143
|
config.provider = arg;
|
|
2122
2144
|
config.qwen.enabled = arg === 'qwen';
|
|
2123
2145
|
try {
|
|
2124
2146
|
writeConfigValue('home', 'provider', arg);
|
|
2125
2147
|
}
|
|
2126
2148
|
catch { }
|
|
2127
|
-
|
|
2149
|
+
// LIVE SWITCH: dispose the old backend and open the new one in place,
|
|
2150
|
+
// so the operator does not have to restart zames. The chat is NOT
|
|
2151
|
+
// resumable across providers (different chat ids), so the new backend
|
|
2152
|
+
// starts a FRESH chat and the handoff (if any) is posted with the next
|
|
2153
|
+
// task.
|
|
2154
|
+
if (editor)
|
|
2155
|
+
editor.lock(t('msg.input_locked'));
|
|
2156
|
+
try {
|
|
2157
|
+
console.log(theme.system(t('provider.switching', { v: arg })));
|
|
2158
|
+
// Launch the NEW backend FIRST; only close the old one once it is up.
|
|
2159
|
+
// If we closed the old browser before the new one launched and the
|
|
2160
|
+
// launch failed, `browser` would point at a dead instance and every
|
|
2161
|
+
// later command would break.
|
|
2162
|
+
const nextBrowser = createProviderBrowser(arg);
|
|
2163
|
+
await nextBrowser.launch();
|
|
2164
|
+
await nextBrowser.waitForLogin();
|
|
2165
|
+
const oldBrowser = browser;
|
|
2166
|
+
browser = nextBrowser;
|
|
2167
|
+
await oldBrowser.close().catch(() => { });
|
|
2168
|
+
// The new backend has no chat yet.
|
|
2169
|
+
currentChatId = await browser.getCurrentChatId().catch(() => null);
|
|
2170
|
+
if (editor) {
|
|
2171
|
+
editor.setProvider(providerLabel(arg));
|
|
2172
|
+
}
|
|
2173
|
+
try {
|
|
2174
|
+
warnUiHealth(await browser.probeUiHealth());
|
|
2175
|
+
}
|
|
2176
|
+
catch { }
|
|
2177
|
+
console.log(theme.system(t('provider.switched', { v: arg })));
|
|
2178
|
+
}
|
|
2179
|
+
catch (e) {
|
|
2180
|
+
// The new backend did not come up: keep the OLD one active so the
|
|
2181
|
+
// session keeps working, and roll the persisted choice back.
|
|
2182
|
+
config.provider = from;
|
|
2183
|
+
config.qwen.enabled = from === 'qwen';
|
|
2184
|
+
try {
|
|
2185
|
+
writeConfigValue('home', 'provider', from);
|
|
2186
|
+
}
|
|
2187
|
+
catch { }
|
|
2188
|
+
console.error(theme.warn(t('provider.switch_failed', {
|
|
2189
|
+
v: arg,
|
|
2190
|
+
error: e.message,
|
|
2191
|
+
})));
|
|
2192
|
+
}
|
|
2193
|
+
finally {
|
|
2194
|
+
if (editor)
|
|
2195
|
+
editor.unlock();
|
|
2196
|
+
}
|
|
2128
2197
|
continue;
|
|
2129
2198
|
}
|
|
2130
2199
|
console.error(theme.warn(t('provider.usage')));
|
package/dist/providers.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 1. add an entry here (id, label, chat URL prefix, extra config section);
|
|
4
4
|
// 2. add the provider's `<name>-ui.ts` + `<name>-net-capture.ts` +
|
|
5
5
|
// `<name>-browser.ts` (implementing ChatProvider, see types.ts);
|
|
6
|
-
// 3. register the class in `
|
|
6
|
+
// 3. register the class in `createProviderBrowser()` in index.ts.
|
|
7
7
|
// Everything else (config enum, /provider validation, sessions tagging, the
|
|
8
8
|
// /doctor row, the {provider} label in operator messages, /chat URL) reads
|
|
9
9
|
// from HERE, so nothing else has to be touched.
|
package/dist/qwen-browser.js
CHANGED
|
@@ -27,7 +27,7 @@ import { cleanAnswer, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadle
|
|
|
27
27
|
import { chatSignalInDom, dumpDomCountsInDom, isElementEnabledInDom, readInputTrimmedTextInDom, readLastAnswerInDom, readPageToastsInDom, readUserAgentInDom, setBusyTitleInDom, clickStopInDom, stopButtonVisibleInDom, } from './browser-dom.js';
|
|
28
28
|
import { probeUiHealth, } from './ui-health.js';
|
|
29
29
|
import { CHAT_URL, CHAT_ID_RE, chatUrl, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, NEW_CHAT_SELECTOR, MODEL_TRIGGER_SELECTOR, MODELS_URL, CHAT_LIST_URL, chatHistoryUrl, AGE_GATE_MODAL, AGE_GATE_CONTINUE, COOKIE_ACCEPT_RE, ATTACH_SELECTORS, ATTACH_MENU_TRIGGER, ATTACH_MENU_ITEM_RE, THINKING_SELECTOR, THINKING_MODE_RE, FAST_MODE_RE, WEB_SEARCH_MENU_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, ASSISTANT_CLASS_RE, CHAT_SIGNAL_NODES_SELECTOR, MARKDOWN_SELECTOR, TOGGLE_SELECTORS, } from './qwen-ui.js';
|
|
30
|
-
import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isGuestLimitText, } from './qwen-net-capture.js';
|
|
30
|
+
import { extractAnswer, extractTokenUsage, isFinishedWithoutAnswer, isRateLimitText, isGuestLimitText, isServerBusyText, } from './qwen-net-capture.js';
|
|
31
31
|
// A SEPARATE profile from DeepSeek's ~/.zames/profile. Two persistent Chromium
|
|
32
32
|
// contexts on ONE --user-data-dir cannot coexist (SingletonLock), and keeping
|
|
33
33
|
// Qwen's cookies apart also means switching providers never logs the other
|
|
@@ -56,6 +56,8 @@ export class QwenBrowser {
|
|
|
56
56
|
serverBusyWaitMs;
|
|
57
57
|
maxIncompleteRetries;
|
|
58
58
|
incompleteWaitMs;
|
|
59
|
+
startWaitMs;
|
|
60
|
+
sendButtonWaitMs;
|
|
59
61
|
// The Qwen model id sent with each completion request. Empty means "let the
|
|
60
62
|
// site pick" (its own default). Read from config, overridable with /model.
|
|
61
63
|
model;
|
|
@@ -82,6 +84,11 @@ export class QwenBrowser {
|
|
|
82
84
|
// limit for today's usage"). Distinct from _netRateLimited: it cannot be
|
|
83
85
|
// waited out, so ask() fails fast with a "log in" message.
|
|
84
86
|
_netGuestLimit;
|
|
87
|
+
// True when the completion body carries the server-busy / service-unavailable
|
|
88
|
+
// notice. Mirrors _netRateLimited: the DOM toast is short-lived, so the RAW
|
|
89
|
+
// body is the reliable signal. ask() then backs off instead of waiting out
|
|
90
|
+
// the whole answer deadline.
|
|
91
|
+
_netServerBusy;
|
|
85
92
|
_netChatId;
|
|
86
93
|
_lastTokenUsage;
|
|
87
94
|
_toggles;
|
|
@@ -101,7 +108,7 @@ export class QwenBrowser {
|
|
|
101
108
|
onSendState;
|
|
102
109
|
onNotice;
|
|
103
110
|
userAgent;
|
|
104
|
-
constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
|
|
111
|
+
constructor({ headless = false, debug = false, channel = null, answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, maxIncompleteRetries = 4, incompleteWaitMs = 2000, startWaitMs = 30000, sendButtonWaitMs = 8000, model = '', deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
|
|
105
112
|
this.headless = headless;
|
|
106
113
|
this.debug = debug;
|
|
107
114
|
this.channel = channel;
|
|
@@ -116,6 +123,8 @@ export class QwenBrowser {
|
|
|
116
123
|
this.serverBusyWaitMs = serverBusyWaitMs;
|
|
117
124
|
this.maxIncompleteRetries = maxIncompleteRetries;
|
|
118
125
|
this.incompleteWaitMs = incompleteWaitMs;
|
|
126
|
+
this.startWaitMs = startWaitMs;
|
|
127
|
+
this.sendButtonWaitMs = sendButtonWaitMs;
|
|
119
128
|
this.model = model;
|
|
120
129
|
this.deepThinking = deepThinking;
|
|
121
130
|
this.webSearch = webSearch;
|
|
@@ -138,6 +147,7 @@ export class QwenBrowser {
|
|
|
138
147
|
this._netNoAnswer = false;
|
|
139
148
|
this._netRateLimited = false;
|
|
140
149
|
this._netGuestLimit = false;
|
|
150
|
+
this._netServerBusy = false;
|
|
141
151
|
this._netChatId = null;
|
|
142
152
|
this._lastTokenUsage = null;
|
|
143
153
|
this._toggles = { deepThinking, webSearch };
|
|
@@ -380,6 +390,12 @@ export class QwenBrowser {
|
|
|
380
390
|
this._netRateLimited = true;
|
|
381
391
|
this._netCapture = '';
|
|
382
392
|
}
|
|
393
|
+
// The server-busy notice can arrive with a non-empty (partial) answer
|
|
394
|
+
// too, so it is checked OUTSIDE the `!extracted` branch as well — the
|
|
395
|
+
// point is to back off, not to discard a usable answer.
|
|
396
|
+
if (isServerBusyText(body)) {
|
|
397
|
+
this._netServerBusy = true;
|
|
398
|
+
}
|
|
383
399
|
this._netCaptureAt = Date.now();
|
|
384
400
|
}
|
|
385
401
|
}
|
|
@@ -830,6 +846,10 @@ export class QwenBrowser {
|
|
|
830
846
|
}, { url: MODELS_URL, auth: this._apiAuth });
|
|
831
847
|
if (res?.error) {
|
|
832
848
|
this._lastHistoryError = res.error;
|
|
849
|
+
// Surface the failure: an empty list otherwise looks identical to
|
|
850
|
+
// "the catalogue is empty", so /model would print "(none)" with no
|
|
851
|
+
// hint that the API call failed.
|
|
852
|
+
this._notice(this._t('qwen.model_error', { v: res.error }));
|
|
833
853
|
return [];
|
|
834
854
|
}
|
|
835
855
|
const data = res?.data || {};
|
|
@@ -861,6 +881,7 @@ export class QwenBrowser {
|
|
|
861
881
|
}
|
|
862
882
|
catch (e) {
|
|
863
883
|
this._lastHistoryError = e.message;
|
|
884
|
+
this._notice(this._t('qwen.model_error', { v: e.message }));
|
|
864
885
|
return [];
|
|
865
886
|
}
|
|
866
887
|
}
|
|
@@ -1038,6 +1059,13 @@ export class QwenBrowser {
|
|
|
1038
1059
|
// Pick Auto/Thinking/Fast in the `.qwen-thinking-selector` dropdown. The
|
|
1039
1060
|
// option labels are localized, so they are matched by a loose regex.
|
|
1040
1061
|
async _applyThinkingMode() {
|
|
1062
|
+
// Only touch the mode dropdown when the desired state changed since the
|
|
1063
|
+
// last apply (same rationale as _applyWebSearch): opening it on every send
|
|
1064
|
+
// is slow and can steal focus from the send button. A manual flip by the
|
|
1065
|
+
// operator is still honored because we re-read the current label below.
|
|
1066
|
+
const wantMode = this.deepThinking ? 1 : 0;
|
|
1067
|
+
if (this._appliedThinking === wantMode)
|
|
1068
|
+
return;
|
|
1041
1069
|
try {
|
|
1042
1070
|
const trigger = this.page.locator(THINKING_SELECTOR).first();
|
|
1043
1071
|
if ((await trigger.count().catch(() => 0)) === 0)
|
|
@@ -1299,7 +1327,7 @@ export class QwenBrowser {
|
|
|
1299
1327
|
if (rateLimitRetries >= this.maxRateLimitRetries)
|
|
1300
1328
|
break;
|
|
1301
1329
|
rateLimitRetries++;
|
|
1302
|
-
this._notice(this._t('
|
|
1330
|
+
this._notice(this._t('qwen.rate_limit_wait', {
|
|
1303
1331
|
min: Math.ceil(this.rateLimitWaitMs / 60000),
|
|
1304
1332
|
attempt: rateLimitRetries,
|
|
1305
1333
|
max: this.maxRateLimitRetries,
|
|
@@ -1307,10 +1335,15 @@ export class QwenBrowser {
|
|
|
1307
1335
|
await this._sleepInterruptible(this.rateLimitWaitMs);
|
|
1308
1336
|
continue;
|
|
1309
1337
|
}
|
|
1310
|
-
if (/server busy/i.test(msg)) {
|
|
1338
|
+
if (e instanceof QwenServerBusyError || /server busy/i.test(msg)) {
|
|
1311
1339
|
if (serverBusyRetries >= this.maxServerBusyRetries)
|
|
1312
1340
|
break;
|
|
1313
1341
|
serverBusyRetries++;
|
|
1342
|
+
this._notice(this._t('qwen.server_busy_wait', {
|
|
1343
|
+
sec: Math.ceil(this.serverBusyWaitMs / 1000),
|
|
1344
|
+
attempt: serverBusyRetries,
|
|
1345
|
+
max: this.maxServerBusyRetries,
|
|
1346
|
+
}));
|
|
1314
1347
|
await this._sleepInterruptible(this.serverBusyWaitMs);
|
|
1315
1348
|
continue;
|
|
1316
1349
|
}
|
|
@@ -1318,7 +1351,7 @@ export class QwenBrowser {
|
|
|
1318
1351
|
if (incompleteRetries >= this.maxIncompleteRetries)
|
|
1319
1352
|
break;
|
|
1320
1353
|
incompleteRetries++;
|
|
1321
|
-
this._notice(theme.warn(this._t('
|
|
1354
|
+
this._notice(theme.warn(this._t('qwen.incomplete_retry', {
|
|
1322
1355
|
attempt: incompleteRetries,
|
|
1323
1356
|
max: this.maxIncompleteRetries,
|
|
1324
1357
|
})));
|
|
@@ -1342,7 +1375,7 @@ export class QwenBrowser {
|
|
|
1342
1375
|
})));
|
|
1343
1376
|
}
|
|
1344
1377
|
}
|
|
1345
|
-
this._notice(theme.warn(this._t('
|
|
1378
|
+
this._notice(theme.warn(this._t('qwen.ask_retry', {
|
|
1346
1379
|
attempt: attempt + 1,
|
|
1347
1380
|
max: this.askRetries,
|
|
1348
1381
|
error: msg,
|
|
@@ -1353,7 +1386,7 @@ export class QwenBrowser {
|
|
|
1353
1386
|
attempt++;
|
|
1354
1387
|
}
|
|
1355
1388
|
}
|
|
1356
|
-
throw new Error(this._t('
|
|
1389
|
+
throw new Error(this._t('qwen.ask_failed', {
|
|
1357
1390
|
max: this.askRetries,
|
|
1358
1391
|
error: lastErr?.message || '',
|
|
1359
1392
|
}));
|
|
@@ -1376,7 +1409,7 @@ export class QwenBrowser {
|
|
|
1376
1409
|
}
|
|
1377
1410
|
catch (e) {
|
|
1378
1411
|
failed.push({ path: f.path, name: f.name });
|
|
1379
|
-
this._notice(theme.warn(this._t('
|
|
1412
|
+
this._notice(theme.warn(this._t('qwen.attach_read_failed', {
|
|
1380
1413
|
name: f.name,
|
|
1381
1414
|
error: e.message,
|
|
1382
1415
|
})));
|
|
@@ -1421,7 +1454,7 @@ export class QwenBrowser {
|
|
|
1421
1454
|
}
|
|
1422
1455
|
catch { }
|
|
1423
1456
|
}
|
|
1424
|
-
this._notice(this._t('
|
|
1457
|
+
this._notice(this._t('qwen.attach_no_input'));
|
|
1425
1458
|
for (const f of files)
|
|
1426
1459
|
failed.push({ path: f.path, name: f.name });
|
|
1427
1460
|
return failed;
|
|
@@ -1498,7 +1531,7 @@ export class QwenBrowser {
|
|
|
1498
1531
|
catch { }
|
|
1499
1532
|
}
|
|
1500
1533
|
if (!input)
|
|
1501
|
-
throw new Error(this._t('
|
|
1534
|
+
throw new Error(this._t('qwen.input_missing'));
|
|
1502
1535
|
const beforeText = await this._readLastAnswerTextCleanDom().catch(() => '');
|
|
1503
1536
|
await this._waitForSendSlot(agent);
|
|
1504
1537
|
if (this._abort)
|
|
@@ -1516,6 +1549,7 @@ export class QwenBrowser {
|
|
|
1516
1549
|
this._netNoAnswer = false;
|
|
1517
1550
|
this._netRateLimited = false;
|
|
1518
1551
|
this._netGuestLimit = false;
|
|
1552
|
+
this._netServerBusy = false;
|
|
1519
1553
|
this._netBody = '';
|
|
1520
1554
|
await this._applyToggles();
|
|
1521
1555
|
if (attachments.length) {
|
|
@@ -1523,13 +1557,26 @@ export class QwenBrowser {
|
|
|
1523
1557
|
prompt = rewriteFailedAttachments(prompt, failed);
|
|
1524
1558
|
}
|
|
1525
1559
|
await this._setInputText(input, prompt);
|
|
1526
|
-
// Send
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
//
|
|
1530
|
-
|
|
1560
|
+
// Send. The input may hold the text WITHOUT the message having been sent
|
|
1561
|
+
// (a real complaint: "text is in the Qwen chat but the send button was not
|
|
1562
|
+
// pressed"). So we do not merely click once — we click AND verify, retrying
|
|
1563
|
+
// through a deadline until the input actually clears (Qwen clears the
|
|
1564
|
+
// textarea the moment it accepts the message). Verification is the ONLY
|
|
1565
|
+
// reliable signal: a disabled button, a menu that stole focus, or a click
|
|
1566
|
+
// that landed on an overlay all leave the text in place.
|
|
1567
|
+
//
|
|
1568
|
+
// We try, in order each round: (1) a click on a VISIBLE, ENABLED send
|
|
1569
|
+
// button, (2) Enter as a fallback. With attachments the upload can keep the
|
|
1570
|
+
// button disabled for a while, so the window is longer in that case.
|
|
1571
|
+
const sendDeadline = Date.now() + (attachments.length ? 30_000 : this.sendButtonWaitMs);
|
|
1572
|
+
const preSendSignal = await this._chatSignal();
|
|
1531
1573
|
let sent = false;
|
|
1532
1574
|
while (!sent && Date.now() < sendDeadline) {
|
|
1575
|
+
// (1) the send button, if present/enabled/visible. We remember whether a
|
|
1576
|
+
// click was actually issued: pressing Enter in the SAME round could send
|
|
1577
|
+
// the message a SECOND time if the click already worked but the input
|
|
1578
|
+
// had not cleared yet.
|
|
1579
|
+
let clicked = false;
|
|
1533
1580
|
for (const sel of SEND_SELECTORS) {
|
|
1534
1581
|
const btn = this.page.locator(sel).last();
|
|
1535
1582
|
try {
|
|
@@ -1543,32 +1590,48 @@ export class QwenBrowser {
|
|
|
1543
1590
|
if (!enabled)
|
|
1544
1591
|
continue;
|
|
1545
1592
|
await btn.click({ timeout: 1500 });
|
|
1546
|
-
|
|
1593
|
+
clicked = true;
|
|
1547
1594
|
break;
|
|
1548
1595
|
}
|
|
1549
1596
|
catch { }
|
|
1550
1597
|
}
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1598
|
+
// (2) Enter fallback — ONLY when no send button was clicked this round
|
|
1599
|
+
// (a build that sends solely on Enter, or a missing button). This is
|
|
1600
|
+
// what avoids a double send.
|
|
1601
|
+
if (!clicked) {
|
|
1602
|
+
await this.page.keyboard.press('Enter').catch(() => { });
|
|
1603
|
+
}
|
|
1604
|
+
// VERIFY: the input must clear. Give the app a moment to react. A send
|
|
1605
|
+
// ALSO counts as successful when the page started generating (the chat
|
|
1606
|
+
// signal grew / a Stop button appeared) even if the textarea did not
|
|
1607
|
+
// clear immediately — some builds keep the last message in the box.
|
|
1560
1608
|
await this.page.waitForTimeout(300);
|
|
1561
1609
|
const stillThere = await input
|
|
1562
1610
|
.evaluate(readInputTrimmedTextInDom)
|
|
1563
1611
|
.catch(() => '');
|
|
1564
|
-
if (stillThere) {
|
|
1565
|
-
|
|
1566
|
-
|
|
1612
|
+
if (!stillThere) {
|
|
1613
|
+
sent = true;
|
|
1614
|
+
break;
|
|
1567
1615
|
}
|
|
1616
|
+
const sig = await this._chatSignal().catch(() => preSendSignal);
|
|
1617
|
+
const stopVisible = await this._stopButtonVisible().catch(() => false);
|
|
1618
|
+
if (stopVisible ||
|
|
1619
|
+
sig.nodes > preSendSignal.nodes ||
|
|
1620
|
+
sig.lastLen > preSendSignal.lastLen) {
|
|
1621
|
+
sent = true;
|
|
1622
|
+
break;
|
|
1623
|
+
}
|
|
1624
|
+
await this.page.waitForTimeout(150);
|
|
1625
|
+
}
|
|
1626
|
+
this._lastSentAt = Date.now();
|
|
1627
|
+
if (!sent) {
|
|
1628
|
+
// The text is still in the input after the whole deadline: the message
|
|
1629
|
+
// was NOT sent. Throw so ask() retries the send instead of waiting out
|
|
1630
|
+
// the full answer timeout for a turn that never started.
|
|
1631
|
+
throw new Error(this._t('qwen.send_button_missing'));
|
|
1568
1632
|
}
|
|
1569
|
-
catch { }
|
|
1570
1633
|
// Wait for the answer to start, then for it to settle.
|
|
1571
|
-
const startDeadline = Date.now() +
|
|
1634
|
+
const startDeadline = Date.now() + this.startWaitMs;
|
|
1572
1635
|
const startSignal = await this._chatSignal();
|
|
1573
1636
|
let started = false;
|
|
1574
1637
|
while (Date.now() < startDeadline) {
|
|
@@ -1584,6 +1647,9 @@ export class QwenBrowser {
|
|
|
1584
1647
|
if (this._netRateLimited && this._netCaptureAt >= this._lastSentAt) {
|
|
1585
1648
|
throw new QwenRateLimitError(this._netBody.slice(0, 300));
|
|
1586
1649
|
}
|
|
1650
|
+
if (this._netServerBusy && this._netCaptureAt >= this._lastSentAt) {
|
|
1651
|
+
throw new QwenServerBusyError(this._netBody.slice(0, 300));
|
|
1652
|
+
}
|
|
1587
1653
|
const sig = await this._chatSignal();
|
|
1588
1654
|
if (sig.nodes > startSignal.nodes || sig.lastLen > startSignal.lastLen) {
|
|
1589
1655
|
started = true;
|
|
@@ -1615,6 +1681,9 @@ export class QwenBrowser {
|
|
|
1615
1681
|
if (this._netRateLimited && this._netCaptureAt >= this._lastSentAt) {
|
|
1616
1682
|
throw new QwenRateLimitError(this._netBody.slice(0, 300));
|
|
1617
1683
|
}
|
|
1684
|
+
if (this._netServerBusy && this._netCaptureAt >= this._lastSentAt) {
|
|
1685
|
+
throw new QwenServerBusyError(this._netBody.slice(0, 300));
|
|
1686
|
+
}
|
|
1618
1687
|
if (this._netNoAnswer && this._netCaptureAt >= this._lastSentAt) {
|
|
1619
1688
|
throw new QwenIncompleteError('finished without answer');
|
|
1620
1689
|
}
|
|
@@ -1642,7 +1711,7 @@ export class QwenBrowser {
|
|
|
1642
1711
|
const answer = this._netCapture || last;
|
|
1643
1712
|
if (answer && answer !== beforeText)
|
|
1644
1713
|
return answer;
|
|
1645
|
-
throw new Error(this._t('
|
|
1714
|
+
throw new Error(this._t('qwen.send_no_new_answer'));
|
|
1646
1715
|
}
|
|
1647
1716
|
}
|
|
1648
1717
|
// Qwen-specific error classes so ask() can branch on the retryable families.
|
|
@@ -1666,3 +1735,9 @@ class QwenIncompleteError extends Error {
|
|
|
1666
1735
|
this.name = 'QwenIncompleteError';
|
|
1667
1736
|
}
|
|
1668
1737
|
}
|
|
1738
|
+
class QwenServerBusyError extends Error {
|
|
1739
|
+
constructor(detail) {
|
|
1740
|
+
super('server busy: ' + detail);
|
|
1741
|
+
this.name = 'QwenServerBusyError';
|
|
1742
|
+
}
|
|
1743
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zames_pro",
|
|
3
|
-
"version": "2.73.
|
|
3
|
+
"version": "2.73.2",
|
|
4
4
|
"description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen and other web LLMs — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -33,7 +33,10 @@
|
|
|
33
33
|
"test": "tsx --test test/*.test.ts",
|
|
34
34
|
"test:watch": "tsx --test --watch test/*.test.ts",
|
|
35
35
|
"self-smoke": "tsx scripts/self-smoke.mts",
|
|
36
|
+
"qwen-smoke": "tsx scripts/qwen-smoke.mts",
|
|
36
37
|
"render-demo": "tsx scripts/render-demo.mts",
|
|
38
|
+
"render-how": "tsx scripts/render-how-it-works.mts",
|
|
39
|
+
"render-how": "tsx scripts/render-how-it-works.mts",
|
|
37
40
|
"build:readme": "tsx scripts/build-readme.mts",
|
|
38
41
|
"postinstall": "node scripts/postinstall.mjs",
|
|
39
42
|
"prepare": "husky"
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Qwen (chat.qwen.ai) self-smoke: a REAL isolated QwenBrowser instance against
|
|
2
|
+
// the live site, to catch integration regressions (login, send, answer) that
|
|
3
|
+
// unit tests cannot.
|
|
4
|
+
//
|
|
5
|
+
// Isolation: HOME is pointed at a throwaway dir BEFORE importing any project
|
|
6
|
+
// module (they compute ~/.zames paths at load). The real Qwen profile
|
|
7
|
+
// (~/.zames/qwen-profile) is COPIED read-only into the temp HOME so the
|
|
8
|
+
// browser starts already signed in; the live profile is NEVER opened. The
|
|
9
|
+
// Playwright browser cache is symlinked read-only. Nothing in the operator's
|
|
10
|
+
// ~/.zames is written.
|
|
11
|
+
//
|
|
12
|
+
// Usage: npx tsx scripts/qwen-smoke.mts [--headed] [--no-reuse-profile]
|
|
13
|
+
// Exit code 0 = all scenarios passed; 2 = login failed (could not start).
|
|
14
|
+
|
|
15
|
+
import fs from 'fs'
|
|
16
|
+
import path from 'path'
|
|
17
|
+
import os from 'os'
|
|
18
|
+
|
|
19
|
+
const HEADED = process.argv.includes('--headed')
|
|
20
|
+
const REUSE_PROFILE = !process.argv.includes('--no-reuse-profile')
|
|
21
|
+
const REAL_HOME = os.homedir()
|
|
22
|
+
const PROFILE_SRC = path.join(REAL_HOME, '.zames', 'qwen-profile')
|
|
23
|
+
|
|
24
|
+
if (!process.env.ZAMES_QWEN_SMOKE_HOME) {
|
|
25
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'zames-qwen-smoke-'))
|
|
26
|
+
process.env.HOME = tmp
|
|
27
|
+
process.env.ZAMES_QWEN_SMOKE_HOME = tmp
|
|
28
|
+
fs.mkdirSync(path.join(tmp, '.zames'), { recursive: true })
|
|
29
|
+
fs.mkdirSync(path.join(tmp, '.cache'), { recursive: true })
|
|
30
|
+
const realCfg = path.join(REAL_HOME, '.zames', 'config.json')
|
|
31
|
+
if (fs.existsSync(realCfg)) {
|
|
32
|
+
fs.copyFileSync(realCfg, path.join(tmp, '.zames', 'config.json'))
|
|
33
|
+
}
|
|
34
|
+
const realPw = path.join(REAL_HOME, '.cache', 'ms-playwright')
|
|
35
|
+
if (fs.existsSync(realPw)) {
|
|
36
|
+
fs.symlinkSync(realPw, path.join(tmp, '.cache', 'ms-playwright'))
|
|
37
|
+
}
|
|
38
|
+
if (REUSE_PROFILE && fs.existsSync(PROFILE_SRC)) {
|
|
39
|
+
fs.cpSync(PROFILE_SRC, path.join(tmp, '.zames', 'qwen-profile'), {
|
|
40
|
+
recursive: true,
|
|
41
|
+
filter: (src) => !/^Singleton/.test(path.basename(src)),
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const SMOKE_HOME = process.env.ZAMES_QWEN_SMOKE_HOME as string
|
|
46
|
+
|
|
47
|
+
// --- project modules (dynamic: they read HOME at import) --------------------
|
|
48
|
+
const { QwenBrowser } = await import('../src/qwen-browser.ts')
|
|
49
|
+
const { loadConfig } = await import('../src/config.ts')
|
|
50
|
+
|
|
51
|
+
const cfg = loadConfig()
|
|
52
|
+
|
|
53
|
+
interface SmokeResult {
|
|
54
|
+
name: string
|
|
55
|
+
ok: boolean
|
|
56
|
+
note?: string
|
|
57
|
+
}
|
|
58
|
+
const results: SmokeResult[] = []
|
|
59
|
+
let loginFailed = false
|
|
60
|
+
const record = (name: string, ok: boolean, note?: string): void => {
|
|
61
|
+
results.push({ name, ok, note })
|
|
62
|
+
console.log((ok ? 'PASS ' : 'FAIL ') + name + (note ? ' — ' + note : ''))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const browser = new QwenBrowser({
|
|
66
|
+
headless: !HEADED,
|
|
67
|
+
debug: process.env.ZAMES_QWEN_SMOKE_DEBUG === '1',
|
|
68
|
+
channel: cfg.browserChannel,
|
|
69
|
+
auth: cfg.browser.auth,
|
|
70
|
+
locale: 'ru',
|
|
71
|
+
minSendIntervalMs: 0,
|
|
72
|
+
answerTimeoutMs: 180000,
|
|
73
|
+
webSearch: false,
|
|
74
|
+
deepThinking: false,
|
|
75
|
+
})
|
|
76
|
+
browser.onNotice = (t: string) => console.log(' [notice] ' + t)
|
|
77
|
+
|
|
78
|
+
async function main() {
|
|
79
|
+
console.log(
|
|
80
|
+
'qwen-smoke: HOME=' +
|
|
81
|
+
SMOKE_HOME +
|
|
82
|
+
' headed=' +
|
|
83
|
+
HEADED +
|
|
84
|
+
' reuse-profile=' +
|
|
85
|
+
REUSE_PROFILE,
|
|
86
|
+
)
|
|
87
|
+
await browser.launch()
|
|
88
|
+
|
|
89
|
+
// Bound the login wait so a cold/expired session fails fast with a clear
|
|
90
|
+
// hint instead of hanging until the caller's tool timeout.
|
|
91
|
+
const loginPromise = browser.waitForLogin()
|
|
92
|
+
const timedOut = await Promise.race([
|
|
93
|
+
loginPromise.then(() => false),
|
|
94
|
+
new Promise<boolean>((r) => setTimeout(() => r(true), 60000)),
|
|
95
|
+
])
|
|
96
|
+
if (timedOut) {
|
|
97
|
+
loginFailed = true
|
|
98
|
+
record('login', false, 'timed out after 60s')
|
|
99
|
+
} else {
|
|
100
|
+
const logged = await browser.isLoggedIn().catch(() => false)
|
|
101
|
+
record('login', logged, logged ? '' : 'not signed in (guest)')
|
|
102
|
+
if (!logged) loginFailed = true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!loginFailed) {
|
|
106
|
+
// 1) new chat + a SIMPLE ask that needs no tools: the model must answer.
|
|
107
|
+
try {
|
|
108
|
+
await browser.newChat()
|
|
109
|
+
const id = await browser.getCurrentChatId()
|
|
110
|
+
record('newChat', true, 'id=' + (id || '(none)'))
|
|
111
|
+
} catch (e) {
|
|
112
|
+
record('newChat', false, (e as Error).message)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const ans = await browser.ask('Reply with exactly: PONG', {
|
|
117
|
+
agent: true,
|
|
118
|
+
})
|
|
119
|
+
const ok = /PONG/i.test(ans)
|
|
120
|
+
record('ask:simple', ok, ok ? '' : 'got: ' + ans.slice(0, 80))
|
|
121
|
+
} catch (e) {
|
|
122
|
+
record('ask:simple', false, (e as Error).message)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 2) a second send right after the first: guards the send-verify path (the
|
|
126
|
+
// "text entered but send button not pressed" regression).
|
|
127
|
+
try {
|
|
128
|
+
const ans = await browser.ask('Reply with exactly: PONG2', {
|
|
129
|
+
agent: true,
|
|
130
|
+
})
|
|
131
|
+
const ok = /PONG2/i.test(ans)
|
|
132
|
+
record('ask:second-send', ok, ok ? '' : 'got: ' + ans.slice(0, 80))
|
|
133
|
+
} catch (e) {
|
|
134
|
+
record('ask:second-send', false, (e as Error).message)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// 3) model catalogue + token usage (best-effort, non-fatal).
|
|
138
|
+
try {
|
|
139
|
+
const models = await browser.listModels()
|
|
140
|
+
record('listModels', models.length > 0, 'n=' + models.length)
|
|
141
|
+
} catch (e) {
|
|
142
|
+
record('listModels', false, (e as Error).message)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const usage = browser.getLastTokenUsage()
|
|
146
|
+
record('tokenUsage', usage !== null, 'usage=' + String(usage))
|
|
147
|
+
|
|
148
|
+
// 4) tool-call emission: the agent-loop depends on the model returning a
|
|
149
|
+
// parseable JSON tool call. Ask for one explicitly and check the PARSER
|
|
150
|
+
// (not the model's prose).
|
|
151
|
+
try {
|
|
152
|
+
const { parseToolCall } = await import('../src/answer-parse.ts')
|
|
153
|
+
const ans = await browser.ask(
|
|
154
|
+
'Call the Read tool for the file package.json. Reply with EXACTLY one ' +
|
|
155
|
+
'JSON tool-call object and nothing else: ' +
|
|
156
|
+
'{"tool":"Read","args":{"path":"..."}}',
|
|
157
|
+
{ agent: true },
|
|
158
|
+
)
|
|
159
|
+
const parsed = parseToolCall(ans)
|
|
160
|
+
const call = Array.isArray(parsed) ? parsed[0] : parsed
|
|
161
|
+
const ok = !!call && call.tool === 'Read'
|
|
162
|
+
record('toolCall', ok, ok ? '' : 'got: ' + ans.slice(0, 100))
|
|
163
|
+
} catch (e) {
|
|
164
|
+
record('toolCall', false, (e as Error).message)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await browser.close().catch(() => {})
|
|
169
|
+
|
|
170
|
+
const failed = results.filter((r) => !r.ok)
|
|
171
|
+
console.log(
|
|
172
|
+
'\nqwen-smoke: ' +
|
|
173
|
+
(results.length - failed.length) +
|
|
174
|
+
'/' +
|
|
175
|
+
results.length +
|
|
176
|
+
' passed',
|
|
177
|
+
)
|
|
178
|
+
if (!process.env.ZAMES_QWEN_SMOKE_KEEP) {
|
|
179
|
+
fs.rmSync(SMOKE_HOME, { recursive: true, force: true })
|
|
180
|
+
} else {
|
|
181
|
+
console.log('kept HOME: ' + SMOKE_HOME)
|
|
182
|
+
}
|
|
183
|
+
process.exit(loginFailed ? 2 : failed.length ? 1 : 0)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
main().catch((e) => {
|
|
187
|
+
console.error('qwen-smoke crashed:', (e as Error).message)
|
|
188
|
+
process.exit(1)
|
|
189
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Render docs/how-it-works.png — the architecture diagram used in README.md.
|
|
2
|
+
//
|
|
3
|
+
// Why a generator: the README must show BOTH chat backends (DeepSeek and
|
|
4
|
+
// Qwen), not just DeepSeek. A hand-drawn PNG drifts; this renders a fixed
|
|
5
|
+
// HTML page with headless Chromium so the diagram is reproducible and easy to
|
|
6
|
+
// update when a provider is added.
|
|
7
|
+
//
|
|
8
|
+
// Run: npm run render-how (tsx scripts/render-how-it-works.mts)
|
|
9
|
+
//
|
|
10
|
+
// Isolation: chromium.launch() uses a THROWAWAY in-memory context, never
|
|
11
|
+
// ~/.zames/profile (see AGENTS.md — never touch the live agent's browser).
|
|
12
|
+
|
|
13
|
+
import { chromium } from 'playwright'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import { fileURLToPath } from 'node:url'
|
|
16
|
+
|
|
17
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
18
|
+
const out = path.join(root, 'docs', 'how-it-works.png')
|
|
19
|
+
|
|
20
|
+
const C = {
|
|
21
|
+
bg: '#23272e',
|
|
22
|
+
panel: '#2b303a',
|
|
23
|
+
border: '#3b4250',
|
|
24
|
+
text: '#cfc9b0',
|
|
25
|
+
dim: '#8a9bb5',
|
|
26
|
+
user: '#b4b8d0',
|
|
27
|
+
accent: '#7fc4f0',
|
|
28
|
+
ds: '#6f8ed0',
|
|
29
|
+
qwen: '#6fd0b0',
|
|
30
|
+
tool: '#c6a97e',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const W = 1300
|
|
34
|
+
const H = 360
|
|
35
|
+
|
|
36
|
+
const box = (
|
|
37
|
+
title: string,
|
|
38
|
+
sub: string,
|
|
39
|
+
color: string,
|
|
40
|
+
): string =>
|
|
41
|
+
'<div class="box" style="border-color:' +
|
|
42
|
+
color +
|
|
43
|
+
'">' +
|
|
44
|
+
'<div class="box-t" style="color:' +
|
|
45
|
+
color +
|
|
46
|
+
'">' +
|
|
47
|
+
title +
|
|
48
|
+
'</div>' +
|
|
49
|
+
'<div class="box-s">' +
|
|
50
|
+
sub +
|
|
51
|
+
'</div></div>'
|
|
52
|
+
|
|
53
|
+
const html =
|
|
54
|
+
'<!doctype html><html><head><meta charset="utf-8"><style>' +
|
|
55
|
+
'@font-face{font-family:ZM;src:local("DejaVu Sans Mono")}' +
|
|
56
|
+
'html,body{margin:0;padding:0;background:' +
|
|
57
|
+
C.bg +
|
|
58
|
+
'}' +
|
|
59
|
+
'body{font-family:"DejaVu Sans Mono",monospace;color:' +
|
|
60
|
+
C.text +
|
|
61
|
+
';-webkit-font-smoothing:antialiased}' +
|
|
62
|
+
'.wrap{box-sizing:border-box;width:' +
|
|
63
|
+
W +
|
|
64
|
+
'px;height:' +
|
|
65
|
+
H +
|
|
66
|
+
'px;padding:26px 30px}' +
|
|
67
|
+
'.row{display:flex;align-items:center;justify-content:space-between;gap:8px}' +
|
|
68
|
+
'.box{box-sizing:border-box;border:2px solid;border-radius:10px;padding:14px 16px;' +
|
|
69
|
+
'background:' +
|
|
70
|
+
C.panel +
|
|
71
|
+
';min-width:150px;text-align:center}' +
|
|
72
|
+
'.box-t{font-size:18px;font-weight:700;margin-bottom:6px}' +
|
|
73
|
+
'.box-s{font-size:12px;color:' +
|
|
74
|
+
C.dim +
|
|
75
|
+
';line-height:1.5}' +
|
|
76
|
+
'.arrow{font-size:26px;color:' +
|
|
77
|
+
C.dim +
|
|
78
|
+
'}' +
|
|
79
|
+
'.flow-label{font-size:12px;color:' +
|
|
80
|
+
C.dim +
|
|
81
|
+
';text-align:center;margin-top:4px}' +
|
|
82
|
+
'.providers{display:flex;flex-direction:column;gap:10px}' +
|
|
83
|
+
'.col{display:flex;flex-direction:column;align-items:center;gap:4px}' +
|
|
84
|
+
'.legend{margin-top:20px;font-size:12px;color:' +
|
|
85
|
+
C.dim +
|
|
86
|
+
';text-align:center}' +
|
|
87
|
+
'.legend b{color:' +
|
|
88
|
+
C.text +
|
|
89
|
+
'}' +
|
|
90
|
+
'</style></head><body><div class="wrap">' +
|
|
91
|
+
'<div class="row">' +
|
|
92
|
+
// left: the operator
|
|
93
|
+
'<div class="col">' +
|
|
94
|
+
box('Operator', 'terminal task<br/>+ tool approval', C.user) +
|
|
95
|
+
'<div class="flow-label">types a task</div>' +
|
|
96
|
+
'</div>' +
|
|
97
|
+
'<div class="arrow">→</div>' +
|
|
98
|
+
// middle: zames
|
|
99
|
+
'<div class="col">' +
|
|
100
|
+
box(
|
|
101
|
+
'zames',
|
|
102
|
+
'sandboxed tools<br/>parses tool-calls<br/>runs them locally',
|
|
103
|
+
C.accent,
|
|
104
|
+
) +
|
|
105
|
+
'<div class="flow-label">system prompt + task</div>' +
|
|
106
|
+
'</div>' +
|
|
107
|
+
'<div class="arrow">⇄</div>' +
|
|
108
|
+
// right: the two backends
|
|
109
|
+
'<div class="providers">' +
|
|
110
|
+
box('DeepSeek', 'chat.deepseek.com<br/>~/.zames/profile', C.ds) +
|
|
111
|
+
box('Qwen', 'chat.qwen.ai<br/>~/.zames/qwen-profile', C.qwen) +
|
|
112
|
+
'</div>' +
|
|
113
|
+
'</div>' +
|
|
114
|
+
'<div class="legend">zames drives a <b>real browser</b> over a persistent per-provider profile — no API key. ' +
|
|
115
|
+
'Switch live with <b>/provider <deepseek|qwen></b> (each chat id belongs to one backend).</div>' +
|
|
116
|
+
'</div></body></html>'
|
|
117
|
+
|
|
118
|
+
const browser = await chromium.launch({ headless: true })
|
|
119
|
+
try {
|
|
120
|
+
const page = await browser.newPage({
|
|
121
|
+
viewport: { width: W, height: H },
|
|
122
|
+
deviceScaleFactor: 2,
|
|
123
|
+
})
|
|
124
|
+
await page.setContent(html, { waitUntil: 'load' })
|
|
125
|
+
await page.screenshot({ path: out })
|
|
126
|
+
console.log('wrote', path.relative(root, out))
|
|
127
|
+
} finally {
|
|
128
|
+
await browser.close()
|
|
129
|
+
}
|