weflow-cli 1.8.2 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +62 -26
- package/CHANGELOG.md +1290 -0
- package/OPERATIONS.md +144 -11
- package/README.en.md +20 -4
- package/README.md +30 -5
- package/bin/weflow-cli-electron.cjs +2 -0
- package/bin/weflow-cli.ts +996 -118
- package/dist/bin/weflow-cli.js +1038 -146
- package/dist/bin/weflow-cli.js.map +1 -1
- package/dist/mcp-server/http.d.ts +14 -0
- package/dist/mcp-server/http.d.ts.map +1 -0
- package/dist/mcp-server/http.js +84 -0
- package/dist/mcp-server/http.js.map +1 -0
- package/dist/mcp-server/httpConfig.d.ts +50 -0
- package/dist/mcp-server/httpConfig.d.ts.map +1 -0
- package/dist/mcp-server/httpConfig.js +104 -0
- package/dist/mcp-server/httpConfig.js.map +1 -0
- package/dist/mcp-server/index.d.ts +13 -1
- package/dist/mcp-server/index.d.ts.map +1 -1
- package/dist/mcp-server/index.js +58 -374
- package/dist/mcp-server/index.js.map +1 -1
- package/dist/src/core/dbPathService.d.ts +12 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -1
- package/dist/src/core/dbPathService.js +20 -3
- package/dist/src/core/dbPathService.js.map +1 -1
- package/dist/src/panel/server.d.ts.map +1 -1
- package/dist/src/panel/server.js +61 -1
- package/dist/src/panel/server.js.map +1 -1
- package/dist/src/services/assistantEval.d.ts +69 -0
- package/dist/src/services/assistantEval.d.ts.map +1 -1
- package/dist/src/services/assistantEval.js +270 -8
- package/dist/src/services/assistantEval.js.map +1 -1
- package/dist/src/services/assistantScenes.d.ts +81 -0
- package/dist/src/services/assistantScenes.d.ts.map +1 -0
- package/dist/src/services/assistantScenes.js +322 -0
- package/dist/src/services/assistantScenes.js.map +1 -0
- package/dist/src/services/assistantService.d.ts +33 -4
- package/dist/src/services/assistantService.d.ts.map +1 -1
- package/dist/src/services/assistantService.js +102 -6
- package/dist/src/services/assistantService.js.map +1 -1
- package/dist/src/services/assistantSkills.d.ts +94 -0
- package/dist/src/services/assistantSkills.d.ts.map +1 -0
- package/dist/src/services/assistantSkills.js +330 -0
- package/dist/src/services/assistantSkills.js.map +1 -0
- package/dist/src/services/assistantTools.d.ts +34 -1
- package/dist/src/services/assistantTools.d.ts.map +1 -1
- package/dist/src/services/assistantTools.js +931 -88
- package/dist/src/services/assistantTools.js.map +1 -1
- package/dist/src/services/assistantTrace.d.ts +4 -0
- package/dist/src/services/assistantTrace.d.ts.map +1 -1
- package/dist/src/services/assistantTrace.js +9 -0
- package/dist/src/services/assistantTrace.js.map +1 -1
- package/dist/src/services/configService.d.ts +9 -0
- package/dist/src/services/configService.d.ts.map +1 -1
- package/dist/src/services/configService.js +5 -2
- package/dist/src/services/configService.js.map +1 -1
- package/dist/src/services/messageQuery.d.ts +18 -0
- package/dist/src/services/messageQuery.d.ts.map +1 -1
- package/dist/src/services/messageQuery.js +29 -0
- package/dist/src/services/messageQuery.js.map +1 -1
- package/dist/src/services/outboundSend.d.ts +53 -0
- package/dist/src/services/outboundSend.d.ts.map +1 -0
- package/dist/src/services/outboundSend.js +90 -0
- package/dist/src/services/outboundSend.js.map +1 -0
- package/dist/src/services/pythonBridge.d.ts.map +1 -1
- package/dist/src/services/pythonBridge.js +1 -0
- package/dist/src/services/pythonBridge.js.map +1 -1
- package/dist/src/services/wechatClient.d.ts +125 -0
- package/dist/src/services/wechatClient.d.ts.map +1 -0
- package/dist/src/services/wechatClient.js +344 -0
- package/dist/src/services/wechatClient.js.map +1 -0
- package/dist/src/services/wechatMedia.d.ts +44 -0
- package/dist/src/services/wechatMedia.d.ts.map +1 -0
- package/dist/src/services/wechatMedia.js +155 -0
- package/dist/src/services/wechatMedia.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +11 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -1
- package/dist/src/services/wechatMessageService.js +55 -11
- package/dist/src/services/wechatMessageService.js.map +1 -1
- package/dist/src/types.d.ts +4 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/utils/python.js +2 -2
- package/dist/src/utils/python.js.map +1 -1
- package/docs/BRANCHES.md +37 -0
- package/docs/CONTACT_DB_SCHEMA.md +1085 -0
- package/docs/DECISIONS.md +1624 -0
- package/docs/EXTENDING.md +98 -10
- package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +6 -0
- package/docs/HEALTH-CHECK.md +12 -7
- package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
- package/docs/MCP.md +58 -4
- package/docs/PROJECT_STATE.md +180 -11
- package/docs/RELEASING.md +115 -1
- package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
- package/logo.png +0 -0
- package/mcp-server/http.ts +108 -0
- package/mcp-server/httpConfig.ts +126 -0
- package/mcp-server/index.ts +359 -681
- package/package.json +5 -3
- package/resources/js/graph3d/NOTICE.txt +35 -0
- package/resources/js/graph3d/d3-binarytree.min.js +2 -0
- package/resources/js/graph3d/d3-dispatch.min.js +2 -0
- package/resources/js/graph3d/d3-force-3d.min.js +2 -0
- package/resources/js/graph3d/d3-octree.min.js +2 -0
- package/resources/js/graph3d/d3-quadtree.min.js +2 -0
- package/resources/js/graph3d/d3-timer.min.js +2 -0
- package/resources/js/graph3d/layout.mjs +63 -0
- package/resources/js/graph3d/three.min.js +6 -0
- package/resources/js/graph3d/viewer2d.js +448 -0
- package/resources/panel/ball-position.cjs +48 -2
- package/resources/panel/index.html +18 -4
- package/resources/panel/main.cjs +137 -5
- package/resources/panel/mascot-base.png +0 -0
- package/resources/panel/mascot-blink-1.png +0 -0
- package/resources/panel/mascot-blink-2.png +0 -0
- package/resources/panel/mascot-iris.png +0 -0
- package/resources/panel/mascot-lift-down-1.png +0 -0
- package/resources/panel/mascot-lift-down-2.png +0 -0
- package/resources/panel/mascot-lift-down-3.png +0 -0
- package/resources/panel/mascot-lift-down-4.png +0 -0
- package/resources/panel/mascot-lift-down-5.png +0 -0
- package/resources/panel/mascot-lift-down-6.png +0 -0
- package/resources/panel/mascot-lift-up-1.png +0 -0
- package/resources/panel/mascot-lift-up-2.png +0 -0
- package/resources/panel/mascot-lift-up-3.png +0 -0
- package/resources/panel/mascot-lift-up-4.png +0 -0
- package/resources/panel/mascot-lift-up-5.png +0 -0
- package/resources/panel/mascot-lift-up-6.png +0 -0
- package/resources/panel/mascot-peek.png +0 -0
- package/resources/panel/mascot-tickle-1.png +0 -0
- package/resources/panel/mascot-tickle-2.png +0 -0
- package/resources/panel/mascot-tickle-3.png +0 -0
- package/resources/panel/mascot-tickle-4.png +0 -0
- package/resources/panel/panel.css +205 -59
- package/resources/panel/preload.cjs +26 -0
- package/resources/panel/renderer.js +646 -17
- package/scripts/_utils.py +1203 -931
- package/scripts/backfill_articles.py +202 -21
- package/scripts/biz_daily.py +26 -34
- package/scripts/bonds.py +331 -0
- package/scripts/chat_notes.py +561 -470
- package/scripts/chat_stats.py +8 -7
- package/scripts/check_readme_commands.py +426 -0
- package/scripts/compile_wiki.py +187 -59
- package/scripts/contact_schema.py +339 -0
- package/scripts/data/wechat_emoji.json +225 -0
- package/scripts/export_chat_html.py +3 -1
- package/scripts/fav_notes.py +4 -1
- package/scripts/graph_2d.py +151 -0
- package/scripts/graph_3d.py +646 -0
- package/scripts/health_check.py +163 -26
- package/scripts/mcp_bridge.py +6 -7
- package/scripts/nt_decrypt.py +3 -3
- package/scripts/panel_frames.py +683 -0
- package/scripts/pipeline.py +21 -10
- package/scripts/quality_eval.py +5 -0
- package/scripts/rag_chat.py +33 -16
- package/scripts/reply_debt.py +26 -6
- package/scripts/semantic_search.py +130 -22
- package/scripts/vault_rag.py +42 -16
- package/scripts/vault_search.py +44 -8
- package/scripts/wechat_emoticon.py +6 -0
- package/scripts/wechat_voice.py +9 -0
- package/scripts/wiki_lint.py +124 -24
- package/src/core/dbPathService.ts +22 -4
- package/src/panel/server.ts +58 -1
- package/src/services/assistantEval.ts +323 -12
- package/src/services/assistantScenes.ts +342 -0
- package/src/services/assistantService.ts +107 -6
- package/src/services/assistantSkills.ts +364 -0
- package/src/services/assistantTools.ts +2452 -1640
- package/src/services/assistantTrace.ts +9 -0
- package/src/services/configService.ts +415 -403
- package/src/services/messageQuery.ts +29 -0
- package/src/services/outboundSend.ts +126 -0
- package/src/services/pythonBridge.ts +1 -0
- package/src/services/wechatClient.ts +380 -0
- package/src/services/wechatMedia.ts +166 -0
- package/src/services/wechatMessageService.ts +62 -11
- package/src/types.ts +4 -0
- package/src/utils/python.ts +2 -2
- package/dist/src/utils/wechatEmoji.d.ts +0 -5
- package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
- package/dist/src/utils/wechatEmoji.js +0 -35
- package/dist/src/utils/wechatEmoji.js.map +0 -1
|
@@ -84,6 +84,40 @@ function addTurn(who, text) {
|
|
|
84
84
|
return div
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
/**
|
|
88
|
+
* 把这一轮的**轨迹**折叠着挂在回答下面(服务端 `/api/ask` 一并给回来)。
|
|
89
|
+
*
|
|
90
|
+
* **默认折叠**是有意的:轨迹多数时候是"我查了什么",摊开会把每次回答都变成一屏日志,
|
|
91
|
+
* 而不想看的人还得滚过它。想核对"它到底查没查、查的是什么"的人点一下展开。
|
|
92
|
+
* 内容与微信里发「轨迹」看到的是同一份(同一个格式函数),所以两处不会各说各话。
|
|
93
|
+
*
|
|
94
|
+
* 全用 `textContent`(不用 innerHTML):轨迹里有工具参数,是**用户数据**。
|
|
95
|
+
*/
|
|
96
|
+
function addThinking(turn, trace) {
|
|
97
|
+
const lines = trace && Array.isArray(trace.lines)
|
|
98
|
+
? trace.lines.filter((line) => typeof line === 'string' && line.trim()) : []
|
|
99
|
+
const reasoning = trace && typeof trace.reasoning === 'string' ? trace.reasoning.trim() : ''
|
|
100
|
+
if (!lines.length && !reasoning) return // 没有轨迹就什么都不挂(不是显示一个空壳)
|
|
101
|
+
const details = document.createElement('details')
|
|
102
|
+
details.className = 'thinking'
|
|
103
|
+
const summary = document.createElement('summary')
|
|
104
|
+
summary.textContent = '思考过程'
|
|
105
|
+
details.appendChild(summary)
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
const row = document.createElement('div')
|
|
108
|
+
row.className = 'thinking-line'
|
|
109
|
+
row.textContent = line
|
|
110
|
+
details.appendChild(row)
|
|
111
|
+
}
|
|
112
|
+
if (reasoning) {
|
|
113
|
+
const row = document.createElement('div')
|
|
114
|
+
row.className = 'thinking-line thinking-reasoning'
|
|
115
|
+
row.textContent = reasoning
|
|
116
|
+
details.appendChild(row)
|
|
117
|
+
}
|
|
118
|
+
turn.appendChild(details)
|
|
119
|
+
}
|
|
120
|
+
|
|
87
121
|
/**
|
|
88
122
|
* 还没聊过时的开头。**空白是这里最糟的状态**:第一次打开的人看到一整片黑,
|
|
89
123
|
* 既不知道它能干什么,也看不出它是活的(下面没有正在输入之类的动静)。
|
|
@@ -94,6 +128,10 @@ const EXAMPLES = [
|
|
|
94
128
|
'总结我和某某的聊天',
|
|
95
129
|
'收藏里有哪些 AI 文章?',
|
|
96
130
|
'我最近都在读什么?',
|
|
131
|
+
// 这条此前只藏在右键菜单里("谁在等我回话"是那些一键功能之一),很多人不会去点右键。
|
|
132
|
+
// 措辞与 `who_owes_reply` 的工具描述一致("看看谁在等你回话"),所以路由是稳的 ——
|
|
133
|
+
// 那个工具自己还带 confirm 闸门,问之前会先把代价说清楚。
|
|
134
|
+
'谁在等我回话?',
|
|
97
135
|
]
|
|
98
136
|
|
|
99
137
|
function renderEmptyState() {
|
|
@@ -127,10 +165,11 @@ function renderEmptyState() {
|
|
|
127
165
|
}
|
|
128
166
|
|
|
129
167
|
/**
|
|
130
|
-
*
|
|
168
|
+
* 球的**脸**跟着状态走(CSS 里 `body.busy/.offline/.quota #ball .face`)——
|
|
169
|
+
* 2026-09-29 之前还有一层会变色/呼吸的光晕,按用户要求整层去掉了(见 panel.css 那段注释)。
|
|
131
170
|
*
|
|
132
171
|
* 集中在一个函数里改:之前这些类名散在 ask()/refreshStatus() 各处的话,
|
|
133
|
-
* 迟早有一条分支忘了摘掉 `busy
|
|
172
|
+
* 迟早有一条分支忘了摘掉 `busy`,球就一直挂着那张脸——那种"看起来在忙其实没在忙"比不显示更糟。
|
|
134
173
|
* 传进来的每一项都**整体替换**,不做增量。
|
|
135
174
|
*/
|
|
136
175
|
function setBallState(state) {
|
|
@@ -192,8 +231,15 @@ async function refreshStatus() {
|
|
|
192
231
|
quickReplies = Array.isArray(s.quickReplies) ? s.quickReplies.filter(n => typeof n === 'string') : []
|
|
193
232
|
const mode = s.channelActive ? '微信 + 本机' : '仅本机入口'
|
|
194
233
|
statusEl.textContent = mode + '|今日 ' + s.quota.used + '/' + s.quota.limit
|
|
195
|
-
// 额度用尽是"今天不能再用"
|
|
196
|
-
|
|
234
|
+
// 额度用尽是"今天不能再用",值得在球上看得出来(琥珀),但**不是**错误。
|
|
235
|
+
//
|
|
236
|
+
// **而且必须显式收掉 `offline`。** 拉到状态这件事本身就说"连不上"结束了,而这条成功路径
|
|
237
|
+
// 原先从不主动清它 —— 于是守护进程重启的那几十秒里页面拉不到状态、挂上那张"委屈"的脸,
|
|
238
|
+
// 之后(守护进程早就回来了)再也回不来。实测症状:球一直是委屈,而状态条其实写着"微信 + 本机"。
|
|
239
|
+
// 不动 `busy`(那是"正在答",由 `ask` 的 finally 负责收);没超额就恢复成没有状态脸。
|
|
240
|
+
if (!document.body.classList.contains('busy')) {
|
|
241
|
+
setBallState(s.quota.used >= s.quota.limit ? 'quota' : null)
|
|
242
|
+
}
|
|
197
243
|
|
|
198
244
|
// 记忆桶那句话说清"是不是同一个大脑"——这是用户最容易误解的地方
|
|
199
245
|
foot.textContent = ''
|
|
@@ -243,7 +289,9 @@ async function ask(text, display = null) {
|
|
|
243
289
|
pending.remove()
|
|
244
290
|
|
|
245
291
|
if (res.ok && body.ok) {
|
|
246
|
-
addTurn('it', body.reply)
|
|
292
|
+
const turn = addTurn('it', body.reply)
|
|
293
|
+
hopAfterTurn = true // 答复成功:busy 收掉之后高兴一下
|
|
294
|
+
addThinking(turn, body.trace)
|
|
247
295
|
// 配额用尽时服务端回的是一句**回答**(不是错误),所以上面照常显示;
|
|
248
296
|
// 这里只是把状态条上的数字刷新一下
|
|
249
297
|
void refreshStatus()
|
|
@@ -257,6 +305,10 @@ async function ask(text, display = null) {
|
|
|
257
305
|
input.disabled = false
|
|
258
306
|
send.disabled = false
|
|
259
307
|
setBallState(null)
|
|
308
|
+
if (hopAfterTurn) {
|
|
309
|
+
hopAfterTurn = false
|
|
310
|
+
startHop()
|
|
311
|
+
}
|
|
260
312
|
void refreshStatus() // 顺手把用量与配额状态刷新(额度用尽会换成琥珀色)
|
|
261
313
|
input.focus()
|
|
262
314
|
}
|
|
@@ -307,9 +359,101 @@ function prefersReducedMotion() {
|
|
|
307
359
|
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
308
360
|
}
|
|
309
361
|
|
|
362
|
+
// ------------------------------------------------------- 眼珠跟着鼠标(2026-09-30)
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* 鼠标位置来自**主进程**(`onCursor`),不是页面的 `mousemove`:球只有 96×96,鼠标绝大多数
|
|
366
|
+
* 时间在窗口**外面**,渲染进程一个事件都收不到。
|
|
367
|
+
*
|
|
368
|
+
* 位移上限是**从美术上量出来的**,不是拍的(量法见提交信息里的脚本):
|
|
369
|
+
* - 虹膜到左边那道深色描边只剩约 2.6px(球上),再往左就裂开一条白缝;
|
|
370
|
+
* - **向上几乎没有余量**(上面就是眼皮),向下宽一些;
|
|
371
|
+
* - 所以夹的是一个**椭圆**,不是一个圆 —— 横向 2.6、向上 0.8、向下 2.6。
|
|
372
|
+
*
|
|
373
|
+
* 常量按**球的 96px** 写;球尺寸将来变了,下面按实测宽度等比缩放,不用回来改这些数。
|
|
374
|
+
*/
|
|
375
|
+
const IRIS_MAX_X = 2.6
|
|
376
|
+
const IRIS_MAX_UP = 0.8
|
|
377
|
+
const IRIS_MAX_DOWN = 2.6
|
|
378
|
+
const IRIS_EASE = 0.18 // 每帧逼近目标的比例(一阶低通)
|
|
379
|
+
const IRIS_FAR = 260 // 鼠标远过这个距离就当"看向最边上",不再加分
|
|
380
|
+
const IRIS_EPS = 0.02 // 到位判定(球上像素)
|
|
381
|
+
|
|
382
|
+
let cursorTarget = null // 鼠标的屏幕坐标;null = 还不知道它在哪
|
|
383
|
+
let irisX = 0
|
|
384
|
+
let irisY = 0
|
|
385
|
+
let irisRaf = null
|
|
386
|
+
|
|
387
|
+
/** 球心在屏幕上的位置。载荷是 DIP、`getBoundingClientRect` 也是 DIP,所以可以直接相减。 */
|
|
388
|
+
function ballCenterOnScreen() {
|
|
389
|
+
const rect = ball.getBoundingClientRect()
|
|
390
|
+
return {
|
|
391
|
+
x: window.screenX + rect.left + rect.width / 2,
|
|
392
|
+
y: window.screenY + rect.top + rect.height / 2,
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 往目标挪一帧。返回"还在动吗"——不动了外层就把 rAF 停掉。 */
|
|
397
|
+
function stepIris() {
|
|
398
|
+
const size = ball.offsetWidth || 96
|
|
399
|
+
const k = size / 96
|
|
400
|
+
let tx = 0
|
|
401
|
+
let ty = 0
|
|
402
|
+
if (cursorTarget && !prefersReducedMotion()) {
|
|
403
|
+
const center = ballCenterOnScreen()
|
|
404
|
+
const dx = cursorTarget.x - center.x
|
|
405
|
+
const dy = cursorTarget.y - center.y
|
|
406
|
+
const dist = Math.hypot(dx, dy) || 1
|
|
407
|
+
const strength = Math.min(1, dist / (IRIS_FAR * k)) // 远处饱和:屏幕另一头也是"看那边"
|
|
408
|
+
const ux = (dx / dist) * strength
|
|
409
|
+
const uy = (dy / dist) * strength
|
|
410
|
+
tx = ux * IRIS_MAX_X * k
|
|
411
|
+
ty = uy * (uy < 0 ? IRIS_MAX_UP : IRIS_MAX_DOWN) * k
|
|
412
|
+
}
|
|
413
|
+
irisX += (tx - irisX) * IRIS_EASE
|
|
414
|
+
irisY += (ty - irisY) * IRIS_EASE
|
|
415
|
+
const done = Math.abs(irisX - tx) < IRIS_EPS && Math.abs(irisY - ty) < IRIS_EPS
|
|
416
|
+
if (done) {
|
|
417
|
+
irisX = tx
|
|
418
|
+
irisY = ty
|
|
419
|
+
}
|
|
420
|
+
const iris = ball.querySelector('.iris')
|
|
421
|
+
if (iris) iris.style.transform = `translate(${irisX.toFixed(2)}px, ${irisY.toFixed(2)}px)`
|
|
422
|
+
return !done
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* **静止就停 rAF**。一直跑 60fps 写 transform 会让合成器一直忙——那是为装饰付的电,
|
|
427
|
+
* 不值得(这个窗口是常驻的)。所以:鼠标动了才起循环,到位就停,下一次推送再起。
|
|
428
|
+
*/
|
|
429
|
+
function pumpIris() {
|
|
430
|
+
if (irisRaf !== null) return
|
|
431
|
+
if (typeof requestAnimationFrame !== 'function') return
|
|
432
|
+
irisRaf = requestAnimationFrame(() => {
|
|
433
|
+
irisRaf = null
|
|
434
|
+
if (stepIris()) pumpIris()
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (hasShell && window.weflowPanel && typeof window.weflowPanel.onCursor === 'function') {
|
|
439
|
+
window.weflowPanel.onCursor((point) => {
|
|
440
|
+
cursorTarget = point
|
|
441
|
+
pumpIris()
|
|
442
|
+
})
|
|
443
|
+
}
|
|
444
|
+
|
|
310
445
|
/** 气泡方位相关的类,每次切形态先全摘掉再按主进程说的挂上 */
|
|
311
446
|
const LAYOUT_CLASSES = ['bubble-left', 'bubble-right', 'anchor-top']
|
|
312
447
|
|
|
448
|
+
/**
|
|
449
|
+
* 半隐相关的类:`ball-peek` 换成探头那张脸(并去掉球的圆角,好让那条直切边落在屏幕边上),
|
|
450
|
+
* `ball-peek-right` 再把图水平镜像一次给右边用。
|
|
451
|
+
*
|
|
452
|
+
* 贴哪条边只有主进程知道(它才管窗口位置),所以这一组**和形态一样是主进程说了算**,
|
|
453
|
+
* 页面只照着挂。同样先整体摘掉再按说的挂——散着写迟早有一条分支忘了摘。
|
|
454
|
+
*/
|
|
455
|
+
const PEEK_CLASSES = ['ball-peek', 'ball-peek-right']
|
|
456
|
+
|
|
313
457
|
/**
|
|
314
458
|
* 按主进程说的形态切 class。**这里是唯一改形态的地方**。
|
|
315
459
|
*
|
|
@@ -319,18 +463,186 @@ const LAYOUT_CLASSES = ['bubble-left', 'bubble-right', 'anchor-top']
|
|
|
319
463
|
*
|
|
320
464
|
* 页面自己算不出这些:它不知道自己在屏幕上的位置,也不知道工作区多大。
|
|
321
465
|
*/
|
|
466
|
+
|
|
467
|
+
// ---------------------------------------------- 被拎起来那一下(2026-10-02)
|
|
468
|
+
//
|
|
469
|
+
// 按下球时它像被拎住后颈提起来,松手落回。**六帧硬切,不做淡入** —— 理由同上面那张笑脸:
|
|
470
|
+
// 换帧本来就是一帧的事,淡入反而像"图片正在加载"。
|
|
471
|
+
//
|
|
472
|
+
// 为什么整套动作都待在渲染进程里:它不需要主进程知道任何事。加一条 IPC 就得动
|
|
473
|
+
// preload 的方法表(那份表被测试逐行钉死)与三处测试替身,代价远大于收益。
|
|
474
|
+
//
|
|
475
|
+
// 帧图**必须在页面加载时就全部预取**:从守护进程取一张脸实测约 15ms(≈60Hz 一帧),
|
|
476
|
+
// 按下那一刻才开始取,第一帧就是空的 —— 那正是当初"点一下会闪一下"的成因。
|
|
477
|
+
// 12 帧:6 帧拎起来 + 6 帧落回去(再加静止帧,共 13 个状态)。
|
|
478
|
+
// 每帧 60ms:半边 360ms,全程约 0.7 秒 —— 比 5 帧那版慢一点,但帧多了之后观感反而更连贯。
|
|
479
|
+
const LIFT_STEP_MS = 60
|
|
480
|
+
const LIFT_RISE = [1, 2, 3, 4, 5, 6].map((n) => 'ball-lift-up-' + n)
|
|
481
|
+
const LIFT_FALL = [1, 2, 3, 4, 5, 6].map((n) => 'ball-lift-down-' + n)
|
|
482
|
+
const LIFT_CLASSES = LIFT_RISE.concat(LIFT_FALL)
|
|
483
|
+
let liftTimer = null
|
|
484
|
+
let liftAtPeak = false
|
|
485
|
+
|
|
486
|
+
/** 换帧的**唯一入口**:五个类先清干净再加一个。散着写迟早有一条分支忘了摘,
|
|
487
|
+
* 球就一直停在半空那张脸上(同 setBallState / setBallFace 的理由)。 */
|
|
488
|
+
function showLift(name) {
|
|
489
|
+
for (const cls of LIFT_CLASSES) document.body.classList.remove(cls)
|
|
490
|
+
document.body.classList.toggle('ball-lift', !!name)
|
|
491
|
+
if (name) document.body.classList.add(name)
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function stopLift() {
|
|
495
|
+
if (liftTimer !== null) {
|
|
496
|
+
clearTimeout(liftTimer)
|
|
497
|
+
liftTimer = null
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** 按下:第一帧同步出(不留空档),其后逐帧走到悬空停住。 */
|
|
502
|
+
function startLift() {
|
|
503
|
+
stopLift()
|
|
504
|
+
liftAtPeak = false
|
|
505
|
+
showLift(LIFT_RISE[0])
|
|
506
|
+
let i = 1
|
|
507
|
+
const step = () => {
|
|
508
|
+
if (i >= LIFT_RISE.length) {
|
|
509
|
+
liftAtPeak = true
|
|
510
|
+
liftTimer = null
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
showLift(LIFT_RISE[i])
|
|
514
|
+
i += 1
|
|
515
|
+
liftTimer = setTimeout(step, LIFT_STEP_MS)
|
|
516
|
+
}
|
|
517
|
+
liftTimer = setTimeout(step, LIFT_STEP_MS)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** 松手:接着往下走完落地那几帧。**不留尾巴** —— 用户试过 900ms 的保留期,说像卡住了。
|
|
521
|
+
* 还没升到顶就松手(一次普通点击)直接回原样:那时它本来也没离开地面多远。 */
|
|
522
|
+
function endLift() {
|
|
523
|
+
settleDangle()
|
|
524
|
+
stopLift()
|
|
525
|
+
if (!liftAtPeak) {
|
|
526
|
+
showLift(null)
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
let i = 0
|
|
530
|
+
const step = () => {
|
|
531
|
+
if (i >= LIFT_FALL.length) {
|
|
532
|
+
showLift(null)
|
|
533
|
+
liftTimer = null
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
showLift(LIFT_FALL[i])
|
|
537
|
+
i += 1
|
|
538
|
+
liftTimer = setTimeout(step, LIFT_STEP_MS)
|
|
539
|
+
}
|
|
540
|
+
step()
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** 什么时候**不**播:系统要求减少动态效果时只留那张被捏的脸;球半隐在屏幕边时,
|
|
544
|
+
* 第一次点击的含义是"回来"(主进程那条 reveal 是异步的,此刻窗口还贴着边)。 */
|
|
545
|
+
function canPlayLift() {
|
|
546
|
+
return !prefersReducedMotion() && !document.body.classList.contains('ball-peek')
|
|
547
|
+
}
|
|
548
|
+
// ---------------------------------------------- 挠痒痒(2026-10-02,倾斜已去掉)
|
|
549
|
+
//
|
|
550
|
+
// 鼠标在球身上划过 = 挠它:播 4 张姿势帧(表情与爪子的小动作),停下 260ms 收。
|
|
551
|
+
//
|
|
552
|
+
// **它曾经还带一个 4° 的倾斜**(按划动速度给、方向与划动相反),2026-10-02 去掉了。用户的理由很直接:
|
|
553
|
+
// 「为什么鼠标划动也会晃,而不是在起飞状态才会晃」—— 倾斜与"被拎起来"那条走的是**同一个视觉通道**
|
|
554
|
+
// (都是 `#ball` 上的 rotate),于是随手扫过看起来像一次小号起飞。现在**旋转只归被拎那条**:划过只有帧、
|
|
555
|
+
// 不转动。这条也进了测试(`test/panel-lift-frames.test.ts` 里有反向断言:挠痒痒那块 CSS 不许再出现
|
|
556
|
+
// rotate),免得哪天又被加回来。
|
|
557
|
+
const TICKLE_FRAMES = [1, 2, 3, 4].map((n) => 'ball-tickle-' + n)
|
|
558
|
+
const TICKLE_STEP_MS = 110 // 换姿势的间隔(比动作帧慢:挠痒痒是持续的小扭,不是急抖)
|
|
559
|
+
const TICKLE_DECAY_MS = 260 // 停止移动多久后收
|
|
560
|
+
let tickleTimer = null
|
|
561
|
+
let tickleSettle = null
|
|
562
|
+
let tickleIndex = 0
|
|
563
|
+
|
|
564
|
+
/** 换姿势的**唯一入口**(同 showLift / setBallFace 的理由:散着写迟早有一条分支忘了摘)。 */
|
|
565
|
+
function showTickleFrame(name) {
|
|
566
|
+
for (const cls of TICKLE_FRAMES) document.body.classList.remove(cls)
|
|
567
|
+
document.body.classList.toggle('ball-tickle', !!name)
|
|
568
|
+
if (name) document.body.classList.add(name)
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** 收:清帧、清定时器。**按下**与**换形态**都会调它(见下面两处)。 */
|
|
572
|
+
function settleTickle() {
|
|
573
|
+
if (tickleTimer !== null) {
|
|
574
|
+
clearTimeout(tickleTimer)
|
|
575
|
+
tickleTimer = null
|
|
576
|
+
}
|
|
577
|
+
if (tickleSettle !== null) {
|
|
578
|
+
clearTimeout(tickleSettle)
|
|
579
|
+
tickleSettle = null
|
|
580
|
+
}
|
|
581
|
+
tickleIndex = 0
|
|
582
|
+
showTickleFrame(null)
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** 什么时候**不**挠:系统要求少动效;球半隐在屏幕边;正在被拎起来(那是更强的交互,先按下的那个赢)。 */
|
|
586
|
+
function canTickle() {
|
|
587
|
+
return !prefersReducedMotion()
|
|
588
|
+
&& !document.body.classList.contains('ball-peek')
|
|
589
|
+
&& !document.body.classList.contains('ball-lift')
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** 在球身上移动一次:只推进姿势帧,**不写任何角度** —— 旋转只归"被拎起来"那条(见本节开头)。 */
|
|
593
|
+
function onTickleMove() {
|
|
594
|
+
if (!canTickle()) return
|
|
595
|
+
if (!document.body.classList.contains('ball-tickle')) {
|
|
596
|
+
tickleIndex = 0
|
|
597
|
+
showTickleFrame(TICKLE_FRAMES[0])
|
|
598
|
+
}
|
|
599
|
+
if (tickleTimer === null) {
|
|
600
|
+
const step = () => {
|
|
601
|
+
tickleIndex = (tickleIndex + 1) % TICKLE_FRAMES.length
|
|
602
|
+
showTickleFrame(TICKLE_FRAMES[tickleIndex])
|
|
603
|
+
tickleTimer = setTimeout(step, TICKLE_STEP_MS)
|
|
604
|
+
}
|
|
605
|
+
tickleTimer = setTimeout(step, TICKLE_STEP_MS)
|
|
606
|
+
}
|
|
607
|
+
if (tickleSettle !== null) clearTimeout(tickleSettle)
|
|
608
|
+
tickleSettle = setTimeout(() => {
|
|
609
|
+
tickleSettle = null
|
|
610
|
+
settleTickle()
|
|
611
|
+
}, TICKLE_DECAY_MS)
|
|
612
|
+
}
|
|
613
|
+
|
|
322
614
|
function applyMode(payload) {
|
|
615
|
+
// 形态一变就把动作清干净:托盘与全局快捷键都能直接改形态,停在第 3 帧上等它回来
|
|
616
|
+
// 就是一张"永远悬在半空"的脸。这里只清类与定时器,形态本身仍由下面那段决定。
|
|
617
|
+
stopLift()
|
|
618
|
+
showLift(null)
|
|
619
|
+
settleTickle()
|
|
620
|
+
closeMemory()
|
|
621
|
+
settleDangle()
|
|
323
622
|
const mode = payload && payload.mode === 'ball' ? 'ball' : 'chat'
|
|
324
623
|
const side = payload && payload.side === 'right' ? 'right' : 'left'
|
|
325
|
-
|
|
326
|
-
|
|
624
|
+
// **只摆锚、不切形态**(`anchorOnly`):展开分两步走,见 `main.cjs` 里"先摆锚、等页面
|
|
625
|
+
// 画完、再改窗口尺寸"那段。切形态会连带把气泡显出来,而那会儿窗口还是 96px ——
|
|
626
|
+
// 气泡恰好盖住球。所以第一步只动 `LAYOUT_CLASSES`。
|
|
627
|
+
const anchorOnly = !!(payload && payload.anchorOnly)
|
|
628
|
+
document.body.classList.remove(
|
|
629
|
+
...(anchorOnly ? LAYOUT_CLASSES : ['mode-ball', 'mode-chat', 'closing', ...LAYOUT_CLASSES]))
|
|
630
|
+
// 半隐与"锚"无关,所以 `anchorOnly` 那一步(展开的两个阶段)不许把它摘掉
|
|
631
|
+
if (!anchorOnly) {
|
|
632
|
+
document.body.classList.remove(...PEEK_CLASSES)
|
|
633
|
+
if (mode === 'ball' && payload && payload.peek) {
|
|
634
|
+
document.body.classList.add('ball-peek')
|
|
635
|
+
if (payload.peek === 'right') document.body.classList.add('ball-peek-right')
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (!anchorOnly) document.body.classList.add(mode === 'chat' ? 'mode-chat' : 'mode-ball')
|
|
327
639
|
if (mode === 'chat') {
|
|
328
640
|
document.body.classList.add(side === 'right' ? 'bubble-right' : 'bubble-left')
|
|
329
641
|
if (payload && payload.anchorY === 'top') document.body.classList.add('anchor-top')
|
|
330
642
|
const height = Number(payload && payload.bubbleHeight)
|
|
331
643
|
document.body.style.setProperty('--bubble-height',
|
|
332
644
|
`${Number.isFinite(height) && height > 0 ? Math.round(height) : 560}px`)
|
|
333
|
-
input.focus()
|
|
645
|
+
if (!anchorOnly) input.focus()
|
|
334
646
|
}
|
|
335
647
|
}
|
|
336
648
|
|
|
@@ -351,9 +663,311 @@ function toggleMode() {
|
|
|
351
663
|
requestMode(document.body.classList.contains('mode-chat') ? 'ball' : 'chat')
|
|
352
664
|
}
|
|
353
665
|
|
|
666
|
+
// **这一段必须在顶层。** 函数声明在块里是**块作用域**,而 `applyMode`(它要在换形态时收起这一层)
|
|
667
|
+
// 是顶层函数 —— 放进 `if (hasShell) { … }` 里,`applyMode` 调 `closeMemory()` 就会抛
|
|
668
|
+
// `ReferenceError: closeMemory is not defined`。同一个坑在这份文件里已经栽过两次(上一次是
|
|
669
|
+
// "被拎起来"那块),所以这行说明留在这儿。
|
|
670
|
+
// ------------------------------------------------------- 看它在记什么(2026-10-03)
|
|
671
|
+
//
|
|
672
|
+
// 只读视图:它记了什么、什么时候记的、从哪句话来的。**这一份不经过模型** —— 面板那条路由
|
|
673
|
+
// 只把数据交出来,页面只负责渲染;模型看到的是另外一份(带预算与脱敏)。
|
|
674
|
+
//
|
|
675
|
+
// 渲染一律 textContent + createElement:记忆里存的是**用户聊天里的话**,用 innerHTML 就等于
|
|
676
|
+
// 在那个窗口里执行它(这是本仓库最硬的一条纪律,测试盯着)。
|
|
677
|
+
const memoryButton = document.getElementById('memory')
|
|
678
|
+
const memoryPanel = document.getElementById('memory-panel')
|
|
679
|
+
const memoryList = document.getElementById('memory-list')
|
|
680
|
+
const memoryNote = document.getElementById('memory-note')
|
|
681
|
+
|
|
682
|
+
function closeMemory() {
|
|
683
|
+
if (memoryPanel) memoryPanel.hidden = true
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function memoryLine(text, className) {
|
|
687
|
+
const p = document.createElement('p')
|
|
688
|
+
if (className) p.className = className
|
|
689
|
+
p.textContent = text
|
|
690
|
+
return p
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function openMemory() {
|
|
694
|
+
if (!memoryPanel || !memoryList || !memoryNote) return
|
|
695
|
+
memoryPanel.hidden = false
|
|
696
|
+
memoryList.replaceChildren()
|
|
697
|
+
memoryNote.textContent = '读取中…'
|
|
698
|
+
try {
|
|
699
|
+
const res = await fetch('/api/memory', { headers: { accept: 'application/json' } })
|
|
700
|
+
const data = await res.json()
|
|
701
|
+
if (!res.ok || !data || data.ok !== true) throw new Error('bad response')
|
|
702
|
+
const facts = Array.isArray(data.facts) ? data.facts : []
|
|
703
|
+
const nodes = []
|
|
704
|
+
if (!facts.length) {
|
|
705
|
+
nodes.push(memoryLine('还没有长期记忆 —— 它只在值得记的时候才写(每 6 轮看一次)。', 'muted'))
|
|
706
|
+
}
|
|
707
|
+
for (const fact of facts.slice().reverse()) {
|
|
708
|
+
const item = document.createElement('article')
|
|
709
|
+
item.className = 'fact'
|
|
710
|
+
item.append(memoryLine(String(fact.content ?? '')))
|
|
711
|
+
const when = Number(fact.ts) ? new Date(Number(fact.ts)).toLocaleString() : '时间未知'
|
|
712
|
+
const quote = String(fact.sourceQuote ?? '').trim()
|
|
713
|
+
item.append(memoryLine(quote ? `${when} · 来自「${quote.slice(0, 40)}」` : when, 'muted'))
|
|
714
|
+
nodes.push(item)
|
|
715
|
+
}
|
|
716
|
+
memoryList.replaceChildren(...nodes)
|
|
717
|
+
const notes = [`长期事实 ${facts.length} 条`]
|
|
718
|
+
if (Number(data.workingTurns) > 0) notes.push(`工作窗口 ${data.workingTurns} 条`)
|
|
719
|
+
if (data.summary) notes.push(`滚动摘要 ${String(data.summary).length} 字`)
|
|
720
|
+
if (data.saveError) notes.push(`⚠ 上次保存失败:${data.saveError}`)
|
|
721
|
+
memoryNote.textContent = notes.join(' · ')
|
|
722
|
+
} catch {
|
|
723
|
+
memoryList.replaceChildren(memoryLine('读不到记忆(守护进程重启过的话,窗口里的凭据就过期了)', 'muted'))
|
|
724
|
+
memoryNote.textContent = ''
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (memoryButton) memoryButton.addEventListener('click', () => { void openMemory() })
|
|
729
|
+
const memoryClose = document.getElementById('memory-close')
|
|
730
|
+
if (memoryClose) memoryClose.addEventListener('click', closeMemory)
|
|
731
|
+
// ------------------------------------------ 被拎着的时候"左右晃"(2026-10-02,改了两轮)
|
|
732
|
+
//
|
|
733
|
+
// 用户要的三件事,按他的原话:
|
|
734
|
+
// 1.「提起来的时候就开始左右晃动」—— 摆动在**按下**那刻启动,不必先拖;
|
|
735
|
+
// 2.「往左边移动往左晃动的幅度更大」—— 移动方向带来一个**同向的倾斜**(往左移就偏左);
|
|
736
|
+
// 3.「往左移动速度的快,就往左多晃一点」—— 幅度本身**跟速度走**。第三点是后补的:
|
|
737
|
+
// 前两轮只有"倾斜量"跟速度走,正弦基础摆幅一直是死值 ±12°,于是慢拖与快甩看起来
|
|
738
|
+
// "基本一样晃"—— 大头是常数,用户说的"目前固定的"就是这个。
|
|
739
|
+
//
|
|
740
|
+
// 所以这里只有一个**劲头**(`dangleAmp`:0 = 静止拎着,1 = 甩得最狠),它同时决定两者:
|
|
741
|
+
// 摆幅 = MIN + (MAX − MIN) × 劲头 倾斜 = BIAS × 劲头 × 方向
|
|
742
|
+
// 劲头缓动到目标(15%/帧),所以速度突变时幅度是"长上去"的,不是跳一下。静止拎着时劲头为 0:
|
|
743
|
+
// 摆幅留在 MIN、倾斜归零 —— 挂在手里的挂件本来就会自己慢慢晃,"提起来就晃"仍然成立。
|
|
744
|
+
//
|
|
745
|
+
// 幅度**没有几何预算**:绕球心转不改变任何像素到球心的距离,所以晃多大都不会被圆裁掉
|
|
746
|
+
// (这正是当初只转不平移的原因)。相对地,跳跃的上移**是有预算的**(内容 125.25 / 半径 128)。
|
|
747
|
+
const DANGLE_SWING_MIN_DEG = 6 // 静止拎着时的摆幅("一提起来就开始晃"的那个最小幅度)
|
|
748
|
+
const DANGLE_SWING_MAX_DEG = 16 // 甩得最狠时的摆幅
|
|
749
|
+
const DANGLE_BIAS_MAX_DEG = 6 // 甩得最狠时的同向倾斜(于是同侧峰值 ≈ 16+6 = 22°)
|
|
750
|
+
const DANGLE_PERIOD_MS = 900 // 一个来回
|
|
751
|
+
const DANGLE_FRAME_MS = 16
|
|
752
|
+
// 1800 是**品味值,不是测量值**:慢推(~400 px/s)约 9°,快甩(≥1800)约 22°,快慢之差落在球顶
|
|
753
|
+
// 上约 10px(96px 的球),一眼看得出。想更敏感就调小,想更迟钝就调大。
|
|
754
|
+
// **450 那版太快吃满,于是速度这一项等于不存在**(用户原话:"往左移动速度的快,就往左多晃一点,
|
|
755
|
+
// 目前固定的"):450 px/s 比任何一次真实拖动都慢(挪一次窗口轻松上千),amp 恒等于 1。
|
|
756
|
+
const DANGLE_FULL_SPEED = 1800 // 每秒拖多少像素算"甩得最狠"
|
|
757
|
+
// **必须比一个摆动周期(900ms)长**:劲头是移动时涨上去的(15%/帧,约 250ms 到顶),而正弦要 225ms
|
|
758
|
+
// 才到正峰、675ms 才到负峰。240ms 那版实测过 —— 往右拖正峰拿满了劲头,往左拖的负峰却在劲头衰减
|
|
759
|
+
// 之后才来,于是只有 −12.6°、和没拖过一样:用户要的"往左移动往左幅度大"只剩了一半。
|
|
760
|
+
// 它只在**停手之后**起作用(拖着走时每次移动都重置它),所以 1.2 秒 ≠ 手一停就回正。
|
|
761
|
+
const DANGLE_CALM_MS = 1200 // 停手 1.2 秒后劲头归零(摆动继续,只是回到最小幅度)
|
|
762
|
+
let dangleTimer = null
|
|
763
|
+
let dangleDecay = null
|
|
764
|
+
let danglePhase = 0
|
|
765
|
+
let dangleLast = 0
|
|
766
|
+
let dangleAmp = 0
|
|
767
|
+
let dangleAmpTarget = 0
|
|
768
|
+
let dangleDir = 1
|
|
769
|
+
let dangleLastX = 0
|
|
770
|
+
let dangleLastY = 0
|
|
771
|
+
let dangleLastT = 0
|
|
772
|
+
|
|
773
|
+
function dangleNow() {
|
|
774
|
+
return typeof performance === 'object' && performance.now ? performance.now() : Date.now()
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** 每一帧:正弦摆动 + 同向倾斜,两者都由**劲头**决定(劲头缓动到目标,所以速度突变是长上去的)。 */
|
|
778
|
+
function dangleTick() {
|
|
779
|
+
const now = dangleNow()
|
|
780
|
+
const dt = dangleLast ? Math.min(80, now - dangleLast) : DANGLE_FRAME_MS
|
|
781
|
+
dangleLast = now
|
|
782
|
+
danglePhase += (dt / DANGLE_PERIOD_MS) * 2 * Math.PI
|
|
783
|
+
dangleAmp += (dangleAmpTarget - dangleAmp) * 0.15
|
|
784
|
+
const swing = DANGLE_SWING_MIN_DEG + (DANGLE_SWING_MAX_DEG - DANGLE_SWING_MIN_DEG) * dangleAmp
|
|
785
|
+
const deg = Math.sin(danglePhase) * swing + DANGLE_BIAS_MAX_DEG * dangleAmp * dangleDir
|
|
786
|
+
document.body.style.setProperty('--dangle-deg', deg.toFixed(2) + 'deg')
|
|
787
|
+
dangleTimer = setTimeout(dangleTick, DANGLE_FRAME_MS)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** 松手/换形态:停掉摆动并回正。 */
|
|
791
|
+
function settleDangle() {
|
|
792
|
+
if (dangleTimer !== null) {
|
|
793
|
+
clearTimeout(dangleTimer)
|
|
794
|
+
dangleTimer = null
|
|
795
|
+
}
|
|
796
|
+
if (dangleDecay !== null) {
|
|
797
|
+
clearTimeout(dangleDecay)
|
|
798
|
+
dangleDecay = null
|
|
799
|
+
}
|
|
800
|
+
danglePhase = 0
|
|
801
|
+
dangleLast = 0
|
|
802
|
+
dangleAmp = 0
|
|
803
|
+
dangleAmpTarget = 0
|
|
804
|
+
dangleDir = 1
|
|
805
|
+
document.body.style.setProperty('--dangle-deg', '0deg')
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** **按下**(被拎起来那一下):摆动从这里就开始,不必等拖动。少动效时不摆。 */
|
|
809
|
+
function startDangle(x, y) {
|
|
810
|
+
settleDangle()
|
|
811
|
+
if (prefersReducedMotion()) return
|
|
812
|
+
dangleLastX = x
|
|
813
|
+
dangleLastY = y
|
|
814
|
+
dangleLastT = dangleNow()
|
|
815
|
+
dangleTick()
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** 拖动的每一次移动:更新**劲头**(跟速度)与**方向**(跟往哪边移)。 */
|
|
819
|
+
function trackDangle(event) {
|
|
820
|
+
if (prefersReducedMotion() || dangleTimer === null) return
|
|
821
|
+
const now = dangleNow()
|
|
822
|
+
const dx = event.screenX - dangleLastX
|
|
823
|
+
const dy = event.screenY - dangleLastY
|
|
824
|
+
const dt = Math.max(1, now - dangleLastT)
|
|
825
|
+
dangleLastX = event.screenX
|
|
826
|
+
dangleLastY = event.screenY
|
|
827
|
+
dangleLastT = now
|
|
828
|
+
const speed = Math.hypot(dx, dy) / dt * 1000
|
|
829
|
+
dangleAmpTarget = Math.min(1, speed / DANGLE_FULL_SPEED) // 动得越快,摆得越大
|
|
830
|
+
if (Math.abs(dx) >= 1) dangleDir = dx > 0 ? 1 : -1 // 往哪边移,就往哪边偏(同向)
|
|
831
|
+
if (dangleDecay !== null) clearTimeout(dangleDecay)
|
|
832
|
+
dangleDecay = setTimeout(() => {
|
|
833
|
+
dangleDecay = null
|
|
834
|
+
dangleAmpTarget = 0 // 停手后劲头归零:摆幅回到最小、倾斜消失,但摆动继续
|
|
835
|
+
}, DANGLE_CALM_MS)
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// ------------------------------------------ 答完了"高兴一下"(2026-10-04)
|
|
839
|
+
//
|
|
840
|
+
// 球收起来时看不出"它答完了":busy 那张脸管的是"正在答",这半边一直空着。
|
|
841
|
+
//
|
|
842
|
+
// **没有生图帧,是刻意的**:模型画"蹲下去"一定会把身体压扁(实测轮廓 +9.2% / +8.0%,换个轻一点的
|
|
843
|
+
// 措辞也一样),连着放就是 270 毫秒里胖瘦四回。而跳本来就只是位移 —— 跳起来的是那只猫,它没变形。
|
|
844
|
+
// 所以:位移 + 轻微缩放 + 复用现有的眯眼笑脸(`mascot-happy.png`)。
|
|
845
|
+
//
|
|
846
|
+
// 位移的余量是算出来的:静止帧内容到球心 125.25、圆半径 128,所以空中那步缩到 0.96(内容 → 120.2)
|
|
847
|
+
// 之后才抬得起那 7 像素(120.2 + 7 = 127.2 < 128)。改这几个数之前先把这条算式重算一遍。
|
|
848
|
+
const HOP_STEPS = [
|
|
849
|
+
{ y: -7, scale: 0.96, ms: 95 },
|
|
850
|
+
{ y: -2, scale: 0.985, ms: 95 },
|
|
851
|
+
{ y: 0, scale: 1, ms: 110 },
|
|
852
|
+
]
|
|
853
|
+
let hopTimer = null
|
|
854
|
+
let hopAfterTurn = false
|
|
855
|
+
|
|
856
|
+
function showHop(step) {
|
|
857
|
+
document.body.classList.toggle('ball-hop', !!step)
|
|
858
|
+
document.body.classList.toggle('ball-hop-happy', !!step)
|
|
859
|
+
if (step) {
|
|
860
|
+
document.body.style.setProperty('--hop-y', step.y + 'px')
|
|
861
|
+
document.body.style.setProperty('--hop-scale', String(step.scale))
|
|
862
|
+
} else {
|
|
863
|
+
document.body.style.setProperty('--hop-y', '0px')
|
|
864
|
+
document.body.style.setProperty('--hop-scale', '1')
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function stopHop() {
|
|
869
|
+
if (hopTimer !== null) {
|
|
870
|
+
clearTimeout(hopTimer)
|
|
871
|
+
hopTimer = null
|
|
872
|
+
}
|
|
873
|
+
showHop(null)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** 答复成功、且不忙了之后才播(失败时球不该跳)。 */
|
|
877
|
+
function startHop() {
|
|
878
|
+
if (prefersReducedMotion()) return
|
|
879
|
+
stopHop()
|
|
880
|
+
let i = 0
|
|
881
|
+
const step = () => {
|
|
882
|
+
if (i >= HOP_STEPS.length) {
|
|
883
|
+
showHop(null)
|
|
884
|
+
hopTimer = null
|
|
885
|
+
return
|
|
886
|
+
}
|
|
887
|
+
showHop(HOP_STEPS[i])
|
|
888
|
+
const ms = HOP_STEPS[i].ms
|
|
889
|
+
i += 1
|
|
890
|
+
hopTimer = setTimeout(step, ms)
|
|
891
|
+
}
|
|
892
|
+
step()
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ------------------------------------------ 偶尔眨一下眼(2026-10-04)
|
|
896
|
+
//
|
|
897
|
+
// **只在"没有别的脸在场"时播**:busy/offline/quota 那三张脸的眼睛是画死的,半隐那张是另一幅构图,
|
|
898
|
+
// 正在被拎着/挠着/跳着时也不该眨。间隔随机(7-13 秒),否则像节拍器。
|
|
899
|
+
//
|
|
900
|
+
// 这 2 帧**眼睛以外与静止帧逐像素相同**(归一化时由 `panel_frames.py` 只把眼睛带贴回来),所以
|
|
901
|
+
// 必须藏虹膜层 —— 否则会看到一层睁着的瞳孔浮在闭着的眼皮上。
|
|
902
|
+
const BLINK_FRAMES = [1, 2].map((n) => 'ball-blink-' + n)
|
|
903
|
+
const BLINK_MS = [70, 110]
|
|
904
|
+
const BLINK_MIN_MS = 7000
|
|
905
|
+
const BLINK_MAX_MS = 13000
|
|
906
|
+
let blinkTimer = null
|
|
907
|
+
let blinkStepTimer = null
|
|
908
|
+
|
|
909
|
+
function showBlinkFrame(name) {
|
|
910
|
+
for (const cls of BLINK_FRAMES) document.body.classList.remove(cls)
|
|
911
|
+
document.body.classList.toggle('ball-blink', !!name)
|
|
912
|
+
if (name) document.body.classList.add(name)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** 什么时候**不**眨眼:少动效、页面不可见、有别的脸/别的动作在场。 */
|
|
916
|
+
function canBlink() {
|
|
917
|
+
if (prefersReducedMotion()) return false
|
|
918
|
+
if (typeof document.hidden === 'boolean' && document.hidden) return false
|
|
919
|
+
for (const cls of ['busy', 'offline', 'quota', 'ball-happy', 'ball-peek',
|
|
920
|
+
'ball-lift', 'ball-tickle', 'ball-hop']) {
|
|
921
|
+
if (document.body.classList.contains(cls)) return false
|
|
922
|
+
}
|
|
923
|
+
return true
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function blinkOnce() {
|
|
927
|
+
if (!canBlink()) return
|
|
928
|
+
showBlinkFrame(BLINK_FRAMES[0])
|
|
929
|
+
blinkStepTimer = setTimeout(() => {
|
|
930
|
+
showBlinkFrame(BLINK_FRAMES[1])
|
|
931
|
+
blinkStepTimer = setTimeout(() => {
|
|
932
|
+
showBlinkFrame(null)
|
|
933
|
+
blinkStepTimer = null
|
|
934
|
+
}, BLINK_MS[1])
|
|
935
|
+
}, BLINK_MS[0])
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function scheduleBlink() {
|
|
939
|
+
if (blinkTimer !== null) clearTimeout(blinkTimer)
|
|
940
|
+
const wait = BLINK_MIN_MS + Math.floor(Math.random() * (BLINK_MAX_MS - BLINK_MIN_MS))
|
|
941
|
+
blinkTimer = setTimeout(() => {
|
|
942
|
+
blinkOnce()
|
|
943
|
+
scheduleBlink()
|
|
944
|
+
}, wait)
|
|
945
|
+
}
|
|
946
|
+
|
|
354
947
|
if (hasShell) {
|
|
355
948
|
// `shell` 这个类决定球在不在场(见 panel.css):浏览器降级那条路永远不该看见球
|
|
949
|
+
|
|
356
950
|
document.body.classList.add('shell', 'mode-ball')
|
|
951
|
+
// **四张脸先取回来。**(2026-09-29 加的,用户报"点一下会闪一下")
|
|
952
|
+
//
|
|
953
|
+
// 这些脸是 CSS 背景图,**什么时候用什么时候才去取**;而实测从守护进程取一张要
|
|
954
|
+
// **~15 毫秒**(60Hz 一帧是 16.7ms,正好一帧)。所以按下球的那一刻才开始取,第一帧
|
|
955
|
+
// 就是空的——看起来就是"闪一下"。仓库里那句老注释写过同一个症状
|
|
956
|
+
// ("球在按下的一瞬间会闪成一张空图"),当时的结论是静态白名单一张都不能漏;
|
|
957
|
+
// 现在白名单是对的,但**第一次用到仍然要等那一趟网络**。预取把它挪到页面刚加载时,
|
|
958
|
+
// 那时没人盯着球看。
|
|
959
|
+
//
|
|
960
|
+
// 放在 `hasShell` 里面:浏览器降级那条路根本不显示球,没必要替它取。
|
|
961
|
+
// **动作帧也要在这里预取。** 按下才开始取的话,第一帧就是空的(见下面那段的说明)。
|
|
962
|
+
for (const face of ['mascot.png', 'mascot-happy.png', 'mascot-thinking.png',
|
|
963
|
+
'mascot-sorry.png', 'mascot-tired.png',
|
|
964
|
+
'mascot-lift-up-1.png', 'mascot-lift-up-2.png', 'mascot-lift-up-3.png', 'mascot-lift-up-4.png', 'mascot-lift-up-5.png', 'mascot-lift-up-6.png',
|
|
965
|
+
'mascot-lift-down-1.png', 'mascot-lift-down-2.png', 'mascot-lift-down-3.png', 'mascot-lift-down-4.png', 'mascot-lift-down-5.png', 'mascot-lift-down-6.png',
|
|
966
|
+
'mascot-tickle-1.png', 'mascot-tickle-2.png', 'mascot-tickle-3.png', 'mascot-tickle-4.png',
|
|
967
|
+
'mascot-blink-1.png', 'mascot-blink-2.png']) {
|
|
968
|
+
const img = new Image()
|
|
969
|
+
img.src = '/panel/' + face
|
|
970
|
+
}
|
|
357
971
|
ball.hidden = false
|
|
358
972
|
collapse.hidden = false
|
|
359
973
|
|
|
@@ -376,18 +990,27 @@ if (hasShell) {
|
|
|
376
990
|
}
|
|
377
991
|
|
|
378
992
|
const DRAG_THRESHOLD_PX = 4
|
|
993
|
+
// 挠痒痒:只在球身上移动时才扭(停下由 onTickleMove 里的回正定时器负责)
|
|
994
|
+
ball.addEventListener('pointermove', onTickleMove)
|
|
995
|
+
|
|
379
996
|
ball.addEventListener('pointerdown', (event) => {
|
|
380
997
|
if (event.button !== 0) return
|
|
381
998
|
setBallFace(true)
|
|
999
|
+
if (canPlayLift()) startLift()
|
|
1000
|
+
settleTickle() // 按下了就先别扭了,动作帧优先
|
|
382
1001
|
const startX = event.screenX
|
|
383
1002
|
const startY = event.screenY
|
|
384
1003
|
let dragging = false
|
|
1004
|
+
startDangle(startX, startY)
|
|
385
1005
|
|
|
386
1006
|
const onMove = (moveEvent) => {
|
|
387
1007
|
if (!dragging && Math.hypot(moveEvent.screenX - startX, moveEvent.screenY - startY) > DRAG_THRESHOLD_PX) {
|
|
388
1008
|
dragging = true
|
|
389
1009
|
}
|
|
390
|
-
if (dragging)
|
|
1010
|
+
if (dragging) {
|
|
1011
|
+
void window.weflowPanel.dragMove(moveEvent.screenX, moveEvent.screenY)
|
|
1012
|
+
trackDangle(moveEvent)
|
|
1013
|
+
}
|
|
391
1014
|
}
|
|
392
1015
|
const onUp = () => {
|
|
393
1016
|
window.removeEventListener('pointermove', onMove)
|
|
@@ -395,11 +1018,15 @@ if (hasShell) {
|
|
|
395
1018
|
window.removeEventListener('pointercancel', onCancel)
|
|
396
1019
|
void window.weflowPanel.dragEnd()
|
|
397
1020
|
setBallFace(false)
|
|
1021
|
+
endLift()
|
|
398
1022
|
if (dragging) return // 拖过了就不算点击
|
|
399
1023
|
toggleMode()
|
|
400
1024
|
}
|
|
401
1025
|
// 指针被系统抢走(触摸、原生菜单弹出)时不走 onUp,必须自己把脸收回来,否则球一直笑着
|
|
402
|
-
const onCancel = () =>
|
|
1026
|
+
const onCancel = () => {
|
|
1027
|
+
setBallFace(false)
|
|
1028
|
+
endLift()
|
|
1029
|
+
}
|
|
403
1030
|
void window.weflowPanel.dragStart(startX, startY)
|
|
404
1031
|
window.addEventListener('pointermove', onMove)
|
|
405
1032
|
window.addEventListener('pointerup', onUp)
|
|
@@ -408,7 +1035,7 @@ if (hasShell) {
|
|
|
408
1035
|
// 右键:快速回复。**只在有外壳时接管**——浏览器降级那条路的原生菜单里有"复制",
|
|
409
1036
|
// 那是用户要用的,不该被我们抢掉。
|
|
410
1037
|
//
|
|
411
|
-
// 有外壳时用**主进程弹的原生菜单**:它画在窗口外面,所以球形态那
|
|
1038
|
+
// 有外壳时用**主进程弹的原生菜单**:它画在窗口外面,所以球形态那 96x96 的窗口
|
|
412
1039
|
// **不用先展开**(页内菜单会被窗口裁掉)。上一版就是"先展开再弹",用户看到的是
|
|
413
1040
|
// "点右键把第二大脑窗口弹出来了"——那不是快速功能该有的样子。
|
|
414
1041
|
document.addEventListener('contextmenu', (event) => {
|
|
@@ -416,7 +1043,7 @@ if (hasShell) {
|
|
|
416
1043
|
void (async () => {
|
|
417
1044
|
const picked = await window.weflowPanel.openQuickMenu(quickReplies)
|
|
418
1045
|
if (!picked || typeof picked !== 'object') return
|
|
419
|
-
// 选中任何一项都要展开:球那个
|
|
1046
|
+
// 选中任何一项都要展开:球那个 96x96 里看不到任何回答,而这几项全都要出文字。
|
|
420
1047
|
// 但**右键本身不动窗口**(那是用户嫌的那一下)——展开只发生在真的选了东西之后。
|
|
421
1048
|
if (document.body.classList.contains('mode-ball')) requestMode('chat')
|
|
422
1049
|
if (picked.kind === 'contact' && typeof picked.name === 'string' && picked.name.trim()) {
|
|
@@ -431,17 +1058,18 @@ if (hasShell) {
|
|
|
431
1058
|
}
|
|
432
1059
|
})()
|
|
433
1060
|
})
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
1061
|
+
// 这里原来挂着两条 `closeQuickMenu()`(点了别处关菜单、Esc 关菜单)——**那是页内菜单时代的
|
|
1062
|
+
// 旧址**:菜单现在是**原生**的(`openQuickMenu` 由主进程弹,画在窗口外面),没有"页内菜单"
|
|
1063
|
+
// 可关,而 `closeQuickMenu` 这个函数**在文件里根本没有定义**。
|
|
1064
|
+
// 后果是:点面板里任何地方、或按 Esc,都会抛一次未捕获的 ReferenceError(不致命,
|
|
1065
|
+
// 但测试里 jsdom 会把它报出来,控制台也一直脏)。按"没有页内菜单"这个事实删掉。
|
|
438
1066
|
|
|
439
1067
|
collapse.addEventListener('click', () => { requestMode('ball') })
|
|
440
1068
|
|
|
441
1069
|
// 形态由主进程说了算(球、收起、托盘菜单、快捷键都是这条路),收起分两步:
|
|
442
1070
|
// `fadeMs > 0` → 先把气泡淡掉(窗口这会儿还是大的,淡出真看得见),**不换形态**;
|
|
443
1071
|
// `done` → 主进程已经把窗口缩回球那么大,这时才把气泡摘掉。
|
|
444
|
-
// 顺序不能反:提前换,
|
|
1072
|
+
// 顺序不能反:提前换,96x96 的窗口里会露出一条气泡的边;不换,气泡会一直在。
|
|
445
1073
|
window.weflowPanel.onMode((payload) => {
|
|
446
1074
|
if (payload && payload.mode === 'ball' && !payload.done) {
|
|
447
1075
|
if (payload.fadeMs > 0 && document.body.classList.contains('mode-chat')) {
|
|
@@ -463,6 +1091,7 @@ if (hasShell) {
|
|
|
463
1091
|
}
|
|
464
1092
|
|
|
465
1093
|
renderEmptyState()
|
|
1094
|
+
if (hasShell) scheduleBlink()
|
|
466
1095
|
void refreshStatus()
|
|
467
1096
|
// 每 30 秒刷一次状态:守护进程可能被停掉,界面不该一直显示旧数字
|
|
468
1097
|
setInterval(refreshStatus, 30000)
|