dsh-vision-router 2.2.8 → 3.0.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 (132) hide show
  1. package/README.md +16 -7
  2. package/README.zh.md +16 -7
  3. package/docs/architecture/2.3.0-compat-retirement.md +147 -0
  4. package/docs/architecture/2.3.0-execution-plan.md +410 -0
  5. package/docs/architecture/2.3.0-final-audit.md +279 -0
  6. package/docs/architecture/2.3.0-reopened-plan.md +720 -0
  7. package/docs/architecture/3.0-architecture-plan.md +252 -0
  8. package/docs/architecture/3.0-r0-contract-ledger.md +137 -0
  9. package/docs/architecture/3.0-r1-source-artifact-boundary.md +98 -0
  10. package/docs/architecture/3.0-r10-final-correctness-closure.md +207 -0
  11. package/docs/architecture/3.0-r11-adversarial-review.md +441 -0
  12. package/docs/architecture/3.0-r5-browser-go-no-go.md +165 -0
  13. package/docs/architecture/3.0-r5-browser-typescript-gate.md +105 -0
  14. package/docs/architecture/3.0-r6-compat-retirement.md +203 -0
  15. package/docs/architecture/3.0-r7-public-api.md +151 -0
  16. package/docs/architecture/3.0-r8-ci-release.md +248 -0
  17. package/docs/architecture/3.0-r9-final-audit.md +370 -0
  18. package/docs/architecture/compat-inventory.md +26 -34
  19. package/docs/architecture/dsh-compatibility-matrix.md +65 -54
  20. package/docs/architecture/dsh-support-window.md +30 -22
  21. package/docs/architecture/final-closure-audit.md +2 -0
  22. package/docs/releases/v2.3.0.md +53 -0
  23. package/docs/releases/v3.0.0.md +85 -0
  24. package/entry.js +9 -57
  25. package/index.js +268 -761
  26. package/lib/agent-request-route-authority.js +1 -20
  27. package/lib/catalog-corrections.js +1 -2
  28. package/lib/client-maintenance-actions.js +145 -0
  29. package/lib/client-presentation-boundary-main.js +75 -17
  30. package/lib/client-presentation-boundary.js +53 -28
  31. package/lib/client.js +246 -144
  32. package/lib/core-apply-lifecycle.js +41 -0
  33. package/lib/core-cache.js +87 -0
  34. package/lib/core-primitives.js +91 -278
  35. package/lib/core-vision-surface-domain.js +63 -0
  36. package/lib/core-vision-surface.js +11 -77
  37. package/lib/delegated-call-config.js +15 -24
  38. package/lib/desktop-screenshot-tool.js +262 -0
  39. package/lib/doctor-cli.js +0 -0
  40. package/lib/doctor-runtime.js +4 -1
  41. package/lib/dsh-contract-compat.js +37 -256
  42. package/lib/dsh-host-capabilities.js +3 -9
  43. package/lib/dsh-settings-017-compat.js +17 -10
  44. package/lib/dsh-support-window.js +3 -3
  45. package/lib/file-logger.js +8 -3
  46. package/lib/grounding-coordinate-runtime.js +1 -2
  47. package/lib/http-body-limit.js +97 -79
  48. package/lib/http-compat.js +1 -2
  49. package/lib/image-offload-compat.js +30 -21
  50. package/lib/image-resource-governor.js +210 -228
  51. package/lib/legacy-global-proxy-boundary.js +46 -3
  52. package/lib/live-model-client-prelude.js +12 -4
  53. package/lib/live-model-discovery.js +134 -43
  54. package/lib/local-remote-settings-permission.js +33 -7
  55. package/lib/local-vision-stabilizer.js +31 -17
  56. package/lib/maintenance-routes.js +134 -0
  57. package/lib/native-image-coexistence.js +15 -3
  58. package/lib/ollama-cold-start.js +87 -23
  59. package/lib/pi-ai-bridge-wire-compat.js +3 -2
  60. package/lib/proxy-dispatcher-pool.js +151 -92
  61. package/lib/proxy-routing.js +75 -51
  62. package/lib/proxy-url-compat.js +3 -10
  63. package/lib/public-config.js +36 -0
  64. package/lib/public-entry.d.ts +148 -0
  65. package/lib/public-entry.js +19 -11
  66. package/lib/remote-settings-bridge.js +11 -0
  67. package/lib/remote-settings-risk-client-boundary.js +155 -0
  68. package/lib/remote-settings-risk-confirmation.js +17 -105
  69. package/lib/replay-delegation.js +12 -7
  70. package/lib/replay-envelope-v2-compat.js +12 -7
  71. package/lib/runtime-composition.js +127 -34
  72. package/lib/runtime-i18n-boundary.js +10 -11
  73. package/lib/screenshot-source-boundary.js +2 -2
  74. package/lib/session-affinity-runtime.js +113 -97
  75. package/lib/session-affinity.js +89 -76
  76. package/lib/session-event-history-compat.js +30 -0
  77. package/lib/session-message-source-compat.js +7 -3
  78. package/lib/session-query-readers.js +263 -0
  79. package/lib/session-surface-compat.js +30 -31
  80. package/lib/session-surface-policy-domain.js +85 -0
  81. package/lib/session-surface-policy.js +2 -111
  82. package/lib/session-turn-resolver.js +2 -6
  83. package/lib/session-vision-attachment-index.js +143 -0
  84. package/lib/session-vision-delegation-boundary.js +34 -0
  85. package/lib/session-vision-delegation.js +22 -0
  86. package/lib/session-vision-index.js +179 -116
  87. package/lib/session-vision-mode-authority.js +229 -192
  88. package/lib/session-vision-mode-boundary.js +65 -22
  89. package/lib/session-vision-policy-domain.js +304 -0
  90. package/lib/session-vision-policy.js +104 -0
  91. package/lib/session-vision-state.js +368 -378
  92. package/lib/settings-client-017-compat.js +9 -7
  93. package/lib/settings-client-loader-lifecycle.js +129 -0
  94. package/lib/sharp-runtime.js +0 -7
  95. package/lib/structured-flow-hardening.js +25 -6
  96. package/lib/tesseract-exec-compat.js +9 -1
  97. package/lib/turn-budget-context.js +47 -41
  98. package/lib/update-check.js +11 -2
  99. package/lib/vision-attachment-handle-runtime.js +176 -156
  100. package/lib/vision-backend-runtime-policy.js +19 -2
  101. package/lib/vision-background-benchmark.js +41 -4
  102. package/lib/vision-background-failure-policy.js +41 -41
  103. package/lib/vision-background-stop-store-core.js +217 -0
  104. package/lib/vision-background-stop-store.js +8 -179
  105. package/lib/vision-capability-benchmark-service.js +1 -0
  106. package/lib/vision-capability-benchmark.js +11 -61
  107. package/lib/vision-capability-evidence.js +35 -0
  108. package/lib/vision-capability-fingerprint.js +72 -0
  109. package/lib/vision-capability-identity.js +116 -61
  110. package/lib/vision-capability-router.js +13 -33
  111. package/lib/vision-chunk-assembler.js +95 -0
  112. package/lib/vision-diagnostics-routes.js +246 -0
  113. package/lib/vision-evidence-guidance.js +3 -1
  114. package/lib/vision-execution-order-apply.js +45 -55
  115. package/lib/vision-execution-order.js +19 -19
  116. package/lib/vision-execution-policy.js +12 -7
  117. package/lib/vision-model-registry.js +30 -17
  118. package/lib/vision-provider-transport.js +137 -176
  119. package/lib/vision-routing-authority.js +47 -48
  120. package/lib/vision-routing-product.js +26 -29
  121. package/lib/vision-routing-settings-prelude.js +8 -3
  122. package/lib/vision-runtime-performance-store.js +166 -0
  123. package/lib/vision-runtime-performance.js +13 -142
  124. package/lib/vision-toggle-root-hardening.js +22 -5
  125. package/lib/vision-tool-runtime-boundary.js +104 -31
  126. package/lib/web/dsh-settings-017-root-transport.js +27 -12
  127. package/lib/web/remote-settings-client.js +6 -9
  128. package/lib/web-capability-boundary.js +17 -7
  129. package/lib/wrapper-directory.js +14 -23
  130. package/package.json +56 -20
  131. package/lib/android-attachment-compat.js +0 -244
  132. package/lib/settings-client-rc8-lifecycle.js +0 -397
package/lib/client.js CHANGED
@@ -12,6 +12,147 @@ window.__ModuleLoader__.load({
12
12
  const { useState, useMemo } = React
13
13
  const { ImageGallery } = require('@deepseek-ai/dsh-client-ui-attachment')
14
14
 
15
+ // <generated:client-maintenance-actions>
16
+ function createClientMaintenanceActions({
17
+ fetchImpl,
18
+ t,
19
+ getTestState,
20
+ setTestState,
21
+ getUpdateState,
22
+ setUpdateState,
23
+ getSelfUpdateState,
24
+ setSelfUpdateState,
25
+ confirmImpl,
26
+ alertImpl,
27
+ } = {}) {
28
+ if (typeof fetchImpl !== 'function') throw new TypeError('client maintenance actions require fetch')
29
+ const translate = typeof t === 'function' ? t : (key) => String(key)
30
+
31
+ const diagnosticError = (value, fallback) => {
32
+ if (typeof value === 'string' && value.trim() !== '') return value.trim()
33
+ if (value && typeof value.message === 'string' && value.message.trim() !== '') {
34
+ return value.message.trim()
35
+ }
36
+ return fallback
37
+ }
38
+
39
+ const runTestConnection = async () => {
40
+ if (getTestState?.()?.status === 'running') return
41
+ setTestState?.({ status: 'running' })
42
+ try {
43
+ const response = await fetchImpl('/_dsh/vision-router/test-connection')
44
+ const result = await response.json().catch(() => undefined)
45
+ setTestState?.({ status: 'done', result })
46
+ } catch (error) {
47
+ setTestState?.({
48
+ status: 'done',
49
+ result: { ok: false, error: error && error.message ? error.message : String(error) },
50
+ })
51
+ }
52
+ }
53
+
54
+ const requestDesktopScreenshotPermission = () => {
55
+ // Fire-and-forget: the native permission dialog must never hold Settings
56
+ // save completion open. The actual screenshot tool still reports denial.
57
+ try {
58
+ void Promise.resolve(fetchImpl('/_dsh/vision-router/request-screenshot-permission', {
59
+ method: 'POST',
60
+ cache: 'no-store',
61
+ })).catch(() => {})
62
+ } catch {
63
+ // Permission prompting is best-effort after an already-landed setting.
64
+ }
65
+ }
66
+
67
+ const runUpdateCheck = async (force = false) => {
68
+ const current = getUpdateState?.() ?? {}
69
+ if (current.status === 'running') return
70
+ setUpdateState?.({ status: 'running', result: current.result })
71
+ try {
72
+ const response = await fetchImpl(
73
+ '/_dsh/vision-router/update-check' + (force ? '?force=1' : ''),
74
+ { cache: 'no-store' },
75
+ )
76
+ const result = await response.json().catch(() => undefined)
77
+ if (!response.ok) {
78
+ throw new Error(diagnosticError(result && result.error, `HTTP ${response.status}`))
79
+ }
80
+ if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
81
+ throw new Error(translate('updateInvalidResponse'))
82
+ }
83
+ setUpdateState?.({ status: 'done', result })
84
+ } catch (error) {
85
+ setUpdateState?.({
86
+ status: 'done',
87
+ result: {
88
+ ok: false,
89
+ error: error && error.message ? error.message : String(error),
90
+ },
91
+ })
92
+ }
93
+ }
94
+
95
+ const runSelfUpdate = async () => {
96
+ if (getSelfUpdateState?.()?.status === 'running') return
97
+ const checked = getUpdateState?.()?.result
98
+ const auto = checked && checked.autoUpdate
99
+ if (!checked || checked.ok !== true || checked.updateAvailable !== true || !auto || !auto.token) return
100
+ if (typeof confirmImpl === 'function' && confirmImpl(translate('updateConfirm')) !== true) return
101
+ setSelfUpdateState?.({ status: 'running', result: undefined })
102
+ try {
103
+ const response = await fetchImpl('/_dsh/vision-router/self-update', {
104
+ method: 'POST',
105
+ cache: 'no-store',
106
+ headers: { 'x-dsh-vision-router-update-token': auto.token },
107
+ })
108
+ const result = await response.json().catch(() => undefined)
109
+ if (!response.ok) {
110
+ throw new Error(diagnosticError(result && result.error, `HTTP ${response.status}`))
111
+ }
112
+ if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
113
+ throw new Error(translate('updateInvalidResponse'))
114
+ }
115
+ setSelfUpdateState?.({ status: 'done', result })
116
+ } catch (error) {
117
+ setSelfUpdateState?.({
118
+ status: 'error',
119
+ result: { ok: false, error: error && error.message ? error.message : String(error) },
120
+ })
121
+ }
122
+ }
123
+
124
+ const openLogFolder = async () => {
125
+ try {
126
+ const response = await fetchImpl('/_dsh/vision-router/logs', {
127
+ method: 'POST',
128
+ cache: 'no-store',
129
+ })
130
+ const result = await response.json().catch(() => undefined)
131
+ if (!response.ok || !result || result.ok !== true) {
132
+ throw new Error(result && result.error ? result.error : `HTTP ${response.status}`)
133
+ }
134
+ return result
135
+ } catch (error) {
136
+ if (typeof alertImpl === 'function') {
137
+ alertImpl(
138
+ translate('openLogFolderFailed') + ':' +
139
+ (error && error.message ? error.message : String(error)),
140
+ )
141
+ }
142
+ return undefined
143
+ }
144
+ }
145
+
146
+ return {
147
+ runTestConnection,
148
+ requestDesktopScreenshotPermission,
149
+ runUpdateCheck,
150
+ runSelfUpdate,
151
+ openLogFolder,
152
+ }
153
+ }
154
+ // </generated:client-maintenance-actions>
155
+
15
156
  // ── locale dictionaries (follow the app's language setting) ─────────────
16
157
  const NS = 'vision-router'
17
158
  const zh = {
@@ -37,7 +178,8 @@ window.__ModuleLoader__.load({
37
178
  guideStep1Body: '聊天页右下角、输入框旁的模型选择器已经被高亮。选择你平时使用的聊天模型;发图片时请选择带「+ 自动识图」的版本。选好后点击「下一步」。',
38
179
  guideStepNext: '下一步',
39
180
  guidePromptTitle: '第 2 步 · 选择识图模型',
40
- guidePromptGearBody: '点击侧边栏左下角被高亮圈出的「设置」齿轮打开设置面板;也可以直接点「下一步」帮你打开。',
181
+ guidePromptLauncherBody: '点击侧边栏左下角被高亮的设置入口;Desktop 中这里可能显示为账号头像或「更多」。也可以直接点「下一步」继续。',
182
+ guidePromptMenuBody: '在弹出的账号菜单中点击被高亮的「设置」(或点「下一步」自动打开设置面板)。',
41
183
  guidePromptNavBody: '在设置面板左侧点击被高亮的「Vision Router」入口(或点「下一步」自动进入)。我会直接定位到「识图模型」。',
42
184
  guidePromptCancel: '结束引导',
43
185
  guideChainTitle: '第 3 步 · 设置备用模型',
@@ -238,12 +380,9 @@ window.__ModuleLoader__.load({
238
380
  '本地后端不受影响,仍在最前。关闭时保持既有顺序(付费在前、免费补全在后)。',
239
381
  hintDesktopScreenshot: '允许模型按需截取桌面屏幕用于识图。默认关闭;开启后才提供 vision_screenshot 工具。macOS 在保存开启时会立即触发一次屏幕录制授权检查;如果已经授权,系统不会重复弹窗。',
240
382
  hintStealth:
241
- '只作用于官方 DeepSeek 路由。新版 DSH 中官方 provider 始终归 Host 所有,Vision Router 只使用「+ 自动识图」' +
242
- '包装,不再要求你禁用 llm-deepseek;旧版 Host 仍保留官方行缺失时的历史接管兼容。opencode 等自定义路由' +
243
- '默认自动包装,只有要限制范围时才用下方手动包装。',
244
- stealthOfficialDeadHint:
245
- '提示:这是旧版 Host,且官方 DeepSeek 行当前被禁用;Vision Router 正使用兼容性 keep-alive 路由。' +
246
- '如需恢复完全官方的原生行,请在 cordis.patch.yml 中重新启用 llm-deepseek 后重启。',
383
+ '仅保留旧配置字段兼容。DVR 2.3 支持的 DSH 中官方 provider 始终归 Host 所有,Vision Router 只使用' +
384
+ '「+ 自动识图」包装,不再接管或重建 deepseek-official,也不要求你禁用 llm-deepseek。opencode 等' +
385
+ '自定义路由默认自动包装,只有要限制范围时才用下方手动包装。',
247
386
  stealthOfficialHostOwnedHint:
248
387
  '检测到官方 DeepSeek provider 被禁用或不可用,但当前 DSH 已由 Host 负责该路由。Vision Router 不会自行重建它。' +
249
388
  '请重新启用 llm-deepseek,再通过「+ 自动识图」包装 /「👁 识图」处理图片任务。',
@@ -325,7 +464,8 @@ window.__ModuleLoader__.load({
325
464
  guideStep1Body: 'The model selector next to the chat input in the lower-right corner is now highlighted. For image turns, pick a group marked “+ Auto Vision”; the model inside still handles chat, reasoning, and tool calls. Click “Next” when done.',
326
465
  guideStepNext: 'Next',
327
466
  guidePromptTitle: 'Step 2 · Choose the vision model',
328
- guidePromptGearBody: 'Click the highlighted Settings gear at the bottom-left of the sidebar, or press “Next” and I will open it for you.',
467
+ guidePromptLauncherBody: 'Click the highlighted settings launcher at the bottom-left of the sidebar. In Desktop it may appear as your account avatar or the More menu. You can also press “Next”.',
468
+ guidePromptMenuBody: 'In the account menu, click the highlighted “Settings” item (or press “Next” to open Settings automatically).',
329
469
  guidePromptNavBody: 'In Settings, click the highlighted “Vision Router” entry (or press “Next”). I will take you directly to “Vision model”.',
330
470
  guidePromptCancel: 'End guide',
331
471
  guideChainTitle: 'Step 3 · Add fallback models',
@@ -527,13 +667,10 @@ window.__ModuleLoader__.load({
527
667
  'Local backends are unaffected and stay first. When off, the existing order is preserved (paid first, free appended last).',
528
668
  hintDesktopScreenshot: 'Lets the model capture the desktop on demand for image understanding. Off by default; vision_screenshot is exposed only when enabled. On macOS, saving the enabled setting immediately triggers a screen-recording permission check; if permission was already granted, macOS will not prompt again.',
529
669
  hintStealth:
530
- 'Only affects the official DeepSeek route. On newer DSH Hosts the official provider stays Host-owned, so Vision Router ' +
531
- 'uses the “+ Auto Vision” wrapper and never asks you to disable llm-deepseek. Legacy Hosts retain the historical ' +
532
- 'takeover compatibility when the stock row is absent. Custom routes like opencode are auto-wrapped by default; use ' +
533
- 'the manual wrapper section below only when you want to restrict their scope.',
534
- stealthOfficialDeadHint:
535
- 'Notice: on this legacy Host the official DeepSeek row is disabled, so Vision Router is using its compatibility ' +
536
- 'keep-alive route. To restore the fully official route, re-enable llm-deepseek in cordis.patch.yml and restart.',
670
+ 'Retained only for persisted-setting compatibility. On every DSH Host supported by DVR 2.3, the official provider stays ' +
671
+ 'Host-owned: Vision Router uses the “+ Auto Vision” wrapper and never takes over or reconstructs deepseek-official, nor ' +
672
+ 'asks you to disable llm-deepseek. Custom routes like opencode are auto-wrapped by default; use the manual wrapper section ' +
673
+ 'below only when you want to restrict their scope.',
537
674
  stealthOfficialHostOwnedHint:
538
675
  'The official DeepSeek provider is disabled or unavailable, but this DSH Host owns that provider route. Vision Router ' +
539
676
  'will not recreate it. Re-enable llm-deepseek, then use the “+ Auto Vision” wrapper / Vision control for image tasks.',
@@ -1849,15 +1986,49 @@ window.__ModuleLoader__.load({
1849
1986
  }
1850
1987
  return undefined
1851
1988
  }
1852
- // The sidebar settings gear: aria-haspopup="dialog" also appears on the
1853
- // composer's context meter, so exclude anything inside the composer card.
1854
- function guideGearButton() {
1989
+ // Web/older Hosts expose a direct Settings dialog button; Desktop can
1990
+ // replace it with an account launcher. Anchor that shape to sidebar.settings.
1991
+ function guideSidebarSettingsRoot() {
1992
+ if (typeof document === 'undefined') return undefined
1993
+ return document.querySelector('[data-slot="sidebar.settings"]') || undefined
1994
+ }
1995
+ function guideDirectSettingsButton() {
1855
1996
  if (typeof document === 'undefined') return undefined
1997
+ const root = guideSidebarSettingsRoot()
1998
+ const direct = root && root.querySelector('button[aria-haspopup="dialog"]')
1999
+ if (guideElementUsable(direct)) return direct
2000
+ // Older supported Hosts may predate the sidebar.settings wrapper.
1856
2001
  const list = Array.from(document.querySelectorAll('button[aria-haspopup="dialog"]')).filter(
1857
2002
  (el) => !el.closest || !el.closest('[data-composer-card]'),
1858
2003
  )
1859
2004
  return list.find(guideElementUsable)
1860
2005
  }
2006
+ function guideAccountSettingsLauncher() {
2007
+ const root = guideSidebarSettingsRoot()
2008
+ if (!root) return undefined
2009
+ const launcher = root.querySelector('button[aria-haspopup="menu"]')
2010
+ return guideElementUsable(launcher) ? launcher : undefined
2011
+ }
2012
+ function guideSettingsLauncher() {
2013
+ return guideDirectSettingsButton() || guideAccountSettingsLauncher()
2014
+ }
2015
+ function guideSettingsMenuItem() {
2016
+ if (typeof document === 'undefined') return undefined
2017
+ const launcher = guideAccountSettingsLauncher()
2018
+ if (!launcher || launcher.getAttribute('aria-expanded') !== 'true') return undefined
2019
+ const menus = Array.from(document.querySelectorAll('[role="menu"]'))
2020
+ for (let index = menus.length - 1; index >= 0; index--) {
2021
+ const menu = menus[index]
2022
+ if (!guideElementUsable(menu)) continue
2023
+ const rows = Array.from(menu.querySelectorAll('button[role="menuitem"]'))
2024
+ const match = rows.find((row) => {
2025
+ const label = (row.textContent || '').trim()
2026
+ return label.startsWith('设置') || label.startsWith('Settings')
2027
+ })
2028
+ if (guideElementUsable(match)) return match
2029
+ }
2030
+ return undefined
2031
+ }
1861
2032
  // The walkthrough has one canonical destination: the first-class
1862
2033
  // Vision Router settings section registered below.
1863
2034
  function guideVisionRouterNav() {
@@ -1878,13 +2049,20 @@ window.__ModuleLoader__.load({
1878
2049
  settingsPanel: guideSettingsPanel,
1879
2050
  settingsOpen: guideSettingsPanelOpen,
1880
2051
  chatModelTarget: () => guideFindFirstUsable(GUIDE_SELECTOR_TARGETS),
1881
- settingsButton: guideGearButton,
2052
+ settingsLauncher: guideSettingsLauncher,
2053
+ settingsMenuItem: guideSettingsMenuItem,
1882
2054
  visionRouterNav: guideVisionRouterNav,
1883
2055
  closeSettings: closeSettingsShell,
1884
- openSettings() {
1885
- const gear = guideGearButton()
1886
- if (!gear || typeof gear.click !== 'function') return false
1887
- gear.click()
2056
+ advanceSettingsLauncher() {
2057
+ const launcher = guideSettingsLauncher()
2058
+ if (!launcher || typeof launcher.click !== 'function') return false
2059
+ launcher.click()
2060
+ return true
2061
+ },
2062
+ openSettingsFromMenu() {
2063
+ const item = guideSettingsMenuItem()
2064
+ if (!item || typeof item.click !== 'function') return false
2065
+ item.click()
1888
2066
  return true
1889
2067
  },
1890
2068
  openVisionRouter() {
@@ -1905,15 +2083,17 @@ window.__ModuleLoader__.load({
1905
2083
  return guideHostUi.chatModelTarget() ? 'selector' : 'corner'
1906
2084
  }
1907
2085
  if (guideSessionState === GUIDE_STATE.VISION_MODEL) return 'done'
1908
- if (typeof document === 'undefined') return 'gear'
2086
+ if (typeof document === 'undefined') return 'launcher'
1909
2087
  if (document.querySelector('[data-vr-guide-target="vision-backend"]')) return 'done'
1910
2088
  if (guideHostUi.settingsPanel()) return 'nav'
1911
- return 'gear'
2089
+ if (guideHostUi.settingsMenuItem()) return 'menu'
2090
+ return 'launcher'
1912
2091
  }
1913
2092
  function guideTarget(step, phase) {
1914
2093
  if (step === 'step1') return guideHostUi.chatModelTarget()
1915
2094
  if (phase === 'nav') return guideHostUi.visionRouterNav()
1916
- return guideHostUi.settingsButton()
2095
+ if (phase === 'menu') return guideHostUi.settingsMenuItem()
2096
+ return guideHostUi.settingsLauncher()
1917
2097
  }
1918
2098
  function ensureGuideSpotlight() {
1919
2099
  if (visionGuideSpotlight) return visionGuideSpotlight
@@ -1954,7 +2134,11 @@ window.__ModuleLoader__.load({
1954
2134
  if (step === 'step1') return { title: t('guideStep1Title'), body: t('guideStep1Body') }
1955
2135
  return {
1956
2136
  title: t('guidePromptTitle'),
1957
- body: phase === 'nav' ? t('guidePromptNavBody') : t('guidePromptGearBody'),
2137
+ body: phase === 'nav'
2138
+ ? t('guidePromptNavBody')
2139
+ : phase === 'menu'
2140
+ ? t('guidePromptMenuBody')
2141
+ : t('guidePromptLauncherBody'),
1958
2142
  }
1959
2143
  }
1960
2144
  function buildGuidePrompt(t, step, phase) {
@@ -1988,12 +2172,19 @@ window.__ModuleLoader__.load({
1988
2172
  next.type = 'button'
1989
2173
  next.className = 'vr-btn vr-btn-save'
1990
2174
  next.textContent = t('guideStepNext')
1991
- next.addEventListener('click', () => {
2175
+ const keepSettingsMenuOpen = (event) => {
2176
+ if (step !== 'step2' || prompt.dataset.vrPhase !== 'menu') return
2177
+ event.preventDefault(); event.stopPropagation()
2178
+ }
2179
+ next.addEventListener('pointerdown', keepSettingsMenuOpen)
2180
+ next.addEventListener('click', (event) => {
1992
2181
  if (step === 'step1') {
1993
2182
  dispatchGuide({ type: GUIDE_EVENT.NEXT }, t)
1994
2183
  } else {
1995
- const currentPhase = guidePhase('step2')
1996
- if (currentPhase === 'gear') guideHostUi.openSettings()
2184
+ const currentPhase = prompt.dataset.vrPhase || guidePhase('step2')
2185
+ if (currentPhase === 'menu') keepSettingsMenuOpen(event)
2186
+ if (currentPhase === 'launcher') guideHostUi.advanceSettingsLauncher()
2187
+ else if (currentPhase === 'menu') guideHostUi.openSettingsFromMenu()
1997
2188
  else if (currentPhase === 'nav') guideHostUi.openVisionRouter()
1998
2189
  }
1999
2190
  clearGuidePromptUI()
@@ -2055,9 +2246,8 @@ window.__ModuleLoader__.load({
2055
2246
  if (arrow) arrow.classList.add(step === 'step1' ? 'vr-guide-arrow-corner-se' : 'vr-guide-arrow-corner-sw')
2056
2247
  return
2057
2248
  }
2058
- // The gear sits at the bottom-left: land above it first. Everywhere
2059
- // else prefer the side so the card reads naturally left-to-right.
2060
- const prefer = phase === 'gear' ? ['top', 'right', 'bottom', 'left'] : ['right', 'top', 'bottom', 'left']
2249
+ // The bottom-left Settings launcher reads best with the prompt above it.
2250
+ const prefer = phase === 'launcher' ? ['top', 'right', 'bottom', 'left'] : ['right', 'top', 'bottom', 'left']
2061
2251
  const clampX = (value) => Math.min(Math.max(value, margin), Math.max(margin, vw - pw - margin))
2062
2252
  const clampY = (value) => Math.min(Math.max(value, margin), Math.max(margin, vh - ph - margin))
2063
2253
  // Keep a small halo around the target clear so the prompt never sits
@@ -3089,91 +3279,24 @@ window.__ModuleLoader__.load({
3089
3279
  }
3090
3280
  }
3091
3281
 
3092
- const runTestConnection = async () => {
3093
- if (testState.status === 'running') return
3094
- setTestState({ status: 'running' })
3095
- try {
3096
- const response = await fetch('/_dsh/vision-router/test-connection')
3097
- const result = await response.json().catch(() => undefined)
3098
- setTestState({ status: 'done', result })
3099
- } catch (error) {
3100
- setTestState({ status: 'done', result: { ok: false, error: error && error.message ? error.message : String(error) } })
3101
- }
3102
- }
3103
-
3104
- const requestDesktopScreenshotPermission = () => {
3105
- // Fire-and-forget: saving the setting must stay responsive while macOS
3106
- // owns the native permission dialog. The tool itself still reports a
3107
- // real capture failure if the user denies access.
3108
- void fetch('/_dsh/vision-router/request-screenshot-permission', {
3109
- method: 'POST',
3110
- cache: 'no-store',
3111
- }).catch(() => {})
3112
- }
3113
-
3114
- const diagnosticError = (value, fallback) => {
3115
- if (typeof value === 'string' && value.trim() !== '') return value.trim()
3116
- if (value && typeof value.message === 'string' && value.message.trim() !== '') {
3117
- return value.message.trim()
3118
- }
3119
- return fallback
3120
- }
3121
-
3122
- const runUpdateCheck = async (force = false) => {
3123
- if (updateState.status === 'running') return
3124
- setUpdateState({ status: 'running', result: updateState.result })
3125
- try {
3126
- const response = await fetch(
3127
- '/_dsh/vision-router/update-check' + (force ? '?force=1' : ''),
3128
- { cache: 'no-store' },
3129
- )
3130
- const result = await response.json().catch(() => undefined)
3131
- if (!response.ok) {
3132
- throw new Error(diagnosticError(result && result.error, `HTTP ${response.status}`))
3133
- }
3134
- if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
3135
- throw new Error(t('updateInvalidResponse'))
3136
- }
3137
- setUpdateState({ status: 'done', result })
3138
- } catch (error) {
3139
- setUpdateState({
3140
- status: 'done',
3141
- result: {
3142
- ok: false,
3143
- error: error && error.message ? error.message : String(error),
3144
- },
3145
- })
3146
- }
3147
- }
3148
-
3149
- const runSelfUpdate = async () => {
3150
- if (selfUpdateState.status === 'running') return
3151
- const checked = updateState.result
3152
- const auto = checked && checked.autoUpdate
3153
- if (!checked || checked.ok !== true || checked.updateAvailable !== true || !auto || !auto.token) return
3154
- if (typeof window.confirm === 'function' && !window.confirm(t('updateConfirm'))) return
3155
- setSelfUpdateState({ status: 'running', result: undefined })
3156
- try {
3157
- const response = await fetch('/_dsh/vision-router/self-update', {
3158
- method: 'POST',
3159
- cache: 'no-store',
3160
- headers: { 'x-dsh-vision-router-update-token': auto.token },
3161
- })
3162
- const result = await response.json().catch(() => undefined)
3163
- if (!response.ok) {
3164
- throw new Error(diagnosticError(result && result.error, `HTTP ${response.status}`))
3165
- }
3166
- if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
3167
- throw new Error(t('updateInvalidResponse'))
3168
- }
3169
- setSelfUpdateState({ status: 'done', result })
3170
- } catch (error) {
3171
- setSelfUpdateState({
3172
- status: 'error',
3173
- result: { ok: false, error: error && error.message ? error.message : String(error) },
3174
- })
3175
- }
3176
- }
3282
+ const {
3283
+ runTestConnection,
3284
+ requestDesktopScreenshotPermission,
3285
+ runUpdateCheck,
3286
+ runSelfUpdate,
3287
+ openLogFolder,
3288
+ } = createClientMaintenanceActions({
3289
+ fetchImpl: (...args) => fetch(...args),
3290
+ t,
3291
+ getTestState: () => testState,
3292
+ setTestState,
3293
+ getUpdateState: () => updateState,
3294
+ setUpdateState,
3295
+ getSelfUpdateState: () => selfUpdateState,
3296
+ setSelfUpdateState,
3297
+ confirmImpl: typeof window.confirm === 'function' ? window.confirm.bind(window) : undefined,
3298
+ alertImpl: typeof window.alert === 'function' ? window.alert.bind(window) : undefined,
3299
+ })
3177
3300
 
3178
3301
  const overriddenBadge = (key) =>
3179
3302
  userHas(snapshot, key)
@@ -3898,8 +4021,8 @@ window.__ModuleLoader__.load({
3898
4021
  // fetched (including after a remount that reset the state), start the
3899
4022
  // fetch. React supports setState during render for the same component,
3900
4023
  // and the status flips to 'loading' so this cannot loop. The same kick
3901
- // runs the cheap test-connection probe once so the stealth keep-alive
3902
- // notice (official row disabled) can render without a button click.
4024
+ // runs the cheap test-connection probe once so the Host-owned official
4025
+ // provider availability notice can render without a button click.
3903
4026
  if (open && catalog.status === 'idle') {
3904
4027
  loadCatalog()
3905
4028
  }
@@ -4055,11 +4178,8 @@ window.__ModuleLoader__.load({
4055
4178
  if (testState.status !== 'done') return null
4056
4179
  const state = testState.result && testState.result.stealth
4057
4180
  if (!state) return null
4058
- if (state.reason === 'host-owned-official-unavailable') {
4059
- return h('p', { className: 'vr-hint vr-stealth-notice' }, t('stealthOfficialHostOwnedHint'))
4060
- }
4061
- if (state.active !== true || state.reason !== 'official-unavailable') return null
4062
- return h('p', { className: 'vr-hint vr-stealth-notice' }, t('stealthOfficialDeadHint'))
4181
+ if (state.reason !== 'host-owned-official-unavailable') return null
4182
+ return h('p', { className: 'vr-hint vr-stealth-notice' }, t('stealthOfficialHostOwnedHint'))
4063
4183
  }
4064
4184
 
4065
4185
  return h('li', { className: 'vr-card' + (open ? ' vr-card-open' : '') },
@@ -4272,25 +4392,7 @@ window.__ModuleLoader__.load({
4272
4392
  ) : null, h('div', { className: 'vr-footer' },
4273
4393
  h('button', {
4274
4394
  type: 'button', className: 'vr-btn',
4275
- onClick: async () => {
4276
- try {
4277
- const response = await fetch('/_dsh/vision-router/logs', {
4278
- method: 'POST',
4279
- cache: 'no-store',
4280
- })
4281
- const result = await response.json().catch(() => undefined)
4282
- if (!response.ok || !result || result.ok !== true) {
4283
- throw new Error(result && result.error ? result.error : `HTTP ${response.status}`)
4284
- }
4285
- } catch (error) {
4286
- if (typeof window.alert === 'function') {
4287
- window.alert(
4288
- t('openLogFolderFailed') + ':' +
4289
- (error && error.message ? error.message : String(error)),
4290
- )
4291
- }
4292
- }
4293
- },
4395
+ onClick: openLogFolder,
4294
4396
  }, t('openLogFolder')),
4295
4397
  failed ? h('p', { className: 'vr-failed', role: 'alert' },
4296
4398
  t('saveFailed') + (failedFields.length > 0 ? `(${failedFields.join('、')})` : '')) : null,
@@ -0,0 +1,41 @@
1
+ export function runCoreApplyLifecycle(invoke, { onSuccess, onFailure } = {}) {
2
+ if (typeof invoke !== 'function')
3
+ throw new TypeError('Core apply lifecycle requires an invoke function');
4
+ const succeed = typeof onSuccess === 'function' ? onSuccess : () => { };
5
+ const fail = (error) => {
6
+ try {
7
+ onFailure?.(error);
8
+ }
9
+ catch {
10
+ // Cleanup/diagnostic failure must not replace the Core apply error.
11
+ }
12
+ throw error;
13
+ };
14
+ let result;
15
+ try {
16
+ result = invoke();
17
+ }
18
+ catch (error) {
19
+ return fail(error);
20
+ }
21
+ if (result !== null
22
+ && (typeof result === 'object' || typeof result === 'function')
23
+ && typeof result.then === 'function') {
24
+ return Promise.resolve(result).then((value) => {
25
+ try {
26
+ succeed();
27
+ return value;
28
+ }
29
+ catch (error) {
30
+ return fail(error);
31
+ }
32
+ }, fail);
33
+ }
34
+ try {
35
+ succeed();
36
+ return result;
37
+ }
38
+ catch (error) {
39
+ return fail(error);
40
+ }
41
+ }
@@ -0,0 +1,87 @@
1
+ export function cacheWeight(value) {
2
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array)
3
+ return value.byteLength;
4
+ if (typeof value === 'string')
5
+ return Buffer.byteLength(value, 'utf8');
6
+ try {
7
+ const encoded = JSON.stringify(value);
8
+ return Buffer.byteLength(encoded === undefined ? String(value) : encoded, 'utf8');
9
+ }
10
+ catch {
11
+ return Buffer.byteLength(String(value), 'utf8');
12
+ }
13
+ }
14
+ /**
15
+ * LRU+TTL cache bounded by both entry count and retained bytes.
16
+ *
17
+ * Keys are normalized to strings at the ownership boundary. A rejected write
18
+ * never remains resident, and every eviction updates retained-byte accounting
19
+ * before the entry is released.
20
+ */
21
+ export function createCache(maxEntries, ttlMsValue, options = {}) {
22
+ const entries = new Map();
23
+ const entryLimit = Math.max(0, Math.floor(Number(maxEntries) || 0));
24
+ const ttlMs = ttlMsValue;
25
+ const maxBytes = Number.isFinite(Number(options.maxBytes)) && Number(options.maxBytes) >= 0
26
+ ? Math.floor(Number(options.maxBytes))
27
+ : 8 * 1024 * 1024;
28
+ const maxEntryBytes = Number.isFinite(Number(options.maxEntryBytes)) && Number(options.maxEntryBytes) >= 0
29
+ ? Math.floor(Number(options.maxEntryBytes))
30
+ : Math.min(maxBytes, 1024 * 1024);
31
+ let retainedBytes = 0;
32
+ const remove = (key) => {
33
+ const entry = entries.get(key);
34
+ if (entry === undefined)
35
+ return;
36
+ retainedBytes = Math.max(0, retainedBytes - entry.weight);
37
+ entries.delete(key);
38
+ };
39
+ const evict = () => {
40
+ while (entries.size > entryLimit || retainedBytes > maxBytes) {
41
+ const oldest = entries.keys().next().value;
42
+ if (oldest === undefined)
43
+ break;
44
+ remove(oldest);
45
+ }
46
+ };
47
+ return {
48
+ get(key) {
49
+ const rawKey = key;
50
+ const entry = entries.get(rawKey);
51
+ if (entry === undefined)
52
+ return undefined;
53
+ if (entry.expiresAt <= Date.now()) {
54
+ remove(rawKey);
55
+ return undefined;
56
+ }
57
+ entries.delete(rawKey);
58
+ entries.set(rawKey, entry);
59
+ return entry.value;
60
+ },
61
+ set(key, value) {
62
+ const normalizedKey = String(key);
63
+ const weight = Buffer.byteLength(normalizedKey, 'utf8') + cacheWeight(value);
64
+ remove(normalizedKey);
65
+ if (entryLimit === 0
66
+ || maxBytes === 0
67
+ || weight > maxEntryBytes
68
+ || weight > maxBytes) {
69
+ return false;
70
+ }
71
+ entries.set(normalizedKey, {
72
+ value,
73
+ weight,
74
+ expiresAt: ttlMs <= 0 ? Infinity : Date.now() + ttlMs,
75
+ });
76
+ retainedBytes += weight;
77
+ evict();
78
+ return entries.has(normalizedKey);
79
+ },
80
+ get size() {
81
+ return entries.size;
82
+ },
83
+ get bytes() {
84
+ return retainedBytes;
85
+ },
86
+ };
87
+ }