@anionex/dsh-vision-toolkit 0.1.7 → 0.1.9

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 (51) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +34 -17
  3. package/README.zh.md +32 -7
  4. package/assets/community-group-qr.png +0 -0
  5. package/assets/vision-model-test.png +0 -0
  6. package/docs/requirements-traceability/README.i18n.yaml +2 -2
  7. package/docs/requirements-traceability/README.md +2 -2
  8. package/docs/requirements-traceability/README.zh.md +2 -2
  9. package/lib/client.js +361 -28
  10. package/lib/client.js.map +1 -1
  11. package/lib/config.js +14 -0
  12. package/lib/config.js.map +1 -1
  13. package/lib/image-input-variants.js +615 -0
  14. package/lib/image-input-variants.js.map +1 -0
  15. package/lib/index.js +8 -1
  16. package/lib/index.js.map +1 -1
  17. package/lib/paste-images.js +6 -0
  18. package/lib/paste-images.js.map +1 -1
  19. package/lib/runtime.js +37 -3
  20. package/lib/runtime.js.map +1 -1
  21. package/lib/types/client/index.d.ts +16 -5
  22. package/lib/types/client/index.d.ts.map +1 -1
  23. package/lib/types/client/paste-images.d.ts +69 -0
  24. package/lib/types/client/paste-images.d.ts.map +1 -1
  25. package/lib/types/config.d.ts +26 -0
  26. package/lib/types/config.d.ts.map +1 -1
  27. package/lib/types/image-input-variants.d.ts +153 -0
  28. package/lib/types/image-input-variants.d.ts.map +1 -0
  29. package/lib/types/index.d.ts.map +1 -1
  30. package/lib/types/paste-images.d.ts +35 -0
  31. package/lib/types/paste-images.d.ts.map +1 -1
  32. package/lib/types/runtime.d.ts +5 -2
  33. package/lib/types/runtime.d.ts.map +1 -1
  34. package/lib/types/web-request.d.ts +7 -0
  35. package/lib/types/web-request.d.ts.map +1 -1
  36. package/lib/types/web.d.ts +17 -2
  37. package/lib/types/web.d.ts.map +1 -1
  38. package/lib/web-request.js +11 -2
  39. package/lib/web-request.js.map +1 -1
  40. package/lib/web.js +88 -5
  41. package/lib/web.js.map +1 -1
  42. package/package.json +2 -1
  43. package/src/client/index.tsx +53 -14
  44. package/src/client/paste-images.tsx +331 -15
  45. package/src/config.ts +40 -0
  46. package/src/image-input-variants.ts +688 -0
  47. package/src/index.ts +18 -1
  48. package/src/paste-images.ts +39 -0
  49. package/src/runtime.ts +42 -3
  50. package/src/web-request.ts +12 -2
  51. package/src/web.ts +90 -4
package/src/index.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  type VisionToolkitConfig,
22
22
  } from './config.ts'
23
23
  import { VisionToolExposure } from './exposure.ts'
24
+ import { createPasteTakeoverResolver, installImageInputVariants } from './image-input-variants.ts'
24
25
  import { VisionToolkitRuntimeManager } from './runtime-manager.ts'
25
26
  import { VISION_TOOLS_SKILL } from './skill.ts'
26
27
  import { createVisionTools } from './tools.ts'
@@ -98,11 +99,27 @@ export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Pro
98
99
  const pastedImages = new PastedImageBackend(ctx, {
99
100
  maxImageBytes: () => manager.status().activeConfig?.maxImageBytes ?? resolveConfig(settings.get()).maxImageBytes,
100
101
  })
101
- installVisionToolkitWeb(ctx, backend, artifacts, pastedImages)
102
+ // Image-input variants register asynchronously once eligible routes exist;
103
+ // the runtime getter stays lazy so variants appear even when the runtime
104
+ // becomes ready after the first sweep.
105
+ const variants = installImageInputVariants(
106
+ ctx,
107
+ () => resolveConfig(settings.get()),
108
+ () => manager.ready ? manager.current() : undefined,
109
+ )
110
+ installVisionToolkitWeb(
111
+ ctx,
112
+ backend,
113
+ artifacts,
114
+ pastedImages,
115
+ createPasteTakeoverResolver(ctx, () => resolveConfig(settings.get())),
116
+ )
117
+ disposers.push(variants.dispose)
102
118
  disposers.push(settings.watch(async (next) => {
103
119
  try {
104
120
  await manager.reconfigure(next)
105
121
  ensureOperational()
122
+ variants.reconcile()
106
123
  } catch (error) {
107
124
  const message = error instanceof Error ? error.message : String(error)
108
125
  ctx.logger.error('dsh-vision-toolkit: keeping the previous runtime after a refused Settings generation. %s', message)
@@ -11,6 +11,45 @@ import { sameOriginPost } from './web-request.ts'
11
11
  /** Exact route used by the browser paste integration. */
12
12
  export const PASTE_IMAGES_ROUTE = '/_dsh/vision-toolkit/paste-images'
13
13
 
14
+ /**
15
+ * Exact route the browser paste integration asks before taking a paste over:
16
+ * `GET ?sessionId=&model=&provider=&modelId=` answers the verdict from the
17
+ * live Session's model route.
18
+ */
19
+ export const PASTE_POLICY_ROUTE = '/_dsh/vision-toolkit/paste-policy'
20
+
21
+ /**
22
+ * One exact model route the browser should switch the Session to before the
23
+ * native attachment flow: the image-input variant of the current text-only
24
+ * model. The variant declares image input, so the paste keeps the composer
25
+ * thumbnail and the durable session image.
26
+ */
27
+ export interface PasteSwitchRoute {
28
+ /** The variant provider route (`vision-toolkit-` + upstream provider id). */
29
+ provider: string
30
+ /** The model id, identical to the upstream text-only model's id. */
31
+ model: string
32
+ /** The variant's selector display name (upstream name + variant suffix). */
33
+ label: string
34
+ /** The upstream reasoning effort, when the selection carries one. */
35
+ reasoningEffort?: string
36
+ }
37
+
38
+ /** The exact model route the browser read from the live model catalog. */
39
+ export interface PasteSelectionQuery {
40
+ provider: string
41
+ model: string
42
+ reasoningEffort?: string
43
+ }
44
+
45
+ /** The paste-policy answer for one Session and model route. */
46
+ export interface PasteVerdict {
47
+ /** Whether the browser should turn the paste into workspace paths instead of attachments. */
48
+ takeOver: boolean
49
+ /** When present, the browser switches to this route first, then lets the paste flow natively. */
50
+ autoSwitch?: PasteSwitchRoute
51
+ }
52
+
14
53
  const MAX_NAME_BYTES = 180
15
54
 
16
55
  interface PasteImageResponse {
package/src/runtime.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  import { createHash, randomUUID } from 'node:crypto'
10
10
  import { readFile, rm, stat, writeFile } from 'node:fs/promises'
11
11
  import { basename, extname, join } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
12
13
  import type { Context } from '@deepseek-ai/cordis'
13
14
  import type { ResolvedCredential } from '@deepseek-ai/dsh-credentials'
14
15
  import { SaxesParser } from 'saxes'
@@ -49,6 +50,8 @@ import {
49
50
  import { PLUGIN_VERSION } from './version.ts'
50
51
 
51
52
  const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
53
+ const VISION_MODEL_TEST_IMAGE = fileURLToPath(new URL('../assets/vision-model-test.png', import.meta.url))
54
+ const VISION_MODEL_TEST_PROMPT = 'This is an explicit service readiness test. Reply with one short sentence confirming that you received the image.'
52
55
 
53
56
  function svgDocumentPathCount(svg: string): number | undefined {
54
57
  const parser = new SaxesParser({ xmlns: true })
@@ -444,9 +447,11 @@ export interface VisionToolkitHealthResult {
444
447
  artifactDirectory: HealthCheck
445
448
  tempDirectory: HealthCheck
446
449
  service: HealthCheck
450
+ model: HealthCheck
447
451
  }
448
452
  healthy: boolean
449
453
  connectionTested: boolean
454
+ modelTested: boolean
450
455
  }
451
456
 
452
457
  /** Shared per-call execution options. */
@@ -764,6 +769,10 @@ export class VisionToolkitRuntime {
764
769
  `credential ${this.config.provider.credential} is not configured; set it through DSH credentials`,
765
770
  )
766
771
  }
772
+ return this.visionEnv(resolved)
773
+ }
774
+
775
+ private visionEnv(resolved: ResolvedCredential): UpstreamEnvironment {
767
776
  return {
768
777
  VISION_API_KEY: resolved.value,
769
778
  VISION_BASE_URL: this.config.provider.baseUrl,
@@ -1690,8 +1699,8 @@ export class VisionToolkitRuntime {
1690
1699
  }
1691
1700
  }
1692
1701
 
1693
- /** health: inspect local readiness and optionally probe the configured `/models` endpoint. */
1694
- async health(testConnection: boolean, options: ToolCallOptions): Promise<VisionToolkitHealthResult> {
1702
+ /** Health: inspect local readiness, optionally probe `/models`, and explicitly test one real multimodal request. */
1703
+ async health(testConnection: boolean, options: ToolCallOptions, testModel = false): Promise<VisionToolkitHealthResult> {
1695
1704
  return this.runOperation('vision_toolkit_health', options, async (operation) => {
1696
1705
  const info = this.upstreamVersion
1697
1706
  const python: HealthCheck = { status: 'ok', detail: `${info.pythonVersion} via ${info.python}` }
@@ -1733,6 +1742,10 @@ export class VisionToolkitRuntime {
1733
1742
  status: 'not_tested',
1734
1743
  detail: 'Connection was not tested; pass testConnection=true to query the configured /models endpoint',
1735
1744
  }
1745
+ let model: HealthCheck = {
1746
+ status: 'not_tested',
1747
+ detail: 'Vision model was not tested; run an explicit model test to send the bundled diagnostic image',
1748
+ }
1736
1749
  if (testConnection) {
1737
1750
  if (resolvedCredential === undefined) {
1738
1751
  service = { status: 'error', detail: 'Connection test skipped because the configured credential is unavailable' }
@@ -1775,7 +1788,32 @@ export class VisionToolkitRuntime {
1775
1788
  }
1776
1789
  }
1777
1790
  }
1778
- const checks = { python, dependencies, chrome, credential, artifactDirectory, tempDirectory, service }
1791
+ if (testModel) {
1792
+ if (resolvedCredential === undefined) {
1793
+ model = { status: 'error', detail: 'Vision model test skipped because the configured credential is unavailable' }
1794
+ } else {
1795
+ try {
1796
+ const result = await this.runUpstream(
1797
+ 'glance',
1798
+ [VISION_MODEL_TEST_IMAGE, '-q', VISION_MODEL_TEST_PROMPT],
1799
+ operation,
1800
+ this.visionEnv(resolvedCredential),
1801
+ )
1802
+ if (result.stdout.trim().length === 0) {
1803
+ throw new VisionToolkitError('output', 'glance: vision API returned an empty description')
1804
+ }
1805
+ model = {
1806
+ status: 'ok',
1807
+ detail: `Vision model ${this.config.provider.model} completed a multimodal request`,
1808
+ }
1809
+ } catch (error) {
1810
+ if (operation.signal.aborted) throw error
1811
+ const detail = error instanceof Error ? error.message : String(error)
1812
+ model = { status: 'error', detail: `Vision model test failed: ${detail.slice(0, 600)}` }
1813
+ }
1814
+ }
1815
+ }
1816
+ const checks = { python, dependencies, chrome, credential, artifactDirectory, tempDirectory, service, model }
1779
1817
  const healthy = Object.values(checks).every(check => check.status !== 'error')
1780
1818
  return {
1781
1819
  pluginVersion: PLUGIN_VERSION,
@@ -1783,6 +1821,7 @@ export class VisionToolkitRuntime {
1783
1821
  checks,
1784
1822
  healthy,
1785
1823
  connectionTested: testConnection,
1824
+ modelTested: testModel,
1786
1825
  }
1787
1826
  })
1788
1827
  }
@@ -1,7 +1,12 @@
1
1
  import type { IncomingMessage } from 'node:http'
2
2
 
3
- /** Accept state-changing requests only from the DSH Web application's origin. */
4
- export function sameOriginPost(req: IncomingMessage): boolean {
3
+ /**
4
+ * Accept a request only from the DSH Web application's origin. Method-agnostic:
5
+ * the same fence guards state-changing POSTs and policy GETs.
6
+ * @param req - the incoming request whose headers carry the origin evidence.
7
+ * @returns whether the request may be answered.
8
+ */
9
+ export function sameOriginRequest(req: IncomingMessage): boolean {
5
10
  const fetchSite = req.headers['sec-fetch-site']
6
11
  if (fetchSite === 'cross-site') return false
7
12
  const origin = req.headers.origin
@@ -15,3 +20,8 @@ export function sameOriginPost(req: IncomingMessage): boolean {
15
20
  return false
16
21
  }
17
22
  }
23
+
24
+ /** Accept state-changing requests only from the DSH Web application's origin. */
25
+ export function sameOriginPost(req: IncomingMessage): boolean {
26
+ return sameOriginRequest(req)
27
+ }
package/src/web.ts CHANGED
@@ -13,7 +13,13 @@ import { SettingsConflictError, type SettingsDescriptor } from '@deepseek-ai/dsh
13
13
  // Type-only import activates the optional webServer Context declaration.
14
14
  import type {} from '@deepseek-ai/dsh-host-webserver'
15
15
  import { ArtifactAccessController, ARTIFACT_ROUTE_PREFIX } from './artifact-access.ts'
16
- import { PastedImageBackend, PASTE_IMAGES_ROUTE } from './paste-images.ts'
16
+ import {
17
+ PastedImageBackend,
18
+ PASTE_IMAGES_ROUTE,
19
+ PASTE_POLICY_ROUTE,
20
+ type PasteSelectionQuery,
21
+ type PasteVerdict,
22
+ } from './paste-images.ts'
17
23
  import {
18
24
  resolveConfig,
19
25
  VISION_TOOLKIT_SETTINGS_NAMESPACE,
@@ -27,7 +33,7 @@ import {
27
33
  type RuntimeManagerStatus,
28
34
  } from './runtime-manager.ts'
29
35
  import { PLUGIN_VERSION, UPSTREAM_COMMIT, UPSTREAM_REPOSITORY, UPSTREAM_VERSION } from './version.ts'
30
- import { sameOriginPost } from './web-request.ts'
36
+ import { sameOriginPost, sameOriginRequest } from './web-request.ts'
31
37
 
32
38
  /** Exact route used by the browser Settings page. */
33
39
  export const SETTINGS_ROUTE = '/_dsh/vision-toolkit/settings'
@@ -68,6 +74,7 @@ interface SaveRequest {
68
74
  interface HealthRequest {
69
75
  action: 'health'
70
76
  testConnection: boolean
77
+ testModel: boolean
71
78
  }
72
79
 
73
80
  interface CredentialRequest {
@@ -150,7 +157,10 @@ function parseRequest(value: unknown): SettingsRequest {
150
157
  if (!isRecord(value) || typeof value.action !== 'string') throw new TypeError('request action is required')
151
158
  if (value.action === 'health') {
152
159
  if (typeof value.testConnection !== 'boolean') throw new TypeError('health.testConnection must be boolean')
153
- return { action: 'health', testConnection: value.testConnection }
160
+ const testModel = value.testModel === undefined ? false : value.testModel
161
+ if (typeof testModel !== 'boolean') throw new TypeError('health.testModel must be boolean')
162
+ if (testModel && !value.testConnection) throw new TypeError('health.testModel requires health.testConnection')
163
+ return { action: 'health', testConnection: value.testConnection, testModel }
154
164
  }
155
165
  if (value.action === 'save') {
156
166
  if (!Number.isSafeInteger(value.expectedRevision) || (value.expectedRevision as number) < 0) {
@@ -288,7 +298,7 @@ export class VisionToolkitWebBackend {
288
298
  signal: controller.signal,
289
299
  workspace: process.cwd(),
290
300
  sessionId: 'vision-toolkit-settings',
291
- })
301
+ }, request.testModel)
292
302
  } finally {
293
303
  req.off('aborted', abort)
294
304
  req.socket.off('close', abort)
@@ -353,17 +363,87 @@ export class VisionToolkitWebBackend {
353
363
  }
354
364
  }
355
365
 
366
+ /**
367
+ * Same-origin policy handler for the paste route: whether the browser should
368
+ * take a paste over into workspace paths, or let it flow natively after an
369
+ * optional automatic switch to the image-input variant. The optional `model`
370
+ * query carries the model-selector label the client currently shows; the
371
+ * optional `provider`/`modelId`/`reasoningEffort` queries carry the exact
372
+ * route the client read from the live model catalog, which the resolver
373
+ * prefers (a label alone cannot pick a provider). Unresolvable routes answer
374
+ * native — the safe default.
375
+ * @param resolve - resolves one live Session's paste verdict.
376
+ * @returns the HTTP handler.
377
+ */
378
+ export function createPastePolicyHandler(
379
+ resolve: (sessionId: string, selection?: PasteSelectionQuery, modelLabel?: string) => Promise<PasteVerdict>,
380
+ ): (req: IncomingMessage, res: ServerResponse) => void {
381
+ return (req, res) => {
382
+ void (async () => {
383
+ try {
384
+ if (req.method !== 'GET') {
385
+ requestError(res, 405, 'method-not-allowed', 'Use GET')
386
+ return
387
+ }
388
+ if (!sameOriginRequest(req)) {
389
+ requestError(res, 403, 'origin-rejected', 'The request must originate from this DSH Web application')
390
+ return
391
+ }
392
+ let sessionId: string
393
+ let modelLabel: string | undefined
394
+ let selection: PasteSelectionQuery | undefined
395
+ try {
396
+ const url = new URL(req.url ?? PASTE_POLICY_ROUTE, 'http://dsh.internal')
397
+ const sessions = url.searchParams.getAll('sessionId')
398
+ if (sessions.length !== 1 || sessions[0] === undefined || sessions[0] === '') {
399
+ throw new TypeError('sessionId is required exactly once')
400
+ }
401
+ sessionId = sessions[0]!
402
+ const models = url.searchParams.getAll('model')
403
+ if (models.length > 1) throw new TypeError('model may be given at most once')
404
+ modelLabel = models[0]
405
+ const providers = url.searchParams.getAll('provider')
406
+ if (providers.length > 1) throw new TypeError('provider may be given at most once')
407
+ const modelIds = url.searchParams.getAll('modelId')
408
+ if (modelIds.length > 1) throw new TypeError('modelId may be given at most once')
409
+ const efforts = url.searchParams.getAll('reasoningEffort')
410
+ if (efforts.length > 1) throw new TypeError('reasoningEffort may be given at most once')
411
+ const provider = providers[0]
412
+ const modelId = modelIds[0]
413
+ if (provider !== undefined && modelId !== undefined && provider !== '' && modelId !== '') {
414
+ selection = {
415
+ provider,
416
+ model: modelId,
417
+ ...(efforts[0] === undefined || efforts[0] === '' ? {} : { reasoningEffort: efforts[0] }),
418
+ }
419
+ }
420
+ } catch (error) {
421
+ requestError(res, 400, 'invalid-request', publicMessage(error))
422
+ return
423
+ }
424
+ const verdict = await resolve(sessionId, selection, modelLabel)
425
+ responseJson(res, 200, { ok: true, value: verdict })
426
+ } catch (error) {
427
+ requestError(res, 500, 'policy-failed', publicMessage(error))
428
+ }
429
+ })()
430
+ }
431
+ }
432
+
356
433
  /**
357
434
  * Attach optional Web routes whenever a webServer service is present.
358
435
  * @param ctx - plugin context owning route effects.
359
436
  * @param backend - Settings handler.
360
437
  * @param artifacts - signed Artifact handler.
438
+ * @param pastedImages - pasted-image workspace handler.
439
+ * @param pastePolicy - paste-policy verdict resolver (sessionId, selection, modelLabel).
361
440
  */
362
441
  export function installVisionToolkitWeb(
363
442
  ctx: Context,
364
443
  backend: VisionToolkitWebBackend,
365
444
  artifacts: ArtifactAccessController,
366
445
  pastedImages: PastedImageBackend,
446
+ pastePolicy: (sessionId: string, selection?: PasteSelectionQuery, modelLabel?: string) => Promise<PasteVerdict>,
367
447
  ): void {
368
448
  ctx.inject(['webServer'], (webCtx) => {
369
449
  webCtx.effect(() => {
@@ -383,7 +463,13 @@ export function installVisionToolkitWeb(
383
463
  path: PASTE_IMAGES_ROUTE,
384
464
  handler: (req, res) => pastedImages.handle(req, res),
385
465
  })
466
+ const disposePastePolicy = webCtx.webServer.register({
467
+ kind: 'exact',
468
+ path: PASTE_POLICY_ROUTE,
469
+ handler: createPastePolicyHandler(pastePolicy),
470
+ })
386
471
  return () => {
472
+ disposePastePolicy()
387
473
  disposePasteImages()
388
474
  disposeSettings()
389
475
  disposeArtifact()