@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.
- package/README.i18n.yaml +2 -2
- package/README.md +55 -162
- package/README.zh.md +60 -170
- package/assets/upstream/README.md +4 -2
- package/assets/upstream/focus-hint-comparison-1.webp +0 -0
- package/assets/upstream/focus-hint-comparison-2.webp +0 -0
- package/assets/upstream/ui-fast-restore-reference.webp +0 -0
- package/assets/upstream/ui-fast-restore-result.webp +0 -0
- package/docs/python-runtime.i18n.yaml +6 -0
- package/docs/python-runtime.md +75 -0
- package/docs/python-runtime.zh.md +75 -0
- package/lib/client.js +270 -4
- package/lib/client.js.map +1 -1
- package/lib/config.js +2 -0
- package/lib/config.js.map +1 -1
- package/lib/image-input-variants.js +89 -26
- package/lib/image-input-variants.js.map +1 -1
- package/lib/index.js +1 -1
- package/lib/index.js.map +1 -1
- package/lib/plugin-update.js +8 -1
- package/lib/plugin-update.js.map +1 -1
- package/lib/runtime-manager.js +10 -3
- package/lib/runtime-manager.js.map +1 -1
- package/lib/runtime.js +12 -9
- package/lib/runtime.js.map +1 -1
- package/lib/types/client/display-config.d.ts +24 -0
- package/lib/types/client/display-config.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +10 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/model-variants-hider.d.ts +40 -0
- package/lib/types/client/model-variants-hider.d.ts.map +1 -0
- package/lib/types/client/paste-images.d.ts.map +1 -1
- package/lib/types/config.d.ts +10 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/image-input-variants.d.ts +2 -1
- package/lib/types/image-input-variants.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/plugin-update.d.ts.map +1 -1
- package/lib/types/runtime-manager.d.ts.map +1 -1
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/web.d.ts +17 -1
- package/lib/types/web.d.ts.map +1 -1
- package/lib/web.js +36 -1
- package/lib/web.js.map +1 -1
- package/package.json +1 -1
- package/src/client/display-config.ts +62 -0
- package/src/client/index.tsx +44 -2
- package/src/client/model-variants-hider.ts +159 -0
- package/src/client/paste-images.tsx +5 -1
- package/src/config.ts +12 -0
- package/src/image-input-variants.ts +90 -24
- package/src/index.ts +1 -0
- package/src/plugin-update.ts +8 -1
- package/src/runtime-manager.ts +10 -2
- package/src/runtime.ts +12 -9
- package/src/web.ts +39 -0
- package/assets/upstream/image-qa.webp +0 -0
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
848
|
-
registrations.
|
|
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 (
|
|
892
|
-
//
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
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
|
|
967
|
+
if (!eligible) continue
|
|
899
968
|
if (disposed) return
|
|
900
969
|
try {
|
|
901
970
|
const dispose = llm.registerAdapter(
|
|
902
|
-
[
|
|
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) => {
|
package/src/plugin-update.ts
CHANGED
|
@@ -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
|
|
840
|
+
argv,
|
|
834
841
|
cwd: profile.profileDir,
|
|
835
842
|
stdio: {
|
|
836
843
|
stdin: 'ignore',
|
package/src/runtime-manager.ts
CHANGED
|
@@ -39,7 +39,12 @@ async function defaultFactory(ctx: Context, config: ResolvedVisionToolkitConfig)
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function fingerprint(config: ResolvedVisionToolkitConfig): string {
|
|
42
|
-
|
|
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)
|
|
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 = '
|
|
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
|
-
|
|
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
|
-
|
|
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]{
|
|
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
|
|
1007
|
-
if (
|
|
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
|
-
|
|
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
|
|
Binary file
|