@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.
- package/README.i18n.yaml +2 -2
- package/README.md +34 -17
- package/README.zh.md +32 -7
- package/assets/community-group-qr.png +0 -0
- package/assets/vision-model-test.png +0 -0
- package/docs/requirements-traceability/README.i18n.yaml +2 -2
- package/docs/requirements-traceability/README.md +2 -2
- package/docs/requirements-traceability/README.zh.md +2 -2
- package/lib/client.js +361 -28
- 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/runtime.js +37 -3
- package/lib/runtime.js.map +1 -1
- package/lib/types/client/index.d.ts +16 -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/runtime.d.ts +5 -2
- package/lib/types/runtime.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 +17 -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 +88 -5
- package/lib/web.js.map +1 -1
- package/package.json +2 -1
- package/src/client/index.tsx +53 -14
- 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/runtime.ts +42 -3
- package/src/web-request.ts +12 -2
- package/src/web.ts +90 -4
|
@@ -8,9 +8,12 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
8
8
|
|
|
9
9
|
const SOURCE = 'vision-toolkit-pasted-image'
|
|
10
10
|
export const PASTE_IMAGES_ROUTE = '/_dsh/vision-toolkit/paste-images'
|
|
11
|
+
export const PASTE_POLICY_ROUTE = '/_dsh/vision-toolkit/paste-policy'
|
|
11
12
|
const MAX_IMAGES = 20
|
|
12
13
|
const MAX_IMAGE_BYTES = 20 * 1024 * 1024
|
|
13
14
|
const MAX_BATCH_BYTES = 80 * 1024 * 1024
|
|
15
|
+
/** A confirmed paste verdict older than this is unknown again, even while a refresh is in flight. */
|
|
16
|
+
const VERDICT_MAX_AGE_MS = 15000
|
|
14
17
|
|
|
15
18
|
interface PasteRecord {
|
|
16
19
|
ref: string
|
|
@@ -28,6 +31,20 @@ interface PasteBatch {
|
|
|
28
31
|
unsubscribe?: (() => void) | undefined
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
/** One model route the host asks the browser to switch to before the native paste flow. */
|
|
35
|
+
interface PasteSwitchRoute {
|
|
36
|
+
provider: string
|
|
37
|
+
model: string
|
|
38
|
+
label: string
|
|
39
|
+
reasoningEffort?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A fresh host verdict for one Session and model label. */
|
|
43
|
+
interface PasteVerdictValue {
|
|
44
|
+
takeOver: boolean
|
|
45
|
+
autoSwitch?: PasteSwitchRoute
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
interface PasteResponse {
|
|
32
49
|
ok: boolean
|
|
33
50
|
value?: { absolutePath?: string }
|
|
@@ -105,6 +122,26 @@ function imageFiles(data: DataTransfer | null): File[] {
|
|
|
105
122
|
return candidates.filter(file => file.type.toLowerCase().startsWith('image/'))
|
|
106
123
|
}
|
|
107
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The selector label the model picker currently shows, or '' when none is
|
|
127
|
+
* readable. Matches the host ModelSelect trigger aria-labels ("Select model,
|
|
128
|
+
* current …" / "选择模型,当前 …"); any other label wording falls back to the
|
|
129
|
+
* session-header verdict, which is stale until the next request.
|
|
130
|
+
*/
|
|
131
|
+
function currentModelLabel(): string {
|
|
132
|
+
const buttons = document.querySelectorAll('button[aria-label]')
|
|
133
|
+
for (const button of buttons) {
|
|
134
|
+
const label = button.getAttribute('aria-label') ?? ''
|
|
135
|
+
if (/select model|current model|选择模型/iu.test(label)) return label
|
|
136
|
+
}
|
|
137
|
+
return ''
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Verdict cache key: the model label is part of the answer, so a switch invalidates it. */
|
|
141
|
+
function verdictKey(sessionId: string, modelLabel: string): string {
|
|
142
|
+
return `${sessionId}|${modelLabel}`
|
|
143
|
+
}
|
|
144
|
+
|
|
108
145
|
function validateImages(files: readonly File[]): void {
|
|
109
146
|
if (files.length > MAX_IMAGES) throw new Error(`Paste at most ${MAX_IMAGES} images at a time`)
|
|
110
147
|
let total = 0
|
|
@@ -132,6 +169,14 @@ export class PasteImageController {
|
|
|
132
169
|
private readonly records = new Map<string, PasteRecord>()
|
|
133
170
|
private readonly listeners = new Set<() => void>()
|
|
134
171
|
private revision = 0
|
|
172
|
+
private readonly verdicts = new Map<string, {
|
|
173
|
+
takeOver: boolean
|
|
174
|
+
autoSwitch?: PasteSwitchRoute
|
|
175
|
+
at: number
|
|
176
|
+
pending: boolean
|
|
177
|
+
}>()
|
|
178
|
+
/** Guards the synthetic replay paste from re-entering capture interception. */
|
|
179
|
+
private replaying = false
|
|
135
180
|
|
|
136
181
|
constructor(private readonly ctx: ClientContext) {}
|
|
137
182
|
|
|
@@ -234,29 +279,254 @@ export class PasteImageController {
|
|
|
234
279
|
}
|
|
235
280
|
}
|
|
236
281
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
282
|
+
/**
|
|
283
|
+
* The host's verdict for one Session and selector label, when fresh. The
|
|
284
|
+
* last CONFIRMED answer is authoritative while a background refresh is in
|
|
285
|
+
* flight (the paste acts on what the host last said; the refresh only
|
|
286
|
+
* covers the next paste). A label that changed since the confirmation
|
|
287
|
+
* answers undefined, so the native attachment flow stays the default.
|
|
288
|
+
* @param sessionId - the live Session the paste belongs to.
|
|
289
|
+
* @param modelLabel - the model-selector label currently shown.
|
|
290
|
+
* @returns the fresh confirmed verdict, or undefined when unconfirmed.
|
|
291
|
+
*/
|
|
292
|
+
private verdictFor(sessionId: string, modelLabel: string): PasteVerdictValue | undefined {
|
|
293
|
+
const entry = this.verdicts.get(verdictKey(sessionId, modelLabel))
|
|
294
|
+
if (entry === undefined || entry.at === 0) return undefined
|
|
295
|
+
if (Date.now() - entry.at > VERDICT_MAX_AGE_MS) return undefined
|
|
296
|
+
return { takeOver: entry.takeOver, ...(entry.autoSwitch === undefined ? {} : { autoSwitch: entry.autoSwitch }) }
|
|
297
|
+
}
|
|
242
298
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
299
|
+
/**
|
|
300
|
+
* The exact model route the live model catalog reports for one Session.
|
|
301
|
+
* Unreadable routes answer undefined, so the verdict falls back to the
|
|
302
|
+
* selector label alone.
|
|
303
|
+
* @param sessionId - the live Session id.
|
|
304
|
+
* @returns the current provider/model selection, when readable.
|
|
305
|
+
*/
|
|
306
|
+
private async readSelection(sessionId: string): Promise<{ provider: string; model: string; reasoningEffort?: string } | undefined> {
|
|
307
|
+
const connection = this.ctx.get('connection') as { api: { sessions: {
|
|
308
|
+
models(request: { sessionId: string }): Promise<{ result: {
|
|
309
|
+
ok: true
|
|
310
|
+
value: { current?: { provider: string; model: string; reasoningEffort?: string } | null }
|
|
311
|
+
} | { ok: false; error: { code: string; message: string } } }>
|
|
312
|
+
} } } | undefined
|
|
313
|
+
if (connection === undefined) return undefined
|
|
314
|
+
try {
|
|
315
|
+
const { result } = await connection.api.sessions.models({ sessionId })
|
|
316
|
+
if (!result.ok) return undefined
|
|
317
|
+
const current = result.value.current
|
|
318
|
+
if (current === undefined || current === null || current.provider === '' || current.model === '') return undefined
|
|
319
|
+
return {
|
|
320
|
+
provider: current.provider,
|
|
321
|
+
model: current.model,
|
|
322
|
+
...(current.reasoningEffort === undefined ? {} : { reasoningEffort: current.reasoningEffort }),
|
|
323
|
+
}
|
|
324
|
+
} catch {
|
|
325
|
+
return undefined
|
|
326
|
+
}
|
|
327
|
+
}
|
|
246
328
|
|
|
247
|
-
|
|
248
|
-
|
|
329
|
+
/**
|
|
330
|
+
* Ask the host what to do with a paste for the current model, and cache the
|
|
331
|
+
* answer per Session and selector label. A model switch changes the label,
|
|
332
|
+
* which changes the cache key, so a stale verdict never outlives the model
|
|
333
|
+
* it described. The exact selection rides along when the live model catalog
|
|
334
|
+
* is readable, so the host can answer with an auto-switch route; a 404
|
|
335
|
+
* simply leaves the verdict unconfirmed; the next focus or paste retries.
|
|
336
|
+
* @param sessionId - the live Session to ask about.
|
|
337
|
+
* @param modelLabel - the model-selector label currently shown.
|
|
338
|
+
*/
|
|
339
|
+
refreshVerdict(sessionId: string, modelLabel: string): void {
|
|
340
|
+
const key = verdictKey(sessionId, modelLabel)
|
|
341
|
+
const cached = this.verdicts.get(key)
|
|
342
|
+
// Dedupe only on an in-flight request, never on freshness: the host's
|
|
343
|
+
// model route can change under an unchanged Session id.
|
|
344
|
+
if (cached?.pending) return
|
|
345
|
+
const entry = {
|
|
346
|
+
pending: true,
|
|
347
|
+
takeOver: cached ? cached.takeOver : false,
|
|
348
|
+
at: cached ? cached.at : 0,
|
|
349
|
+
...(cached?.autoSwitch === undefined ? {} : { autoSwitch: cached.autoSwitch }),
|
|
350
|
+
}
|
|
351
|
+
this.verdicts.set(key, entry)
|
|
352
|
+
void (async () => {
|
|
353
|
+
const selection = await this.readSelection(sessionId)
|
|
354
|
+
const query = new URLSearchParams({ sessionId })
|
|
355
|
+
if (modelLabel !== '') query.set('model', modelLabel)
|
|
356
|
+
if (selection !== undefined) {
|
|
357
|
+
query.set('provider', selection.provider)
|
|
358
|
+
query.set('modelId', selection.model)
|
|
359
|
+
if (selection.reasoningEffort !== undefined) query.set('reasoningEffort', selection.reasoningEffort)
|
|
360
|
+
}
|
|
361
|
+
let request: Promise<Response>
|
|
362
|
+
try {
|
|
363
|
+
request = fetch(`${PASTE_POLICY_ROUTE}?${query.toString()}`)
|
|
364
|
+
} catch {
|
|
365
|
+
// No fetch surface (test runtime, pre-fetch bootstrap): leave the
|
|
366
|
+
// verdict unconfirmed rather than letting the paste listener die.
|
|
367
|
+
entry.pending = false
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
request
|
|
371
|
+
.then((response) => {
|
|
372
|
+
if (response.status === 404) {
|
|
373
|
+
// Route not mounted yet (plugin load race, hot reload): forget every
|
|
374
|
+
// verdict and retry on the next focus or paste instead of standing
|
|
375
|
+
// down for the page lifetime.
|
|
376
|
+
this.verdicts.clear()
|
|
377
|
+
return null
|
|
378
|
+
}
|
|
379
|
+
if (!response.ok) throw new Error(`paste policy ${response.status}`)
|
|
380
|
+
return response.json() as Promise<{ ok: true; value: PasteVerdictValue }>
|
|
381
|
+
})
|
|
382
|
+
.then((body) => {
|
|
383
|
+
entry.pending = false
|
|
384
|
+
if (body !== null) {
|
|
385
|
+
entry.takeOver = body.value.takeOver === true
|
|
386
|
+
if (body.value.autoSwitch !== undefined) entry.autoSwitch = body.value.autoSwitch
|
|
387
|
+
else delete entry.autoSwitch
|
|
388
|
+
entry.at = Date.now()
|
|
389
|
+
}
|
|
390
|
+
})
|
|
391
|
+
.catch(() => {
|
|
392
|
+
entry.pending = false
|
|
393
|
+
})
|
|
394
|
+
})()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Switch one Session to the route the host validated, through the same
|
|
399
|
+
* model-directory seat the selector uses when present (so the shared UI
|
|
400
|
+
* state moves with the session), falling back to the raw RPC.
|
|
401
|
+
* @param sessionId - the live Session id.
|
|
402
|
+
* @param route - the validated variant route.
|
|
403
|
+
*/
|
|
404
|
+
private async switchModel(sessionId: string, route: PasteSwitchRoute): Promise<void> {
|
|
405
|
+
const directories = this.ctx.get('modelDirectories') as {
|
|
406
|
+
directoryFor(id: string): { select(selection: { provider: string; model: string; reasoningEffort?: string }): Promise<void> }
|
|
407
|
+
} | undefined
|
|
408
|
+
if (directories !== undefined) {
|
|
409
|
+
// The label is a display hint; the seat only needs the exact route.
|
|
410
|
+
await directories.directoryFor(sessionId).select({
|
|
411
|
+
provider: route.provider,
|
|
412
|
+
model: route.model,
|
|
413
|
+
...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
|
|
414
|
+
})
|
|
415
|
+
return
|
|
416
|
+
}
|
|
417
|
+
const connection = this.ctx.get('connection') as { api: { sessions: {
|
|
418
|
+
selectModel(request: {
|
|
419
|
+
sessionId: string
|
|
420
|
+
provider: string
|
|
421
|
+
model: string
|
|
422
|
+
reasoningEffort?: string
|
|
423
|
+
}): Promise<{ result: { ok: boolean; error?: { code: string; message: string } } }>
|
|
424
|
+
} } } | undefined
|
|
425
|
+
if (connection === undefined) throw new Error('No model switch channel is available in this Web application')
|
|
426
|
+
const { result } = await connection.api.sessions.selectModel({
|
|
427
|
+
sessionId,
|
|
428
|
+
provider: route.provider,
|
|
429
|
+
model: route.model,
|
|
430
|
+
...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
|
|
431
|
+
})
|
|
432
|
+
if (!result.ok) throw new Error(`${result.error?.code ?? 'select-model-failed'}: ${result.error?.message ?? 'unknown error'}`)
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Replay a swallowed paste as a synthetic clipboard event so the composer's
|
|
437
|
+
* own intake (limits, thumbnails, keyboard) runs with the captured files.
|
|
438
|
+
* @returns false when the environment cannot construct a clipboard payload.
|
|
439
|
+
*/
|
|
440
|
+
private replayPaste(target: HTMLTextAreaElement, files: readonly File[], text: string): boolean {
|
|
441
|
+
let data: DataTransfer
|
|
442
|
+
try {
|
|
443
|
+
data = new DataTransfer()
|
|
444
|
+
for (const file of files) data.items.add(file)
|
|
445
|
+
if (text !== '') data.setData('text/plain', text)
|
|
446
|
+
} catch {
|
|
447
|
+
return false
|
|
448
|
+
}
|
|
449
|
+
let event: ClipboardEvent
|
|
450
|
+
try {
|
|
451
|
+
event = new ClipboardEvent('paste', {
|
|
452
|
+
clipboardData: data,
|
|
453
|
+
bubbles: true,
|
|
454
|
+
cancelable: true,
|
|
455
|
+
} as ClipboardEventInit)
|
|
456
|
+
} catch {
|
|
457
|
+
return false
|
|
458
|
+
}
|
|
459
|
+
if (event.clipboardData === null || event.clipboardData.files.length === 0) return false
|
|
460
|
+
this.replaying = true
|
|
461
|
+
try {
|
|
462
|
+
target.dispatchEvent(event)
|
|
463
|
+
} finally {
|
|
464
|
+
this.replaying = false
|
|
465
|
+
}
|
|
466
|
+
return true
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Auto-switch flow: switch the Session to the image-input variant, announce
|
|
471
|
+
* it, then replay the paste into the composer's native intake. A failed
|
|
472
|
+
* switch, or an environment that cannot replay clipboard bytes, degrades to
|
|
473
|
+
* the path takeover with the same files.
|
|
474
|
+
* @param sessionId - the live Session id.
|
|
475
|
+
* @param target - the composer textarea the paste landed on.
|
|
476
|
+
* @param files - the captured image files.
|
|
477
|
+
* @param text - same-paste text, replayed alongside the files.
|
|
478
|
+
* @param route - the validated variant route to switch to.
|
|
479
|
+
*/
|
|
480
|
+
private async autoSwitchPaste(
|
|
481
|
+
sessionId: string,
|
|
482
|
+
target: HTMLTextAreaElement,
|
|
483
|
+
files: readonly File[],
|
|
484
|
+
text: string,
|
|
485
|
+
route: PasteSwitchRoute,
|
|
486
|
+
): Promise<void> {
|
|
249
487
|
const input = this.inputFor(sessionId)
|
|
250
|
-
|
|
251
|
-
|
|
488
|
+
try {
|
|
489
|
+
await this.switchModel(sessionId, route)
|
|
490
|
+
input.notify('info', `Switched to ${route.label || `${route.model} (Vision Toolkit)`}; pasted images now keep the native attachment flow`)
|
|
491
|
+
} catch (error) {
|
|
492
|
+
input.notify('error', `Model switch failed; images will be sent as workspace paths: ${message(error)}`)
|
|
493
|
+
this.takeoverPaste(sessionId, target, files, text)
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
// Replaying lets the composer's own intake run (thumbnail, limits,
|
|
497
|
+
// keyboard); if the environment cannot replay clipboard bytes, the
|
|
498
|
+
// images still land as workspace paths.
|
|
499
|
+
const before = input.state.getSnapshot().imageIds.length
|
|
500
|
+
const replayed = this.replayPaste(target, files, text)
|
|
501
|
+
const after = input.state.getSnapshot().imageIds.length
|
|
502
|
+
if (!replayed || after <= before) {
|
|
503
|
+
this.takeoverPaste(sessionId, target, files, text)
|
|
504
|
+
}
|
|
505
|
+
}
|
|
252
506
|
|
|
507
|
+
/**
|
|
508
|
+
* Path-takeover flow: insert the same-paste text and every image as a text
|
|
509
|
+
* reference that serializes to the image's workspace path on send.
|
|
510
|
+
* @param sessionId - the live Session id.
|
|
511
|
+
* @param target - the composer textarea the paste landed on.
|
|
512
|
+
* @param files - the captured image files.
|
|
513
|
+
* @param text - same-paste text.
|
|
514
|
+
*/
|
|
515
|
+
private takeoverPaste(
|
|
516
|
+
sessionId: string,
|
|
517
|
+
target: HTMLTextAreaElement,
|
|
518
|
+
files: readonly File[],
|
|
519
|
+
text: string,
|
|
520
|
+
): void {
|
|
521
|
+
const input = this.inputFor(sessionId)
|
|
522
|
+
const snapshot = input.state.getSnapshot()
|
|
523
|
+
if (snapshot.phase !== 'plain') return
|
|
253
524
|
const start = Math.max(0, Math.min(target.selectionStart ?? snapshot.draft.length, snapshot.draft.length))
|
|
254
525
|
const end = Math.max(start, Math.min(target.selectionEnd ?? start, snapshot.draft.length))
|
|
255
|
-
const text = (event.clipboardData?.getData('text/plain') ?? '').replaceAll('\uFFFC', '')
|
|
256
526
|
try {
|
|
257
527
|
let cursor = this.insertText(input, text, start, end)
|
|
258
528
|
validateImages(files)
|
|
259
|
-
cursor = this.insertRecords(
|
|
529
|
+
cursor = this.insertRecords(sessionId, input, files, cursor)
|
|
260
530
|
requestAnimationFrame(() => {
|
|
261
531
|
target.focus({ preventScroll: true })
|
|
262
532
|
target.setSelectionRange(cursor, cursor)
|
|
@@ -264,6 +534,43 @@ export class PasteImageController {
|
|
|
264
534
|
} catch (error) {
|
|
265
535
|
input.notify('error', message(error))
|
|
266
536
|
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
handlePaste(event: ClipboardEvent): boolean {
|
|
540
|
+
if (this.replaying) return false
|
|
541
|
+
const files = imageFiles(event.clipboardData)
|
|
542
|
+
if (files.length === 0) return false
|
|
543
|
+
const target = event.target
|
|
544
|
+
if (!(target instanceof HTMLTextAreaElement) || target.closest('[data-composer-card]') === null) return false
|
|
545
|
+
|
|
546
|
+
const sessionId = this.ctx.sessions.list.getSnapshot().current
|
|
547
|
+
if (sessionId === undefined) return false
|
|
548
|
+
const modelLabel = currentModelLabel()
|
|
549
|
+
this.refreshVerdict(sessionId, modelLabel)
|
|
550
|
+
// Only a fresh host verdict acts; the native attachment flow stays the
|
|
551
|
+
// default while the host is unconfirmed.
|
|
552
|
+
const verdict = this.verdictFor(sessionId, modelLabel)
|
|
553
|
+
if (verdict === undefined) return false
|
|
554
|
+
// An image-capable model (the variant routes included) keeps its native
|
|
555
|
+
// paste: no switch, no takeover.
|
|
556
|
+
if (verdict.takeOver === false && verdict.autoSwitch === undefined) return false
|
|
557
|
+
|
|
558
|
+
event.preventDefault()
|
|
559
|
+
event.stopPropagation()
|
|
560
|
+
event.stopImmediatePropagation()
|
|
561
|
+
|
|
562
|
+
const input = this.inputFor(sessionId)
|
|
563
|
+
if (input.state.getSnapshot().phase !== 'plain') return true
|
|
564
|
+
|
|
565
|
+
const text = (event.clipboardData?.getData('text/plain') ?? '').replaceAll('\uFFFC', '')
|
|
566
|
+
if (verdict.autoSwitch !== undefined) {
|
|
567
|
+
// The text-only model has an image-input variant: switch first, then
|
|
568
|
+
// let the paste flow natively so the thumbnail and durable session
|
|
569
|
+
// image are preserved.
|
|
570
|
+
void this.autoSwitchPaste(sessionId, target, files, text, verdict.autoSwitch)
|
|
571
|
+
return true
|
|
572
|
+
}
|
|
573
|
+
this.takeoverPaste(sessionId, target, files, text)
|
|
267
574
|
return true
|
|
268
575
|
}
|
|
269
576
|
|
|
@@ -411,8 +718,17 @@ export function installPasteImages(ctx: ClientContext): void {
|
|
|
411
718
|
})
|
|
412
719
|
ctx.effect(() => {
|
|
413
720
|
const listener = (event: ClipboardEvent): void => { controller.handlePaste(event) }
|
|
721
|
+
// A focus-time prefetch has the verdict ready before the first paste can land.
|
|
722
|
+
const onFocusIn = (): void => {
|
|
723
|
+
const sessionId = ctx.sessions.list.getSnapshot().current
|
|
724
|
+
if (sessionId !== undefined) controller.refreshVerdict(String(sessionId), currentModelLabel())
|
|
725
|
+
}
|
|
414
726
|
document.addEventListener('paste', listener, true)
|
|
415
|
-
|
|
727
|
+
document.addEventListener('focusin', onFocusIn, true)
|
|
728
|
+
return () => {
|
|
729
|
+
document.removeEventListener('paste', listener, true)
|
|
730
|
+
document.removeEventListener('focusin', onFocusIn, true)
|
|
731
|
+
}
|
|
416
732
|
}, 'dsh-vision-toolkit: clipboard image capture')
|
|
417
733
|
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
418
734
|
name: 'conversation.input.dock',
|
package/src/config.ts
CHANGED
|
@@ -54,6 +54,27 @@ export interface VisionToolkitConfig {
|
|
|
54
54
|
}
|
|
55
55
|
/** Extra directories (besides the workspace) inputs may come from. */
|
|
56
56
|
allowedDirs?: string[]
|
|
57
|
+
/**
|
|
58
|
+
* Image-input variants: sibling model-selector entries for every model the
|
|
59
|
+
* host positively declares text-only. A variant declares image input, so
|
|
60
|
+
* pasted images keep the native attachment flow (composer thumbnail and
|
|
61
|
+
* durable session image), and the plugin rewrites image blocks into Vision
|
|
62
|
+
* Toolkit descriptions only on the wire to the model.
|
|
63
|
+
*/
|
|
64
|
+
imageInputVariants?: {
|
|
65
|
+
/** Whether variant routes are registered at all (default true). */
|
|
66
|
+
enabled?: boolean
|
|
67
|
+
/** Restrict wrapped upstream routes by provider id; empty wraps every eligible route. */
|
|
68
|
+
providers?: string[]
|
|
69
|
+
/**
|
|
70
|
+
* Whether the browser paste integration automatically switches the Session
|
|
71
|
+
* to the image-input variant of a text-only model before the paste, so
|
|
72
|
+
* pasted images keep the native attachment flow with no manual model
|
|
73
|
+
* change. Off means a text-only model keeps the path takeover (default
|
|
74
|
+
* true).
|
|
75
|
+
*/
|
|
76
|
+
autoSwitch?: boolean
|
|
77
|
+
}
|
|
57
78
|
}
|
|
58
79
|
|
|
59
80
|
/** Configuration schema with the documented P0 defaults. */
|
|
@@ -77,6 +98,11 @@ export const Config: Schema<VisionToolkitConfig> = z.object({
|
|
|
77
98
|
python: z.string(),
|
|
78
99
|
}),
|
|
79
100
|
allowedDirs: z.array(z.string()).default([]),
|
|
101
|
+
imageInputVariants: z.object({
|
|
102
|
+
enabled: z.boolean().default(true),
|
|
103
|
+
providers: z.array(z.string()).default([]),
|
|
104
|
+
autoSwitch: z.boolean().default(true),
|
|
105
|
+
}),
|
|
80
106
|
})
|
|
81
107
|
|
|
82
108
|
/** Configuration after static validation, with every default materialized. */
|
|
@@ -100,6 +126,11 @@ export interface ResolvedVisionToolkitConfig {
|
|
|
100
126
|
python?: string
|
|
101
127
|
}
|
|
102
128
|
allowedDirs: string[]
|
|
129
|
+
imageInputVariants: {
|
|
130
|
+
enabled: boolean
|
|
131
|
+
providers: string[]
|
|
132
|
+
autoSwitch: boolean
|
|
133
|
+
}
|
|
103
134
|
}
|
|
104
135
|
|
|
105
136
|
const MAX_TIMEOUT_MS = 600000
|
|
@@ -187,6 +218,10 @@ export function resolveConfig(config: VisionToolkitConfig = {}): ResolvedVisionT
|
|
|
187
218
|
throw new VisionToolkitError('config', 'runtime.python must not be empty')
|
|
188
219
|
}
|
|
189
220
|
const allowedDirs = (config.allowedDirs ?? []).map(dir => dir.trim()).filter(dir => dir.length > 0)
|
|
221
|
+
const imageInputVariants = config.imageInputVariants ?? {}
|
|
222
|
+
const variantProviders = (imageInputVariants.providers ?? [])
|
|
223
|
+
.map(provider => provider.trim())
|
|
224
|
+
.filter(provider => provider.length > 0)
|
|
190
225
|
return {
|
|
191
226
|
provider: { baseUrl, credential, model, protocol, anthropicThinking, userAgent },
|
|
192
227
|
language,
|
|
@@ -200,5 +235,10 @@ export function resolveConfig(config: VisionToolkitConfig = {}): ResolvedVisionT
|
|
|
200
235
|
...(python !== undefined ? { python } : {}),
|
|
201
236
|
},
|
|
202
237
|
allowedDirs,
|
|
238
|
+
imageInputVariants: {
|
|
239
|
+
enabled: imageInputVariants.enabled ?? true,
|
|
240
|
+
providers: variantProviders,
|
|
241
|
+
autoSwitch: imageInputVariants.autoSwitch ?? true,
|
|
242
|
+
},
|
|
203
243
|
}
|
|
204
244
|
}
|