@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/lib/client.js CHANGED
@@ -24,9 +24,17 @@ const en = {
24
24
  settingsIntro: 'Configure the pinned visual engineering runtime, its external vision endpoint, and local safety limits.',
25
25
  externalNotice: 'Remote tools send the selected image bytes to the configured external vision API. Local crop, trace, pixel diff, palette, foreground extraction, and HTML rendering do not upload images.',
26
26
  provider: 'Vision service',
27
- providerHint: 'Choose the API protocol, then provide the service address, model, and credential used by online vision features.',
27
+ providerHint: 'Choose the API protocol, then provide the service address, model, and API key used by online vision features.',
28
28
  baseUrl: 'Base URL',
29
- credential: 'Credential reference',
29
+ apiKey: 'API key',
30
+ apiKeyPlaceholderMissing: 'Paste the API key',
31
+ apiKeyPlaceholderConfigured: 'Saved; leave blank to keep it',
32
+ apiKeyHint: 'The key is stored in DSH Credentials and is never shown again after saving.',
33
+ apiKeyLocked: 'The current key comes from a read-only source and cannot be replaced here.',
34
+ apiKeyBlank: 'The API key cannot contain only spaces.',
35
+ apiKeyInvalid: 'Paste only the key, without a variable name, quotes, spaces, or line breaks.',
36
+ credential: 'Credential name',
37
+ credentialHint: 'Advanced: the API key is stored under this name. Keep VISION_API_KEY unless another plugin configuration requires a different reference.',
30
38
  model: 'Model',
31
39
  protocol: 'API protocol',
32
40
  anthropicThinking: 'Anthropic thinking',
@@ -48,7 +56,7 @@ const en = {
48
56
  saving: 'Validating runtime…',
49
57
  reload: 'Reload',
50
58
  saved: 'Settings validated and applied.',
51
- readOnly: 'The active Settings provider is read-only.',
59
+ readOnly: 'Service settings are read-only. A writable API key can still be saved.',
52
60
  configured: 'Configured',
53
61
  missing: 'Missing',
54
62
  source: 'Source',
@@ -62,7 +70,7 @@ const en = {
62
70
  connectionHint: 'Connection testing explicitly sends the configured credential to GET /models. It uploads no image and creates no completion.',
63
71
  saveBeforeTesting: 'Save service changes before testing the connection.',
64
72
  advanced: 'Advanced settings',
65
- advancedHint: 'Provider compatibility, output language, resource limits, runtime source, Python, and additional readable directories.',
73
+ advancedHint: 'Credential name, provider compatibility, output language, resource limits, runtime source, Python, and additional readable directories.',
66
74
  pluginVersion: 'Plugin',
67
75
  upstreamVersion: 'Upstream',
68
76
  activeGeneration: 'Runtime generation',
@@ -153,9 +161,17 @@ const zh = {
153
161
  settingsIntro: '在这里设置视觉模型服务、工具运行环境,以及图片和文件的本地访问范围。',
154
162
  externalNotice: '使用图像理解、目标定位、界面检测或文字识别等在线功能时,所选图片会发送到下方配置的视觉服务。图片裁剪、轮廓描摹、像素对比、主色提取、前景提取和网页截图均在本机完成,不会上传图片。',
155
163
  provider: '在线视觉服务',
156
- providerHint: '选择接口协议后,填写在线视觉功能使用的 API 地址、模型名称和密钥名称。',
164
+ providerHint: '选择接口协议后,填写在线视觉功能使用的 API 地址、模型名称和 API 密钥。',
157
165
  baseUrl: 'API 地址',
158
- credential: 'API 密钥名称',
166
+ apiKey: 'API 密钥',
167
+ apiKeyPlaceholderMissing: '粘贴 API 密钥',
168
+ apiKeyPlaceholderConfigured: '已保存;留空表示不修改',
169
+ apiKeyHint: '密钥会保存到 DSH 凭据存储,保存后不会在页面中回显。',
170
+ apiKeyLocked: '当前密钥来自只读配置,无法在此替换。',
171
+ apiKeyBlank: 'API 密钥不能只包含空格。',
172
+ apiKeyInvalid: '请只粘贴密钥本身,不要包含变量名、引号、空格或换行。',
173
+ credential: '凭据名称',
174
+ credentialHint: '高级用法:API 密钥会按此名称保存。除非其他插件配置要求不同名称,否则保持 VISION_API_KEY。',
159
175
  model: '模型名称',
160
176
  protocol: 'API 协议',
161
177
  anthropicThinking: 'Anthropic thinking',
@@ -177,7 +193,7 @@ const zh = {
177
193
  saving: '正在检查并应用…',
178
194
  reload: '重新加载',
179
195
  saved: '设置已保存并生效。',
180
- readOnly: '当前设置由只读配置提供,无法在此修改。',
196
+ readOnly: '服务设置来自只读配置;如果 API 密钥可写,仍可在此保存密钥。',
181
197
  configured: '已就绪',
182
198
  missing: '未配置',
183
199
  source: '配置来源',
@@ -191,7 +207,7 @@ const zh = {
191
207
  connectionHint: '“检查本地环境”只检查本机依赖和目录;“测试服务连接”会携带已配置的 API 密钥请求 /models,但不会上传图片或调用模型。',
192
208
  saveBeforeTesting: '修改服务配置后,请先保存,再测试连接。',
193
209
  advanced: '高级设置',
194
- advancedHint: '服务兼容参数、结果语言、资源限制、运行环境来源、Python 和额外可读目录。一般无需修改。',
210
+ advancedHint: '凭据名称、服务兼容参数、结果语言、资源限制、运行环境来源、Python 和额外可读目录。一般无需修改。',
195
211
  pluginVersion: '插件版本',
196
212
  upstreamVersion: '工具包版本',
197
213
  activeGeneration: '本次运行已应用',
@@ -541,18 +557,48 @@ class VisionSettingsController {
541
557
  return;
542
558
  void this.load();
543
559
  }
544
- async save(value, expectedRevision) {
560
+ async save(value, expectedRevision, credentialValue, writeSettings) {
545
561
  this.set({ ...this.state, action: 'save', error: undefined, message: undefined });
562
+ let snapshot = this.state.snapshot;
546
563
  try {
547
- const snapshot = await apiRequest({
548
- method: 'POST',
549
- headers: { 'Content-Type': 'application/json' },
550
- body: JSON.stringify({ action: 'save', expectedRevision, value }),
551
- });
564
+ if (writeSettings) {
565
+ snapshot = await apiRequest({
566
+ method: 'POST',
567
+ headers: { 'Content-Type': 'application/json' },
568
+ body: JSON.stringify({ action: 'save', expectedRevision, value }),
569
+ });
570
+ }
571
+ if (snapshot === undefined)
572
+ throw new Error('Vision Toolkit Settings are unavailable');
573
+ if (credentialValue !== undefined) {
574
+ try {
575
+ snapshot = await apiRequest({
576
+ method: 'POST',
577
+ headers: { 'Content-Type': 'application/json' },
578
+ body: JSON.stringify({
579
+ action: 'credential',
580
+ expectedRevision: snapshot.settings.revision,
581
+ ref: snapshot.credential.ref,
582
+ value: credentialValue,
583
+ }),
584
+ });
585
+ }
586
+ catch (error) {
587
+ this.set({
588
+ status: 'ready',
589
+ snapshot,
590
+ health: this.state.health,
591
+ error: error instanceof Error ? error.message : String(error),
592
+ });
593
+ return false;
594
+ }
595
+ }
552
596
  this.set({ status: 'ready', snapshot, health: this.state.health, message: 'saved' });
597
+ return true;
553
598
  }
554
599
  catch (error) {
555
600
  this.set({ ...this.state, action: undefined, error: error instanceof Error ? error.message : String(error) });
601
+ return false;
556
602
  }
557
603
  }
558
604
  async runHealth(testConnection) {
@@ -596,6 +642,18 @@ function positiveInteger(raw, label, t) {
596
642
  throw new Error(t('positiveInteger', { field: label }));
597
643
  return value;
598
644
  }
645
+ function apiKeyFailure(value, t) {
646
+ if (value.length === 0)
647
+ return undefined;
648
+ const trimmed = value.trim();
649
+ if (trimmed.length === 0)
650
+ return t('apiKeyBlank');
651
+ const quoted = trimmed.length > 1 && ['"', '\'', '`'].includes(trimmed[0] ?? '') && trimmed.endsWith(trimmed[0] ?? '');
652
+ const environmentLine = /^[A-Z][A-Z0-9_]*=[^=]/u.test(trimmed);
653
+ if (quoted || environmentLine || !/^[\x21-\x7E]+$/u.test(trimmed))
654
+ return t('apiKeyInvalid');
655
+ return undefined;
656
+ }
599
657
  function valueOf(draft, t) {
600
658
  return {
601
659
  provider: {
@@ -709,6 +767,7 @@ function LoadedSettings({ controller, t }) {
709
767
  const state = (0, react_1.useSyncExternalStore)(controller.subscribe, controller.snapshot, controller.snapshot);
710
768
  const snapshot = state.snapshot;
711
769
  const [draft, setDraft] = (0, react_1.useState)(undefined);
770
+ const [apiKey, setApiKey] = (0, react_1.useState)('');
712
771
  const [draftError, setDraftError] = (0, react_1.useState)(undefined);
713
772
  (0, react_1.useEffect)(() => { if (state.status === 'idle')
714
773
  void controller.load(); }, [controller, state.status]);
@@ -725,16 +784,26 @@ function LoadedSettings({ controller, t }) {
725
784
  const update = (key, value) => setDraft(current => current === undefined ? current : { ...current, [key]: value });
726
785
  const save = () => {
727
786
  try {
787
+ const keyFailure = apiKeyFailure(apiKey, t);
788
+ if (keyFailure !== undefined) {
789
+ setDraftError(keyFailure);
790
+ return;
791
+ }
792
+ const credentialValue = apiKey.length === 0 ? undefined : apiKey.trim();
728
793
  setDraftError(undefined);
729
- void controller.save(valueOf(draft, t), snapshot.settings.revision);
794
+ void controller.save(valueOf(draft, t), snapshot.settings.revision, credentialValue, snapshot.writable).then(saved => { if (saved)
795
+ setApiKey(''); });
730
796
  }
731
797
  catch (error) {
732
798
  setDraftError(error instanceof Error ? error.message : String(error));
733
799
  }
734
800
  };
735
801
  const busy = state.action !== undefined;
802
+ const credentialMatchesSnapshot = draft.credential.trim() === snapshot.credential.ref;
803
+ const keyLocked = credentialMatchesSnapshot && !snapshot.credential.writable;
804
+ const canSave = snapshot.writable || (apiKey.length > 0 && !keyLocked);
736
805
  const runtimeErrorTitle = snapshot.runtime.ready ? t('runtimeCandidateRejected') : t('runtimeUnavailable');
737
- return ((0, jsx_runtime_1.jsxs)("div", { className: "dvt-settings", children: [(0, jsx_runtime_1.jsx)("div", { className: "dvt-alert notice", children: t('externalNotice') }), !snapshot.writable ? (0, jsx_runtime_1.jsx)("div", { className: "dvt-alert warning", children: t('readOnly') }) : null, draftError === undefined ? null : (0, jsx_runtime_1.jsx)("div", { className: "dvt-alert error", children: draftError }), state.error === undefined ? null : (0, jsx_runtime_1.jsx)("div", { className: "dvt-alert error", children: state.error }), state.message === 'saved' ? (0, jsx_runtime_1.jsx)("div", { className: "dvt-alert success", children: t('saved') }) : null, snapshot.runtime.lastError === undefined ? null : (0, jsx_runtime_1.jsxs)("div", { className: "dvt-alert error", children: [(0, jsx_runtime_1.jsx)("strong", { children: runtimeErrorTitle }), (0, jsx_runtime_1.jsx)("span", { children: snapshot.runtime.lastError })] }), (0, jsx_runtime_1.jsxs)("section", { className: "dvt-panel dvt-essential", children: [(0, jsx_runtime_1.jsxs)("div", { className: "dvt-panel-title", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h3", { children: t('provider') }), (0, jsx_runtime_1.jsx)("p", { children: t('providerHint') })] }), (0, jsx_runtime_1.jsx)("span", { className: `dvt-badge ${snapshot.credential.configured ? 'ok' : 'error'}`, children: snapshot.credential.configured ? t('configured') : t('missing') })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-form-grid", children: [(0, jsx_runtime_1.jsx)(Field, { label: t('protocol'), children: (0, jsx_runtime_1.jsxs)("select", { value: draft.protocol, onChange: (event) => { update('protocol', event.target.value); }, children: [(0, jsx_runtime_1.jsx)("option", { value: "openai", children: "OpenAI Chat Completions" }), (0, jsx_runtime_1.jsx)("option", { value: "anthropic", children: "Anthropic Messages" })] }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('baseUrl'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { value: draft.baseUrl, onChange: (event) => { update('baseUrl', event.target.value); } }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('model'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { value: draft.model, onChange: (event) => { update('model', event.target.value); } }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('credential'), hint: snapshot.credential.source === undefined ? undefined : t('sourceHint', { source: t('source'), value: credentialSource(snapshot.credential.source, t) }), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { value: draft.credential, onChange: (event) => { update('credential', event.target.value); } }) })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-save-row", children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "primary", disabled: !snapshot.writable || busy, onClick: save, children: state.action === 'save' ? t('saving') : t('save') }), (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "outline", disabled: busy, onClick: () => { void controller.load(); }, children: t('reload') })] }), (0, jsx_runtime_1.jsxs)("section", { className: "dvt-panel", children: [(0, jsx_runtime_1.jsxs)("div", { className: "dvt-panel-title", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h3", { children: t('health') }), (0, jsx_runtime_1.jsx)("p", { children: t('connectionHint') })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-actions", children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { size: "sm", variant: "outline", disabled: busy || !snapshot.runtime.ready, onClick: () => { void controller.runHealth(false); }, children: state.action === 'health' ? t('testing') : t('runHealth') }), (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { size: "sm", variant: "primary", disabled: busy || !snapshot.runtime.ready, onClick: () => { void controller.runHealth(true); }, children: state.action === 'connection' ? t('testing') : t('testConnection') })] })] }), (0, jsx_runtime_1.jsx)("p", { className: "dvt-muted", children: t('saveBeforeTesting') }), state.health === undefined ? (0, jsx_runtime_1.jsx)("p", { className: "dvt-muted", children: t('notTested') }) : (0, jsx_runtime_1.jsx)("div", { className: "dvt-health-grid", children: Object.entries(state.health.checks).map(([name, check]) => (0, jsx_runtime_1.jsxs)("div", { "data-status": check.status, children: [(0, jsx_runtime_1.jsx)("span", { children: t(HEALTH_NAME_KEYS[name] ?? 'health') }), (0, jsx_runtime_1.jsx)("strong", { children: t(HEALTH_STATUS_KEYS[check.status]) }), (0, jsx_runtime_1.jsx)("p", { children: healthDetail(name, check.detail, t) })] }, name)) })] }), (0, jsx_runtime_1.jsxs)("details", { className: "dvt-advanced", children: [(0, jsx_runtime_1.jsxs)("summary", { children: [(0, jsx_runtime_1.jsxs)("span", { children: [(0, jsx_runtime_1.jsx)("strong", { children: t('advanced') }), (0, jsx_runtime_1.jsx)("small", { children: t('advancedHint') })] }), (0, jsx_runtime_1.jsx)("span", { className: "dvt-details-chevron", "aria-hidden": "true", children: "\u2304" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-advanced-body", children: [(0, jsx_runtime_1.jsxs)("section", { className: "dvt-panel", children: [(0, jsx_runtime_1.jsx)("div", { className: "dvt-panel-title", children: (0, jsx_runtime_1.jsx)("h3", { children: t('provider') }) }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-form-grid", children: [draft.protocol === 'anthropic' ? (0, jsx_runtime_1.jsx)(Field, { label: t('anthropicThinking'), hint: t('anthropicThinkingHint'), children: (0, jsx_runtime_1.jsxs)("select", { "aria-label": t('anthropicThinking'), value: draft.anthropicThinking, onChange: (event) => { update('anthropicThinking', event.target.value); }, children: [(0, jsx_runtime_1.jsx)("option", { value: "omit", children: "omit (widest compatibility)" }), (0, jsx_runtime_1.jsx)("option", { value: "disabled", children: "disabled (model support required)" }), (0, jsx_runtime_1.jsx)("option", { value: "adaptive", children: "adaptive (model support required)" })] }) }) : null, (0, jsx_runtime_1.jsx)(Field, { label: t('userAgent'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { value: draft.userAgent, onChange: (event) => { update('userAgent', event.target.value); } }) })] })] }), (0, jsx_runtime_1.jsxs)("section", { className: "dvt-panel", children: [(0, jsx_runtime_1.jsx)("div", { className: "dvt-panel-title", children: (0, jsx_runtime_1.jsx)("h3", { children: t('limits') }) }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-form-grid", children: [(0, jsx_runtime_1.jsx)(Field, { label: t('language'), children: (0, jsx_runtime_1.jsxs)("select", { value: draft.language, onChange: (event) => { update('language', event.target.value); }, children: [(0, jsx_runtime_1.jsx)("option", { value: "zh", children: "\u4E2D\u6587" }), (0, jsx_runtime_1.jsx)("option", { value: "en", children: "English" })] }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('timeout'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { inputMode: "numeric", value: draft.timeoutMs, onChange: (event) => { update('timeoutMs', event.target.value); } }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('maxBytes'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { inputMode: "numeric", value: draft.maxImageBytes, onChange: (event) => { update('maxImageBytes', event.target.value); } }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('maxPixels'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { inputMode: "numeric", value: draft.maxImagePixels, onChange: (event) => { update('maxImagePixels', event.target.value); } }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('concurrency'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { inputMode: "numeric", value: draft.concurrency, onChange: (event) => { update('concurrency', event.target.value); } }) })] })] }), (0, jsx_runtime_1.jsxs)("section", { className: "dvt-panel", children: [(0, jsx_runtime_1.jsxs)("div", { className: "dvt-panel-title", children: [(0, jsx_runtime_1.jsx)("h3", { children: t('runtime') }), (0, jsx_runtime_1.jsx)("span", { className: `dvt-badge ${snapshot.runtime.ready ? 'ok' : 'error'}`, children: snapshot.runtime.ready ? snapshot.runtime.upstream?.source === 'managed' ? t('runtimeManaged') : snapshot.runtime.upstream?.source === 'external' ? t('runtimeExternal') : t('runtimeReady') : t('runtimeUnavailable') })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-form-grid", children: [(0, jsx_runtime_1.jsx)(Field, { label: t('runtimeMode'), children: (0, jsx_runtime_1.jsxs)("select", { value: draft.runtimeMode, onChange: (event) => { update('runtimeMode', event.target.value); }, children: [(0, jsx_runtime_1.jsx)("option", { value: "managed", children: t('runtimeManaged') }), (0, jsx_runtime_1.jsx)("option", { value: "external", children: t('runtimeExternal') })] }) }), draft.runtimeMode === 'external' ? (0, jsx_runtime_1.jsx)(Field, { label: t('toolkitPath'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { value: draft.toolkitPath, onChange: (event) => { update('toolkitPath', event.target.value); } }) }) : null, (0, jsx_runtime_1.jsx)(Field, { label: t('python'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { placeholder: "python3", value: draft.python, onChange: (event) => { update('python', event.target.value); } }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('allowedDirs'), hint: t('allowedDirsHint'), children: (0, jsx_runtime_1.jsx)("textarea", { rows: 3, value: draft.allowedDirs, onChange: (event) => { update('allowedDirs', event.target.value); } }) })] }), snapshot.runtime.upstream === undefined ? null : (0, jsx_runtime_1.jsxs)("div", { className: "dvt-runtime-facts", children: [(0, jsx_runtime_1.jsx)("code", { children: snapshot.runtime.upstream.path }), (0, jsx_runtime_1.jsxs)("code", { children: [snapshot.runtime.upstream.python, " \u00B7 ", snapshot.runtime.upstream.pythonVersion] }), (0, jsx_runtime_1.jsx)("code", { children: snapshot.runtime.upstream.runtimeHome })] })] })] })] }), (0, jsx_runtime_1.jsxs)("footer", { className: "dvt-settings-footer", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("span", { className: "dvt-kicker", children: t('pluginKind') }), (0, jsx_runtime_1.jsx)("h2", { children: t('settingsTitle') }), (0, jsx_runtime_1.jsx)("p", { children: t('settingsIntro') })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-release", children: [(0, jsx_runtime_1.jsxs)("span", { children: [t('pluginVersion'), " ", (0, jsx_runtime_1.jsx)("strong", { children: snapshot.release.pluginVersion })] }), (0, jsx_runtime_1.jsxs)("span", { children: [t('upstreamVersion'), " ", (0, jsx_runtime_1.jsx)("strong", { children: snapshot.release.upstreamVersion })] }), (0, jsx_runtime_1.jsxs)("span", { children: [t('activeGeneration'), " ", (0, jsx_runtime_1.jsx)("strong", { children: t('activeGenerationValue', { generation: snapshot.runtime.generation }) })] })] })] })] }));
806
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "dvt-settings", children: [(0, jsx_runtime_1.jsx)("div", { className: "dvt-alert notice", children: t('externalNotice') }), !snapshot.writable ? (0, jsx_runtime_1.jsx)("div", { className: "dvt-alert warning", children: t('readOnly') }) : null, draftError === undefined ? null : (0, jsx_runtime_1.jsx)("div", { className: "dvt-alert error", children: draftError }), state.error === undefined ? null : (0, jsx_runtime_1.jsx)("div", { className: "dvt-alert error", children: state.error }), state.message === 'saved' ? (0, jsx_runtime_1.jsx)("div", { className: "dvt-alert success", children: t('saved') }) : null, snapshot.runtime.lastError === undefined ? null : (0, jsx_runtime_1.jsxs)("div", { className: "dvt-alert error", children: [(0, jsx_runtime_1.jsx)("strong", { children: runtimeErrorTitle }), (0, jsx_runtime_1.jsx)("span", { children: snapshot.runtime.lastError })] }), (0, jsx_runtime_1.jsxs)("section", { className: "dvt-panel dvt-essential", children: [(0, jsx_runtime_1.jsxs)("div", { className: "dvt-panel-title", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h3", { children: t('provider') }), (0, jsx_runtime_1.jsx)("p", { children: t('providerHint') })] }), (0, jsx_runtime_1.jsx)("span", { className: `dvt-badge ${snapshot.credential.configured ? 'ok' : 'error'}`, children: snapshot.credential.configured ? t('configured') : t('missing') })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-form-grid", children: [(0, jsx_runtime_1.jsx)(Field, { label: t('protocol'), children: (0, jsx_runtime_1.jsxs)("select", { disabled: !snapshot.writable || busy, value: draft.protocol, onChange: (event) => { update('protocol', event.target.value); }, children: [(0, jsx_runtime_1.jsx)("option", { value: "openai", children: "OpenAI Chat Completions" }), (0, jsx_runtime_1.jsx)("option", { value: "anthropic", children: "Anthropic Messages" })] }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('baseUrl'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { disabled: !snapshot.writable || busy, value: draft.baseUrl, onChange: (event) => { update('baseUrl', event.target.value); } }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('model'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { disabled: !snapshot.writable || busy, value: draft.model, onChange: (event) => { update('model', event.target.value); } }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('apiKey'), hint: keyLocked ? t('apiKeyLocked') : snapshot.credential.source === undefined ? t('apiKeyHint') : `${t('apiKeyHint')} ${t('sourceHint', { source: t('source'), value: credentialSource(snapshot.credential.source, t) })}`, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { "aria-label": t('apiKey'), type: "password", autoComplete: "new-password", disabled: busy || keyLocked, placeholder: snapshot.credential.configured ? t('apiKeyPlaceholderConfigured') : t('apiKeyPlaceholderMissing'), value: apiKey, onChange: (event) => { setApiKey(event.target.value); setDraftError(undefined); } }) })] })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-save-row", children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "primary", disabled: !canSave || busy, onClick: save, children: state.action === 'save' ? t('saving') : t('save') }), (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { variant: "outline", disabled: busy, onClick: () => { void controller.load(); }, children: t('reload') })] }), (0, jsx_runtime_1.jsxs)("section", { className: "dvt-panel", children: [(0, jsx_runtime_1.jsxs)("div", { className: "dvt-panel-title", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("h3", { children: t('health') }), (0, jsx_runtime_1.jsx)("p", { children: t('connectionHint') })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-actions", children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { size: "sm", variant: "outline", disabled: busy || !snapshot.runtime.ready, onClick: () => { void controller.runHealth(false); }, children: state.action === 'health' ? t('testing') : t('runHealth') }), (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Button, { size: "sm", variant: "primary", disabled: busy || !snapshot.runtime.ready, onClick: () => { void controller.runHealth(true); }, children: state.action === 'connection' ? t('testing') : t('testConnection') })] })] }), (0, jsx_runtime_1.jsx)("p", { className: "dvt-muted", children: t('saveBeforeTesting') }), state.health === undefined ? (0, jsx_runtime_1.jsx)("p", { className: "dvt-muted", children: t('notTested') }) : (0, jsx_runtime_1.jsx)("div", { className: "dvt-health-grid", children: Object.entries(state.health.checks).map(([name, check]) => (0, jsx_runtime_1.jsxs)("div", { "data-status": check.status, children: [(0, jsx_runtime_1.jsx)("span", { children: t(HEALTH_NAME_KEYS[name] ?? 'health') }), (0, jsx_runtime_1.jsx)("strong", { children: t(HEALTH_STATUS_KEYS[check.status]) }), (0, jsx_runtime_1.jsx)("p", { children: healthDetail(name, check.detail, t) })] }, name)) })] }), (0, jsx_runtime_1.jsxs)("details", { className: "dvt-advanced", children: [(0, jsx_runtime_1.jsxs)("summary", { children: [(0, jsx_runtime_1.jsxs)("span", { children: [(0, jsx_runtime_1.jsx)("strong", { children: t('advanced') }), (0, jsx_runtime_1.jsx)("small", { children: t('advancedHint') })] }), (0, jsx_runtime_1.jsx)("span", { className: "dvt-details-chevron", "aria-hidden": "true", children: "\u2304" })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-advanced-body", children: [(0, jsx_runtime_1.jsxs)("section", { className: "dvt-panel", children: [(0, jsx_runtime_1.jsx)("div", { className: "dvt-panel-title", children: (0, jsx_runtime_1.jsx)("h3", { children: t('provider') }) }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-form-grid", children: [(0, jsx_runtime_1.jsx)(Field, { label: t('credential'), hint: t('credentialHint'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { "aria-label": t('credential'), disabled: !snapshot.writable || busy, value: draft.credential, onChange: (event) => { update('credential', event.target.value); } }) }), draft.protocol === 'anthropic' ? (0, jsx_runtime_1.jsx)(Field, { label: t('anthropicThinking'), hint: t('anthropicThinkingHint'), children: (0, jsx_runtime_1.jsxs)("select", { "aria-label": t('anthropicThinking'), value: draft.anthropicThinking, onChange: (event) => { update('anthropicThinking', event.target.value); }, children: [(0, jsx_runtime_1.jsx)("option", { value: "omit", children: "omit (widest compatibility)" }), (0, jsx_runtime_1.jsx)("option", { value: "disabled", children: "disabled (model support required)" }), (0, jsx_runtime_1.jsx)("option", { value: "adaptive", children: "adaptive (model support required)" })] }) }) : null, (0, jsx_runtime_1.jsx)(Field, { label: t('userAgent'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { value: draft.userAgent, onChange: (event) => { update('userAgent', event.target.value); } }) })] })] }), (0, jsx_runtime_1.jsxs)("section", { className: "dvt-panel", children: [(0, jsx_runtime_1.jsx)("div", { className: "dvt-panel-title", children: (0, jsx_runtime_1.jsx)("h3", { children: t('limits') }) }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-form-grid", children: [(0, jsx_runtime_1.jsx)(Field, { label: t('language'), children: (0, jsx_runtime_1.jsxs)("select", { value: draft.language, onChange: (event) => { update('language', event.target.value); }, children: [(0, jsx_runtime_1.jsx)("option", { value: "zh", children: "\u4E2D\u6587" }), (0, jsx_runtime_1.jsx)("option", { value: "en", children: "English" })] }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('timeout'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { inputMode: "numeric", value: draft.timeoutMs, onChange: (event) => { update('timeoutMs', event.target.value); } }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('maxBytes'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { inputMode: "numeric", value: draft.maxImageBytes, onChange: (event) => { update('maxImageBytes', event.target.value); } }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('maxPixels'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { inputMode: "numeric", value: draft.maxImagePixels, onChange: (event) => { update('maxImagePixels', event.target.value); } }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('concurrency'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { inputMode: "numeric", value: draft.concurrency, onChange: (event) => { update('concurrency', event.target.value); } }) })] })] }), (0, jsx_runtime_1.jsxs)("section", { className: "dvt-panel", children: [(0, jsx_runtime_1.jsxs)("div", { className: "dvt-panel-title", children: [(0, jsx_runtime_1.jsx)("h3", { children: t('runtime') }), (0, jsx_runtime_1.jsx)("span", { className: `dvt-badge ${snapshot.runtime.ready ? 'ok' : 'error'}`, children: snapshot.runtime.ready ? snapshot.runtime.upstream?.source === 'managed' ? t('runtimeManaged') : snapshot.runtime.upstream?.source === 'external' ? t('runtimeExternal') : t('runtimeReady') : t('runtimeUnavailable') })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-form-grid", children: [(0, jsx_runtime_1.jsx)(Field, { label: t('runtimeMode'), children: (0, jsx_runtime_1.jsxs)("select", { value: draft.runtimeMode, onChange: (event) => { update('runtimeMode', event.target.value); }, children: [(0, jsx_runtime_1.jsx)("option", { value: "managed", children: t('runtimeManaged') }), (0, jsx_runtime_1.jsx)("option", { value: "external", children: t('runtimeExternal') })] }) }), draft.runtimeMode === 'external' ? (0, jsx_runtime_1.jsx)(Field, { label: t('toolkitPath'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { value: draft.toolkitPath, onChange: (event) => { update('toolkitPath', event.target.value); } }) }) : null, (0, jsx_runtime_1.jsx)(Field, { label: t('python'), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.Input, { placeholder: "python3", value: draft.python, onChange: (event) => { update('python', event.target.value); } }) }), (0, jsx_runtime_1.jsx)(Field, { label: t('allowedDirs'), hint: t('allowedDirsHint'), children: (0, jsx_runtime_1.jsx)("textarea", { rows: 3, value: draft.allowedDirs, onChange: (event) => { update('allowedDirs', event.target.value); } }) })] }), snapshot.runtime.upstream === undefined ? null : (0, jsx_runtime_1.jsxs)("div", { className: "dvt-runtime-facts", children: [(0, jsx_runtime_1.jsx)("code", { children: snapshot.runtime.upstream.path }), (0, jsx_runtime_1.jsxs)("code", { children: [snapshot.runtime.upstream.python, " \u00B7 ", snapshot.runtime.upstream.pythonVersion] }), (0, jsx_runtime_1.jsx)("code", { children: snapshot.runtime.upstream.runtimeHome })] })] })] })] }), (0, jsx_runtime_1.jsxs)("footer", { className: "dvt-settings-footer", children: [(0, jsx_runtime_1.jsxs)("div", { children: [(0, jsx_runtime_1.jsx)("span", { className: "dvt-kicker", children: t('pluginKind') }), (0, jsx_runtime_1.jsx)("h2", { children: t('settingsTitle') }), (0, jsx_runtime_1.jsx)("p", { children: t('settingsIntro') })] }), (0, jsx_runtime_1.jsxs)("div", { className: "dvt-release", children: [(0, jsx_runtime_1.jsxs)("span", { children: [t('pluginVersion'), " ", (0, jsx_runtime_1.jsx)("strong", { children: snapshot.release.pluginVersion })] }), (0, jsx_runtime_1.jsxs)("span", { children: [t('upstreamVersion'), " ", (0, jsx_runtime_1.jsx)("strong", { children: snapshot.release.upstreamVersion })] }), (0, jsx_runtime_1.jsxs)("span", { children: [t('activeGeneration'), " ", (0, jsx_runtime_1.jsx)("strong", { children: t('activeGenerationValue', { generation: snapshot.runtime.generation }) })] })] })] })] }));
738
807
  }
739
808
  const CSS = `
740
809
  .dvt-tool{margin:4px 0;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;box-shadow:var(--dsw-shadow-lv1)}
@@ -826,7 +895,7 @@ function apply(ctx) {
826
895
  __modules["./paste-images.js"] = function(module, exports, require, __load_) {
827
896
  "use strict";
828
897
  Object.defineProperty(exports, "__esModule", { value: true });
829
- exports.PasteImageController = exports.PASTE_IMAGES_ROUTE = void 0;
898
+ exports.PasteImageController = exports.PASTE_POLICY_ROUTE = exports.PASTE_IMAGES_ROUTE = void 0;
830
899
  exports.PasteImageDock = PasteImageDock;
831
900
  exports.installPasteImages = installPasteImages;
832
901
  const jsx_runtime_1 = require("react/jsx-runtime");
@@ -834,9 +903,12 @@ const jsx_runtime_1 = require("react/jsx-runtime");
834
903
  const react_1 = require("react");
835
904
  const SOURCE = 'vision-toolkit-pasted-image';
836
905
  exports.PASTE_IMAGES_ROUTE = '/_dsh/vision-toolkit/paste-images';
906
+ exports.PASTE_POLICY_ROUTE = '/_dsh/vision-toolkit/paste-policy';
837
907
  const MAX_IMAGES = 20;
838
908
  const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
839
909
  const MAX_BATCH_BYTES = 80 * 1024 * 1024;
910
+ /** A confirmed paste verdict older than this is unknown again, even while a refresh is in flight. */
911
+ const VERDICT_MAX_AGE_MS = 15000;
840
912
  const CORDIS_ORIGINAL = Symbol.for('cordis.original');
841
913
  function registryIdentity(registry) {
842
914
  let current = registry;
@@ -875,6 +947,25 @@ function imageFiles(data) {
875
947
  const candidates = itemFiles.length > 0 ? itemFiles : Array.from(data.files);
876
948
  return candidates.filter(file => file.type.toLowerCase().startsWith('image/'));
877
949
  }
950
+ /**
951
+ * The selector label the model picker currently shows, or '' when none is
952
+ * readable. Matches the host ModelSelect trigger aria-labels ("Select model,
953
+ * current …" / "选择模型,当前 …"); any other label wording falls back to the
954
+ * session-header verdict, which is stale until the next request.
955
+ */
956
+ function currentModelLabel() {
957
+ const buttons = document.querySelectorAll('button[aria-label]');
958
+ for (const button of buttons) {
959
+ const label = button.getAttribute('aria-label') ?? '';
960
+ if (/select model|current model|选择模型/iu.test(label))
961
+ return label;
962
+ }
963
+ return '';
964
+ }
965
+ /** Verdict cache key: the model label is part of the answer, so a switch invalidates it. */
966
+ function verdictKey(sessionId, modelLabel) {
967
+ return `${sessionId}|${modelLabel}`;
968
+ }
878
969
  function validateImages(files) {
879
970
  if (files.length > MAX_IMAGES)
880
971
  throw new Error(`Paste at most ${MAX_IMAGES} images at a time`);
@@ -906,6 +997,9 @@ class PasteImageController {
906
997
  records = new Map();
907
998
  listeners = new Set();
908
999
  revision = 0;
1000
+ verdicts = new Map();
1001
+ /** Guards the synthetic replay paste from re-entering capture interception. */
1002
+ replaying = false;
909
1003
  constructor(ctx) {
910
1004
  this.ctx = ctx;
911
1005
  }
@@ -1005,30 +1099,246 @@ class PasteImageController {
1005
1099
  throw error;
1006
1100
  }
1007
1101
  }
1008
- handlePaste(event) {
1009
- const files = imageFiles(event.clipboardData);
1010
- if (files.length === 0)
1102
+ /**
1103
+ * The host's verdict for one Session and selector label, when fresh. The
1104
+ * last CONFIRMED answer is authoritative while a background refresh is in
1105
+ * flight (the paste acts on what the host last said; the refresh only
1106
+ * covers the next paste). A label that changed since the confirmation
1107
+ * answers undefined, so the native attachment flow stays the default.
1108
+ * @param sessionId - the live Session the paste belongs to.
1109
+ * @param modelLabel - the model-selector label currently shown.
1110
+ * @returns the fresh confirmed verdict, or undefined when unconfirmed.
1111
+ */
1112
+ verdictFor(sessionId, modelLabel) {
1113
+ const entry = this.verdicts.get(verdictKey(sessionId, modelLabel));
1114
+ if (entry === undefined || entry.at === 0)
1115
+ return undefined;
1116
+ if (Date.now() - entry.at > VERDICT_MAX_AGE_MS)
1117
+ return undefined;
1118
+ return { takeOver: entry.takeOver, ...(entry.autoSwitch === undefined ? {} : { autoSwitch: entry.autoSwitch }) };
1119
+ }
1120
+ /**
1121
+ * The exact model route the live model catalog reports for one Session.
1122
+ * Unreadable routes answer undefined, so the verdict falls back to the
1123
+ * selector label alone.
1124
+ * @param sessionId - the live Session id.
1125
+ * @returns the current provider/model selection, when readable.
1126
+ */
1127
+ async readSelection(sessionId) {
1128
+ const connection = this.ctx.get('connection');
1129
+ if (connection === undefined)
1130
+ return undefined;
1131
+ try {
1132
+ const { result } = await connection.api.sessions.models({ sessionId });
1133
+ if (!result.ok)
1134
+ return undefined;
1135
+ const current = result.value.current;
1136
+ if (current === undefined || current === null || current.provider === '' || current.model === '')
1137
+ return undefined;
1138
+ return {
1139
+ provider: current.provider,
1140
+ model: current.model,
1141
+ ...(current.reasoningEffort === undefined ? {} : { reasoningEffort: current.reasoningEffort }),
1142
+ };
1143
+ }
1144
+ catch {
1145
+ return undefined;
1146
+ }
1147
+ }
1148
+ /**
1149
+ * Ask the host what to do with a paste for the current model, and cache the
1150
+ * answer per Session and selector label. A model switch changes the label,
1151
+ * which changes the cache key, so a stale verdict never outlives the model
1152
+ * it described. The exact selection rides along when the live model catalog
1153
+ * is readable, so the host can answer with an auto-switch route; a 404
1154
+ * simply leaves the verdict unconfirmed; the next focus or paste retries.
1155
+ * @param sessionId - the live Session to ask about.
1156
+ * @param modelLabel - the model-selector label currently shown.
1157
+ */
1158
+ refreshVerdict(sessionId, modelLabel) {
1159
+ const key = verdictKey(sessionId, modelLabel);
1160
+ const cached = this.verdicts.get(key);
1161
+ // Dedupe only on an in-flight request, never on freshness: the host's
1162
+ // model route can change under an unchanged Session id.
1163
+ if (cached?.pending)
1164
+ return;
1165
+ const entry = {
1166
+ pending: true,
1167
+ takeOver: cached ? cached.takeOver : false,
1168
+ at: cached ? cached.at : 0,
1169
+ ...(cached?.autoSwitch === undefined ? {} : { autoSwitch: cached.autoSwitch }),
1170
+ };
1171
+ this.verdicts.set(key, entry);
1172
+ void (async () => {
1173
+ const selection = await this.readSelection(sessionId);
1174
+ const query = new URLSearchParams({ sessionId });
1175
+ if (modelLabel !== '')
1176
+ query.set('model', modelLabel);
1177
+ if (selection !== undefined) {
1178
+ query.set('provider', selection.provider);
1179
+ query.set('modelId', selection.model);
1180
+ if (selection.reasoningEffort !== undefined)
1181
+ query.set('reasoningEffort', selection.reasoningEffort);
1182
+ }
1183
+ let request;
1184
+ try {
1185
+ request = fetch(`${exports.PASTE_POLICY_ROUTE}?${query.toString()}`);
1186
+ }
1187
+ catch {
1188
+ // No fetch surface (test runtime, pre-fetch bootstrap): leave the
1189
+ // verdict unconfirmed rather than letting the paste listener die.
1190
+ entry.pending = false;
1191
+ return;
1192
+ }
1193
+ request
1194
+ .then((response) => {
1195
+ if (response.status === 404) {
1196
+ // Route not mounted yet (plugin load race, hot reload): forget every
1197
+ // verdict and retry on the next focus or paste instead of standing
1198
+ // down for the page lifetime.
1199
+ this.verdicts.clear();
1200
+ return null;
1201
+ }
1202
+ if (!response.ok)
1203
+ throw new Error(`paste policy ${response.status}`);
1204
+ return response.json();
1205
+ })
1206
+ .then((body) => {
1207
+ entry.pending = false;
1208
+ if (body !== null) {
1209
+ entry.takeOver = body.value.takeOver === true;
1210
+ if (body.value.autoSwitch !== undefined)
1211
+ entry.autoSwitch = body.value.autoSwitch;
1212
+ else
1213
+ delete entry.autoSwitch;
1214
+ entry.at = Date.now();
1215
+ }
1216
+ })
1217
+ .catch(() => {
1218
+ entry.pending = false;
1219
+ });
1220
+ })();
1221
+ }
1222
+ /**
1223
+ * Switch one Session to the route the host validated, through the same
1224
+ * model-directory seat the selector uses when present (so the shared UI
1225
+ * state moves with the session), falling back to the raw RPC.
1226
+ * @param sessionId - the live Session id.
1227
+ * @param route - the validated variant route.
1228
+ */
1229
+ async switchModel(sessionId, route) {
1230
+ const directories = this.ctx.get('modelDirectories');
1231
+ if (directories !== undefined) {
1232
+ // The label is a display hint; the seat only needs the exact route.
1233
+ await directories.directoryFor(sessionId).select({
1234
+ provider: route.provider,
1235
+ model: route.model,
1236
+ ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
1237
+ });
1238
+ return;
1239
+ }
1240
+ const connection = this.ctx.get('connection');
1241
+ if (connection === undefined)
1242
+ throw new Error('No model switch channel is available in this Web application');
1243
+ const { result } = await connection.api.sessions.selectModel({
1244
+ sessionId,
1245
+ provider: route.provider,
1246
+ model: route.model,
1247
+ ...(route.reasoningEffort === undefined ? {} : { reasoningEffort: route.reasoningEffort }),
1248
+ });
1249
+ if (!result.ok)
1250
+ throw new Error(`${result.error?.code ?? 'select-model-failed'}: ${result.error?.message ?? 'unknown error'}`);
1251
+ }
1252
+ /**
1253
+ * Replay a swallowed paste as a synthetic clipboard event so the composer's
1254
+ * own intake (limits, thumbnails, keyboard) runs with the captured files.
1255
+ * @returns false when the environment cannot construct a clipboard payload.
1256
+ */
1257
+ replayPaste(target, files, text) {
1258
+ let data;
1259
+ try {
1260
+ data = new DataTransfer();
1261
+ for (const file of files)
1262
+ data.items.add(file);
1263
+ if (text !== '')
1264
+ data.setData('text/plain', text);
1265
+ }
1266
+ catch {
1011
1267
  return false;
1012
- const target = event.target;
1013
- if (!(target instanceof HTMLTextAreaElement) || target.closest('[data-composer-card]') === null)
1268
+ }
1269
+ let event;
1270
+ try {
1271
+ event = new ClipboardEvent('paste', {
1272
+ clipboardData: data,
1273
+ bubbles: true,
1274
+ cancelable: true,
1275
+ });
1276
+ }
1277
+ catch {
1014
1278
  return false;
1015
- event.preventDefault();
1016
- event.stopPropagation();
1017
- event.stopImmediatePropagation();
1018
- const sessionId = this.ctx.sessions.list.getSnapshot().current;
1019
- if (sessionId === undefined)
1020
- return true;
1279
+ }
1280
+ if (event.clipboardData === null || event.clipboardData.files.length === 0)
1281
+ return false;
1282
+ this.replaying = true;
1283
+ try {
1284
+ target.dispatchEvent(event);
1285
+ }
1286
+ finally {
1287
+ this.replaying = false;
1288
+ }
1289
+ return true;
1290
+ }
1291
+ /**
1292
+ * Auto-switch flow: switch the Session to the image-input variant, announce
1293
+ * it, then replay the paste into the composer's native intake. A failed
1294
+ * switch, or an environment that cannot replay clipboard bytes, degrades to
1295
+ * the path takeover with the same files.
1296
+ * @param sessionId - the live Session id.
1297
+ * @param target - the composer textarea the paste landed on.
1298
+ * @param files - the captured image files.
1299
+ * @param text - same-paste text, replayed alongside the files.
1300
+ * @param route - the validated variant route to switch to.
1301
+ */
1302
+ async autoSwitchPaste(sessionId, target, files, text, route) {
1303
+ const input = this.inputFor(sessionId);
1304
+ try {
1305
+ await this.switchModel(sessionId, route);
1306
+ input.notify('info', `Switched to ${route.label || `${route.model} (Vision Toolkit)`}; pasted images now keep the native attachment flow`);
1307
+ }
1308
+ catch (error) {
1309
+ input.notify('error', `Model switch failed; images will be sent as workspace paths: ${message(error)}`);
1310
+ this.takeoverPaste(sessionId, target, files, text);
1311
+ return;
1312
+ }
1313
+ // Replaying lets the composer's own intake run (thumbnail, limits,
1314
+ // keyboard); if the environment cannot replay clipboard bytes, the
1315
+ // images still land as workspace paths.
1316
+ const before = input.state.getSnapshot().imageIds.length;
1317
+ const replayed = this.replayPaste(target, files, text);
1318
+ const after = input.state.getSnapshot().imageIds.length;
1319
+ if (!replayed || after <= before) {
1320
+ this.takeoverPaste(sessionId, target, files, text);
1321
+ }
1322
+ }
1323
+ /**
1324
+ * Path-takeover flow: insert the same-paste text and every image as a text
1325
+ * reference that serializes to the image's workspace path on send.
1326
+ * @param sessionId - the live Session id.
1327
+ * @param target - the composer textarea the paste landed on.
1328
+ * @param files - the captured image files.
1329
+ * @param text - same-paste text.
1330
+ */
1331
+ takeoverPaste(sessionId, target, files, text) {
1021
1332
  const input = this.inputFor(sessionId);
1022
1333
  const snapshot = input.state.getSnapshot();
1023
1334
  if (snapshot.phase !== 'plain')
1024
- return true;
1335
+ return;
1025
1336
  const start = Math.max(0, Math.min(target.selectionStart ?? snapshot.draft.length, snapshot.draft.length));
1026
1337
  const end = Math.max(start, Math.min(target.selectionEnd ?? start, snapshot.draft.length));
1027
- const text = (event.clipboardData?.getData('text/plain') ?? '').replaceAll('\uFFFC', '');
1028
1338
  try {
1029
1339
  let cursor = this.insertText(input, text, start, end);
1030
1340
  validateImages(files);
1031
- cursor = this.insertRecords(String(sessionId), input, files, cursor);
1341
+ cursor = this.insertRecords(sessionId, input, files, cursor);
1032
1342
  requestAnimationFrame(() => {
1033
1343
  target.focus({ preventScroll: true });
1034
1344
  target.setSelectionRange(cursor, cursor);
@@ -1037,6 +1347,45 @@ class PasteImageController {
1037
1347
  catch (error) {
1038
1348
  input.notify('error', message(error));
1039
1349
  }
1350
+ }
1351
+ handlePaste(event) {
1352
+ if (this.replaying)
1353
+ return false;
1354
+ const files = imageFiles(event.clipboardData);
1355
+ if (files.length === 0)
1356
+ return false;
1357
+ const target = event.target;
1358
+ if (!(target instanceof HTMLTextAreaElement) || target.closest('[data-composer-card]') === null)
1359
+ return false;
1360
+ const sessionId = this.ctx.sessions.list.getSnapshot().current;
1361
+ if (sessionId === undefined)
1362
+ return false;
1363
+ const modelLabel = currentModelLabel();
1364
+ this.refreshVerdict(sessionId, modelLabel);
1365
+ // Only a fresh host verdict acts; the native attachment flow stays the
1366
+ // default while the host is unconfirmed.
1367
+ const verdict = this.verdictFor(sessionId, modelLabel);
1368
+ if (verdict === undefined)
1369
+ return false;
1370
+ // An image-capable model (the variant routes included) keeps its native
1371
+ // paste: no switch, no takeover.
1372
+ if (verdict.takeOver === false && verdict.autoSwitch === undefined)
1373
+ return false;
1374
+ event.preventDefault();
1375
+ event.stopPropagation();
1376
+ event.stopImmediatePropagation();
1377
+ const input = this.inputFor(sessionId);
1378
+ if (input.state.getSnapshot().phase !== 'plain')
1379
+ return true;
1380
+ const text = (event.clipboardData?.getData('text/plain') ?? '').replaceAll('\uFFFC', '');
1381
+ if (verdict.autoSwitch !== undefined) {
1382
+ // The text-only model has an image-input variant: switch first, then
1383
+ // let the paste flow natively so the thumbnail and durable session
1384
+ // image are preserved.
1385
+ void this.autoSwitchPaste(sessionId, target, files, text, verdict.autoSwitch);
1386
+ return true;
1387
+ }
1388
+ this.takeoverPaste(sessionId, target, files, text);
1040
1389
  return true;
1041
1390
  }
1042
1391
  remove(sessionId, occurrence) {
@@ -1183,8 +1532,18 @@ function installPasteImages(ctx) {
1183
1532
  });
1184
1533
  ctx.effect(() => {
1185
1534
  const listener = (event) => { controller.handlePaste(event); };
1535
+ // A focus-time prefetch has the verdict ready before the first paste can land.
1536
+ const onFocusIn = () => {
1537
+ const sessionId = ctx.sessions.list.getSnapshot().current;
1538
+ if (sessionId !== undefined)
1539
+ controller.refreshVerdict(String(sessionId), currentModelLabel());
1540
+ };
1186
1541
  document.addEventListener('paste', listener, true);
1187
- return () => { document.removeEventListener('paste', listener, true); };
1542
+ document.addEventListener('focusin', onFocusIn, true);
1543
+ return () => {
1544
+ document.removeEventListener('paste', listener, true);
1545
+ document.removeEventListener('focusin', onFocusIn, true);
1546
+ };
1188
1547
  }, 'dsh-vision-toolkit: clipboard image capture');
1189
1548
  ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
1190
1549
  name: 'conversation.input.dock',