weflow-cli 1.8.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/OPERATIONS.md +42 -6
  3. package/README.en.md +3 -3
  4. package/README.md +3 -3
  5. package/bin/weflow-cli.ts +3 -1
  6. package/dist/bin/weflow-cli.js +3 -1
  7. package/dist/bin/weflow-cli.js.map +1 -1
  8. package/dist/src/panel/server.js +1 -1
  9. package/dist/src/panel/server.js.map +1 -1
  10. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  11. package/dist/src/services/assistantPrivacy.js +7 -2
  12. package/dist/src/services/assistantPrivacy.js.map +1 -1
  13. package/dist/src/services/assistantService.d.ts.map +1 -1
  14. package/dist/src/services/assistantService.js +10 -1
  15. package/dist/src/services/assistantService.js.map +1 -1
  16. package/dist/src/services/assistantTools.d.ts +12 -0
  17. package/dist/src/services/assistantTools.d.ts.map +1 -1
  18. package/dist/src/services/assistantTools.js +29 -3
  19. package/dist/src/services/assistantTools.js.map +1 -1
  20. package/dist/src/services/wereadService.d.ts +33 -6
  21. package/dist/src/services/wereadService.d.ts.map +1 -1
  22. package/dist/src/services/wereadService.js +27 -2
  23. package/dist/src/services/wereadService.js.map +1 -1
  24. package/docs/BRANCHES.md +13 -4
  25. package/docs/PROJECT_STATE.md +299 -299
  26. package/package.json +1 -1
  27. package/resources/panel/main.cjs +43 -7
  28. package/resources/panel/mascot-thinking.png +0 -0
  29. package/resources/panel/panel.css +424 -407
  30. package/resources/panel/preload.cjs +79 -72
  31. package/resources/panel/renderer.js +470 -441
  32. package/scripts/_utils.py +23 -1
  33. package/scripts/backfill_articles.py +438 -438
  34. package/scripts/compile_wiki.py +18 -17
  35. package/scripts/wiki_lint.py +429 -397
  36. package/src/panel/server.ts +424 -424
  37. package/src/services/assistantPrivacy.ts +7 -2
  38. package/src/services/assistantService.ts +11 -1
  39. package/src/services/assistantTools.ts +33 -3
  40. package/src/services/wereadService.ts +43 -7
  41. package/resources/panel/mascot-focus.png +0 -0
@@ -1,424 +1,424 @@
1
- /**
2
- * 助手守护进程的本机入口:**只绑回环**的 HTTP 端点,给悬浮窗(和 `weflow-cli panel`)用。
3
- *
4
- * 为什么要有它:助手的配额计数器、串行队列、被那条队列保护的实例字段都是**进程内状态**,
5
- * 所以"两个入口"必须落在**一个宿主进程**里。悬浮窗自己 `new AssistantService()` 会变成
6
- * 第二个大脑:配额各算各的、两进程并发写同一个 userId 的记忆(`save()` 虽是合并写,
7
- * 但同一个 userId 仍是后写覆盖先写)。**面板只做客户端。**
8
- *
9
- * ## 信任边界(D-045)
10
- *
11
- * 这条端点能读到用户的聊天数据,所以照着 `docs/DECISIONS.md` 的 D-004(阅读器只绑回环)
12
- * 往下走,但**必须比它严**:
13
- *
14
- * - **绑 `127.0.0.1`,不可配置**。不是 LAN 服务。
15
- * - **每个请求都要 token**,`GET /api/status` 也不例外。阅读器(`scripts/fav_server.py`)
16
- * 根本没有 token,而且它的 Origin 校验**不带 Origin 就放行**——那是给"本机阅读器"的设计,
17
- * 不能照抄到这个能替用户读聊天记录的端点上来。
18
- * - **Origin 是第二道**:带了 Origin 但不在白名单 → 403,**即使 token 正确**。
19
- * 反过来,**不带** Origin 要放行:`file://` 加载的 Electron 渲染进程与 `curl` 都是这种形态,
20
- * 硬要求 Origin 会把面板自己挡在门外。
21
- * - **POST 必须 `Content-Type: application/json`**:挡掉"跨站简单请求"那种不发 preflight 的形态。
22
- * - **同时只允许一轮在飞**:单轮最坏是分钟级(6 轮工具 + 守卫一轮 + 压缩/抽取各一次 LLM),
23
- * 用户在转圈时连按回车会往队列里挂一串——每一个都烧配额。第 2 个直接 429,不入队。
24
- *
25
- * 与助手出站那个 `isSafeUrl()`(`assistantTools.ts`)**不是一回事**:它管的是"助手去抓网页",
26
- * 回答的是另一个问题(那边**拒绝**回环地址)。别把两者"统一"起来。
27
- */
28
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
29
- import { readFileSync, existsSync } from 'node:fs'
30
- import { join } from 'node:path'
31
- import { randomBytes } from 'node:crypto'
32
- import {
33
- DEFAULT_PANEL_PORT, PANEL_ENDPOINT_VERSION, PANEL_SERVICE_ID, generateToken, tokenMatches,
34
- writeEndpoint, type PanelChannelMode, type PanelEndpoint,
35
- } from './endpoint.js'
36
- import type { AssistantService, TurnOutcome } from '../services/assistantService.js'
37
- import { privacyGate } from '../services/assistantPrivacy.js'
38
- import { configService } from '../services/configService.js'
39
- import { resolvePackageRoot } from '../utils/packageRoot.js'
40
-
41
- /** 8KB 够一句问题用;超了就是有人在灌东西 */
42
- const MAX_BODY_BYTES = 8 * 1024
43
- /** 一轮最坏分钟级(见上),给足余量;客户端那边也有自己的超时 */
44
- const TURN_TIMEOUT_MS = 180_000
45
- /** 浏览器那条路的会话 cookie 名。值是 token 本身,但 `HttpOnly` —— 页面脚本拿不到它 */
46
- const COOKIE_NAME = 'weflow_panel'
47
- /** 一次性口令的有效期。只用来"换 cookie"这一次,换个页面就该过期了 */
48
- const PAIR_TTL_MS = 60_000
49
- /**
50
- * 静态文件**白名单**。不是"把目录挂出去":只认这三个名字,其余一律 404。
51
- * 挂目录就得处理路径穿越,而这里根本不需要那个能力。
52
- */
53
- const STATIC_FILES: Record<string, string> = {
54
- '/panel': 'index.html',
55
- '/panel/': 'index.html',
56
- '/panel/index.html': 'index.html',
57
- '/panel/renderer.js': 'renderer.js',
58
- '/panel/panel.css': 'panel.css',
59
- // 悬浮球上那张图(项目吉祥物)。走白名单而不是挂目录,理由同上。
60
- '/panel/mascot.png': 'mascot.png',
61
- // 被捏一下时换的那张(同一只猫,眯眼笑)。**必须一起进白名单**——漏了它,
62
- // 球在按下的一瞬间会闪成一张空图(`PANEL_ASSET_MISSING` 只写在响应里,页面上看不到)。
63
- '/panel/mascot-happy.png': 'mascot-happy.png',
64
- // 三个真状态的脸(忙/连不上/额度用完)。与上面那张一样,**必须一起进白名单**——
65
- // 漏一张,那个状态一出现球就闪成空图,而响应里的 PANEL_ASSET_MISSING 页面上看不到。
66
- '/panel/mascot-focus.png': 'mascot-focus.png',
67
- '/panel/mascot-sorry.png': 'mascot-sorry.png',
68
- '/panel/mascot-tired.png': 'mascot-tired.png',
69
- }
70
- const CONTENT_TYPES: Record<string, string> = {
71
- '.html': 'text/html; charset=utf-8',
72
- '.js': 'text/javascript; charset=utf-8',
73
- '.css': 'text/css; charset=utf-8',
74
- '.png': 'image/png',
75
- }
76
-
77
- export interface PanelServerOptions {
78
- service: AssistantService
79
- memoryBucket: string
80
- /** 给人看的一句话:这个桶是不是与微信共用的,以及为什么。界面要显示它 */
81
- memoryNote?: string
82
- channel: PanelChannelMode
83
- port?: number
84
- onLog?: (line: string) => void
85
- }
86
-
87
- export interface PanelServer {
88
- port: number
89
- endpoint: PanelEndpoint
90
- close: () => Promise<void>
91
- }
92
-
93
- function json(res: ServerResponse, status: number, payload: unknown): void {
94
- const body = JSON.stringify(payload)
95
- res.writeHead(status, {
96
- 'Content-Type': 'application/json; charset=utf-8',
97
- 'Cache-Control': 'no-store',
98
- 'X-Content-Type-Options': 'nosniff',
99
- })
100
- res.end(body)
101
- }
102
-
103
- /** 只认本机来源。带 Origin 就必须对得上;不带 Origin 放行(curl / file:// 的 Electron) */
104
- export function isLocalOrigin(origin: string | undefined, port: number): boolean {
105
- if (!origin) return true
106
- return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`
107
- }
108
-
109
- function bearer(req: IncomingMessage): string | undefined {
110
- const header = req.headers['authorization']
111
- if (typeof header !== 'string') return undefined
112
- const match = /^Bearer\s+(.+)$/i.exec(header.trim())
113
- return match ? match[1] : undefined
114
- }
115
-
116
- function cookieToken(req: IncomingMessage): string | undefined {
117
- const raw = req.headers['cookie']
118
- if (typeof raw !== 'string') return undefined
119
- for (const part of raw.split(';')) {
120
- const [name, ...rest] = part.trim().split('=')
121
- if (name === COOKIE_NAME) return rest.join('=')
122
- }
123
- return undefined
124
- }
125
-
126
- /**
127
- * 这一份请求是拿什么来证明自己的:CLI/Python 走 `Authorization`,**浏览器走 cookie**。
128
- *
129
- * 浏览器为什么不能也用 header:页面脚本一旦拿得到 token,就等于把 token 交给了渲染进程,
130
- * 而渲染进程要显示的是**助手回复**——那里面有用户的聊天内容。用 `HttpOnly` cookie,
131
- * 脚本就永远读不到它(防的是 XSS 拿 token 去干别的事),同时页面照常能发请求。
132
- */
133
- function presentedToken(req: IncomingMessage): string | undefined {
134
- return bearer(req) ?? cookieToken(req)
135
- }
136
-
137
- /**
138
- * 读请求体,超限就**立刻**给结论,但**不掐连接**。
139
- *
140
- * 第一版在这里 `req.destroy()` 了:结果是客户端看到"连接被重置"而不是 413
141
- * ——`fetch` 直接抛 `fetch failed`,调用方拿不到那个状态码。所以超限之后**丢弃余下的字节**
142
- * 而不是断开(回环端点上的这点带宽代价,比"给不出 413"划算得多)。
143
- */
144
- function readBody(req: IncomingMessage): Promise<{ ok: true; text: string } | { ok: false; code: string }> {
145
- return new Promise((resolve) => {
146
- let size = 0
147
- let tooLarge = false
148
- const chunks: Buffer[] = []
149
- req.on('data', (chunk: Buffer) => {
150
- if (tooLarge) return // 已经下过结论了,余下的丢掉
151
- size += chunk.length
152
- if (size > MAX_BODY_BYTES) {
153
- tooLarge = true
154
- resolve({ ok: false, code: 'PAYLOAD_TOO_LARGE' })
155
- return
156
- }
157
- chunks.push(chunk)
158
- })
159
- req.on('end', () => { if (!tooLarge) resolve({ ok: true, text: Buffer.concat(chunks).toString('utf8') }) })
160
- req.on('error', () => resolve({ ok: false, code: 'BODY_READ_FAILED' }))
161
- })
162
- }
163
-
164
- /**
165
- * 端口的环境变量覆盖。**只给测试用**(`WEFLOW_PANEL_PORT=0` 让内核分配一个临时端口),
166
- * 与 `WEFLOW_ASSISTANT_EXPORT_ROOT` 是同一个先例。
167
- *
168
- * 为什么需要它:测试里 `AssistantService.start()` 会真的把这个端点起起来,
169
- * 于是**每个用到 store 的测试都会去抢生产端口 8766**——真守护进程在跑时就是 EADDRINUSE,
170
- * 而且测试之间也会互相抢。非法值一律当没给(不猜、不静默用一个奇怪的端口)。
171
- */
172
- function portFromEnv(): number | undefined {
173
- const raw = process.env.WEFLOW_PANEL_PORT
174
- if (raw === undefined || raw === '') return undefined
175
- const parsed = Number(raw)
176
- if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) return undefined
177
- return parsed
178
- }
179
-
180
- /**
181
- * 起端点。返回**实际**监听的端口——调用方要把它写进端点文件,不要用请求的那个值。
182
- */
183
- export async function startPanelServer(options: PanelServerOptions): Promise<PanelServer> {
184
- const { service, memoryBucket, memoryNote = '', channel, onLog } = options
185
- const wantedPort = options.port ?? portFromEnv() ?? DEFAULT_PANEL_PORT
186
- const token = generateToken()
187
- const startedAt = new Date().toISOString()
188
- let inFlight = false
189
-
190
- const server: Server = createServer((req, res) => {
191
- void handle(req, res).catch(() => {
192
- if (!res.headersSent) json(res, 500, { ok: false, code: 'INTERNAL' })
193
- else res.end()
194
- })
195
- })
196
-
197
- /**
198
- * 一次性口令 → cookie。**为什么要有这一步**:浏览器那条路(Edge `--app`)得先拿到凭据,
199
- * 而**不能把 token 放进 URL**——命令行对同机任何进程可见(`wmic process get commandline`),
200
- * 而且 URL 会进浏览器历史。所以给的是一个**一次性、60 秒、用完即废**的口令。
201
- */
202
- const pairCodes = new Map<string, number>()
203
-
204
- function issuePairCode(): string {
205
- const code = randomBytes(18).toString('base64url')
206
- pairCodes.set(code, Date.now() + PAIR_TTL_MS)
207
- // 顺手清掉过期的,别让这张表无限长
208
- for (const [k, exp] of pairCodes) if (exp < Date.now()) pairCodes.delete(k)
209
- return code
210
- }
211
-
212
- function consumePairCode(code: string): boolean {
213
- const exp = pairCodes.get(code)
214
- if (!exp) return false
215
- pairCodes.delete(code) // **单次使用**:即使它被日志/历史记下来,也已经废了
216
- return exp >= Date.now()
217
- }
218
-
219
- function serveStatic(res: ServerResponse, fileName: string): void {
220
- try {
221
- const full = join(resolvePackageRoot(import.meta.url), 'resources', 'panel', fileName)
222
- if (!existsSync(full)) { json(res, 503, { ok: false, code: 'PANEL_ASSET_MISSING', file: fileName }); return }
223
- const ext = fileName.slice(fileName.lastIndexOf('.'))
224
- res.writeHead(200, {
225
- 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream',
226
- 'Cache-Control': 'no-store',
227
- // CSP 走响应头而不是 <meta>:头不能被页面内容绕过
228
- 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; "
229
- + "connect-src 'self'; img-src 'self' data:; form-action 'none'; base-uri 'none'",
230
- 'X-Content-Type-Options': 'nosniff',
231
- 'Referrer-Policy': 'no-referrer',
232
- })
233
- res.end(readFileSync(full))
234
- } catch {
235
- json(res, 500, { ok: false, code: 'INTERNAL' })
236
- }
237
- }
238
-
239
- async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
240
- const port = (server.address() as { port: number } | null)?.port ?? wantedPort
241
- const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`)
242
-
243
- if (!isLocalOrigin(req.headers.origin as string | undefined, port)) {
244
- json(res, 403, { ok: false, code: 'ORIGIN_DENIED' })
245
- return
246
- }
247
-
248
- // ---------------------------------------------------------- 页面(浏览器那条路)
249
- const asset = STATIC_FILES[url.pathname]
250
- if (asset) {
251
- if (req.method !== 'GET') { json(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' }); return }
252
- const code = url.searchParams.get('c')
253
- const authed = tokenMatches(token, presentedToken(req))
254
- if (!authed) {
255
- // 没带凭据:只接受一次性口令,并把 cookie 种上(token **不进 URL**,见 issuePairCode)
256
- if (!code || !consumePairCode(code)) {
257
- json(res, 401, { ok: false, code: 'UNAUTHORIZED', hint: '用 weflow-cli panel 打开' })
258
- return
259
- }
260
- res.setHeader('Set-Cookie', `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/`)
261
- }
262
- // 只在**页面本身**加载时记一行。不记 `/api/status`:面板每 30 秒轮询一次状态,
263
- // 记它会把日志灌满,而它想回答的问题("界面开着吗、按哪条路进来的")这一行就答了。
264
- if (asset === 'index.html') {
265
- onLog?.(`[panel] 界面已加载(${code && !authed ? '一次性口令' : 'cookie'})`)
266
- }
267
- serveStatic(res, asset)
268
- return
269
- }
270
-
271
- if (!tokenMatches(token, presentedToken(req))) {
272
- // 不回显任何东西:错的 token 与缺的 token 得到完全一样的响应
273
- json(res, 401, { ok: false, code: 'UNAUTHORIZED' })
274
- return
275
- }
276
-
277
- if (url.pathname === '/api/pair') {
278
- if (req.method !== 'POST') { json(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' }); return }
279
- // 换口令这一步**必须带真实 token**(上面的检查已经过了),所以本机别的东西换不到
280
- const code = issuePairCode()
281
- json(res, 200, {
282
- ok: true, code, expiresInMs: PAIR_TTL_MS,
283
- url: `http://127.0.0.1:${port}/panel?c=${code}`,
284
- })
285
- return
286
- }
287
-
288
- if (url.pathname === '/api/status') {
289
- if (req.method !== 'GET') { json(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' }); return }
290
- json(res, 200, {
291
- ok: true,
292
- service: PANEL_SERVICE_ID,
293
- startedAt,
294
- channel,
295
- channelActive: service.isChannelActive(),
296
- memoryBucket,
297
- memoryNote,
298
- quota: service.quotaState(),
299
- aiConfigured: privacyGate.isLocalInference() || !!configService.get('deepseekApiKey'),
300
- // 右键"快速回复"的名单(`config set quickReplyContacts "甲,乙"`)。
301
- // 页面本来就在轮询这个端点,所以它不必再开一个入口——**面板那条路上不加新端点**。
302
- // 名字与 id 都在这里:菜单显示名字,`/api/ask` 收到的是名字(解析交给助手那一侧,
303
- // 重名会得到"匹配多个会话"这种可读的失败,不猜)。
304
- quickReplies: String(configService.get('quickReplyContacts') || '')
305
- .split(',').map(item => item.trim()).filter(Boolean).slice(0, 20),
306
- })
307
- return
308
- }
309
-
310
- if (url.pathname === '/api/ask') {
311
- if (req.method !== 'POST') { json(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' }); return }
312
- const contentType = String(req.headers['content-type'] ?? '')
313
- if (!contentType.toLowerCase().startsWith('application/json')) {
314
- json(res, 415, { ok: false, code: 'UNSUPPORTED_MEDIA_TYPE' })
315
- return
316
- }
317
- if (inFlight) {
318
- // 不入队:入队意味着它**一定会被执行**,也就一定会烧配额
319
- json(res, 429, { ok: false, code: 'BUSY', error: '上一句还在处理' })
320
- return
321
- }
322
- const body = await readBody(req)
323
- if (!body.ok) { json(res, body.code === 'PAYLOAD_TOO_LARGE' ? 413 : 400, { ok: false, code: body.code }); return }
324
- let text = ''
325
- try { text = String((JSON.parse(body.text) as any)?.text ?? '') } catch {
326
- json(res, 400, { ok: false, code: 'BAD_JSON' }); return
327
- }
328
- text = text.trim()
329
- if (!text) { json(res, 400, { ok: false, code: 'EMPTY_TEXT' }); return }
330
- if (text.length > 4000) { json(res, 413, { ok: false, code: 'TEXT_TOO_LONG' }); return }
331
-
332
- inFlight = true
333
- try {
334
- const raced = await withTimeout(service.ask(memoryBucket, text, onLog), TURN_TIMEOUT_MS)
335
- if (raced.kind === 'timeout') {
336
- // 超时**不等于**这一轮没跑完:它可能还在队列里跑着。所以这里只是不再等它,
337
- // 那一轮之后仍会落记忆、仍会占配额。日志里说清,别让人以为它被取消了。
338
- onLog?.(' → 本机入口不再等待这一轮(它可能仍在队列里跑)')
339
- json(res, 504, { ok: false, code: 'TIMEOUT' })
340
- return
341
- }
342
- if (raced.kind === 'failed') {
343
- onLog?.(` → 本机入口抛错: ${raced.message}`)
344
- json(res, 500, { ok: false, code: 'TURN_FAILED' })
345
- return
346
- }
347
- const outcome = raced.value
348
- if (outcome.status === 'replied' || outcome.status === 'quota-exceeded') {
349
- json(res, 200, { ok: true, status: outcome.status, reply: outcome.text, memoryBucket })
350
- return
351
- }
352
- if (outcome.status === 'not-running') {
353
- json(res, 503, { ok: false, code: 'NOT_RUNNING' })
354
- return
355
- }
356
- // `denied` / `ignored` 对本机入口不该出现(它不过白名单)。真出现就是接线错了,
357
- // 记在本机日志里,对客户端只说"这一轮没成",**不回显内部细节**。
358
- onLog?.(` → 本机入口异常结果: ${outcome.status}`)
359
- json(res, 500, { ok: false, code: 'TURN_FAILED' })
360
- } finally {
361
- inFlight = false
362
- }
363
- return
364
- }
365
-
366
- json(res, 404, { ok: false, code: 'NOT_FOUND' })
367
- }
368
-
369
- await new Promise<void>((resolve, reject) => {
370
- server.once('error', reject)
371
- // **只绑回环**。这个字面量就是 D-004 那条决策的落点,不要做成可配置项。
372
- server.listen(wantedPort, '127.0.0.1', () => {
373
- server.removeListener('error', reject)
374
- resolve()
375
- })
376
- }).catch((error: any) => {
377
- if (error?.code === 'EADDRINUSE') {
378
- throw new Error(`本机端点端口 ${wantedPort} 已被占用(换一个:--port,或先停掉占用的程序)`)
379
- }
380
- throw error
381
- })
382
-
383
- const actualPort = (server.address() as { port: number }).port
384
- const endpoint: PanelEndpoint = {
385
- version: PANEL_ENDPOINT_VERSION,
386
- service: PANEL_SERVICE_ID,
387
- port: actualPort,
388
- token,
389
- pid: process.pid,
390
- startedAt,
391
- channel,
392
- memoryBucket,
393
- }
394
- writeEndpoint(endpoint)
395
-
396
- return {
397
- port: actualPort,
398
- endpoint,
399
- close: () => new Promise<void>((resolve) => {
400
- server.close(() => resolve())
401
- // 长连接挂着时 close 不会回调;本机服务没有长连接,但给个兜底
402
- setTimeout(resolve, 500).unref?.()
403
- }),
404
- }
405
- }
406
-
407
- type Raced<T> =
408
- | { kind: 'done'; value: T }
409
- | { kind: 'timeout' }
410
- | { kind: 'failed'; message: string }
411
-
412
- /**
413
- * 等一轮,但不超过 `ms`。**超时与失败必须分开报**:把"抛错了"也说成"超时",
414
- * 用户会去查网络,而真正的原因在日志里被掩掉了。
415
- */
416
- function withTimeout(promise: Promise<TurnOutcome>, ms: number): Promise<Raced<TurnOutcome>> {
417
- return new Promise((resolve) => {
418
- const timer = setTimeout(() => resolve({ kind: 'timeout' }), ms)
419
- promise.then(
420
- (value) => { clearTimeout(timer); resolve({ kind: 'done', value }) },
421
- (error: any) => { clearTimeout(timer); resolve({ kind: 'failed', message: String(error?.message ?? error) }) },
422
- )
423
- })
424
- }
1
+ /**
2
+ * 助手守护进程的本机入口:**只绑回环**的 HTTP 端点,给悬浮窗(和 `weflow-cli panel`)用。
3
+ *
4
+ * 为什么要有它:助手的配额计数器、串行队列、被那条队列保护的实例字段都是**进程内状态**,
5
+ * 所以"两个入口"必须落在**一个宿主进程**里。悬浮窗自己 `new AssistantService()` 会变成
6
+ * 第二个大脑:配额各算各的、两进程并发写同一个 userId 的记忆(`save()` 虽是合并写,
7
+ * 但同一个 userId 仍是后写覆盖先写)。**面板只做客户端。**
8
+ *
9
+ * ## 信任边界(D-045)
10
+ *
11
+ * 这条端点能读到用户的聊天数据,所以照着 `docs/DECISIONS.md` 的 D-004(阅读器只绑回环)
12
+ * 往下走,但**必须比它严**:
13
+ *
14
+ * - **绑 `127.0.0.1`,不可配置**。不是 LAN 服务。
15
+ * - **每个请求都要 token**,`GET /api/status` 也不例外。阅读器(`scripts/fav_server.py`)
16
+ * 根本没有 token,而且它的 Origin 校验**不带 Origin 就放行**——那是给"本机阅读器"的设计,
17
+ * 不能照抄到这个能替用户读聊天记录的端点上来。
18
+ * - **Origin 是第二道**:带了 Origin 但不在白名单 → 403,**即使 token 正确**。
19
+ * 反过来,**不带** Origin 要放行:`file://` 加载的 Electron 渲染进程与 `curl` 都是这种形态,
20
+ * 硬要求 Origin 会把面板自己挡在门外。
21
+ * - **POST 必须 `Content-Type: application/json`**:挡掉"跨站简单请求"那种不发 preflight 的形态。
22
+ * - **同时只允许一轮在飞**:单轮最坏是分钟级(6 轮工具 + 守卫一轮 + 压缩/抽取各一次 LLM),
23
+ * 用户在转圈时连按回车会往队列里挂一串——每一个都烧配额。第 2 个直接 429,不入队。
24
+ *
25
+ * 与助手出站那个 `isSafeUrl()`(`assistantTools.ts`)**不是一回事**:它管的是"助手去抓网页",
26
+ * 回答的是另一个问题(那边**拒绝**回环地址)。别把两者"统一"起来。
27
+ */
28
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
29
+ import { readFileSync, existsSync } from 'node:fs'
30
+ import { join } from 'node:path'
31
+ import { randomBytes } from 'node:crypto'
32
+ import {
33
+ DEFAULT_PANEL_PORT, PANEL_ENDPOINT_VERSION, PANEL_SERVICE_ID, generateToken, tokenMatches,
34
+ writeEndpoint, type PanelChannelMode, type PanelEndpoint,
35
+ } from './endpoint.js'
36
+ import type { AssistantService, TurnOutcome } from '../services/assistantService.js'
37
+ import { privacyGate } from '../services/assistantPrivacy.js'
38
+ import { configService } from '../services/configService.js'
39
+ import { resolvePackageRoot } from '../utils/packageRoot.js'
40
+
41
+ /** 8KB 够一句问题用;超了就是有人在灌东西 */
42
+ const MAX_BODY_BYTES = 8 * 1024
43
+ /** 一轮最坏分钟级(见上),给足余量;客户端那边也有自己的超时 */
44
+ const TURN_TIMEOUT_MS = 180_000
45
+ /** 浏览器那条路的会话 cookie 名。值是 token 本身,但 `HttpOnly` —— 页面脚本拿不到它 */
46
+ const COOKIE_NAME = 'weflow_panel'
47
+ /** 一次性口令的有效期。只用来"换 cookie"这一次,换个页面就该过期了 */
48
+ const PAIR_TTL_MS = 60_000
49
+ /**
50
+ * 静态文件**白名单**。不是"把目录挂出去":只认这三个名字,其余一律 404。
51
+ * 挂目录就得处理路径穿越,而这里根本不需要那个能力。
52
+ */
53
+ const STATIC_FILES: Record<string, string> = {
54
+ '/panel': 'index.html',
55
+ '/panel/': 'index.html',
56
+ '/panel/index.html': 'index.html',
57
+ '/panel/renderer.js': 'renderer.js',
58
+ '/panel/panel.css': 'panel.css',
59
+ // 悬浮球上那张图(项目吉祥物)。走白名单而不是挂目录,理由同上。
60
+ '/panel/mascot.png': 'mascot.png',
61
+ // 被捏一下时换的那张(同一只猫,眯眼笑)。**必须一起进白名单**——漏了它,
62
+ // 球在按下的一瞬间会闪成一张空图(`PANEL_ASSET_MISSING` 只写在响应里,页面上看不到)。
63
+ '/panel/mascot-happy.png': 'mascot-happy.png',
64
+ // 三个真状态的脸(忙/连不上/额度用完)。与上面那张一样,**必须一起进白名单**——
65
+ // 漏一张,那个状态一出现球就闪成空图,而响应里的 PANEL_ASSET_MISSING 页面上看不到。
66
+ '/panel/mascot-thinking.png': 'mascot-thinking.png',
67
+ '/panel/mascot-sorry.png': 'mascot-sorry.png',
68
+ '/panel/mascot-tired.png': 'mascot-tired.png',
69
+ }
70
+ const CONTENT_TYPES: Record<string, string> = {
71
+ '.html': 'text/html; charset=utf-8',
72
+ '.js': 'text/javascript; charset=utf-8',
73
+ '.css': 'text/css; charset=utf-8',
74
+ '.png': 'image/png',
75
+ }
76
+
77
+ export interface PanelServerOptions {
78
+ service: AssistantService
79
+ memoryBucket: string
80
+ /** 给人看的一句话:这个桶是不是与微信共用的,以及为什么。界面要显示它 */
81
+ memoryNote?: string
82
+ channel: PanelChannelMode
83
+ port?: number
84
+ onLog?: (line: string) => void
85
+ }
86
+
87
+ export interface PanelServer {
88
+ port: number
89
+ endpoint: PanelEndpoint
90
+ close: () => Promise<void>
91
+ }
92
+
93
+ function json(res: ServerResponse, status: number, payload: unknown): void {
94
+ const body = JSON.stringify(payload)
95
+ res.writeHead(status, {
96
+ 'Content-Type': 'application/json; charset=utf-8',
97
+ 'Cache-Control': 'no-store',
98
+ 'X-Content-Type-Options': 'nosniff',
99
+ })
100
+ res.end(body)
101
+ }
102
+
103
+ /** 只认本机来源。带 Origin 就必须对得上;不带 Origin 放行(curl / file:// 的 Electron) */
104
+ export function isLocalOrigin(origin: string | undefined, port: number): boolean {
105
+ if (!origin) return true
106
+ return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`
107
+ }
108
+
109
+ function bearer(req: IncomingMessage): string | undefined {
110
+ const header = req.headers['authorization']
111
+ if (typeof header !== 'string') return undefined
112
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim())
113
+ return match ? match[1] : undefined
114
+ }
115
+
116
+ function cookieToken(req: IncomingMessage): string | undefined {
117
+ const raw = req.headers['cookie']
118
+ if (typeof raw !== 'string') return undefined
119
+ for (const part of raw.split(';')) {
120
+ const [name, ...rest] = part.trim().split('=')
121
+ if (name === COOKIE_NAME) return rest.join('=')
122
+ }
123
+ return undefined
124
+ }
125
+
126
+ /**
127
+ * 这一份请求是拿什么来证明自己的:CLI/Python 走 `Authorization`,**浏览器走 cookie**。
128
+ *
129
+ * 浏览器为什么不能也用 header:页面脚本一旦拿得到 token,就等于把 token 交给了渲染进程,
130
+ * 而渲染进程要显示的是**助手回复**——那里面有用户的聊天内容。用 `HttpOnly` cookie,
131
+ * 脚本就永远读不到它(防的是 XSS 拿 token 去干别的事),同时页面照常能发请求。
132
+ */
133
+ function presentedToken(req: IncomingMessage): string | undefined {
134
+ return bearer(req) ?? cookieToken(req)
135
+ }
136
+
137
+ /**
138
+ * 读请求体,超限就**立刻**给结论,但**不掐连接**。
139
+ *
140
+ * 第一版在这里 `req.destroy()` 了:结果是客户端看到"连接被重置"而不是 413
141
+ * ——`fetch` 直接抛 `fetch failed`,调用方拿不到那个状态码。所以超限之后**丢弃余下的字节**
142
+ * 而不是断开(回环端点上的这点带宽代价,比"给不出 413"划算得多)。
143
+ */
144
+ function readBody(req: IncomingMessage): Promise<{ ok: true; text: string } | { ok: false; code: string }> {
145
+ return new Promise((resolve) => {
146
+ let size = 0
147
+ let tooLarge = false
148
+ const chunks: Buffer[] = []
149
+ req.on('data', (chunk: Buffer) => {
150
+ if (tooLarge) return // 已经下过结论了,余下的丢掉
151
+ size += chunk.length
152
+ if (size > MAX_BODY_BYTES) {
153
+ tooLarge = true
154
+ resolve({ ok: false, code: 'PAYLOAD_TOO_LARGE' })
155
+ return
156
+ }
157
+ chunks.push(chunk)
158
+ })
159
+ req.on('end', () => { if (!tooLarge) resolve({ ok: true, text: Buffer.concat(chunks).toString('utf8') }) })
160
+ req.on('error', () => resolve({ ok: false, code: 'BODY_READ_FAILED' }))
161
+ })
162
+ }
163
+
164
+ /**
165
+ * 端口的环境变量覆盖。**只给测试用**(`WEFLOW_PANEL_PORT=0` 让内核分配一个临时端口),
166
+ * 与 `WEFLOW_ASSISTANT_EXPORT_ROOT` 是同一个先例。
167
+ *
168
+ * 为什么需要它:测试里 `AssistantService.start()` 会真的把这个端点起起来,
169
+ * 于是**每个用到 store 的测试都会去抢生产端口 8766**——真守护进程在跑时就是 EADDRINUSE,
170
+ * 而且测试之间也会互相抢。非法值一律当没给(不猜、不静默用一个奇怪的端口)。
171
+ */
172
+ function portFromEnv(): number | undefined {
173
+ const raw = process.env.WEFLOW_PANEL_PORT
174
+ if (raw === undefined || raw === '') return undefined
175
+ const parsed = Number(raw)
176
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) return undefined
177
+ return parsed
178
+ }
179
+
180
+ /**
181
+ * 起端点。返回**实际**监听的端口——调用方要把它写进端点文件,不要用请求的那个值。
182
+ */
183
+ export async function startPanelServer(options: PanelServerOptions): Promise<PanelServer> {
184
+ const { service, memoryBucket, memoryNote = '', channel, onLog } = options
185
+ const wantedPort = options.port ?? portFromEnv() ?? DEFAULT_PANEL_PORT
186
+ const token = generateToken()
187
+ const startedAt = new Date().toISOString()
188
+ let inFlight = false
189
+
190
+ const server: Server = createServer((req, res) => {
191
+ void handle(req, res).catch(() => {
192
+ if (!res.headersSent) json(res, 500, { ok: false, code: 'INTERNAL' })
193
+ else res.end()
194
+ })
195
+ })
196
+
197
+ /**
198
+ * 一次性口令 → cookie。**为什么要有这一步**:浏览器那条路(Edge `--app`)得先拿到凭据,
199
+ * 而**不能把 token 放进 URL**——命令行对同机任何进程可见(`wmic process get commandline`),
200
+ * 而且 URL 会进浏览器历史。所以给的是一个**一次性、60 秒、用完即废**的口令。
201
+ */
202
+ const pairCodes = new Map<string, number>()
203
+
204
+ function issuePairCode(): string {
205
+ const code = randomBytes(18).toString('base64url')
206
+ pairCodes.set(code, Date.now() + PAIR_TTL_MS)
207
+ // 顺手清掉过期的,别让这张表无限长
208
+ for (const [k, exp] of pairCodes) if (exp < Date.now()) pairCodes.delete(k)
209
+ return code
210
+ }
211
+
212
+ function consumePairCode(code: string): boolean {
213
+ const exp = pairCodes.get(code)
214
+ if (!exp) return false
215
+ pairCodes.delete(code) // **单次使用**:即使它被日志/历史记下来,也已经废了
216
+ return exp >= Date.now()
217
+ }
218
+
219
+ function serveStatic(res: ServerResponse, fileName: string): void {
220
+ try {
221
+ const full = join(resolvePackageRoot(import.meta.url), 'resources', 'panel', fileName)
222
+ if (!existsSync(full)) { json(res, 503, { ok: false, code: 'PANEL_ASSET_MISSING', file: fileName }); return }
223
+ const ext = fileName.slice(fileName.lastIndexOf('.'))
224
+ res.writeHead(200, {
225
+ 'Content-Type': CONTENT_TYPES[ext] ?? 'application/octet-stream',
226
+ 'Cache-Control': 'no-store',
227
+ // CSP 走响应头而不是 <meta>:头不能被页面内容绕过
228
+ 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; "
229
+ + "connect-src 'self'; img-src 'self' data:; form-action 'none'; base-uri 'none'",
230
+ 'X-Content-Type-Options': 'nosniff',
231
+ 'Referrer-Policy': 'no-referrer',
232
+ })
233
+ res.end(readFileSync(full))
234
+ } catch {
235
+ json(res, 500, { ok: false, code: 'INTERNAL' })
236
+ }
237
+ }
238
+
239
+ async function handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
240
+ const port = (server.address() as { port: number } | null)?.port ?? wantedPort
241
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`)
242
+
243
+ if (!isLocalOrigin(req.headers.origin as string | undefined, port)) {
244
+ json(res, 403, { ok: false, code: 'ORIGIN_DENIED' })
245
+ return
246
+ }
247
+
248
+ // ---------------------------------------------------------- 页面(浏览器那条路)
249
+ const asset = STATIC_FILES[url.pathname]
250
+ if (asset) {
251
+ if (req.method !== 'GET') { json(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' }); return }
252
+ const code = url.searchParams.get('c')
253
+ const authed = tokenMatches(token, presentedToken(req))
254
+ if (!authed) {
255
+ // 没带凭据:只接受一次性口令,并把 cookie 种上(token **不进 URL**,见 issuePairCode)
256
+ if (!code || !consumePairCode(code)) {
257
+ json(res, 401, { ok: false, code: 'UNAUTHORIZED', hint: '用 weflow-cli panel 打开' })
258
+ return
259
+ }
260
+ res.setHeader('Set-Cookie', `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Path=/`)
261
+ }
262
+ // 只在**页面本身**加载时记一行。不记 `/api/status`:面板每 30 秒轮询一次状态,
263
+ // 记它会把日志灌满,而它想回答的问题("界面开着吗、按哪条路进来的")这一行就答了。
264
+ if (asset === 'index.html') {
265
+ onLog?.(`[panel] 界面已加载(${code && !authed ? '一次性口令' : 'cookie'})`)
266
+ }
267
+ serveStatic(res, asset)
268
+ return
269
+ }
270
+
271
+ if (!tokenMatches(token, presentedToken(req))) {
272
+ // 不回显任何东西:错的 token 与缺的 token 得到完全一样的响应
273
+ json(res, 401, { ok: false, code: 'UNAUTHORIZED' })
274
+ return
275
+ }
276
+
277
+ if (url.pathname === '/api/pair') {
278
+ if (req.method !== 'POST') { json(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' }); return }
279
+ // 换口令这一步**必须带真实 token**(上面的检查已经过了),所以本机别的东西换不到
280
+ const code = issuePairCode()
281
+ json(res, 200, {
282
+ ok: true, code, expiresInMs: PAIR_TTL_MS,
283
+ url: `http://127.0.0.1:${port}/panel?c=${code}`,
284
+ })
285
+ return
286
+ }
287
+
288
+ if (url.pathname === '/api/status') {
289
+ if (req.method !== 'GET') { json(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' }); return }
290
+ json(res, 200, {
291
+ ok: true,
292
+ service: PANEL_SERVICE_ID,
293
+ startedAt,
294
+ channel,
295
+ channelActive: service.isChannelActive(),
296
+ memoryBucket,
297
+ memoryNote,
298
+ quota: service.quotaState(),
299
+ aiConfigured: privacyGate.isLocalInference() || !!configService.get('deepseekApiKey'),
300
+ // 右键"快速回复"的名单(`config set quickReplyContacts "甲,乙"`)。
301
+ // 页面本来就在轮询这个端点,所以它不必再开一个入口——**面板那条路上不加新端点**。
302
+ // 名字与 id 都在这里:菜单显示名字,`/api/ask` 收到的是名字(解析交给助手那一侧,
303
+ // 重名会得到"匹配多个会话"这种可读的失败,不猜)。
304
+ quickReplies: String(configService.get('quickReplyContacts') || '')
305
+ .split(',').map(item => item.trim()).filter(Boolean).slice(0, 20),
306
+ })
307
+ return
308
+ }
309
+
310
+ if (url.pathname === '/api/ask') {
311
+ if (req.method !== 'POST') { json(res, 405, { ok: false, code: 'METHOD_NOT_ALLOWED' }); return }
312
+ const contentType = String(req.headers['content-type'] ?? '')
313
+ if (!contentType.toLowerCase().startsWith('application/json')) {
314
+ json(res, 415, { ok: false, code: 'UNSUPPORTED_MEDIA_TYPE' })
315
+ return
316
+ }
317
+ if (inFlight) {
318
+ // 不入队:入队意味着它**一定会被执行**,也就一定会烧配额
319
+ json(res, 429, { ok: false, code: 'BUSY', error: '上一句还在处理' })
320
+ return
321
+ }
322
+ const body = await readBody(req)
323
+ if (!body.ok) { json(res, body.code === 'PAYLOAD_TOO_LARGE' ? 413 : 400, { ok: false, code: body.code }); return }
324
+ let text = ''
325
+ try { text = String((JSON.parse(body.text) as any)?.text ?? '') } catch {
326
+ json(res, 400, { ok: false, code: 'BAD_JSON' }); return
327
+ }
328
+ text = text.trim()
329
+ if (!text) { json(res, 400, { ok: false, code: 'EMPTY_TEXT' }); return }
330
+ if (text.length > 4000) { json(res, 413, { ok: false, code: 'TEXT_TOO_LONG' }); return }
331
+
332
+ inFlight = true
333
+ try {
334
+ const raced = await withTimeout(service.ask(memoryBucket, text, onLog), TURN_TIMEOUT_MS)
335
+ if (raced.kind === 'timeout') {
336
+ // 超时**不等于**这一轮没跑完:它可能还在队列里跑着。所以这里只是不再等它,
337
+ // 那一轮之后仍会落记忆、仍会占配额。日志里说清,别让人以为它被取消了。
338
+ onLog?.(' → 本机入口不再等待这一轮(它可能仍在队列里跑)')
339
+ json(res, 504, { ok: false, code: 'TIMEOUT' })
340
+ return
341
+ }
342
+ if (raced.kind === 'failed') {
343
+ onLog?.(` → 本机入口抛错: ${raced.message}`)
344
+ json(res, 500, { ok: false, code: 'TURN_FAILED' })
345
+ return
346
+ }
347
+ const outcome = raced.value
348
+ if (outcome.status === 'replied' || outcome.status === 'quota-exceeded') {
349
+ json(res, 200, { ok: true, status: outcome.status, reply: outcome.text, memoryBucket })
350
+ return
351
+ }
352
+ if (outcome.status === 'not-running') {
353
+ json(res, 503, { ok: false, code: 'NOT_RUNNING' })
354
+ return
355
+ }
356
+ // `denied` / `ignored` 对本机入口不该出现(它不过白名单)。真出现就是接线错了,
357
+ // 记在本机日志里,对客户端只说"这一轮没成",**不回显内部细节**。
358
+ onLog?.(` → 本机入口异常结果: ${outcome.status}`)
359
+ json(res, 500, { ok: false, code: 'TURN_FAILED' })
360
+ } finally {
361
+ inFlight = false
362
+ }
363
+ return
364
+ }
365
+
366
+ json(res, 404, { ok: false, code: 'NOT_FOUND' })
367
+ }
368
+
369
+ await new Promise<void>((resolve, reject) => {
370
+ server.once('error', reject)
371
+ // **只绑回环**。这个字面量就是 D-004 那条决策的落点,不要做成可配置项。
372
+ server.listen(wantedPort, '127.0.0.1', () => {
373
+ server.removeListener('error', reject)
374
+ resolve()
375
+ })
376
+ }).catch((error: any) => {
377
+ if (error?.code === 'EADDRINUSE') {
378
+ throw new Error(`本机端点端口 ${wantedPort} 已被占用(换一个:--port,或先停掉占用的程序)`)
379
+ }
380
+ throw error
381
+ })
382
+
383
+ const actualPort = (server.address() as { port: number }).port
384
+ const endpoint: PanelEndpoint = {
385
+ version: PANEL_ENDPOINT_VERSION,
386
+ service: PANEL_SERVICE_ID,
387
+ port: actualPort,
388
+ token,
389
+ pid: process.pid,
390
+ startedAt,
391
+ channel,
392
+ memoryBucket,
393
+ }
394
+ writeEndpoint(endpoint)
395
+
396
+ return {
397
+ port: actualPort,
398
+ endpoint,
399
+ close: () => new Promise<void>((resolve) => {
400
+ server.close(() => resolve())
401
+ // 长连接挂着时 close 不会回调;本机服务没有长连接,但给个兜底
402
+ setTimeout(resolve, 500).unref?.()
403
+ }),
404
+ }
405
+ }
406
+
407
+ type Raced<T> =
408
+ | { kind: 'done'; value: T }
409
+ | { kind: 'timeout' }
410
+ | { kind: 'failed'; message: string }
411
+
412
+ /**
413
+ * 等一轮,但不超过 `ms`。**超时与失败必须分开报**:把"抛错了"也说成"超时",
414
+ * 用户会去查网络,而真正的原因在日志里被掩掉了。
415
+ */
416
+ function withTimeout(promise: Promise<TurnOutcome>, ms: number): Promise<Raced<TurnOutcome>> {
417
+ return new Promise((resolve) => {
418
+ const timer = setTimeout(() => resolve({ kind: 'timeout' }), ms)
419
+ promise.then(
420
+ (value) => { clearTimeout(timer); resolve({ kind: 'done', value }) },
421
+ (error: any) => { clearTimeout(timer); resolve({ kind: 'failed', message: String(error?.message ?? error) }) },
422
+ )
423
+ })
424
+ }