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,441 +1,470 @@
1
- /**
2
- * 面板的渲染层。**这是整条链路上最不可信的一层**——它显示的东西来自助手的回复,
3
- * 而助手的回复里会有用户自己的聊天内容。所以两条死规矩:
4
- *
5
- * 1. **一律 `textContent`,绝不 `innerHTML`**。回复里出现 `<script>` 或 `<img onerror>` 之类的
6
- * 内容时,用 innerHTML 就等于在那个窗口里执行它。这个窗口没有地址栏,用户看不出被导航走了。
7
- * 2. **拿不到 token,也拿不到端口**:凭据在 `HttpOnly` cookie 里(脚本读不到),
8
- * 请求只发相对路径 `/api/...`。所以这个文件里不该出现任何凭据相关的东西。
9
- *
10
- * 页面本身由守护进程从回环端点递过来(`GET /panel`),CSP 走响应头:`default-src 'none'` +
11
- * `script-src 'self'`,所以也没有内联脚本、没有外部资源。
12
- */
13
- 'use strict'
14
-
15
- const log = document.getElementById('log')
16
- const input = document.getElementById('input')
17
- const send = document.getElementById('send')
18
- const statusEl = document.getElementById('status')
19
- const foot = document.getElementById('foot')
20
-
21
- const COPY_LABEL = '复制'
22
- const COPIED_LABEL = '已复制'
23
- const MANUAL_LABEL = '按 Ctrl+C'
24
-
25
- /** 把节点内容全选上。用于剪贴板 API 用不了时的退路 */
26
- function selectContents(node) {
27
- const range = document.createRange()
28
- range.selectNodeContents(node)
29
- const selection = window.getSelection()
30
- selection.removeAllRanges()
31
- selection.addRange(range)
32
- }
33
-
34
- /**
35
- * 助手回答下面的复制按钮。
36
- *
37
- * 为什么要有:起草回复那条路**只产出文本**——不碰微信窗口、不模拟按键(见 D-047),
38
- * 所以"把这几条候选拿到别处去用"这一步只能由用户自己做,那就得让他一键拿得走。
39
- * 它是**整条回答**的复制,不做"逐条候选分别复制":候选是模型用自己的话重述过的,
40
- * 在客户端按行去猜哪行是候选,猜错就是把半句话复制走。
41
- *
42
- * 反馈必须落在按钮自己身上(窗口只有巴掌大,用户不会去看别处)。
43
- */
44
- function addCopyButton(turn, textNode, text) {
45
- const button = document.createElement('button')
46
- button.type = 'button'
47
- button.className = 'copy'
48
- button.textContent = COPY_LABEL
49
- let timer = null
50
- const flash = (label) => {
51
- button.textContent = label
52
- if (timer) clearTimeout(timer)
53
- timer = setTimeout(() => { button.textContent = COPY_LABEL }, 1500)
54
- }
55
- button.addEventListener('click', async () => {
56
- try {
57
- await navigator.clipboard.writeText(text)
58
- flash(COPIED_LABEL)
59
- } catch {
60
- // 剪贴板 API 拿不到时**不许假装复制成功**:用户会以为剪贴板里有东西,粘出来
61
- // 是上一次的旧内容——那比什么都不做更坏。改成把这段选上,让他自己按 Ctrl+C。
62
- selectContents(textNode)
63
- flash(MANUAL_LABEL)
64
- }
65
- })
66
- turn.appendChild(button)
67
- }
68
-
69
- /** 把一条消息加进对话区。`who` 决定样式,**内容永远走 textContent** */
70
- function addTurn(who, text) {
71
- const empty = log.querySelector('.empty')
72
- if (empty) empty.remove() // 第一句话一来,开头那段提示就该让位
73
- const div = document.createElement('div')
74
- div.className = 'turn ' + who
75
- const body = document.createElement('div')
76
- body.className = 'turn-text'
77
- body.textContent = text
78
- div.appendChild(body)
79
- // 只有助手的回答带复制按钮:用户自己发的不用复制,"正在输入"没有内容可复制,
80
- // 错误提示也不该被粘到别处去。
81
- if (who === 'it') addCopyButton(div, body, text)
82
- log.appendChild(div)
83
- log.scrollTop = log.scrollHeight
84
- return div
85
- }
86
-
87
- /**
88
- * 还没聊过时的开头。**空白是这里最糟的状态**:第一次打开的人看到一整片黑,
89
- * 既不知道它能干什么,也看不出它是活的(下面没有正在输入之类的动静)。
90
- * 例子做成可点的按钮——点一下就是真的问一句,走的还是同一条提交路径。
91
- */
92
- const EXAMPLES = [
93
- '我最近都在忙什么?',
94
- '总结我和某某的聊天',
95
- '收藏里有哪些 AI 文章?',
96
- '我最近都在读什么?',
97
- ]
98
-
99
- function renderEmptyState() {
100
- if (log.children.length) return
101
- const box = document.createElement('div')
102
- box.className = 'empty'
103
-
104
- const title = document.createElement('div')
105
- title.className = 'empty-title'
106
- title.textContent = '直接问就行,我会查本机数据回答'
107
- box.appendChild(title)
108
-
109
- const sub = document.createElement('div')
110
- sub.className = 'empty-sub'
111
- sub.textContent = '聊天、收藏、公众号、微信读书、待办都在本机,数据库不出这台机器。'
112
- box.appendChild(sub)
113
-
114
- for (const example of EXAMPLES) {
115
- const chip = document.createElement('button')
116
- chip.type = 'button'
117
- chip.className = 'chip'
118
- chip.textContent = example
119
- chip.addEventListener('click', () => {
120
- if (input.disabled) return // 上一句还在飞的时候点了没用,就不该有反应
121
- input.value = example
122
- document.getElementById('composer').requestSubmit()
123
- })
124
- box.appendChild(chip)
125
- }
126
- log.appendChild(box)
127
- }
128
-
129
- /**
130
- * 球的背景跟着状态走(CSS 里 `body.busy/.offline/.quota #ball .glow`)。
131
- *
132
- * 集中在一个函数里改:之前这些类名散在 ask()/refreshStatus() 各处的话,
133
- * 迟早有一条分支忘了摘掉 `busy`,球就一直亮着——那种"看起来在忙其实没在忙"比不显示更糟。
134
- * 传进来的每一项都**整体替换**,不做增量。
135
- */
136
- function setBallState(state) {
137
- document.body.classList.remove('busy', 'offline', 'quota')
138
- if (state) document.body.classList.add(state)
139
- }
140
-
141
- /** 服务端的状态码 → 一句人话。**别把 code 原样丢给用户** */
142
- function explain(code, fallback) {
143
- switch (code) {
144
- case 'BUSY': return '上一句还在处理,等它说完再发。'
145
- case 'NOT_RUNNING': return '助手没在运行。在电脑上跑一次 `weflow-cli assistant start`。'
146
- case 'QUOTA_EXCEEDED': return '今天的额度用完了。'
147
- case 'TIMEOUT': return '这一轮太久没结束(它可能还在后台跑)。稍等一下再问。'
148
- case 'UNAUTHORIZED': return '凭据失效了。重新用 `weflow-cli panel` 打开这个窗口。'
149
- case 'ORIGIN_DENIED': return '这个来源被拒绝了。'
150
- default: return fallback || ('没成(' + code + ')')
151
- }
152
- }
153
-
154
- async function refreshStatus() {
155
- try {
156
- const res = await fetch('/api/status', { credentials: 'same-origin' })
157
- if (!res.ok) {
158
- const body = await res.json().catch(() => ({}))
159
- statusEl.textContent = explain(body.code, '连接异常')
160
- return
161
- }
162
- const s = await res.json()
163
- quickReplies = Array.isArray(s.quickReplies) ? s.quickReplies.filter(n => typeof n === 'string') : []
164
- const mode = s.channelActive ? '微信 + 本机' : '仅本机入口'
165
- statusEl.textContent = mode + '|今日 ' + s.quota.used + '/' + s.quota.limit
166
- // 额度用尽是"今天不能再用",值得在球上看得出来(琥珀),但**不是**错误
167
- if (s.quota.used >= s.quota.limit) setBallState('quota')
168
-
169
- // 记忆桶那句话说清"是不是同一个大脑"——这是用户最容易误解的地方
170
- foot.textContent = ''
171
- const line = document.createElement('div')
172
- line.textContent = s.memoryNote || ('记忆桶:' + s.memoryBucket)
173
- foot.appendChild(line)
174
- if (!s.aiConfigured) {
175
- const warn = document.createElement('div')
176
- warn.className = 'warn'
177
- warn.textContent = '还没配置 LLM:在电脑上跑 `weflow-cli config set deepseekApiKey "..."`'
178
- foot.appendChild(warn)
179
- input.disabled = true
180
- send.disabled = true
181
- }
182
- } catch {
183
- statusEl.textContent = '连不上本机入口'
184
- setBallState('offline') // 暖色且停住流动:不动的东西才会被注意到
185
- }
186
- }
187
-
188
- /** 让助手给这个人起草一条回复。**只产文本**——发送在这条链路上结构性不可达。 */
189
- function draftRequest(name) {
190
- return `用 draft_reply 给「${name}」起草 3 条候选回复,把候选原样列出来。只产文本,不要发送。`
191
- }
192
-
193
- /**
194
- * 问一句。`display` 用来把"用户看到的那句话"和"实际发出去的那句"分开:
195
- * 快速回复要发的是一句点名工具的请求(要模型选对工具),但那句话长得像机器指令,
196
- * 摆进对话里读起来不像人说的——所以对话里显示「快速回复:咸鱼梦想家」。
197
- */
198
- async function ask(text, display = null) {
199
- addTurn('me', display || text)
200
- const pending = addTurn('it pending', '…')
201
- input.disabled = true
202
- send.disabled = true
203
- // 球收起来的时候,此前完全看不出它在干活——这是这条状态最主要的用处
204
- setBallState('busy')
205
-
206
- try {
207
- const res = await fetch('/api/ask', {
208
- method: 'POST',
209
- credentials: 'same-origin',
210
- headers: { 'Content-Type': 'application/json' },
211
- body: JSON.stringify({ text: text }),
212
- })
213
- const body = await res.json().catch(() => ({}))
214
- pending.remove()
215
-
216
- if (res.ok && body.ok) {
217
- addTurn('it', body.reply)
218
- // 配额用尽时服务端回的是一句**回答**(不是错误),所以上面照常显示;
219
- // 这里只是把状态条上的数字刷新一下
220
- void refreshStatus()
221
- return
222
- }
223
- addTurn('err', explain(body.code, body.error))
224
- } catch {
225
- pending.remove()
226
- addTurn('err', '请求没发出去(助手进程可能刚停)。')
227
- } finally {
228
- input.disabled = false
229
- send.disabled = false
230
- setBallState(null)
231
- void refreshStatus() // 顺手把用量与配额状态刷新(额度用尽会换成琥珀色)
232
- input.focus()
233
- }
234
- }
235
-
236
- document.getElementById('composer').addEventListener('submit', (event) => {
237
- event.preventDefault()
238
- const text = input.value.trim()
239
- if (!text) return
240
- input.value = ''
241
- void ask(text)
242
- })
243
-
244
- input.addEventListener('keydown', (event) => {
245
- // Enter 发送,Shift+Enter 换行
246
- if (event.key === 'Enter' && !event.shiftKey) {
247
- event.preventDefault()
248
- document.getElementById('composer').requestSubmit()
249
- }
250
- })
251
-
252
- // ---------------------------------------------------------------- 球 / 对话窗
253
- //
254
- // 只在**有外壳**(Electron)时才起小球:浏览器那条路(Edge `--app`)给不了无边框置顶的小球,
255
- // 硬做只会做出一个奇怪的方窗口。所以这里靠 `window.weflowPanel` 在不在来判断,
256
- // 而且这正是 preload 唯一暴露的东西——页面拿不到凭据,也不该拿得到。
257
-
258
- const ball = document.getElementById('ball')
259
- const collapse = document.getElementById('collapse')
260
- const hasShell = typeof window.weflowPanel !== 'undefined'
261
-
262
- /**
263
- * 右键"快速回复"的名单。来自 `/api/status`——页面本来每 30 秒就轮询那个端点,
264
- * 所以这份名单**不必再开一个入口**(面板那条路上不加新端点、不加 IPC)。
265
- */
266
- let quickReplies = []
267
-
268
- /**
269
- * 系统关了动画没有。**这一条只有渲染进程能问**(`prefers-reduced-motion` 是媒体查询,
270
- * 主进程那边没有等价 API),而窗口几何的补间在主进程、CSS 管不着——所以读出来的结果
271
- * 要随 `setMode` 带过去。
272
- *
273
- * `typeof` 那半不是客套:`matchMedia` 在 jsdom 里**根本不存在**(测试就是拿 jsdom 跑这个
274
- * 文件的),直接调会在加载时就抛。
275
- */
276
- function prefersReducedMotion() {
277
- return typeof window.matchMedia === 'function'
278
- && window.matchMedia('(prefers-reduced-motion: reduce)').matches
279
- }
280
-
281
- /** 气泡方位相关的类,每次切形态先全摘掉再按主进程说的挂上 */
282
- const LAYOUT_CLASSES = ['bubble-left', 'bubble-right', 'anchor-top']
283
-
284
- /**
285
- * 按主进程说的形态切 class。**这里是唯一改形态的地方**。
286
- *
287
- * 载荷带方位:`side` 是气泡在球的哪一边(球就在窗口的另一边),`anchorY` 是球贴窗口的
288
- * 上边还是下边(气泡跟着它竖着对齐),`bubbleHeight` 是气泡该有多高——**屏幕不够高时
289
- * 气泡会变矮**,那是为了让球待在窗口的角上不动(见 `ball-position.cjs` 的 `bubbleLayout`)。
290
- *
291
- * 页面自己算不出这些:它不知道自己在屏幕上的位置,也不知道工作区多大。
292
- */
293
- function applyMode(payload) {
294
- const mode = payload && payload.mode === 'ball' ? 'ball' : 'chat'
295
- const side = payload && payload.side === 'right' ? 'right' : 'left'
296
- document.body.classList.remove('mode-ball', 'mode-chat', 'closing', ...LAYOUT_CLASSES)
297
- document.body.classList.add(mode === 'chat' ? 'mode-chat' : 'mode-ball')
298
- if (mode === 'chat') {
299
- document.body.classList.add(side === 'right' ? 'bubble-right' : 'bubble-left')
300
- if (payload && payload.anchorY === 'top') document.body.classList.add('anchor-top')
301
- const height = Number(payload && payload.bubbleHeight)
302
- document.body.style.setProperty('--bubble-height',
303
- `${Number.isFinite(height) && height > 0 ? Math.round(height) : 560}px`)
304
- input.focus()
305
- }
306
- }
307
-
308
- /**
309
- * 请主进程换形态。**页面自己不先换 class**:从前这条路是页面直接 `classList.replace`,
310
- * 而托盘那条走的是 `panel:mode`,两条路行为不一样(一边球瞬间消失、一边等主进程)。
311
- * 现在形态只有主进程一个来源,页面照着 `panel:mode` 做,展开与收起于是必然对称。
312
- */
313
- function requestMode(mode) {
314
- void window.weflowPanel.setMode(mode, { animate: !prefersReducedMotion() })
315
- }
316
-
317
- /**
318
- * 点球 = 开关:站着就展开、展开了就收起。**球全程不消失**——它是"在说话的那个图标",
319
- * 让它先撤下去再冒出个窗口,就是用户嫌的那股突兀劲。
320
- */
321
- function toggleMode() {
322
- requestMode(document.body.classList.contains('mode-chat') ? 'ball' : 'chat')
323
- }
324
-
325
- if (hasShell) {
326
- // `shell` 这个类决定球在不在场(见 panel.css):浏览器降级那条路永远不该看见球
327
- document.body.classList.add('shell', 'mode-ball')
328
- ball.hidden = false
329
- collapse.hidden = false
330
-
331
- // 球:**点一下展开,按住拖动挪位置**。
332
- //
333
- // 为什么不用 `-webkit-app-region: drag`:Windows 上拖拽区会**吞掉鼠标事件**,
334
- // 页面根本收不到 click——现象就是"点这个图标没反应"(实测的故障)。
335
- // 所以拖拽自己实现:按下时告诉主进程记住窗口与指针位置,移动时按差值挪窗口;
336
- // 松手时如果**指针几乎没动**,那就是一次点击。
337
- // 阈值 4 像素:手抖不会把点击变成拖动,而想拖的人自然会移过 4 像素。
338
- /** 被捏一下的表情。**只有一个入口**(同 `setBallState` 的理由:散着写迟早有一条分支
339
- * 忘了摘,球就一直是那张笑脸)。
340
- *
341
- * **按住是笑脸,一松手立刻变回来。** 第一版松手后还留 900ms,想的是"一次点击只闪
342
- * 十几毫秒,不留就看不见"——用户试过之后说那个延迟很别扭:手指松开了表情还挂着,
343
- * 像是卡在那儿。所以不留了。
344
- */
345
- function setBallFace(happy) {
346
- document.body.classList.toggle('ball-happy', happy)
347
- }
348
-
349
- const DRAG_THRESHOLD_PX = 4
350
- ball.addEventListener('pointerdown', (event) => {
351
- if (event.button !== 0) return
352
- setBallFace(true)
353
- const startX = event.screenX
354
- const startY = event.screenY
355
- let dragging = false
356
-
357
- const onMove = (moveEvent) => {
358
- if (!dragging && Math.hypot(moveEvent.screenX - startX, moveEvent.screenY - startY) > DRAG_THRESHOLD_PX) {
359
- dragging = true
360
- }
361
- if (dragging) void window.weflowPanel.dragMove(moveEvent.screenX, moveEvent.screenY)
362
- }
363
- const onUp = () => {
364
- window.removeEventListener('pointermove', onMove)
365
- window.removeEventListener('pointerup', onUp)
366
- window.removeEventListener('pointercancel', onCancel)
367
- void window.weflowPanel.dragEnd()
368
- setBallFace(false)
369
- if (dragging) return // 拖过了就不算点击
370
- toggleMode()
371
- }
372
- // 指针被系统抢走(触摸、原生菜单弹出)时不走 onUp,必须自己把脸收回来,否则球一直笑着
373
- const onCancel = () => setBallFace(false)
374
- void window.weflowPanel.dragStart(startX, startY)
375
- window.addEventListener('pointermove', onMove)
376
- window.addEventListener('pointerup', onUp)
377
- window.addEventListener('pointercancel', onCancel)
378
- })
379
- // 右键:快速回复。**只在有外壳时接管**——浏览器降级那条路的原生菜单里有"复制",
380
- // 那是用户要用的,不该被我们抢掉。
381
- //
382
- // 有外壳时用**主进程弹的原生菜单**:它画在窗口外面,所以球形态那 76x76 的窗口
383
- // **不用先展开**(页内菜单会被窗口裁掉)。上一版就是"先展开再弹",用户看到的是
384
- // "点右键把第二大脑窗口弹出来了"——那不是快速功能该有的样子。
385
- document.addEventListener('contextmenu', (event) => {
386
- event.preventDefault()
387
- void (async () => {
388
- const picked = await window.weflowPanel.openQuickMenu(quickReplies)
389
- if (!picked || typeof picked !== 'object') return
390
- // 选中任何一项都要展开:球那个 76x76 里看不到任何回答,而这几项全都要出文字。
391
- // 但**右键本身不动窗口**(那是用户嫌的那一下)——展开只发生在真的选了东西之后。
392
- if (document.body.classList.contains('mode-ball')) requestMode('chat')
393
- if (picked.kind === 'contact' && typeof picked.name === 'string' && picked.name.trim()) {
394
- const name = picked.name.trim()
395
- void ask(draftRequest(name), `快速回复:${name}`)
396
- return
397
- }
398
- if (picked.kind === 'action' && typeof picked.prompt === 'string' && picked.prompt.trim()) {
399
- // 话术**由菜单项带过来**,页面不自己按 id 查表——两份映射会漂移,而漂移的后果是
400
- // "菜单写着甲、点下去做了乙",不报错。
401
- void ask(picked.prompt, String(picked.label || '快捷功能'))
402
- }
403
- })()
404
- })
405
- document.addEventListener('click', () => closeQuickMenu())
406
- document.addEventListener('keydown', (event) => {
407
- if (event.key === 'Escape') closeQuickMenu()
408
- })
409
-
410
- collapse.addEventListener('click', () => { requestMode('ball') })
411
-
412
- // 形态由主进程说了算(球、收起、托盘菜单、快捷键都是这条路),收起分两步:
413
- // `fadeMs > 0` → 先把气泡淡掉(窗口这会儿还是大的,淡出真看得见),**不换形态**;
414
- // `done` → 主进程已经把窗口缩回球那么大,这时才把气泡摘掉。
415
- // 顺序不能反:提前换,76x76 的窗口里会露出一条气泡的边;不换,气泡会一直在。
416
- window.weflowPanel.onMode((payload) => {
417
- if (payload && payload.mode === 'ball' && !payload.done) {
418
- if (payload.fadeMs > 0 && document.body.classList.contains('mode-chat')) {
419
- document.body.classList.add('closing')
420
- return
421
- }
422
- applyMode(payload)
423
- return
424
- }
425
- applyMode(payload)
426
- })
427
- } else {
428
- // 浏览器降级:把话说清,别让用户以为悬浮球坏了
429
- const note = document.createElement('div')
430
- note.className = 'warn'
431
- note.textContent = '这是浏览器窗口(没有 Electron):给不了悬浮球、托盘和全局快捷键。'
432
- + '想要真·悬浮球,在电脑上跑 npm i -g electron 之后再执行 weflow-cli panel。'
433
- foot.appendChild(note)
434
- }
435
-
436
- renderEmptyState()
437
- void refreshStatus()
438
- // 每 30 秒刷一次状态:守护进程可能被停掉,界面不该一直显示旧数字
439
- setInterval(refreshStatus, 30000)
440
- // 球形态下不抢焦点(抢了会把用户正在打字的窗口顶掉)
441
- if (!hasShell || !document.body.classList.contains('mode-ball')) input.focus()
1
+ /**
2
+ * 面板的渲染层。**这是整条链路上最不可信的一层**——它显示的东西来自助手的回复,
3
+ * 而助手的回复里会有用户自己的聊天内容。所以两条死规矩:
4
+ *
5
+ * 1. **一律 `textContent`,绝不 `innerHTML`**。回复里出现 `<script>` 或 `<img onerror>` 之类的
6
+ * 内容时,用 innerHTML 就等于在那个窗口里执行它。这个窗口没有地址栏,用户看不出被导航走了。
7
+ * 2. **拿不到 token,也拿不到端口**:凭据在 `HttpOnly` cookie 里(脚本读不到),
8
+ * 请求只发相对路径 `/api/...`。所以这个文件里不该出现任何凭据相关的东西。
9
+ *
10
+ * 页面本身由守护进程从回环端点递过来(`GET /panel`),CSP 走响应头:`default-src 'none'` +
11
+ * `script-src 'self'`,所以也没有内联脚本、没有外部资源。
12
+ */
13
+ 'use strict'
14
+
15
+ const log = document.getElementById('log')
16
+ const input = document.getElementById('input')
17
+ const send = document.getElementById('send')
18
+ const statusEl = document.getElementById('status')
19
+ const foot = document.getElementById('foot')
20
+
21
+ const COPY_LABEL = '复制'
22
+ const COPIED_LABEL = '已复制'
23
+ const MANUAL_LABEL = '按 Ctrl+C'
24
+
25
+ /** 把节点内容全选上。用于剪贴板 API 用不了时的退路 */
26
+ function selectContents(node) {
27
+ const range = document.createRange()
28
+ range.selectNodeContents(node)
29
+ const selection = window.getSelection()
30
+ selection.removeAllRanges()
31
+ selection.addRange(range)
32
+ }
33
+
34
+ /**
35
+ * 助手回答下面的复制按钮。
36
+ *
37
+ * 为什么要有:起草回复那条路**只产出文本**——不碰微信窗口、不模拟按键(见 D-047),
38
+ * 所以"把这几条候选拿到别处去用"这一步只能由用户自己做,那就得让他一键拿得走。
39
+ * 它是**整条回答**的复制,不做"逐条候选分别复制":候选是模型用自己的话重述过的,
40
+ * 在客户端按行去猜哪行是候选,猜错就是把半句话复制走。
41
+ *
42
+ * 反馈必须落在按钮自己身上(窗口只有巴掌大,用户不会去看别处)。
43
+ */
44
+ function addCopyButton(turn, textNode, text) {
45
+ const button = document.createElement('button')
46
+ button.type = 'button'
47
+ button.className = 'copy'
48
+ button.textContent = COPY_LABEL
49
+ let timer = null
50
+ const flash = (label) => {
51
+ button.textContent = label
52
+ if (timer) clearTimeout(timer)
53
+ timer = setTimeout(() => { button.textContent = COPY_LABEL }, 1500)
54
+ }
55
+ button.addEventListener('click', async () => {
56
+ try {
57
+ await navigator.clipboard.writeText(text)
58
+ flash(COPIED_LABEL)
59
+ } catch {
60
+ // 剪贴板 API 拿不到时**不许假装复制成功**:用户会以为剪贴板里有东西,粘出来
61
+ // 是上一次的旧内容——那比什么都不做更坏。改成把这段选上,让他自己按 Ctrl+C。
62
+ selectContents(textNode)
63
+ flash(MANUAL_LABEL)
64
+ }
65
+ })
66
+ turn.appendChild(button)
67
+ }
68
+
69
+ /** 把一条消息加进对话区。`who` 决定样式,**内容永远走 textContent** */
70
+ function addTurn(who, text) {
71
+ const empty = log.querySelector('.empty')
72
+ if (empty) empty.remove() // 第一句话一来,开头那段提示就该让位
73
+ const div = document.createElement('div')
74
+ div.className = 'turn ' + who
75
+ const body = document.createElement('div')
76
+ body.className = 'turn-text'
77
+ body.textContent = text
78
+ div.appendChild(body)
79
+ // 只有助手的回答带复制按钮:用户自己发的不用复制,"正在输入"没有内容可复制,
80
+ // 错误提示也不该被粘到别处去。
81
+ if (who === 'it') addCopyButton(div, body, text)
82
+ log.appendChild(div)
83
+ log.scrollTop = log.scrollHeight
84
+ return div
85
+ }
86
+
87
+ /**
88
+ * 还没聊过时的开头。**空白是这里最糟的状态**:第一次打开的人看到一整片黑,
89
+ * 既不知道它能干什么,也看不出它是活的(下面没有正在输入之类的动静)。
90
+ * 例子做成可点的按钮——点一下就是真的问一句,走的还是同一条提交路径。
91
+ */
92
+ const EXAMPLES = [
93
+ '我最近都在忙什么?',
94
+ '总结我和某某的聊天',
95
+ '收藏里有哪些 AI 文章?',
96
+ '我最近都在读什么?',
97
+ ]
98
+
99
+ function renderEmptyState() {
100
+ if (log.children.length) return
101
+ const box = document.createElement('div')
102
+ box.className = 'empty'
103
+
104
+ const title = document.createElement('div')
105
+ title.className = 'empty-title'
106
+ title.textContent = '直接问就行,我会查本机数据回答'
107
+ box.appendChild(title)
108
+
109
+ const sub = document.createElement('div')
110
+ sub.className = 'empty-sub'
111
+ sub.textContent = '聊天、收藏、公众号、微信读书、待办都在本机,数据库不出这台机器。'
112
+ box.appendChild(sub)
113
+
114
+ for (const example of EXAMPLES) {
115
+ const chip = document.createElement('button')
116
+ chip.type = 'button'
117
+ chip.className = 'chip'
118
+ chip.textContent = example
119
+ chip.addEventListener('click', () => {
120
+ if (input.disabled) return // 上一句还在飞的时候点了没用,就不该有反应
121
+ input.value = example
122
+ document.getElementById('composer').requestSubmit()
123
+ })
124
+ box.appendChild(chip)
125
+ }
126
+ log.appendChild(box)
127
+ }
128
+
129
+ /**
130
+ * 球的背景跟着状态走(CSS 里 `body.busy/.offline/.quota #ball .glow`)。
131
+ *
132
+ * 集中在一个函数里改:之前这些类名散在 ask()/refreshStatus() 各处的话,
133
+ * 迟早有一条分支忘了摘掉 `busy`,球就一直亮着——那种"看起来在忙其实没在忙"比不显示更糟。
134
+ * 传进来的每一项都**整体替换**,不做增量。
135
+ */
136
+ function setBallState(state) {
137
+ document.body.classList.remove('busy', 'offline', 'quota')
138
+ if (state) document.body.classList.add(state)
139
+ }
140
+
141
+ /** 服务端的状态码 → 一句人话。**别把 code 原样丢给用户** */
142
+ function explain(code, fallback) {
143
+ switch (code) {
144
+ case 'BUSY': return '上一句还在处理,等它说完再发。'
145
+ case 'NOT_RUNNING': return '助手没在运行。在电脑上跑一次 `weflow-cli assistant start`。'
146
+ case 'QUOTA_EXCEEDED': return '今天的额度用完了。'
147
+ case 'TIMEOUT': return '这一轮太久没结束(它可能还在后台跑)。稍等一下再问。'
148
+ case 'UNAUTHORIZED': return '凭据失效了。重新用 `weflow-cli panel` 打开这个窗口。'
149
+ case 'ORIGIN_DENIED': return '这个来源被拒绝了。'
150
+ default: return fallback || ('没成(' + code + ')')
151
+ }
152
+ }
153
+
154
+ /** 已请求过几次自愈。成功会重载页面,这个计数自然归零 */
155
+ let repairTries = 0
156
+
157
+ /**
158
+ * 凭据失效时请主进程换一张。
159
+ *
160
+ * **为什么需要它**:token 是守护进程每次启动新生成的,而窗口的 cookie 是打开那一刻种的。
161
+ * 所以 `assistant stop/start` 之后,还开着的窗口手里是死凭据——`/api/status` 与**球的脸图**
162
+ * (`Cache-Control: no-store`)一起 401,表现是**球变成空白**。2026-09-28 实测到:窗口比守护
163
+ * 进程早 25 小时,用户看到的是"图标不见了",而状态栏那句"凭据失效了"在球形态下根本没人看。
164
+ *
165
+ * 连试三次还不行就停手:**重载风暴比空白球更难排查**,剩下的交给状态栏那句话。
166
+ */
167
+ async function requestRepair() {
168
+ if (repairTries >= 3) return
169
+ repairTries += 1
170
+ try {
171
+ const r = await weflowPanel.repair()
172
+ if (r && r.ok) return // 主进程会重载页面,这里不用做别的
173
+ if (r && r.code === 'DAEMON_DOWN') {
174
+ statusEl.textContent = '助手没在运行。先在终端跑 weflow-cli assistant start。'
175
+ }
176
+ } catch {
177
+ // 主进程没接上就算了这个(例如旧版 preload),状态栏那句话已经说了该做什么
178
+ }
179
+ }
180
+
181
+ async function refreshStatus() {
182
+ try {
183
+ const res = await fetch('/api/status', { credentials: 'same-origin' })
184
+ if (!res.ok) {
185
+ const body = await res.json().catch(() => ({}))
186
+ statusEl.textContent = explain(body.code, '连接异常')
187
+ // 只有**凭据过期**这一类能自愈;别的 401/403 重载多少次都一样
188
+ if (body.code === 'UNAUTHORIZED') requestRepair()
189
+ return
190
+ }
191
+ const s = await res.json()
192
+ quickReplies = Array.isArray(s.quickReplies) ? s.quickReplies.filter(n => typeof n === 'string') : []
193
+ const mode = s.channelActive ? '微信 + 本机' : '仅本机入口'
194
+ statusEl.textContent = mode + '|今日 ' + s.quota.used + '/' + s.quota.limit
195
+ // 额度用尽是"今天不能再用",值得在球上看得出来(琥珀),但**不是**错误
196
+ if (s.quota.used >= s.quota.limit) setBallState('quota')
197
+
198
+ // 记忆桶那句话说清"是不是同一个大脑"——这是用户最容易误解的地方
199
+ foot.textContent = ''
200
+ const line = document.createElement('div')
201
+ line.textContent = s.memoryNote || ('记忆桶:' + s.memoryBucket)
202
+ foot.appendChild(line)
203
+ if (!s.aiConfigured) {
204
+ const warn = document.createElement('div')
205
+ warn.className = 'warn'
206
+ warn.textContent = '还没配置 LLM:在电脑上跑 `weflow-cli config set deepseekApiKey "..."`'
207
+ foot.appendChild(warn)
208
+ input.disabled = true
209
+ send.disabled = true
210
+ }
211
+ } catch {
212
+ statusEl.textContent = '连不上本机入口'
213
+ setBallState('offline') // 暖色且停住流动:不动的东西才会被注意到
214
+ }
215
+ }
216
+
217
+ /** 让助手给这个人起草一条回复。**只产文本**——发送在这条链路上结构性不可达。 */
218
+ function draftRequest(name) {
219
+ return `用 draft_reply 给「${name}」起草 3 条候选回复,把候选原样列出来。只产文本,不要发送。`
220
+ }
221
+
222
+ /**
223
+ * 问一句。`display` 用来把"用户看到的那句话"和"实际发出去的那句"分开:
224
+ * 快速回复要发的是一句点名工具的请求(要模型选对工具),但那句话长得像机器指令,
225
+ * 摆进对话里读起来不像人说的——所以对话里显示「快速回复:咸鱼梦想家」。
226
+ */
227
+ async function ask(text, display = null) {
228
+ addTurn('me', display || text)
229
+ const pending = addTurn('it pending', '…')
230
+ input.disabled = true
231
+ send.disabled = true
232
+ // 球收起来的时候,此前完全看不出它在干活——这是这条状态最主要的用处
233
+ setBallState('busy')
234
+
235
+ try {
236
+ const res = await fetch('/api/ask', {
237
+ method: 'POST',
238
+ credentials: 'same-origin',
239
+ headers: { 'Content-Type': 'application/json' },
240
+ body: JSON.stringify({ text: text }),
241
+ })
242
+ const body = await res.json().catch(() => ({}))
243
+ pending.remove()
244
+
245
+ if (res.ok && body.ok) {
246
+ addTurn('it', body.reply)
247
+ // 配额用尽时服务端回的是一句**回答**(不是错误),所以上面照常显示;
248
+ // 这里只是把状态条上的数字刷新一下
249
+ void refreshStatus()
250
+ return
251
+ }
252
+ addTurn('err', explain(body.code, body.error))
253
+ } catch {
254
+ pending.remove()
255
+ addTurn('err', '请求没发出去(助手进程可能刚停)。')
256
+ } finally {
257
+ input.disabled = false
258
+ send.disabled = false
259
+ setBallState(null)
260
+ void refreshStatus() // 顺手把用量与配额状态刷新(额度用尽会换成琥珀色)
261
+ input.focus()
262
+ }
263
+ }
264
+
265
+ document.getElementById('composer').addEventListener('submit', (event) => {
266
+ event.preventDefault()
267
+ const text = input.value.trim()
268
+ if (!text) return
269
+ input.value = ''
270
+ void ask(text)
271
+ })
272
+
273
+ input.addEventListener('keydown', (event) => {
274
+ // Enter 发送,Shift+Enter 换行
275
+ if (event.key === 'Enter' && !event.shiftKey) {
276
+ event.preventDefault()
277
+ document.getElementById('composer').requestSubmit()
278
+ }
279
+ })
280
+
281
+ // ---------------------------------------------------------------- 球 / 对话窗
282
+ //
283
+ // 只在**有外壳**(Electron)时才起小球:浏览器那条路(Edge `--app`)给不了无边框置顶的小球,
284
+ // 硬做只会做出一个奇怪的方窗口。所以这里靠 `window.weflowPanel` 在不在来判断,
285
+ // 而且这正是 preload 唯一暴露的东西——页面拿不到凭据,也不该拿得到。
286
+
287
+ const ball = document.getElementById('ball')
288
+ const collapse = document.getElementById('collapse')
289
+ const hasShell = typeof window.weflowPanel !== 'undefined'
290
+
291
+ /**
292
+ * 右键"快速回复"的名单。来自 `/api/status`——页面本来每 30 秒就轮询那个端点,
293
+ * 所以这份名单**不必再开一个入口**(面板那条路上不加新端点、不加 IPC)。
294
+ */
295
+ let quickReplies = []
296
+
297
+ /**
298
+ * 系统关了动画没有。**这一条只有渲染进程能问**(`prefers-reduced-motion` 是媒体查询,
299
+ * 主进程那边没有等价 API),而窗口几何的补间在主进程、CSS 管不着——所以读出来的结果
300
+ * 要随 `setMode` 带过去。
301
+ *
302
+ * `typeof` 那半不是客套:`matchMedia` 在 jsdom 里**根本不存在**(测试就是拿 jsdom 跑这个
303
+ * 文件的),直接调会在加载时就抛。
304
+ */
305
+ function prefersReducedMotion() {
306
+ return typeof window.matchMedia === 'function'
307
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches
308
+ }
309
+
310
+ /** 气泡方位相关的类,每次切形态先全摘掉再按主进程说的挂上 */
311
+ const LAYOUT_CLASSES = ['bubble-left', 'bubble-right', 'anchor-top']
312
+
313
+ /**
314
+ * 按主进程说的形态切 class。**这里是唯一改形态的地方**。
315
+ *
316
+ * 载荷带方位:`side` 是气泡在球的哪一边(球就在窗口的另一边),`anchorY` 是球贴窗口的
317
+ * 上边还是下边(气泡跟着它竖着对齐),`bubbleHeight` 是气泡该有多高——**屏幕不够高时
318
+ * 气泡会变矮**,那是为了让球待在窗口的角上不动(见 `ball-position.cjs` 的 `bubbleLayout`)。
319
+ *
320
+ * 页面自己算不出这些:它不知道自己在屏幕上的位置,也不知道工作区多大。
321
+ */
322
+ function applyMode(payload) {
323
+ const mode = payload && payload.mode === 'ball' ? 'ball' : 'chat'
324
+ const side = payload && payload.side === 'right' ? 'right' : 'left'
325
+ document.body.classList.remove('mode-ball', 'mode-chat', 'closing', ...LAYOUT_CLASSES)
326
+ document.body.classList.add(mode === 'chat' ? 'mode-chat' : 'mode-ball')
327
+ if (mode === 'chat') {
328
+ document.body.classList.add(side === 'right' ? 'bubble-right' : 'bubble-left')
329
+ if (payload && payload.anchorY === 'top') document.body.classList.add('anchor-top')
330
+ const height = Number(payload && payload.bubbleHeight)
331
+ document.body.style.setProperty('--bubble-height',
332
+ `${Number.isFinite(height) && height > 0 ? Math.round(height) : 560}px`)
333
+ input.focus()
334
+ }
335
+ }
336
+
337
+ /**
338
+ * 请主进程换形态。**页面自己不先换 class**:从前这条路是页面直接 `classList.replace`,
339
+ * 而托盘那条走的是 `panel:mode`,两条路行为不一样(一边球瞬间消失、一边等主进程)。
340
+ * 现在形态只有主进程一个来源,页面照着 `panel:mode` 做,展开与收起于是必然对称。
341
+ */
342
+ function requestMode(mode) {
343
+ void window.weflowPanel.setMode(mode, { animate: !prefersReducedMotion() })
344
+ }
345
+
346
+ /**
347
+ * 点球 = 开关:站着就展开、展开了就收起。**球全程不消失**——它是"在说话的那个图标",
348
+ * 让它先撤下去再冒出个窗口,就是用户嫌的那股突兀劲。
349
+ */
350
+ function toggleMode() {
351
+ requestMode(document.body.classList.contains('mode-chat') ? 'ball' : 'chat')
352
+ }
353
+
354
+ if (hasShell) {
355
+ // `shell` 这个类决定球在不在场(见 panel.css):浏览器降级那条路永远不该看见球
356
+ document.body.classList.add('shell', 'mode-ball')
357
+ ball.hidden = false
358
+ collapse.hidden = false
359
+
360
+ // 球:**点一下展开,按住拖动挪位置**。
361
+ //
362
+ // 为什么不用 `-webkit-app-region: drag`:Windows 上拖拽区会**吞掉鼠标事件**,
363
+ // 页面根本收不到 click——现象就是"点这个图标没反应"(实测的故障)。
364
+ // 所以拖拽自己实现:按下时告诉主进程记住窗口与指针位置,移动时按差值挪窗口;
365
+ // 松手时如果**指针几乎没动**,那就是一次点击。
366
+ // 阈值 4 像素:手抖不会把点击变成拖动,而想拖的人自然会移过 4 像素。
367
+ /** 被捏一下的表情。**只有一个入口**(同 `setBallState` 的理由:散着写迟早有一条分支
368
+ * 忘了摘,球就一直是那张笑脸)。
369
+ *
370
+ * **按住是笑脸,一松手立刻变回来。** 第一版松手后还留 900ms,想的是"一次点击只闪
371
+ * 十几毫秒,不留就看不见"——用户试过之后说那个延迟很别扭:手指松开了表情还挂着,
372
+ * 像是卡在那儿。所以不留了。
373
+ */
374
+ function setBallFace(happy) {
375
+ document.body.classList.toggle('ball-happy', happy)
376
+ }
377
+
378
+ const DRAG_THRESHOLD_PX = 4
379
+ ball.addEventListener('pointerdown', (event) => {
380
+ if (event.button !== 0) return
381
+ setBallFace(true)
382
+ const startX = event.screenX
383
+ const startY = event.screenY
384
+ let dragging = false
385
+
386
+ const onMove = (moveEvent) => {
387
+ if (!dragging && Math.hypot(moveEvent.screenX - startX, moveEvent.screenY - startY) > DRAG_THRESHOLD_PX) {
388
+ dragging = true
389
+ }
390
+ if (dragging) void window.weflowPanel.dragMove(moveEvent.screenX, moveEvent.screenY)
391
+ }
392
+ const onUp = () => {
393
+ window.removeEventListener('pointermove', onMove)
394
+ window.removeEventListener('pointerup', onUp)
395
+ window.removeEventListener('pointercancel', onCancel)
396
+ void window.weflowPanel.dragEnd()
397
+ setBallFace(false)
398
+ if (dragging) return // 拖过了就不算点击
399
+ toggleMode()
400
+ }
401
+ // 指针被系统抢走(触摸、原生菜单弹出)时不走 onUp,必须自己把脸收回来,否则球一直笑着
402
+ const onCancel = () => setBallFace(false)
403
+ void window.weflowPanel.dragStart(startX, startY)
404
+ window.addEventListener('pointermove', onMove)
405
+ window.addEventListener('pointerup', onUp)
406
+ window.addEventListener('pointercancel', onCancel)
407
+ })
408
+ // 右键:快速回复。**只在有外壳时接管**——浏览器降级那条路的原生菜单里有"复制",
409
+ // 那是用户要用的,不该被我们抢掉。
410
+ //
411
+ // 有外壳时用**主进程弹的原生菜单**:它画在窗口外面,所以球形态那 76x76 的窗口
412
+ // **不用先展开**(页内菜单会被窗口裁掉)。上一版就是"先展开再弹",用户看到的是
413
+ // "点右键把第二大脑窗口弹出来了"——那不是快速功能该有的样子。
414
+ document.addEventListener('contextmenu', (event) => {
415
+ event.preventDefault()
416
+ void (async () => {
417
+ const picked = await window.weflowPanel.openQuickMenu(quickReplies)
418
+ if (!picked || typeof picked !== 'object') return
419
+ // 选中任何一项都要展开:球那个 76x76 里看不到任何回答,而这几项全都要出文字。
420
+ // 但**右键本身不动窗口**(那是用户嫌的那一下)——展开只发生在真的选了东西之后。
421
+ if (document.body.classList.contains('mode-ball')) requestMode('chat')
422
+ if (picked.kind === 'contact' && typeof picked.name === 'string' && picked.name.trim()) {
423
+ const name = picked.name.trim()
424
+ void ask(draftRequest(name), `快速回复:${name}`)
425
+ return
426
+ }
427
+ if (picked.kind === 'action' && typeof picked.prompt === 'string' && picked.prompt.trim()) {
428
+ // 话术**由菜单项带过来**,页面不自己按 id 查表——两份映射会漂移,而漂移的后果是
429
+ // "菜单写着甲、点下去做了乙",不报错。
430
+ void ask(picked.prompt, String(picked.label || '快捷功能'))
431
+ }
432
+ })()
433
+ })
434
+ document.addEventListener('click', () => closeQuickMenu())
435
+ document.addEventListener('keydown', (event) => {
436
+ if (event.key === 'Escape') closeQuickMenu()
437
+ })
438
+
439
+ collapse.addEventListener('click', () => { requestMode('ball') })
440
+
441
+ // 形态由主进程说了算(球、收起、托盘菜单、快捷键都是这条路),收起分两步:
442
+ // `fadeMs > 0` → 先把气泡淡掉(窗口这会儿还是大的,淡出真看得见),**不换形态**;
443
+ // `done` → 主进程已经把窗口缩回球那么大,这时才把气泡摘掉。
444
+ // 顺序不能反:提前换,76x76 的窗口里会露出一条气泡的边;不换,气泡会一直在。
445
+ window.weflowPanel.onMode((payload) => {
446
+ if (payload && payload.mode === 'ball' && !payload.done) {
447
+ if (payload.fadeMs > 0 && document.body.classList.contains('mode-chat')) {
448
+ document.body.classList.add('closing')
449
+ return
450
+ }
451
+ applyMode(payload)
452
+ return
453
+ }
454
+ applyMode(payload)
455
+ })
456
+ } else {
457
+ // 浏览器降级:把话说清,别让用户以为悬浮球坏了
458
+ const note = document.createElement('div')
459
+ note.className = 'warn'
460
+ note.textContent = '这是浏览器窗口(没有 Electron):给不了悬浮球、托盘和全局快捷键。'
461
+ + '想要真·悬浮球,在电脑上跑 npm i -g electron 之后再执行 weflow-cli panel。'
462
+ foot.appendChild(note)
463
+ }
464
+
465
+ renderEmptyState()
466
+ void refreshStatus()
467
+ // 每 30 秒刷一次状态:守护进程可能被停掉,界面不该一直显示旧数字
468
+ setInterval(refreshStatus, 30000)
469
+ // 球形态下不抢焦点(抢了会把用户正在打字的窗口顶掉)
470
+ if (!hasShell || !document.body.classList.contains('mode-ball')) input.focus()