zames_pro 2.16.0 → 2.17.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 +37 -1
- package/dist/agent-loop.js +5 -1
- package/dist/browser.js +536 -15
- package/dist/commands.js +1 -0
- package/dist/config-menu.js +4 -0
- package/dist/config.js +11 -1
- package/dist/i18n.js +36 -1
- package/dist/index.js +43 -4
- package/dist/input.js +51 -9
- package/dist/spinner.js +39 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,7 +9,42 @@ directory, reads and edits files, runs commands, and commits to git.
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
11
11
|
- Node.js >= 18
|
|
12
|
-
- A DeepSeek account
|
|
12
|
+
- A DeepSeek account. On first launch zames asks for your DeepSeek
|
|
13
|
+
login/password in the terminal (and stores them in `~/.zames/config.json`
|
|
14
|
+
after a successful sign-in, so a later logout is handled automatically
|
|
15
|
+
without asking you again). You can also sign in manually in the browser
|
|
16
|
+
window when the browser is headed.
|
|
17
|
+
|
|
18
|
+
## Signing in
|
|
19
|
+
|
|
20
|
+
The browser runs **headless by default**. When DeepSeek requires a sign-in,
|
|
21
|
+
zames:
|
|
22
|
+
|
|
23
|
+
1. reuses the session stored in the persistent profile (`~/.zames/profile`)
|
|
24
|
+
if it is still valid;
|
|
25
|
+
2. otherwise signs in automatically with the saved credentials
|
|
26
|
+
(`browser.auth.username` / `browser.auth.password`);
|
|
27
|
+
3. otherwise asks you for the login/password in the terminal (in a TTY) and,
|
|
28
|
+
after a successful sign-in, remembers them for next time;
|
|
29
|
+
4. otherwise falls back to a manual sign-in hint.
|
|
30
|
+
|
|
31
|
+
To sign in by hand (for example, if DeepSeek shows a captcha), run with a
|
|
32
|
+
visible window:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
zames --headed
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The `--headless` flag (the default) and `headless: true` in the config keep
|
|
39
|
+
the browser without a window; `--headed` / `headless: false` show it.
|
|
40
|
+
|
|
41
|
+
Headless works out of the box: a headless Chrome normally advertises a
|
|
42
|
+
`HeadlessChrome/...` User-Agent that DeepSeek's CDN blocks with a 403, so
|
|
43
|
+
zames strips that marker before loading the page (keeping the real engine
|
|
44
|
+
version). You do not need `--headed` just to log in.
|
|
45
|
+
|
|
46
|
+
Credentials and toggles can also be edited from `/config`
|
|
47
|
+
(`browser.auth.username`, `browser.auth.password`, `browser.auth.saveSession`).
|
|
13
48
|
|
|
14
49
|
## Installation
|
|
15
50
|
|
|
@@ -71,6 +106,7 @@ zames --new-chat
|
|
|
71
106
|
zames --resend-prompt
|
|
72
107
|
zames --dir <path>
|
|
73
108
|
zames --headless
|
|
109
|
+
zames --headed
|
|
74
110
|
zames --debug
|
|
75
111
|
zames --version
|
|
76
112
|
zames --help
|
package/dist/agent-loop.js
CHANGED
|
@@ -4,7 +4,7 @@ import { getGitContext, formatGitContext } from './gitTools.js';
|
|
|
4
4
|
import { parseXmlToolCalls } from './xml-toolcall.js';
|
|
5
5
|
import { translate } from './i18n.js';
|
|
6
6
|
import { normText } from './browser.js';
|
|
7
|
-
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', }) {
|
|
7
|
+
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', }) {
|
|
8
8
|
// UI callbacks must NEVER break the agent loop. A rendering error (a huge
|
|
9
9
|
// tool result, a broken markdown frame, a closed terminal) used to throw
|
|
10
10
|
// out of the loop right after a tool call — the session looked "stopped
|
|
@@ -26,6 +26,10 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
26
26
|
// Previously safeThinking() fired before browser.ask(), so the spinner ran
|
|
27
27
|
// through the whole throttle pause / chat-open phase with nothing in flight.
|
|
28
28
|
browser.onSendStart = safeThinking;
|
|
29
|
+
// Animate the send-interval pause too: the browser reports the remaining
|
|
30
|
+
// seconds, the UI shows an animated status instead of a frozen line.
|
|
31
|
+
const safeSendPause = safe(onSendPause);
|
|
32
|
+
browser.onSendPause = safeSendPause;
|
|
29
33
|
const safeAssistantThought = safe(onAssistantThought);
|
|
30
34
|
const safeToolCall = safe(onToolCall);
|
|
31
35
|
const safeToolResult = safe(onToolResult);
|
package/dist/browser.js
CHANGED
|
@@ -5,8 +5,134 @@ import os from 'os';
|
|
|
5
5
|
import fs from 'fs/promises';
|
|
6
6
|
import { execSync } from 'child_process';
|
|
7
7
|
import { theme } from './theme.js';
|
|
8
|
+
import { translate, DEFAULT_LOCALE } from './i18n.js';
|
|
9
|
+
// Muted terminal input for passwords. We do NOT use readline here: readline
|
|
10
|
+
// echoes the typed text through its own internal _writeToOutput, which cannot
|
|
11
|
+
// be reliably overridden from outside (a real bug: the login prompt swallowed
|
|
12
|
+
// the password step). Instead we read raw keystrokes from stdin and echo one
|
|
13
|
+
// `*` per typed character. In a non-TTY (pipe/redirect) we fall back to a
|
|
14
|
+
// plain line read.
|
|
15
|
+
export function askPassword(question) {
|
|
16
|
+
return new Promise((resolve) => {
|
|
17
|
+
process.stdout.write(question);
|
|
18
|
+
// Non-interactive: read a single line from stdin as-is.
|
|
19
|
+
if (!process.stdin.isTTY) {
|
|
20
|
+
void import('readline').then(({ createInterface }) => {
|
|
21
|
+
const rl = createInterface({
|
|
22
|
+
input: process.stdin,
|
|
23
|
+
output: process.stdout,
|
|
24
|
+
});
|
|
25
|
+
rl.question('', (answer) => {
|
|
26
|
+
rl.close();
|
|
27
|
+
resolve(answer);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const stdin = process.stdin;
|
|
33
|
+
const wasRaw = stdin.isRaw;
|
|
34
|
+
stdin.setRawMode(true);
|
|
35
|
+
stdin.resume();
|
|
36
|
+
let value = '';
|
|
37
|
+
const onData = (buf) => {
|
|
38
|
+
const s = buf.toString('utf8');
|
|
39
|
+
for (const ch of s) {
|
|
40
|
+
const code = ch.charCodeAt(0);
|
|
41
|
+
// Enter / Ctrl+J -> done.
|
|
42
|
+
if (ch === '\r' || ch === '\n') {
|
|
43
|
+
done();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Ctrl+C -> abort the whole process (same as elsewhere in the CLI).
|
|
47
|
+
if (code === 3) {
|
|
48
|
+
cleanup();
|
|
49
|
+
process.stdout.write('\n');
|
|
50
|
+
process.exit(130);
|
|
51
|
+
}
|
|
52
|
+
// Ctrl+D -> finish with whatever we have.
|
|
53
|
+
if (code === 4) {
|
|
54
|
+
done();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Backspace (0x7f or 0x08): remove the last char and one star.
|
|
58
|
+
if (code === 127 || code === 8) {
|
|
59
|
+
if (value.length > 0) {
|
|
60
|
+
value = value.slice(0, -1);
|
|
61
|
+
process.stdout.write('\b \b');
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// Ignore other control characters (arrows, etc.).
|
|
66
|
+
if (code < 32)
|
|
67
|
+
continue;
|
|
68
|
+
value += ch;
|
|
69
|
+
process.stdout.write('*');
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const cleanup = () => {
|
|
73
|
+
stdin.removeListener('data', onData);
|
|
74
|
+
try {
|
|
75
|
+
stdin.setRawMode(wasRaw || false);
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
stdin.pause();
|
|
79
|
+
};
|
|
80
|
+
const done = () => {
|
|
81
|
+
cleanup();
|
|
82
|
+
process.stdout.write('\n');
|
|
83
|
+
resolve(value);
|
|
84
|
+
};
|
|
85
|
+
stdin.on('data', onData);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
8
88
|
const USER_DATA_DIR = path.join(os.homedir(), '.zames', 'profile');
|
|
9
89
|
const CHAT_URL = 'https://chat.deepseek.com/';
|
|
90
|
+
// A headless Chrome advertises "HeadlessChrome/..." in its User-Agent, and
|
|
91
|
+
// DeepSeek's CDN (CloudFront/WAF) rejects that UA with a plain "403 ERROR"
|
|
92
|
+
// page before the app is even served — so a headless login looked broken while
|
|
93
|
+
// a headed one worked (headed Chrome sends "Chrome/..."). We keep the real
|
|
94
|
+
// engine version and only drop the "Headless" marker. When the operator
|
|
95
|
+
// explicitly set a UA in the config, that one wins (see _launchOnce).
|
|
96
|
+
export function sanitizeHeadlessUA(ua) {
|
|
97
|
+
return String(ua || '').replace(/HeadlessChrome/g, 'Chrome');
|
|
98
|
+
}
|
|
99
|
+
// Written after a successful sign-in so the next launch knows a session was
|
|
100
|
+
// stored in the persistent profile (used by /doctor and diagnostics).
|
|
101
|
+
const AUTH_MARKER_FILE = path.join(os.homedir(), '.zames', 'auth.json');
|
|
102
|
+
// DeepSeek sign-in form. The login page is served on the same origin and
|
|
103
|
+
// swaps in a password field; the exact classes change, so we match loosely on
|
|
104
|
+
// input types and the known placeholder/autocomplete attributes.
|
|
105
|
+
const PASSWORD_SELECTORS = [
|
|
106
|
+
'input[type="password"]',
|
|
107
|
+
'input[autocomplete="current-password"]',
|
|
108
|
+
'input[autocomplete="new-password"]',
|
|
109
|
+
];
|
|
110
|
+
const LOGIN_SELECTORS = [
|
|
111
|
+
'input[type="email"]',
|
|
112
|
+
'input[type="tel"]',
|
|
113
|
+
'input[name="email"]',
|
|
114
|
+
'input[name="phone"]',
|
|
115
|
+
'input[name="username"]',
|
|
116
|
+
'input[placeholder*="email" i]',
|
|
117
|
+
'input[placeholder*="phone" i]',
|
|
118
|
+
'input[placeholder*="телефон" i]',
|
|
119
|
+
'input[placeholder*="почт" i]',
|
|
120
|
+
'input[autocomplete="username"]',
|
|
121
|
+
'input[autocomplete="email"]',
|
|
122
|
+
];
|
|
123
|
+
const LOGIN_SUBMIT_SELECTORS = [
|
|
124
|
+
// DeepSeek's real button: a div[role=button] with these classes and a
|
|
125
|
+
// <span class="ds-button__content">Log in</span> inside.
|
|
126
|
+
'div[role="button"].ds-button--primary.ds-button--filled',
|
|
127
|
+
'div[role="button"].ds-button--primary',
|
|
128
|
+
'button[type="submit"]',
|
|
129
|
+
'button:has-text("Log in")',
|
|
130
|
+
'button:has-text("Sign in")',
|
|
131
|
+
'button:has-text("Войти")',
|
|
132
|
+
'div[role="button"]:has-text("Log in")',
|
|
133
|
+
'div[role="button"]:has-text("Sign in")',
|
|
134
|
+
'div[role="button"]:has-text("Войти")',
|
|
135
|
+
];
|
|
10
136
|
const INPUT_SELECTORS = [
|
|
11
137
|
'textarea',
|
|
12
138
|
'div[contenteditable="true"]',
|
|
@@ -130,6 +256,16 @@ export class DeepSeekBrowser {
|
|
|
130
256
|
// is never read/shown). webSearch: the "Smart search" toggle.
|
|
131
257
|
deepThinking;
|
|
132
258
|
webSearch;
|
|
259
|
+
// Credentials for automatic sign-in. The password is stored in the config
|
|
260
|
+
// file (~/.zames/config.json) and is used only when DeepSeek has logged the
|
|
261
|
+
// session out. `saveSession` keeps the profile (cookies) so a fresh launch
|
|
262
|
+
// reuses the session instead of asking the operator every time.
|
|
263
|
+
auth;
|
|
264
|
+
// Interface language for the login prompts printed by the browser itself.
|
|
265
|
+
locale;
|
|
266
|
+
// Called after a successful interactive login so index.ts can persist the
|
|
267
|
+
// credentials into the user config (the browser does not write config).
|
|
268
|
+
onAuthSave;
|
|
133
269
|
_lastSentAt;
|
|
134
270
|
_abort;
|
|
135
271
|
// The user pressed Esc/Ctrl+C — a "stop" for the WHOLE current batch of
|
|
@@ -149,7 +285,15 @@ export class DeepSeekBrowser {
|
|
|
149
285
|
// a generation really begins, not during the pre-send phase (chat open,
|
|
150
286
|
// throttle wait), which used to show a spinner with no work in flight.
|
|
151
287
|
onSendStart;
|
|
152
|
-
|
|
288
|
+
// Fired when the agent starts waiting out the send-interval pause, with the
|
|
289
|
+
// remaining seconds. Lets the UI animate the pause status instead of
|
|
290
|
+
// printing a static line (the dots used to be frozen during the pause).
|
|
291
|
+
onSendPause;
|
|
292
|
+
// User-Agent sent by the browser. In headless mode the "Headless" marker
|
|
293
|
+
// must be stripped (DeepSeek's CDN answers 403 to it); computed in the
|
|
294
|
+
// constructor, overridable by an explicit UA in the options/config.
|
|
295
|
+
userAgent;
|
|
296
|
+
constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 2, stabilityDelayMs = 400, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, deepThinking = false, webSearch = true, auth, locale, onAuthSave, userAgent, } = {}) {
|
|
153
297
|
this.headless = headless;
|
|
154
298
|
this.debug = debug;
|
|
155
299
|
this.channel = channel;
|
|
@@ -164,6 +308,13 @@ export class DeepSeekBrowser {
|
|
|
164
308
|
this.serverBusyWaitMs = serverBusyWaitMs;
|
|
165
309
|
this.deepThinking = deepThinking;
|
|
166
310
|
this.webSearch = webSearch;
|
|
311
|
+
this.auth = {
|
|
312
|
+
username: (auth?.username || '').trim(),
|
|
313
|
+
password: auth?.password || '',
|
|
314
|
+
saveSession: auth?.saveSession !== false,
|
|
315
|
+
};
|
|
316
|
+
this.locale = locale || DEFAULT_LOCALE;
|
|
317
|
+
this.onAuthSave = onAuthSave || null;
|
|
167
318
|
this._lastSentAt = 0;
|
|
168
319
|
this._abort = false;
|
|
169
320
|
this._stopped = false;
|
|
@@ -174,6 +325,16 @@ export class DeepSeekBrowser {
|
|
|
174
325
|
this._netSniffLimit = 5;
|
|
175
326
|
this._netHookInstalled = false;
|
|
176
327
|
this.onSendStart = null;
|
|
328
|
+
this.onSendPause = null;
|
|
329
|
+
// An explicit UA (config/options) is respected as-is. Otherwise it stays
|
|
330
|
+
// empty here and is derived from the real engine UA after launch — see
|
|
331
|
+
// _fixHeadlessUserAgent (a headless "HeadlessChrome/..." UA gets 403 from
|
|
332
|
+
// DeepSeek's CDN).
|
|
333
|
+
this.userAgent = userAgent ? sanitizeHeadlessUA(userAgent) : '';
|
|
334
|
+
}
|
|
335
|
+
/** Translation bound to this browser's locale. */
|
|
336
|
+
_t(key, params) {
|
|
337
|
+
return translate(this.locale)(key, params);
|
|
177
338
|
}
|
|
178
339
|
async launch() {
|
|
179
340
|
await killStaleChrome();
|
|
@@ -183,6 +344,35 @@ export class DeepSeekBrowser {
|
|
|
183
344
|
await cleanSingletonFiles();
|
|
184
345
|
}
|
|
185
346
|
await this._launchOnce();
|
|
347
|
+
await this._fixHeadlessUserAgent();
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* A headless Chromium advertises "HeadlessChrome/<v>" in its User-Agent, and
|
|
351
|
+
* DeepSeek's CDN (CloudFront/WAF) answers that UA with a plain "403 ERROR"
|
|
352
|
+
* page before the app is served — so sign-in worked with a visible window and
|
|
353
|
+
* silently failed headless. Read the UA the engine really reports and, when
|
|
354
|
+
* it carries the "Headless" marker, relaunch once with a sanitized
|
|
355
|
+
* `--user-agent` (the marker removed, the real version kept). A UA passed
|
|
356
|
+
* explicitly in the options/config is never touched.
|
|
357
|
+
*/
|
|
358
|
+
async _fixHeadlessUserAgent() {
|
|
359
|
+
if (!this.headless)
|
|
360
|
+
return;
|
|
361
|
+
try {
|
|
362
|
+
const ua = await this.page.evaluate(() => navigator.userAgent);
|
|
363
|
+
const fixed = sanitizeHeadlessUA(ua);
|
|
364
|
+
if (fixed === ua)
|
|
365
|
+
return;
|
|
366
|
+
this.userAgent = fixed;
|
|
367
|
+
if (this.debug)
|
|
368
|
+
console.error('profile: headless UA → перезапуск с обычным Chrome UA');
|
|
369
|
+
await this.context.close().catch(() => { });
|
|
370
|
+
await this._launchOnce();
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
if (this.debug)
|
|
374
|
+
console.error('profile: UA-фикс не удался:', e.message);
|
|
375
|
+
}
|
|
186
376
|
}
|
|
187
377
|
async _launchOnce() {
|
|
188
378
|
const options = {
|
|
@@ -195,6 +385,8 @@ export class DeepSeekBrowser {
|
|
|
195
385
|
};
|
|
196
386
|
if (this.channel)
|
|
197
387
|
options.channel = this.channel;
|
|
388
|
+
if (this.userAgent)
|
|
389
|
+
options.userAgent = this.userAgent;
|
|
198
390
|
try {
|
|
199
391
|
this.context = await chromium.launchPersistentContext(USER_DATA_DIR, options);
|
|
200
392
|
}
|
|
@@ -273,11 +465,47 @@ export class DeepSeekBrowser {
|
|
|
273
465
|
await this.launch();
|
|
274
466
|
}
|
|
275
467
|
async waitForLogin() {
|
|
276
|
-
|
|
277
|
-
if (
|
|
468
|
+
// 1) Already signed in (session cookie in the persistent profile)?
|
|
469
|
+
if (await this.isLoggedIn()) {
|
|
470
|
+
if (this.debug)
|
|
471
|
+
console.error('auth: сессия уже активна');
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
// 2) Try to sign in automatically with the saved credentials. The
|
|
475
|
+
// password lives in the config (~/.zames/config.json) and is only used
|
|
476
|
+
// when the session is logged out — i.e. exactly the "re-login without
|
|
477
|
+
// asking" case. If the login form is absent (e.g. DeepSeek uses a
|
|
478
|
+
// captcha / another provider), this fails and we fall through.
|
|
479
|
+
if (this.auth.username && this.auth.password) {
|
|
480
|
+
console.log(theme.system(this._t('auth.auto_login')));
|
|
481
|
+
const ok = await this._autoLogin().catch((e) => {
|
|
482
|
+
console.log(theme.warn(this._t('auth.auto_login_failed', { v: e.message })));
|
|
483
|
+
return false;
|
|
484
|
+
});
|
|
485
|
+
if (ok) {
|
|
486
|
+
console.log(theme.assistant(this._t('auth.auto_login_ok')));
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// 3) Ask the operator for credentials in the terminal, try them, and
|
|
491
|
+
// only then fall back to the manual hint. Interactive only: in a
|
|
492
|
+
// pipe/redirect (or headless without credentials) we print a hint.
|
|
493
|
+
const asked = await this._promptAndLogin().catch((e) => {
|
|
494
|
+
if (this.debug)
|
|
495
|
+
console.error('auth: prompt error:', e.message);
|
|
496
|
+
return false;
|
|
497
|
+
});
|
|
498
|
+
if (asked)
|
|
278
499
|
return;
|
|
279
|
-
|
|
280
|
-
console.log('
|
|
500
|
+
// 4) Last resort: manual sign-in in the browser window.
|
|
501
|
+
console.log('\n' + theme.warn(this._t('auth.need_login')));
|
|
502
|
+
if (this.headless) {
|
|
503
|
+
console.log(' ' + this._t('auth.manual_hint_headless'));
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
console.log(' ' + this._t('auth.manual_hint'));
|
|
507
|
+
}
|
|
508
|
+
console.log('\n');
|
|
281
509
|
const readline = await import('readline');
|
|
282
510
|
const rl = readline.createInterface({
|
|
283
511
|
input: process.stdin,
|
|
@@ -289,20 +517,286 @@ export class DeepSeekBrowser {
|
|
|
289
517
|
resolve();
|
|
290
518
|
});
|
|
291
519
|
});
|
|
520
|
+
if (await this.isLoggedIn()) {
|
|
521
|
+
await this._persistSessionIfNeeded();
|
|
522
|
+
console.log(theme.assistant(this._t('auth.auto_login_ok')));
|
|
523
|
+
}
|
|
292
524
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
525
|
+
/**
|
|
526
|
+
* Try to sign in with this.auth. Returns true when the login form was
|
|
527
|
+
* filled and the session became active. Never throws for a missing form —
|
|
528
|
+
* the caller decides whether to fall back.
|
|
529
|
+
*/
|
|
530
|
+
async _autoLogin() {
|
|
531
|
+
const filled = await this._fillLoginForm(this.auth.username, this.auth.password);
|
|
532
|
+
if (!filled)
|
|
533
|
+
return false;
|
|
534
|
+
const ok = await this._waitLoggedIn(20_000);
|
|
535
|
+
if (ok)
|
|
536
|
+
await this._persistSessionIfNeeded();
|
|
537
|
+
return ok;
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Ask the operator for login/password in the terminal (only in a TTY) and
|
|
541
|
+
* try them. An empty password falls back to the saved one. Returns true
|
|
542
|
+
* when the sign-in succeeded.
|
|
543
|
+
*/
|
|
544
|
+
async _promptAndLogin() {
|
|
545
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY)
|
|
546
|
+
return false;
|
|
547
|
+
const hasSaved = !!(this.auth.username && this.auth.password);
|
|
548
|
+
// Prompt only when we have a login field to fill.
|
|
549
|
+
if (!(await this._loginFormVisible())) {
|
|
550
|
+
if (this.debug)
|
|
551
|
+
console.error('auth: форма входа не найдена');
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
console.log('\n' + theme.warn(this._t('auth.need_login')));
|
|
555
|
+
// Username via a normal readline question (echoed).
|
|
556
|
+
let user = this.auth.username;
|
|
557
|
+
if (!user) {
|
|
558
|
+
const readline = await import('readline');
|
|
559
|
+
const rl = readline.createInterface({
|
|
560
|
+
input: process.stdin,
|
|
561
|
+
output: process.stdout,
|
|
562
|
+
terminal: true,
|
|
563
|
+
});
|
|
564
|
+
user = (await new Promise((resolve) => rl.question(this._t('auth.prompt_login'), (a) => resolve(a)))).trim();
|
|
565
|
+
rl.close();
|
|
566
|
+
}
|
|
567
|
+
// Password via raw keystroke reading, echoed as `*` (readline would echo
|
|
568
|
+
// it in clear or swallow the prompt — see askPassword).
|
|
569
|
+
const passPromptKey = hasSaved
|
|
570
|
+
? 'auth.prompt_password_saved'
|
|
571
|
+
: 'auth.prompt_password';
|
|
572
|
+
const pass = await askPassword(this._t(passPromptKey));
|
|
573
|
+
const password = pass || this.auth.password;
|
|
574
|
+
if (!user || !password)
|
|
575
|
+
return false;
|
|
576
|
+
console.log(theme.system(this._t('auth.auto_login')));
|
|
577
|
+
const filled = await this._fillLoginForm(user, password);
|
|
578
|
+
if (!filled) {
|
|
579
|
+
console.log(theme.warn(this._t('auth.form_not_found')));
|
|
580
|
+
return false;
|
|
581
|
+
}
|
|
582
|
+
const ok = await this._waitLoggedIn(20_000);
|
|
583
|
+
if (ok) {
|
|
584
|
+
// Remember the credentials so the next launch can re-login silently.
|
|
585
|
+
this.auth.username = user;
|
|
586
|
+
this.auth.password = password;
|
|
587
|
+
await this._persistSessionIfNeeded();
|
|
588
|
+
this.onAuthSave?.(user, password);
|
|
589
|
+
console.log(theme.assistant(this._t('auth.auto_login_ok')));
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
// Detect a credentials error shown by DeepSeek, otherwise report a
|
|
593
|
+
// generic failure with the page hint so the operator can react.
|
|
594
|
+
const reason = await this._readLoginError();
|
|
595
|
+
if (reason) {
|
|
596
|
+
console.log(theme.error(this._t('auth.login_rejected', { v: reason })));
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
console.log(theme.warn(this._t('auth.auto_login_failed', { v: this._t('auth.no_reason') })));
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return ok;
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Read a visible login/credentials error message from the page, if any.
|
|
606
|
+
* Best-effort: returns '' when nothing recognizable is found.
|
|
607
|
+
*/
|
|
608
|
+
async _readLoginError() {
|
|
609
|
+
try {
|
|
610
|
+
const txt = await this.page.evaluate(() => {
|
|
611
|
+
const sels = [
|
|
612
|
+
'[role="alert"]',
|
|
613
|
+
'[class*="error" i]',
|
|
614
|
+
'[class*="toast" i]',
|
|
615
|
+
'[class*="notification" i]',
|
|
616
|
+
];
|
|
617
|
+
let out = '';
|
|
618
|
+
for (const s of sels) {
|
|
619
|
+
for (const el of Array.from(document.querySelectorAll(s))) {
|
|
620
|
+
const e = el;
|
|
621
|
+
const st = getComputedStyle(e);
|
|
622
|
+
if (st.display === 'none' || st.visibility === 'hidden')
|
|
623
|
+
continue;
|
|
624
|
+
const t = (e.innerText || '').trim();
|
|
625
|
+
if (t)
|
|
626
|
+
out += ' ' + t;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
return out.replace(/\s+/g, ' ').trim();
|
|
630
|
+
});
|
|
631
|
+
return txt.slice(0, 200);
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
return '';
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/** Is a login form (password field) present on the page? */
|
|
638
|
+
async _loginFormVisible() {
|
|
639
|
+
return await this.page
|
|
640
|
+
.locator(PASSWORD_SELECTORS.join(', '))
|
|
641
|
+
.first()
|
|
642
|
+
.isVisible({ timeout: 2000 })
|
|
643
|
+
.catch(() => false);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Fill the DeepSeek sign-in form with the given credentials and submit it.
|
|
647
|
+
* Returns false if the form (or the submit button) is not found.
|
|
648
|
+
*/
|
|
649
|
+
async _fillLoginForm(username, password) {
|
|
650
|
+
const pwd = await this._findVisible(PASSWORD_SELECTORS, 4000);
|
|
651
|
+
if (!pwd)
|
|
652
|
+
return false;
|
|
653
|
+
// The email/phone field is the text input above the password field. Try
|
|
654
|
+
// the known selectors first, then fall back to the nearest text input.
|
|
655
|
+
let userInput = await this._findVisible(LOGIN_SELECTORS, 2000);
|
|
656
|
+
if (!userInput) {
|
|
657
|
+
// Find the index of the password input and use the closest preceding
|
|
658
|
+
// text/email/tel input as the login field.
|
|
659
|
+
const idx = await this.page.evaluate((pwdSel) => {
|
|
660
|
+
const p = document.querySelector(pwdSel);
|
|
661
|
+
if (!p)
|
|
662
|
+
return -1;
|
|
663
|
+
const inputs = Array.from(document.querySelectorAll('input'));
|
|
664
|
+
const pi = inputs.indexOf(p);
|
|
665
|
+
for (let i = pi - 1; i >= 0; i--) {
|
|
666
|
+
const t = (inputs[i].type || 'text').toLowerCase();
|
|
667
|
+
if (t === 'text' || t === 'email' || t === 'tel')
|
|
668
|
+
return i;
|
|
669
|
+
}
|
|
670
|
+
return -1;
|
|
671
|
+
}, PASSWORD_SELECTORS[0]);
|
|
672
|
+
if (idx >= 0)
|
|
673
|
+
userInput = this.page.locator('input').nth(idx);
|
|
674
|
+
}
|
|
675
|
+
if (!userInput)
|
|
676
|
+
return false;
|
|
677
|
+
await this._setInputText(userInput, username);
|
|
678
|
+
await this._setInputText(pwd, password);
|
|
679
|
+
await this.page.waitForTimeout(150);
|
|
680
|
+
// Submit. DeepSeek's login form is a React form; the button text is
|
|
681
|
+
// localized and classes change, so we try, in order:
|
|
682
|
+
// 1. known submit selectors;
|
|
683
|
+
// 2. a submit/primary button inside the same <form> as the password;
|
|
684
|
+
// 3. pressing Enter in the password field;
|
|
685
|
+
// 4. clicking the password field's closest button sibling.
|
|
686
|
+
// After each attempt we give the login a short window and stop as soon as
|
|
687
|
+
// it succeeds, so a stray click does not fire on an already-logged-in page.
|
|
688
|
+
const tryLogin = async () => {
|
|
689
|
+
if (await this._waitLoggedIn(3000))
|
|
300
690
|
return true;
|
|
691
|
+
return false;
|
|
692
|
+
};
|
|
693
|
+
// Precise first attempt: DeepSeek's button is
|
|
694
|
+
// <div role="button" class="ds-button ds-button--primary ...">
|
|
695
|
+
// <span class="ds-button__content">Log in</span></div>
|
|
696
|
+
// Click exactly the one whose visible label is a login word, so we never
|
|
697
|
+
// hit "Log in with Google" or another primary button by mistake.
|
|
698
|
+
const precise = this.page
|
|
699
|
+
.locator('div[role="button"].ds-button--primary, button')
|
|
700
|
+
.filter({ hasText: /^\s*(log ?in|sign ?in|войти)\s*$/i });
|
|
701
|
+
const pc = await precise.count().catch(() => 0);
|
|
702
|
+
if (pc > 0) {
|
|
703
|
+
try {
|
|
704
|
+
await precise.last().click({ timeout: 2000 });
|
|
705
|
+
if (await tryLogin())
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
catch { }
|
|
709
|
+
}
|
|
710
|
+
for (const sel of LOGIN_SUBMIT_SELECTORS) {
|
|
711
|
+
const btn = this.page.locator(sel).last();
|
|
712
|
+
try {
|
|
713
|
+
if ((await btn.count()) === 0)
|
|
714
|
+
continue;
|
|
715
|
+
if (!(await btn.isVisible().catch(() => false)))
|
|
716
|
+
continue;
|
|
717
|
+
await btn.click({ timeout: 2000 });
|
|
718
|
+
if (await tryLogin())
|
|
719
|
+
return true;
|
|
301
720
|
}
|
|
302
721
|
catch { }
|
|
303
722
|
}
|
|
723
|
+
// Button inside the same form as the password field.
|
|
724
|
+
const formClicked = await this.page
|
|
725
|
+
.evaluate((pwdSel) => {
|
|
726
|
+
const p = document.querySelector(pwdSel);
|
|
727
|
+
if (!p)
|
|
728
|
+
return false;
|
|
729
|
+
const form = p.closest('form');
|
|
730
|
+
const scope = form || document;
|
|
731
|
+
const btns = Array.from(scope.querySelectorAll('button[type="submit"], button, div[role="button"]'));
|
|
732
|
+
for (const b of btns) {
|
|
733
|
+
const txt = (b.textContent || '').trim().toLowerCase();
|
|
734
|
+
const aria = (b.getAttribute('aria-label') || '').toLowerCase();
|
|
735
|
+
if (/log ?in|sign ?in|войти|continue|продолж/.test(txt + ' ' + aria)) {
|
|
736
|
+
b.click();
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
// Fallback: the primary-looking button in the scope.
|
|
741
|
+
const primary = btns.find((b) => {
|
|
742
|
+
const cls = (b.className || '').toString();
|
|
743
|
+
return /primary|submit|ds-button--primary/i.test(cls);
|
|
744
|
+
});
|
|
745
|
+
if (primary) {
|
|
746
|
+
primary.click();
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
return false;
|
|
750
|
+
}, PASSWORD_SELECTORS[0])
|
|
751
|
+
.catch(() => false);
|
|
752
|
+
if (formClicked && (await tryLogin()))
|
|
753
|
+
return true;
|
|
754
|
+
// Enter in the password field.
|
|
755
|
+
await pwd.press('Enter').catch(() => { });
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
/** Poll isLoggedIn until it succeeds or the deadline passes. */
|
|
759
|
+
async _waitLoggedIn(timeoutMs) {
|
|
760
|
+
const deadline = Date.now() + timeoutMs;
|
|
761
|
+
while (Date.now() < deadline) {
|
|
762
|
+
if (await this.isLoggedIn().catch(() => false))
|
|
763
|
+
return true;
|
|
764
|
+
await this.page.waitForTimeout(500);
|
|
765
|
+
}
|
|
304
766
|
return false;
|
|
305
767
|
}
|
|
768
|
+
/**
|
|
769
|
+
* Persist the authenticated session. The persistent profile already keeps
|
|
770
|
+
* cookies on disk; `saveSession` (config) is kept as an explicit toggle for
|
|
771
|
+
* the operator. We additionally save a small marker so /doctor and the next
|
|
772
|
+
* launch can tell that a session exists.
|
|
773
|
+
*/
|
|
774
|
+
async _persistSessionIfNeeded() {
|
|
775
|
+
if (!this.auth.saveSession)
|
|
776
|
+
return;
|
|
777
|
+
if (!(await this.isLoggedIn().catch(() => false)))
|
|
778
|
+
return;
|
|
779
|
+
try {
|
|
780
|
+
await fs.mkdir(path.join(os.homedir(), '.zames'), { recursive: true });
|
|
781
|
+
await fs.writeFile(AUTH_MARKER_FILE, JSON.stringify({ at: new Date().toISOString() }, null, 2), 'utf-8');
|
|
782
|
+
}
|
|
783
|
+
catch { }
|
|
784
|
+
}
|
|
785
|
+
async isLoggedIn() {
|
|
786
|
+
// Single combined query with a short timeout: the old loop waited up to
|
|
787
|
+
// 3s PER selector (9s total), and _waitLoggedIn calls this repeatedly,
|
|
788
|
+
// which made login feel like it "hangs".
|
|
789
|
+
try {
|
|
790
|
+
await this.page
|
|
791
|
+
.locator(INPUT_SELECTORS.join(', '))
|
|
792
|
+
.first()
|
|
793
|
+
.waitFor({ state: 'visible', timeout: 2000 });
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
306
800
|
async newChat() {
|
|
307
801
|
const newChatBtn = this.page
|
|
308
802
|
.locator('button, a')
|
|
@@ -690,20 +1184,47 @@ export class DeepSeekBrowser {
|
|
|
690
1184
|
const gap = this.minSendIntervalMs - (Date.now() - this._lastSentAt);
|
|
691
1185
|
if (gap <= 0)
|
|
692
1186
|
return;
|
|
693
|
-
|
|
694
|
-
|
|
1187
|
+
// Report the pause to the UI as an ANIMATED status (with the remaining
|
|
1188
|
+
// seconds) instead of the old static console line — the dots used to be
|
|
1189
|
+
// frozen here, which looked like the spinner had hung. The seconds are
|
|
1190
|
+
// refreshed once per second, so the status visibly counts down.
|
|
1191
|
+
const report = (leftMs) => {
|
|
1192
|
+
const secs = Math.max(0, Math.ceil(leftMs / 1000));
|
|
1193
|
+
if (this.onSendPause) {
|
|
1194
|
+
try {
|
|
1195
|
+
this.onSendPause(secs);
|
|
1196
|
+
}
|
|
1197
|
+
catch { }
|
|
1198
|
+
}
|
|
1199
|
+
else {
|
|
1200
|
+
console.error(theme.warn(`⏳ send pause ${secs}s`));
|
|
1201
|
+
}
|
|
1202
|
+
};
|
|
1203
|
+
report(gap);
|
|
1204
|
+
await this._sleepInterruptible(gap, report);
|
|
695
1205
|
}
|
|
696
1206
|
// Sleep in small slices so Esc/Ctrl+C can cancel the wait promptly.
|
|
697
1207
|
// Returns true if the sleep was cut short by _abort.
|
|
698
|
-
|
|
1208
|
+
//
|
|
1209
|
+
// onTick (optional) is called about once per second with the remaining time,
|
|
1210
|
+
// so the UI can show a live countdown during the pause.
|
|
1211
|
+
async _sleepInterruptible(ms, onTick) {
|
|
699
1212
|
const step = 100;
|
|
700
1213
|
let left = ms;
|
|
1214
|
+
let sinceTick = 0;
|
|
701
1215
|
while (left > 0) {
|
|
702
1216
|
if (this._abort)
|
|
703
1217
|
return true;
|
|
704
1218
|
const chunk = Math.min(step, left);
|
|
705
1219
|
await this.page.waitForTimeout(chunk);
|
|
706
1220
|
left -= chunk;
|
|
1221
|
+
if (onTick) {
|
|
1222
|
+
sinceTick += chunk;
|
|
1223
|
+
if (sinceTick >= 1000) {
|
|
1224
|
+
sinceTick = 0;
|
|
1225
|
+
onTick(left);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
707
1228
|
}
|
|
708
1229
|
return this._abort;
|
|
709
1230
|
}
|
package/dist/commands.js
CHANGED
|
@@ -153,6 +153,7 @@ export function renderDoctor(d) {
|
|
|
153
153
|
row(d.gitOk, 'git', d.gitOk ? 'repo (' + (d.gitBranch || 'detached') + ')' : 'not a repository');
|
|
154
154
|
row(d.configOk, 'config', d.configOk ? 'loaded' : 'error: ' + (d.configError || 'unknown'));
|
|
155
155
|
row(true, 'browser', d.browserChannel ? d.browserChannel : 'bundled chromium');
|
|
156
|
+
row(!!d.authSaved, 'auth', d.authSaved ? 'session saved (auto re-login ready)' : 'no saved session');
|
|
156
157
|
row(!!d.clipboardTool, 'clipboard', d.clipboardTool || 'no tool found (image paste disabled)');
|
|
157
158
|
row(true, 'mcp', d.mcpServers + ' server(s), ' + d.mcpTools + ' tool(s)');
|
|
158
159
|
row(d.transcriptOk, 'transcript', d.transcriptOk ? 'on' : 'off');
|
package/dist/config-menu.js
CHANGED
|
@@ -19,6 +19,10 @@ function displayValue(field, value, t) {
|
|
|
19
19
|
return t('cfg.menu.default');
|
|
20
20
|
if (typeof value === 'boolean')
|
|
21
21
|
return value ? t('common.on') : t('common.off');
|
|
22
|
+
// Never print a password in clear text: show a fixed mask when set.
|
|
23
|
+
if (/password/i.test(field.path) && String(value).length > 0) {
|
|
24
|
+
return '********';
|
|
25
|
+
}
|
|
22
26
|
return String(value);
|
|
23
27
|
}
|
|
24
28
|
function groupLabel(field, t) {
|
package/dist/config.js
CHANGED
|
@@ -7,7 +7,9 @@ const HOME_CONFIG = path.join(ZAMES_HOME, 'config.json');
|
|
|
7
7
|
const PROJECT_CONFIG = path.join(process.cwd(), '.zamesrc.json');
|
|
8
8
|
export const DEFAULTS = {
|
|
9
9
|
maxIterations: 0,
|
|
10
|
-
|
|
10
|
+
// Headless by default: the browser runs without a visible window. Set
|
|
11
|
+
// headless: false (or pass --headed) to watch/debug the DeepSeek page.
|
|
12
|
+
headless: true,
|
|
11
13
|
debug: false,
|
|
12
14
|
browserChannel: null,
|
|
13
15
|
confirmation: {
|
|
@@ -43,6 +45,11 @@ export const DEFAULTS = {
|
|
|
43
45
|
maxRateLimitRetries: 6,
|
|
44
46
|
deepThinking: false,
|
|
45
47
|
webSearch: true,
|
|
48
|
+
auth: {
|
|
49
|
+
username: '',
|
|
50
|
+
password: '',
|
|
51
|
+
saveSession: true,
|
|
52
|
+
},
|
|
46
53
|
},
|
|
47
54
|
ui: {
|
|
48
55
|
locale: DEFAULT_LOCALE,
|
|
@@ -81,6 +88,9 @@ export const CONFIG_SCHEMA = [
|
|
|
81
88
|
{ path: 'ui.locale', type: 'enum', values: ['ru', 'en'], labelKey: 'cfg.f.ui_locale', groupKey: 'cfg.group.ui' },
|
|
82
89
|
{ path: 'maxIterations', type: 'number', min: 0, max: 100000, labelKey: 'cfg.f.maxIterations', groupKey: 'cfg.group.agent' },
|
|
83
90
|
{ path: 'headless', type: 'boolean', labelKey: 'cfg.f.headless', groupKey: 'cfg.group.agent' },
|
|
91
|
+
{ path: 'browser.auth.username', type: 'string', labelKey: 'cfg.f.browser_authUsername', groupKey: 'cfg.group.browser' },
|
|
92
|
+
{ path: 'browser.auth.password', type: 'string', labelKey: 'cfg.f.browser_authPassword', groupKey: 'cfg.group.browser' },
|
|
93
|
+
{ path: 'browser.auth.saveSession', type: 'boolean', labelKey: 'cfg.f.browser_authSaveSession', groupKey: 'cfg.group.browser' },
|
|
84
94
|
{ path: 'debug', type: 'boolean', labelKey: 'cfg.f.debug', groupKey: 'cfg.group.agent' },
|
|
85
95
|
{ path: 'hotReload', type: 'boolean', labelKey: 'cfg.f.hotReload', groupKey: 'cfg.group.agent' },
|
|
86
96
|
{ path: 'confirmation.write', type: 'boolean', labelKey: 'cfg.f.confirmation_write', groupKey: 'cfg.group.confirmation' },
|
package/dist/i18n.js
CHANGED
|
@@ -46,7 +46,8 @@ const CATALOG = {
|
|
|
46
46
|
'help.opt.new_chat': { ru: 'начать новый чат (по умолчанию)', en: 'start a new chat (default)' },
|
|
47
47
|
'help.opt.resend_prompt': { ru: 'дослать system-prompt в существующий чат', en: 'resend system-prompt into an existing chat' },
|
|
48
48
|
'help.opt.max_iter': { ru: 'лимит итераций, 0 = без лимита ({n})', en: 'iteration limit, 0 = unlimited ({n})' },
|
|
49
|
-
'help.opt.headless': { ru: 'браузер без UI', en: 'headless browser' },
|
|
49
|
+
'help.opt.headless': { ru: 'браузер без UI (по умолчанию)', en: 'headless browser (default)' },
|
|
50
|
+
'help.opt.headed': { ru: 'показать окно браузера (отладка/вход)', en: 'show the browser window (debug/sign-in)' },
|
|
50
51
|
'help.opt.debug': { ru: 'подробный лог', en: 'verbose log' },
|
|
51
52
|
'help.opt.calibrate': { ru: 'режим калибровки селекторов', en: 'selector calibration mode' },
|
|
52
53
|
'help.opt.dev': { ru: 'режим разработки: авто-перечитывание модулей', en: 'dev mode: auto-reload modules' },
|
|
@@ -132,6 +133,9 @@ const CATALOG = {
|
|
|
132
133
|
en: 'Mixing the slurry…|Whipping it to a smooth paste…|Stirring logic into it…|Shaking it into a cocktail…|Whisking it around…|Blending it with caffeine…|Kneading dough out of it…|Whipping it into foam…|Tossing it with a spatula…|Kneading clay out of it…|Homogenizing it into concrete…|Shaking it until clear…|Blending it into a smoothie…|Mixing cement out of it…|Whipping porridge out of it…|Stirring it with hope…|Swirling the sediment…|Running the blender over it…|Mixing it beyond recognition…|Mixing mortar out of it…|Whipping a cocktail of it…|Stirring it with the deadline spatula…|Whipping foam out of it…|Homogenizing it evenly…|Tumbling it in a cement mixer…|Beating it into a uniform mass…|Stirring it until clear…|Mixing it from whatever is around…',
|
|
133
134
|
},
|
|
134
135
|
'spinner.hint': { ru: 'Esc — стоп', en: 'Esc — stop' },
|
|
136
|
+
'spinner.pause': { ru: '⏳ пауза {n}с перед отправкой', en: '⏳ pause {n}s before send' },
|
|
137
|
+
'editor.more': { ru: '…ещё {n}', en: '…{n} more' },
|
|
138
|
+
'editor.answer': { ru: '● Ответ', en: '● Answer' },
|
|
135
139
|
// ---------- status ----------
|
|
136
140
|
'status.workdir': { ru: 'Рабочая директория: {v}', en: 'Working directory: {v}' },
|
|
137
141
|
'status.review_mode': { ru: 'Режим ревью: {v}', en: 'Review mode: {v}' },
|
|
@@ -295,6 +299,37 @@ const CATALOG = {
|
|
|
295
299
|
'cfg.f.browser_maxRateLimitRetries': { ru: 'Число повторов при лимите', en: 'Rate-limit retry count' },
|
|
296
300
|
'cfg.f.browser_deepThinking': { ru: 'Глубокое мышление (долго; размышления не выводятся)', en: 'Deep thinking (slow; reasoning is hidden)' },
|
|
297
301
|
'cfg.f.browser_webSearch': { ru: 'Умный поиск в интернете', en: 'Smart web search' },
|
|
302
|
+
'cfg.f.browser_authUsername': { ru: 'Логин DeepSeek для авто-входа', en: 'DeepSeek login for auto sign-in' },
|
|
303
|
+
'cfg.f.browser_authPassword': { ru: 'Пароль DeepSeek для авто-входа', en: 'DeepSeek password for auto sign-in' },
|
|
304
|
+
'cfg.f.browser_authSaveSession': { ru: 'Сохранять сессию входа в профиле', en: 'Persist the login session in the profile' },
|
|
305
|
+
// ---------- login / auth ----------
|
|
306
|
+
'auth.need_login': { ru: '🔐 Требуется вход в DeepSeek.', en: '🔐 DeepSeek sign-in required.' },
|
|
307
|
+
'auth.prompt_login': { ru: 'Логин DeepSeek (телефон/email): ', en: 'DeepSeek login (phone/email): ' },
|
|
308
|
+
'auth.prompt_password': { ru: 'Пароль DeepSeek: ', en: 'DeepSeek password: ' },
|
|
309
|
+
'auth.prompt_password_saved': {
|
|
310
|
+
ru: 'Пароль DeepSeek (Enter — использовать сохранённый): ',
|
|
311
|
+
en: 'DeepSeek password (Enter — use the saved one): ',
|
|
312
|
+
},
|
|
313
|
+
'auth.auto_login': { ru: 'Авто-вход в DeepSeek...', en: 'Signing in to DeepSeek...' },
|
|
314
|
+
'auth.auto_login_ok': { ru: 'Вход выполнен.', en: 'Signed in.' },
|
|
315
|
+
'auth.auto_login_failed': { ru: 'Авто-вход не удался: {v}', en: 'Auto sign-in failed: {v}' },
|
|
316
|
+
'auth.login_rejected': { ru: 'DeepSeek отклонил вход: {v}', en: 'DeepSeek rejected the sign-in: {v}' },
|
|
317
|
+
'auth.form_not_found': {
|
|
318
|
+
ru: 'Форма входа не распознана. Запустите с --headed и войдите вручную.',
|
|
319
|
+
en: 'Login form not recognized. Run with --headed and sign in manually.',
|
|
320
|
+
},
|
|
321
|
+
'auth.no_reason': {
|
|
322
|
+
ru: 'причина не определена (возможно, неверный логин/пароль или капча)',
|
|
323
|
+
en: 'no reason detected (possibly wrong login/password or a captcha)',
|
|
324
|
+
},
|
|
325
|
+
'auth.manual_hint': { ru: 'Войдите в DeepSeek в открытом браузере, затем нажмите Enter.', en: 'Sign in to DeepSeek in the open browser, then press Enter.' },
|
|
326
|
+
'auth.manual_hint_headless': {
|
|
327
|
+
ru: 'Войдите в DeepSeek: откройте https://chat.deepseek.com/ в своём браузере. Если включён headless — задайте логин/пароль через /config или запустите с --headed.',
|
|
328
|
+
en: 'Sign in to DeepSeek: open https://chat.deepseek.com/ in your browser. If headless is on — set the login/password via /config or run with --headed.',
|
|
329
|
+
},
|
|
330
|
+
'auth.session_saved': { ru: 'Сессия DeepSeek сохранена.', en: 'DeepSeek session saved.' },
|
|
331
|
+
'auth.session_restored': { ru: 'Сессия DeepSeek восстановлена.', en: 'DeepSeek session restored.' },
|
|
332
|
+
'auth.enter_to_continue': { ru: 'Нажмите Enter, чтобы продолжить...', en: 'Press Enter to continue...' },
|
|
298
333
|
'cfg.menu.title': { ru: '⚙ Настройки — выбери параметр', en: '⚙ Settings — choose a parameter' },
|
|
299
334
|
'cfg.menu.hint': { ru: '↑/↓ — выбор, Enter — изменить, q/Esc — выйти', en: '↑/↓ select, Enter edit, q/Esc quit' },
|
|
300
335
|
'cfg.menu.edit_hint': { ru: 'Введи значение и Enter (пусто — отмена)', en: 'Enter a value and press Enter (empty — cancel)' },
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs/promises';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
4
5
|
import { fileURLToPath } from 'url';
|
|
5
6
|
import { theme } from './theme.js';
|
|
6
7
|
import { DeepSeekBrowser } from './browser.js';
|
|
@@ -19,6 +20,16 @@ import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCo
|
|
|
19
20
|
import { closeWeb } from './web.js';
|
|
20
21
|
import { saveSession, loadLastSession, listSessions, sessionsDir, } from './sessions.js';
|
|
21
22
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
// True when a DeepSeek session/credentials were stored earlier (auth.json).
|
|
24
|
+
// Used by /doctor to report that auto re-login is ready.
|
|
25
|
+
function authMarkerExists() {
|
|
26
|
+
try {
|
|
27
|
+
return existsSync(path.join(ZAMES_HOME, 'auth.json'));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
22
33
|
// ---------- CLI parsing ----------
|
|
23
34
|
const args = process.argv.slice(2);
|
|
24
35
|
function getArg(flag, fallback = null) {
|
|
@@ -50,7 +61,11 @@ let currentLocale = isLocale(config.ui?.locale)
|
|
|
50
61
|
// Translation: reads currentLocale at call time, so a language change takes
|
|
51
62
|
// effect immediately, without a restart (for lines printed afterwards).
|
|
52
63
|
const t = (key, params) => translate(currentLocale)(key, params);
|
|
53
|
-
|
|
64
|
+
// Headless is the default. --headed forces a visible window (useful for the
|
|
65
|
+
// first sign-in or debugging the DOM). --headless keeps it explicit.
|
|
66
|
+
const headless = hasFlag('--headed')
|
|
67
|
+
? false
|
|
68
|
+
: hasFlag('--headless') || config.headless;
|
|
54
69
|
const debug = hasFlag('--debug') || config.debug;
|
|
55
70
|
const calibrate = hasFlag('--calibrate');
|
|
56
71
|
const maxIterArg = getArg('--max-iter', null);
|
|
@@ -189,6 +204,7 @@ ${theme.bold(t('help.options'))}
|
|
|
189
204
|
--resend-prompt ${t('help.opt.resend_prompt')}
|
|
190
205
|
--max-iter <n> ${t('help.opt.max_iter', { n: config.maxIterations })}
|
|
191
206
|
--headless ${t('help.opt.headless')}
|
|
207
|
+
--headed ${t('help.opt.headed')}
|
|
192
208
|
--debug ${t('help.opt.debug')}
|
|
193
209
|
--calibrate ${t('help.opt.calibrate')}
|
|
194
210
|
--dev ${t('help.opt.dev')}
|
|
@@ -828,6 +844,7 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
|
|
|
828
844
|
attachments: next.attachments || [],
|
|
829
845
|
transcript,
|
|
830
846
|
onThinking: () => ui.thinking(),
|
|
847
|
+
onSendPause: (seconds) => ui.sendPause(seconds),
|
|
831
848
|
onToolCall: (name, toolArgs) => ui.toolCall(name, toolArgs),
|
|
832
849
|
onToolResult: (result) => ui.toolResult(result),
|
|
833
850
|
onAssistantMessage: (msg) => ui.assistant(msg),
|
|
@@ -881,9 +898,10 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
|
|
|
881
898
|
finally {
|
|
882
899
|
stopWatching();
|
|
883
900
|
ui.stop();
|
|
884
|
-
// Detach the send
|
|
901
|
+
// Detach the send hooks so a later browser.ask() outside this task cannot
|
|
885
902
|
// start a stale spinner.
|
|
886
903
|
browser.onSendStart = null;
|
|
904
|
+
browser.onSendPause = null;
|
|
887
905
|
}
|
|
888
906
|
}
|
|
889
907
|
// ---------- main ----------
|
|
@@ -944,6 +962,18 @@ async function main() {
|
|
|
944
962
|
debug,
|
|
945
963
|
channel: config.browserChannel,
|
|
946
964
|
...config.browser,
|
|
965
|
+
locale: currentLocale,
|
|
966
|
+
// Persist credentials the operator typed in the terminal so the next
|
|
967
|
+
// launch can re-login silently after a logout.
|
|
968
|
+
onAuthSave: (username, password) => {
|
|
969
|
+
config.browser.auth.username = username;
|
|
970
|
+
config.browser.auth.password = password;
|
|
971
|
+
try {
|
|
972
|
+
writeConfigValue('home', 'browser.auth.username', username);
|
|
973
|
+
writeConfigValue('home', 'browser.auth.password', password);
|
|
974
|
+
}
|
|
975
|
+
catch { }
|
|
976
|
+
},
|
|
947
977
|
});
|
|
948
978
|
const bootSpinner = mod.createSpinner(currentLocale);
|
|
949
979
|
bootSpinner.thinking();
|
|
@@ -1096,6 +1126,7 @@ async function main() {
|
|
|
1096
1126
|
const ed = new LineEditor({
|
|
1097
1127
|
prompt: buildPrompt(),
|
|
1098
1128
|
commands: buildSlashCommands(),
|
|
1129
|
+
locale: currentLocale,
|
|
1099
1130
|
});
|
|
1100
1131
|
editor = ed;
|
|
1101
1132
|
ed.setTmpDir(TMP_DIR);
|
|
@@ -1259,6 +1290,7 @@ async function main() {
|
|
|
1259
1290
|
currentLocale = value;
|
|
1260
1291
|
config.ui.locale = value;
|
|
1261
1292
|
if (editor) {
|
|
1293
|
+
editor.setLocale(value);
|
|
1262
1294
|
editor.setCommands(buildSlashCommands());
|
|
1263
1295
|
editor.setPrompt(buildPrompt());
|
|
1264
1296
|
}
|
|
@@ -1304,7 +1336,9 @@ async function main() {
|
|
|
1304
1336
|
? cur
|
|
1305
1337
|
? t('common.on')
|
|
1306
1338
|
: t('common.off')
|
|
1307
|
-
:
|
|
1339
|
+
: /password/i.test(f.path) && String(cur).length > 0
|
|
1340
|
+
? '********'
|
|
1341
|
+
: JSON.stringify(cur);
|
|
1308
1342
|
const extra = f.values ? ' [' + f.values.join('|') + ']' : '';
|
|
1309
1343
|
console.log(' ' +
|
|
1310
1344
|
theme.user(f.path) +
|
|
@@ -1395,7 +1429,11 @@ async function main() {
|
|
|
1395
1429
|
const cur = getByPath(config, key);
|
|
1396
1430
|
console.log(theme.system(t('cfg.value', {
|
|
1397
1431
|
v: key,
|
|
1398
|
-
value: cur === undefined
|
|
1432
|
+
value: cur === undefined
|
|
1433
|
+
? t('cfg.menu.default')
|
|
1434
|
+
: /password/i.test(key) && String(cur).length > 0
|
|
1435
|
+
? '********'
|
|
1436
|
+
: JSON.stringify(cur),
|
|
1399
1437
|
})));
|
|
1400
1438
|
return;
|
|
1401
1439
|
}
|
|
@@ -2101,6 +2139,7 @@ async function main() {
|
|
|
2101
2139
|
mcpServers: mcpStatus.servers.length,
|
|
2102
2140
|
mcpTools: mcpStatus.toolCount,
|
|
2103
2141
|
transcriptOk: !!transcript.file,
|
|
2142
|
+
authSaved: authMarkerExists(),
|
|
2104
2143
|
})));
|
|
2105
2144
|
continue;
|
|
2106
2145
|
}
|
package/dist/input.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { theme } from './theme.js';
|
|
2
2
|
import { renderMarkdown } from './markdown.js';
|
|
3
3
|
import { randomThinkingPhrase, stripEllipsis } from './spinner.js';
|
|
4
|
+
import { translate } from './i18n.js';
|
|
4
5
|
import { AttachmentStore, parseImagePaste, looksLikeFilePath, } from './attachments.js';
|
|
5
6
|
// A permanent input line at the bottom of the terminal + a status/output area above it.
|
|
6
7
|
//
|
|
@@ -70,7 +71,6 @@ export function expandPastes(pastes, text) {
|
|
|
70
71
|
// We align the width to the maximum (3) so the hint doesn't shift.
|
|
71
72
|
const DOTS = ['', '.', '..', '...'];
|
|
72
73
|
const DOTS_PAD = ' ';
|
|
73
|
-
const HINT = theme.dim(' · Esc — стоп');
|
|
74
74
|
function safeJson(v) {
|
|
75
75
|
try {
|
|
76
76
|
return JSON.stringify(v);
|
|
@@ -184,6 +184,9 @@ export class LineEditor {
|
|
|
184
184
|
onCtrlC;
|
|
185
185
|
_inPaste;
|
|
186
186
|
_dotTimer;
|
|
187
|
+
// True while the dot-animation timer is running. Lets sendPause() update
|
|
188
|
+
// only the base text (remaining seconds) without restarting the timer.
|
|
189
|
+
_animating;
|
|
187
190
|
_dotPhase;
|
|
188
191
|
_thinkBase;
|
|
189
192
|
_wasRaw;
|
|
@@ -212,7 +215,11 @@ export class LineEditor {
|
|
|
212
215
|
// user could type and send a message mid-operation, and it would be queued
|
|
213
216
|
// and sent right after the operation, breaking the restored session.
|
|
214
217
|
locked;
|
|
215
|
-
|
|
218
|
+
// Interface language for the editor's own labels (hint, answer marker,
|
|
219
|
+
// pause status). Everything the OPERATOR sees must be localized.
|
|
220
|
+
locale;
|
|
221
|
+
constructor({ prompt = '> ', commands = [], locale = 'ru' } = {}) {
|
|
222
|
+
this.locale = locale;
|
|
216
223
|
this.promptStr = prompt;
|
|
217
224
|
this.buf = '';
|
|
218
225
|
this.cursor = 0;
|
|
@@ -226,6 +233,7 @@ export class LineEditor {
|
|
|
226
233
|
this.onCtrlC = null;
|
|
227
234
|
this._inPaste = false;
|
|
228
235
|
this._dotTimer = null;
|
|
236
|
+
this._animating = false;
|
|
229
237
|
this._dotPhase = 0;
|
|
230
238
|
this._thinkBase = '';
|
|
231
239
|
this._wasRaw = false;
|
|
@@ -362,6 +370,11 @@ export class LineEditor {
|
|
|
362
370
|
this.promptStr = str;
|
|
363
371
|
this._render();
|
|
364
372
|
}
|
|
373
|
+
// Update the interface language (labels: hint, answer marker, pause).
|
|
374
|
+
setLocale(locale) {
|
|
375
|
+
this.locale = locale;
|
|
376
|
+
this._render();
|
|
377
|
+
}
|
|
365
378
|
// Update slash-command descriptions (interface language change).
|
|
366
379
|
setCommands(commands) {
|
|
367
380
|
this.slashCommands = commands;
|
|
@@ -412,7 +425,7 @@ export class LineEditor {
|
|
|
412
425
|
out += NL + lines.join(NL);
|
|
413
426
|
const hidden = sugg.length - shown.length;
|
|
414
427
|
if (hidden > 0)
|
|
415
|
-
out += NL + theme.dim('
|
|
428
|
+
out += NL + theme.dim(' ' + translate(this.locale)('editor.more', { n: hidden }));
|
|
416
429
|
}
|
|
417
430
|
process.stdout.write(out);
|
|
418
431
|
const lastRow = lay.rows.length - 1;
|
|
@@ -449,36 +462,65 @@ export class LineEditor {
|
|
|
449
462
|
clearInterval(this._dotTimer);
|
|
450
463
|
this._dotTimer = null;
|
|
451
464
|
}
|
|
465
|
+
this._animating = false;
|
|
452
466
|
}
|
|
453
467
|
_dots(n) {
|
|
454
468
|
// The dots are the same color as the base and of fixed width — otherwise
|
|
455
469
|
// the hint on the right "jumps" when the animation phase changes.
|
|
456
470
|
return theme.brown(DOTS[n] + DOTS_PAD.slice(DOTS[n].length));
|
|
457
471
|
}
|
|
472
|
+
// Status-line hint ("Esc — stop"), localized.
|
|
473
|
+
_hint() {
|
|
474
|
+
return theme.dim(' · ' + translate(this.locale)('spinner.hint'));
|
|
475
|
+
}
|
|
458
476
|
_startThinking() {
|
|
459
477
|
if (this.pendingText) {
|
|
460
|
-
this.setStatus(theme.prompt('✎ ') + this.pendingText +
|
|
478
|
+
this.setStatus(theme.prompt('✎ ') + this.pendingText + this._hint());
|
|
461
479
|
return;
|
|
462
480
|
}
|
|
463
|
-
this.
|
|
481
|
+
this._startAnimated(randomThinkingPhrase());
|
|
482
|
+
}
|
|
483
|
+
// Animated status: a brown base text plus a growing "running" dot sequence.
|
|
484
|
+
// Shared by the thinking spinner and the send-pause indicator, so the pause
|
|
485
|
+
// is animated too (previously the pause was a static console line and the
|
|
486
|
+
// dots stayed frozen).
|
|
487
|
+
_startAnimated(baseText) {
|
|
488
|
+
this._thinkBase = theme.brown(stripEllipsis(baseText));
|
|
464
489
|
this._dotPhase = 0;
|
|
465
|
-
this.setStatus(this._thinkBase + this._dots(0) +
|
|
490
|
+
this.setStatus(this._thinkBase + this._dots(0) + this._hint());
|
|
466
491
|
this._stopDots();
|
|
467
492
|
this._dotTimer = setInterval(() => {
|
|
468
493
|
this._dotPhase = (this._dotPhase + 1) % DOTS.length;
|
|
469
|
-
this.setStatus(this._thinkBase + this._dots(this._dotPhase) +
|
|
494
|
+
this.setStatus(this._thinkBase + this._dots(this._dotPhase) + this._hint());
|
|
470
495
|
}, 400);
|
|
496
|
+
this._animating = true;
|
|
471
497
|
if (this._dotTimer.unref)
|
|
472
498
|
this._dotTimer.unref();
|
|
473
499
|
}
|
|
474
500
|
thinking() {
|
|
475
501
|
this._startThinking();
|
|
476
502
|
}
|
|
503
|
+
// The agent is waiting out the send-interval pause before a real send.
|
|
504
|
+
// Show it as an ANIMATED status with the remaining seconds. The browser
|
|
505
|
+
// calls this repeatedly (once per second), so we update only the base text
|
|
506
|
+
// and keep the running dot timer — otherwise the dots would restart from
|
|
507
|
+
// zero on every update and look frozen on a single dot.
|
|
508
|
+
sendPause(seconds) {
|
|
509
|
+
if (this.pendingText)
|
|
510
|
+
return;
|
|
511
|
+
const label = translate(this.locale)('spinner.pause', { n: seconds });
|
|
512
|
+
if (this._animating && this._dotTimer) {
|
|
513
|
+
this._thinkBase = theme.brown(stripEllipsis(label));
|
|
514
|
+
this.setStatus(this._thinkBase + this._dots(this._dotPhase) + this._hint());
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
this._startAnimated(label);
|
|
518
|
+
}
|
|
477
519
|
setPending(text) {
|
|
478
520
|
this.pendingText = text && String(text).length ? String(text) : null;
|
|
479
521
|
if (this.pendingText) {
|
|
480
522
|
this._stopDots();
|
|
481
|
-
this.setStatus(theme.prompt('✎ ') + this.pendingText +
|
|
523
|
+
this.setStatus(theme.prompt('✎ ') + this.pendingText + this._hint());
|
|
482
524
|
}
|
|
483
525
|
else {
|
|
484
526
|
this._startThinking();
|
|
@@ -500,7 +542,7 @@ export class LineEditor {
|
|
|
500
542
|
assistant(msg) {
|
|
501
543
|
this.stop();
|
|
502
544
|
const rendered = renderMarkdown(msg);
|
|
503
|
-
this.printAbove(NL + theme.assistant('
|
|
545
|
+
this.printAbove(NL + theme.assistant(translate(this.locale)('editor.answer')) + NL + rendered + NL + theme.dim('─'.repeat(60)));
|
|
504
546
|
}
|
|
505
547
|
// Warning to the operator (e.g. the agent stopped suspiciously).
|
|
506
548
|
// Printed above the input line without overwriting it.
|
package/dist/spinner.js
CHANGED
|
@@ -20,6 +20,12 @@ export function createSpinner(locale = 'ru') {
|
|
|
20
20
|
let dotTimer = null;
|
|
21
21
|
let dotPhase = 0;
|
|
22
22
|
let pending = null;
|
|
23
|
+
// Current animated base text and its dot renderer. Kept in variables (not
|
|
24
|
+
// captured in the interval closure) so sendPause() can update the text
|
|
25
|
+
// without restarting the dot animation — otherwise the dots would reset to
|
|
26
|
+
// zero every second and look frozen on a single dot.
|
|
27
|
+
let animBase = '';
|
|
28
|
+
let animating = false;
|
|
23
29
|
// Dot animation: start from an empty string (0 dots), then grow.
|
|
24
30
|
// We align the width to the maximum (3) so the hint doesn't shift.
|
|
25
31
|
const DOTS = ['', '.', '..', '...'];
|
|
@@ -35,19 +41,23 @@ export function createSpinner(locale = 'ru') {
|
|
|
35
41
|
clearInterval(dotTimer);
|
|
36
42
|
dotTimer = null;
|
|
37
43
|
}
|
|
44
|
+
animating = false;
|
|
38
45
|
if (spinner)
|
|
39
46
|
spinner.stop();
|
|
40
47
|
};
|
|
41
48
|
// Hint in the status line: while the agent works the terminal is live,
|
|
42
49
|
// so you can type the next message. Without it this is not obvious.
|
|
43
50
|
const HINT = theme.dim(' · ' + translate(locale)('spinner.hint'));
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
// Run the animated status line: a brown base text plus a growing/shrinking
|
|
52
|
+
// "running" dot sequence. Shared by the thinking spinner and the send-pause
|
|
53
|
+
// indicator, so the pause is animated too (before, it was a static line and
|
|
54
|
+
// the dots did not move — the operator saw a frozen spinner).
|
|
55
|
+
const startAnimated = (baseText) => {
|
|
56
|
+
animBase = theme.brown(stripEllipsis(baseText));
|
|
47
57
|
// The dots are the same color as the base and of fixed width — otherwise
|
|
48
58
|
// the hint on the right "jumps" when the animation phase changes.
|
|
49
59
|
const dots = (n) => theme.brown(DOTS[n] + DOTS_PAD.slice(DOTS[n].length));
|
|
50
|
-
start(
|
|
60
|
+
start(animBase + dots(0) + HINT);
|
|
51
61
|
dotPhase = 0;
|
|
52
62
|
if (dotTimer)
|
|
53
63
|
clearInterval(dotTimer);
|
|
@@ -55,11 +65,16 @@ export function createSpinner(locale = 'ru') {
|
|
|
55
65
|
if (!spinner)
|
|
56
66
|
return;
|
|
57
67
|
dotPhase = (dotPhase + 1) % DOTS.length;
|
|
58
|
-
spinner.text =
|
|
68
|
+
spinner.text = animBase + dots(dotPhase) + HINT;
|
|
59
69
|
}, 400);
|
|
70
|
+
animating = true;
|
|
60
71
|
if (dotTimer.unref)
|
|
61
72
|
dotTimer.unref();
|
|
62
73
|
};
|
|
74
|
+
// Start the animated status: brown text + "running" dots.
|
|
75
|
+
const startThinking = () => {
|
|
76
|
+
startAnimated(randomThinkingPhrase(locale));
|
|
77
|
+
};
|
|
63
78
|
// Show the typed but not yet sent text instead of the spinner.
|
|
64
79
|
// Lets you type a message right while the agent is working.
|
|
65
80
|
const showPending = () => {
|
|
@@ -67,6 +82,7 @@ export function createSpinner(locale = 'ru') {
|
|
|
67
82
|
clearInterval(dotTimer);
|
|
68
83
|
dotTimer = null;
|
|
69
84
|
}
|
|
85
|
+
animating = false;
|
|
70
86
|
if (!spinner)
|
|
71
87
|
spinner = ora('');
|
|
72
88
|
spinner.start();
|
|
@@ -80,6 +96,23 @@ export function createSpinner(locale = 'ru') {
|
|
|
80
96
|
}
|
|
81
97
|
startThinking();
|
|
82
98
|
},
|
|
99
|
+
// The agent is waiting out the send-interval pause before a real send.
|
|
100
|
+
// Show it as an ANIMATED status line with the remaining seconds, so the
|
|
101
|
+
// spinner keeps moving during the pause (previously a static console line
|
|
102
|
+
// was printed and the dots stayed frozen).
|
|
103
|
+
sendPause: (seconds) => {
|
|
104
|
+
if (pending)
|
|
105
|
+
return;
|
|
106
|
+
const label = translate(locale)('spinner.pause', { n: seconds });
|
|
107
|
+
// Update only the base text while the dot animation is already running,
|
|
108
|
+
// so the countdown refreshes without resetting the dots.
|
|
109
|
+
if (animating && dotTimer && spinner) {
|
|
110
|
+
animBase = theme.brown(stripEllipsis(label));
|
|
111
|
+
spinner.text = animBase + theme.brown(DOTS[dotPhase] + DOTS_PAD.slice(DOTS[dotPhase].length)) + HINT;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
startAnimated(label);
|
|
115
|
+
},
|
|
83
116
|
// The text the user types while the agent is working.
|
|
84
117
|
// Empty string / null — restore the regular spinner.
|
|
85
118
|
setPending: (text) => {
|
|
@@ -106,7 +139,7 @@ export function createSpinner(locale = 'ru') {
|
|
|
106
139
|
const rendered = renderMarkdown(msg);
|
|
107
140
|
// Model answer marker: helps visually separate it from the user's input
|
|
108
141
|
// (which is highlighted by the prompt with a golden arrow).
|
|
109
|
-
console.log(NL + theme.assistant('
|
|
142
|
+
console.log(NL + theme.assistant(translate(locale)('editor.answer')) + NL);
|
|
110
143
|
console.log(rendered);
|
|
111
144
|
console.log(theme.dim('─'.repeat(60)) + NL);
|
|
112
145
|
},
|