weflow-cli 1.9.0 → 1.11.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.
Files changed (134) hide show
  1. package/ARCHITECTURE.md +62 -26
  2. package/CHANGELOG.md +1051 -0
  3. package/OPERATIONS.md +99 -13
  4. package/README.en.md +14 -5
  5. package/README.md +23 -8
  6. package/bin/weflow-cli.ts +364 -40
  7. package/dist/bin/weflow-cli.js +381 -69
  8. package/dist/bin/weflow-cli.js.map +1 -1
  9. package/dist/mcp-server/index.d.ts.map +1 -1
  10. package/dist/mcp-server/index.js +19 -1
  11. package/dist/mcp-server/index.js.map +1 -1
  12. package/dist/src/core/dbPathService.d.ts +12 -0
  13. package/dist/src/core/dbPathService.d.ts.map +1 -1
  14. package/dist/src/core/dbPathService.js +20 -3
  15. package/dist/src/core/dbPathService.js.map +1 -1
  16. package/dist/src/panel/server.d.ts.map +1 -1
  17. package/dist/src/panel/server.js +35 -0
  18. package/dist/src/panel/server.js.map +1 -1
  19. package/dist/src/services/assistantService.d.ts +19 -0
  20. package/dist/src/services/assistantService.d.ts.map +1 -1
  21. package/dist/src/services/assistantService.js +20 -0
  22. package/dist/src/services/assistantService.js.map +1 -1
  23. package/dist/src/services/assistantTools.d.ts +27 -1
  24. package/dist/src/services/assistantTools.d.ts.map +1 -1
  25. package/dist/src/services/assistantTools.js +169 -35
  26. package/dist/src/services/assistantTools.js.map +1 -1
  27. package/dist/src/services/configService.d.ts +2 -0
  28. package/dist/src/services/configService.d.ts.map +1 -1
  29. package/dist/src/services/configService.js +3 -2
  30. package/dist/src/services/configService.js.map +1 -1
  31. package/dist/src/services/exportService.d.ts +43 -0
  32. package/dist/src/services/exportService.d.ts.map +1 -1
  33. package/dist/src/services/exportService.js +116 -11
  34. package/dist/src/services/exportService.js.map +1 -1
  35. package/dist/src/services/messageQuery.d.ts +18 -0
  36. package/dist/src/services/messageQuery.d.ts.map +1 -1
  37. package/dist/src/services/messageQuery.js +29 -0
  38. package/dist/src/services/messageQuery.js.map +1 -1
  39. package/dist/src/services/wechatClient.d.ts +125 -0
  40. package/dist/src/services/wechatClient.d.ts.map +1 -0
  41. package/dist/src/services/wechatClient.js +344 -0
  42. package/dist/src/services/wechatClient.js.map +1 -0
  43. package/dist/src/services/wechatMedia.d.ts +44 -0
  44. package/dist/src/services/wechatMedia.d.ts.map +1 -0
  45. package/dist/src/services/wechatMedia.js +155 -0
  46. package/dist/src/services/wechatMedia.js.map +1 -0
  47. package/dist/src/services/wechatMessageService.d.ts +11 -0
  48. package/dist/src/services/wechatMessageService.d.ts.map +1 -1
  49. package/dist/src/services/wechatMessageService.js +55 -11
  50. package/dist/src/services/wechatMessageService.js.map +1 -1
  51. package/dist/src/types.d.ts +4 -0
  52. package/dist/src/types.d.ts.map +1 -1
  53. package/docs/CONTACT_DB_SCHEMA.md +1085 -0
  54. package/docs/DECISIONS.md +1274 -0
  55. package/docs/EXTENDING.md +69 -7
  56. package/docs/FUNCTIONAL-IMPROVEMENT-SPEC.md +27 -0
  57. package/docs/HEALTH-CHECK.md +12 -7
  58. package/docs/JEV/345/272/224/347/224/250/346/226/275/345/267/245/346/226/207/346/241/243.md +2 -2
  59. package/docs/MCP.md +2 -2
  60. package/docs/PROJECT_STATE.md +64 -5
  61. package/docs/RELEASING.md +128 -1
  62. package/docs/images/weflow-architecture-gpt-image-2.png +0 -0
  63. package/logo.png +0 -0
  64. package/mcp-server/index.ts +18 -3
  65. package/package.json +5 -3
  66. package/resources/js/graph3d/NOTICE.txt +35 -0
  67. package/resources/js/graph3d/d3-binarytree.min.js +2 -0
  68. package/resources/js/graph3d/d3-dispatch.min.js +2 -0
  69. package/resources/js/graph3d/d3-force-3d.min.js +2 -0
  70. package/resources/js/graph3d/d3-octree.min.js +2 -0
  71. package/resources/js/graph3d/d3-quadtree.min.js +2 -0
  72. package/resources/js/graph3d/d3-timer.min.js +2 -0
  73. package/resources/js/graph3d/layout.mjs +63 -0
  74. package/resources/js/graph3d/three.min.js +6 -0
  75. package/resources/js/graph3d/viewer2d.js +448 -0
  76. package/resources/panel/index.html +12 -0
  77. package/resources/panel/mascot-blink-1.png +0 -0
  78. package/resources/panel/mascot-blink-2.png +0 -0
  79. package/resources/panel/mascot-lift-down-1.png +0 -0
  80. package/resources/panel/mascot-lift-down-2.png +0 -0
  81. package/resources/panel/mascot-lift-down-3.png +0 -0
  82. package/resources/panel/mascot-lift-down-4.png +0 -0
  83. package/resources/panel/mascot-lift-down-5.png +0 -0
  84. package/resources/panel/mascot-lift-down-6.png +0 -0
  85. package/resources/panel/mascot-lift-up-1.png +0 -0
  86. package/resources/panel/mascot-lift-up-2.png +0 -0
  87. package/resources/panel/mascot-lift-up-3.png +0 -0
  88. package/resources/panel/mascot-lift-up-4.png +0 -0
  89. package/resources/panel/mascot-lift-up-5.png +0 -0
  90. package/resources/panel/mascot-lift-up-6.png +0 -0
  91. package/resources/panel/mascot-tickle-1.png +0 -0
  92. package/resources/panel/mascot-tickle-2.png +0 -0
  93. package/resources/panel/mascot-tickle-3.png +0 -0
  94. package/resources/panel/mascot-tickle-4.png +0 -0
  95. package/resources/panel/panel.css +111 -0
  96. package/resources/panel/renderer.js +480 -8
  97. package/scripts/_utils.py +1203 -977
  98. package/scripts/biz_daily.py +25 -10
  99. package/scripts/chat_notes.py +15 -5
  100. package/scripts/check_readme_commands.py +426 -0
  101. package/scripts/compile_wiki.py +163 -57
  102. package/scripts/contact_schema.py +339 -0
  103. package/scripts/data/wechat_emoji.json +225 -0
  104. package/scripts/export_chat_html.py +346 -6
  105. package/scripts/fav_notes.py +4 -1
  106. package/scripts/graph_2d.py +151 -0
  107. package/scripts/graph_3d.py +646 -0
  108. package/scripts/health_check.py +163 -26
  109. package/scripts/nt_decrypt.py +3 -3
  110. package/scripts/panel_frames.py +683 -0
  111. package/scripts/pipeline.py +21 -10
  112. package/scripts/quality_eval.py +5 -0
  113. package/scripts/rag_chat.py +33 -16
  114. package/scripts/reply_debt.py +26 -6
  115. package/scripts/semantic_search.py +212 -58
  116. package/scripts/vault_rag.py +42 -16
  117. package/scripts/vault_search.py +44 -8
  118. package/scripts/wechat_emoticon.py +6 -0
  119. package/scripts/wiki_lint.py +94 -17
  120. package/src/core/dbPathService.ts +22 -4
  121. package/src/panel/server.ts +33 -0
  122. package/src/services/assistantService.ts +26 -0
  123. package/src/services/assistantTools.ts +2452 -2319
  124. package/src/services/configService.ts +5 -2
  125. package/src/services/exportService.ts +129 -12
  126. package/src/services/messageQuery.ts +29 -0
  127. package/src/services/wechatClient.ts +380 -0
  128. package/src/services/wechatMedia.ts +166 -0
  129. package/src/services/wechatMessageService.ts +62 -11
  130. package/src/types.ts +4 -0
  131. package/dist/src/utils/wechatEmoji.d.ts +0 -5
  132. package/dist/src/utils/wechatEmoji.d.ts.map +0 -1
  133. package/dist/src/utils/wechatEmoji.js +0 -35
  134. package/dist/src/utils/wechatEmoji.js.map +0 -1
@@ -128,6 +128,10 @@ const EXAMPLES = [
128
128
  '总结我和某某的聊天',
129
129
  '收藏里有哪些 AI 文章?',
130
130
  '我最近都在读什么?',
131
+ // 这条此前只藏在右键菜单里("谁在等我回话"是那些一键功能之一),很多人不会去点右键。
132
+ // 措辞与 `who_owes_reply` 的工具描述一致("看看谁在等你回话"),所以路由是稳的 ——
133
+ // 那个工具自己还带 confirm 闸门,问之前会先把代价说清楚。
134
+ '谁在等我回话?',
131
135
  ]
132
136
 
133
137
  function renderEmptyState() {
@@ -227,8 +231,15 @@ async function refreshStatus() {
227
231
  quickReplies = Array.isArray(s.quickReplies) ? s.quickReplies.filter(n => typeof n === 'string') : []
228
232
  const mode = s.channelActive ? '微信 + 本机' : '仅本机入口'
229
233
  statusEl.textContent = mode + '|今日 ' + s.quota.used + '/' + s.quota.limit
230
- // 额度用尽是"今天不能再用",值得在球上看得出来(琥珀),但**不是**错误
231
- if (s.quota.used >= s.quota.limit) setBallState('quota')
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
+ }
232
243
 
233
244
  // 记忆桶那句话说清"是不是同一个大脑"——这是用户最容易误解的地方
234
245
  foot.textContent = ''
@@ -279,6 +290,7 @@ async function ask(text, display = null) {
279
290
 
280
291
  if (res.ok && body.ok) {
281
292
  const turn = addTurn('it', body.reply)
293
+ hopAfterTurn = true // 答复成功:busy 收掉之后高兴一下
282
294
  addThinking(turn, body.trace)
283
295
  // 配额用尽时服务端回的是一句**回答**(不是错误),所以上面照常显示;
284
296
  // 这里只是把状态条上的数字刷新一下
@@ -293,6 +305,10 @@ async function ask(text, display = null) {
293
305
  input.disabled = false
294
306
  send.disabled = false
295
307
  setBallState(null)
308
+ if (hopAfterTurn) {
309
+ hopAfterTurn = false
310
+ startHop()
311
+ }
296
312
  void refreshStatus() // 顺手把用量与配额状态刷新(额度用尽会换成琥珀色)
297
313
  input.focus()
298
314
  }
@@ -447,7 +463,162 @@ const PEEK_CLASSES = ['ball-peek', 'ball-peek-right']
447
463
  *
448
464
  * 页面自己算不出这些:它不知道自己在屏幕上的位置,也不知道工作区多大。
449
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
+
450
614
  function applyMode(payload) {
615
+ // 形态一变就把动作清干净:托盘与全局快捷键都能直接改形态,停在第 3 帧上等它回来
616
+ // 就是一张"永远悬在半空"的脸。这里只清类与定时器,形态本身仍由下面那段决定。
617
+ stopLift()
618
+ showLift(null)
619
+ settleTickle()
620
+ closeMemory()
621
+ settleDangle()
451
622
  const mode = payload && payload.mode === 'ball' ? 'ball' : 'chat'
452
623
  const side = payload && payload.side === 'right' ? 'right' : 'left'
453
624
  // **只摆锚、不切形态**(`anchorOnly`):展开分两步走,见 `main.cjs` 里"先摆锚、等页面
@@ -492,8 +663,290 @@ function toggleMode() {
492
663
  requestMode(document.body.classList.contains('mode-chat') ? 'ball' : 'chat')
493
664
  }
494
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
+
495
947
  if (hasShell) {
496
948
  // `shell` 这个类决定球在不在场(见 panel.css):浏览器降级那条路永远不该看见球
949
+
497
950
  document.body.classList.add('shell', 'mode-ball')
498
951
  // **四张脸先取回来。**(2026-09-29 加的,用户报"点一下会闪一下")
499
952
  //
@@ -505,8 +958,13 @@ if (hasShell) {
505
958
  // 那时没人盯着球看。
506
959
  //
507
960
  // 放在 `hasShell` 里面:浏览器降级那条路根本不显示球,没必要替它取。
961
+ // **动作帧也要在这里预取。** 按下才开始取的话,第一帧就是空的(见下面那段的说明)。
508
962
  for (const face of ['mascot.png', 'mascot-happy.png', 'mascot-thinking.png',
509
- 'mascot-sorry.png', 'mascot-tired.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']) {
510
968
  const img = new Image()
511
969
  img.src = '/panel/' + face
512
970
  }
@@ -532,18 +990,27 @@ if (hasShell) {
532
990
  }
533
991
 
534
992
  const DRAG_THRESHOLD_PX = 4
993
+ // 挠痒痒:只在球身上移动时才扭(停下由 onTickleMove 里的回正定时器负责)
994
+ ball.addEventListener('pointermove', onTickleMove)
995
+
535
996
  ball.addEventListener('pointerdown', (event) => {
536
997
  if (event.button !== 0) return
537
998
  setBallFace(true)
999
+ if (canPlayLift()) startLift()
1000
+ settleTickle() // 按下了就先别扭了,动作帧优先
538
1001
  const startX = event.screenX
539
1002
  const startY = event.screenY
540
1003
  let dragging = false
1004
+ startDangle(startX, startY)
541
1005
 
542
1006
  const onMove = (moveEvent) => {
543
1007
  if (!dragging && Math.hypot(moveEvent.screenX - startX, moveEvent.screenY - startY) > DRAG_THRESHOLD_PX) {
544
1008
  dragging = true
545
1009
  }
546
- if (dragging) void window.weflowPanel.dragMove(moveEvent.screenX, moveEvent.screenY)
1010
+ if (dragging) {
1011
+ void window.weflowPanel.dragMove(moveEvent.screenX, moveEvent.screenY)
1012
+ trackDangle(moveEvent)
1013
+ }
547
1014
  }
548
1015
  const onUp = () => {
549
1016
  window.removeEventListener('pointermove', onMove)
@@ -551,11 +1018,15 @@ if (hasShell) {
551
1018
  window.removeEventListener('pointercancel', onCancel)
552
1019
  void window.weflowPanel.dragEnd()
553
1020
  setBallFace(false)
1021
+ endLift()
554
1022
  if (dragging) return // 拖过了就不算点击
555
1023
  toggleMode()
556
1024
  }
557
1025
  // 指针被系统抢走(触摸、原生菜单弹出)时不走 onUp,必须自己把脸收回来,否则球一直笑着
558
- const onCancel = () => setBallFace(false)
1026
+ const onCancel = () => {
1027
+ setBallFace(false)
1028
+ endLift()
1029
+ }
559
1030
  void window.weflowPanel.dragStart(startX, startY)
560
1031
  window.addEventListener('pointermove', onMove)
561
1032
  window.addEventListener('pointerup', onUp)
@@ -564,7 +1035,7 @@ if (hasShell) {
564
1035
  // 右键:快速回复。**只在有外壳时接管**——浏览器降级那条路的原生菜单里有"复制",
565
1036
  // 那是用户要用的,不该被我们抢掉。
566
1037
  //
567
- // 有外壳时用**主进程弹的原生菜单**:它画在窗口外面,所以球形态那 76x76 的窗口
1038
+ // 有外壳时用**主进程弹的原生菜单**:它画在窗口外面,所以球形态那 96x96 的窗口
568
1039
  // **不用先展开**(页内菜单会被窗口裁掉)。上一版就是"先展开再弹",用户看到的是
569
1040
  // "点右键把第二大脑窗口弹出来了"——那不是快速功能该有的样子。
570
1041
  document.addEventListener('contextmenu', (event) => {
@@ -572,7 +1043,7 @@ if (hasShell) {
572
1043
  void (async () => {
573
1044
  const picked = await window.weflowPanel.openQuickMenu(quickReplies)
574
1045
  if (!picked || typeof picked !== 'object') return
575
- // 选中任何一项都要展开:球那个 76x76 里看不到任何回答,而这几项全都要出文字。
1046
+ // 选中任何一项都要展开:球那个 96x96 里看不到任何回答,而这几项全都要出文字。
576
1047
  // 但**右键本身不动窗口**(那是用户嫌的那一下)——展开只发生在真的选了东西之后。
577
1048
  if (document.body.classList.contains('mode-ball')) requestMode('chat')
578
1049
  if (picked.kind === 'contact' && typeof picked.name === 'string' && picked.name.trim()) {
@@ -598,7 +1069,7 @@ if (hasShell) {
598
1069
  // 形态由主进程说了算(球、收起、托盘菜单、快捷键都是这条路),收起分两步:
599
1070
  // `fadeMs > 0` → 先把气泡淡掉(窗口这会儿还是大的,淡出真看得见),**不换形态**;
600
1071
  // `done` → 主进程已经把窗口缩回球那么大,这时才把气泡摘掉。
601
- // 顺序不能反:提前换,76x76 的窗口里会露出一条气泡的边;不换,气泡会一直在。
1072
+ // 顺序不能反:提前换,96x96 的窗口里会露出一条气泡的边;不换,气泡会一直在。
602
1073
  window.weflowPanel.onMode((payload) => {
603
1074
  if (payload && payload.mode === 'ball' && !payload.done) {
604
1075
  if (payload.fadeMs > 0 && document.body.classList.contains('mode-chat')) {
@@ -620,6 +1091,7 @@ if (hasShell) {
620
1091
  }
621
1092
 
622
1093
  renderEmptyState()
1094
+ if (hasShell) scheduleBlink()
623
1095
  void refreshStatus()
624
1096
  // 每 30 秒刷一次状态:守护进程可能被停掉,界面不该一直显示旧数字
625
1097
  setInterval(refreshStatus, 30000)