@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/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
|
|
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
|
-
|
|
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: '
|
|
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: '
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
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
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
1016
|
-
event.
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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',
|