@anionex/dsh-vision-toolkit 0.1.6 → 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.
Files changed (41) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +23 -11
  3. package/README.zh.md +23 -11
  4. package/lib/client.js +391 -32
  5. package/lib/client.js.map +1 -1
  6. package/lib/config.js +14 -0
  7. package/lib/config.js.map +1 -1
  8. package/lib/image-input-variants.js +615 -0
  9. package/lib/image-input-variants.js.map +1 -0
  10. package/lib/index.js +8 -1
  11. package/lib/index.js.map +1 -1
  12. package/lib/paste-images.js +6 -0
  13. package/lib/paste-images.js.map +1 -1
  14. package/lib/types/client/index.d.ts +13 -5
  15. package/lib/types/client/index.d.ts.map +1 -1
  16. package/lib/types/client/paste-images.d.ts +69 -0
  17. package/lib/types/client/paste-images.d.ts.map +1 -1
  18. package/lib/types/config.d.ts +26 -0
  19. package/lib/types/config.d.ts.map +1 -1
  20. package/lib/types/image-input-variants.d.ts +153 -0
  21. package/lib/types/image-input-variants.d.ts.map +1 -0
  22. package/lib/types/index.d.ts.map +1 -1
  23. package/lib/types/paste-images.d.ts +35 -0
  24. package/lib/types/paste-images.d.ts.map +1 -1
  25. package/lib/types/web-request.d.ts +7 -0
  26. package/lib/types/web-request.d.ts.map +1 -1
  27. package/lib/types/web.d.ts +18 -2
  28. package/lib/types/web.d.ts.map +1 -1
  29. package/lib/web-request.js +11 -2
  30. package/lib/web-request.js.map +1 -1
  31. package/lib/web.js +142 -11
  32. package/lib/web.js.map +1 -1
  33. package/package.json +2 -1
  34. package/src/client/index.tsx +95 -20
  35. package/src/client/paste-images.tsx +331 -15
  36. package/src/config.ts +40 -0
  37. package/src/image-input-variants.ts +688 -0
  38. package/src/index.ts +18 -1
  39. package/src/paste-images.ts +39 -0
  40. package/src/web-request.ts +12 -2
  41. package/src/web.ts +158 -11
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
@@ -7,13 +7,19 @@
7
7
 
8
8
  import type { IncomingMessage, ServerResponse } from 'node:http'
9
9
  import type { Context } from '@deepseek-ai/cordis'
10
- import type { CredentialInfo } from '@deepseek-ai/dsh-credentials'
10
+ import type { CredentialInfo, CredentialRef } from '@deepseek-ai/dsh-credentials'
11
11
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
12
12
  import { SettingsConflictError, type SettingsDescriptor } from '@deepseek-ai/dsh-settings'
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'
@@ -70,7 +76,14 @@ interface HealthRequest {
70
76
  testConnection: boolean
71
77
  }
72
78
 
73
- type SettingsRequest = SaveRequest | HealthRequest
79
+ interface CredentialRequest {
80
+ action: 'credential'
81
+ expectedRevision: number
82
+ ref: CredentialRef
83
+ value: string
84
+ }
85
+
86
+ type SettingsRequest = SaveRequest | HealthRequest | CredentialRequest
74
87
 
75
88
  interface JsonError {
76
89
  ok: false
@@ -101,6 +114,8 @@ function isRecord(value: unknown): value is Record<string, unknown> {
101
114
  return typeof value === 'object' && value !== null && !Array.isArray(value)
102
115
  }
103
116
 
117
+ class CredentialReferenceConflictError extends Error {}
118
+
104
119
  function descriptorOf(ctx: Context): SettingsDescriptor {
105
120
  const descriptor = ctx.settings.describe().find(row => row.ns === VISION_TOOLKIT_SETTINGS_NAMESPACE)
106
121
  if (descriptor === undefined) throw new Error('vision-toolkit Settings namespace is not registered')
@@ -154,6 +169,27 @@ function parseRequest(value: unknown): SettingsRequest {
154
169
  value: value.value as VisionToolkitConfig,
155
170
  }
156
171
  }
172
+ if (value.action === 'credential') {
173
+ if (!Number.isSafeInteger(value.expectedRevision) || (value.expectedRevision as number) < 0) {
174
+ throw new TypeError('credential.expectedRevision must be a non-negative integer')
175
+ }
176
+ if (typeof value.ref !== 'string') throw new TypeError('credential.ref must be a string')
177
+ if (typeof value.value !== 'string') throw new TypeError('credential.value must be a string')
178
+ const secret = value.value.trim()
179
+ if (secret.length === 0) throw new TypeError('API key cannot be blank')
180
+ const first = secret[0]
181
+ const quoted = secret.length > 1 && (first === '"' || first === '\'' || first === '`') && secret.endsWith(first)
182
+ const environmentLine = /^[A-Z][A-Z0-9_]*=[^=]/u.test(secret)
183
+ if (quoted || environmentLine || !/^[\x21-\x7E]+$/u.test(secret)) {
184
+ throw new TypeError('paste only the API key, without a variable name, quotes, spaces, or line breaks')
185
+ }
186
+ return {
187
+ action: 'credential',
188
+ expectedRevision: value.expectedRevision as number,
189
+ ref: credentialRef(value.ref),
190
+ value: secret,
191
+ }
192
+ }
157
193
  throw new TypeError(`unsupported action: ${value.action}`)
158
194
  }
159
195
 
@@ -227,6 +263,26 @@ export class VisionToolkitWebBackend {
227
263
  return this.snapshot()
228
264
  }
229
265
 
266
+ private async saveCredential(request: CredentialRequest): Promise<VisionToolkitSettingsSnapshot> {
267
+ const descriptor = descriptorOf(this.ctx)
268
+ if (descriptor.revision !== request.expectedRevision) {
269
+ throw new SettingsConflictError(
270
+ VISION_TOOLKIT_SETTINGS_NAMESPACE,
271
+ request.expectedRevision,
272
+ descriptor.revision,
273
+ )
274
+ }
275
+ const resolved = resolveConfig(descriptor.value as VisionToolkitConfig)
276
+ const currentRef = credentialRef(String(resolved.provider.credential))
277
+ if (currentRef !== request.ref) {
278
+ throw new CredentialReferenceConflictError(
279
+ `credential reference changed from "${request.ref}" to "${currentRef}"; reload Settings and try again`,
280
+ )
281
+ }
282
+ await this.ctx.credentials.set(currentRef, request.value)
283
+ return this.snapshot()
284
+ }
285
+
230
286
  private async health(request: HealthRequest, req: IncomingMessage): Promise<VisionToolkitHealthResult> {
231
287
  if (!this.manager.ready) throw new Error('runtime is not ready; fix Settings and save a valid configuration first')
232
288
  const controller = new AbortController()
@@ -273,32 +329,117 @@ export class VisionToolkitWebBackend {
273
329
  return
274
330
  }
275
331
  try {
276
- if (parsed.action === 'health') {
277
- responseJson(res, 200, { ok: true, value: await this.health(parsed, req) })
278
- } else {
279
- responseJson(res, 200, { ok: true, value: await this.save(parsed) })
332
+ switch (parsed.action) {
333
+ case 'health':
334
+ responseJson(res, 200, { ok: true, value: await this.health(parsed, req) })
335
+ break
336
+ case 'save':
337
+ responseJson(res, 200, { ok: true, value: await this.save(parsed) })
338
+ break
339
+ case 'credential':
340
+ responseJson(res, 200, { ok: true, value: await this.saveCredential(parsed) })
341
+ break
280
342
  }
281
343
  } catch (error) {
282
- const conflict = error instanceof SettingsConflictError
283
- const code = conflict ? 'settings-conflict' : parsed.action === 'health' ? 'health-failed' : 'settings-rejected'
284
- const status = conflict ? 409 : parsed.action === 'health' ? 503 : 400
344
+ const settingsConflict = error instanceof SettingsConflictError
345
+ const credentialConflict = error instanceof CredentialReferenceConflictError
346
+ const code = settingsConflict
347
+ ? 'settings-conflict'
348
+ : credentialConflict
349
+ ? 'credential-conflict'
350
+ : parsed.action === 'health'
351
+ ? 'health-failed'
352
+ : parsed.action === 'credential'
353
+ ? 'credential-rejected'
354
+ : 'settings-rejected'
355
+ const status = settingsConflict || credentialConflict ? 409 : parsed.action === 'health' ? 503 : 400
285
356
  this.ctx.logger.warn('dsh-vision-toolkit Web action=%s failed: %s', parsed.action, publicMessage(error))
286
357
  requestError(res, status, code, publicMessage(error))
287
358
  }
288
359
  }
289
360
  }
290
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
+
291
429
  /**
292
430
  * Attach optional Web routes whenever a webServer service is present.
293
431
  * @param ctx - plugin context owning route effects.
294
432
  * @param backend - Settings handler.
295
433
  * @param artifacts - signed Artifact handler.
434
+ * @param pastedImages - pasted-image workspace handler.
435
+ * @param pastePolicy - paste-policy verdict resolver (sessionId, selection, modelLabel).
296
436
  */
297
437
  export function installVisionToolkitWeb(
298
438
  ctx: Context,
299
439
  backend: VisionToolkitWebBackend,
300
440
  artifacts: ArtifactAccessController,
301
441
  pastedImages: PastedImageBackend,
442
+ pastePolicy: (sessionId: string, selection?: PasteSelectionQuery, modelLabel?: string) => Promise<PasteVerdict>,
302
443
  ): void {
303
444
  ctx.inject(['webServer'], (webCtx) => {
304
445
  webCtx.effect(() => {
@@ -318,7 +459,13 @@ export function installVisionToolkitWeb(
318
459
  path: PASTE_IMAGES_ROUTE,
319
460
  handler: (req, res) => pastedImages.handle(req, res),
320
461
  })
462
+ const disposePastePolicy = webCtx.webServer.register({
463
+ kind: 'exact',
464
+ path: PASTE_POLICY_ROUTE,
465
+ handler: createPastePolicyHandler(pastePolicy),
466
+ })
321
467
  return () => {
468
+ disposePastePolicy()
322
469
  disposePasteImages()
323
470
  disposeSettings()
324
471
  disposeArtifact()