webaiapi 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +184 -2
- package/bin/webapi.mjs +45 -0
- package/dist/browser/auth-capture.d.ts +110 -0
- package/dist/browser/auth-capture.d.ts.map +1 -0
- package/dist/browser/auth-capture.js +487 -0
- package/dist/browser/auth-capture.js.map +1 -0
- package/dist/browser/credentials.d.ts +102 -0
- package/dist/browser/credentials.d.ts.map +1 -0
- package/dist/browser/credentials.js +209 -0
- package/dist/browser/credentials.js.map +1 -0
- package/dist/browser/js-eval.d.ts +44 -0
- package/dist/browser/js-eval.d.ts.map +1 -0
- package/dist/browser/js-eval.js +20 -0
- package/dist/browser/js-eval.js.map +1 -0
- package/dist/browser/login-flow.d.ts +171 -0
- package/dist/browser/login-flow.d.ts.map +1 -0
- package/dist/browser/login-flow.js +606 -0
- package/dist/browser/login-flow.js.map +1 -0
- package/dist/browser/manager.d.ts +144 -0
- package/dist/browser/manager.d.ts.map +1 -0
- package/dist/browser/manager.js +586 -0
- package/dist/browser/manager.js.map +1 -0
- package/dist/browser/platforms.d.ts +96 -0
- package/dist/browser/platforms.d.ts.map +1 -0
- package/dist/browser/platforms.js +288 -0
- package/dist/browser/platforms.js.map +1 -0
- package/dist/browser/sse-capture.d.ts +23 -0
- package/dist/browser/sse-capture.d.ts.map +1 -0
- package/dist/browser/sse-capture.js +199 -0
- package/dist/browser/sse-capture.js.map +1 -0
- package/dist/browser/stealth.d.ts +12 -0
- package/dist/browser/stealth.d.ts.map +1 -0
- package/dist/browser/stealth.js +12 -0
- package/dist/browser/stealth.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +441 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +90 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +246 -0
- package/dist/config.js.map +1 -0
- package/dist/controllers/history-db.d.ts +56 -0
- package/dist/controllers/history-db.d.ts.map +1 -0
- package/dist/controllers/history-db.js +260 -0
- package/dist/controllers/history-db.js.map +1 -0
- package/dist/controllers/request-pool.d.ts +78 -0
- package/dist/controllers/request-pool.d.ts.map +1 -0
- package/dist/controllers/request-pool.js +222 -0
- package/dist/controllers/request-pool.js.map +1 -0
- package/dist/controllers/router.d.ts +172 -0
- package/dist/controllers/router.d.ts.map +1 -0
- package/dist/controllers/router.js +569 -0
- package/dist/controllers/router.js.map +1 -0
- package/dist/index.d.ts +72 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/log/api-logger.d.ts +55 -0
- package/dist/log/api-logger.d.ts.map +1 -0
- package/dist/log/api-logger.js +264 -0
- package/dist/log/api-logger.js.map +1 -0
- package/dist/log/debug-logger.d.ts +49 -0
- package/dist/log/debug-logger.d.ts.map +1 -0
- package/dist/log/debug-logger.js +285 -0
- package/dist/log/debug-logger.js.map +1 -0
- package/dist/log/logger.d.ts +16 -0
- package/dist/log/logger.d.ts.map +1 -0
- package/dist/log/logger.js +35 -0
- package/dist/log/logger.js.map +1 -0
- package/dist/postinstall.d.ts +3 -0
- package/dist/postinstall.d.ts.map +1 -0
- package/dist/postinstall.js +90 -0
- package/dist/postinstall.js.map +1 -0
- package/dist/providers/base.d.ts +83 -0
- package/dist/providers/base.d.ts.map +1 -0
- package/dist/providers/base.js +51 -0
- package/dist/providers/base.js.map +1 -0
- package/dist/providers/chatglm-dom-extract.d.ts +24 -0
- package/dist/providers/chatglm-dom-extract.d.ts.map +1 -0
- package/dist/providers/chatglm-dom-extract.js +175 -0
- package/dist/providers/chatglm-dom-extract.js.map +1 -0
- package/dist/providers/chatglm-dom.d.ts +110 -0
- package/dist/providers/chatglm-dom.d.ts.map +1 -0
- package/dist/providers/chatglm-dom.js +384 -0
- package/dist/providers/chatglm-dom.js.map +1 -0
- package/dist/providers/chatglm-sse.d.ts +119 -0
- package/dist/providers/chatglm-sse.d.ts.map +1 -0
- package/dist/providers/chatglm-sse.js +241 -0
- package/dist/providers/chatglm-sse.js.map +1 -0
- package/dist/providers/chatglm.d.ts +288 -0
- package/dist/providers/chatglm.d.ts.map +1 -0
- package/dist/providers/chatglm.js +1631 -0
- package/dist/providers/chatglm.js.map +1 -0
- package/dist/routes/chat.d.ts +117 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +1346 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/compress.d.ts +142 -0
- package/dist/routes/compress.d.ts.map +1 -0
- package/dist/routes/compress.js +551 -0
- package/dist/routes/compress.js.map +1 -0
- package/dist/routes/failover.d.ts +34 -0
- package/dist/routes/failover.d.ts.map +1 -0
- package/dist/routes/failover.js +221 -0
- package/dist/routes/failover.js.map +1 -0
- package/dist/routes/helpers.d.ts +174 -0
- package/dist/routes/helpers.d.ts.map +1 -0
- package/dist/routes/helpers.js +525 -0
- package/dist/routes/helpers.js.map +1 -0
- package/dist/routes/meta.d.ts +31 -0
- package/dist/routes/meta.d.ts.map +1 -0
- package/dist/routes/meta.js +184 -0
- package/dist/routes/meta.js.map +1 -0
- package/dist/routes/sse.d.ts +82 -0
- package/dist/routes/sse.d.ts.map +1 -0
- package/dist/routes/sse.js +77 -0
- package/dist/routes/sse.js.map +1 -0
- package/dist/server/app.d.ts +24 -0
- package/dist/server/app.d.ts.map +1 -0
- package/dist/server/app.js +292 -0
- package/dist/server/app.js.map +1 -0
- package/dist/server/middleware/api-key.d.ts +61 -0
- package/dist/server/middleware/api-key.d.ts.map +1 -0
- package/dist/server/middleware/api-key.js +95 -0
- package/dist/server/middleware/api-key.js.map +1 -0
- package/dist/server/middleware/rate-limit.d.ts +67 -0
- package/dist/server/middleware/rate-limit.d.ts.map +1 -0
- package/dist/server/middleware/rate-limit.js +134 -0
- package/dist/server/middleware/rate-limit.js.map +1 -0
- package/dist/server/middleware/timeout.d.ts +34 -0
- package/dist/server/middleware/timeout.d.ts.map +1 -0
- package/dist/server/middleware/timeout.js +64 -0
- package/dist/server/middleware/timeout.js.map +1 -0
- package/dist/server/types.d.ts +20 -0
- package/dist/server/types.d.ts.map +1 -0
- package/dist/server/types.js +2 -0
- package/dist/server/types.js.map +1 -0
- package/dist/tools/build.d.ts +14 -0
- package/dist/tools/build.d.ts.map +1 -0
- package/dist/tools/build.js +105 -0
- package/dist/tools/build.js.map +1 -0
- package/dist/tools/clean.d.ts +32 -0
- package/dist/tools/clean.d.ts.map +1 -0
- package/dist/tools/clean.js +65 -0
- package/dist/tools/clean.js.map +1 -0
- package/dist/tools/convert.d.ts +50 -0
- package/dist/tools/convert.d.ts.map +1 -0
- package/dist/tools/convert.js +289 -0
- package/dist/tools/convert.js.map +1 -0
- package/dist/tools/names.d.ts +138 -0
- package/dist/tools/names.d.ts.map +1 -0
- package/dist/tools/names.js +578 -0
- package/dist/tools/names.js.map +1 -0
- package/dist/tools/parse.d.ts +31 -0
- package/dist/tools/parse.d.ts.map +1 -0
- package/dist/tools/parse.js +273 -0
- package/dist/tools/parse.js.map +1 -0
- package/dist/tools/placeholder.d.ts +21 -0
- package/dist/tools/placeholder.d.ts.map +1 -0
- package/dist/tools/placeholder.js +135 -0
- package/dist/tools/placeholder.js.map +1 -0
- package/dist/tools/prompts.d.ts +42 -0
- package/dist/tools/prompts.d.ts.map +1 -0
- package/dist/tools/prompts.js +69 -0
- package/dist/tools/prompts.js.map +1 -0
- package/dist/tools/protocol.d.ts +58 -0
- package/dist/tools/protocol.d.ts.map +1 -0
- package/dist/tools/protocol.js +287 -0
- package/dist/tools/protocol.js.map +1 -0
- package/dist/tools/repair.d.ts +83 -0
- package/dist/tools/repair.d.ts.map +1 -0
- package/dist/tools/repair.js +510 -0
- package/dist/tools/repair.js.map +1 -0
- package/dist/tools/workspace.d.ts +40 -0
- package/dist/tools/workspace.d.ts.map +1 -0
- package/dist/tools/workspace.js +224 -0
- package/dist/tools/workspace.js.map +1 -0
- package/dist/types/message.d.ts +87 -0
- package/dist/types/message.d.ts.map +1 -0
- package/dist/types/message.js +85 -0
- package/dist/types/message.js.map +1 -0
- package/dist/types/openai.d.ts +100 -0
- package/dist/types/openai.d.ts.map +1 -0
- package/dist/types/openai.js +66 -0
- package/dist/types/openai.js.map +1 -0
- package/dist/utils/errors.d.ts +82 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +115 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/mutex.d.ts +31 -0
- package/dist/utils/mutex.d.ts.map +1 -0
- package/dist/utils/mutex.js +61 -0
- package/dist/utils/mutex.js.map +1 -0
- package/dist/utils/prompt.d.ts +150 -0
- package/dist/utils/prompt.d.ts.map +1 -0
- package/dist/utils/prompt.js +514 -0
- package/dist/utils/prompt.js.map +1 -0
- package/dist/utils/text.d.ts +30 -0
- package/dist/utils/text.d.ts.map +1 -0
- package/dist/utils/text.js +70 -0
- package/dist/utils/text.js.map +1 -0
- package/package.json +67 -4
- package/postinstall.mjs +34 -0
|
@@ -0,0 +1,1631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGLM 浏览器自动化 Provider —— `legacy-python/src/providers/chatglm_browser.py`
|
|
3
|
+
* (1,951 行) 的 TypeScript 移植。
|
|
4
|
+
*
|
|
5
|
+
* 职责:用 Playwright Firefox 操作 chatglm.cn,把网页对话包装成 `ChatResponse` 流。
|
|
6
|
+
*
|
|
7
|
+
* ## 与原实现的差异
|
|
8
|
+
* - **页面侧脚本外移**到 `chatglm-dom.ts` / `chatglm-dom-extract.ts`,本文件只剩控制流。
|
|
9
|
+
* - **SSE 解析外移**到 `chatglm-sse.ts`(纯函数、可单测)。
|
|
10
|
+
* - `asyncio.Lock` → {@link Mutex};`chat_stream` 的 `async with` 改为
|
|
11
|
+
* `acquire()/release()`(`finally` 里释放,语义等价)。
|
|
12
|
+
* - 模块级 `_last_raw_sse_*` 全局 → `chatglm-sse.ts` 里的 `setLastRawSse`(语义不变)。
|
|
13
|
+
* - `import time` → `Date.now()`(毫秒)。
|
|
14
|
+
*
|
|
15
|
+
* ## ⚠️ 本文件里几乎每段注释都是真实事故换来的,删改前请先读
|
|
16
|
+
* `docs/hard-constraints.md`。
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { accountCooldownRemaining, clearAccountRole, enterAccount, exitAccount, getBrowser, getChannelPage, injectResponseJs, invalidateBrowser, isAccountBusy, loadCookies, markAccountCooldown, navigateTo, newWorkPage, peekPage, setAccountRole, transferAccountBusy, } from '../browser/manager.js';
|
|
21
|
+
import { listAccounts, loadAuth } from '../browser/credentials.js';
|
|
22
|
+
import { iife } from '../browser/js-eval.js';
|
|
23
|
+
import { logger } from '../log/logger.js';
|
|
24
|
+
import { Mutex } from '../utils/mutex.js';
|
|
25
|
+
import { sanitizeMessage, topLevelJsonFields } from '../utils/text.js';
|
|
26
|
+
import { BaseProvider } from './base.js';
|
|
27
|
+
import { APPLY_CHAT_PREFS_JS, BUSY_NOTICE_DIAG_JS, CLEAR_INPUT_JS, COUNT_CONVERSATION_ITEMS_JS, DETECT_COMPLETION_JS, DETECT_THINKING_JS, DISMISS_OVERLAY_BLOCKING_JS, DISMISS_OVERLAY_CLOSE_JS, DISMISS_OVERLAY_FALLBACK_JS, HAS_INPUT_BOX_JS, INPUT_SELECTORS, INPUT_VALUE_LEN_JS, IS_INPUT_CLEARED_JS, PAGE_SHOWS_BUSY_JS, PROBE_CHAT_TOGGLES_JS, PROBE_LOGGED_IN_JS, READ_BUSY_NOTICE_JS, REQUEST_BODIES_GET_JS, RESET_SSE_JS, SEND_BUTTON_SELECTORS, SSE_GET_JS, ZERO_OUTPUT_DIAG_JS, } from './chatglm-dom.js';
|
|
28
|
+
import { EXTRACT_RESPONSE_JS, FORCED_EXTRACT_JS } from './chatglm-dom-extract.js';
|
|
29
|
+
import { extractSseAll, getLastRawSse, isBrowserConnectionError, logSseSummary, safeIncremental, setLastRawSse, } from './chatglm-sse.js';
|
|
30
|
+
/** 平台名(也用作账号状态的命名空间)。 */
|
|
31
|
+
const PLATFORM = 'chatglm';
|
|
32
|
+
/** 聊天页 URL(会 302 到 `/main/alltoolsdetail`,那也是聊天页)。 */
|
|
33
|
+
const CHAT_URL = 'https://chatglm.cn/main/chat?lang=zh';
|
|
34
|
+
/** 未登录时页面上的标识词(侧边栏显示「登录 / 登录送积分好礼」,访客显示「访客_xxxx」)。 */
|
|
35
|
+
const NOT_LOGGED_MARKERS = ['访客', '登录', '注册', '未登录'];
|
|
36
|
+
/** 未登录页面上的其它特征串。 */
|
|
37
|
+
const NOT_LOGGED_SUBSTRINGS = ['登录送积分', '立即登录', '未登录', '登录/注册'];
|
|
38
|
+
/** 毫秒级等待(替代 `asyncio.sleep`)。 */
|
|
39
|
+
function sleep(ms) {
|
|
40
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
41
|
+
}
|
|
42
|
+
/** SSE 原文落盘目录(排查上游格式 / 提取保真度用)。 */
|
|
43
|
+
const SSE_LOG_DIR = 'logs';
|
|
44
|
+
/** 从平台 ask 请求体里取 `meta_data`(解析失败返回空对象)。 */
|
|
45
|
+
function askMetaDataOf(body) {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(body);
|
|
48
|
+
if (!parsed || typeof parsed !== 'object')
|
|
49
|
+
return {};
|
|
50
|
+
const meta = parsed['meta_data'];
|
|
51
|
+
return meta && typeof meta === 'object' ? meta : {};
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** 安全取字符串(`context` 是 `Record<string, unknown>`)。 */
|
|
58
|
+
function asString(value) {
|
|
59
|
+
return typeof value === 'string' ? value : undefined;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 给 Promise 加超时(替代 `asyncio.wait_for`)。
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ 存在的唯一理由:Playwright 的 `fill()` 在元素不可编辑时会**无限等待**
|
|
65
|
+
* 「可见 / 可编辑」,把整个请求拖到中间件 300s 超时(2026-09-28 的 504 事故)。
|
|
66
|
+
* 所以凡是可能挂住的页面操作都要包超时。
|
|
67
|
+
*/
|
|
68
|
+
async function withTimeout(promise, ms, label) {
|
|
69
|
+
let timer;
|
|
70
|
+
try {
|
|
71
|
+
return await Promise.race([
|
|
72
|
+
promise,
|
|
73
|
+
new Promise((_, reject) => {
|
|
74
|
+
timer = setTimeout(() => reject(new Error(`${label} 超时(${ms}ms)`)), ms);
|
|
75
|
+
}),
|
|
76
|
+
]);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
if (timer)
|
|
80
|
+
clearTimeout(timer);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
export class ChatGLMBrowserProvider extends BaseProvider {
|
|
84
|
+
/* ---------------- 角色(构造时确定,切号不会改变) ---------------- */
|
|
85
|
+
/** 本实例是不是「主通道」(服务用户真实对话的那个)。 */
|
|
86
|
+
isMainChannel;
|
|
87
|
+
/**
|
|
88
|
+
* 主轮全局(`_last_raw_sse_*` / `logs/chatglm_sse_*.txt`)只有主通道能写。
|
|
89
|
+
*
|
|
90
|
+
* ⚠️ 判据是**角色**而不是「是否在专用页面上」:主通道被限流后切到另一个账号的
|
|
91
|
+
* 独立页面时,它依然是主通道,主轮全局仍由它写。
|
|
92
|
+
*/
|
|
93
|
+
ownsGlobals;
|
|
94
|
+
/** 允许「被限流时换账号重试」的只有主通道。 */
|
|
95
|
+
allowRotate;
|
|
96
|
+
MAX_ROTATE_PER_REQUEST = 1;
|
|
97
|
+
/** 被平台限流后的账号冷却时长(实测约 1 分钟自动恢复,留余量取 120s)。 */
|
|
98
|
+
ROTATE_COOLDOWN = 120;
|
|
99
|
+
/**
|
|
100
|
+
* 本轮是否撞到「平台限制提示」。由 `streamResponse` 设置,
|
|
101
|
+
* 由 `chatStream` 决定:能换账号就换号重试(客户端什么都还没收到,体验最好),
|
|
102
|
+
* 不能换才回传给客户端(`[ChatGLM 平台限制] …`)。
|
|
103
|
+
*/
|
|
104
|
+
lastLimitNotice = null;
|
|
105
|
+
/* ---------------- 运行时状态 ---------------- */
|
|
106
|
+
page = null;
|
|
107
|
+
lock = new Mutex();
|
|
108
|
+
responseJsInjected = false;
|
|
109
|
+
cookiesLoaded = false;
|
|
110
|
+
lastSendTime = 0;
|
|
111
|
+
SEND_COOLDOWN = 10;
|
|
112
|
+
/** 页面最后使用时间(空闲超时用)。 */
|
|
113
|
+
lastUsedTime = Date.now();
|
|
114
|
+
/** 30 分钟空闲 → 刷新页面。 */
|
|
115
|
+
PAGE_IDLE_TIMEOUT = 1800;
|
|
116
|
+
/* ---------------- 网页会话复用状态 ---------------- */
|
|
117
|
+
/**
|
|
118
|
+
* 页面当前那个对话属于哪个逻辑会话(`conversation_id`)。
|
|
119
|
+
* 与其一起记住页面对象:浏览器被重建 / 页面刷新后句柄会变,
|
|
120
|
+
* 此时必须当成「没有可复用的会话」。
|
|
121
|
+
*/
|
|
122
|
+
sessionKey = null;
|
|
123
|
+
sessionPage = null;
|
|
124
|
+
/** 辅助页面:专供两轮策略的 reparse 使用(独立对话,懒创建)。 */
|
|
125
|
+
auxPage = null;
|
|
126
|
+
/**
|
|
127
|
+
* 网页输入框可接受的文本长度上限。
|
|
128
|
+
*
|
|
129
|
+
* 实测 ChatGLM:7 万字符仍可发送,8.2 万字符时 `textarea.value` 能写入
|
|
130
|
+
* 但按 Enter 不会提交(无报错、无新对话轮次)→ 表现为「空回复」。
|
|
131
|
+
* 取 64K:留出安全余量,同时尽量保留上下文。
|
|
132
|
+
*/
|
|
133
|
+
MAX_INPUT_LEN = 64000;
|
|
134
|
+
/**
|
|
135
|
+
* 「平台自带搜索刷屏」的提前收尾阈值:本轮平台 search `≥ 次数` 且已等待 `> 秒数`。
|
|
136
|
+
*
|
|
137
|
+
* ⚠️ 2026-10-14 实测:模型把「bash 怎么删带空格的目录」当成需要检索的知识问题,
|
|
138
|
+
* 单轮在 ChatGLM 自带的 search 上刷了 **57 次**(45 个不同 query),耗掉 210s,
|
|
139
|
+
* 最后只吐出一段复述提示词的纯文本(零客户端工具调用)。
|
|
140
|
+
* 平台搜索对本地代码任务**永远没有价值**,而它一旦开始刷,等得越久亏得越多 ——
|
|
141
|
+
* 更致命的是把总预算吃光,让后面的 reparse / 续跑兜底没预算可用。
|
|
142
|
+
*/
|
|
143
|
+
static PLATFORM_SEARCH_GIVEUP_COUNT = 3;
|
|
144
|
+
static PLATFORM_SEARCH_GIVEUP_SECONDS = 90;
|
|
145
|
+
/** 页面开关探测是否已做过(每进程一次,见 {@link probeChatToggles})。 */
|
|
146
|
+
togglesProbed = false;
|
|
147
|
+
/* ---------------- 登录状态缓存 ---------------- */
|
|
148
|
+
/**
|
|
149
|
+
* `checkLoginStatus()` 被 router 在**每次请求**都调用,而它内部会做一次整页导航
|
|
150
|
+
* (约 6s)。缓存后同一会话内不再重复导航。
|
|
151
|
+
*
|
|
152
|
+
* ⚠️ **只缓存「已登录」**:若把「未登录」也缓存,一次瞬时误判(页面正在重建 /
|
|
153
|
+
* DOM 未就绪)会让整个平台池失效 120s,表现为「聊着聊着突然不可用」。
|
|
154
|
+
*/
|
|
155
|
+
loggedIn = false;
|
|
156
|
+
lastLoginCheck = 0;
|
|
157
|
+
LOGIN_CACHE_TTL = 120;
|
|
158
|
+
/** 当前账号 ID(主通道可能被换号改写)。 */
|
|
159
|
+
accountId;
|
|
160
|
+
/** 当前通道 key(主通道被换号后会变成 `chatglm:<newAccount>`)。 */
|
|
161
|
+
channelKey;
|
|
162
|
+
constructor(options = {}) {
|
|
163
|
+
super('chatglm-browser');
|
|
164
|
+
this.models = ['chatglm', 'glm-4', 'glm-3'];
|
|
165
|
+
this.accountId = options.accountId ?? 'default';
|
|
166
|
+
this.channelKey = options.channelKey;
|
|
167
|
+
// 主通道被限流后会**切到另一个账号的独立页面**,此时 channelKey 非空、
|
|
168
|
+
// 但它依然是主通道(要继续写主轮全局、要继续切辅助页、要继续允许换号)。
|
|
169
|
+
this.isMainChannel = options.channelKey === undefined;
|
|
170
|
+
this.ownsGlobals = this.isMainChannel;
|
|
171
|
+
this.allowRotate = this.isMainChannel;
|
|
172
|
+
if (this.isMainChannel) {
|
|
173
|
+
// 登记「这个账号归主通道用」,辅助通道挑账号时会避开
|
|
174
|
+
setAccountRole(PLATFORM, this.accountId, 'main');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/* ================================================================== */
|
|
178
|
+
/* 页面生命周期 */
|
|
179
|
+
/* ================================================================== */
|
|
180
|
+
/** 页面空闲超过 30 分钟就刷新(避免长期挂着导致状态异常)。 */
|
|
181
|
+
async checkPageIdleTimeout() {
|
|
182
|
+
if (!this.page)
|
|
183
|
+
return;
|
|
184
|
+
const idleTime = (Date.now() - this.lastUsedTime) / 1000;
|
|
185
|
+
if (idleTime > this.PAGE_IDLE_TIMEOUT) {
|
|
186
|
+
logger.info(`[chatglm] _check_page_idle_timeout(): 页面已空闲 ${idleTime.toFixed(0)}s,超过 ` +
|
|
187
|
+
`${this.PAGE_IDLE_TIMEOUT.toFixed(0)}s 超时,刷新页面...`);
|
|
188
|
+
await this.refreshPage();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/** 刷新到新的聊天会话。 */
|
|
192
|
+
async refreshPage() {
|
|
193
|
+
if (!this.page)
|
|
194
|
+
return;
|
|
195
|
+
try {
|
|
196
|
+
logger.info('[chatglm] _refresh_page(): 正在导航到新聊天会话...');
|
|
197
|
+
await this.page.goto(CHAT_URL, { waitUntil: 'networkidle', timeout: 30000 });
|
|
198
|
+
await sleep(2000);
|
|
199
|
+
logger.info('[chatglm] _refresh_page(): 页面刷新完成');
|
|
200
|
+
// 刷新 = 重新开一个干净会话,之前的网页会话上下文没了
|
|
201
|
+
this.sessionKey = null;
|
|
202
|
+
this.sessionPage = null;
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
logger.error(`[chatglm] _refresh_page(): 刷新失败: ${String(error)}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** 标记页面已使用(更新最后使用时间)。 */
|
|
209
|
+
async markUsed() {
|
|
210
|
+
this.lastUsedTime = Date.now();
|
|
211
|
+
}
|
|
212
|
+
/** 当前账号 ID(⚠️ 被限流换号后会变,不要缓存)。 */
|
|
213
|
+
getAccountId() {
|
|
214
|
+
return this.accountId;
|
|
215
|
+
}
|
|
216
|
+
/** 辅助通道标识(主通道返回 undefined)。 */
|
|
217
|
+
getChannelKey() {
|
|
218
|
+
return this.channelKey;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* 确保页面就绪:页面 → 凭证 → 注入 JS。
|
|
222
|
+
*
|
|
223
|
+
* ⚠️⚠️ 三个「只做一次」的步骤**绝不能**嵌在 `if (!this.page)` 里(2026-09-27 事故):
|
|
224
|
+
* `checkLoginStatus()` 会先一步执行(router 在每次请求前都会调它),
|
|
225
|
+
* 它在内部 `getBrowser()` 后就把 `this.page` 赋好了 —— 于是本方法进来时
|
|
226
|
+
* `this.page` 已经有值,整个初始化块被跳过 → **`loadCookies()` 从来没被调用过**
|
|
227
|
+
* (日志里搜不到「已加载 N 个 Cookie」),浏览器一直以**访客**身份跑,
|
|
228
|
+
* 访客免费额度用完后平台就一直回「请求过于频繁,请稍后再试」。
|
|
229
|
+
*/
|
|
230
|
+
async ensureInitialized() {
|
|
231
|
+
logger.info(`[chatglm] _ensure_initialized() 被调用, 账号=${this.accountId}` +
|
|
232
|
+
`${this.channelKey ? '(辅助通道)' : ''}, ` +
|
|
233
|
+
`self._page=${this.page ? 'initialized' : 'None'}`);
|
|
234
|
+
if (this.channelKey) {
|
|
235
|
+
await this.ensureChannelPage();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!this.page) {
|
|
239
|
+
logger.info('[chatglm] _ensure_initialized(): 初始化浏览器...');
|
|
240
|
+
// 导航到 ChatGLM 聊天页面(这会创建并更新全局 page)
|
|
241
|
+
await getBrowser();
|
|
242
|
+
this.page = peekPage();
|
|
243
|
+
if (!this.page)
|
|
244
|
+
throw new Error('浏览器页面不可用');
|
|
245
|
+
await navigateTo(CHAT_URL);
|
|
246
|
+
await sleep(2000);
|
|
247
|
+
// 说明:https://chatglm.cn/main/chat 会自动重定向到
|
|
248
|
+
// https://chatglm.cn/main/alltoolsdetail?lang=zh —— 后者**就是聊天页面**
|
|
249
|
+
// (发消息后 URL 会变成 ...&cid=xxx),不是错误页。
|
|
250
|
+
// 之前只要 URL 含 alltoolsdetail 就重新导航,等于每次都白白多花 2~6s,
|
|
251
|
+
// 而且会刷新页面丢弃已有会话。现在改为按「是否真的有输入框」判断。
|
|
252
|
+
if (!(await this.hasInputBox())) {
|
|
253
|
+
logger.warn(`[chatglm] _ensure_initialized(): 页面 ${this.page.url()} 无输入框,重新导航...`);
|
|
254
|
+
try {
|
|
255
|
+
await this.page.goto(CHAT_URL, { waitUntil: 'networkidle', timeout: 30000 });
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
logger.warn(`[chatglm] _ensure_initialized(): 重新导航失败: ${String(error)}`);
|
|
259
|
+
}
|
|
260
|
+
await sleep(2000);
|
|
261
|
+
}
|
|
262
|
+
logger.info(`[chatglm] _ensure_initialized(): 已导航到 ${this.page.url()}`);
|
|
263
|
+
}
|
|
264
|
+
// ===== 凭证加载(独立判断,不能被上面的分支挡住)=====
|
|
265
|
+
// 注意:Cookie 是加到 **context** 上的,已经加载完成的页面**不会自动生效**,
|
|
266
|
+
// 必须重新导航一次才能真正以登录身份访问。
|
|
267
|
+
if (!this.cookiesLoaded) {
|
|
268
|
+
// ⚠️ 必须带上 `this.accountId`:主通道的账号是**启动时随机分配**的
|
|
269
|
+
// (见 `ModelRouter._pickRandomMainAccount`),写死默认账号会把
|
|
270
|
+
// 另一个账号的 Cookie 注入全局 context → 页面身份与 provider 认知不一致。
|
|
271
|
+
if (await loadCookies(PLATFORM, this.accountId)) {
|
|
272
|
+
this.cookiesLoaded = true;
|
|
273
|
+
try {
|
|
274
|
+
await this.page?.goto(CHAT_URL, { waitUntil: 'networkidle', timeout: 30000 });
|
|
275
|
+
await sleep(2000);
|
|
276
|
+
logger.info('[chatglm] 已注入登录凭证并重新加载页面');
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
logger.warn(`[chatglm] 注入凭证后刷新页面失败: ${String(error)}`);
|
|
280
|
+
}
|
|
281
|
+
// 凭证变了 → 之前的登录判定作废,下次必须重新探测
|
|
282
|
+
this.loggedIn = false;
|
|
283
|
+
this.lastLoginCheck = 0;
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
logger.warn('ChatGLM Cookie 未加载,可能需要重新捕获(访客身份下会被限流)');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// ===== 注入响应提取 JS(独立判断)=====
|
|
290
|
+
if (!this.responseJsInjected) {
|
|
291
|
+
await injectResponseJs(this.page ?? undefined);
|
|
292
|
+
this.responseJsInjected = true;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* 辅助通道的页面准备:独立 context + 指定账号,且**每次辅助请求都重开干净对话**。
|
|
297
|
+
*
|
|
298
|
+
* 取舍理由(与旧 `_enter_aux_page()` 一致):
|
|
299
|
+
* - 避免上一轮的对话历史被带进这次压缩/转换(压缩提示词是自包含的);
|
|
300
|
+
* - 避免复用旧页面时输入框不可编辑 → `fill()` 无限等待 → 504(2026-09-28 事故)。
|
|
301
|
+
* 代价只有 1~2s,而且辅助通道与主对话并行,这点开销不吃主请求的预算。
|
|
302
|
+
*/
|
|
303
|
+
async ensureChannelPage() {
|
|
304
|
+
const page = await getChannelPage(this.channelKey, PLATFORM, this.accountId);
|
|
305
|
+
if (!page) {
|
|
306
|
+
throw new Error(`辅助通道 ${this.channelKey} 页面不可用(账号 ${this.accountId})`);
|
|
307
|
+
}
|
|
308
|
+
if (this.page !== page) {
|
|
309
|
+
this.page = page;
|
|
310
|
+
this.responseJsInjected = false;
|
|
311
|
+
// 新页面 = 之前的网页会话没了,不能再说「可复用」
|
|
312
|
+
this.sessionKey = null;
|
|
313
|
+
this.sessionPage = null;
|
|
314
|
+
}
|
|
315
|
+
if (!this.responseJsInjected) {
|
|
316
|
+
await injectResponseJs(this.page);
|
|
317
|
+
this.responseJsInjected = true;
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
await this.page.goto(CHAT_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
321
|
+
await sleep(2000);
|
|
322
|
+
await this.dismissBlockingOverlay();
|
|
323
|
+
this.sessionKey = null;
|
|
324
|
+
this.sessionPage = null;
|
|
325
|
+
logger.info(`[chatglm] 辅助通道 ${this.channelKey} 已开新对话: ${this.page.url()}`);
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
logger.warn(`[chatglm] 辅助通道 ${this.channelKey} 导航失败: ${String(error)}`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
/* ================================================================== */
|
|
332
|
+
/* 登录检查 */
|
|
333
|
+
/* ================================================================== */
|
|
334
|
+
/**
|
|
335
|
+
* 检查登录状态(带缓存 + 复用已有页面)。
|
|
336
|
+
*
|
|
337
|
+
* 性能说明:该方法被 `ModelRouter._getMixCandidates()` 在**每次请求**中调用。
|
|
338
|
+
* 原实现每次都做一次整页导航(实测约 6s),是请求耗时的大头。现在:
|
|
339
|
+
* - 已登录且近期检查过 → 直接返回缓存(0 开销)
|
|
340
|
+
* - 只有在尚未初始化页面时才真正导航一次,并复用该页面
|
|
341
|
+
*/
|
|
342
|
+
async checkLoginStatus() {
|
|
343
|
+
if (this.channelKey) {
|
|
344
|
+
// 辅助通道**不做整页导航探测** —— 探测走的是全局页面 + `navigateTo()`,
|
|
345
|
+
// 那会把**主页面**劫持到自己这个账号上,主会话的复用链当场断掉。
|
|
346
|
+
// 这里只看凭证文件里有没有 cookie:真有登录问题,后续发送会失败并回落到主通道。
|
|
347
|
+
try {
|
|
348
|
+
const auth = loadAuth(PLATFORM, { accountId: this.accountId });
|
|
349
|
+
const ok = Boolean(auth?.cookies?.length);
|
|
350
|
+
if (!ok) {
|
|
351
|
+
logger.warn(`[chatglm] 辅助通道 ${this.channelKey} 账号 ${this.accountId} 凭证缺少 Cookie`);
|
|
352
|
+
}
|
|
353
|
+
return ok;
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
logger.warn(`[chatglm] 辅助通道 ${this.channelKey} 登录检查失败: ${String(error)}`);
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// 1) 缓存命中(**只缓存「已登录」**)
|
|
361
|
+
if (this.loggedIn && this.page && (Date.now() - this.lastLoginCheck) / 1000 < this.LOGIN_CACHE_TTL) {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
// 有请求正在使用页面时不导航 —— 否则会把进行中的对话刷掉,
|
|
365
|
+
// 且被刷掉的那个请求会拿到「请等待其他对话生成完毕」之类的页面提示。
|
|
366
|
+
if (this.lock.isLocked) {
|
|
367
|
+
logger.info('[chatglm] 页面正在被请求占用,跳过登录检查(返回上次结果)');
|
|
368
|
+
return this.loggedIn;
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
const { page } = await getBrowser();
|
|
372
|
+
// 2) 与全局页面保持同步。浏览器可能被重建,此时 this.page 是失效句柄,
|
|
373
|
+
// 而 getBrowser() 返回的是新的空白页。
|
|
374
|
+
if (this.page !== page) {
|
|
375
|
+
if (this.page !== null) {
|
|
376
|
+
logger.warn('[chatglm] 页面句柄已变化(浏览器被重建),同步为新页面并重置状态');
|
|
377
|
+
}
|
|
378
|
+
this.page = page;
|
|
379
|
+
this.cookiesLoaded = false;
|
|
380
|
+
this.responseJsInjected = false;
|
|
381
|
+
this.sessionKey = null;
|
|
382
|
+
this.sessionPage = null;
|
|
383
|
+
}
|
|
384
|
+
// 3) 判断是否需要导航:空白页 / 非 chatglm 域名 / 没有输入框
|
|
385
|
+
let needNav = false;
|
|
386
|
+
try {
|
|
387
|
+
const url = page.url() ?? '';
|
|
388
|
+
if (!url.includes('chatglm.cn') || url === 'about:blank')
|
|
389
|
+
needNav = true;
|
|
390
|
+
else if (!(await this.hasInputBox()))
|
|
391
|
+
needNav = true;
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
needNav = true;
|
|
395
|
+
}
|
|
396
|
+
/* ===== 3.5) 凭证加载 =====
|
|
397
|
+
* ⚠️⚠️ **必须独立判断,绝不能嵌在 `if (!this.page)` 里**(这是硬约束第①条,
|
|
398
|
+
* 2026-09-27 的真实事故):`checkLoginStatus()` 会先一步执行(router 在每次
|
|
399
|
+
* 请求前都调它),此时 `this.page` 已被赋好值,任何「只在页面为空时才初始化」
|
|
400
|
+
* 的写法都会被整块跳过 → Cookie 从来没被注入过 → 浏览器一直以**访客**身份跑,
|
|
401
|
+
* 访客额度用完后平台永远回「请求过于频繁」。
|
|
402
|
+
*
|
|
403
|
+
* 本方法(登录检查)也会走到这里,所以这一步在这里同样不能省:
|
|
404
|
+
* 否则 `/v1/health` 与平台名模型(`model=chatglm`)会误判为「未登录」,
|
|
405
|
+
* 而 `getProvider()` 对未登录的平台名模型是**直接抛 404** 的。
|
|
406
|
+
*
|
|
407
|
+
* ⚠️ Cookie 是加到 **context** 上的,已经加载完成的页面**不会自动生效**,
|
|
408
|
+
* 所以刚加载完必须重新导航一次。
|
|
409
|
+
*/
|
|
410
|
+
if (!this.cookiesLoaded) {
|
|
411
|
+
if (await loadCookies(PLATFORM, this.accountId)) {
|
|
412
|
+
this.cookiesLoaded = true;
|
|
413
|
+
needNav = true; // 必须重新导航,Cookie 才会生效
|
|
414
|
+
logger.info('[chatglm] 已注入登录凭证(登录检查路径),将重新加载页面');
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
logger.warn(`ChatGLM Cookie 未加载(${PLATFORM}/${this.accountId}),` +
|
|
418
|
+
'登录检查将按访客判定;对话路径可能被限流');
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (needNav) {
|
|
422
|
+
await navigateTo(CHAT_URL);
|
|
423
|
+
try {
|
|
424
|
+
await page.waitForLoadState('load', { timeout: 30000 });
|
|
425
|
+
}
|
|
426
|
+
catch {
|
|
427
|
+
// 忽略:等待超时不影响后续探测
|
|
428
|
+
}
|
|
429
|
+
this.page = page;
|
|
430
|
+
// 等待重定向(/main/chat → /main/alltoolsdetail)稳定
|
|
431
|
+
await sleep(1500);
|
|
432
|
+
}
|
|
433
|
+
// 4) 探测登录态
|
|
434
|
+
let isLogged = await this.probeLoggedIn(page);
|
|
435
|
+
// 5) 未匹配到用户信息时,刷新一次再判,避免 DOM 异常导致的误判
|
|
436
|
+
if (!isLogged) {
|
|
437
|
+
logger.warn('[chatglm] 未匹配到登录信息,刷新页面后重试判定...');
|
|
438
|
+
try {
|
|
439
|
+
await this.page.goto(CHAT_URL, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
440
|
+
await sleep(2500);
|
|
441
|
+
isLogged = await this.probeLoggedIn(this.page);
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
logger.warn(`[chatglm] 刷新重试失败: ${String(error)}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
this.loggedIn = Boolean(isLogged);
|
|
448
|
+
this.lastLoginCheck = Date.now();
|
|
449
|
+
logger.info(`[chatglm] 登录状态检查完成: ${this.loggedIn ? '已登录' : '未登录'}` +
|
|
450
|
+
(this.loggedIn ? `(后续 ${this.LOGIN_CACHE_TTL.toFixed(0)}s 内复用缓存)` : ''));
|
|
451
|
+
return this.loggedIn;
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
logger.error(`检查登录状态失败: ${String(error)}`);
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* 探测页面是否处于**已登录**状态(访客不算登录)。
|
|
460
|
+
*
|
|
461
|
+
* ⚠️ 必须用 `querySelectorAll` 逐个判断可见性(脚本里已如此)——
|
|
462
|
+
* 只取第一个匹配元素时,若第一个 `[class*="user"]` 恰好是隐藏元素会误判为未登录。
|
|
463
|
+
*
|
|
464
|
+
* ⚠️ 更隐蔽的坑(2026-09-27 实测):**ChatGLM 未登录时会以「访客_xxxx」身份继续可用**
|
|
465
|
+
* (有少量免费额度),用完后接口就返回「请求过于频繁,请稍后再试」。而访客页面上
|
|
466
|
+
* `.user-info / [class*="user"]` 这些元素**同样可见** → 旧探测把访客误判成已登录 →
|
|
467
|
+
* 平台池一直"可用",实际每个请求都在烧访客额度,表现为莫名其妙的限流。
|
|
468
|
+
* 因此这里必须显式识别访客身份(看用户名标识,而不是"有没有用户元素")。
|
|
469
|
+
*/
|
|
470
|
+
async probeLoggedIn(page) {
|
|
471
|
+
let info;
|
|
472
|
+
try {
|
|
473
|
+
info = (await page.evaluate(iife(PROBE_LOGGED_IN_JS)));
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
const name = String(info.name ?? '');
|
|
479
|
+
const raw = String(info.raw ?? '');
|
|
480
|
+
logger.info(`[chatglm] 登录探测: 用户名标识=${JSON.stringify(name)}(${JSON.stringify(raw)}), ` +
|
|
481
|
+
`有用户元素=${String(info.has_user_el)}`);
|
|
482
|
+
// 未登录时侧边栏那个位置显示的是「登录 / 登录送积分好礼」;
|
|
483
|
+
// 以访客身份继续用时显示「访客_xxxx」。两种都算未登录。
|
|
484
|
+
if (NOT_LOGGED_MARKERS.some((m) => name.startsWith(m)) ||
|
|
485
|
+
NOT_LOGGED_SUBSTRINGS.some((m) => raw.includes(m))) {
|
|
486
|
+
logger.warn(`[chatglm] 当前**未登录**(页面标识=${JSON.stringify(name || raw)}):` +
|
|
487
|
+
'访客免费额度用完后会一直返回「请求过于频繁,请稍后再试」。' +
|
|
488
|
+
'请重新登录并捕获凭证(`npx webaiapi login chatglm`)。');
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
if (!name) {
|
|
492
|
+
// 读不到用户名时只能退回「有没有用户元素」——并按已登录处理,
|
|
493
|
+
// 避免把正常页面误判成未登录而封锁平台(此前的教训)。
|
|
494
|
+
logger.warn('[chatglm] 未读到用户名标识,退回按「有用户元素」判定');
|
|
495
|
+
}
|
|
496
|
+
return Boolean(info.has_user_el);
|
|
497
|
+
}
|
|
498
|
+
/* ================================================================== */
|
|
499
|
+
/* 流式响应(核心等待循环) */
|
|
500
|
+
/* ================================================================== */
|
|
501
|
+
/**
|
|
502
|
+
* 发消息并等待回复,边等边 yield 增量。
|
|
503
|
+
*
|
|
504
|
+
* 本函数只**记录** `lastLimitNotice`,由 `chatStream` 决定是换号重试还是回传提示。
|
|
505
|
+
*/
|
|
506
|
+
async *streamResponse(request) {
|
|
507
|
+
this.lastLimitNotice = null;
|
|
508
|
+
logger.info(`[chatglm] _stream_response() 被调用, self._page=${this.page ? 'initialized' : 'None'}`);
|
|
509
|
+
if (!this.page) {
|
|
510
|
+
logger.warn('[chatglm] _stream_response(): self._page is None, yielding error');
|
|
511
|
+
yield { error: '浏览器未初始化' };
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
await this.checkPageIdleTimeout();
|
|
515
|
+
/* ---------------- 会话复用判定 ---------------- */
|
|
516
|
+
// routes 侧会准备两份文本:
|
|
517
|
+
// request.message —— 调用方**希望**发的那份(可能是增量)
|
|
518
|
+
// context["delta_message"] —— 只含新增消息
|
|
519
|
+
// context["full_message"] —— 完整提示词
|
|
520
|
+
// 只有当页面上的会话确实还是同一个(同页面对象 + 同一 sessionKey +
|
|
521
|
+
// URL 带 cid= 表示对话仍在进行)时,才敢只发增量;否则发完整提示词,
|
|
522
|
+
// 保证**上下文永远不丢**。
|
|
523
|
+
const ctx = request.context ?? {};
|
|
524
|
+
const sessionKey = asString(ctx['session_key']) ?? null;
|
|
525
|
+
const deltaMsg = asString(ctx['delta_message'])?.trim() ?? '';
|
|
526
|
+
const fullMsg = asString(ctx['full_message'])?.trim() ?? '';
|
|
527
|
+
const pageUrl = this.page.url() ?? '';
|
|
528
|
+
const canReuse = Boolean(deltaMsg &&
|
|
529
|
+
sessionKey &&
|
|
530
|
+
sessionKey === this.sessionKey &&
|
|
531
|
+
this.sessionPage === this.page &&
|
|
532
|
+
pageUrl.includes('cid='));
|
|
533
|
+
let messageContent;
|
|
534
|
+
if (deltaMsg && canReuse) {
|
|
535
|
+
messageContent = deltaMsg;
|
|
536
|
+
logger.info(`[chatglm] 复用当前网页会话(${sessionKey}),只发送增量 ${deltaMsg.length} 字符`);
|
|
537
|
+
}
|
|
538
|
+
else {
|
|
539
|
+
if (deltaMsg) {
|
|
540
|
+
logger.info('[chatglm] 无法复用网页会话' +
|
|
541
|
+
`(session_match=${sessionKey === this.sessionKey}, ` +
|
|
542
|
+
`same_page=${this.sessionPage === this.page}, ` +
|
|
543
|
+
`cid=${pageUrl.includes('cid=')}),改用完整提示词`);
|
|
544
|
+
}
|
|
545
|
+
messageContent = fullMsg || request.message || '';
|
|
546
|
+
}
|
|
547
|
+
// 防御:网页输入框有长度上限(实测 >7 万字符时 Enter 无法提交,表现为静默不发送)
|
|
548
|
+
if (messageContent.length > this.MAX_INPUT_LEN) {
|
|
549
|
+
const overflow = messageContent.length - this.MAX_INPUT_LEN;
|
|
550
|
+
const marker = `\n\n... [上下文过长,已省略中间 ${overflow} 字符] ...\n\n`;
|
|
551
|
+
const headLen = Math.floor(this.MAX_INPUT_LEN * 0.45);
|
|
552
|
+
const tailLen = Math.max(0, this.MAX_INPUT_LEN - headLen - marker.length);
|
|
553
|
+
messageContent =
|
|
554
|
+
messageContent.slice(0, headLen) +
|
|
555
|
+
marker +
|
|
556
|
+
(tailLen > 0 ? messageContent.slice(-tailLen) : '');
|
|
557
|
+
logger.warn(`[chatglm] 输入内容过长(${request.message.length} 字符),` +
|
|
558
|
+
`已截断至 ${messageContent.length} 字符(网页输入框有上限)`);
|
|
559
|
+
}
|
|
560
|
+
logger.info(`[chatglm] 开始发送消息: ${messageContent.slice(0, 50)}...`);
|
|
561
|
+
// 发送前清空 SSE 捕获缓冲,确保抓到的只是本轮回答
|
|
562
|
+
await this.resetSseCapture();
|
|
563
|
+
/* 发送前把平台自带的「联网搜索」关掉、把「思考强度」降到「快速」。
|
|
564
|
+
* 理由:默认 is_networking=true + reasoning_effort=max 会让模型每轮刷几十次平台搜索、
|
|
565
|
+
* 并「想」上 90~200s(见 docs/native-parity.md 阶段 3)。幂等,失败不影响本轮。 */
|
|
566
|
+
await this.applyChatPreferences();
|
|
567
|
+
// sessionKey 无论复用与否都要记:发完整提示词时这个会话也被「喂满」了,
|
|
568
|
+
// 下一次同会话的请求同样可以只发增量。
|
|
569
|
+
await this.sendMessage(messageContent, sessionKey, Boolean(deltaMsg && canReuse));
|
|
570
|
+
logger.info('[chatglm] 消息已发送,等待 AI 回复...');
|
|
571
|
+
/* 侦察:把「平台自己的请求体」导出来(判断能否用平台原生函数调用通道承载客户端工具)。
|
|
572
|
+
* 只读、只落盘 + 打日志,失败一律不影响主流程。 */
|
|
573
|
+
if (this.ownsGlobals)
|
|
574
|
+
await this.dumpPlatformRequests();
|
|
575
|
+
/* 侦察:找出「联网搜索 / 深度思考」的页面开关(决定能否在发送前关掉它们)。
|
|
576
|
+
* 每进程一次,落盘 `logs/chatglm_page_toggles.txt`。 */
|
|
577
|
+
if (this.ownsGlobals && !this.togglesProbed) {
|
|
578
|
+
this.togglesProbed = true;
|
|
579
|
+
await this.probeChatToggles();
|
|
580
|
+
}
|
|
581
|
+
let lastText = '';
|
|
582
|
+
let maxLenSeen = 0; // DOM 兜底路径下提取到的最长文本长度
|
|
583
|
+
let maxSseLen = 0; // SSE 路径下 text+think 提取长度(辅助活跃信号)
|
|
584
|
+
let maxSseRawLen = 0; // SSE 原始报文长度(**主活跃信号**,严格单调递增)
|
|
585
|
+
let platformToolStats = {};
|
|
586
|
+
const startTime = Date.now();
|
|
587
|
+
let lastActivity = Date.now();
|
|
588
|
+
const idleTimeout = 90; // 连续 90s 无新内容才判定结束
|
|
589
|
+
// 总时长上限。必须**小于**请求超时中间件的 300s,否则中间件会先掐断请求,
|
|
590
|
+
// 导致我们自己的兜底提取与 DOM 诊断来不及执行。
|
|
591
|
+
// 若调用方传入 `deadlineTs`(整个 HTTP 请求的绝对截止时间),则收敛为「剩余时间」
|
|
592
|
+
// —— 首轮 + 两轮策略的 reparse 是两个完整往返,各自按 240s 跑必然超过 300s,
|
|
593
|
+
// 客户端只会拿到 504。
|
|
594
|
+
let hardTimeout = 240;
|
|
595
|
+
const deadlineTs = request.deadlineTs;
|
|
596
|
+
if (deadlineTs) {
|
|
597
|
+
const remain = (deadlineTs - Date.now()) / 1000;
|
|
598
|
+
hardTimeout = Math.max(5, Math.min(hardTimeout, remain));
|
|
599
|
+
logger.info(`[chatglm] 本次请求时间预算收敛为 ${hardTimeout.toFixed(0)}s` +
|
|
600
|
+
`(整个 HTTP 请求剩余 ${remain.toFixed(0)}s)`);
|
|
601
|
+
}
|
|
602
|
+
let lastProgressLog = 0;
|
|
603
|
+
// SSE 解析缓存:报文只增不减,长度未变时无需重复解析
|
|
604
|
+
// (80 万字符的报文每 0.2s 重解析一次开销很大)
|
|
605
|
+
const sseCache = {
|
|
606
|
+
rawLen: -1,
|
|
607
|
+
answer: '',
|
|
608
|
+
think: '',
|
|
609
|
+
finished: false,
|
|
610
|
+
platformTools: {},
|
|
611
|
+
};
|
|
612
|
+
try {
|
|
613
|
+
logger.info('[chatglm] 开始等待 AI 回复...');
|
|
614
|
+
let loopCount = 0;
|
|
615
|
+
const thinkingCheckInterval = 15; // 每 15 轮(约 3s)一次,减少 JS 评估频率
|
|
616
|
+
for (;;) {
|
|
617
|
+
loopCount += 1;
|
|
618
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
619
|
+
if (loopCount % 5 === 1) {
|
|
620
|
+
logger.info(`[chatglm] 等待 AI 回复中... (已等待 ${elapsed.toFixed(1)}s, 第${loopCount}轮)`);
|
|
621
|
+
}
|
|
622
|
+
// 检查思考阶段(降低频率:每 15 轮一次,减少 DOM 查询)
|
|
623
|
+
let isThinking = false;
|
|
624
|
+
if (loopCount % thinkingCheckInterval === 1) {
|
|
625
|
+
isThinking = await this.detectThinking();
|
|
626
|
+
}
|
|
627
|
+
if (isThinking) {
|
|
628
|
+
logger.debug(`[chatglm] 正在思考... (${elapsed.toFixed(1)}s)`);
|
|
629
|
+
await sleep(500);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
/* ===== 文本来源优先级(关键)=====
|
|
633
|
+
* 1) SSE 原始流:**内容保真**。DOM 里的文本是 Markdown 渲染后的结果,
|
|
634
|
+
* 实测会被破坏:`__name__` → `name`(下划线被当加粗语法吃掉)、
|
|
635
|
+
* 半角引号 `"` → 全角 `“”`(JSON 因此解析失败)、空格丢失;
|
|
636
|
+
* 而且容器里还混着用户自己发出的提问(提示词里的 <tool_call>
|
|
637
|
+
* 格式示例会被误当成 AI 的工具调用)。
|
|
638
|
+
* 2) DOM innerText:仅作兜底。 */
|
|
639
|
+
let currentText = '';
|
|
640
|
+
const sseRaw = await this.readSseCapture();
|
|
641
|
+
let sseFinished = false;
|
|
642
|
+
if (sseRaw) {
|
|
643
|
+
if (sseRaw.length !== sseCache.rawLen) {
|
|
644
|
+
const r = extractSseAll(sseRaw);
|
|
645
|
+
sseCache.rawLen = sseRaw.length;
|
|
646
|
+
sseCache.answer = r.answer;
|
|
647
|
+
sseCache.think = r.think;
|
|
648
|
+
sseCache.finished = r.finished;
|
|
649
|
+
sseCache.platformTools = r.platformTools;
|
|
650
|
+
}
|
|
651
|
+
sseFinished = sseCache.finished;
|
|
652
|
+
platformToolStats = sseCache.platformTools;
|
|
653
|
+
currentText = sseCache.answer;
|
|
654
|
+
/* ===== 活动判定 =====
|
|
655
|
+
* **主信号:SSE 原始报文长度**。只要上游还在推事件报文一定变长;流断了它就停
|
|
656
|
+
* → 语义与「有没有新内容」完全一致,且严格单调递增。
|
|
657
|
+
* ⚠️ 不能只用 text/think 的提取长度:两者都是「增量片段 + 轮末累积快照」的
|
|
658
|
+
* 混合体,提取长度在**本轮快照到来之前长时间不变**(实测 505 个 think 片段里
|
|
659
|
+
* 最长片段恒为 1787,而拼接总长一直在涨到 11097)→ maxSseLen 再也刷不动 →
|
|
660
|
+
* 90s 空闲超时把**仍在运行**的第 3 轮思考掐掉,最终 0 产出(客户端拿到空回复)。
|
|
661
|
+
* ⚠️ 也不能用 DOM 文本长度:思考阶段 DOM 的 .answer 里装的是思考正文,
|
|
662
|
+
* 思考结束后它会被**最终答案替换**(只剩几十字符),长度反而变小。 */
|
|
663
|
+
if (sseRaw.length > maxSseRawLen) {
|
|
664
|
+
maxSseRawLen = sseRaw.length;
|
|
665
|
+
lastActivity = Date.now();
|
|
666
|
+
}
|
|
667
|
+
// 辅助信号:提取长度增长(报文长度相同但解析结果变大时兜底)
|
|
668
|
+
const sseTotal = sseCache.answer.length + sseCache.think.length;
|
|
669
|
+
if (sseTotal > maxSseLen) {
|
|
670
|
+
maxSseLen = sseTotal;
|
|
671
|
+
lastActivity = Date.now();
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// ⚠️ 只有「完全没有 SSE 捕获」时才回退 DOM。
|
|
675
|
+
// SSE 捕获存在但 type=text 为空 ⇒ 模型还在 think 阶段,
|
|
676
|
+
// 此时 DOM 的 .answer 里装的正是**思考正文**,取它会把思考过程
|
|
677
|
+
// 当成回答流给客户端(实测客户端先收到 3751 字符思考、然后停住)。
|
|
678
|
+
if (!currentText && !sseRaw) {
|
|
679
|
+
currentText = String(await this.page.evaluate(iife(EXTRACT_RESPONSE_JS)));
|
|
680
|
+
}
|
|
681
|
+
// 统一清洗(SSE 与 DOM 两个来源都要清洗)
|
|
682
|
+
currentText = sanitizeMessage(currentText);
|
|
683
|
+
// 每 20s 输出一次提取进度(诊断「一直没产出/一直有增量」)
|
|
684
|
+
if ((Date.now() - lastProgressLog) / 1000 > 20) {
|
|
685
|
+
lastProgressLog = Date.now();
|
|
686
|
+
logger.info(`[chatglm] 提取进度: 长度=${currentText.length}, ` +
|
|
687
|
+
`已产出=${lastText.length}, 已等待=${((Date.now() - startTime) / 1000).toFixed(0)}s, ` +
|
|
688
|
+
`SSE回答=${sseCache.answer.length}, SSE思考=${sseCache.think.length}, ` +
|
|
689
|
+
`SSE报文=${sseRaw.length}, SSE完成=${String(sseCache.finished)}, ` +
|
|
690
|
+
`平台内部工具=${JSON.stringify(sseCache.platformTools)}, ` +
|
|
691
|
+
`内容开头=${JSON.stringify(currentText.slice(0, 80))}`);
|
|
692
|
+
}
|
|
693
|
+
// 计算增量文本(必须用 safeIncremental:模型中途改写回答时,
|
|
694
|
+
// 按长度硬切会产生半句话的乱码碎片)
|
|
695
|
+
const incremental = safeIncremental(lastText, currentText);
|
|
696
|
+
if (incremental) {
|
|
697
|
+
yield { content: sanitizeMessage(incremental) };
|
|
698
|
+
lastText = currentText;
|
|
699
|
+
// 只有「内容真正变长」才算有效活动。
|
|
700
|
+
// 否则页面上的动画/轮换元素会导致文本反复变化,让 lastActivity 一直被刷新,
|
|
701
|
+
// 从而永远不触发空闲超时(曾出现跑满 1000+ 轮、230s 仍未结束的情况)。
|
|
702
|
+
if (currentText.length > maxLenSeen) {
|
|
703
|
+
maxLenSeen = currentText.length;
|
|
704
|
+
lastActivity = Date.now();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
/* ===== 完成判定 =====
|
|
708
|
+
* ⚠️ 完成信号必须**结合本轮是否已产出内容**来判断:刚提交消息后,页面上可能
|
|
709
|
+
* 还残留上一轮回答(或本轮用户消息自身)的「重新生成」按钮,让检出立刻为真。
|
|
710
|
+
* 曾因此导致两轮策略只等 1 轮就退出、拿到空回答而彻底失效。
|
|
711
|
+
* 因此:只有**本轮已有内容**时才立即采信完成信号;否则给 30s 宽限期。
|
|
712
|
+
* 完成判定**优先用 SSE 的 status == "finish"**(权威信号)——
|
|
713
|
+
* DOM 的「重新生成」按钮在 ChatGLM 改版后已不可靠(实测整轮 176s 里一直返回 False,
|
|
714
|
+
* 明明答完了还在白等 90s 空闲超时)。DOM 检测降为兜底,每 5 轮(1s)一次。 */
|
|
715
|
+
let isComplete = sseFinished;
|
|
716
|
+
if (!isComplete && loopCount % 5 === 1) {
|
|
717
|
+
isComplete = await this.detectCompletion();
|
|
718
|
+
}
|
|
719
|
+
if (isComplete) {
|
|
720
|
+
if (currentText) {
|
|
721
|
+
// 已有内容:SSE finish 是权威的,可直接结束;
|
|
722
|
+
// DOM 兜底路径再要求「内容 3s 内没有继续变长」,避免返回半截答案。
|
|
723
|
+
if (sseFinished || (Date.now() - lastActivity) / 1000 >= 3)
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
else if (sseFinished || (Date.now() - startTime) / 1000 > 30) {
|
|
727
|
+
// 本轮始终没有正文(模型没答 / 只输出工具调用):
|
|
728
|
+
// SSE 已 finish 就立即结束,否则等满 30s 宽限期再放弃
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const nowTs = Date.now();
|
|
733
|
+
// 平台限制快速失败:页面提示「请等待其他对话生成完毕」/「请求过于频繁」时,
|
|
734
|
+
// 说明 ChatGLM 已拒绝本次请求,再等也不会出内容(省下 90s 空等)。
|
|
735
|
+
if (!lastText && loopCount % 25 === 0) {
|
|
736
|
+
if (await this.pageShowsBusyNotice()) {
|
|
737
|
+
const notice = (await this.readBusyNotice()) || '请等待其他对话生成完毕';
|
|
738
|
+
// 诊断:把判定依据(最后一条 .answer 的原文)也打出来,
|
|
739
|
+
// 用于区分「平台真限流」与「我们自己的文案被当成提示」
|
|
740
|
+
try {
|
|
741
|
+
const diag = (await this.page.evaluate(iife(BUSY_NOTICE_DIAG_JS)));
|
|
742
|
+
logger.warn(`[chatglm] 判定依据: url=${String(diag.url)}, ` +
|
|
743
|
+
`.answer 数量=${String(diag.n)}, ` +
|
|
744
|
+
`最后一条内容=${JSON.stringify(String(diag.tail ?? '').slice(0, 200))}`);
|
|
745
|
+
}
|
|
746
|
+
catch {
|
|
747
|
+
// 诊断失败不影响主流程
|
|
748
|
+
}
|
|
749
|
+
logger.warn(`[chatglm] 页面提示「${notice}」,本次请求被 ChatGLM 拒绝` +
|
|
750
|
+
'(并发对话或触发限流)。已提前结束等待,请避免并发/高频发送。');
|
|
751
|
+
// ⚠️ 这里**只记录**,交给 `chatStream` 决定:主通道还有别的账号时
|
|
752
|
+
// 换号重试(此时客户端什么都没收到,体验最好);实在没得换才回传提示。
|
|
753
|
+
// lastText 仍然置上,避免收尾的「兜底提取」重复取数。
|
|
754
|
+
this.lastLimitNotice = notice;
|
|
755
|
+
lastText = `[ChatGLM 平台限制] ${notice}`;
|
|
756
|
+
break;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
/* ===== 平台自带搜索刷屏 → 提前收尾 =====
|
|
760
|
+
* 见 PLATFORM_SEARCH_GIVEUP_COUNT 的说明:留着它只会白烧预算,
|
|
761
|
+
* 而预算恰恰是后面 reparse / 续跑兜底唯一的本钱。 */
|
|
762
|
+
const platformSearchCount = (platformToolStats['search'] ?? 0) + (platformToolStats['open'] ?? 0);
|
|
763
|
+
if (platformSearchCount >= ChatGLMBrowserProvider.PLATFORM_SEARCH_GIVEUP_COUNT &&
|
|
764
|
+
elapsed > ChatGLMBrowserProvider.PLATFORM_SEARCH_GIVEUP_SECONDS) {
|
|
765
|
+
logger.warn(`[chatglm] 本轮平台自带搜索已触发 ${platformSearchCount} 次` +
|
|
766
|
+
`(已等待 ${elapsed.toFixed(0)}s),对本地任务无价值,提前收尾把预算留给兜底`);
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
// 活动式空闲超时:只要持续有新内容就不算空闲
|
|
770
|
+
if ((nowTs - lastActivity) / 1000 > idleTimeout) {
|
|
771
|
+
logger.info(`[chatglm] 空闲超时(${idleTimeout.toFixed(0)}s 无新内容),结束等待`);
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
// 总时长硬上限(防止极端情况挂死)
|
|
775
|
+
if ((nowTs - startTime) / 1000 > hardTimeout) {
|
|
776
|
+
logger.warn(`[chatglm] 总超时(${hardTimeout.toFixed(0)}s),结束等待`);
|
|
777
|
+
break;
|
|
778
|
+
}
|
|
779
|
+
await sleep(200);
|
|
780
|
+
}
|
|
781
|
+
/* ---------------- 记录 SSE 原始流(供降级解析使用) ---------------- */
|
|
782
|
+
// ⚠️ **辅助通道不得写这些主轮全局**(引入并行后新增的约束):这些全局是给
|
|
783
|
+
// routes 恢复「主轮回答里的工具调用」用的。辅助通道(后台预压缩)可能与
|
|
784
|
+
// 主对话**同时**跑完,若它也写,主轮的 reparse 就会拿到压缩请求的报文
|
|
785
|
+
// → 转换出完全错误的工具调用。辅助通道的产出通过 chunk 直接返回,不需要全局。
|
|
786
|
+
try {
|
|
787
|
+
const sseRawEnd = await this.readSseCapture();
|
|
788
|
+
if (sseRawEnd) {
|
|
789
|
+
const r = extractSseAll(sseRawEnd);
|
|
790
|
+
if (this.ownsGlobals) {
|
|
791
|
+
setLastRawSse({ text: r.answer, think: r.think, toolCalls: [] });
|
|
792
|
+
}
|
|
793
|
+
if (Object.keys(r.platformTools).length > 0)
|
|
794
|
+
platformToolStats = r.platformTools;
|
|
795
|
+
logSseSummary(sseRawEnd, r);
|
|
796
|
+
// ⚠️ 辅助通道不落盘:否则并行的压缩会覆盖掉主轮报文。
|
|
797
|
+
if (this.ownsGlobals)
|
|
798
|
+
this.dumpSseSample(sseRawEnd);
|
|
799
|
+
}
|
|
800
|
+
else {
|
|
801
|
+
if (this.ownsGlobals)
|
|
802
|
+
setLastRawSse({ text: '', think: '', toolCalls: [] });
|
|
803
|
+
logger.warn('[chatglm] SSE 原始流捕获为空(未 hook 到或接口非流式)');
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch (error) {
|
|
807
|
+
logger.debug(`[chatglm] 读取 SSE 捕获失败: ${String(error)}`);
|
|
808
|
+
}
|
|
809
|
+
/* ---------------- 兜底:整轮零产出时强制再取一次 DOM 正文 ---------------- */
|
|
810
|
+
if (!lastText) {
|
|
811
|
+
try {
|
|
812
|
+
const forced = String(await this.page.evaluate(iife(FORCED_EXTRACT_JS)));
|
|
813
|
+
if (forced) {
|
|
814
|
+
logger.info(`[chatglm] 兜底提取答案正文,长度=${forced.length}`);
|
|
815
|
+
// 记入 lastText,让下方的「零产出」判定知道本轮已有内容
|
|
816
|
+
lastText = forced;
|
|
817
|
+
yield { content: sanitizeMessage(forced) };
|
|
818
|
+
}
|
|
819
|
+
else {
|
|
820
|
+
// 诊断:区分「模型没答」与「答了但选择器/锚点没匹配上」
|
|
821
|
+
const diag = (await this.page.evaluate(iife(ZERO_OUTPUT_DIAG_JS)));
|
|
822
|
+
logger.warn(`[chatglm] 未提取到任何内容,DOM 诊断: url=${String(diag.url)}, ` +
|
|
823
|
+
`answerCount=${String(diag.answerCount)}, answerLen=${String(diag.answerLen)}, ` +
|
|
824
|
+
`convLen=${String(diag.convLen)}, regenerate=${String(diag.regenerate)}`);
|
|
825
|
+
logger.warn(`[chatglm] .answer 内容前 300 字符: ${JSON.stringify(String(diag.answerHead ?? ''))}`);
|
|
826
|
+
logger.warn(`[chatglm] 对话容器前 300 字符: ${JSON.stringify(String(diag.convHead ?? ''))}`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
catch (error) {
|
|
830
|
+
logger.warn(`[chatglm] 兜底提取失败: ${String(error)}`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
/* ===== 零产出:整轮既没有正文、也没有平台限制提示 =====
|
|
834
|
+
* 以前这里直接 return(什么都不发)→ 上游把它当成「成功」,客户端拿到一个
|
|
835
|
+
* 200 的空响应(表现为「AI 没反应」)。实测:模型整轮调用**网页端自带的联网搜索**
|
|
836
|
+
* (SSE 里出现 search 387 次、报文 410KB),从头到尾没有 type=text。
|
|
837
|
+
*
|
|
838
|
+
* ⚠️ 但只要本轮还有**思考材料**就不要报错:routes 的策略3 会拿「SSE 回答 +
|
|
839
|
+
* SSE 思考 + DOM」再走一轮转换,能从中恢复出工具调用;此时若回传 error,
|
|
840
|
+
* routes 会直接跨平台切换,思考里的工具意图就被白白丢掉了。
|
|
841
|
+
* 只有**连思考都没有**才判定本轮失败。 */
|
|
842
|
+
if (!lastText) {
|
|
843
|
+
const toolsDesc = Object.entries(platformToolStats)
|
|
844
|
+
.sort(([a], [b]) => (a < b ? -1 : 1))
|
|
845
|
+
.map(([k, v]) => `${k}×${v}`)
|
|
846
|
+
.join(', ') || '无';
|
|
847
|
+
const thinkMaterial = (this.ownsGlobals ? getLastRawSse().think : sseCache.think).trim();
|
|
848
|
+
logger.warn(`[chatglm] 整轮零产出(无正文、无工具调用),` +
|
|
849
|
+
`思考材料=${thinkMaterial.length} 字符,平台内部工具调用统计: ${toolsDesc}`);
|
|
850
|
+
if ('search' in platformToolStats) {
|
|
851
|
+
logger.warn('[chatglm] ⚠️ 模型整轮在用**网页端自带联网搜索**(协议里已明令禁止),' +
|
|
852
|
+
'既没读取本地文件也没产出正文。' +
|
|
853
|
+
'若该提问反复触发,请改写提问使其不需要联网信息。');
|
|
854
|
+
}
|
|
855
|
+
if (thinkMaterial) {
|
|
856
|
+
logger.warn('[chatglm] 本轮无正文但有思考材料,交给上层从思考里恢复工具调用' +
|
|
857
|
+
'(策略3 已放宽门槛);不发 error,避免误触发切换');
|
|
858
|
+
}
|
|
859
|
+
else {
|
|
860
|
+
yield {
|
|
861
|
+
error: '[chatglm] 平台未产出任何正文' +
|
|
862
|
+
('search' in platformToolStats
|
|
863
|
+
? `(模型整轮在用网页端自带的联网搜索:${toolsDesc})`
|
|
864
|
+
: ''),
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
catch (error) {
|
|
870
|
+
logger.error(`流式响应失败: ${String(error)}`);
|
|
871
|
+
if (isBrowserConnectionError(error)) {
|
|
872
|
+
invalidateBrowser(`${error instanceof Error ? error.name : 'Error'}: ${String(error)}`);
|
|
873
|
+
}
|
|
874
|
+
yield { error: String(error) };
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* 把 SSE 原文落盘。
|
|
879
|
+
*
|
|
880
|
+
* `..._last.txt` 永远写**最新**一轮;`..._longest.txt` 只在更长时覆盖 ——
|
|
881
|
+
* 旧实现只保留「更长的那份」,结果一个大样本会一直挡住后续样本,
|
|
882
|
+
* 失败报文根本没落盘,排查时看到的是早已过期的报文。
|
|
883
|
+
*/
|
|
884
|
+
dumpSseSample(sseRaw) {
|
|
885
|
+
try {
|
|
886
|
+
mkdirSync(SSE_LOG_DIR, { recursive: true });
|
|
887
|
+
writeFileSync(join(SSE_LOG_DIR, 'chatglm_sse_last.txt'), sseRaw, 'utf8');
|
|
888
|
+
const longest = join(SSE_LOG_DIR, 'chatglm_sse_longest.txt');
|
|
889
|
+
let isLongest = true;
|
|
890
|
+
if (existsSync(longest)) {
|
|
891
|
+
try {
|
|
892
|
+
isLongest = statSync(longest).size <= sseRaw.length;
|
|
893
|
+
}
|
|
894
|
+
catch {
|
|
895
|
+
// 读取失败就当它不是最长的
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (isLongest)
|
|
899
|
+
writeFileSync(longest, sseRaw, 'utf8');
|
|
900
|
+
}
|
|
901
|
+
catch {
|
|
902
|
+
// 落盘失败不影响主流程
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* 侦察:导出**平台自己的请求体**。
|
|
907
|
+
*
|
|
908
|
+
* ## 要回答的问题(决定架构走向)
|
|
909
|
+
* 网页端 Agent 与原生模型最大的差别在于:它只有**自己固定的工具集**
|
|
910
|
+
* (`search` / `open` / `click` / `execute_sandbox_code` / `finish`,且由平台服务端执行),
|
|
911
|
+
* 而原生模型接受调用方传入的 `tools` 并回传 `tool_calls`。
|
|
912
|
+
* 如果平台的 ask 接口**接受 tools / 函数定义**,我们就能把客户端工具直接交给它、
|
|
913
|
+
* 用它的原生函数调用通道承载,彻底扔掉「注入 `<tool_call>` 文本协议 + 正则解析 + 四层兜底」;
|
|
914
|
+
* 反之文本桥就删不掉。
|
|
915
|
+
*
|
|
916
|
+
* 因此这里把请求体原样落盘(`logs/chatglm_platform_request.txt`),
|
|
917
|
+
* 并在日志里打**顶层字段名**(最关键的结论:有没有 tools/functions 之类的入口)。
|
|
918
|
+
*/
|
|
919
|
+
async dumpPlatformRequests() {
|
|
920
|
+
if (!this.page)
|
|
921
|
+
return;
|
|
922
|
+
let reqs;
|
|
923
|
+
try {
|
|
924
|
+
reqs = (await this.page.evaluate(iife(REQUEST_BODIES_GET_JS)));
|
|
925
|
+
}
|
|
926
|
+
catch (error) {
|
|
927
|
+
logger.debug(`[recon] 读取平台请求体失败: ${String(error)}`);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (!Array.isArray(reqs) || reqs.length === 0) {
|
|
931
|
+
logger.warn('[recon] 未捕获到平台请求体(hook 未生效?或该请求走 WebSocket / 原生 fetch 之外)');
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
for (const r of reqs) {
|
|
935
|
+
const fields = topLevelJsonFields(r.body ?? '');
|
|
936
|
+
logger.info(`[recon] 平台请求 ${r.method ?? ''} ${r.url ?? ''} | content-type=${r.contentType ?? ''} | ` +
|
|
937
|
+
`顶层字段=[${fields.join(', ')}] | 体积=${(r.body ?? '').length} | ` +
|
|
938
|
+
`开头=${JSON.stringify((r.body ?? '').slice(0, 160))}`);
|
|
939
|
+
// 阶段 3 的验证闭环:页面开关到底有没有改变平台参数
|
|
940
|
+
if ((r.url ?? '').includes('/assistant/stream')) {
|
|
941
|
+
const meta = askMetaDataOf(r.body ?? '');
|
|
942
|
+
logger.info(`[聊天参数] 平台侧: is_networking=${String(meta['is_networking'])} ` +
|
|
943
|
+
`chat_mode=${String(meta['chat_mode'])} reasoning_effort=${String(meta['reasoning_effort'])} ` +
|
|
944
|
+
`selected_model=${String(meta['selected_model'])}`);
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
try {
|
|
948
|
+
mkdirSync(SSE_LOG_DIR, { recursive: true });
|
|
949
|
+
const text = reqs
|
|
950
|
+
.map((r, i) => `### [${i}] ${r.method ?? ''} ${r.url ?? ''}\n` +
|
|
951
|
+
`content-type: ${r.contentType ?? ''}\n\n${r.body ?? ''}\n`)
|
|
952
|
+
.join('\n');
|
|
953
|
+
writeFileSync(join(SSE_LOG_DIR, 'chatglm_platform_request.txt'), text, 'utf8');
|
|
954
|
+
}
|
|
955
|
+
catch {
|
|
956
|
+
// 落盘失败不影响主流程
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
/**
|
|
960
|
+
* 发送前应用页面偏好:**关掉「联网搜索」**、**思考强度降为「快速」**。
|
|
961
|
+
*
|
|
962
|
+
* ⚠️ 这两项直接决定「模型会不会去刷平台自带的搜索」与「每轮要想多久」
|
|
963
|
+
* (实测默认开着时单轮 90~200s、`search` 刷 57 次)。失败只记日志,绝不阻断本轮。
|
|
964
|
+
*
|
|
965
|
+
* 逃生的口子:设 `WEBAIAPI_SKIP_PAGE_PREFS=1` 可跳过(排查用)。
|
|
966
|
+
*/
|
|
967
|
+
async applyChatPreferences() {
|
|
968
|
+
if (!this.page)
|
|
969
|
+
return;
|
|
970
|
+
if (process.env['WEBAIAPI_SKIP_PAGE_PREFS'] === '1')
|
|
971
|
+
return;
|
|
972
|
+
const started = Date.now();
|
|
973
|
+
try {
|
|
974
|
+
const r = (await this.page.evaluate(iife(APPLY_CHAT_PREFS_JS)));
|
|
975
|
+
if (!r)
|
|
976
|
+
return;
|
|
977
|
+
/** 取快照里「带 selected 的思考强度项」。 */
|
|
978
|
+
const selOf = (snap) => (snap?.items ?? [])
|
|
979
|
+
.filter((it) => /selected/.test(String(it.cls)))
|
|
980
|
+
.map((it) => `${String(it.text).slice(0, 12)}`)
|
|
981
|
+
.join('|') || '(无 selected)';
|
|
982
|
+
logger.info(`[聊天参数] 点击=${JSON.stringify(r.clicked ?? [])}, ` +
|
|
983
|
+
`思考强度选中: ${selOf(r.before)} → ${selOf(r.after)}, ` +
|
|
984
|
+
`联网按钮 class: ${String(r.before?.searchBtn?.cls ?? '(无)').slice(0, 60)} → ` +
|
|
985
|
+
`${String(r.after?.searchBtn?.cls ?? '(无)').slice(0, 60)}, 耗时=${Date.now() - started}ms`);
|
|
986
|
+
try {
|
|
987
|
+
mkdirSync(SSE_LOG_DIR, { recursive: true });
|
|
988
|
+
writeFileSync(join(SSE_LOG_DIR, 'chatglm_page_prefs.txt'), JSON.stringify(r, null, 2), 'utf8');
|
|
989
|
+
}
|
|
990
|
+
catch {
|
|
991
|
+
// 落盘失败不影响主流程
|
|
992
|
+
}
|
|
993
|
+
// 弹层别留着挡发送(发送路径虽有自己的浮层处理,这里先收干净)
|
|
994
|
+
try {
|
|
995
|
+
await this.page.keyboard.press('Escape');
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
// 忽略
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
catch (error) {
|
|
1002
|
+
logger.debug(`[chatglm] 应用页面偏好失败: ${String(error)}`);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* 侦察:导出「联网搜索 / 深度思考」相关的页面开关与本地存储键。
|
|
1007
|
+
*
|
|
1008
|
+
* ## 要回答的问题(省时间的最大杠杆)
|
|
1009
|
+
* 平台 ask 请求体里有 `meta_data.is_networking`(默认 true)与
|
|
1010
|
+
* `chat_mode=deep_thinking` + `reasoning_effort=max`:
|
|
1011
|
+
* 前者让模型不停去调平台自带的联网搜索(`search` 单轮 57 次、45 个 query),
|
|
1012
|
+
* 后者让每回合要「想」90~200s。它们都是**页面 UI 状态**,
|
|
1013
|
+
* 只有点到对应开关才能在发请求前改掉。
|
|
1014
|
+
*
|
|
1015
|
+
* 探测结果落盘 `logs/chatglm_page_toggles.txt`(每进程一次)。
|
|
1016
|
+
*/
|
|
1017
|
+
async probeChatToggles() {
|
|
1018
|
+
if (!this.page)
|
|
1019
|
+
return;
|
|
1020
|
+
let data;
|
|
1021
|
+
try {
|
|
1022
|
+
data = await this.page.evaluate(iife(PROBE_CHAT_TOGGLES_JS));
|
|
1023
|
+
}
|
|
1024
|
+
catch (error) {
|
|
1025
|
+
logger.debug(`[recon] 探测页面开关失败: ${String(error)}`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (!data || typeof data !== 'object')
|
|
1029
|
+
return;
|
|
1030
|
+
const rec = data;
|
|
1031
|
+
logger.info(`[recon] 页面开关探测:控件 ${rec.controls?.length ?? 0} 个,命中「联网/搜索/深度思考」` +
|
|
1032
|
+
`${rec.matched?.length ?? 0} 个:` +
|
|
1033
|
+
JSON.stringify((rec.matched ?? []).slice(0, 8).map((m) => `${m.text || m.aria}(${m.cls})`)));
|
|
1034
|
+
logger.info(`[recon] 本地存储键:${JSON.stringify(Object.keys(rec.storage ?? {}).slice(0, 20))}`);
|
|
1035
|
+
try {
|
|
1036
|
+
mkdirSync(SSE_LOG_DIR, { recursive: true });
|
|
1037
|
+
writeFileSync(join(SSE_LOG_DIR, 'chatglm_page_toggles.txt'), JSON.stringify(data, null, 2), 'utf8');
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
// 落盘失败不影响主流程
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
/* ================================================================== */
|
|
1044
|
+
/* 页面状态小工具 */
|
|
1045
|
+
/* ================================================================== */
|
|
1046
|
+
/** ChatGLM 专用思考检测。 */
|
|
1047
|
+
async detectThinking() {
|
|
1048
|
+
if (!this.page)
|
|
1049
|
+
return false;
|
|
1050
|
+
try {
|
|
1051
|
+
return Boolean(await this.page.evaluate(iife(DETECT_THINKING_JS)));
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
/** ChatGLM 专用完成检测(DOM 兜底;权威信号是 SSE 的 `status == "finish"`)。 */
|
|
1058
|
+
async detectCompletion() {
|
|
1059
|
+
if (!this.page)
|
|
1060
|
+
return false;
|
|
1061
|
+
try {
|
|
1062
|
+
return Boolean(await this.page.evaluate(iife(DETECT_COMPLETION_JS)));
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
return false;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
/** 页面**最后一条** `.answer` 是否就是平台并发/限流提示。 */
|
|
1069
|
+
async pageShowsBusyNotice() {
|
|
1070
|
+
if (!this.page)
|
|
1071
|
+
return false;
|
|
1072
|
+
try {
|
|
1073
|
+
return Boolean(await this.page.evaluate(iife(PAGE_SHOWS_BUSY_JS)));
|
|
1074
|
+
}
|
|
1075
|
+
catch {
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
/** 读取平台限制提示原文(没有则空串)。 */
|
|
1080
|
+
async readBusyNotice() {
|
|
1081
|
+
if (!this.page)
|
|
1082
|
+
return '';
|
|
1083
|
+
try {
|
|
1084
|
+
return String((await this.page.evaluate(iife(READ_BUSY_NOTICE_JS))) ?? '');
|
|
1085
|
+
}
|
|
1086
|
+
catch {
|
|
1087
|
+
return '';
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
/** 清空页面上的 SSE 捕获缓冲(每轮请求前调用)。 */
|
|
1091
|
+
async resetSseCapture() {
|
|
1092
|
+
if (!this.page)
|
|
1093
|
+
return;
|
|
1094
|
+
try {
|
|
1095
|
+
await this.page.evaluate(iife(RESET_SSE_JS));
|
|
1096
|
+
}
|
|
1097
|
+
catch {
|
|
1098
|
+
// 忽略
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
/** 读取页面捕获到的 SSE 原始文本(未经 Markdown 渲染,内容保真)。 */
|
|
1102
|
+
async readSseCapture() {
|
|
1103
|
+
if (!this.page)
|
|
1104
|
+
return '';
|
|
1105
|
+
let data;
|
|
1106
|
+
try {
|
|
1107
|
+
data = (await this.page.evaluate(iife(SSE_GET_JS)));
|
|
1108
|
+
}
|
|
1109
|
+
catch {
|
|
1110
|
+
return '';
|
|
1111
|
+
}
|
|
1112
|
+
if (!data)
|
|
1113
|
+
return '';
|
|
1114
|
+
const parts = [];
|
|
1115
|
+
if (data.text)
|
|
1116
|
+
parts.push(data.text);
|
|
1117
|
+
if (data.xhrText)
|
|
1118
|
+
parts.push(data.xhrText);
|
|
1119
|
+
return parts.join('\n');
|
|
1120
|
+
}
|
|
1121
|
+
/** 页面是否存在聊天输入框(用于判断页面是否处于可聊天的状态)。 */
|
|
1122
|
+
async hasInputBox() {
|
|
1123
|
+
if (!this.page)
|
|
1124
|
+
return false;
|
|
1125
|
+
try {
|
|
1126
|
+
return Boolean(await this.page.evaluate(iife(HAS_INPUT_BOX_JS)));
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
return false;
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
/** 统计当前对话轮数(用于校验消息是否真正发出)。 */
|
|
1133
|
+
async countConversationItems() {
|
|
1134
|
+
try {
|
|
1135
|
+
return Number(await this.page?.evaluate(iife(COUNT_CONVERSATION_ITEMS_JS))) || 0;
|
|
1136
|
+
}
|
|
1137
|
+
catch {
|
|
1138
|
+
return 0;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
/** 读取输入框当前的文本长度(-1 = 未找到输入框)。 */
|
|
1142
|
+
async inputValueLen() {
|
|
1143
|
+
try {
|
|
1144
|
+
return Number(await this.page?.evaluate(iife(INPUT_VALUE_LEN_JS)));
|
|
1145
|
+
}
|
|
1146
|
+
catch {
|
|
1147
|
+
return -1;
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
/** 输入框是否已被清空(ChatGLM 提交成功后会清空输入框)。 */
|
|
1151
|
+
async isInputCleared() {
|
|
1152
|
+
try {
|
|
1153
|
+
return Boolean(await this.page?.evaluate(iife(IS_INPUT_CLEARED_JS)));
|
|
1154
|
+
}
|
|
1155
|
+
catch {
|
|
1156
|
+
return false;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* 等待发送成功的**任一**信号:
|
|
1161
|
+
* 1. 出现新的对话轮次(`.conversation-item` 数量增加)
|
|
1162
|
+
* 2. 输入框被清空(ChatGLM 提交后会清空输入框)
|
|
1163
|
+
*
|
|
1164
|
+
* 两个信号取「或」:不同页面版本下有时只有其中一个可用。
|
|
1165
|
+
*/
|
|
1166
|
+
async waitSendSuccess(before, timeout = 12) {
|
|
1167
|
+
const deadline = Date.now() + timeout * 1000;
|
|
1168
|
+
while (Date.now() < deadline) {
|
|
1169
|
+
if ((await this.countConversationItems()) > before)
|
|
1170
|
+
return true;
|
|
1171
|
+
if (await this.isInputCleared())
|
|
1172
|
+
return true;
|
|
1173
|
+
await sleep(500);
|
|
1174
|
+
}
|
|
1175
|
+
return false;
|
|
1176
|
+
}
|
|
1177
|
+
/** 查找发送按钮(部分情况下 Enter 不生效,需要点击按钮)。 */
|
|
1178
|
+
async findSendButton() {
|
|
1179
|
+
for (const sel of SEND_BUTTON_SELECTORS) {
|
|
1180
|
+
try {
|
|
1181
|
+
const btn = await this.page?.$(sel);
|
|
1182
|
+
if (btn && (await btn.isVisible()))
|
|
1183
|
+
return btn;
|
|
1184
|
+
}
|
|
1185
|
+
catch {
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
/**
|
|
1192
|
+
* 关闭可能遮挡输入框的浮层/对话框。
|
|
1193
|
+
*
|
|
1194
|
+
* ChatGLM 基于 Element UI,页面会偶发出现遮罩层(loading/公告弹窗),
|
|
1195
|
+
* 导致 Playwright 点击输入框或发送按钮时被拦截
|
|
1196
|
+
* (报错 "subtree intercepts pointer events"),消息因此发不出去。
|
|
1197
|
+
*/
|
|
1198
|
+
async dismissBlockingOverlay() {
|
|
1199
|
+
if (!this.page)
|
|
1200
|
+
return;
|
|
1201
|
+
try {
|
|
1202
|
+
// 1) 先点掉浮层内可见的关闭按钮
|
|
1203
|
+
const closed = Number(await this.page.evaluate(iife(DISMISS_OVERLAY_CLOSE_JS))) || 0;
|
|
1204
|
+
if (closed > 0) {
|
|
1205
|
+
logger.info(`[chatglm] 已关闭 ${closed} 个可见浮层关闭按钮`);
|
|
1206
|
+
await sleep(400);
|
|
1207
|
+
}
|
|
1208
|
+
// 2) 仍有遮挡则按 Esc
|
|
1209
|
+
const blocking = Number(await this.page.evaluate(iife(DISMISS_OVERLAY_BLOCKING_JS))) || 0;
|
|
1210
|
+
if (blocking > 0) {
|
|
1211
|
+
logger.info(`[chatglm] 检测到 ${blocking} 个遮挡浮层,按 Esc 关闭`);
|
|
1212
|
+
await this.page.keyboard.press('Escape');
|
|
1213
|
+
await sleep(400);
|
|
1214
|
+
}
|
|
1215
|
+
// 3) 兜底:让残留遮罩不再拦截指针事件
|
|
1216
|
+
await this.page.evaluate(iife(DISMISS_OVERLAY_FALLBACK_JS));
|
|
1217
|
+
}
|
|
1218
|
+
catch (error) {
|
|
1219
|
+
logger.debug(`[chatglm] 关闭遮挡浮层时出现问题: ${String(error)}`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* 发送消息。
|
|
1224
|
+
*
|
|
1225
|
+
* 与旧版差异:
|
|
1226
|
+
* - 发送后**校验**是否真的产生了新对话轮次(Enter 可能未提交)
|
|
1227
|
+
* - 校验失败时最多重试 1 次,第二次改试「点击发送按钮」
|
|
1228
|
+
* - 多次失败直接抛异常,避免上游拿到空回复却以为是模型没说话
|
|
1229
|
+
*
|
|
1230
|
+
* @param sessionKey 逻辑会话 ID;发送成功后记到 `this.sessionKey`,
|
|
1231
|
+
* 供下一次请求判断能否沿用同一个网页会话
|
|
1232
|
+
* @param reuseSession true 表示本次发的就是增量、要**续写**当前会话(不导航)
|
|
1233
|
+
*/
|
|
1234
|
+
async sendMessage(message, sessionKey = null, reuseSession = false) {
|
|
1235
|
+
if (!this.page)
|
|
1236
|
+
throw new Error('浏览器未初始化');
|
|
1237
|
+
// 等待冷却时间
|
|
1238
|
+
const cooldownRemaining = this.SEND_COOLDOWN - (Date.now() - this.lastSendTime) / 1000;
|
|
1239
|
+
if (cooldownRemaining > 0) {
|
|
1240
|
+
logger.debug(`发送冷却中,剩余 ${cooldownRemaining.toFixed(1)}s`);
|
|
1241
|
+
await sleep(cooldownRemaining * 1000);
|
|
1242
|
+
}
|
|
1243
|
+
/* ===== 会话复用 / 新开会话 =====
|
|
1244
|
+
* 1) `reuseSession=true` → **直接续写**当前会话,不导航
|
|
1245
|
+
* (路由侧这次只发了增量提示词,导航会丢掉上下文,既错又慢)
|
|
1246
|
+
* 2) 否则 → 页面正处于某次对话中(URL 带 cid=)时先重开一个干净会话:
|
|
1247
|
+
* 路由侧这次发的是**完整提示词**,落在新会话里上下文才完整。
|
|
1248
|
+
* (旧行为是每次都重开:一个请求一个新对话,会话数暴涨、准备阶段也多花几秒) */
|
|
1249
|
+
const currentUrl = this.page.url() ?? '';
|
|
1250
|
+
if (!reuseSession && currentUrl.includes('cid=')) {
|
|
1251
|
+
logger.info('[chatglm] _send_message(): 上一轮对话仍在,重新打开新会话...');
|
|
1252
|
+
try {
|
|
1253
|
+
await this.page.goto(CHAT_URL, { waitUntil: 'networkidle', timeout: 30000 });
|
|
1254
|
+
}
|
|
1255
|
+
catch (error) {
|
|
1256
|
+
logger.warn(`[chatglm] _send_message(): 重新导航失败: ${String(error)}`);
|
|
1257
|
+
}
|
|
1258
|
+
await sleep(1500);
|
|
1259
|
+
}
|
|
1260
|
+
const beforeCount = await this.countConversationItems();
|
|
1261
|
+
let lastError = null;
|
|
1262
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1263
|
+
try {
|
|
1264
|
+
// 先清掉可能遮挡输入框/发送按钮的浮层,否则点击会被拦截
|
|
1265
|
+
await this.dismissBlockingOverlay();
|
|
1266
|
+
// 等待输入框就绪(最多 10s)
|
|
1267
|
+
let textarea = null;
|
|
1268
|
+
for (const sel of INPUT_SELECTORS) {
|
|
1269
|
+
for (let i = 0; i < 5 && !textarea; i += 1) {
|
|
1270
|
+
try {
|
|
1271
|
+
textarea = (await this.page.$(sel)) ?? null;
|
|
1272
|
+
}
|
|
1273
|
+
catch {
|
|
1274
|
+
textarea = null;
|
|
1275
|
+
}
|
|
1276
|
+
if (!textarea)
|
|
1277
|
+
await sleep(200);
|
|
1278
|
+
}
|
|
1279
|
+
if (textarea)
|
|
1280
|
+
break;
|
|
1281
|
+
}
|
|
1282
|
+
if (!textarea)
|
|
1283
|
+
throw new Error('未找到输入框');
|
|
1284
|
+
logger.info(`[chatglm] _send_message(): URL=${this.page.url()}, ` +
|
|
1285
|
+
`找到输入框=True, 尝试=${attempt + 1}`);
|
|
1286
|
+
// 清空输入框
|
|
1287
|
+
await this.page.evaluate(iife(CLEAR_INPUT_JS));
|
|
1288
|
+
// 填入消息。fill() 同时支持 textarea 与 [contenteditable],且内部自行 focus,
|
|
1289
|
+
// 不依赖鼠标点击 → 不会被浮层拦截。
|
|
1290
|
+
// ⚠️ 必须包超时:元素不可编辑时 fill() 会一直等「可见/可编辑」,
|
|
1291
|
+
// 实测能把整个请求拖到 300s 中间件超时(2026-09-28 的 504)。
|
|
1292
|
+
// 超时后走下面的键盘输入兜底。
|
|
1293
|
+
try {
|
|
1294
|
+
await withTimeout(textarea.fill(message), 20000, 'fill()');
|
|
1295
|
+
}
|
|
1296
|
+
catch (error) {
|
|
1297
|
+
logger.warn(`[chatglm] fill() 失败/超时,回退键盘输入: ${String(error)}`);
|
|
1298
|
+
try {
|
|
1299
|
+
// ⚠️ 必须传**真函数**:传函数字符串同样不会被调用(见 js-eval.ts)
|
|
1300
|
+
await withTimeout(textarea.evaluate((el) => el.focus()), 10000, 'focus()');
|
|
1301
|
+
}
|
|
1302
|
+
catch {
|
|
1303
|
+
// 焦点失败也继续尝试键盘输入
|
|
1304
|
+
}
|
|
1305
|
+
await this.page.keyboard.type(message, { delay: 5 });
|
|
1306
|
+
}
|
|
1307
|
+
// 回读校验:确认内容真的进了输入框(否则「清空」会被误判为发送成功)
|
|
1308
|
+
const filledLen = await this.inputValueLen();
|
|
1309
|
+
if (filledLen <= 0)
|
|
1310
|
+
throw new Error('输入框内容为空,fill() 未生效');
|
|
1311
|
+
logger.info(`[chatglm] 输入框已填入 ${filledLen} 字符`);
|
|
1312
|
+
// 重新获取句柄:Vue 重渲染后旧句柄可能脱离 DOM
|
|
1313
|
+
let ta2 = null;
|
|
1314
|
+
for (const sel of INPUT_SELECTORS) {
|
|
1315
|
+
ta2 = (await this.page.$(sel)) ?? null;
|
|
1316
|
+
if (ta2)
|
|
1317
|
+
break;
|
|
1318
|
+
}
|
|
1319
|
+
const target = ta2 ?? textarea;
|
|
1320
|
+
// 提交:第 1 次用 Enter;第 2 次优先点发送按钮(force 绕过遮挡)
|
|
1321
|
+
let submitted = false;
|
|
1322
|
+
if (attempt === 1) {
|
|
1323
|
+
const btn = await this.findSendButton();
|
|
1324
|
+
if (btn) {
|
|
1325
|
+
try {
|
|
1326
|
+
await btn.click({ force: true, timeout: 5000 });
|
|
1327
|
+
submitted = true;
|
|
1328
|
+
logger.info('[chatglm] _send_message(): 已点击发送按钮');
|
|
1329
|
+
}
|
|
1330
|
+
catch (error) {
|
|
1331
|
+
logger.warn(`[chatglm] 发送按钮点击失败,回退 Enter: ${String(error)}`);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
if (!submitted) {
|
|
1336
|
+
// 用元素级 press(内部会先 focus),避免 page.keyboard 因焦点
|
|
1337
|
+
// 被浮层/Vue 重渲染抢走而把 Enter 丢给别的元素
|
|
1338
|
+
try {
|
|
1339
|
+
await target.press('Enter', { timeout: 5000 });
|
|
1340
|
+
}
|
|
1341
|
+
catch (error) {
|
|
1342
|
+
logger.warn(`[chatglm] 元素级 Enter 失败,回退 page.keyboard: ${String(error)}`);
|
|
1343
|
+
await this.page.keyboard.press('Enter');
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
// 校验:新对话轮次 或 输入框被清空
|
|
1347
|
+
if (await this.waitSendSuccess(beforeCount, 12)) {
|
|
1348
|
+
this.lastSendTime = Date.now();
|
|
1349
|
+
await this.markUsed();
|
|
1350
|
+
// 记下「当前页面上的会话属于哪个逻辑会话」,供下一次请求判断能否沿用
|
|
1351
|
+
// (只发增量)而不是重开新对话。
|
|
1352
|
+
// ⚠️ 必须**无条件覆盖**(包括 sessionKey=null):两轮策略的 reparse 会带着
|
|
1353
|
+
// null 往页面里发一次转换请求,页面此时已经切到别的会话;若还留着旧的
|
|
1354
|
+
// sessionKey,下一次请求就会把「增量」发进错误的会话里 → 上下文丢失。
|
|
1355
|
+
this.sessionKey = sessionKey;
|
|
1356
|
+
this.sessionPage = this.page;
|
|
1357
|
+
logger.info('[chatglm] 消息已确认提交');
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
logger.warn(`[chatglm] 第 ${attempt + 1} 次发送后未检测到新对话轮次,准备重试`);
|
|
1361
|
+
}
|
|
1362
|
+
catch (error) {
|
|
1363
|
+
lastError = error;
|
|
1364
|
+
logger.warn(`[chatglm] 第 ${attempt + 1} 次发送异常: ${String(error)}`);
|
|
1365
|
+
}
|
|
1366
|
+
await sleep(1000);
|
|
1367
|
+
}
|
|
1368
|
+
this.lastSendTime = Date.now();
|
|
1369
|
+
await this.markUsed();
|
|
1370
|
+
throw new Error(`消息发送失败:多次尝试后仍未检测到新对话轮次 (${String(lastError)})`);
|
|
1371
|
+
}
|
|
1372
|
+
/* ================================================================== */
|
|
1373
|
+
/* 对外接口 */
|
|
1374
|
+
/* ================================================================== */
|
|
1375
|
+
/** 创建新会话(ChatGLM 不需要,返回固定 ID)。 */
|
|
1376
|
+
async createConversation() {
|
|
1377
|
+
return 'default_conversation';
|
|
1378
|
+
}
|
|
1379
|
+
/** 删除会话(ChatGLM 不需要实现)。 */
|
|
1380
|
+
async deleteConversation(_conversationId) {
|
|
1381
|
+
// 空实现:ChatGLM 侧没有可删除的会话对象
|
|
1382
|
+
}
|
|
1383
|
+
/** 非流式聊天(调用 `streamResponse` 并聚合)。 */
|
|
1384
|
+
async chat(request) {
|
|
1385
|
+
await this.ensureInitialized();
|
|
1386
|
+
let content = '';
|
|
1387
|
+
try {
|
|
1388
|
+
for await (const response of this.streamResponse(request)) {
|
|
1389
|
+
if (response.error)
|
|
1390
|
+
return { error: response.error };
|
|
1391
|
+
if (response.content)
|
|
1392
|
+
content += response.content;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
catch (error) {
|
|
1396
|
+
return { error: String(error) };
|
|
1397
|
+
}
|
|
1398
|
+
return { content: sanitizeMessage(content) };
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* 流式聊天。
|
|
1402
|
+
*
|
|
1403
|
+
* **必须串行化**:整个 provider 只有 1 个浏览器页面,若两个请求同时在页面上
|
|
1404
|
+
* 发消息,ChatGLM 会返回「请等待其他对话生成完毕 / 本次回答已被终止 / 重新回答」,
|
|
1405
|
+
* 并且该提示会被当成正常回复返回给客户端。
|
|
1406
|
+
*/
|
|
1407
|
+
async *chatStream(request) {
|
|
1408
|
+
logger.info('[chatglm] chatStream() 被调用(等待串行锁)');
|
|
1409
|
+
const release = await this.lock.acquire();
|
|
1410
|
+
logger.info('[chatglm] 已获得串行锁,开始处理请求');
|
|
1411
|
+
// 占住「正在这个账号上干活」的登记:主通道换号时靠它避开
|
|
1412
|
+
// 正在被辅助通道使用的账号(否则切过去立刻撞平台并发提示)。
|
|
1413
|
+
enterAccount(PLATFORM, this.accountId);
|
|
1414
|
+
// 请求方(routes)可要求把这次对话放到「辅助页面」上跑:两轮策略的 reparse
|
|
1415
|
+
// 要往网页里再发一次对话,若共用主页面就会把当前会话切走 ——
|
|
1416
|
+
// 主会话上下文丢了、会话复用(只发增量)失效。
|
|
1417
|
+
//
|
|
1418
|
+
// ⚠️ 判据是**角色**(`isMainChannel`)而不是「是否在专用页面上」:
|
|
1419
|
+
// 主通道被限流后切到另一个账号的独立页面时,它依然是主通道,
|
|
1420
|
+
// 这时它自己的页面就是主会话页面,辅助请求**仍必须**另开页面。
|
|
1421
|
+
const ctx = request.context ?? {};
|
|
1422
|
+
const useAux = Boolean(ctx['use_aux_page']) && this.isMainChannel;
|
|
1423
|
+
const savedPage = useAux ? await this.enterAuxPage() : null;
|
|
1424
|
+
try {
|
|
1425
|
+
// ⚠️ 走「辅助页面」的请求(压缩/转换回落到本实例时)**不参与轮换**:
|
|
1426
|
+
// 它已经切到辅助页、并由 `finally` 负责切回主页面,
|
|
1427
|
+
// 此时再换账号会把两者状态搅在一起(换号后又被切回旧页面)。
|
|
1428
|
+
const canRotateHere = this.allowRotate && !useAux;
|
|
1429
|
+
// 进门前若当前账号正在冷却(刚被限流过)→ 先换到空闲账号,
|
|
1430
|
+
// 省掉一轮注定失败的往返
|
|
1431
|
+
if (canRotateHere)
|
|
1432
|
+
await this.rotateIfCoolingOnEntry(request);
|
|
1433
|
+
const fullMsg = asString(ctx['full_message'])?.trim() ?? '';
|
|
1434
|
+
const tried = new Set([this.accountId]);
|
|
1435
|
+
let rotations = 0;
|
|
1436
|
+
for (;;) {
|
|
1437
|
+
await this.ensureInitialized();
|
|
1438
|
+
logger.info('[chatglm] 开始迭代 _stream_response()');
|
|
1439
|
+
for await (const response of this.streamResponse(request)) {
|
|
1440
|
+
logger.debug(`[chatglm] yield response, content_len=${response.content ? response.content.length : 0}`);
|
|
1441
|
+
yield response;
|
|
1442
|
+
}
|
|
1443
|
+
// 没撞到平台限制 → 正常结束
|
|
1444
|
+
if (!this.lastLimitNotice)
|
|
1445
|
+
break;
|
|
1446
|
+
const notice = this.lastLimitNotice;
|
|
1447
|
+
this.lastLimitNotice = null;
|
|
1448
|
+
// 被限流 → 当前账号进冷却(后面的请求不再优先用它)
|
|
1449
|
+
markAccountCooldown(PLATFORM, this.accountId, this.ROTATE_COOLDOWN);
|
|
1450
|
+
logger.warn(`[chatglm] 账号 ${this.accountId} 撞到平台限制「${notice}」,` +
|
|
1451
|
+
`标记冷却 ${this.ROTATE_COOLDOWN.toFixed(0)}s`);
|
|
1452
|
+
const target = canRotateHere ? this.pickRotateAccount(tried) : null;
|
|
1453
|
+
if (!target || rotations >= this.MAX_ROTATE_PER_REQUEST) {
|
|
1454
|
+
// 没得换(只有一个账号 / 别的账号都在忙或在冷却)→ 回传提示,
|
|
1455
|
+
// 让客户端至少知道发生了什么,而不是拿到空回复
|
|
1456
|
+
logger.warn(`[chatglm] 没有可切换的账号(候选=${[...tried].join(',')}),回传平台限制提示`);
|
|
1457
|
+
yield {
|
|
1458
|
+
content: `[ChatGLM 平台限制] ${notice}。` +
|
|
1459
|
+
'本次请求未得到回复,请稍等 1~2 分钟后重试。',
|
|
1460
|
+
};
|
|
1461
|
+
break;
|
|
1462
|
+
}
|
|
1463
|
+
logger.warn(`[chatglm] 账号 ${this.accountId} 被限流 → 切换到 ${target} 重试` +
|
|
1464
|
+
`(换号 = 新网页会话,改发完整提示词 ${fullMsg.length} 字符)`);
|
|
1465
|
+
this.rotateToAccount(target);
|
|
1466
|
+
tried.add(target);
|
|
1467
|
+
rotations += 1;
|
|
1468
|
+
// ⚠️ 换了账号 = 新网页会话,绝不能发增量(那会丢掉全部上下文)。
|
|
1469
|
+
// `request.message` 平时可能是增量;这里显式换成完整提示词。
|
|
1470
|
+
if (fullMsg) {
|
|
1471
|
+
request.message = fullMsg;
|
|
1472
|
+
delete ctx['delta_message'];
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
catch (error) {
|
|
1477
|
+
logger.error(`流式聊天失败: ${String(error)}`);
|
|
1478
|
+
yield { error: String(error) };
|
|
1479
|
+
}
|
|
1480
|
+
finally {
|
|
1481
|
+
exitAccount(PLATFORM, this.accountId);
|
|
1482
|
+
if (useAux)
|
|
1483
|
+
await this.leaveAuxPage(savedPage);
|
|
1484
|
+
release();
|
|
1485
|
+
logger.info('[chatglm] 释放串行锁');
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
/* ================================================================== */
|
|
1489
|
+
/* 主通道账号轮换(被限流 / 额度耗尽时换另一个账号继续答) */
|
|
1490
|
+
/* ================================================================== */
|
|
1491
|
+
/** 同平台所有可用账号 ID(排除没有 Cookie 的)。 */
|
|
1492
|
+
accountCandidates() {
|
|
1493
|
+
const out = [];
|
|
1494
|
+
try {
|
|
1495
|
+
for (const acc of listAccounts(PLATFORM)) {
|
|
1496
|
+
const auth = loadAuth(PLATFORM, { accountId: acc });
|
|
1497
|
+
if (auth?.cookies?.length)
|
|
1498
|
+
out.push(acc);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
catch (error) {
|
|
1502
|
+
logger.warn(`[chatglm] 列出候选账号失败: ${String(error)}`);
|
|
1503
|
+
}
|
|
1504
|
+
return out;
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* 挑一个可以接手的账号。
|
|
1508
|
+
*
|
|
1509
|
+
* 跳过:本轮已经试过的、正在冷却的、此刻正被别的角色(辅助通道)使用的。
|
|
1510
|
+
* 被**闲置登记**(`role` 有记录但此刻不忙)占着的账号允许抢占 ——
|
|
1511
|
+
* 辅助通道会在下一次挑账号时自动避开它。
|
|
1512
|
+
*/
|
|
1513
|
+
pickRotateAccount(exclude) {
|
|
1514
|
+
for (const acc of this.accountCandidates()) {
|
|
1515
|
+
if (exclude.has(acc))
|
|
1516
|
+
continue;
|
|
1517
|
+
if (accountCooldownRemaining(PLATFORM, acc) > 0)
|
|
1518
|
+
continue;
|
|
1519
|
+
if (isAccountBusy(PLATFORM, acc)) {
|
|
1520
|
+
logger.info(`[chatglm] 账号 ${acc} 正在被其他通道使用,跳过`);
|
|
1521
|
+
continue;
|
|
1522
|
+
}
|
|
1523
|
+
return acc;
|
|
1524
|
+
}
|
|
1525
|
+
return null;
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* 把主通道切到指定账号(走「独立 context 的专用页面」)。
|
|
1529
|
+
*
|
|
1530
|
+
* ⚠️ 必须换 context:不同账号 = 不同 Cookie jar。沿用全局页面会把
|
|
1531
|
+
* 新账号的 Cookie 加到旧账号的 context 上,等于没换。
|
|
1532
|
+
*/
|
|
1533
|
+
rotateToAccount(accountId) {
|
|
1534
|
+
const old = this.accountId;
|
|
1535
|
+
clearAccountRole(PLATFORM, old, 'main');
|
|
1536
|
+
// busy 占用必须跟着换号一起转移:`enterAccount()` 是进门时按「旧账号」登记的,
|
|
1537
|
+
// 而 `chatStream()` 的 finally 会用「新账号」调 `exitAccount()`。
|
|
1538
|
+
// 不转移 → 旧账号被永久标记为「正忙」(辅助通道/后续换号都不敢再用它)。
|
|
1539
|
+
transferAccountBusy(PLATFORM, old, accountId);
|
|
1540
|
+
this.accountId = accountId;
|
|
1541
|
+
this.channelKey = `${PLATFORM}:${accountId}`;
|
|
1542
|
+
// 页面/session 状态全部作废:下一个账号是全新的网页会话
|
|
1543
|
+
this.page = null;
|
|
1544
|
+
this.sessionKey = null;
|
|
1545
|
+
this.sessionPage = null;
|
|
1546
|
+
this.responseJsInjected = false;
|
|
1547
|
+
this.cookiesLoaded = true; // Cookie 由通道 context 注入,不再走全局 loadCookies
|
|
1548
|
+
this.loggedIn = false;
|
|
1549
|
+
this.lastLoginCheck = 0;
|
|
1550
|
+
setAccountRole(PLATFORM, accountId, 'main');
|
|
1551
|
+
}
|
|
1552
|
+
/** 请求进门时,若当前账号还在冷却中就直接换号(省一轮注定失败的往返)。 */
|
|
1553
|
+
async rotateIfCoolingOnEntry(request) {
|
|
1554
|
+
if (!this.allowRotate)
|
|
1555
|
+
return;
|
|
1556
|
+
if (accountCooldownRemaining(PLATFORM, this.accountId) <= 0)
|
|
1557
|
+
return;
|
|
1558
|
+
const target = this.pickRotateAccount(new Set([this.accountId]));
|
|
1559
|
+
if (!target) {
|
|
1560
|
+
logger.info(`[chatglm] 账号 ${this.accountId} 仍在冷却中,但没有可切换的账号 → 继续使用`);
|
|
1561
|
+
return;
|
|
1562
|
+
}
|
|
1563
|
+
logger.info(`[chatglm] 账号 ${this.accountId} 仍在冷却中 → 进门即切换到 ${target}`);
|
|
1564
|
+
this.rotateToAccount(target);
|
|
1565
|
+
const full = asString((request.context ?? {})['full_message'])?.trim() ?? '';
|
|
1566
|
+
if (full) {
|
|
1567
|
+
request.message = full;
|
|
1568
|
+
delete (request.context ?? {})['delta_message'];
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
/* ================================================================== */
|
|
1572
|
+
/* 辅助页面(两轮策略的 reparse 专用,与主会话隔离) */
|
|
1573
|
+
/* ================================================================== */
|
|
1574
|
+
/**
|
|
1575
|
+
* 切到辅助页面,并把主页面状态原样收好(返回 saved,失败返回 null)。
|
|
1576
|
+
*
|
|
1577
|
+
* 辅助页面是**懒创建**且长期保留的:它专供 reparse 使用,
|
|
1578
|
+
* 每次 reparse 都会在它上面开一个新对话(不影响主页面上的会话)。
|
|
1579
|
+
*/
|
|
1580
|
+
async enterAuxPage() {
|
|
1581
|
+
const saved = {
|
|
1582
|
+
page: this.page,
|
|
1583
|
+
sessionKey: this.sessionKey,
|
|
1584
|
+
sessionPage: this.sessionPage,
|
|
1585
|
+
};
|
|
1586
|
+
try {
|
|
1587
|
+
let page = this.auxPage;
|
|
1588
|
+
if (page === null || page.isClosed()) {
|
|
1589
|
+
page = await newWorkPage();
|
|
1590
|
+
this.auxPage = page;
|
|
1591
|
+
}
|
|
1592
|
+
if (page === null)
|
|
1593
|
+
return null;
|
|
1594
|
+
this.page = page;
|
|
1595
|
+
// ⚠️ 每次进来都重开一个干净的对话页(不能只在「不在 chatglm 站点」时导航):
|
|
1596
|
+
// 实测复用旧辅助页面时踩到输入框不可编辑(上一轮生成没结束 / 有浮层 /
|
|
1597
|
+
// Vue 状态异常),`fill()` 会一直等「元素可见可编辑」直到请求 300s 超时
|
|
1598
|
+
// (2026-09-28 21:30 的 504 就是这么来的)。
|
|
1599
|
+
// 辅助页面是草稿页,重开成本只有 1~2s,换来的是稳定。
|
|
1600
|
+
try {
|
|
1601
|
+
await page.goto(CHAT_URL, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
1602
|
+
await this.dismissBlockingOverlay();
|
|
1603
|
+
await sleep(1000);
|
|
1604
|
+
}
|
|
1605
|
+
catch (error) {
|
|
1606
|
+
logger.warn(`[chatglm] 辅助页面重开失败(继续尝试使用): ${String(error)}`);
|
|
1607
|
+
}
|
|
1608
|
+
this.sessionKey = null; // 辅助页面上没有可复用的主会话
|
|
1609
|
+
this.sessionPage = null;
|
|
1610
|
+
logger.info('[chatglm] 已切到辅助页面(独立对话,主会话保持不变)');
|
|
1611
|
+
return saved;
|
|
1612
|
+
}
|
|
1613
|
+
catch (error) {
|
|
1614
|
+
logger.warn(`[chatglm] 切到辅助页面失败,将使用主页面: ${String(error)}`);
|
|
1615
|
+
this.page = saved.page;
|
|
1616
|
+
this.sessionKey = saved.sessionKey;
|
|
1617
|
+
this.sessionPage = saved.sessionPage;
|
|
1618
|
+
return null;
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
/** 从辅助页面切回主页面(主会话上下文完好,可继续复用)。 */
|
|
1622
|
+
async leaveAuxPage(saved) {
|
|
1623
|
+
if (!saved)
|
|
1624
|
+
return;
|
|
1625
|
+
this.page = saved.page;
|
|
1626
|
+
this.sessionKey = saved.sessionKey;
|
|
1627
|
+
this.sessionPage = saved.sessionPage;
|
|
1628
|
+
logger.info('[chatglm] 已切回主页面(主会话上下文保持,后续可继续只发增量)');
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
//# sourceMappingURL=chatglm.js.map
|