zames_pro 2.72.9 → 2.73.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.
@@ -0,0 +1,183 @@
1
+ // PURE scraping/parsing helpers for the Qwen provider (chat.qwen.ai).
2
+ //
3
+ // WHY a separate module (mirroring browser-chats.ts for DeepSeek): the chat
4
+ // list and the restored dialogue are read from a page we do not control, so
5
+ // the scraping policy must have ONE home that can be unit-tested WITHOUT
6
+ // Playwright. These functions run INSIDE page.evaluate, so they must not close
7
+ // over module scope — they use only their arguments and DOM globals.
8
+ //
9
+ // Values verified against a live guest session:
10
+ // chat links -> a[href*="/c/"] (a guest chat is /c/guest, skipped)
11
+ // messages -> markdown/answer blocks inside message containers
12
+ // Pull the chat id out of a Qwen chat link. A guest chat (/c/guest) is not a
13
+ // real, resumable chat and is filtered by the caller.
14
+ const CHAT_HREF_RE = /\/c\/([A-Za-z0-9_-]+)/;
15
+ /**
16
+ * In-page scraper for the sidebar chat list. Returns the links' ids/titles in
17
+ * document order, de-duplicated, capped at `limit`. A guest session has no
18
+ * sidebar history, so the result is legitimately empty.
19
+ */
20
+ export function scrapeQwenChatList(limit) {
21
+ const out = [];
22
+ const seen = new Set();
23
+ const anchors = document.querySelectorAll('a[href*="/c/"], a[href*="/chat"]');
24
+ for (const a of Array.from(anchors)) {
25
+ const href = a.getAttribute('href') || '';
26
+ const m = href.match(CHAT_HREF_RE);
27
+ if (!m)
28
+ continue;
29
+ if (m[1] === 'guest')
30
+ continue;
31
+ if (seen.has(m[1]))
32
+ continue;
33
+ seen.add(m[1]);
34
+ const titleEl = a.querySelector('[class*="title"], [class*="name"]');
35
+ const title = ((titleEl ? titleEl.textContent : a.textContent) || '')
36
+ .trim()
37
+ .replace(/\s+/g, ' ');
38
+ out.push({ id: m[1], title });
39
+ if (out.length >= limit)
40
+ break;
41
+ }
42
+ return out;
43
+ }
44
+ /**
45
+ * In-page scraper for the WHOLE visible dialogue of the open chat. Nested
46
+ * duplicate containers are collapsed to the OUTERMOST one: the broad
47
+ * `[class*="message"]` chain matched both an outer wrapper and an inner block,
48
+ * which printed every turn TWICE (observed live: 'assistant:teal' twice).
49
+ * Reasoning blocks (think class, on the node or an ancestor) are skipped.
50
+ */
51
+ export function scrapeQwenChatMessages(opts) {
52
+ const thinkRe = new RegExp(opts.thinkRe, 'i');
53
+ const assistantRe = new RegExp(opts.assistantRe, 'i');
54
+ const inThink = (e) => {
55
+ let n = e;
56
+ while (n) {
57
+ if (thinkRe.test((n.className || '').toString()))
58
+ return true;
59
+ n = n.parentElement;
60
+ }
61
+ return false;
62
+ };
63
+ let containers = [];
64
+ for (const sel of opts.containerSels) {
65
+ const found = Array.from(document.querySelectorAll(sel)).filter((e) => !inThink(e) && (e.textContent || '').trim().length > 0);
66
+ if (found.length) {
67
+ containers = found;
68
+ break;
69
+ }
70
+ }
71
+ const uniq = Array.from(new Set(containers));
72
+ // Keep only the OUTERMOST block of a nested pair.
73
+ const blocks = uniq.filter((b) => !uniq.some((o) => o !== b && o.contains(b)));
74
+ const out = [];
75
+ for (const c of blocks) {
76
+ let node = c.querySelector('[class*="markdown"], [class*="answer"]');
77
+ const isAssistant = assistantRe.test(c.className || '') || !!node;
78
+ if (!node)
79
+ node = c;
80
+ const text = (node.textContent || '').trim();
81
+ if (!text)
82
+ continue;
83
+ out.push({ role: isAssistant ? 'assistant' : 'user', text });
84
+ }
85
+ return out;
86
+ }
87
+ // ---------- API payload parsers (pure, unit-tested) ----------
88
+ /**
89
+ * Parse the chat list from `GET /api/v2/chats/?page=1&exclude_project=true`.
90
+ * The shape has drifted between builds, so accept several:
91
+ * { data: [ {id,title} ] }, { data: { chats: [...] } }, { data: { list: [...] } }.
92
+ * Returns [] for anything unrecognizable (never throws).
93
+ */
94
+ export function parseQwenChatList(json) {
95
+ const root = json;
96
+ const data = root && typeof root === 'object' ? root.data : null;
97
+ const arr = Array.isArray(data)
98
+ ? data
99
+ : data && typeof data === 'object'
100
+ ? (data.chats ??
101
+ data.list ??
102
+ data.items)
103
+ : null;
104
+ if (!Array.isArray(arr))
105
+ return [];
106
+ const out = [];
107
+ for (const c of arr) {
108
+ if (!c || typeof c !== 'object')
109
+ continue;
110
+ const m = c;
111
+ const id = typeof m.id === 'string'
112
+ ? m.id
113
+ : typeof m.chat_id === 'string'
114
+ ? m.chat_id
115
+ : '';
116
+ if (!id)
117
+ continue;
118
+ const title = (typeof m.title === 'string' && m.title) ||
119
+ (typeof m.name === 'string' && m.name) ||
120
+ '';
121
+ out.push({ id, title: title.trim() });
122
+ }
123
+ return out;
124
+ }
125
+ /**
126
+ * Parse one chat's history from `GET /api/v2/chats/<id>?direction=up&limit=N`.
127
+ * Accepts the plausible shapes:
128
+ * { data: { messages: [...] } }, { data: { chat: { messages } } }, { data: [...] }.
129
+ * A message's text is taken from `content` (string) or from `fragments[]`
130
+ * (a DeepSeek-style payload: keep REQUEST for user, RESPONSE for assistant).
131
+ */
132
+ export function parseQwenHistory(json) {
133
+ const root = json;
134
+ const data = root && typeof root === 'object' ? root.data : null;
135
+ let arr = null;
136
+ if (Array.isArray(data))
137
+ arr = data;
138
+ else if (data && typeof data === 'object') {
139
+ const d = data;
140
+ if (Array.isArray(d.messages))
141
+ arr = d.messages;
142
+ else if (d.chat && typeof d.chat === 'object') {
143
+ const chat = d.chat;
144
+ if (Array.isArray(chat.messages))
145
+ arr = chat.messages;
146
+ }
147
+ }
148
+ if (!Array.isArray(arr))
149
+ return [];
150
+ const out = [];
151
+ for (const raw of arr) {
152
+ if (!raw || typeof raw !== 'object')
153
+ continue;
154
+ const m = raw;
155
+ // Role detection is DEFENSIVE: the field has been `role` (string,
156
+ // case-insensitive), a numeric flag, or `is_user`. Anything that is not
157
+ // clearly the user is treated as the assistant (the restored dialogue
158
+ // cares about the distinction, not the exact enum).
159
+ const roleStr = String(m.role ?? '').toLowerCase();
160
+ const isUser = roleStr === 'user' ||
161
+ roleStr === '0' ||
162
+ m.is_user === true ||
163
+ m.from === 'user';
164
+ const role = isUser ? 'user' : 'assistant';
165
+ let text = '';
166
+ if (typeof m.content === 'string')
167
+ text = m.content;
168
+ else if (typeof m.text === 'string')
169
+ text = m.text;
170
+ else if (Array.isArray(m.fragments)) {
171
+ const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
172
+ for (const fr of m.fragments) {
173
+ if (fr && fr.type === want && typeof fr.content === 'string') {
174
+ text += fr.content;
175
+ }
176
+ }
177
+ }
178
+ text = text.trim();
179
+ if (text)
180
+ out.push({ role, text });
181
+ }
182
+ return out;
183
+ }
@@ -0,0 +1,221 @@
1
+ // Extraction of the RAW model answer from Qwen Chat's (chat.qwen.ai) network
2
+ // data. The DOM distorts the answer (markdown/LaTeX/link auto-detection), so
3
+ // — exactly like the DeepSeek path (net-capture.ts) — the answer the agent
4
+ // consumes is parsed from the intercepted SSE stream instead of the rendered
5
+ // page.
6
+ //
7
+ // Qwen's completions stream (POST /api/v2/chat/completions?chat_id=...):
8
+ // data: {"response.created": {chat_id, response_id, ...}}
9
+ // data: {"response.info": {"action": "keep_alive", ...}}
10
+ // data: {"choices":[{"delta":{"role":"assistant","content":"",
11
+ // "phase":"thinking_summary","extra":{...}},
12
+ // "status":"typing"}], "usage":{...}}
13
+ // data: {"choices":[{"delta":{"role":"assistant","content":"PONG",
14
+ // "phase":"answer","status":"typing"}}], ...}
15
+ // data: {"choices":[{"delta":{"content":"","role":"assistant",
16
+ // "status":"finished","phase":"answer"}}]}
17
+ //
18
+ // The answer text is the concatenation of delta.content chunks whose
19
+ // `phase` is NOT the reasoning phase (`thinking_summary`). Reasoning must
20
+ // never leak into the answer (same rule as DeepSeek's THINK fragments).
21
+ //
22
+ // `usage` carries input_tokens/output_tokens/total_tokens — the closest thing
23
+ // Qwen exposes to "used context" (there is no accumulated_token_usage).
24
+ const NL = String.fromCharCode(10);
25
+ function parseDataLines(body) {
26
+ const out = [];
27
+ for (const line of String(body || '').split(NL)) {
28
+ const trimmed = line.trim();
29
+ if (!trimmed.startsWith('data:'))
30
+ continue;
31
+ const payload = trimmed.slice(5).trim();
32
+ if (!payload || payload === '[DONE]')
33
+ continue;
34
+ try {
35
+ const obj = JSON.parse(payload);
36
+ if (obj && typeof obj === 'object')
37
+ out.push(obj);
38
+ }
39
+ catch { }
40
+ }
41
+ return out;
42
+ }
43
+ // True when a delta chunk is part of the reasoning (thinking) phase, which is
44
+ // NOT the answer.
45
+ function isThinkingPhase(delta) {
46
+ const phase = delta.phase;
47
+ return typeof phase === 'string' && /think/i.test(phase);
48
+ }
49
+ // Assembles the answer text from the Qwen SSE stream. Also accepts an
50
+ // OpenAI-compatible shape (choices[].delta.content) as a safety net, and
51
+ // ignores reasoning_content there as well.
52
+ export function extractFromSse(body) {
53
+ let out = '';
54
+ for (const obj of parseDataLines(body)) {
55
+ const choices = obj.choices;
56
+ if (!Array.isArray(choices))
57
+ continue;
58
+ for (const ch of choices) {
59
+ if (!ch || typeof ch !== 'object')
60
+ continue;
61
+ const c = ch;
62
+ const delta = c.delta && typeof c.delta === 'object'
63
+ ? c.delta
64
+ : c.message && typeof c.message === 'object'
65
+ ? c.message
66
+ : null;
67
+ if (!delta)
68
+ continue;
69
+ if (isThinkingPhase(delta))
70
+ continue;
71
+ const content = delta.content;
72
+ if (typeof content === 'string' && content)
73
+ out += content;
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ // A plain JSON body (a non-streamed response) — pick the first string content
79
+ // that is not reasoning. Best-effort; the live stream is SSE.
80
+ export function extractFromJson(body) {
81
+ let obj;
82
+ try {
83
+ obj = JSON.parse(body);
84
+ }
85
+ catch {
86
+ return '';
87
+ }
88
+ return pickAnswer(obj);
89
+ }
90
+ function pickAnswer(node) {
91
+ if (node == null)
92
+ return '';
93
+ if (typeof node === 'string')
94
+ return node;
95
+ if (Array.isArray(node))
96
+ return node.map(pickAnswer).join('');
97
+ if (typeof node !== 'object')
98
+ return '';
99
+ const obj = node;
100
+ if (Array.isArray(obj.choices)) {
101
+ let out = '';
102
+ for (const ch of obj.choices) {
103
+ if (!ch || typeof ch !== 'object')
104
+ continue;
105
+ const c = ch;
106
+ const delta = c.delta && typeof c.delta === 'object'
107
+ ? c.delta
108
+ : c.message && typeof c.message === 'object'
109
+ ? c.message
110
+ : c;
111
+ if (isThinkingPhase(delta))
112
+ continue;
113
+ const content = delta.content;
114
+ if (typeof content === 'string')
115
+ out += content;
116
+ }
117
+ if (out)
118
+ return out;
119
+ }
120
+ for (const key of ['content', 'text', 'answer']) {
121
+ const v = obj[key];
122
+ if (typeof v === 'string' && v)
123
+ return v;
124
+ }
125
+ return '';
126
+ }
127
+ // A universal attempt: first SSE, then regular JSON.
128
+ export function extractAnswer(body) {
129
+ const sse = extractFromSse(body);
130
+ if (sse)
131
+ return sse;
132
+ return extractFromJson(body);
133
+ }
134
+ // The CONTEXT size (in tokens) Qwen reports in the `usage` object of the SSE
135
+ // stream. `input_tokens` is the PROMPT size for the turn — the best match for
136
+ // "how much context is used", the same idea as DeepSeek's
137
+ // accumulated_token_usage. (`total_tokens` = prompt + answer, so it inflates
138
+ // the number by the answer length; it is only the fallback when input_tokens
139
+ // is absent.) Null when the body carries no usage.
140
+ export function extractTokenUsage(body) {
141
+ let lastInput = null;
142
+ let lastTotal = null;
143
+ for (const obj of parseDataLines(body)) {
144
+ const usage = obj.usage;
145
+ if (!usage || typeof usage !== 'object')
146
+ continue;
147
+ const u = usage;
148
+ if (typeof u.input_tokens === 'number' && Number.isFinite(u.input_tokens))
149
+ lastInput = u.input_tokens;
150
+ if (typeof u.total_tokens === 'number' && Number.isFinite(u.total_tokens))
151
+ lastTotal = u.total_tokens;
152
+ }
153
+ if (lastInput !== null)
154
+ return lastInput;
155
+ if (lastTotal !== null)
156
+ return lastTotal;
157
+ // Plain JSON fallback (a non-streamed body).
158
+ try {
159
+ const obj = JSON.parse(body);
160
+ const usage = obj?.usage;
161
+ if (usage) {
162
+ if (typeof usage.input_tokens === 'number')
163
+ return usage.input_tokens;
164
+ if (typeof usage.total_tokens === 'number')
165
+ return usage.total_tokens;
166
+ }
167
+ }
168
+ catch { }
169
+ return null;
170
+ }
171
+ // True when the stream ENDED (a chunk with status "finished") but produced NO
172
+ // answer content — the model finished with only a reasoning summary. The
173
+ // caller then retries instead of waiting out the whole timeout. Mirrors
174
+ // DeepSeek's isFinishedWithoutAnswer.
175
+ export function isFinishedWithoutAnswer(body) {
176
+ const b = String(body || '');
177
+ if (!b)
178
+ return false;
179
+ let finished = false;
180
+ for (const obj of parseDataLines(b)) {
181
+ const choices = obj.choices;
182
+ if (!Array.isArray(choices))
183
+ continue;
184
+ for (const ch of choices) {
185
+ if (!ch || typeof ch !== 'object')
186
+ continue;
187
+ const delta = ch.delta;
188
+ if (delta && delta.status === 'finished')
189
+ finished = true;
190
+ }
191
+ }
192
+ if (!finished)
193
+ return false;
194
+ return extractFromSse(b) === '';
195
+ }
196
+ // True when the body carries Qwen's rate-limit / busy notice, so ask() can
197
+ // back off instead of waiting out the deadline. Matched against the RAW body.
198
+ export function isRateLimitText(body) {
199
+ const b = String(body || '');
200
+ if (!b)
201
+ return false;
202
+ return /too many requests|rate limit|too frequent|please try again later|слишком часто/i.test(b);
203
+ }
204
+ // The GUEST daily limit is a DIFFERENT failure from a transient rate limit:
205
+ // Qwen answers `RateLimited` with "You've reached the guest chat limit for
206
+ // today's usage. Log in to continue." Retrying after `rateLimitWaitMs` cannot
207
+ // help (it resets the next day); the operator must sign in. Detected from the
208
+ // raw body so ask() can fail fast with a clear "log in" message instead of
209
+ // burning the whole retry budget.
210
+ export function isGuestLimitText(body) {
211
+ const b = String(body || '');
212
+ if (!b)
213
+ return false;
214
+ return /guest chat limit|guest.*limit|log in to continue|гостевой лимит/i.test(b);
215
+ }
216
+ export function isServerBusyText(body) {
217
+ const b = String(body || '');
218
+ if (!b)
219
+ return false;
220
+ return /server is busy|service unavailable|try again later/i.test(b);
221
+ }
@@ -0,0 +1,160 @@
1
+ // Qwen Chat (chat.qwen.ai) UI constants — the SINGLE place where the Qwen
2
+ // web app's specifics live.
3
+ //
4
+ // WHY this mirrors deepseek-ui.ts: zames drives the site through Playwright,
5
+ // so every selector and protocol marker is a hardcoded assumption about a
6
+ // page we do not control. Concentrating them here turns "Qwen redesigned the
7
+ // UI" into ONE file to audit and lets probeUiHealth report the exact broken
8
+ // capability BEFORE a task hangs on it.
9
+ //
10
+ // Values verified against a live guest session (headless Chromium):
11
+ // input -> textarea.message-input-textarea (placeholder "Ask Qwen")
12
+ // send -> button.send-button (aria-label "Send")
13
+ // model -> .wms-trigger (aria-label "Select Model")
14
+ // new -> .new-chat (aria-label "New Chat")
15
+ // The API is a two-step v2 flow: POST /api/v2/chats/new -> { id }, then
16
+ // POST /api/v2/chat/completions?chat_id=<id> (SSE, see qwen-net-capture.ts).
17
+ // ---------- endpoints ----------
18
+ export const CHAT_URL = 'https://chat.qwen.ai/';
19
+ // A logged-in chat lives at /c/<id>; a guest chat is /c/guest.
20
+ export const CHAT_ID_RE = /\/c\/([A-Za-z0-9_-]+)/;
21
+ export function chatUrl(id) {
22
+ return CHAT_URL + 'c/' + id;
23
+ }
24
+ // The two-step API. `NEW_CHAT_URL` creates a chat and returns its id;
25
+ // `COMPLETIONS_URL(id)` streams the answer.
26
+ export const NEW_CHAT_URL = 'https://chat.qwen.ai/api/v2/chats/new';
27
+ export const MODELS_URL = 'https://chat.qwen.ai/api/v2/models/';
28
+ // The chat list, newest first, EXCLUDING project chats (verified live on a
29
+ // signed-in session). Used by listChats() instead of scraping the sidebar.
30
+ export const CHAT_LIST_URL = 'https://chat.qwen.ai/api/v2/chats/?page=1&exclude_project=true';
31
+ // One chat's messages: GET /api/v2/chats/<id>?direction=up&limit=N. Verified
32
+ // live while switching chats. Used by fetchChatMessages().
33
+ export function chatHistoryUrl(chatId, limit = 50) {
34
+ return ('https://chat.qwen.ai/api/v2/chats/' +
35
+ encodeURIComponent(chatId) +
36
+ '?direction=up&limit=' +
37
+ String(limit));
38
+ }
39
+ export function completionsUrl(chatId) {
40
+ return ('https://chat.qwen.ai/api/v2/chat/completions?chat_id=' +
41
+ encodeURIComponent(chatId));
42
+ }
43
+ // The completions SSE endpoint carries the real answer; used to gate the
44
+ // network capture so an unrelated JSON endpoint never clobbers it.
45
+ export const ANSWER_URL_RE = /\/api\/v2\/chat\/completions/i;
46
+ // ---------- input ----------
47
+ export const INPUT_SELECTORS = [
48
+ 'textarea.message-input-textarea',
49
+ 'textarea[placeholder="Ask Qwen"]',
50
+ 'textarea',
51
+ 'div[contenteditable="true"]',
52
+ '[role="textbox"]',
53
+ ];
54
+ // ---------- send ----------
55
+ // The send control is a <button class="send-button"> with aria-label "Send".
56
+ // A generic primary button is deliberately NOT matched: it could be the Stop
57
+ // control, which would make _isGenerating() always true.
58
+ export const SEND_SELECTORS = [
59
+ 'button.send-button',
60
+ 'button[aria-label="Send" i]',
61
+ '.message-input-right-button-send button',
62
+ 'button[type="submit"]',
63
+ ];
64
+ // ---------- stop ----------
65
+ export const STOP_SELECTORS = [
66
+ 'button[aria-label*="stop" i]',
67
+ 'button[aria-label*="останов" i]',
68
+ 'button.stop-button',
69
+ 'button:has-text("Stop")',
70
+ ];
71
+ export const STOP_NAME_RE = /^(?:stop|остановить)(?:\s+\S{0,20})?\s*[.!…]?$/i;
72
+ // ---------- new chat ----------
73
+ export const NEW_CHAT_SELECTOR = '.new-chat, button[aria-label="New Chat" i]';
74
+ // ---------- model selector ----------
75
+ // The current model label sits in the header. It is read-only for the UI; the
76
+ // model itself is chosen via the config (sent with each completion request)
77
+ // unless a per-session override is set.
78
+ export const MODEL_TRIGGER_SELECTOR = '.wms-trigger';
79
+ // ---------- age gate / cookies ----------
80
+ // A first-time visitor must confirm their birth year before chatting. The
81
+ // dialog is an ant-modal; the Continue button is the black footer button.
82
+ export const AGE_GATE_MODAL = '.age-confirmation-modal';
83
+ export const AGE_GATE_CONTINUE = '.age-confirmation-modal .qwen-chat-v2-btn-black';
84
+ // The cookie banner's accept button (matched by its visible label).
85
+ export const COOKIE_ACCEPT_RE = /^accept all cookies$/i;
86
+ // ---------- messages / answers ----------
87
+ // Assistant answers render as markdown blocks. The class names drift between
88
+ // builds, so a content wrapper is tried first, then the generic markdown.
89
+ export const ANSWER_SELECTORS = [
90
+ '[class*="markdown-content"]',
91
+ '[class*="markdown"]',
92
+ '[class*="answer"]',
93
+ ];
94
+ // Reasoning / thinking summaries are NOT the answer and must never be picked
95
+ // up as one (the terminal would show the model's long reasoning).
96
+ export const THINK_CLASS_RE = /thinking|think-content|think_summary/i;
97
+ // Message-level containers for the restored dialogue (broad first).
98
+ export const MESSAGE_CONTAINER_SELECTORS = [
99
+ '[class*="chat-message"]',
100
+ '[class*="message-item"]',
101
+ '[data-message-id]',
102
+ '[class*="message"]',
103
+ ];
104
+ export const ASSISTANT_CLASS_RE = /assistant|answer|markdown/i;
105
+ // A cheap "did the page grow?" signal: count message nodes and measure the
106
+ // last answer node's text length (no full-body innerText walk).
107
+ export const CHAT_SIGNAL_NODES_SELECTOR = '[class*="markdown"], [class*="chat-message"], [class*="message-item"]';
108
+ export const MARKDOWN_SELECTOR = '[class*="markdown"]';
109
+ // ---------- attachments ----------
110
+ // The attach flow on Qwen: click the "+" (`.mode-select-open`) to open the
111
+ // mode menu, then the "Upload attachment" item — that click fires a real
112
+ // filechooser. The hidden <input id="filesUpload"> is the fallback (setting
113
+ // files on it directly).
114
+ export const ATTACH_MENU_TRIGGER = '.mode-select-open';
115
+ export const ATTACH_MENU_ITEM_RE = /upload attachment/i;
116
+ export const ATTACH_SELECTORS = ['input#filesUpload', 'input[type="file"]'];
117
+ // ---------- login ----------
118
+ // Qwen's sign-in is a SEPARATE origin (auth.qwen.ai) opened from the header
119
+ // "Log in" button. Email / Google / GitHub are accepted; no phone required.
120
+ export const LOGIN_BUTTON_RE = /^\s*(log ?in|sign ?in|войти)\s*$/i;
121
+ export const PASSWORD_SELECTORS = [
122
+ 'input[type="password"]',
123
+ 'input[autocomplete="current-password"]',
124
+ ];
125
+ export const LOGIN_SELECTORS = [
126
+ 'input[type="email"]',
127
+ 'input[name="email"]',
128
+ 'input[placeholder*="email" i]',
129
+ 'input[autocomplete="email"]',
130
+ 'input[autocomplete="username"]',
131
+ ];
132
+ // ---------- service statuses / errors ----------
133
+ // A generic "generating" label that must never be taken for an answer.
134
+ export const STATUS_RE = /^(thinking|searching|generating|thinking completed|остановить|stop)[\s.…]*$/i;
135
+ // Qwen's rate-limit / busy notices (best-effort; the toast may be short-lived).
136
+ export const RATE_LIMIT_RE = /too many requests|rate limit|too frequent|please try again later|слишком часто/i;
137
+ export const SERVER_BUSY_RE = /server is busy|service unavailable|try again later/i;
138
+ // ---------- toggles ----------
139
+ // The "Auto" thinking/search toggle and the mode selector. Read-only probing
140
+ // support; the model's thinking mode is set via config when supported.
141
+ // ---------- toggles (thinking mode / web search) ----------
142
+ // Qwen exposes thinking as a MODE dropdown (`.qwen-thinking-selector`, label
143
+ // "Auto" by default) with options Auto / Thinking / Fast — NOT a boolean
144
+ // toggle like DeepSeek. Web search is an item in the "+" (mode) menu. Both
145
+ // are applied best-effort before a send.
146
+ export const THINKING_SELECTOR = '.qwen-thinking-selector';
147
+ // Menu options that select a thinking mode (matched by normalized label).
148
+ export const THINKING_MODE_RE = /thinking|размышл/i;
149
+ export const FAST_MODE_RE = /fast|quick|быстр/i;
150
+ // The "+" (mode) menu item that enables web search.
151
+ export const WEB_SEARCH_MENU_RE = /web search|поиск в интернет|веб-поиск/i;
152
+ // Advisory probe selectors (the mode button is always present).
153
+ export const TOGGLE_SELECTORS = [
154
+ THINKING_SELECTOR,
155
+ '.mode-select-open',
156
+ '[role="switch"]',
157
+ 'div[role="button"][aria-pressed]',
158
+ ];
159
+ export const DEEP_THINKING_RE = /think|reason|размышл/i;
160
+ export const WEB_SEARCH_RE = /search|поиск/i;
package/dist/run-task.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { theme } from './theme.js';
2
2
  import { withGoal, mergeMessages, isSlashCommand, formatDuration, } from './commands.js';
3
3
  import { formatCompactTokens, pasteReplacement, expandPastes } from './input.js';
4
+ import { providerAskDeadlineMs } from './config.js';
4
5
  // Ask the operator to approve a tool call flagged by an `ask` permission rule
5
6
  // (C1). While the LineEditor owns the terminal it must be paused first, or the
6
7
  // confirmation prompt and the input line fight for the same keys. A non-TTY run
@@ -349,7 +350,7 @@ export async function runTask(deps, browser, tools, taskText, workdir, opts, att
349
350
  onChatReady,
350
351
  debugLog: debug,
351
352
  locale,
352
- askDeadlineMs: config.browser.askDeadlineMs,
353
+ askDeadlineMs: providerAskDeadlineMs(config),
353
354
  maxAfterToolRetries: config.browser.maxAfterToolRetries,
354
355
  // Auto-compact between tool calls when the context nears the window
355
356
  // limit. The callback is provided by the caller so it can refresh the
package/dist/sessions.js CHANGED
@@ -51,7 +51,7 @@ function safeName(id) {
51
51
  }
52
52
  // Save/update a session. workdir is needed so that when launched from the same
53
53
  // project we restore exactly its last chat.
54
- export function saveSession({ id, title = '', workdir = '', todos, }) {
54
+ export function saveSession({ id, title = '', workdir = '', todos, provider, }) {
55
55
  if (!id)
56
56
  return null;
57
57
  try {
@@ -70,6 +70,7 @@ export function saveSession({ id, title = '', workdir = '', todos, }) {
70
70
  createdAt: prev.createdAt || now,
71
71
  updatedAt: now,
72
72
  version: SESSIONS_FORMAT_VERSION,
73
+ provider: provider || prev.provider || 'deepseek',
73
74
  };
74
75
  // Keep the previous checklist unless a new one is explicitly passed —
75
76
  // saveLastChat() calls this for title/workdir only and must not wipe it.
@@ -107,7 +108,12 @@ function writeIndex({ id, workdir }) {
107
108
  }
108
109
  // The last session for the working directory. If there is none —
109
110
  // we return the last session overall (useful when launched from a new place).
110
- export function loadLastSession(workdir = '') {
111
+ // The last session for the working directory, restricted to `provider` when
112
+ // given. A chat id only exists on ITS backend, so restoring a DeepSeek chat
113
+ // while the active provider is Qwen (or vice versa) would fail to open — the
114
+ // filter makes auto-restore and /last pick the right one (and fall back to the
115
+ // global last only within the same provider).
116
+ export function loadLastSession(workdir = '', provider) {
111
117
  try {
112
118
  const index = JSON.parse(fs.readFileSync(INDEX_FILE, 'utf-8'));
113
119
  const byWorkdir = index && index.byWorkdir ? index.byWorkdir : {};
@@ -119,8 +125,13 @@ export function loadLastSession(workdir = '') {
119
125
  if (internal.has(id))
120
126
  continue;
121
127
  const s = readSession(id);
122
- if (s)
123
- return s;
128
+ if (!s)
129
+ continue;
130
+ // Older files have no provider: treat them as deepseek (the only backend
131
+ // that existed when they were written).
132
+ if (provider && (s.provider || 'deepseek') !== provider)
133
+ continue;
134
+ return s;
124
135
  }
125
136
  return null;
126
137
  }