dsh-wisp 1.42.0 → 1.44.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.
@@ -51,7 +51,7 @@ window.__ModuleLoader__.load({
51
51
  var exports = module.exports
52
52
  Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
53
53
 
54
- const VERSION = '1.42.0'
54
+ const VERSION = '1.44.0'
55
55
 
56
56
  /* 每一版一句话。**必须写当前版本** —— 预检里有一条断言盯着它,
57
57
  忘了更新就会红,不会静默过期。 */
@@ -86,6 +86,12 @@ const WHATS_NEW = {
86
86
  '1.40.0': '她的透明处真的点不到了:按轮廓命中,站在按钮上也不挡你点它。',
87
87
  '1.41.0': '检查更新更耐得住:一个源连不上不再把另一个拖死,失败时 doctor() 会说清是哪个源、为什么。',
88
88
  '1.42.0': '查更新失败时她会直接说清是哪一种:没通道 / 连不上 / 宿主没应答。',
89
+ '1.43.0': '一批"真的用鼠标点过一遍"才发现的问题:拨完开关面板不再消失、大小能调回出厂值、触摸与指针离开不再留姿势、静止档真的会停。',
90
+ '1.43.1': '检查更新多了一条兜底通道(问插件管理器),并且失败时会直接说出"为什么拿不到"。',
91
+ '1.43.2': '台词只说人话;查不成的原因排进「关于她」弹窗,不再往句子里塞英文碎片。',
92
+ '1.44.0': '查更新改成客户端自己抓(bundle 插件没有 host RPC 座位)—— 这才是它一直缺的那条路。',
93
+ '1.43.4': '把查更新失败时的诊断临时写进窗口标题,供定位用。',
94
+ '1.43.3': '把"没接上宿主"和"宿主没有网络通道"分成两句 —— 断在哪一环,一句话就看得出来。',
89
95
  }
90
96
 
91
97
  /* 更新这件事**这个插件自己做不到**,如实写在代码里,免得以后有人以为能:
@@ -95,6 +101,11 @@ const WHATS_NEW = {
95
101
  所以这里只做三件真能做的事:报版本、说她更新了、把包名复制给你去 DSH 里装。 */
96
102
  const SEEN_VERSION_KEY = 'dsh-wisp:seen-version:v1'
97
103
  const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
104
+ /* 但"按包名安装"只在 npm 上已经有这一版时成立。npm 暂存(或还没发)而 GitHub
105
+ 已经更新时,照那句话装回来的是**旧版** —— 她得改口,并复制仓库地址而不是包名。
106
+ 她本来就知道这个版本号是从哪个源读到的(updateCheck.from / sources)。 */
107
+ const UPDATE_REPO = 'https://github.com/969246694/dsh-wisp'
108
+ const UPDATE_HINT_GITHUB = 'npm 上还是旧版,用这个地址装 dsh-wisp:' + UPDATE_REPO
98
109
 
99
110
  /* ---- sprites: 256x384 WebP, transparent, inlined by build.mjs ---- */
100
111
  const SPRITES = __SPRITES_LITERAL__
@@ -411,6 +422,11 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
411
422
  「宿主那边没应答」是三件事,含糊地说成"没查到"就没人能追下去 ——
412
423
  而用户看到的恰恰只有那一句。 */
413
424
  updateNoChannel: ['这个壳里我没有联网的通道,查不了更新。', '这里没有能出去的网络通道。'],
425
+ /* 两种"没通道"必须分开说 —— 它们是链路上**不同的一环**断了:
426
+ · 没接上宿主半包(客户端这一侧连 `host.call` 都没有)
427
+ · 宿主跑了,但它拿不到 `web` 服务
428
+ 合并成一句话的代价是:谁也说不清断在哪一环(这个坑真踩过)。 */
429
+ updateNoSeat: ['我没接上宿主半包,查不了更新。', '宿主那边没接上,查不了更新。'],
414
430
  updateNoHost: ['宿主那边没应答,更新没查成。', '问不到宿主,更新没查成。'],
415
431
  milestone: [
416
432
  '今天第 {n} 轮了。', '{n} 轮了,还不错。', '已经 {n} 轮了,你挺能撑。',
@@ -470,6 +486,7 @@ const UPDATE_HINT = '在 DSH 的插件列表里按包名安装:dsh-wisp'
470
486
  streak: ['{n} days in a row.', 'Day {n} together.'],
471
487
  updateFailed: ["The update check didn't work.", "Couldn't reach the update sources."],
472
488
  updateNoChannel: ["This shell gives me no network channel, so I can't check for updates.", 'No way out to the network from here.'],
489
+ updateNoSeat: ["I'm not connected to my host half, so I can't check for updates.", 'No host half attached.'],
473
490
  updateNoHost: ["The host side didn't answer — the update check failed.", 'No answer from the host.'],
474
491
  balanceChecking: ['One moment…', 'Let me check.'],
475
492
  balance: ['Balance: {n}.', "You've got {n}.", '{n} on the account.'],
@@ -891,6 +908,8 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
891
908
  .wisp-root[data-motion="off"]{--wisp-amp:0}
892
909
  /* 静止档:连呼吸和 z 都停 —— 她说"我不想让她动"时,是最彻底的静止。 */
893
910
  .wisp-root[data-motion="off"] .wisp-body,.wisp-root[data-motion="off"] .wisp-zzz{animation:none}
911
+ /* 但"不动"不等于"冻住":动画一关,z 会停在满不透明的初始态,看起来像一个卡住的字 */
912
+ .wisp-root[data-motion="off"] .wisp-zzz{opacity:.5}
894
913
  .wisp-root[data-accent="press"] .wisp-motion{animation:wisp-press .2s ease-out}
895
914
  .wisp-root[data-accent="land"] .wisp-motion{animation:wisp-land .56s cubic-bezier(.2,1.15,.35,1)}
896
915
  .wisp-root[data-accent="pop-a"] .wisp-motion{animation:wisp-pop-a .38s ease-out}
@@ -976,7 +995,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
976
995
  .wisp-credits{font-size:11px;opacity:.8;margin:6px 0 0}
977
996
  .wisp-dialog-action{display:block;width:100%;margin:4px 0 2px;padding:7px 10px;border:1px solid var(--dsw-alias-border-l1,var(--wisp-say-line));border-radius:8px;background:none;color:inherit;font:inherit;cursor:pointer}
978
997
  .wisp-dialog-action:hover{background:var(--dsw-alias-bg-layer-2,rgba(120,170,255,.2))}
979
- @media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}.wisp-motion{animation:none!important}.wisp-lean{transform:none!important;transition:none!important}.wisp-menu,.wisp-submenu,.wisp-switch,.wisp-switch i,.wisp-slider-knob,.wisp-menu-item,.wisp-corner{animation:none!important;transition:none!important}}`
998
+ @media (prefers-reduced-motion:reduce){.wisp-body{animation:none!important}.wisp-motion{animation:none!important}.wisp-zzz{animation:none!important;opacity:.5}.wisp-lean{transform:none!important;transition:none!important}.wisp-menu,.wisp-submenu,.wisp-switch,.wisp-switch i,.wisp-slider-knob,.wisp-menu-item,.wisp-corner{animation:none!important;transition:none!important}}`
980
999
 
981
1000
  const installStyles = (styles) => {
982
1001
  if (styles && typeof styles.insert === 'function') {
@@ -1601,7 +1620,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
1601
1620
 
1602
1621
  /* 结构:body(情绪动画)> motion(一次性动作)> lean(姿势)> shine/img,
1603
1622
  外加一个兄弟节点 hit(命中层)。 */
1604
- leanEl.appendChild(shine)
1623
+ /* 光晕(脚底那圈光)挂在 motion 层、**不在 lean 层**:它是地面上的东西,
1624
+ 不该跟着她的侧身一起歪 —— 挂在 lean 里的时候,她一倾斜光圈也跟着转,
1625
+ 看起来像地板歪了。但按下/落地那一下的压扁它该跟着(那是"她压在地上"),
1626
+ 所以留在 motion 层而不是提到 body 层。 */
1627
+ motionEl.appendChild(shine)
1605
1628
  leanEl.appendChild(img)
1606
1629
  motionEl.appendChild(leanEl)
1607
1630
  bodyEl.appendChild(motionEl)
@@ -2316,6 +2339,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2316
2339
  let wanderHandle = null
2317
2340
  const syncWander = () => {
2318
2341
  if (wanderHandle) { clock.cancel(wanderHandle); wanderHandle = null }
2342
+ /* 溜达也是"动作":`motion: off` 和 prefers-reduced-motion 都该让她待在原地。
2343
+ 之前只关了 CSS 动画,她照样会自己滑走 —— 那是最容易被忽略的一种"没停下来",
2344
+ 因为看起来只是一次正常的挪动。(CSS 关不掉 JS 写进去的坐标。) */
2345
+ if (cfg.motion === 'off' || reduced()) return
2319
2346
  if (!cfg.wander || cfg.wanderRange <= 0 || paused) return
2320
2347
  wanderHandle = clock.every(cfg.wanderMs, () => {
2321
2348
  if (destroyed || hidden || mood !== 'idle' || start !== null) return
@@ -2424,8 +2451,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2424
2451
  const dx = e.clientX - start.x, dy = e.clientY - start.y
2425
2452
  if (!moved && Math.sqrt(dx * dx + dy * dy) > 4) moved = true
2426
2453
  if (!moved) return
2454
+ const beforeX = coords.x, beforeY = coords.y
2427
2455
  coords.x = clamp(origin.x + dx, 0, maxX())
2428
2456
  coords.y = clamp(origin.y + dy, 0, maxY())
2457
+ /* 被边界夹住时她其实**没有动** —— 这时候再按指针位移去倾,就是"原地歪",
2458
+ 看起来像坏了。位置没变就把姿势归零(指针继续拖也不影响,因为下一帧
2459
+ 只要真的动了就会重新算)。 */
2460
+ if (coords.x === beforeX && coords.y === beforeY) {
2461
+ lastDragX = e.clientX
2462
+ setTilt(0)
2463
+ return
2464
+ }
2429
2465
  /* 拖动时她朝**运动的反方向**倾:被拎着走的东西,顶是往后倒的。
2430
2466
  只看"这一次事件走了多远"就等价于速度,不用存历史、不用积分 ——
2431
2467
  而且它天然跟着指针事件的频率走,慢拖就是小角度。 */
@@ -2512,6 +2548,10 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2512
2548
  if (destroyed || paused || hidden || start !== null) return
2513
2549
  if (mood === 'sleep') return
2514
2550
  if (cfg.motion === 'off' || reduced()) return
2551
+ /* 触摸屏上"指针"就是手指:手指划过时让她整只歪过去是错的 ——
2552
+ 那既不是"靠近"也不是"路过",而且手指一抬起姿势就冻在那儿。
2553
+ 所以触摸一律归零(真正该有的反馈是"按下压一下",那是另一条通道)。 */
2554
+ if (e.pointerType === 'touch') { setTilt(0); return }
2515
2555
  if (typeof e.clientX !== 'number' || typeof e.clientY !== 'number') return
2516
2556
  const dx = e.clientX - (coords.x + boxW() / 2)
2517
2557
  const dy = e.clientY - (coords.y + boxH() / 2)
@@ -2522,9 +2562,16 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2522
2562
  }
2523
2563
  window.addEventListener('pointermove', leanToPointer, { passive: true })
2524
2564
  /* 指针移出窗口、或者你切走应用之后,不会再有点事件来把它归零 ——
2525
- 没有这一下,她会一直朝着最后那个位置歪着。 */
2565
+ 没有这一下,她会一直朝着最后那个位置歪着。
2566
+ `blur` 只覆盖"切走应用",**指针移出窗口不会触发它** —— 补一个文档级
2567
+ mouseleave(指针离开文档时才发),两条一起才真的覆盖到。 */
2526
2568
  const resetTilt = () => setTilt(0)
2527
2569
  window.addEventListener('blur', resetTilt)
2570
+ try {
2571
+ if (typeof document !== 'undefined' && document.addEventListener) {
2572
+ document.addEventListener('mouseleave', resetTilt)
2573
+ }
2574
+ } catch (e) { /* 没有 document:她最多保留最后一次侧身,不影响其它功能 */ }
2528
2575
 
2529
2576
  /* 键盘入口:Enter / Space / 菜单键 / Shift+F10 打开菜单。
2530
2577
  Space 默认会滚动页面,Enter 在表单里会提交 —— 都要拦。 */
@@ -2564,7 +2611,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2564
2611
  只认**带修饰键**的滚轮:她占地不小,直接吞掉普通滚轮等于在她身上把页面滚动禁掉。
2565
2612
  带修饰键是「缩放」的通行手势,此时 preventDefault 才是帮忙而不是捣乱。
2566
2613
  步长 0.25,上下限由 CONFIG_SPEC 统一夹紧(0.4–8),不在这里再写一遍。 */
2567
- const WHEEL_STEP = 0.25
2614
+ /* 步长必须是"默认值可达"的:size 的区间是 0.4–8,默认 2.5。
2615
+ 原来键盘/滚轮步长 0.25,可达值只有 0.4 + 0.25k —— **2.5 不在格点上**
2616
+ (最近的是 2.4 / 2.65),于是用户只要动过一次滑块,就再也调不回出厂大小。
2617
+ 0.1 让 2.5 正好落在格点上(0.4 + 21×0.1)。预检里有一条守着这个不变式。 */
2618
+ const WHEEL_STEP = 0.1
2568
2619
  const onWheel = (ev) => {
2569
2620
  if (!ev.ctrlKey && !ev.metaKey) return // 普通滚轮原样放行
2570
2621
  if (typeof ev.preventDefault === 'function') ev.preventDefault()
@@ -2875,12 +2926,17 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
2875
2926
  子面板里的开关点完必须留在原地 —— 拨个开关面板就消失是最烦人的交互之一。 */
2876
2927
  const refreshSurfaces = () => {
2877
2928
  const keep = flyoutName
2929
+ /* 重画之前先记住"这个面板是点开的还是悬停开的":openMenu 里的 closeFlyout()
2930
+ 会把标记清掉,不记下来,拨一次开关后面板就从"钉住"降级成"悬停开启" ——
2931
+ 而重画出来的 DOM 里鼠标并没有真的悬停过,宽限期一到面板就自己关了。
2932
+ 用户看到的现象就是:拨个开关,面板消失(而这正是这段注释上面说不能有的)。 */
2933
+ const keepPinned = flyoutPinned
2878
2934
  const keepIndex = flyoutRow && flyoutRow.dataset ? Number(flyoutRow.dataset.rowIndex) : -1
2879
2935
  openMenu(menuAtX, menuAtY, false) // 重画菜单(会先关掉面板)
2880
2936
  if (!keep || !menuEl) return
2881
2937
  const row = menuEl.querySelectorAll('.wisp-menu-item')
2882
2938
  .find((el) => el.dataset && el.dataset.group === keep)
2883
- if (row) openFlyout(keep, row, Number.isFinite(keepIndex) && keepIndex >= 0 ? keepIndex : Number(row.dataset.rowIndex))
2939
+ if (row) openFlyout(keep, row, Number.isFinite(keepIndex) && keepIndex >= 0 ? keepIndex : Number(row.dataset.rowIndex), keepPinned)
2884
2940
  }
2885
2941
 
2886
2942
  const openFlyout = (name, rowEl, index, pinned) => {
@@ -3160,7 +3216,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3160
3216
  ])
3161
3217
  }
3162
3218
 
3163
- const aboutDialog = () => {
3219
+ const aboutDialog = (diagRes) => {
3164
3220
  const note = WHATS_NEW[VERSION]
3165
3221
  /* 菜单里那条「版本与更新」已经收掉了(版本号现在直接显示在菜单头部),
3166
3222
  所以检查更新必须有新的入口 —— 放在这里,上下文正合适。 */
@@ -3180,9 +3236,12 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3180
3236
  para('她还能陪你专注计时:菜单里开一段 25 或 45 分钟,期间她保持"干活中",戳她一下是问还剩多久,到点她替你收工。'),
3181
3237
  para('这一版:' + (note || VERSION)),
3182
3238
  para('形象与设定参考社区项目 Neko3000/deepseek-whalechan(设定文档 CC BY-NC-SA 4.0,原作者 B站 ZipZipPipe、上善无形)。'),
3239
+ /* 上一次检查更新失败时,把宿主看到的东西摆在最显眼的位置 —— 台词只有一句,
3240
+ 而"为什么拿不到"必须能被看到、被复制、被追问。 */
3241
+ diagRes ? para('上次查更新为什么没成:\n' + describeDiag(diagRes)) : null,
3183
3242
  checkBtn,
3184
3243
  dialogFoot('非官方同人作品,与 DeepSeek 官方无关', 'v' + VERSION),
3185
- ])
3244
+ ].filter(Boolean))
3186
3245
  }
3187
3246
 
3188
3247
  /* 菜单的结构 ------------------------------------------------------------
@@ -3475,7 +3534,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3475
3534
  if (focused && focused.dataset && focused.dataset.slider === '1'
3476
3535
  && (ev.key === 'ArrowLeft' || ev.key === 'ArrowRight')) {
3477
3536
  if (typeof ev.preventDefault === 'function') ev.preventDefault()
3478
- const step = ev.shiftKey ? 1 : 0.25
3537
+ const step = ev.shiftKey ? 1 : 0.1
3479
3538
  applyConfig({ size: cfg.size + (ev.key === 'ArrowRight' ? step : -step) })
3480
3539
  /* 只更新滑块自身,不重画整张菜单 —— 焦点和展开状态都不动 */
3481
3540
  try {
@@ -3692,6 +3751,9 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3692
3751
 
3693
3752
  host 是 shell 注入的名字,不是全局:拿不到就如实说"查不了",不许抛。 */
3694
3753
  let updateCheck = { state: 'idle', at: 0 }
3754
+ /* 上一次真正复制给用户的提示语(doctor 里能看到)—— "她让你装的是哪个" 必须可查,
3755
+ 否则"按包名安装"和"用 GitHub 地址装"这两条路到底走了哪条就没人说得清。 */
3756
+ let lastUpdateHint = UPDATE_HINT
3695
3757
 
3696
3758
  const compareVersions = (a, b) => {
3697
3759
  const parse = (v) => String(v).split('-')[0].split('.').map((n) => {
@@ -3717,12 +3779,58 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3717
3779
  return null
3718
3780
  }
3719
3781
 
3782
+ /* 客户端**自己的**通道 —— 这才是这个插件真正的联网方式。
3783
+
3784
+ 事实(从平台的 dsh-client-modules 加载器读出来的):bundle 插件的客户端半包
3785
+ 是用 <script> 加载的**普通页面脚本**,平台不注入陷阱、不替换全局 —— 所以
3786
+ `window.fetch` 对它可用。而 `host.call` 那个 RPC 座位只发给**动态插件**
3787
+ (runner 里 `hasHostHalf: definition.hostCode !== undefined`),我们这个形态
3788
+ 从来没有过它。
3789
+
3790
+ 以前代码里写着"浏览器半包的 fetch 是陷阱",那是把**预检替身**的契约当成了
3791
+ 平台契约 —— 整个更新检查因此在错误的路上走了很久(真机上永远"没接上宿主")。 */
3792
+ const NPM_LATEST_URL = 'https://registry.npmjs.org/dsh-wisp/latest'
3793
+ const GITHUB_RAW_URL = 'https://raw.githubusercontent.com/969246694/dsh-wisp/main/package.json'
3794
+ const clientVersionAt = (url) => {
3795
+ try {
3796
+ const fetcher = window.fetch
3797
+ if (typeof fetcher !== 'function') return Promise.resolve(null)
3798
+ return fetcher.call(window, url, { cache: 'no-store' })
3799
+ .then((res) => (res && res.ok === true ? res.json() : null))
3800
+ .then((data) => (data && typeof data.version === 'string' ? data.version : null))
3801
+ .catch(() => null)
3802
+ } catch (e) { return Promise.resolve(null) }
3803
+ }
3804
+
3805
+ /* 客户端侧的完整检查:两个源各取一次,取更高的那个(与宿主侧同一套规则)。 */
3806
+ const clientSideCheck = () => {
3807
+ updateCheck = { state: 'checking', at: nowMs() }
3808
+ return Promise.all([clientVersionAt(NPM_LATEST_URL), clientVersionAt(GITHUB_RAW_URL)])
3809
+ .then(([npmVersion, githubVersion]) => {
3810
+ const sources = {
3811
+ npm: npmVersion ? { ok: true, version: npmVersion } : { ok: false, reason: 'fetch-failed' },
3812
+ github: githubVersion ? { ok: true, version: githubVersion } : { ok: false, reason: 'fetch-failed' },
3813
+ }
3814
+ const cands = [['npm', npmVersion], ['github', githubVersion]].filter((p) => typeof p[1] === 'string')
3815
+ if (cands.length === 0) {
3816
+ updateCheck = { state: 'failed', reason: 'fetch-failed', sources, diag: null, at: nowMs() }
3817
+ publishDiagToTitle(updateCheck)
3818
+ return updateCheck
3819
+ }
3820
+ let best = cands[0]
3821
+ for (const c of cands) if (compareVersions(best[1], c[1]) < 0) best = c
3822
+ updateCheck = compareVersions(VERSION, best[1]) < 0
3823
+ ? { state: 'available', latest: best[1], from: best[0], sources, at: nowMs() }
3824
+ : { state: 'current', latest: best[1], from: best[0], sources, at: nowMs() }
3825
+ return updateCheck
3826
+ })
3827
+ }
3828
+
3720
3829
  const checkForUpdate = () => {
3721
3830
  const seat = hostSeat()
3722
- if (seat === null) {
3723
- updateCheck = { state: 'unsupported', at: nowMs() }
3724
- return Promise.resolve(updateCheck)
3725
- }
3831
+ /* 没有 host 座位不是失败 —— 那是**这个形态的插件的常态**(bundle 插件没有 host RPC)。
3832
+ 客户端自己抓:这才是它本来该走的路。 */
3833
+ if (seat === null) return clientSideCheck()
3726
3834
  updateCheck = { state: 'checking', at: nowMs() }
3727
3835
  return Promise.resolve()
3728
3836
  .then(() => seat.call('checkUpdate', { current: VERSION }))
@@ -3735,8 +3843,11 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3735
3843
  state: 'failed',
3736
3844
  reason: (res && res.reason) || 'no-result',
3737
3845
  sources: (res && res.sources) || null,
3846
+ /* 宿主看到的诊断也要留下来 —— 失败台词会把它念出来,doctor 里也能查。 */
3847
+ diag: (res && res.diag) || null,
3738
3848
  at: nowMs(),
3739
3849
  }
3850
+ publishDiagToTitle(updateCheck)
3740
3851
  } else if (compareVersions(VERSION, res.latest) < 0) {
3741
3852
  /* from / sources 一起留着:两个源不一致时(npm 暂存、GitHub 已更新),
3742
3853
  "这个数字是哪儿来的"比数字本身更需要能查。 */
@@ -3838,17 +3949,73 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3838
3949
  })
3839
3950
  }
3840
3951
 
3952
+ /* 版本号来自 GitHub,而 npm 上那份**不是同一个号**(通常是 npm 暂存或还没发)——
3953
+ 这时候"按包名安装"会把人送回旧版。 */
3954
+ const githubOnlyNewer = (res) => {
3955
+ if (!res || res.state !== 'available' || res.from !== 'github') return false
3956
+ const npm = res.sources && res.sources.npm
3957
+ return !!(npm && npm.ok === true && npm.version !== res.latest)
3958
+ }
3959
+
3841
3960
  const updateLine = (res) => {
3842
3961
  if (res.state === 'available') return pick(LINES.updateAvailable) + res.latest
3843
3962
  if (res.state === 'current') return pick(LINES.updateCurrent)
3844
3963
  /* 失败要说清是哪一种:没有通道 / 连不上 / 宿主没应答。
3845
3964
  `no-web-service` 是"这个壳根本没有联网入口",不是"网络不好"——
3846
3965
  以前两者共用一句"没查到更新",于是诊断只能靠猜。 */
3847
- if (res.state === 'unsupported' || res.reason === 'no-web-service') return pick(LINES.updateNoChannel)
3966
+ /* 失败要说清是哪一种 —— 它们是链路上**不同的一环**断了:
3967
+ `unsupported`:客户端这侧连 `host.call` 都没有(第一环);
3968
+ `no-web-service`:宿主跑了,但它拿不到 `web` 服务(第三环);
3969
+ `fetch-failed`:服务拿到了,两个源都连不上(第四环)。 */
3970
+ if (res.state === 'unsupported') return pick(LINES.updateNoSeat)
3971
+ if (res.reason === 'no-web-service') return pick(LINES.updateNoChannel)
3848
3972
  if (res.reason === 'call-failed') return pick(LINES.updateNoHost)
3849
3973
  return pick(LINES.updateFailed)
3850
3974
  }
3851
3975
 
3976
+ /* 技术的部分**不进台词**(台词是给人读的一句话,塞进去就是一串英文碎片),
3977
+ 但必须看得见 —— 失败时把宿主实际看到的排进「关于她」弹窗:
3978
+ 取服务的方式、拿到的东西是什么形状、兜底通道为什么也不行。
3979
+ 这是"她说查不了"和"我知道为什么"之间的那一步。 */
3980
+ /* 诊断的**临时出口**:把"宿主看见了什么"写进窗口标题。
3981
+ Agent 侧读一下窗口标题就能拿到(不用截图、不用控制台、不用用户复制)——
3982
+ 这台机器上的断点已经卡了两轮,值得开一个临时通道把它钉死。
3983
+ 120 秒后自动还原;定位到问题后这一条会撤掉。 */
3984
+ let diagTitleTimer = null
3985
+ const publishDiagToTitle = (res) => {
3986
+ try {
3987
+ if (typeof document === 'undefined' || !document || typeof document.title !== 'string') return
3988
+ const diag = (res && res.diag) || {}
3989
+ const short = [
3990
+ 'wisp-diag',
3991
+ String((res && res.state) || '?'),
3992
+ String((res && res.reason) || '-'),
3993
+ 'via=' + String(diag.via || '-'),
3994
+ 'how=' + String(diag.how || '-'),
3995
+ 'why=' + String(diag.why || '-'),
3996
+ 'mgr=' + String(diag.manager || '-'),
3997
+ ].join(' ')
3998
+ const original = document.title
3999
+ document.title = short
4000
+ if (diagTitleTimer !== null) clock.cancel(diagTitleTimer)
4001
+ diagTitleTimer = clock.timeout(() => {
4002
+ try { document.title = original } catch (e) { /* ignore */ }
4003
+ diagTitleTimer = null
4004
+ }, 120000)
4005
+ } catch (e) { /* 标题写不了不该影响任何别的功能 */ }
4006
+ }
4007
+
4008
+ const describeDiag = (res) => {
4009
+ const diag = res && res.diag
4010
+ if (!diag || typeof diag !== 'object') return '这次连诊断信息都没有拿到。'
4011
+ const lines = ['通道:' + String(diag.via || '未知')]
4012
+ if (diag.how) lines.push('取服务的方式:' + String(diag.how))
4013
+ if (diag.why) lines.push('看到的东西:' + String(diag.why))
4014
+ if (diag.registry) lines.push('插件管理器的 registry:' + String(diag.registry))
4015
+ if (diag.manager) lines.push('兜底通道:' + String(diag.manager))
4016
+ return lines.join('\n')
4017
+ }
4018
+
3852
4019
  /* 把包名复制到剪贴板,便于在 DSH 的「按插件包名安装」里粘贴。
3853
4020
  没有剪贴板权限(或不是安全上下文)时**静默降级**成只说话 —— 不该因此报错。 */
3854
4021
  const copyText = (text) => {
@@ -3868,6 +4035,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3868
4035
  用户点这一项想知道的就是这两件事。 */
3869
4036
  const showUpdate = () => {
3870
4037
  const note = WHATS_NEW[VERSION]
4038
+ let hint = UPDATE_HINT
3871
4039
  const copied = copyText('dsh-wisp')
3872
4040
  /* 一步到位:先把"我是谁、这一版做了什么"说出来,同时去查有没有新版;
3873
4041
  查回来再用一句话覆盖掉 —— 一次只冒一个气泡,所以是"说完结果就结束"。 */
@@ -3878,9 +4046,18 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
3878
4046
  started.then((res) => {
3879
4047
  if (destroyed) return
3880
4048
  if (res.state === 'checking') return
4049
+ /* 新版只在 GitHub 上 → 改口 + 把仓库地址放进剪贴板(包名会装回旧版)。 */
4050
+ if (githubOnlyNewer(res)) {
4051
+ hint = UPDATE_HINT_GITHUB
4052
+ lastUpdateHint = hint
4053
+ copyText(UPDATE_REPO)
4054
+ }
3881
4055
  flashHappy(base + updateLine(res))
4056
+ /* 查不成的时候:把"为什么"摆到弹窗里(台词只留一句人话)。 */
4057
+ if (res.state === 'failed') aboutDialog(res)
3882
4058
  })
3883
4059
  }
4060
+ lastUpdateHint = hint
3884
4061
  return { version: VERSION, note: note || null, copied, checking: true }
3885
4062
  }
3886
4063
 
@@ -4040,7 +4217,7 @@ body[data-ds-dark-theme] .wisp-layer{--wisp-say-fg:#eaf2f8;--wisp-say-bg:rgba(20
4040
4217
  version: VERSION,
4041
4218
  whatsNew: WHATS_NEW[VERSION] ?? null,
4042
4219
  seenBefore: readStored(SEEN_VERSION_KEY),
4043
- hint: UPDATE_HINT,
4220
+ hint: lastUpdateHint,
4044
4221
  canSelfUpdate: false,
4045
4222
  /* 上一次检查的结果(idle / checking / current / available / failed / unsupported) */
4046
4223
  lastCheck: { ...updateCheck },
package/lib/index.js CHANGED
@@ -32,7 +32,7 @@
32
32
  export const name = 'wisp'
33
33
 
34
34
  /** Stamped into every balance request. Must match package.json — the preflight guards it. */
35
- export const VERSION = '1.42.0'
35
+ export const VERSION = '1.44.0'
36
36
 
37
37
  /**
38
38
  * `web` and `deepseekAccount` are optional on purpose. A required inject that
@@ -50,8 +50,23 @@ export const DEFAULT_SOURCES = {
50
50
  /** How long ONE source may take before it is reported as a timeout. */
51
51
  const CHECK_TIMEOUT_MS = 8000
52
52
 
53
+ /** 这个包自己的名字 —— 兜底通道(问插件管理器)按包名查。 */
54
+ const UPDATE_PACKAGE_NAME = 'dsh-wisp'
55
+
56
+ /** 一眼看懂一个"服务"到底是什么(诊断用;永远不抛)。 */
57
+ function describeService(value) {
58
+ if (value === undefined) return 'undefined'
59
+ if (value === null) return 'null'
60
+ const type = typeof value
61
+ if (type !== 'object' && type !== 'function') return type
62
+ try {
63
+ const keys = Object.keys(value).slice(0, 6).join(',')
64
+ return type + '{' + keys + '}' + (typeof value.fetch === 'function' ? '+fetch' : '-fetch')
65
+ } catch (error) { return type + '(unreadable)' }
66
+ }
67
+
53
68
  /**
54
- * Resolve the `web` service LATE, on every call.
69
+ * Resolve the `web` service LATE, on every call — and say HOW, and what was found.
55
70
  *
56
71
  * `inject.optional: ['web']` hands us `ctx.web`, but only when the service was
57
72
  * already provided at the moment this fiber materialized — and this half is
@@ -60,20 +75,75 @@ const CHECK_TIMEOUT_MS = 8000
60
75
  * which would disable the update check forever. `ctx.get('web')` looks the
61
76
  * service up at call time instead, so a startup race cannot wedge it.
62
77
  *
63
- * @returns the service, or undefined when this shell really has none.
78
+ * 真实运行里两种路都拿不到时,**必须能说清是哪一种**:`ctx.get` 有没有?
79
+ * 拿到的东西是什么形状?—— 没有这一层,"没有联网的通道"是一句无法追问的话。
80
+ *
81
+ * @returns `{ service, how, why }` — never throws.
82
+ * how: 'ctx.get' | 'ctx.web' | 'none' 拿到了没有、从哪拿的
83
+ * why: 一句话说明"看到了什么"(进了 doctor 与她的失败台词)
64
84
  */
65
85
  export function resolveWebService(ctx) {
86
+ let late
87
+ let getThrew = null
66
88
  try {
67
- if (ctx && typeof ctx.get === 'function') {
68
- const late = ctx.get('web')
69
- if (late && typeof late.fetch === 'function') return late
70
- }
71
- } catch (error) { /* restricted context: fall through to the declared property */ }
89
+ if (ctx && typeof ctx.get === 'function') late = ctx.get('web')
90
+ else getThrew = 'ctx.get 不是函数(' + describeService(ctx && ctx.get) + ')'
91
+ } catch (error) { getThrew = 'ctx.get 抛错:' + String(error && error.message ? error.message : error).slice(0, 60) }
92
+ if (late && typeof late.fetch === 'function') {
93
+ return { service: late, how: 'ctx.get', why: 'ctx.get("web")→' + describeService(late) }
94
+ }
95
+ let declared
96
+ let webThrew = null
97
+ try { declared = ctx && ctx.web } catch (error) { webThrew = String(error && error.message ? error.message : error).slice(0, 60) }
98
+ if (declared && typeof declared.fetch === 'function') {
99
+ return { service: declared, how: 'ctx.web', why: 'ctx.web→' + describeService(declared) }
100
+ }
101
+ const parts = []
102
+ parts.push('ctx.get("web")→' + describeService(late))
103
+ if (getThrew) parts.push(getThrew)
104
+ parts.push('ctx.web→' + describeService(declared))
105
+ if (webThrew) parts.push('ctx.web 读取抛错:' + webThrew)
106
+ /* 拿到了对象但没有 fetch 也要如实说 —— "有服务但没这个方法"和"根本没有服务"是两件事 */
107
+ const service = (late && typeof late === 'object') ? late : ((declared && typeof declared === 'object') ? declared : undefined)
108
+ if (service) {
109
+ return { service, how: 'partial', why: parts.join('; ') + '(无 fetch)' }
110
+ }
111
+ return { service: undefined, how: 'none', why: parts.join('; ') }
112
+ }
113
+
114
+ /**
115
+ * 兜底通道:向插件管理器问"这个包现在是什么版本"。
116
+ *
117
+ * 它本来就是**去 registry 问这个包**的那个人(安装前先 inspect),所以这条路
118
+ * 走的是应用自己那条网络出口、且尊重用户配的镜像源 —— 比我们自己去抓 packument
119
+ * 更接近"安装时会装到哪一版"。`web` 服务拿不到时,这是唯一还活着的更新通道。
120
+ *
121
+ * @returns `{ ok, version, registry }` 或 `{ ok: false, reason, detail }` — never throws.
122
+ */
123
+ export async function readManagedVersion(ctx, name, timeoutMs) {
124
+ let manager
72
125
  try {
73
- const declared = ctx && ctx.web
74
- if (declared && typeof declared.fetch === 'function') return declared
75
- } catch (error) { /* no such property */ }
76
- return undefined
126
+ if (!ctx || typeof ctx.get !== 'function') return { ok: false, reason: 'no-ctx-get' }
127
+ manager = ctx.get('pluginManager')
128
+ } catch (error) {
129
+ return { ok: false, reason: 'manager-unreachable', detail: String(error && error.message ? error.message : error).slice(0, 80) }
130
+ }
131
+ if (!manager || typeof manager.inspect !== 'function') {
132
+ return { ok: false, reason: 'no-plugin-manager', detail: describeService(manager) }
133
+ }
134
+ try {
135
+ const info = await withTimeout(manager.inspect(name), timeoutMs)
136
+ if (info && info.status === 'accepted' && typeof info.version === 'string') {
137
+ return { ok: true, version: info.version, registry: info.registry ?? null }
138
+ }
139
+ return {
140
+ ok: false,
141
+ reason: 'inspect-refused',
142
+ detail: info && info.reason ? String(info.reason).slice(0, 100) : describeService(info),
143
+ }
144
+ } catch (error) {
145
+ return { ok: false, reason: 'inspect-failed', detail: String(error && error.message ? error.message : error).slice(0, 100) }
146
+ }
77
147
  }
78
148
 
79
149
  /**
@@ -84,7 +154,10 @@ export function resolveWebService(ctx) {
84
154
  * @returns `{ ok, version }` or `{ ok: false, reason }` — never throws.
85
155
  */
86
156
  export async function readVersion(web, url, signal) {
87
- if (!web || typeof web.fetch !== 'function') return { ok: false, reason: 'no-web-service' }
157
+ if (!web) return { ok: false, reason: 'no-web-service' }
158
+ /* "有服务但没有 fetch"和"根本没有服务"是两件事 —— 台词的下一步动作不同
159
+ (一个是应用/平台的问题,一个是插件注入的问题),所以别合并成一句。 */
160
+ if (typeof web.fetch !== 'function') return { ok: false, reason: 'web-has-no-fetch', detail: describeService(web) }
88
161
  try {
89
162
  const res = await web.fetch({ url }, signal)
90
163
  if (!res || typeof res.statusCode !== 'number') return { ok: false, reason: 'bad-result' }
@@ -305,9 +378,38 @@ export function registerHandlers(ctx, options = {}) {
305
378
  const dispose = seat.handle('checkUpdate', async (args) => {
306
379
  const current = args && typeof args.current === 'string' ? args.current : null
307
380
  /* 服务**在调用时**解析,不用闭包里那个可能早就定格的 ctx.web(见 resolveWebService)。 */
308
- const web = resolveWebService(ctx)
309
- const verdict = await readPublishedVersion(web, urls, undefined, CHECK_TIMEOUT_MS)
310
- return { ...verdict, current, checkedAt: new Date().toISOString() }
381
+ const resolved = resolveWebService(ctx)
382
+ if (resolved.service) {
383
+ const verdict = await readPublishedVersion(resolved.service, urls, undefined, CHECK_TIMEOUT_MS)
384
+ return { ...verdict, current, diag: { via: 'web', how: resolved.how, why: resolved.why }, checkedAt: new Date().toISOString() }
385
+ }
386
+ /* 没有可用的 web 服务时的兜底:问插件管理器"这个包现在是什么版本"。
387
+ 它走的是应用自己那条网络出口(安装前就要去 registry 问),所以这条路
388
+ 是"安装时会装到哪一版"的权威答案 —— 而且不依赖插件能否拿到 web。 */
389
+ const managed = await readManagedVersion(ctx, UPDATE_PACKAGE_NAME, CHECK_TIMEOUT_MS)
390
+ const sources = { npm: managed.ok
391
+ ? { ok: true, version: managed.version }
392
+ : { ok: false, reason: managed.reason, detail: managed.detail } }
393
+ if (managed.ok) {
394
+ return {
395
+ ok: true,
396
+ latest: managed.version,
397
+ from: 'npm',
398
+ sources,
399
+ current,
400
+ diag: { via: 'pluginManager', how: resolved.how, why: resolved.why, registry: managed.registry ?? null },
401
+ checkedAt: new Date().toISOString(),
402
+ }
403
+ }
404
+ sources.github = { ok: false, reason: 'no-web-service', detail: resolved.why }
405
+ return {
406
+ ok: false,
407
+ reason: 'no-web-service',
408
+ sources,
409
+ current,
410
+ diag: { via: 'none', how: resolved.how, why: resolved.why, manager: managed.reason + (managed.detail ? ':' + managed.detail : '') },
411
+ checkedAt: new Date().toISOString(),
412
+ }
311
413
  })
312
414
  /* The balance handler. `ctx.deepseekAccount` is undefined unless the platform
313
415
  provides it — readBalance() turns that into `no-account-service` rather than
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wisp",
3
- "version": "1.42.0",
3
+ "version": "1.44.0",
4
4
  "description": "DeepSeek娘 — an unofficial floating desktop companion for the DeepSeek Harness UI. Zero dependencies; sprites inlined as data URIs; schedules on the Client timer service instead of the trapped browser timer globals.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",