@anionex/dsh-vision-toolkit 0.1.7 → 0.1.8

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/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 {
@@ -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'
@@ -353,17 +359,87 @@ export class VisionToolkitWebBackend {
353
359
  }
354
360
  }
355
361
 
362
+ /**
363
+ * Same-origin policy handler for the paste route: whether the browser should
364
+ * take a paste over into workspace paths, or let it flow natively after an
365
+ * optional automatic switch to the image-input variant. The optional `model`
366
+ * query carries the model-selector label the client currently shows; the
367
+ * optional `provider`/`modelId`/`reasoningEffort` queries carry the exact
368
+ * route the client read from the live model catalog, which the resolver
369
+ * prefers (a label alone cannot pick a provider). Unresolvable routes answer
370
+ * native — the safe default.
371
+ * @param resolve - resolves one live Session's paste verdict.
372
+ * @returns the HTTP handler.
373
+ */
374
+ export function createPastePolicyHandler(
375
+ resolve: (sessionId: string, selection?: PasteSelectionQuery, modelLabel?: string) => Promise<PasteVerdict>,
376
+ ): (req: IncomingMessage, res: ServerResponse) => void {
377
+ return (req, res) => {
378
+ void (async () => {
379
+ try {
380
+ if (req.method !== 'GET') {
381
+ requestError(res, 405, 'method-not-allowed', 'Use GET')
382
+ return
383
+ }
384
+ if (!sameOriginRequest(req)) {
385
+ requestError(res, 403, 'origin-rejected', 'The request must originate from this DSH Web application')
386
+ return
387
+ }
388
+ let sessionId: string
389
+ let modelLabel: string | undefined
390
+ let selection: PasteSelectionQuery | undefined
391
+ try {
392
+ const url = new URL(req.url ?? PASTE_POLICY_ROUTE, 'http://dsh.internal')
393
+ const sessions = url.searchParams.getAll('sessionId')
394
+ if (sessions.length !== 1 || sessions[0] === undefined || sessions[0] === '') {
395
+ throw new TypeError('sessionId is required exactly once')
396
+ }
397
+ sessionId = sessions[0]!
398
+ const models = url.searchParams.getAll('model')
399
+ if (models.length > 1) throw new TypeError('model may be given at most once')
400
+ modelLabel = models[0]
401
+ const providers = url.searchParams.getAll('provider')
402
+ if (providers.length > 1) throw new TypeError('provider may be given at most once')
403
+ const modelIds = url.searchParams.getAll('modelId')
404
+ if (modelIds.length > 1) throw new TypeError('modelId may be given at most once')
405
+ const efforts = url.searchParams.getAll('reasoningEffort')
406
+ if (efforts.length > 1) throw new TypeError('reasoningEffort may be given at most once')
407
+ const provider = providers[0]
408
+ const modelId = modelIds[0]
409
+ if (provider !== undefined && modelId !== undefined && provider !== '' && modelId !== '') {
410
+ selection = {
411
+ provider,
412
+ model: modelId,
413
+ ...(efforts[0] === undefined || efforts[0] === '' ? {} : { reasoningEffort: efforts[0] }),
414
+ }
415
+ }
416
+ } catch (error) {
417
+ requestError(res, 400, 'invalid-request', publicMessage(error))
418
+ return
419
+ }
420
+ const verdict = await resolve(sessionId, selection, modelLabel)
421
+ responseJson(res, 200, { ok: true, value: verdict })
422
+ } catch (error) {
423
+ requestError(res, 500, 'policy-failed', publicMessage(error))
424
+ }
425
+ })()
426
+ }
427
+ }
428
+
356
429
  /**
357
430
  * Attach optional Web routes whenever a webServer service is present.
358
431
  * @param ctx - plugin context owning route effects.
359
432
  * @param backend - Settings handler.
360
433
  * @param artifacts - signed Artifact handler.
434
+ * @param pastedImages - pasted-image workspace handler.
435
+ * @param pastePolicy - paste-policy verdict resolver (sessionId, selection, modelLabel).
361
436
  */
362
437
  export function installVisionToolkitWeb(
363
438
  ctx: Context,
364
439
  backend: VisionToolkitWebBackend,
365
440
  artifacts: ArtifactAccessController,
366
441
  pastedImages: PastedImageBackend,
442
+ pastePolicy: (sessionId: string, selection?: PasteSelectionQuery, modelLabel?: string) => Promise<PasteVerdict>,
367
443
  ): void {
368
444
  ctx.inject(['webServer'], (webCtx) => {
369
445
  webCtx.effect(() => {
@@ -383,7 +459,13 @@ export function installVisionToolkitWeb(
383
459
  path: PASTE_IMAGES_ROUTE,
384
460
  handler: (req, res) => pastedImages.handle(req, res),
385
461
  })
462
+ const disposePastePolicy = webCtx.webServer.register({
463
+ kind: 'exact',
464
+ path: PASTE_POLICY_ROUTE,
465
+ handler: createPastePolicyHandler(pastePolicy),
466
+ })
386
467
  return () => {
468
+ disposePastePolicy()
387
469
  disposePasteImages()
388
470
  disposeSettings()
389
471
  disposeArtifact()