@anionex/dsh-vision-toolkit 0.1.31 → 0.1.33

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 (58) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +55 -162
  3. package/README.zh.md +60 -170
  4. package/assets/upstream/README.md +4 -2
  5. package/assets/upstream/focus-hint-comparison-1.webp +0 -0
  6. package/assets/upstream/focus-hint-comparison-2.webp +0 -0
  7. package/assets/upstream/ui-fast-restore-reference.webp +0 -0
  8. package/assets/upstream/ui-fast-restore-result.webp +0 -0
  9. package/docs/python-runtime.i18n.yaml +6 -0
  10. package/docs/python-runtime.md +75 -0
  11. package/docs/python-runtime.zh.md +75 -0
  12. package/lib/client.js +270 -4
  13. package/lib/client.js.map +1 -1
  14. package/lib/config.js +2 -0
  15. package/lib/config.js.map +1 -1
  16. package/lib/image-input-variants.js +89 -26
  17. package/lib/image-input-variants.js.map +1 -1
  18. package/lib/index.js +1 -1
  19. package/lib/index.js.map +1 -1
  20. package/lib/plugin-update.js +8 -1
  21. package/lib/plugin-update.js.map +1 -1
  22. package/lib/runtime-manager.js +10 -3
  23. package/lib/runtime-manager.js.map +1 -1
  24. package/lib/runtime.js +12 -9
  25. package/lib/runtime.js.map +1 -1
  26. package/lib/types/client/display-config.d.ts +24 -0
  27. package/lib/types/client/display-config.d.ts.map +1 -0
  28. package/lib/types/client/index.d.ts +10 -0
  29. package/lib/types/client/index.d.ts.map +1 -1
  30. package/lib/types/client/model-variants-hider.d.ts +40 -0
  31. package/lib/types/client/model-variants-hider.d.ts.map +1 -0
  32. package/lib/types/client/paste-images.d.ts.map +1 -1
  33. package/lib/types/config.d.ts +10 -0
  34. package/lib/types/config.d.ts.map +1 -1
  35. package/lib/types/image-input-variants.d.ts +2 -1
  36. package/lib/types/image-input-variants.d.ts.map +1 -1
  37. package/lib/types/index.d.ts.map +1 -1
  38. package/lib/types/plugin-update.d.ts.map +1 -1
  39. package/lib/types/runtime-manager.d.ts.map +1 -1
  40. package/lib/types/runtime.d.ts.map +1 -1
  41. package/lib/types/web.d.ts +17 -1
  42. package/lib/types/web.d.ts.map +1 -1
  43. package/lib/web.js +36 -1
  44. package/lib/web.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/client/display-config.ts +62 -0
  47. package/src/client/index.tsx +44 -2
  48. package/src/client/model-variants-hider.ts +159 -0
  49. package/src/client/paste-images.tsx +5 -1
  50. package/src/config.ts +12 -0
  51. package/src/image-input-variants.ts +90 -24
  52. package/src/index.ts +1 -0
  53. package/src/plugin-update.ts +8 -1
  54. package/src/runtime-manager.ts +10 -2
  55. package/src/runtime.ts +12 -9
  56. package/src/web.ts +39 -0
  57. package/assets/upstream/image-qa.webp +0 -0
  58. package/assets/upstream/screenshot-debugging.webp +0 -0
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Transparent routing for the host model selector: when `imageInputVariants.hidden`
3
+ * is enabled, variant routes keep the upstream provider/model display names and
4
+ * the browser hides the upstream text-only entries that have a variant twin.
5
+ * Users then see one entry per model — the original name — while the session
6
+ * actually runs on the image-capable variant, so pasted images, history with
7
+ * images, and the built-in `read_image` tool all keep working on text-only
8
+ * models without exposing `(Vision Toolkit)` routes.
9
+ *
10
+ * The host selector renders one `[role=group]` per provider whose group title
11
+ * id is `:<react-radix>:-<providerId>`, and one `[role=menuitemradio]` per
12
+ * model. We key groups by that provider id (variant routes carry the
13
+ * `vision-toolkit-` prefix) and hide every upstream entry whose display name
14
+ * matches a variant twin, collapsing fully-hidden upstream groups.
15
+ *
16
+ * The hiding decision is purely DOM-local: transparent mode is exactly the
17
+ * case where a variant twin keeps the upstream display name, while explicit
18
+ * mode appends `(Vision Toolkit)` and therefore never matches. No display-config
19
+ * round-trip is needed before the selector can be tidied, so the first paint
20
+ * of an opened menu already shows the merged list instead of flashing the
21
+ * duplicate upstream group.
22
+ * @module dsh-vision-toolkit/model-variants-hider
23
+ */
24
+
25
+ const VARIANT_PROVIDER_PREFIX = 'vision-toolkit-'
26
+
27
+ /** Elements we hid and their original inline display value, for restoration. */
28
+ const hiddenElements = new Map<HTMLElement, string>()
29
+
30
+ let active = false
31
+ let observer: MutationObserver | undefined
32
+ let tidyQueued = false
33
+
34
+ /** Derive the provider id from a group's `aria-labelledby` title id. */
35
+ function providerIdOf(group: Element): string | undefined {
36
+ const labelledBy = group.getAttribute('aria-labelledby')
37
+ if (labelledBy === null || labelledBy === '') return undefined
38
+ const titleId = document.getElementById(labelledBy)?.id ?? labelledBy
39
+ const reactPrefixed = /^:[^:]+:-(.+)$/u.exec(titleId)
40
+ if (reactPrefixed !== null) return reactPrefixed[1]
41
+ return titleId.replace(/^-/u, '')
42
+ }
43
+
44
+ function modelNames(group: Element): string[] {
45
+ return [...group.querySelectorAll<HTMLElement>('[role="menuitemradio"]')]
46
+ .map(button => (button.title || (button.textContent ?? '')).trim())
47
+ .filter(Boolean)
48
+ }
49
+
50
+ function hideElement(element: HTMLElement): void {
51
+ if (!hiddenElements.has(element)) hiddenElements.set(element, element.style.display)
52
+ element.style.display = 'none'
53
+ }
54
+
55
+ function restoreHidden(): void {
56
+ for (const [element, display] of hiddenElements) {
57
+ element.style.display = display
58
+ }
59
+ hiddenElements.clear()
60
+ }
61
+
62
+ /**
63
+ * Hide upstream text-only entries that have a variant twin. Group keys come
64
+ * from `aria-labelledby` ids so provider identity is reliable even when the
65
+ * variant provider name equals the upstream name (transparent mode).
66
+ */
67
+ export function tidyModelSelector(): void {
68
+ if (document.querySelector('[role="menu"]') === null) return
69
+ // The host re-renders selectors while sessions stay open; drop bookkeeping
70
+ // for entries that already left the DOM so the map cannot grow unboundedly.
71
+ for (const element of [...hiddenElements.keys()]) {
72
+ if (!element.isConnected) hiddenElements.delete(element)
73
+ }
74
+ const groups = [...document.querySelectorAll<HTMLElement>('[role="menu"] [role="group"]')]
75
+ const byProvider = new Map<string, HTMLElement[]>()
76
+ for (const group of groups) {
77
+ const provider = providerIdOf(group)
78
+ if (provider === undefined) continue
79
+ const entries = byProvider.get(provider)
80
+ if (entries === undefined) byProvider.set(provider, [group])
81
+ else entries.push(group)
82
+ }
83
+
84
+ const shouldHide = new Set<HTMLElement>()
85
+ for (const [provider, providerGroups] of byProvider) {
86
+ if (!provider.startsWith(VARIANT_PROVIDER_PREFIX)) continue
87
+ const upstream = provider.slice(VARIANT_PROVIDER_PREFIX.length)
88
+ const twinNames = new Set(providerGroups.flatMap(modelNames))
89
+ if (twinNames.size === 0) continue
90
+ for (const upstreamGroup of byProvider.get(upstream) ?? []) {
91
+ const buttons = [...upstreamGroup.querySelectorAll<HTMLElement>('[role="menuitemradio"]')]
92
+ const matched: HTMLElement[] = []
93
+ for (const button of buttons) {
94
+ const name = (button.title || (button.textContent ?? '')).trim()
95
+ if (twinNames.has(name)) matched.push(button)
96
+ }
97
+ if (matched.length === 0) continue
98
+ for (const button of matched) shouldHide.add(button)
99
+ // Collapse the whole group only when every model has a variant twin;
100
+ // otherwise a partially matched group keeps its unmatched entries.
101
+ if (matched.length === buttons.length) shouldHide.add(upstreamGroup)
102
+ }
103
+ }
104
+
105
+ // Restore entries whose twin disappeared (e.g. transparent routing was
106
+ // disabled and the wrapper rebuilt with explicit `(Vision Toolkit)` names).
107
+ for (const [element, display] of [...hiddenElements]) {
108
+ if (!shouldHide.has(element)) {
109
+ element.style.display = display
110
+ hiddenElements.delete(element)
111
+ }
112
+ }
113
+ for (const element of shouldHide) hideElement(element)
114
+ }
115
+
116
+ /**
117
+ * Install the transparent-routing integrator. It watches the document for
118
+ * model-selector renderings and re-tidies them whenever the host re-renders.
119
+ * Tidy runs in a microtask (before the browser paints) and is coalesced across
120
+ * the render batch, so opening the selector never shows the upstream twins.
121
+ * @returns the disposer that stops observation and restores hidden entries.
122
+ */
123
+ export function installModelVariantsHider(): () => void {
124
+ if (observer !== undefined) {
125
+ // A previous install is still active; a duplicate effect must not tear
126
+ // down the integrator while its original owner still expects it.
127
+ return () => {}
128
+ }
129
+ let disposed = false
130
+ const tidySoon = (): void => {
131
+ if (tidyQueued || disposed) return
132
+ tidyQueued = true
133
+ queueMicrotask(() => {
134
+ tidyQueued = false
135
+ if (disposed) return
136
+ active = true
137
+ tidyModelSelector()
138
+ })
139
+ }
140
+
141
+ observer = new MutationObserver(tidySoon)
142
+ observer.observe(document.body, { childList: true, subtree: true })
143
+ active = true
144
+ tidyModelSelector()
145
+
146
+ return () => {
147
+ disposed = true
148
+ observer?.disconnect()
149
+ observer = undefined
150
+ tidyQueued = false
151
+ restoreHidden()
152
+ active = false
153
+ }
154
+ }
155
+
156
+ /** Test seam: expose whether the integrator is currently installed. */
157
+ export function isModelVariantsHiderActive(): boolean {
158
+ return active
159
+ }
@@ -5,6 +5,7 @@ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
5
5
  import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
6
6
  import type { InputTriggerSource } from '@deepseek-ai/dsh-client-ui-input-trigger/client'
7
7
  import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
8
+ import { readDisplayConfig } from './display-config.ts'
8
9
 
9
10
  const SOURCE = 'vision-toolkit-pasted-image'
10
11
  export const PASTE_IMAGES_ROUTE = '/_dsh/vision-toolkit/paste-images'
@@ -488,7 +489,10 @@ export class PasteImageController {
488
489
  const input = this.inputFor(sessionId)
489
490
  try {
490
491
  await this.switchModel(sessionId, route)
491
- input.notify('info', `Switched to ${route.label || `${route.model} (Vision Toolkit)`}; pasted images now keep the native attachment flow`)
492
+ const { hidden } = await readDisplayConfig()
493
+ input.notify('info', hidden
494
+ ? 'Visual enhancement active: pasted images keep the native attachment flow'
495
+ : `Switched to ${route.label || `${route.model} (Vision Toolkit)`}; pasted images now keep the native attachment flow`)
492
496
  } catch (error) {
493
497
  input.notify('error', `Model switch failed; images will be sent as workspace paths: ${message(error)}`)
494
498
  this.takeoverPaste(sessionId, target, files, text)
package/src/config.ts CHANGED
@@ -100,6 +100,15 @@ export interface VisionToolkitConfig {
100
100
  * keeps the path-only takeover instead (default true).
101
101
  */
102
102
  autoSwitch?: boolean
103
+ /**
104
+ * Transparent routing: variant routes keep the upstream provider and model
105
+ * display names, and the browser integration hides the upstream text-only
106
+ * entries that have a variant twin, so the model selector shows one entry
107
+ * per model and sessions stay on the image-capable variant without users
108
+ * seeing or switching a `(Vision Toolkit)` route. Off (default) keeps the
109
+ * explicit sibling entries and the current behavior.
110
+ */
111
+ hidden?: boolean
103
112
  }
104
113
  }
105
114
 
@@ -128,6 +137,7 @@ export const Config: Schema<VisionToolkitConfig> = z.object({
128
137
  enabled: z.boolean().default(true),
129
138
  providers: z.array(z.string()).default([]),
130
139
  autoSwitch: z.boolean().default(true),
140
+ hidden: z.boolean().default(false),
131
141
  }),
132
142
  })
133
143
 
@@ -156,6 +166,7 @@ export interface ResolvedVisionToolkitConfig {
156
166
  enabled: boolean
157
167
  providers: string[]
158
168
  autoSwitch: boolean
169
+ hidden: boolean
159
170
  }
160
171
  }
161
172
 
@@ -265,6 +276,7 @@ export function resolveConfig(config: VisionToolkitConfig = {}): ResolvedVisionT
265
276
  enabled: imageInputVariants.enabled ?? true,
266
277
  providers: variantProviders,
267
278
  autoSwitch: imageInputVariants.autoSwitch ?? true,
279
+ hidden: imageInputVariants.hidden ?? false,
268
280
  },
269
281
  }
270
282
  }
@@ -41,6 +41,12 @@ export const VARIANT_SUFFIX = ' (Vision Toolkit)'
41
41
  /** Promise-cache bound for image descriptions, so a long-lived Web profile cannot hoard evidence text. */
42
42
  const EVIDENCE_CACHE_LIMIT = 64
43
43
 
44
+ /** Periodic self-heal sweep for image-input variant routes. */
45
+ const VARIANT_SWEEP_INTERVAL_MS = 10_000
46
+
47
+ /** How long an upstream must stay absent before its wrapper is released. */
48
+ const VARIANT_RELEASE_GRACE_MS = 10_000
49
+
44
50
  /**
45
51
  * Media types the Vision Toolkit glance pipeline accepts, by declared media
46
52
  * type. Narrower than the paste-to-workspace route (which stores any image
@@ -540,12 +546,16 @@ export class ImageInputVariantAdapter extends LlmAdapter {
540
546
  private readonly upstreamName: string,
541
547
  private readonly runtime: () => VisionToolkitRuntime | undefined,
542
548
  private readonly cache: EvidenceCache,
549
+ private readonly hidden: () => boolean = () => false,
543
550
  ) {
544
551
  super()
545
552
  }
546
553
 
547
554
  override providerInfo(provider: string): LlmProviderInfo {
548
- return { id: provider, name: `${this.upstreamName}${VARIANT_SUFFIX}` }
555
+ return {
556
+ id: provider,
557
+ name: this.hidden() ? this.upstreamName : `${this.upstreamName}${VARIANT_SUFFIX}`,
558
+ }
549
559
  }
550
560
 
551
561
  override async listModels(provider: string): Promise<readonly LlmModelInfo[]> {
@@ -553,7 +563,7 @@ export class ImageInputVariantAdapter extends LlmAdapter {
553
563
  return models.filter(shouldWrapModel).map((model) => ({
554
564
  provider,
555
565
  id: model.id,
556
- name: `${model.name}${VARIANT_SUFFIX}`,
566
+ name: this.hidden() ? model.name : `${model.name}${VARIANT_SUFFIX}`,
557
567
  inputModalities: ['text', 'image'],
558
568
  ...(model.description === undefined ? {} : { description: model.description }),
559
569
  }))
@@ -571,7 +581,7 @@ export class ImageInputVariantAdapter extends LlmAdapter {
571
581
  return {
572
582
  provider,
573
583
  id: model,
574
- name: `${info.name}${VARIANT_SUFFIX}`,
584
+ name: this.hidden() ? info.name : `${info.name}${VARIANT_SUFFIX}`,
575
585
  inputModalities: ['text', 'image'],
576
586
  ...(info.description === undefined ? {} : { description: info.description }),
577
587
  // Capability and call-default metadata rides through unchanged: the
@@ -835,6 +845,16 @@ export function installImageInputVariants(
835
845
  getRuntime: () => VisionToolkitRuntime | undefined,
836
846
  ): { dispose: () => void; reconcile: () => void } {
837
847
  const registrations = new Map<string, () => void>()
848
+ // The host snapshots adapter provider metadata (including the group display
849
+ // name) at registration time, so a transparent-routing toggle must rebuild
850
+ // every wrapper for the new names to reach the model selector.
851
+ let lastHidden = false
852
+ // Upstream ids observed missing and the timestamp of the first missing
853
+ // observation. A wrapper is only released after its upstream stays absent
854
+ // for the full grace period, so a transient registry gap (adapter
855
+ // re-registration, HMR reload) cannot drop a wrapper that a live session is
856
+ // still pointed at, no matter how many sweeps fire in between.
857
+ const stale = new Map<string, number>()
838
858
  let disposed = false
839
859
  // Serialize sweeps: a registration itself announces llm/adapters-updated,
840
860
  // and two interleaved sweeps must never probe the same route concurrently.
@@ -843,13 +863,29 @@ export function installImageInputVariants(
843
863
  // not one per notification (a single registration emits a notification).
844
864
  let sweepQueued = false
845
865
 
866
+ const release = (upstream: string): void => {
867
+ const dispose = registrations.get(upstream)
868
+ if (dispose === undefined) return
869
+ registrations.delete(upstream)
870
+ stale.delete(upstream)
871
+ try {
872
+ dispose()
873
+ } catch (error) {
874
+ ctx.logger.warn(
875
+ 'dsh-vision-toolkit: image-input variant release failed for "%s": %s',
876
+ upstream,
877
+ messageOf(error),
878
+ )
879
+ }
880
+ }
881
+
846
882
  const releaseAll = (): void => {
847
- for (const dispose of [...registrations.values()]) dispose()
848
- registrations.clear()
883
+ stale.clear()
884
+ for (const upstream of [...registrations.keys()]) release(upstream)
849
885
  }
850
886
 
851
887
  const sweep = (): void => {
852
- if (sweepQueued) return
888
+ if (disposed || sweepQueued) return
853
889
  sweepQueued = true
854
890
  queueMicrotask(() => {
855
891
  sweepQueued = false
@@ -861,6 +897,10 @@ export function installImageInputVariants(
861
897
  if (disposed) return
862
898
  try {
863
899
  const variants = getConfig().imageInputVariants
900
+ if (variants.hidden !== lastHidden) {
901
+ lastHidden = variants.hidden
902
+ releaseAll()
903
+ }
864
904
  if (!variants.enabled) {
865
905
  releaseAll()
866
906
  return
@@ -874,12 +914,33 @@ export function installImageInputVariants(
874
914
  } catch {
875
915
  return
876
916
  }
917
+ const live = new Set(providers.map(provider => provider.id))
918
+ // Release restrict-narrowed routes immediately (a configuration change is
919
+ // authoritative). For routes merely missing from the live registry, keep
920
+ // the wrapper until the absence outlives the grace period.
921
+ for (const upstream of [...registrations.keys()]) {
922
+ if (restrict.size > 0 && !restrict.has(upstream)) {
923
+ release(upstream)
924
+ continue
925
+ }
926
+ if (live.has(upstream)) {
927
+ stale.delete(upstream)
928
+ continue
929
+ }
930
+ const missedAt = stale.get(upstream)
931
+ if (missedAt !== undefined && Date.now() - missedAt >= VARIANT_RELEASE_GRACE_MS) {
932
+ release(upstream)
933
+ } else if (missedAt === undefined) {
934
+ stale.set(upstream, Date.now())
935
+ }
936
+ }
877
937
  for (const provider of providers) {
878
938
  const upstream = provider.id
879
939
  if (restrict.size > 0 && !restrict.has(upstream)) continue
880
940
  // Our own variant routes declare image input and are never wrapped;
881
941
  // probing them would just re-probe their upstream route.
882
942
  if (upstream.startsWith(VARIANT_PROVIDER_PREFIX)) continue
943
+ const variantId = variantProviderId(upstream)
883
944
  let models: readonly LlmModelInfo[]
884
945
  try {
885
946
  models = await llm.listModels(upstream)
@@ -888,18 +949,26 @@ export function installImageInputVariants(
888
949
  }
889
950
  const eligible = models.some(shouldWrapModel)
890
951
  const registered = registrations.has(upstream)
891
- if (!eligible && registered) {
892
- // The route lost its eligible models: release the stale variant.
893
- const dispose = registrations.get(upstream)
894
- dispose?.()
895
- registrations.delete(upstream)
896
- continue
952
+ if (registered && live.has(variantId)) {
953
+ // Re-read the live registry after the await: a rebuild inside the
954
+ // probe window can drop our wrapper without touching our map.
955
+ const liveNow = new Set(llm.listProviders().map(provider => provider.id))
956
+ if (liveNow.has(variantId)) {
957
+ // Healthy handle: only react when the route lost every eligible model.
958
+ if (!eligible) release(upstream)
959
+ continue
960
+ }
961
+ }
962
+ if (registered) {
963
+ // Dead handle from a registry rebuild/reset: drop it and register a
964
+ // fresh wrapper below.
965
+ release(upstream)
897
966
  }
898
- if (!eligible || registered) continue
967
+ if (!eligible) continue
899
968
  if (disposed) return
900
969
  try {
901
970
  const dispose = llm.registerAdapter(
902
- [variantProviderId(upstream)],
971
+ [variantId],
903
972
  new ImageInputVariantAdapter(
904
973
  ctx,
905
974
  llm,
@@ -907,9 +976,11 @@ export function installImageInputVariants(
907
976
  provider.name,
908
977
  getRuntime,
909
978
  new EvidenceCache(EVIDENCE_CACHE_LIMIT),
979
+ () => getConfig().imageInputVariants.hidden,
910
980
  ),
911
981
  )
912
982
  registrations.set(upstream, dispose)
983
+ stale.delete(upstream)
913
984
  } catch (error) {
914
985
  ctx.logger.warn(
915
986
  'dsh-vision-toolkit: image-input variant registration skipped for "%s": %s',
@@ -918,16 +989,6 @@ export function installImageInputVariants(
918
989
  )
919
990
  }
920
991
  }
921
- const live = new Set(providers.map(provider => provider.id))
922
- for (const [upstream, dispose] of [...registrations]) {
923
- // A wrapper is released when its upstream route vanished OR the
924
- // current configuration no longer allows that route (restrict
925
- // narrowing must not leave stale variants behind).
926
- if (!live.has(upstream) || (restrict.size > 0 && !restrict.has(upstream))) {
927
- dispose()
928
- registrations.delete(upstream)
929
- }
930
- }
931
992
  } catch (error) {
932
993
  ctx.logger.warn('dsh-vision-toolkit: image-input variant sweep failed: %s', messageOf(error))
933
994
  }
@@ -937,9 +998,14 @@ export function installImageInputVariants(
937
998
  ctx.on('llm/adapters-updated', () => { sweep() })
938
999
  }
939
1000
  sweep()
1001
+ // Self-heal safety net: re-verify periodically so a wrapper dropped by an
1002
+ // unobserved cause (registry rebuild without an event we can see) is restored
1003
+ // within one interval.
1004
+ const timer = setInterval(() => { sweep() }, VARIANT_SWEEP_INTERVAL_MS)
940
1005
  return {
941
1006
  dispose: () => {
942
1007
  disposed = true
1008
+ clearInterval(timer)
943
1009
  releaseAll()
944
1010
  },
945
1011
  reconcile: () => { sweep() },
package/src/index.ts CHANGED
@@ -114,6 +114,7 @@ export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Pro
114
114
  artifacts,
115
115
  pastedImages,
116
116
  createPasteTakeoverResolver(ctx, () => resolveConfig(settings.get())),
117
+ () => ({ hidden: resolveConfig(settings.get()).imageInputVariants.hidden }),
117
118
  )
118
119
  disposers.push(variants.dispose)
119
120
  disposers.push(settings.watch(async (next) => {
@@ -822,6 +822,13 @@ export class VisionToolkitPluginUpdateService {
822
822
  profile: ProfileInstall,
823
823
  pnpmPath: string,
824
824
  ): Promise<CommandResult> {
825
+ // Windows batch shims cannot be spawned directly; route them through cmd.exe.
826
+ const program = this.platform === 'win32' && /\.(?:cmd|bat)$/i.test(pnpmPath)
827
+ ? process.env.COMSPEC ?? 'cmd.exe'
828
+ : pnpmPath
829
+ const argv = program === pnpmPath
830
+ ? [pnpmPath, ...args]
831
+ : [program, '/d', '/s', '/c', pnpmPath, ...args]
825
832
  const controller = new AbortController()
826
833
  let timedOut = false
827
834
  const timeout = setTimeout(() => {
@@ -830,7 +837,7 @@ export class VisionToolkitPluginUpdateService {
830
837
  }, timeoutMs)
831
838
  try {
832
839
  const handle = this.ctx.subprocess.spawn({
833
- argv: [pnpmPath, ...args],
840
+ argv,
834
841
  cwd: profile.profileDir,
835
842
  stdio: {
836
843
  stdin: 'ignore',
@@ -39,7 +39,12 @@ async function defaultFactory(ctx: Context, config: ResolvedVisionToolkitConfig)
39
39
  }
40
40
 
41
41
  function fingerprint(config: ResolvedVisionToolkitConfig): string {
42
- return JSON.stringify(config)
42
+ // Transparent routing is a display/policy flag: toggling it must not rebuild
43
+ // or re-verify the vision runtime, only reconcile the model-selector routes.
44
+ return JSON.stringify({
45
+ ...config,
46
+ imageInputVariants: { ...config.imageInputVariants, hidden: false },
47
+ })
43
48
  }
44
49
 
45
50
  function messageOf(error: unknown): string {
@@ -73,7 +78,9 @@ export class VisionToolkitRuntimeManager {
73
78
  async prepareCandidate(raw: VisionToolkitConfig): Promise<PreparedRuntimeGeneration> {
74
79
  const config = resolveConfig(raw)
75
80
  const resolvedFingerprint = fingerprint(config)
76
- if (this.active?.fingerprint === resolvedFingerprint) return this.active
81
+ if (this.active?.fingerprint === resolvedFingerprint) {
82
+ return { ...this.active, config }
83
+ }
77
84
  const runtime = await this.factory(this.ctx, config)
78
85
  return { config, fingerprint: resolvedFingerprint, runtime }
79
86
  }
@@ -84,6 +91,7 @@ export class VisionToolkitRuntimeManager {
84
91
  */
85
92
  activateCandidate(candidate: PreparedRuntimeGeneration): void {
86
93
  if (this.active?.fingerprint === candidate.fingerprint) {
94
+ this.active = candidate
87
95
  this.lastError = undefined
88
96
  return
89
97
  }
package/src/runtime.ts CHANGED
@@ -56,7 +56,9 @@ const VISION_MODEL_TEST_IMAGE = fileURLToPath(new URL('../assets/vision-model-te
56
56
  const VISION_MODEL_TEST_PROMPT = 'This is an explicit service readiness test. Reply with one short sentence confirming that you received the image.'
57
57
 
58
58
  /** Bump when the Pillow compression ladder changes so stale cache entries are ignored. */
59
- const COMPRESSED_IMAGE_CACHE_VERSION = 'v1'
59
+ const COMPRESSED_IMAGE_CACHE_VERSION = 'v2'
60
+ /** Cache keys carry 64-bit digests so Windows paths stay below MAX_PATH; the full file sha256 is computed on read and compared against this prefix. */
61
+ const COMPRESSED_IMAGE_CACHE_KEY_DIGEST_LENGTH = 16
60
62
  const COMPRESSED_IMAGE_CACHE_MAX_ENTRIES = 200
61
63
  const COMPRESSED_IMAGE_CACHE_MAX_BYTES = 512 * 1024 * 1024
62
64
  const COMPRESSED_IMAGE_CACHE_STALE_PARTIAL_MS = 60 * 60 * 1000
@@ -883,7 +885,7 @@ export class VisionToolkitRuntime {
883
885
  private async readCacheCandidate(
884
886
  root: string,
885
887
  name: string,
886
- expectedOutDigest: string,
888
+ expectedOutDigestPrefix: string,
887
889
  maxBytes: number,
888
890
  maxPixels: number,
889
891
  operation: OperationContext,
@@ -909,7 +911,8 @@ export class VisionToolkitRuntime {
909
911
  } catch {
910
912
  return undefined
911
913
  }
912
- if (bytes.length !== info.size || createHash('sha256').update(bytes).digest('hex') !== expectedOutDigest) return undefined
914
+ const digest = createHash('sha256').update(bytes).digest('hex')
915
+ if (bytes.length !== info.size || !digest.startsWith(expectedOutDigestPrefix)) return undefined
913
916
  let probed: { width: number; height: number; format: string } | undefined
914
917
  try {
915
918
  probed = await this.adapter.probeImageSize(real, { signal: operation.signal })
@@ -929,7 +932,7 @@ export class VisionToolkitRuntime {
929
932
 
930
933
  private cacheEntryOutDigest(entry: string, prefix: string): string | undefined {
931
934
  const tail = entry.slice(prefix.length + 1)
932
- return /^[0-9a-f]{64}-/u.test(tail) ? tail.slice(0, 64) : undefined
935
+ return /^[0-9a-f]{16}-/u.test(tail) ? tail.slice(0, 16) : undefined
933
936
  }
934
937
 
935
938
  private async pruneCompressedCache(root: string): Promise<void> {
@@ -997,21 +1000,21 @@ export class VisionToolkitRuntime {
997
1000
  if (bytes.length !== image.bytes) {
998
1001
  throw new VisionToolkitError('input', `image changed while preparing the vision request: ${image.path}`)
999
1002
  }
1000
- const digest = createHash('sha256').update(bytes).digest('hex')
1003
+ const digest = createHash('sha256').update(bytes).digest('hex').slice(0, COMPRESSED_IMAGE_CACHE_KEY_DIGEST_LENGTH)
1001
1004
  const root = await this.compressedImageRoot(policy)
1002
1005
  await this.pruneCompressedCache(root)
1003
1006
  const prefix = `${COMPRESSED_IMAGE_CACHE_VERSION}-${digest}-b${this.config.maxImageBytes}-p${this.config.maxImagePixels}`
1004
1007
  for (const entry of await readdir(root)) {
1005
1008
  if (!entry.startsWith(`${prefix}-`) || entry.startsWith('.')) continue
1006
- const outDigest = this.cacheEntryOutDigest(entry, prefix)
1007
- if (outDigest === undefined) {
1009
+ const outDigestPrefix = this.cacheEntryOutDigest(entry, prefix)
1010
+ if (outDigestPrefix === undefined) {
1008
1011
  await rm(join(root, entry), { force: true }).catch(() => {})
1009
1012
  continue
1010
1013
  }
1011
1014
  const cached = await this.readCacheCandidate(
1012
1015
  root,
1013
1016
  entry,
1014
- outDigest,
1017
+ outDigestPrefix,
1015
1018
  this.config.maxImageBytes,
1016
1019
  this.config.maxImagePixels,
1017
1020
  operation,
@@ -1037,7 +1040,7 @@ export class VisionToolkitRuntime {
1037
1040
  }
1038
1041
  const extension = compressed.format === 'jpeg' ? 'jpg' : compressed.format
1039
1042
  const stagedBytes = await readFile(staged, { signal: operation.signal })
1040
- const outDigest = createHash('sha256').update(stagedBytes).digest('hex')
1043
+ const outDigest = createHash('sha256').update(stagedBytes).digest('hex').slice(0, COMPRESSED_IMAGE_CACHE_KEY_DIGEST_LENGTH)
1041
1044
  const finalName = `${prefix}-${outDigest}-${compressed.width}x${compressed.height}.${extension}`
1042
1045
  const finalPath = join(root, finalName)
1043
1046
  const existing = await this.readCacheCandidate(
package/src/web.ts CHANGED
@@ -46,6 +46,9 @@ import { sameOriginPost, sameOriginRequest } from './web-request.ts'
46
46
  /** Exact route used by the browser Settings page. */
47
47
  export const SETTINGS_ROUTE = '/_dsh/vision-toolkit/settings'
48
48
 
49
+ /** Same-origin route used by the browser client to read display-mode flags. */
50
+ export const DISPLAY_CONFIG_ROUTE = '/_dsh/vision-toolkit/display-config'
51
+
49
52
  /** Public Settings snapshot; credential values are deliberately impossible here. */
50
53
  export interface VisionToolkitSettingsSnapshot {
51
54
  schemaVersion: 1
@@ -504,6 +507,34 @@ export function createPastePolicyHandler(
504
507
  }
505
508
  }
506
509
 
510
+ /**
511
+ * Same-origin display-config handler: exposes whether transparent routing is
512
+ * active. The paste integration uses it to choose its notice text; the model
513
+ * selector hides upstream twins synchronously from DOM display names and does
514
+ * not depend on this route.
515
+ * @param getDisplayConfig - resolves the current display-mode flags.
516
+ * @returns the HTTP handler.
517
+ */
518
+ export function createDisplayConfigHandler(
519
+ getDisplayConfig: () => { hidden: boolean },
520
+ ): (req: IncomingMessage, res: ServerResponse) => void {
521
+ return (req, res) => {
522
+ try {
523
+ if (req.method !== 'GET') {
524
+ requestError(res, 405, 'method-not-allowed', 'Use GET')
525
+ return
526
+ }
527
+ if (!sameOriginRequest(req)) {
528
+ requestError(res, 403, 'origin-rejected', 'The request must originate from this DSH Web application')
529
+ return
530
+ }
531
+ responseJson(res, 200, { ok: true, value: getDisplayConfig() })
532
+ } catch (error) {
533
+ requestError(res, 500, 'display-config-failed', publicMessage(error))
534
+ }
535
+ }
536
+ }
537
+
507
538
  /**
508
539
  * Attach optional Web routes whenever a webServer service is present.
509
540
  * @param ctx - plugin context owning route effects.
@@ -511,6 +542,7 @@ export function createPastePolicyHandler(
511
542
  * @param artifacts - signed Artifact handler.
512
543
  * @param pastedImages - pasted-image workspace handler.
513
544
  * @param pastePolicy - paste-policy verdict resolver (sessionId, selection, modelLabel).
545
+ * @param getDisplayConfig - resolves display-mode flags for the browser client.
514
546
  */
515
547
  export function installVisionToolkitWeb(
516
548
  ctx: Context,
@@ -518,6 +550,7 @@ export function installVisionToolkitWeb(
518
550
  artifacts: ArtifactAccessController,
519
551
  pastedImages: PastedImageBackend,
520
552
  pastePolicy: (sessionId: string, selection?: PasteSelectionQuery, modelLabel?: string) => Promise<PasteVerdict>,
553
+ getDisplayConfig: () => { hidden: boolean },
521
554
  ): void {
522
555
  ctx.inject(['webServer'], (webCtx) => {
523
556
  webCtx.effect(() => {
@@ -543,7 +576,13 @@ export function installVisionToolkitWeb(
543
576
  path: PASTE_POLICY_ROUTE,
544
577
  handler: createPastePolicyHandler(pastePolicy),
545
578
  })
579
+ const disposeDisplayConfig = webCtx.webServer.register({
580
+ kind: 'exact',
581
+ path: DISPLAY_CONFIG_ROUTE,
582
+ handler: createDisplayConfigHandler(getDisplayConfig),
583
+ })
546
584
  return () => {
585
+ disposeDisplayConfig()
547
586
  disposePastePolicy()
548
587
  disposePasteImages()
549
588
  disposeSettings()
Binary file