@anionex/dsh-vision-toolkit 0.1.5
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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +383 -0
- package/README.zh.md +383 -0
- package/assets/dsh-conversation-artifact.png +0 -0
- package/assets/dsh-conversation-image-qa-top.png +0 -0
- package/assets/dsh-conversation-image-qa.png +0 -0
- package/assets/dsh-conversation-pixel-diff.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging-top.png +0 -0
- package/assets/dsh-conversation-screenshot-debugging.png +0 -0
- package/assets/dsh-conversation-tool-call.png +0 -0
- package/assets/dsh-conversation-vision-trace.png +0 -0
- package/assets/hero.png +0 -0
- package/assets/social-preview.png +0 -0
- package/assets/upstream/README.md +16 -0
- package/assets/upstream/image-qa.webp +0 -0
- package/assets/upstream/infographic-reference.webp +0 -0
- package/assets/upstream/infographic-result.webp +0 -0
- package/assets/upstream/screenshot-debugging.webp +0 -0
- package/assets/upstream/ui-result.webp +0 -0
- package/assets/upstream/ui-sketch.webp +0 -0
- package/assets/vision-settings.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/assets/vision-settings.png +0 -0
- package/docs/requirements-traceability/README.i18n.yaml +6 -0
- package/docs/requirements-traceability/README.md +75 -0
- package/docs/requirements-traceability/README.zh.md +75 -0
- package/examples/ui-restoration/README.i18n.yaml +6 -0
- package/examples/ui-restoration/README.md +70 -0
- package/examples/ui-restoration/README.zh.md +70 -0
- package/examples/ui-restoration/assets/final-heatmap.png +0 -0
- package/examples/ui-restoration/assets/final-report.json +83 -0
- package/examples/ui-restoration/assets/implementation.png +0 -0
- package/examples/ui-restoration/assets/initial-heatmap.png +0 -0
- package/examples/ui-restoration/assets/initial-report.json +83 -0
- package/examples/ui-restoration/assets/initial.png +0 -0
- package/examples/ui-restoration/assets/metrics.json +12 -0
- package/examples/ui-restoration/assets/reference.png +0 -0
- package/examples/ui-restoration/implementation.html +94 -0
- package/examples/ui-restoration/initial.html +57 -0
- package/lib/artifact-access.js +369 -0
- package/lib/artifact-access.js.map +1 -0
- package/lib/artifacts.js +56 -0
- package/lib/artifacts.js.map +1 -0
- package/lib/client.js +952 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +117 -0
- package/lib/config.js.map +1 -0
- package/lib/errors.js +56 -0
- package/lib/errors.js.map +1 -0
- package/lib/exposure.js +213 -0
- package/lib/exposure.js.map +1 -0
- package/lib/index.js +97 -0
- package/lib/index.js.map +1 -0
- package/lib/paste-images.js +199 -0
- package/lib/paste-images.js.map +1 -0
- package/lib/paths.js +325 -0
- package/lib/paths.js.map +1 -0
- package/lib/runtime-install.js +601 -0
- package/lib/runtime-install.js.map +1 -0
- package/lib/runtime-manager.js +126 -0
- package/lib/runtime-manager.js.map +1 -0
- package/lib/runtime.js +1344 -0
- package/lib/runtime.js.map +1 -0
- package/lib/skill.js +139 -0
- package/lib/skill.js.map +1 -0
- package/lib/tools.js +528 -0
- package/lib/tools.js.map +1 -0
- package/lib/types/artifact-access.d.ts +61 -0
- package/lib/types/artifact-access.d.ts.map +1 -0
- package/lib/types/artifacts.d.ts +42 -0
- package/lib/types/artifacts.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +179 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/paste-images.d.ts +57 -0
- package/lib/types/client/paste-images.d.ts.map +1 -0
- package/lib/types/config.d.ts +73 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/errors.d.ts +35 -0
- package/lib/types/errors.d.ts.map +1 -0
- package/lib/types/exposure.d.ts +40 -0
- package/lib/types/exposure.d.ts.map +1 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/paste-images.d.ts +21 -0
- package/lib/types/paste-images.d.ts.map +1 -0
- package/lib/types/paths.d.ts +107 -0
- package/lib/types/paths.d.ts.map +1 -0
- package/lib/types/runtime-install.d.ts +49 -0
- package/lib/types/runtime-install.d.ts.map +1 -0
- package/lib/types/runtime-manager.d.ts +60 -0
- package/lib/types/runtime-manager.d.ts.map +1 -0
- package/lib/types/runtime.d.ts +389 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/skill.d.ts +15 -0
- package/lib/types/skill.d.ts.map +1 -0
- package/lib/types/tools.d.ts +22 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +207 -0
- package/lib/types/upstream.d.ts.map +1 -0
- package/lib/types/version.d.ts +15 -0
- package/lib/types/version.d.ts.map +1 -0
- package/lib/types/web-request.d.ts +4 -0
- package/lib/types/web-request.d.ts.map +1 -0
- package/lib/types/web.d.ts +74 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/upstream.js +675 -0
- package/lib/upstream.js.map +1 -0
- package/lib/version.js +18 -0
- package/lib/version.js.map +1 -0
- package/lib/web-request.js +20 -0
- package/lib/web-request.js.map +1 -0
- package/lib/web.js +244 -0
- package/lib/web.js.map +1 -0
- package/package.json +139 -0
- package/runtime/requirements.lock +3 -0
- package/src/artifact-access.ts +386 -0
- package/src/artifacts.ts +85 -0
- package/src/client/index.tsx +866 -0
- package/src/client/paste-images.tsx +426 -0
- package/src/config.ts +177 -0
- package/src/errors.ts +62 -0
- package/src/exposure.ts +227 -0
- package/src/index.ts +122 -0
- package/src/paste-images.ts +234 -0
- package/src/paths.ts +348 -0
- package/src/runtime-install.ts +723 -0
- package/src/runtime-manager.ts +166 -0
- package/src/runtime.ts +1783 -0
- package/src/skill.ts +143 -0
- package/src/tools.ts +668 -0
- package/src/upstream.ts +861 -0
- package/src/version.ts +37 -0
- package/src/web-request.ts +17 -0
- package/src/web.ts +329 -0
- package/vendor/agent-vision-toolkit/CHANGELOG.md +16 -0
- package/vendor/agent-vision-toolkit/LICENSE +21 -0
- package/vendor/agent-vision-toolkit/README.md +399 -0
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +89 -0
- package/vendor/agent-vision-toolkit/bin/crop +90 -0
- package/vendor/agent-vision-toolkit/bin/detect +13 -0
- package/vendor/agent-vision-toolkit/bin/glance +93 -0
- package/vendor/agent-vision-toolkit/bin/ground +13 -0
- package/vendor/agent-vision-toolkit/bin/trace +129 -0
- package/vendor/agent-vision-toolkit/detect.py +56 -0
- package/vendor/agent-vision-toolkit/ground.py +216 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/dominant_colors.py +224 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/extract_fg.py +278 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/html_shot.py +108 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
- package/vendor/agent-vision-toolkit/skills/vision-tools/scripts/pixel_diff.py +88 -0
- package/vendor/agent-vision-toolkit/vision_client.py +156 -0
package/src/exposure.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent-scoped progressive exposure for the model-facing visual tools.
|
|
3
|
+
* Runtime readiness is global, while tool schemas enter only an Agent that has
|
|
4
|
+
* loaded the matching Skill; administrative diagnostics stay on the Web seam.
|
|
5
|
+
* @module dsh-vision-toolkit/exposure
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
9
|
+
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
|
10
|
+
import type { Session } from '@deepseek-ai/dsh-session'
|
|
11
|
+
import { defineTool, type ToolDefinition } from '@deepseek-ai/dsh-tools'
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
13
|
+
import { VISION_TOOLS_SKILL_CONTENT, VISION_TOOLS_SKILL_NAME } from './skill.ts'
|
|
14
|
+
|
|
15
|
+
/** Small bootstrap tool retained only until the current Agent gains visual tools. */
|
|
16
|
+
export const VISION_TOOLKIT_ACTIVATE = 'vision_toolkit_activate'
|
|
17
|
+
|
|
18
|
+
interface AgentExposure {
|
|
19
|
+
active: boolean
|
|
20
|
+
hideActivation?: () => void
|
|
21
|
+
toolDisposers: Array<() => void>
|
|
22
|
+
toolNames: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Result returned by the one-shot activation transport. */
|
|
26
|
+
export interface VisionToolkitActivationResult {
|
|
27
|
+
activated: boolean
|
|
28
|
+
tools: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderJson(_args: unknown, value: unknown): ContentBlock[] {
|
|
32
|
+
return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
36
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isVisionSkillArguments(value: unknown): boolean {
|
|
40
|
+
return isRecord(value) && value.name === VISION_TOOLS_SKILL_NAME
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function nativeSkillCall(raw: string): boolean {
|
|
44
|
+
try {
|
|
45
|
+
return isVisionSkillArguments(JSON.parse(raw))
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function containsBundledSkillContent(blocks: readonly unknown[]): boolean {
|
|
52
|
+
return blocks.some(block => isRecord(block)
|
|
53
|
+
&& block.type === 'text'
|
|
54
|
+
&& typeof block.text === 'string'
|
|
55
|
+
&& block.text.includes(VISION_TOOLS_SKILL_CONTENT))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isBundledSkillResult(value: unknown): boolean {
|
|
59
|
+
return isRecord(value)
|
|
60
|
+
&& value.name === VISION_TOOLS_SKILL_NAME
|
|
61
|
+
&& value.content === VISION_TOOLS_SKILL_CONTENT
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Whether durable history proves that this Session loaded the bundled Skill. */
|
|
65
|
+
function hasLoadedVisionSkill(session: Session): boolean {
|
|
66
|
+
const nativeCalls = new Set<string>()
|
|
67
|
+
for (const event of session.events) {
|
|
68
|
+
if (event.type === 'user/message') {
|
|
69
|
+
const source = event.data.source
|
|
70
|
+
if (source.kind === 'skill-invocation'
|
|
71
|
+
&& source.name === VISION_TOOLS_SKILL_NAME
|
|
72
|
+
&& containsBundledSkillContent(event.data.content)) return true
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
if (event.type === 'tool/call') {
|
|
76
|
+
if (event.data.name === 'skill' && nativeSkillCall(event.data.arguments)) {
|
|
77
|
+
nativeCalls.add(String(event.data.callId))
|
|
78
|
+
}
|
|
79
|
+
continue
|
|
80
|
+
}
|
|
81
|
+
if (event.type === 'tool/result') {
|
|
82
|
+
const [block] = event.data.message.content
|
|
83
|
+
if (block?.type === 'tool-result'
|
|
84
|
+
&& block.isError !== true
|
|
85
|
+
&& nativeCalls.has(String(block.toolCallId))
|
|
86
|
+
&& containsBundledSkillContent(block.content)) return true
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (event.type === 'tool/code-dispatch'
|
|
90
|
+
&& event.data.name === 'skill'
|
|
91
|
+
&& event.data.isError === false
|
|
92
|
+
&& isVisionSkillArguments(event.data.arguments)
|
|
93
|
+
&& containsBundledSkillContent(event.data.content)) return true
|
|
94
|
+
}
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Owns one progressive-exposure generation for a ready Vision Toolkit runtime.
|
|
100
|
+
* The bootstrap tool is global; visual definitions are created and registered
|
|
101
|
+
* in an Agent scope only after the Skill load is durable or just succeeded.
|
|
102
|
+
*/
|
|
103
|
+
export class VisionToolExposure {
|
|
104
|
+
readonly activationTool: ToolDefinition
|
|
105
|
+
private readonly states = new Map<Agent, AgentExposure>()
|
|
106
|
+
private installed = false
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param ctx - Plugin context with Tool and Agent registries.
|
|
110
|
+
* @param createTools - Fresh definitions bound to the current runtime generation.
|
|
111
|
+
*/
|
|
112
|
+
constructor(
|
|
113
|
+
private readonly ctx: Context,
|
|
114
|
+
private readonly createTools: () => ToolDefinition[],
|
|
115
|
+
) {
|
|
116
|
+
this.activationTool = defineTool({
|
|
117
|
+
name: VISION_TOOLKIT_ACTIVATE,
|
|
118
|
+
description: `Activate the independent Vision Toolkit execution tools for this Agent after loading the ${VISION_TOOLS_SKILL_NAME} Skill. `
|
|
119
|
+
+ 'The Skill tool normally activates them automatically; call this once only after a direct Skill invocation when the visual tools are still absent. This activation tool disappears after success.',
|
|
120
|
+
parameters: {},
|
|
121
|
+
output: {
|
|
122
|
+
schema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
properties: {
|
|
126
|
+
activated: { type: 'boolean', required: true },
|
|
127
|
+
tools: { type: 'array', items: { type: 'string' }, required: true },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
render: renderJson,
|
|
131
|
+
},
|
|
132
|
+
execute: (_args, exec): Promise<VisionToolkitActivationResult> => {
|
|
133
|
+
if (exec.agent === undefined) {
|
|
134
|
+
throw new Error(`${VISION_TOOLKIT_ACTIVATE}: an Agent Session is required`)
|
|
135
|
+
}
|
|
136
|
+
if (!hasLoadedVisionSkill(exec.agent.session)) {
|
|
137
|
+
throw new Error(`${VISION_TOOLKIT_ACTIVATE}: load the ${VISION_TOOLS_SKILL_NAME} Skill first`)
|
|
138
|
+
}
|
|
139
|
+
return Promise.resolve(this.activate(exec.agent))
|
|
140
|
+
},
|
|
141
|
+
presentCall: () => ({ card: 'generic', title: 'Activate vision tools', kind: 'execute' }),
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Install lifecycle listeners and adopt Agents that already exist. */
|
|
146
|
+
install(): () => void {
|
|
147
|
+
if (this.installed) throw new Error('dsh-vision-toolkit: progressive exposure is already installed')
|
|
148
|
+
this.installed = true
|
|
149
|
+
const listeners = [
|
|
150
|
+
this.ctx.on('agent/created', ({ agent }) => { this.attach(agent) }),
|
|
151
|
+
this.ctx.on('agent/disposed', ({ agent }) => { this.detach(agent) }),
|
|
152
|
+
this.ctx.on('tools/result', (exec, result) => {
|
|
153
|
+
if (result.isError === false
|
|
154
|
+
&& exec.name === 'skill'
|
|
155
|
+
&& exec.agent !== undefined
|
|
156
|
+
&& isVisionSkillArguments(exec.arguments)
|
|
157
|
+
&& isBundledSkillResult(result.value)) {
|
|
158
|
+
this.activate(exec.agent)
|
|
159
|
+
}
|
|
160
|
+
return undefined
|
|
161
|
+
}),
|
|
162
|
+
]
|
|
163
|
+
try {
|
|
164
|
+
for (const agent of this.ctx.agents.list()) this.attach(agent)
|
|
165
|
+
} catch (error) {
|
|
166
|
+
for (const dispose of listeners.reverse()) dispose()
|
|
167
|
+
this.disposeStates()
|
|
168
|
+
this.installed = false
|
|
169
|
+
throw error
|
|
170
|
+
}
|
|
171
|
+
return () => {
|
|
172
|
+
if (!this.installed) return
|
|
173
|
+
this.installed = false
|
|
174
|
+
for (const dispose of listeners.reverse()) dispose()
|
|
175
|
+
this.disposeStates()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private attach(agent: Agent): void {
|
|
180
|
+
if (this.states.has(agent)) return
|
|
181
|
+
this.states.set(agent, { active: false, toolDisposers: [], toolNames: [] })
|
|
182
|
+
if (hasLoadedVisionSkill(agent.session)) this.activate(agent)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private activate(agent: Agent): VisionToolkitActivationResult {
|
|
186
|
+
this.attach(agent)
|
|
187
|
+
const state = this.states.get(agent)
|
|
188
|
+
/* v8 ignore next -- attach() synchronously creates this exact entry. */
|
|
189
|
+
if (state === undefined) throw new Error(`dsh-vision-toolkit: Agent ${String(agent.id)} has no exposure state`)
|
|
190
|
+
if (state.active) return { activated: false, tools: [...state.toolNames] }
|
|
191
|
+
|
|
192
|
+
const definitions = this.createTools()
|
|
193
|
+
const toolDisposers: Array<() => void> = []
|
|
194
|
+
let hideActivation: (() => void) | undefined
|
|
195
|
+
try {
|
|
196
|
+
for (const definition of definitions) toolDisposers.push(agent.ctx.tools.register(definition))
|
|
197
|
+
hideActivation = agent.ctx.tools.restrict({ deny: [VISION_TOOLKIT_ACTIVATE] })
|
|
198
|
+
} catch (error) {
|
|
199
|
+
hideActivation?.()
|
|
200
|
+
for (const dispose of toolDisposers.reverse()) dispose()
|
|
201
|
+
throw error
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
state.active = true
|
|
205
|
+
state.hideActivation = hideActivation
|
|
206
|
+
state.toolDisposers = toolDisposers
|
|
207
|
+
state.toolNames = definitions.map(definition => definition.name)
|
|
208
|
+
return { activated: true, tools: [...state.toolNames] }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private detach(agent: Agent): void {
|
|
212
|
+
const state = this.states.get(agent)
|
|
213
|
+
if (state === undefined) return
|
|
214
|
+
this.states.delete(agent)
|
|
215
|
+
this.disposeState(state)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
private disposeStates(): void {
|
|
219
|
+
for (const state of this.states.values()) this.disposeState(state)
|
|
220
|
+
this.states.clear()
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private disposeState(state: AgentExposure): void {
|
|
224
|
+
state.hideActivation?.()
|
|
225
|
+
for (const dispose of state.toolDisposers.reverse()) dispose()
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @anionex/dsh-vision-toolkit — DSH Vision Toolkit profile bundle.
|
|
3
|
+
*
|
|
4
|
+
* Plugin lifecycle follows the documented readiness chain: verify the pinned
|
|
5
|
+
* upstream checkout, publish the vision-tools Skill and its one-shot bootstrap,
|
|
6
|
+
* then mount the execution tools only in Agents that load that Skill. Any
|
|
7
|
+
* failure leaves no model capability behind, and disposal unregisters every
|
|
8
|
+
* global and Agent-scoped contribution the plugin mounted.
|
|
9
|
+
* @module @anionex/dsh-vision-toolkit
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
13
|
+
import type {} from '@deepseek-ai/dsh-agent'
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-session'
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
16
|
+
import { ArtifactAccessController, prepareArtifactAccessKey } from './artifact-access.ts'
|
|
17
|
+
import {
|
|
18
|
+
Config,
|
|
19
|
+
VISION_TOOLKIT_SETTINGS_NAMESPACE,
|
|
20
|
+
resolveConfig,
|
|
21
|
+
type VisionToolkitConfig,
|
|
22
|
+
} from './config.ts'
|
|
23
|
+
import { VisionToolExposure } from './exposure.ts'
|
|
24
|
+
import { VisionToolkitRuntimeManager } from './runtime-manager.ts'
|
|
25
|
+
import { VISION_TOOLS_SKILL } from './skill.ts'
|
|
26
|
+
import { createVisionTools } from './tools.ts'
|
|
27
|
+
import { PLUGIN_VERSION } from './version.ts'
|
|
28
|
+
import { installVisionToolkitWeb, VisionToolkitWebBackend } from './web.ts'
|
|
29
|
+
import { PastedImageBackend } from './paste-images.ts'
|
|
30
|
+
|
|
31
|
+
export const name = '@anionex/dsh-vision-toolkit'
|
|
32
|
+
|
|
33
|
+
export { Config }
|
|
34
|
+
|
|
35
|
+
export const inject = ['tools', 'credentials', 'skills', 'subprocess', 'settings', 'agents', 'sessions']
|
|
36
|
+
|
|
37
|
+
/** Plugin entry: validate configuration synchronously, then mount asynchronously. */
|
|
38
|
+
export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Promise<() => void> {
|
|
39
|
+
// Registration itself rejects an invalid stored section before any runtime
|
|
40
|
+
// or Tool becomes visible. The custom Web editor preflights runtime changes
|
|
41
|
+
// before persistence; hand-edited settings still fail loud here or retain
|
|
42
|
+
// the last serving generation when changed live.
|
|
43
|
+
const settings = ctx.settings.register(VISION_TOOLKIT_SETTINGS_NAMESPACE, Config, {
|
|
44
|
+
base: config,
|
|
45
|
+
applies: 'live',
|
|
46
|
+
validate: (value) => { resolveConfig(value) },
|
|
47
|
+
})
|
|
48
|
+
const manager = new VisionToolkitRuntimeManager(ctx)
|
|
49
|
+
const artifacts = new ArtifactAccessController(await prepareArtifactAccessKey())
|
|
50
|
+
const lifecycle = new AbortController()
|
|
51
|
+
const disposers: Array<() => void> = []
|
|
52
|
+
let operationalDisposers: { activationTool: () => void; exposure: () => void; skill: () => void } | undefined
|
|
53
|
+
|
|
54
|
+
const ensureOperational = (): void => {
|
|
55
|
+
if (!manager.ready || operationalDisposers !== undefined) return
|
|
56
|
+
const exposure = new VisionToolExposure(ctx, () => createVisionTools(
|
|
57
|
+
() => manager.current(),
|
|
58
|
+
value => artifacts.presentationMeta(value),
|
|
59
|
+
lifecycle.signal,
|
|
60
|
+
))
|
|
61
|
+
let activationTool: (() => void) | undefined
|
|
62
|
+
let exposureDisposer: (() => void) | undefined
|
|
63
|
+
let skill: (() => void) | undefined
|
|
64
|
+
try {
|
|
65
|
+
activationTool = ctx.tools.register(exposure.activationTool)
|
|
66
|
+
skill = ctx.skills.register(VISION_TOOLS_SKILL)
|
|
67
|
+
exposureDisposer = exposure.install()
|
|
68
|
+
operationalDisposers = { activationTool, exposure: exposureDisposer, skill }
|
|
69
|
+
const info = manager.current().upstreamVersion
|
|
70
|
+
ctx.logger.info(
|
|
71
|
+
'dsh-vision-toolkit %s ready (upstream %s @ %s, checkout %s)',
|
|
72
|
+
PLUGIN_VERSION,
|
|
73
|
+
info.version,
|
|
74
|
+
info.commit,
|
|
75
|
+
info.path,
|
|
76
|
+
)
|
|
77
|
+
} catch (error) {
|
|
78
|
+
exposureDisposer?.()
|
|
79
|
+
if (skill !== undefined) skill()
|
|
80
|
+
activationTool?.()
|
|
81
|
+
throw error
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
await manager.initialize(settings.get())
|
|
87
|
+
ensureOperational()
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
90
|
+
ctx.logger.error(
|
|
91
|
+
'dsh-vision-toolkit %s: runtime not ready; the vision-tools skill, activation bootstrap, and Agent-scoped visual tools are NOT registered. Settings remain available for repair. %s',
|
|
92
|
+
PLUGIN_VERSION,
|
|
93
|
+
message,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const backend = new VisionToolkitWebBackend(ctx, manager, artifacts, ensureOperational)
|
|
98
|
+
const pastedImages = new PastedImageBackend(ctx, {
|
|
99
|
+
maxImageBytes: () => manager.status().activeConfig?.maxImageBytes ?? resolveConfig(settings.get()).maxImageBytes,
|
|
100
|
+
})
|
|
101
|
+
installVisionToolkitWeb(ctx, backend, artifacts, pastedImages)
|
|
102
|
+
disposers.push(settings.watch(async (next) => {
|
|
103
|
+
try {
|
|
104
|
+
await manager.reconfigure(next)
|
|
105
|
+
ensureOperational()
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
108
|
+
ctx.logger.error('dsh-vision-toolkit: keeping the previous runtime after a refused Settings generation. %s', message)
|
|
109
|
+
}
|
|
110
|
+
}))
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
lifecycle.abort()
|
|
114
|
+
if (operationalDisposers !== undefined) {
|
|
115
|
+
operationalDisposers.exposure()
|
|
116
|
+
operationalDisposers.activationTool()
|
|
117
|
+
operationalDisposers.skill()
|
|
118
|
+
operationalDisposers = undefined
|
|
119
|
+
}
|
|
120
|
+
for (const dispose of disposers.reverse()) dispose()
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/** Workspace-local storage for images pasted into the DSH Web composer. */
|
|
2
|
+
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
4
|
+
import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
6
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import type {} from '@deepseek-ai/dsh-session'
|
|
9
|
+
import { sameOriginPost } from './web-request.ts'
|
|
10
|
+
|
|
11
|
+
/** Exact route used by the browser paste integration. */
|
|
12
|
+
export const PASTE_IMAGES_ROUTE = '/_dsh/vision-toolkit/paste-images'
|
|
13
|
+
|
|
14
|
+
const MAX_NAME_BYTES = 180
|
|
15
|
+
|
|
16
|
+
interface PasteImageResponse {
|
|
17
|
+
ok: true
|
|
18
|
+
value: { absolutePath: string; filename: string; bytes: number }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface PasteImageFailure {
|
|
22
|
+
ok: false
|
|
23
|
+
error: { code: string; message: string }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type PasteImageResult = PasteImageResponse | PasteImageFailure
|
|
27
|
+
|
|
28
|
+
function responseJson(res: ServerResponse, status: number, body: PasteImageResult): void {
|
|
29
|
+
const bytes = Buffer.from(JSON.stringify(body))
|
|
30
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
31
|
+
res.setHeader('Content-Length', String(bytes.length))
|
|
32
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
33
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
34
|
+
res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'")
|
|
35
|
+
res.writeHead(status)
|
|
36
|
+
res.end(bytes)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requestError(res: ServerResponse, status: number, code: string, message: string): void {
|
|
40
|
+
responseJson(res, status, { ok: false, error: { code, message } })
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function message(error: unknown): string {
|
|
44
|
+
return error instanceof Error ? error.message : String(error)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function singleQuery(url: URL, key: string): string {
|
|
48
|
+
const values = url.searchParams.getAll(key)
|
|
49
|
+
if (values.length !== 1 || values[0] === undefined || values[0] === '') {
|
|
50
|
+
throw new TypeError(`${key} is required exactly once`)
|
|
51
|
+
}
|
|
52
|
+
return values[0]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function declaredSize(url: URL): number {
|
|
56
|
+
const value = Number(singleQuery(url, 'size'))
|
|
57
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError('size must be a positive safe integer')
|
|
58
|
+
return value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function imageMediaType(req: IncomingMessage): string {
|
|
62
|
+
const value = req.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase()
|
|
63
|
+
if (value === undefined || !value.startsWith('image/')) throw new TypeError('Content-Type must be image/*')
|
|
64
|
+
return value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function extensionFor(mediaType: string): string {
|
|
68
|
+
switch (mediaType) {
|
|
69
|
+
case 'image/jpeg': return '.jpg'
|
|
70
|
+
case 'image/png': return '.png'
|
|
71
|
+
case 'image/gif': return '.gif'
|
|
72
|
+
case 'image/webp': return '.webp'
|
|
73
|
+
case 'image/bmp': return '.bmp'
|
|
74
|
+
case 'image/tiff': return '.tiff'
|
|
75
|
+
case 'image/avif': return '.avif'
|
|
76
|
+
case 'image/heic': return '.heic'
|
|
77
|
+
case 'image/heif': return '.heif'
|
|
78
|
+
case 'image/svg+xml': return '.svg'
|
|
79
|
+
default: return '.img'
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Convert an untrusted browser label into one portable leaf filename. */
|
|
84
|
+
export function safePastedImageName(raw: string, mediaType: string): string {
|
|
85
|
+
const leaf = basename(raw.replaceAll('\\', '/')).normalize('NFC')
|
|
86
|
+
let cleaned = leaf
|
|
87
|
+
.replace(/[<>:"|?*\u0000-\u001f/\\]/gu, '_')
|
|
88
|
+
.replace(/\s+/gu, ' ')
|
|
89
|
+
.replace(/^\.+/u, '')
|
|
90
|
+
.trim()
|
|
91
|
+
.replace(/[. ]+$/u, '')
|
|
92
|
+
if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(cleaned)) cleaned = `_${cleaned}`
|
|
93
|
+
const fallback = `clipboard-image${extensionFor(mediaType)}`
|
|
94
|
+
const candidate = cleaned === '' || cleaned === '.' || cleaned === '..' ? fallback : cleaned
|
|
95
|
+
if (Buffer.byteLength(candidate) <= MAX_NAME_BYTES) return candidate
|
|
96
|
+
const extension = extname(candidate).slice(0, 20)
|
|
97
|
+
const budget = Math.max(1, MAX_NAME_BYTES - Buffer.byteLength(extension))
|
|
98
|
+
let stem = candidate.slice(0, Math.max(1, candidate.length - extension.length))
|
|
99
|
+
while (Buffer.byteLength(stem) > budget) stem = stem.slice(0, -1)
|
|
100
|
+
return `${stem}${extension}`
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Reject a resolved path that is not rooted below the expected directory. */
|
|
104
|
+
export function ensurePathInside(root: string, target: string): void {
|
|
105
|
+
const rel = relative(root, target)
|
|
106
|
+
if (rel !== '' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
|
|
107
|
+
throw new Error(`resolved pasted-image path escapes its workspace root: ${target}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface PasteRoot {
|
|
112
|
+
writeRoot: string
|
|
113
|
+
visibleRoot: string
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function ensureManagedDirectory(workspace: string, path: string): Promise<string> {
|
|
117
|
+
try {
|
|
118
|
+
await mkdir(path, { mode: 0o700 })
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
|
|
121
|
+
}
|
|
122
|
+
const entry = await lstat(path)
|
|
123
|
+
if (entry.isSymbolicLink()) {
|
|
124
|
+
throw new Error(`resolved pasted-image path escapes its workspace root: symbolic link ${path}`)
|
|
125
|
+
}
|
|
126
|
+
if (!entry.isDirectory()) throw new Error(`pasted-image path is not a directory: ${path}`)
|
|
127
|
+
const canonical = await realpath(path)
|
|
128
|
+
ensurePathInside(workspace, canonical)
|
|
129
|
+
return canonical
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function sessionPasteRoot(ctx: Context, sessionId: string): Promise<PasteRoot> {
|
|
133
|
+
const session = ctx.sessions.get(sessionId as never)
|
|
134
|
+
if (session === undefined) throw new Error(`live Session not found: ${sessionId}`)
|
|
135
|
+
const cwd = session.header.cwd
|
|
136
|
+
if (cwd === undefined || !isAbsolute(cwd)) throw new Error(`Session has no absolute workspace: ${sessionId}`)
|
|
137
|
+
|
|
138
|
+
const visibleWorkspace = resolve(cwd)
|
|
139
|
+
const workspace = await realpath(visibleWorkspace)
|
|
140
|
+
const pluginRoot = join(visibleWorkspace, '.dsh-vision-toolkit')
|
|
141
|
+
await ensureManagedDirectory(workspace, pluginRoot)
|
|
142
|
+
const temporaryRoot = join(pluginRoot, 'tmp')
|
|
143
|
+
await ensureManagedDirectory(workspace, temporaryRoot)
|
|
144
|
+
const requestedRoot = join(temporaryRoot, 'pasted-images')
|
|
145
|
+
const root = await ensureManagedDirectory(workspace, requestedRoot)
|
|
146
|
+
|
|
147
|
+
const sessionKey = createHash('sha256').update(sessionId).digest('hex').slice(0, 20)
|
|
148
|
+
const requestedSessionRoot = join(requestedRoot, sessionKey)
|
|
149
|
+
const sessionRoot = await ensureManagedDirectory(root, requestedSessionRoot)
|
|
150
|
+
ensurePathInside(root, sessionRoot)
|
|
151
|
+
return { writeRoot: sessionRoot, visibleRoot: requestedSessionRoot }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function writeImage(
|
|
155
|
+
req: IncomingMessage,
|
|
156
|
+
directory: string,
|
|
157
|
+
filename: string,
|
|
158
|
+
expectedBytes: number,
|
|
159
|
+
maxBytes: number,
|
|
160
|
+
): Promise<string> {
|
|
161
|
+
if (expectedBytes > maxBytes) throw new RangeError(`image exceeds the ${maxBytes}-byte paste limit`)
|
|
162
|
+
const id = randomUUID()
|
|
163
|
+
const finalPath = join(directory, `${id}-${filename}`)
|
|
164
|
+
const stagingPath = join(directory, `.${id}.partial`)
|
|
165
|
+
ensurePathInside(directory, finalPath)
|
|
166
|
+
ensurePathInside(directory, stagingPath)
|
|
167
|
+
|
|
168
|
+
const handle = await open(stagingPath, 'wx', 0o600)
|
|
169
|
+
let received = 0
|
|
170
|
+
try {
|
|
171
|
+
for await (const chunk of req) {
|
|
172
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
173
|
+
received += bytes.length
|
|
174
|
+
if (received > expectedBytes || received > maxBytes) throw new RangeError('pasted image body exceeds its declared size')
|
|
175
|
+
await handle.write(bytes)
|
|
176
|
+
}
|
|
177
|
+
if (received !== expectedBytes) {
|
|
178
|
+
throw new Error(`pasted image body size mismatch: expected ${expectedBytes}, received ${received}`)
|
|
179
|
+
}
|
|
180
|
+
await handle.sync()
|
|
181
|
+
await handle.close()
|
|
182
|
+
await rename(stagingPath, finalPath)
|
|
183
|
+
return finalPath
|
|
184
|
+
} catch (error) {
|
|
185
|
+
await handle.close().catch(() => {})
|
|
186
|
+
await rm(stagingPath, { force: true }).catch(() => {})
|
|
187
|
+
throw error
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Runtime limit face kept separate for focused backend tests. */
|
|
192
|
+
export interface PasteImageRuntime {
|
|
193
|
+
maxImageBytes(): number
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Same-origin, live-Session-bound image upload endpoint. */
|
|
197
|
+
export class PastedImageBackend {
|
|
198
|
+
constructor(
|
|
199
|
+
private readonly ctx: Context,
|
|
200
|
+
private readonly runtime: PasteImageRuntime,
|
|
201
|
+
) {}
|
|
202
|
+
|
|
203
|
+
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
204
|
+
if (req.method !== 'POST') {
|
|
205
|
+
res.setHeader('Allow', 'POST')
|
|
206
|
+
requestError(res, 405, 'method-not-allowed', 'Use POST')
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
if (!sameOriginPost(req)) {
|
|
210
|
+
requestError(res, 403, 'origin-rejected', 'The request must originate from this DSH Web application')
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const url = new URL(req.url ?? PASTE_IMAGES_ROUTE, 'http://dsh.internal')
|
|
216
|
+
const sessionId = singleQuery(url, 'sessionId')
|
|
217
|
+
const size = declaredSize(url)
|
|
218
|
+
const mediaType = imageMediaType(req)
|
|
219
|
+
const filename = safePastedImageName(singleQuery(url, 'name'), mediaType)
|
|
220
|
+
const contentLength = req.headers['content-length']
|
|
221
|
+
if (contentLength !== undefined && Number(contentLength) !== size) {
|
|
222
|
+
throw new TypeError('Content-Length does not match the declared size')
|
|
223
|
+
}
|
|
224
|
+
const directory = await sessionPasteRoot(this.ctx, sessionId)
|
|
225
|
+
const writtenPath = await writeImage(req, directory.writeRoot, filename, size, this.runtime.maxImageBytes())
|
|
226
|
+
const absolutePath = join(directory.visibleRoot, basename(writtenPath))
|
|
227
|
+
responseJson(res, 201, { ok: true, value: { absolutePath, filename, bytes: size } })
|
|
228
|
+
} catch (error) {
|
|
229
|
+
const status = error instanceof RangeError ? 413 : 400
|
|
230
|
+
this.ctx.logger.warn('dsh-vision-toolkit pasted image rejected: %s', message(error))
|
|
231
|
+
requestError(res, status, 'paste-image-rejected', message(error))
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|