@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.
- package/README.i18n.yaml +2 -2
- package/README.md +23 -11
- package/README.zh.md +23 -11
- package/lib/client.js +391 -32
- package/lib/client.js.map +1 -1
- package/lib/config.js +14 -0
- package/lib/config.js.map +1 -1
- package/lib/image-input-variants.js +615 -0
- package/lib/image-input-variants.js.map +1 -0
- package/lib/index.js +8 -1
- package/lib/index.js.map +1 -1
- package/lib/paste-images.js +6 -0
- package/lib/paste-images.js.map +1 -1
- package/lib/types/client/index.d.ts +13 -5
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/paste-images.d.ts +69 -0
- package/lib/types/client/paste-images.d.ts.map +1 -1
- package/lib/types/config.d.ts +26 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/image-input-variants.d.ts +153 -0
- package/lib/types/image-input-variants.d.ts.map +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/paste-images.d.ts +35 -0
- package/lib/types/paste-images.d.ts.map +1 -1
- package/lib/types/web-request.d.ts +7 -0
- package/lib/types/web-request.d.ts.map +1 -1
- package/lib/types/web.d.ts +18 -2
- package/lib/types/web.d.ts.map +1 -1
- package/lib/web-request.js +11 -2
- package/lib/web-request.js.map +1 -1
- package/lib/web.js +142 -11
- package/lib/web.js.map +1 -1
- package/package.json +2 -1
- package/src/client/index.tsx +95 -20
- package/src/client/paste-images.tsx +331 -15
- package/src/config.ts +40 -0
- package/src/image-input-variants.ts +688 -0
- package/src/index.ts +18 -1
- package/src/paste-images.ts +39 -0
- package/src/web-request.ts +12 -2
- 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
|
-
|
|
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)
|
package/src/paste-images.ts
CHANGED
|
@@ -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/web-request.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { IncomingMessage } from 'node:http'
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
|
283
|
-
const
|
|
284
|
-
const
|
|
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()
|