weflow-cli 1.7.0 → 1.8.1

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 (200) hide show
  1. package/ARCHITECTURE.md +3 -2
  2. package/CHANGELOG.md +2108 -2
  3. package/OPERATIONS.md +810 -1
  4. package/README.en.md +96 -10
  5. package/README.md +34 -9
  6. package/bin/weflow-cli.ts +937 -28
  7. package/dist/bin/weflow-cli.js +955 -28
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.js +39 -13
  10. package/dist/mcp-server/index.js.map +1 -1
  11. package/dist/src/core/appMsgFormat.d.ts +37 -0
  12. package/dist/src/core/appMsgFormat.d.ts.map +1 -0
  13. package/dist/src/core/appMsgFormat.js +106 -0
  14. package/dist/src/core/appMsgFormat.js.map +1 -0
  15. package/dist/src/core/ntCore.d.ts +19 -2
  16. package/dist/src/core/ntCore.d.ts.map +1 -1
  17. package/dist/src/core/ntCore.js +11 -1
  18. package/dist/src/core/ntCore.js.map +1 -1
  19. package/dist/src/core/sqlcipherCore.d.ts +0 -12
  20. package/dist/src/core/sqlcipherCore.d.ts.map +1 -1
  21. package/dist/src/core/sqlcipherCore.js +5 -64
  22. package/dist/src/core/sqlcipherCore.js.map +1 -1
  23. package/dist/src/core/wcdbCore.d.ts +1 -1
  24. package/dist/src/core/wcdbCore.d.ts.map +1 -1
  25. package/dist/src/core/wcdbCore.js +5 -1
  26. package/dist/src/core/wcdbCore.js.map +1 -1
  27. package/dist/src/panel/client.d.ts +25 -0
  28. package/dist/src/panel/client.d.ts.map +1 -0
  29. package/dist/src/panel/client.js +34 -0
  30. package/dist/src/panel/client.js.map +1 -0
  31. package/dist/src/panel/endpoint.d.ts +45 -0
  32. package/dist/src/panel/endpoint.d.ts.map +1 -0
  33. package/dist/src/panel/endpoint.js +132 -0
  34. package/dist/src/panel/endpoint.js.map +1 -0
  35. package/dist/src/panel/launch.d.ts +50 -0
  36. package/dist/src/panel/launch.d.ts.map +1 -0
  37. package/dist/src/panel/launch.js +74 -0
  38. package/dist/src/panel/launch.js.map +1 -0
  39. package/dist/src/panel/server.d.ts +23 -0
  40. package/dist/src/panel/server.d.ts.map +1 -0
  41. package/dist/src/panel/server.js +413 -0
  42. package/dist/src/panel/server.js.map +1 -0
  43. package/dist/src/panel/userId.d.ts +37 -0
  44. package/dist/src/panel/userId.d.ts.map +1 -0
  45. package/dist/src/panel/userId.js +56 -0
  46. package/dist/src/panel/userId.js.map +1 -0
  47. package/dist/src/services/assistantDaemon.d.ts +20 -3
  48. package/dist/src/services/assistantDaemon.d.ts.map +1 -1
  49. package/dist/src/services/assistantDaemon.js +46 -9
  50. package/dist/src/services/assistantDaemon.js.map +1 -1
  51. package/dist/src/services/assistantEval.d.ts +141 -0
  52. package/dist/src/services/assistantEval.d.ts.map +1 -0
  53. package/dist/src/services/assistantEval.js +497 -0
  54. package/dist/src/services/assistantEval.js.map +1 -0
  55. package/dist/src/services/assistantMemory.d.ts +106 -4
  56. package/dist/src/services/assistantMemory.d.ts.map +1 -1
  57. package/dist/src/services/assistantMemory.js +396 -61
  58. package/dist/src/services/assistantMemory.js.map +1 -1
  59. package/dist/src/services/assistantPrivacy.d.ts +15 -2
  60. package/dist/src/services/assistantPrivacy.d.ts.map +1 -1
  61. package/dist/src/services/assistantPrivacy.js +25 -6
  62. package/dist/src/services/assistantPrivacy.js.map +1 -1
  63. package/dist/src/services/assistantRouter.d.ts +41 -0
  64. package/dist/src/services/assistantRouter.d.ts.map +1 -0
  65. package/dist/src/services/assistantRouter.js +151 -0
  66. package/dist/src/services/assistantRouter.js.map +1 -0
  67. package/dist/src/services/assistantService.d.ts +209 -3
  68. package/dist/src/services/assistantService.d.ts.map +1 -1
  69. package/dist/src/services/assistantService.js +599 -75
  70. package/dist/src/services/assistantService.js.map +1 -1
  71. package/dist/src/services/assistantTools.d.ts +217 -1
  72. package/dist/src/services/assistantTools.d.ts.map +1 -1
  73. package/dist/src/services/assistantTools.js +1116 -59
  74. package/dist/src/services/assistantTools.js.map +1 -1
  75. package/dist/src/services/assistantTrace.d.ts +47 -0
  76. package/dist/src/services/assistantTrace.d.ts.map +1 -0
  77. package/dist/src/services/assistantTrace.js +150 -0
  78. package/dist/src/services/assistantTrace.js.map +1 -0
  79. package/dist/src/services/chatService.d.ts +1 -1
  80. package/dist/src/services/chatService.d.ts.map +1 -1
  81. package/dist/src/services/chatService.js +5 -2
  82. package/dist/src/services/chatService.js.map +1 -1
  83. package/dist/src/services/configService.d.ts +9 -0
  84. package/dist/src/services/configService.d.ts.map +1 -1
  85. package/dist/src/services/configService.js +15 -8
  86. package/dist/src/services/configService.js.map +1 -1
  87. package/dist/src/services/pythonBridge.d.ts +41 -0
  88. package/dist/src/services/pythonBridge.d.ts.map +1 -0
  89. package/dist/src/services/pythonBridge.js +119 -0
  90. package/dist/src/services/pythonBridge.js.map +1 -0
  91. package/dist/src/services/syncService.d.ts +8 -2
  92. package/dist/src/services/syncService.d.ts.map +1 -1
  93. package/dist/src/services/syncService.js +26 -7
  94. package/dist/src/services/syncService.js.map +1 -1
  95. package/dist/src/services/wechatMessageService.d.ts +25 -0
  96. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  97. package/dist/src/services/wechatMessageService.js +69 -7
  98. package/dist/src/services/wechatMessageService.js.map +1 -1
  99. package/dist/src/services/wereadService.d.ts +33 -6
  100. package/dist/src/services/wereadService.d.ts.map +1 -1
  101. package/dist/src/services/wereadService.js +27 -2
  102. package/dist/src/services/wereadService.js.map +1 -1
  103. package/dist/src/utils/atomicWrite.d.ts +4 -0
  104. package/dist/src/utils/atomicWrite.d.ts.map +1 -0
  105. package/dist/src/utils/atomicWrite.js +60 -0
  106. package/dist/src/utils/atomicWrite.js.map +1 -0
  107. package/dist/src/utils/dateRange.d.ts +15 -0
  108. package/dist/src/utils/dateRange.d.ts.map +1 -1
  109. package/dist/src/utils/dateRange.js +47 -0
  110. package/dist/src/utils/dateRange.js.map +1 -1
  111. package/dist/src/utils/text.d.ts +18 -0
  112. package/dist/src/utils/text.d.ts.map +1 -0
  113. package/dist/src/utils/text.js +20 -0
  114. package/dist/src/utils/text.js.map +1 -0
  115. package/docs/AI_INTERFACE.md +2 -0
  116. package/docs/BRANCHES.md +13 -4
  117. package/docs/DECISIONS.md +1424 -0
  118. package/docs/EXTENDING.md +187 -0
  119. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +310 -0
  120. package/docs/MCP.md +23 -4
  121. package/docs/PROJECT_STATE.md +210 -9
  122. package/docs/SETUP.md +22 -0
  123. package/docs/SYNC_CONTRACT.md +31 -3
  124. package/mcp-server/index.ts +681 -657
  125. package/package.json +84 -83
  126. package/resources/panel/ball-position.cjs +153 -0
  127. package/resources/panel/index.html +46 -0
  128. package/resources/panel/main.cjs +483 -0
  129. package/resources/panel/mascot-happy.png +0 -0
  130. package/resources/panel/mascot-sorry.png +0 -0
  131. package/resources/panel/mascot-thinking.png +0 -0
  132. package/resources/panel/mascot-tired.png +0 -0
  133. package/resources/panel/mascot.png +0 -0
  134. package/resources/panel/package.json +7 -0
  135. package/resources/panel/panel.css +415 -0
  136. package/resources/panel/preload.cjs +79 -0
  137. package/resources/panel/quick-menu.cjs +197 -0
  138. package/resources/panel/renderer.js +470 -0
  139. package/resources/panel/tray-menu.cjs +40 -0
  140. package/resources/panel/tray.png +0 -0
  141. package/scripts/_utils.py +425 -54
  142. package/scripts/annual_report.py +4 -2
  143. package/scripts/article_notes.py +423 -0
  144. package/scripts/backfill_articles.py +438 -0
  145. package/scripts/biz_daily.py +590 -105
  146. package/scripts/chat_notes.py +470 -0
  147. package/scripts/classify_daily.py +10 -18
  148. package/scripts/compile_wiki.py +1060 -46
  149. package/scripts/create_reading_notes.py +199 -14
  150. package/scripts/decide.py +253 -0
  151. package/scripts/draft_reply.py +671 -0
  152. package/scripts/export_chat_html.py +35 -33
  153. package/scripts/extract_todos.py +31 -5
  154. package/scripts/fav_notes.py +318 -0
  155. package/scripts/fix_topics.py +2 -1
  156. package/scripts/generate_ai_report.py +214 -9
  157. package/scripts/generate_html.py +18 -3
  158. package/scripts/generate_review.py +2 -2
  159. package/scripts/jev_client.py +294 -0
  160. package/scripts/jev_probe.py +440 -0
  161. package/scripts/nt_common.py +82 -0
  162. package/scripts/nt_decrypt.py +1755 -1460
  163. package/scripts/pipeline.py +27 -2
  164. package/scripts/promote_ideas.py +19 -4
  165. package/scripts/quality_eval.py +707 -0
  166. package/scripts/rag_chat.py +4 -4
  167. package/scripts/read_image.py +187 -0
  168. package/scripts/reply_debt.py +488 -0
  169. package/scripts/route_cards.py +453 -0
  170. package/scripts/semantic_search.py +208 -49
  171. package/scripts/user_notes.py +315 -0
  172. package/scripts/vault_rag.py +13 -7
  173. package/scripts/vault_search.py +21 -4
  174. package/scripts/wiki_lint.py +429 -0
  175. package/src/core/appMsgFormat.ts +117 -0
  176. package/src/core/ntCore.ts +27 -2
  177. package/src/core/sqlcipherCore.ts +5 -61
  178. package/src/core/wcdbCore.ts +6 -2
  179. package/src/panel/client.ts +49 -0
  180. package/src/panel/endpoint.ts +144 -0
  181. package/src/panel/launch.ts +101 -0
  182. package/src/panel/server.ts +424 -0
  183. package/src/panel/userId.ts +72 -0
  184. package/src/services/assistantDaemon.ts +57 -9
  185. package/src/services/assistantEval.ts +611 -0
  186. package/src/services/assistantMemory.ts +413 -57
  187. package/src/services/assistantPrivacy.ts +25 -6
  188. package/src/services/assistantRouter.ts +191 -0
  189. package/src/services/assistantService.ts +681 -74
  190. package/src/services/assistantTools.ts +1149 -58
  191. package/src/services/assistantTrace.ts +175 -0
  192. package/src/services/chatService.ts +6 -2
  193. package/src/services/configService.ts +403 -385
  194. package/src/services/pythonBridge.ts +139 -0
  195. package/src/services/syncService.ts +37 -9
  196. package/src/services/wechatMessageService.ts +73 -7
  197. package/src/services/wereadService.ts +43 -7
  198. package/src/utils/atomicWrite.ts +56 -0
  199. package/src/utils/dateRange.ts +46 -0
  200. package/src/utils/text.ts +20 -0
@@ -0,0 +1,197 @@
1
+ /**
2
+ * 右键菜单的**内容**:纯数据 + 回调,不 require electron。
3
+ *
4
+ * 为什么单独一个文件:菜单里的点击在 CI 里点不了,但**"有哪几项、点了各自该干什么"是我们的代码**,
5
+ * 而 `Menu.buildFromTemplate` + `popup` 那一步才是 Electron 的。拆开之后这个函数能被测试直接
6
+ * require 进来、把每个 `click` 真的调一遍——比拿正则去匹配源码文本结实得多(同 `tray-menu.cjs`)。
7
+ *
8
+ * **为什么球形态下用原生菜单而不是页内那个**:原生菜单画在**窗口外面**,所以球那 76x76 的
9
+ * 窗口不用先展开成对话窗——用户的原话是"右键应该算是快速功能",而"顺手把窗口弹出来"恰好
10
+ * 是他要避免的那一下。页内菜单留给浏览器降级那条路(那条路没有 Electron,也就没有原生菜单,
11
+ * 而它的窗口是正常的浏览器窗,画得下)。
12
+ *
13
+ * 菜单有两段:**起草回复**(联系人名单)与**快捷功能**(固定动作)。第二段是后加的,
14
+ * 加它的判据只有一条:**这个动作能不能用一句固定的话做完**。要打字才能用的(搜索、导出、
15
+ * 指定某条消息)做不成一键,不许往这里塞——塞进来只会做出一个点了没反应的菜单项。
16
+ *
17
+ * 一条不能松的语义:**代价写在菜单里**,而且**逐项说清**(见 `costNote()`)。点一下 = 一次
18
+ * 出境这件事必须在菜单里看得见,而不是点完才知道;反过来,"这几项其实只读本地"也该看得见,
19
+ * 否则用户会不敢点本地那几项。
20
+ *
21
+ * 最后一项是**「关闭悬浮球」**,它是这个菜单里唯一不碰 AI 的一项。三条取舍:
22
+ * - 它是 `win.hide()`(收起来),**不是退出**。退出面板 / 退出并停止助手属于"退出"那一类,
23
+ * 在托盘菜单里(`tray-menu.cjs` 把它们和"显示 / 展开"分隔开了),这里塞进来就把两类混了。
24
+ * - 所以它**可逆**,而收回来的路(托盘图标)必须写在标签里——球藏起来以后任务栏没有它的位置
25
+ * (`setSkipTaskbar(true)`),不写清楚就成了"点一下它永远消失了"。
26
+ * - **任何情况下它都在**:名单没配过、快捷功能全不可用时,用户照样得有个办法把球收起来。
27
+ */
28
+ 'use strict'
29
+
30
+ /** 菜单最多列几个人。窗口小、名单可能很长,超出部分不该把菜单撑成一整屏 */
31
+ const MAX_ITEMS = 20
32
+
33
+ /** 名单为空时菜单显示的两行:说清没配,并给出该跑的那条命令 */
34
+ const EMPTY_HINT = [
35
+ '还没配名单',
36
+ 'weflow-cli config set quickReplyContacts "某人,另一人"',
37
+ ]
38
+
39
+ /**
40
+ * 收起来那一项的标签。括号里那句**不是凑字数的**:球藏起来之后任务栏里没有它
41
+ * (`setSkipTaskbar(true)`),不写出"托盘图标能再打开",这一项看上去就像"永久关掉"。
42
+ */
43
+ const CLOSE_LABEL = '关闭悬浮球(托盘图标能再打开)'
44
+
45
+ /** 两段的段头。**不可点**:它们是分组,不是按钮 */
46
+ const REPLY_SECTION = '起草回复'
47
+ const ACTION_SECTION = '快捷功能'
48
+
49
+ /**
50
+ * 快捷功能。每一项 = 一句固定的话 + 一句给人看的标签。
51
+ *
52
+ * `prompt` **随菜单项一起回传**(经 `panel:quickMenu` 的返回值到页面),页面不另存一份
53
+ * id→话术的映射:两份映射一定会漂移,而漂移的那种失败是"菜单写着甲、点下去做了乙",
54
+ * 不报错、只有读代码才发现。
55
+ *
56
+ * `cloud` 只用来生成提示(`costNote()`),不影响行为——但这些话术**要么点了就调模型**,
57
+ * 要么**纯本地**,没有中间态,所以一个布尔值够用。
58
+ *
59
+ * 措辞里点名工具("用 who_owes_reply 看一遍")是必要的:助手是在 19 个工具里自己选,
60
+ * 不点名时"谁在等我回话"这种话它可能去调 `search_chats` 凑答案。**这仍是靠模型选对工具**,
61
+ * 不是硬绑定——真出现选错的情况再谈加参数。
62
+ */
63
+ const ACTIONS = [
64
+ {
65
+ id: 'who_owes_reply',
66
+ label: '谁在等我回话',
67
+ cloud: true,
68
+ prompt: '用 who_owes_reply 逐会话看一遍:谁在等我回话?只列真的欠着回复的,每条写清是对方和该回什么。',
69
+ },
70
+ {
71
+ id: 'get_todos',
72
+ label: '我的待办',
73
+ cloud: false,
74
+ prompt: '用 get_todos 把我的待办列出来,按紧急程度排。',
75
+ },
76
+ {
77
+ id: 'get_daily_report',
78
+ label: '今日日报',
79
+ cloud: false,
80
+ prompt: '用 get_daily_report 说说最新一期日报里有什么。',
81
+ },
82
+ {
83
+ id: 'get_stats',
84
+ label: '最近统计',
85
+ cloud: false,
86
+ prompt: '用 get_stats 说说我最近的聊天统计。',
87
+ },
88
+ {
89
+ id: 'get_reading_stats',
90
+ label: '阅读统计',
91
+ cloud: false,
92
+ prompt: '用 get_reading_stats 说说我的阅读情况。',
93
+ },
94
+ {
95
+ id: 'list_sessions',
96
+ label: '最近会话',
97
+ cloud: false,
98
+ prompt: '用 list_sessions 列出我最近的会话。',
99
+ },
100
+ {
101
+ id: 'get_sns',
102
+ label: '朋友圈',
103
+ cloud: false,
104
+ prompt: '用 get_sns 说说我朋友圈最近有什么。',
105
+ },
106
+ {
107
+ id: 'get_weread',
108
+ label: '微信读书',
109
+ cloud: false,
110
+ prompt: '用 get_weread 说说我微信读书的最近情况。',
111
+ },
112
+ ]
113
+
114
+ /** 把名单规整成菜单项用的数组:只要非空字符串、去重、有上限 */
115
+ function normalizeNames(labels) {
116
+ const seen = new Set()
117
+ const names = []
118
+ for (const raw of Array.isArray(labels) ? labels : []) {
119
+ if (typeof raw !== 'string') continue
120
+ const name = raw.trim()
121
+ if (!name || seen.has(name)) continue
122
+ seen.add(name)
123
+ names.push(name)
124
+ if (names.length >= MAX_ITEMS) break
125
+ }
126
+ return names
127
+ }
128
+
129
+ /**
130
+ * 代价那一行。**从 ACTIONS 派生,不写死**——写死的那种失败是"菜单加了功能、提示还停在旧话上",
131
+ * 而不准确的代价提示比没有更糟:它让人以为已经被告知了。
132
+ *
133
+ * 起草回复(联系人名单)也要算进去:它是这个菜单里唯一**会把聊天正文发出去**的一项。
134
+ */
135
+ function costNote(hasContacts) {
136
+ const cloud = []
137
+ if (hasContacts) cloud.push(REPLY_SECTION)
138
+ for (const action of ACTIONS) {
139
+ if (action.cloud) cloud.push(action.label)
140
+ }
141
+ if (!cloud.length) return '这些项都不调云端模型,只读本地'
142
+ return `会调云端模型的只有:${cloud.join('、')};其余只读本地,不花钱`
143
+ }
144
+
145
+ /**
146
+ * @param {unknown} labels 名单(联系人名)
147
+ * @param {{
148
+ * pick: (item: {kind: 'contact', name: string} | {kind: 'action', label: string, prompt: string}) => void,
149
+ * close: () => void,
150
+ * }} actions 选中了哪一项 / 选了"收起来"。
151
+ * 关掉菜单这件事由调用方的 `popup({ callback })` 负责,这里不管。
152
+ *
153
+ * 两项都必填:少给一个就当场抛,而不是弹出一个点了没反应的菜单。
154
+ */
155
+ function quickMenuTemplate(labels, actions) {
156
+ if (typeof actions?.pick !== 'function') {
157
+ throw new Error('quickMenuTemplate 需要一个 pick 回调')
158
+ }
159
+ if (typeof actions?.close !== 'function') {
160
+ throw new Error('quickMenuTemplate 需要一个 close 回调')
161
+ }
162
+ const names = normalizeNames(labels)
163
+ const closeItem = { label: CLOSE_LABEL, click: () => actions.close() }
164
+
165
+ const items = []
166
+ if (names.length) {
167
+ items.push({ label: REPLY_SECTION, enabled: false })
168
+ for (const name of names) {
169
+ items.push({ label: name, click: () => actions.pick({ kind: 'contact', name }) })
170
+ }
171
+ } else {
172
+ // 没配名单**不等于这个菜单没用**:下面那八个快捷功能照常给出,所以这里只是少了一段,
173
+ // 不是"空框"(第一版没有快捷功能时,这一段之后就直接是收起来,那才叫空框)。
174
+ items.push(...EMPTY_HINT.map(label => ({ label, enabled: false })))
175
+ }
176
+
177
+ items.push({ type: 'separator' }, { label: ACTION_SECTION, enabled: false })
178
+ for (const action of ACTIONS) {
179
+ items.push({
180
+ label: action.label,
181
+ click: () => actions.pick({ kind: 'action', label: action.label, prompt: action.prompt }),
182
+ })
183
+ }
184
+
185
+ items.push(
186
+ { type: 'separator' },
187
+ { label: costNote(names.length > 0), enabled: false },
188
+ { type: 'separator' },
189
+ closeItem,
190
+ )
191
+ return items
192
+ }
193
+
194
+ module.exports = {
195
+ MAX_ITEMS, EMPTY_HINT, CLOSE_LABEL, REPLY_SECTION, ACTION_SECTION,
196
+ ACTIONS, costNote, normalizeNames, quickMenuTemplate,
197
+ }
@@ -0,0 +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
+ /** 已请求过几次自愈。成功会重载页面,这个计数自然归零 */
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()