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.
- package/CHANGELOG.md +73 -0
- package/README.md +111 -19
- package/dist/agent-loop.js +15 -5
- package/dist/browser-pure.js +6 -6
- package/dist/browser.js +5 -1
- package/dist/commands.js +7 -0
- package/dist/config.js +56 -1
- package/dist/i18n/config.js +14 -0
- package/dist/i18n/deepseek.js +26 -26
- package/dist/i18n/help.js +8 -0
- package/dist/i18n/qwen.js +85 -0
- package/dist/i18n.js +2 -0
- package/dist/index.js +226 -28
- package/dist/input.js +23 -0
- package/dist/provider-handoff.js +73 -0
- package/dist/providers.js +50 -0
- package/dist/qwen-browser.js +1668 -0
- package/dist/qwen-chats.js +183 -0
- package/dist/qwen-net-capture.js +221 -0
- package/dist/qwen-ui.js +160 -0
- package/dist/run-task.js +2 -1
- package/dist/sessions.js +15 -4
- package/dist/ui-health.js +27 -21
- package/package.json +7 -3
- package/scripts/coverage-gate.mts +5 -1
|
@@ -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
|
+
}
|
package/dist/qwen-ui.js
ADDED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|