@mengruo/dsh-vision-toolkit 0.0.1
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 +356 -0
- package/README.zh.md +354 -0
- package/assets/community-group-qr.png +0 -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/dsh-view-example.png +0 -0
- package/assets/hero-v2.png +0 -0
- package/assets/logo_aihubmix.png +0 -0
- package/assets/python-bootstrap.json +48 -0
- package/assets/skill/SKILL.md +329 -0
- package/assets/skill/UPSTREAM.json +71 -0
- package/assets/skill/references/gui.md +88 -0
- package/assets/skill/references/long-screenshot-ocr.md +77 -0
- package/assets/skill/references/restore-graphic.md +84 -0
- package/assets/skill/references/restore-structure.md +45 -0
- package/assets/skill/references/restore-ui.md +202 -0
- package/assets/social-preview.png +0 -0
- package/assets/upstream/README.md +18 -0
- package/assets/upstream/focus-hint-comparison-1.webp +0 -0
- package/assets/upstream/focus-hint-comparison-2.webp +0 -0
- package/assets/upstream/infographic-reference.webp +0 -0
- package/assets/upstream/infographic-result.webp +0 -0
- package/assets/upstream/ui-fast-restore-reference.webp +0 -0
- package/assets/upstream/ui-fast-restore-result.webp +0 -0
- package/assets/upstream/ui-result.webp +0 -0
- package/assets/upstream/ui-sketch.webp +0 -0
- package/assets/vision-model-test.png +0 -0
- package/assets/vision-settings.png +0 -0
- package/assets/wechat-reward.png +0 -0
- package/cordis.patch.yml +6 -0
- package/docs/aihubmix-gemini-vision.i18n.yaml +6 -0
- package/docs/aihubmix-gemini-vision.md +138 -0
- package/docs/aihubmix-gemini-vision.zh.md +138 -0
- package/docs/assets/aihubmix-api-keys.png +0 -0
- package/docs/assets/aihubmix-create-key.png +0 -0
- package/docs/assets/aihubmix-free-vision-model.png +0 -0
- package/docs/assets/aihubmix-home.png +0 -0
- package/docs/assets/aihubmix-sign-up.png +0 -0
- package/docs/assets/groq-console-home.png +0 -0
- package/docs/assets/groq-console-keys-login.png +0 -0
- package/docs/assets/groq-docs-quickstart.png +0 -0
- package/docs/assets/groq-docs-qwen3.6.png +0 -0
- package/docs/assets/groq-docs-vision.png +0 -0
- package/docs/assets/vision-settings.png +0 -0
- package/docs/dsh-desktop-install.md +67 -0
- package/docs/dsh-desktop-install.zh.md +67 -0
- package/docs/groq-qwen3.6-vision.i18n.yaml +6 -0
- package/docs/groq-qwen3.6-vision.md +236 -0
- package/docs/groq-qwen3.6-vision.zh.md +236 -0
- package/docs/python-runtime.i18n.yaml +6 -0
- package/docs/python-runtime.md +89 -0
- package/docs/python-runtime.zh.md +89 -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 +2099 -0
- package/lib/client.js.map +1 -0
- package/lib/config.js +172 -0
- package/lib/config.js.map +1 -0
- package/lib/defaults.js +6 -0
- package/lib/defaults.js.map +1 -0
- package/lib/errors.js +56 -0
- package/lib/errors.js.map +1 -0
- package/lib/evidence-cache.js +350 -0
- package/lib/evidence-cache.js.map +1 -0
- package/lib/exposure.js +247 -0
- package/lib/exposure.js.map +1 -0
- package/lib/image-input-variants.js +882 -0
- package/lib/image-input-variants.js.map +1 -0
- package/lib/index.js +105 -0
- package/lib/index.js.map +1 -0
- package/lib/paste-images.js +217 -0
- package/lib/paste-images.js.map +1 -0
- package/lib/paths.js +354 -0
- package/lib/paths.js.map +1 -0
- package/lib/plugin-update.js +1003 -0
- package/lib/plugin-update.js.map +1 -0
- package/lib/runtime-install.js +964 -0
- package/lib/runtime-install.js.map +1 -0
- package/lib/runtime-manager.js +133 -0
- package/lib/runtime-manager.js.map +1 -0
- package/lib/runtime.js +1678 -0
- package/lib/runtime.js.map +1 -0
- package/lib/skill.js +25 -0
- package/lib/skill.js.map +1 -0
- package/lib/tools.js +549 -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/display-config.d.ts +24 -0
- package/lib/types/client/display-config.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +350 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/model-variants-hider.d.ts +40 -0
- package/lib/types/client/model-variants-hider.d.ts.map +1 -0
- package/lib/types/client/paste-images.d.ts +126 -0
- package/lib/types/client/paste-images.d.ts.map +1 -0
- package/lib/types/config.d.ts +123 -0
- package/lib/types/config.d.ts.map +1 -0
- package/lib/types/defaults.d.ts +6 -0
- package/lib/types/defaults.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/evidence-cache.d.ts +91 -0
- package/lib/types/evidence-cache.d.ts.map +1 -0
- package/lib/types/exposure.d.ts +50 -0
- package/lib/types/exposure.d.ts.map +1 -0
- package/lib/types/image-input-variants.d.ts +144 -0
- package/lib/types/image-input-variants.d.ts.map +1 -0
- package/lib/types/index.d.ts +19 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/paste-images.d.ts +73 -0
- package/lib/types/paste-images.d.ts.map +1 -0
- package/lib/types/paths.d.ts +117 -0
- package/lib/types/paths.d.ts.map +1 -0
- package/lib/types/plugin-update.d.ts +119 -0
- package/lib/types/plugin-update.d.ts.map +1 -0
- package/lib/types/runtime-install.d.ts +92 -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 +412 -0
- package/lib/types/runtime.d.ts.map +1 -0
- package/lib/types/skill.d.ts +14 -0
- package/lib/types/skill.d.ts.map +1 -0
- package/lib/types/tools.d.ts +35 -0
- package/lib/types/tools.d.ts.map +1 -0
- package/lib/types/upstream.d.ts +233 -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 +11 -0
- package/lib/types/web-request.d.ts.map +1 -0
- package/lib/types/web.d.ts +118 -0
- package/lib/types/web.d.ts.map +1 -0
- package/lib/upstream.js +860 -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 +29 -0
- package/lib/web-request.js.map +1 -0
- package/lib/web.js +462 -0
- package/lib/web.js.map +1 -0
- package/package.json +149 -0
- package/patches/vision-tools-dsh.patch +970 -0
- package/runtime/requirements.lock +3 -0
- package/src/artifact-access.ts +386 -0
- package/src/artifacts.ts +85 -0
- package/src/client/display-config.ts +62 -0
- package/src/client/index.tsx +1580 -0
- package/src/client/model-variants-hider.ts +159 -0
- package/src/client/paste-images.tsx +747 -0
- package/src/config.ts +290 -0
- package/src/defaults.ts +5 -0
- package/src/errors.ts +62 -0
- package/src/evidence-cache.ts +418 -0
- package/src/exposure.ts +265 -0
- package/src/image-input-variants.ts +996 -0
- package/src/index.ts +141 -0
- package/src/paste-images.ts +286 -0
- package/src/paths.ts +383 -0
- package/src/plugin-update.ts +1143 -0
- package/src/runtime-install.ts +1161 -0
- package/src/runtime-manager.ts +174 -0
- package/src/runtime.ts +2189 -0
- package/src/skill.ts +35 -0
- package/src/tools.ts +691 -0
- package/src/upstream.ts +1078 -0
- package/src/version.ts +37 -0
- package/src/web-request.ts +27 -0
- package/src/web.ts +594 -0
- package/vendor/agent-vision-toolkit/CHANGELOG.md +30 -0
- package/vendor/agent-vision-toolkit/LICENSE +21 -0
- package/vendor/agent-vision-toolkit/README.md +405 -0
- package/vendor/agent-vision-toolkit/UPSTREAM_MANIFEST.json +94 -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 +60 -0
- package/vendor/agent-vision-toolkit/ground.py +267 -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 +420 -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/tests/test_vision_client.py +450 -0
- package/vendor/agent-vision-toolkit/vision_client.py +310 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mengruo/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-skills Skill and its one-shot bootstrap,
|
|
6
|
+
* then mount the execution tools only in Agents that load that Skill or invoke
|
|
7
|
+
* the bootstrap. Any
|
|
8
|
+
* failure leaves no model capability behind, and disposal unregisters every
|
|
9
|
+
* global and Agent-scoped contribution the plugin mounted.
|
|
10
|
+
* @module @mengruo/dsh-vision-toolkit
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
14
|
+
import type {} from '@deepseek-ai/dsh-agent'
|
|
15
|
+
import type {} from '@deepseek-ai/dsh-session'
|
|
16
|
+
import type {} from '@deepseek-ai/dsh-settings'
|
|
17
|
+
import { ArtifactAccessController, prepareArtifactAccessKey } from './artifact-access.ts'
|
|
18
|
+
import {
|
|
19
|
+
Config,
|
|
20
|
+
VISION_TOOLKIT_SETTINGS_NAMESPACE,
|
|
21
|
+
resolveConfig,
|
|
22
|
+
type VisionToolkitConfig,
|
|
23
|
+
} from './config.ts'
|
|
24
|
+
import { VisionToolExposure } from './exposure.ts'
|
|
25
|
+
import { createPasteTakeoverResolver, installImageInputVariants } from './image-input-variants.ts'
|
|
26
|
+
import { VisionToolkitRuntimeManager } from './runtime-manager.ts'
|
|
27
|
+
import { VISION_SKILLS_SKILL } from './skill.ts'
|
|
28
|
+
import { createVisionTools } from './tools.ts'
|
|
29
|
+
import { PLUGIN_VERSION } from './version.ts'
|
|
30
|
+
import { installVisionToolkitWeb, VisionToolkitWebBackend } from './web.ts'
|
|
31
|
+
import { MAX_PASTE_IMAGE_BYTES, PastedImageBackend } from './paste-images.ts'
|
|
32
|
+
|
|
33
|
+
export const name = '@mengruo/dsh-vision-toolkit'
|
|
34
|
+
|
|
35
|
+
export { Config }
|
|
36
|
+
|
|
37
|
+
export const inject = ['tools', 'credentials', 'skills', 'subprocess', 'settings', 'agents', 'sessions']
|
|
38
|
+
|
|
39
|
+
/** Plugin entry: validate configuration synchronously, then mount asynchronously. */
|
|
40
|
+
export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Promise<() => void> {
|
|
41
|
+
// Registration itself rejects an invalid stored section before any runtime
|
|
42
|
+
// or Tool becomes visible. The custom Web editor preflights runtime changes
|
|
43
|
+
// before persistence; hand-edited settings still fail loud here or retain
|
|
44
|
+
// the last serving generation when changed live.
|
|
45
|
+
const settings = ctx.settings.register(VISION_TOOLKIT_SETTINGS_NAMESPACE, Config, {
|
|
46
|
+
base: config,
|
|
47
|
+
applies: 'live',
|
|
48
|
+
validate: (value) => { resolveConfig(value) },
|
|
49
|
+
})
|
|
50
|
+
const manager = new VisionToolkitRuntimeManager(ctx)
|
|
51
|
+
const artifacts = new ArtifactAccessController(await prepareArtifactAccessKey())
|
|
52
|
+
const lifecycle = new AbortController()
|
|
53
|
+
const disposers: Array<() => void> = []
|
|
54
|
+
let operationalDisposers: { activationTool: () => void; exposure: () => void; skill: () => void } | undefined
|
|
55
|
+
|
|
56
|
+
const ensureOperational = (): void => {
|
|
57
|
+
if (!manager.ready || operationalDisposers !== undefined) return
|
|
58
|
+
const exposure = new VisionToolExposure(ctx, () => createVisionTools(
|
|
59
|
+
() => manager.current(),
|
|
60
|
+
value => artifacts.presentationMeta(value),
|
|
61
|
+
lifecycle.signal,
|
|
62
|
+
))
|
|
63
|
+
let activationTool: (() => void) | undefined
|
|
64
|
+
let exposureDisposer: (() => void) | undefined
|
|
65
|
+
let skill: (() => void) | undefined
|
|
66
|
+
try {
|
|
67
|
+
activationTool = ctx.tools.register(exposure.activationTool)
|
|
68
|
+
skill = ctx.skills.register(VISION_SKILLS_SKILL)
|
|
69
|
+
exposureDisposer = exposure.install()
|
|
70
|
+
operationalDisposers = { activationTool, exposure: exposureDisposer, skill }
|
|
71
|
+
const info = manager.current().upstreamVersion
|
|
72
|
+
ctx.logger.info(
|
|
73
|
+
'dsh-vision-toolkit %s ready (upstream %s @ %s, checkout %s)',
|
|
74
|
+
PLUGIN_VERSION,
|
|
75
|
+
info.version,
|
|
76
|
+
info.commit,
|
|
77
|
+
info.path,
|
|
78
|
+
)
|
|
79
|
+
} catch (error) {
|
|
80
|
+
exposureDisposer?.()
|
|
81
|
+
if (skill !== undefined) skill()
|
|
82
|
+
activationTool?.()
|
|
83
|
+
throw error
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
await manager.initialize(settings.get())
|
|
89
|
+
ensureOperational()
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
92
|
+
ctx.logger.error(
|
|
93
|
+
'dsh-vision-toolkit %s: runtime not ready; the vision-skills skill, activation bootstrap, and Agent-scoped visual tools are NOT registered. Settings remain available for repair. %s',
|
|
94
|
+
PLUGIN_VERSION,
|
|
95
|
+
message,
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const backend = new VisionToolkitWebBackend(ctx, manager, artifacts, ensureOperational)
|
|
100
|
+
const pastedImages = new PastedImageBackend(ctx, {
|
|
101
|
+
maxUploadBytes: () => MAX_PASTE_IMAGE_BYTES,
|
|
102
|
+
})
|
|
103
|
+
// Image-input variants register asynchronously once eligible routes exist;
|
|
104
|
+
// the runtime getter stays lazy so variants appear even when the runtime
|
|
105
|
+
// becomes ready after the first sweep.
|
|
106
|
+
const variants = installImageInputVariants(
|
|
107
|
+
ctx,
|
|
108
|
+
() => resolveConfig(settings.get()),
|
|
109
|
+
() => manager.ready ? manager.current() : undefined,
|
|
110
|
+
)
|
|
111
|
+
installVisionToolkitWeb(
|
|
112
|
+
ctx,
|
|
113
|
+
backend,
|
|
114
|
+
artifacts,
|
|
115
|
+
pastedImages,
|
|
116
|
+
createPasteTakeoverResolver(ctx, () => resolveConfig(settings.get())),
|
|
117
|
+
() => ({ hidden: resolveConfig(settings.get()).imageInputVariants.hidden }),
|
|
118
|
+
)
|
|
119
|
+
disposers.push(variants.dispose)
|
|
120
|
+
disposers.push(settings.watch(async (next) => {
|
|
121
|
+
try {
|
|
122
|
+
await manager.reconfigure(next)
|
|
123
|
+
ensureOperational()
|
|
124
|
+
variants.reconcile()
|
|
125
|
+
} catch (error) {
|
|
126
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
127
|
+
ctx.logger.error('dsh-vision-toolkit: keeping the previous runtime after a refused Settings generation. %s', message)
|
|
128
|
+
}
|
|
129
|
+
}))
|
|
130
|
+
|
|
131
|
+
return () => {
|
|
132
|
+
lifecycle.abort()
|
|
133
|
+
if (operationalDisposers !== undefined) {
|
|
134
|
+
operationalDisposers.exposure()
|
|
135
|
+
operationalDisposers.activationTool()
|
|
136
|
+
operationalDisposers.skill()
|
|
137
|
+
operationalDisposers = undefined
|
|
138
|
+
}
|
|
139
|
+
for (const dispose of disposers.reverse()) dispose()
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
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
|
+
/**
|
|
15
|
+
* Exact route the browser paste integration asks before taking a paste over:
|
|
16
|
+
* `GET ?sessionId=&model=&provider=&modelId=` answers the verdict from the
|
|
17
|
+
* live Session's model route.
|
|
18
|
+
*/
|
|
19
|
+
export const PASTE_POLICY_ROUTE = '/_dsh/vision-toolkit/paste-policy'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One exact model route the browser should switch the Session to before the
|
|
23
|
+
* native attachment flow: the image-input variant of the current text-only
|
|
24
|
+
* model. The variant declares image input, so the paste keeps the composer
|
|
25
|
+
* thumbnail and the durable session image.
|
|
26
|
+
*/
|
|
27
|
+
export interface PasteSwitchRoute {
|
|
28
|
+
/** The variant provider route (`vision-toolkit-` + upstream provider id). */
|
|
29
|
+
provider: string
|
|
30
|
+
/** The model id, identical to the upstream text-only model's id. */
|
|
31
|
+
model: string
|
|
32
|
+
/** The variant's selector display name (upstream name + variant suffix). */
|
|
33
|
+
label: string
|
|
34
|
+
/** The upstream reasoning effort, when the selection carries one. */
|
|
35
|
+
reasoningEffort?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The exact model route the browser read from the live model catalog. */
|
|
39
|
+
export interface PasteSelectionQuery {
|
|
40
|
+
provider: string
|
|
41
|
+
model: string
|
|
42
|
+
reasoningEffort?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The paste-policy answer for one Session and model route. */
|
|
46
|
+
export interface PasteVerdict {
|
|
47
|
+
/** Whether the browser should turn the paste into workspace paths instead of attachments. */
|
|
48
|
+
takeOver: boolean
|
|
49
|
+
/** When present, the browser switches to this route first, then lets the paste flow natively. */
|
|
50
|
+
autoSwitch?: PasteSwitchRoute
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const MAX_NAME_BYTES = 180
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Hard per-image upload ceiling for pastes. Files between the configured
|
|
57
|
+
* `maxImageBytes` (default 4 MiB) and this ceiling are stored as-is and
|
|
58
|
+
* auto-compressed by the runtime on first use; matching the browser-side
|
|
59
|
+
* safety cap keeps the two bundles in agreement.
|
|
60
|
+
*/
|
|
61
|
+
export const MAX_PASTE_IMAGE_BYTES = 20 * 1024 * 1024
|
|
62
|
+
|
|
63
|
+
interface PasteImageResponse {
|
|
64
|
+
ok: true
|
|
65
|
+
value: { absolutePath: string; filename: string; bytes: number }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PasteImageFailure {
|
|
69
|
+
ok: false
|
|
70
|
+
error: { code: string; message: string }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type PasteImageResult = PasteImageResponse | PasteImageFailure
|
|
74
|
+
|
|
75
|
+
function responseJson(res: ServerResponse, status: number, body: PasteImageResult): void {
|
|
76
|
+
const bytes = Buffer.from(JSON.stringify(body))
|
|
77
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
78
|
+
res.setHeader('Content-Length', String(bytes.length))
|
|
79
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
80
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
81
|
+
res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'")
|
|
82
|
+
res.writeHead(status)
|
|
83
|
+
res.end(bytes)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function requestError(res: ServerResponse, status: number, code: string, message: string): void {
|
|
87
|
+
responseJson(res, status, { ok: false, error: { code, message } })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function message(error: unknown): string {
|
|
91
|
+
return error instanceof Error ? error.message : String(error)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function singleQuery(url: URL, key: string): string {
|
|
95
|
+
const values = url.searchParams.getAll(key)
|
|
96
|
+
if (values.length !== 1 || values[0] === undefined || values[0] === '') {
|
|
97
|
+
throw new TypeError(`${key} is required exactly once`)
|
|
98
|
+
}
|
|
99
|
+
return values[0]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function declaredSize(url: URL): number {
|
|
103
|
+
const value = Number(singleQuery(url, 'size'))
|
|
104
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new TypeError('size must be a positive safe integer')
|
|
105
|
+
return value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function imageMediaType(req: IncomingMessage): string {
|
|
109
|
+
const value = req.headers['content-type']?.split(';', 1)[0]?.trim().toLowerCase()
|
|
110
|
+
if (value === undefined || !value.startsWith('image/')) throw new TypeError('Content-Type must be image/*')
|
|
111
|
+
return value
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function extensionFor(mediaType: string): string {
|
|
115
|
+
switch (mediaType) {
|
|
116
|
+
case 'image/jpeg': return '.jpg'
|
|
117
|
+
case 'image/png': return '.png'
|
|
118
|
+
case 'image/gif': return '.gif'
|
|
119
|
+
case 'image/webp': return '.webp'
|
|
120
|
+
case 'image/bmp': return '.bmp'
|
|
121
|
+
case 'image/tiff': return '.tiff'
|
|
122
|
+
case 'image/avif': return '.avif'
|
|
123
|
+
case 'image/heic': return '.heic'
|
|
124
|
+
case 'image/heif': return '.heif'
|
|
125
|
+
case 'image/svg+xml': return '.svg'
|
|
126
|
+
default: return '.img'
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Convert an untrusted browser label into one portable leaf filename. */
|
|
131
|
+
export function safePastedImageName(raw: string, mediaType: string): string {
|
|
132
|
+
const leaf = basename(raw.replaceAll('\\', '/')).normalize('NFC')
|
|
133
|
+
let cleaned = leaf
|
|
134
|
+
.replace(/[<>:"|?*\u0000-\u001f/\\]/gu, '_')
|
|
135
|
+
.replace(/\s+/gu, ' ')
|
|
136
|
+
.replace(/^\.+/u, '')
|
|
137
|
+
.trim()
|
|
138
|
+
.replace(/[. ]+$/u, '')
|
|
139
|
+
if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(cleaned)) cleaned = `_${cleaned}`
|
|
140
|
+
const fallback = `clipboard-image${extensionFor(mediaType)}`
|
|
141
|
+
const candidate = cleaned === '' || cleaned === '.' || cleaned === '..' ? fallback : cleaned
|
|
142
|
+
if (Buffer.byteLength(candidate) <= MAX_NAME_BYTES) return candidate
|
|
143
|
+
const extension = extname(candidate).slice(0, 20)
|
|
144
|
+
const budget = Math.max(1, MAX_NAME_BYTES - Buffer.byteLength(extension))
|
|
145
|
+
let stem = candidate.slice(0, Math.max(1, candidate.length - extension.length))
|
|
146
|
+
while (Buffer.byteLength(stem) > budget) stem = stem.slice(0, -1)
|
|
147
|
+
return `${stem}${extension}`
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Reject a resolved path that is not rooted below the expected directory. */
|
|
151
|
+
export function ensurePathInside(root: string, target: string): void {
|
|
152
|
+
const rel = relative(root, target)
|
|
153
|
+
if (rel !== '' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
|
|
154
|
+
throw new Error(`resolved pasted-image path escapes its workspace root: ${target}`)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export interface PasteRoot {
|
|
159
|
+
writeRoot: string
|
|
160
|
+
visibleRoot: string
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function ensureManagedDirectory(workspace: string, path: string): Promise<string> {
|
|
164
|
+
try {
|
|
165
|
+
await mkdir(path, { mode: 0o700 })
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
|
|
168
|
+
}
|
|
169
|
+
const entry = await lstat(path)
|
|
170
|
+
if (entry.isSymbolicLink()) {
|
|
171
|
+
throw new Error(`resolved pasted-image path escapes its workspace root: symbolic link ${path}`)
|
|
172
|
+
}
|
|
173
|
+
if (!entry.isDirectory()) throw new Error(`pasted-image path is not a directory: ${path}`)
|
|
174
|
+
const canonical = await realpath(path)
|
|
175
|
+
ensurePathInside(workspace, canonical)
|
|
176
|
+
return canonical
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Resolve the managed per-session image directory used by both browser pastes
|
|
181
|
+
* and native attachment bridging. Keeping both flows under the same workspace
|
|
182
|
+
* root makes the resulting absolute path valid for the model's visual tools.
|
|
183
|
+
*/
|
|
184
|
+
export async function sessionPasteRoot(ctx: Context, sessionId: string): Promise<PasteRoot> {
|
|
185
|
+
const session = ctx.sessions.get(sessionId as never)
|
|
186
|
+
if (session === undefined) throw new Error(`live Session not found: ${sessionId}`)
|
|
187
|
+
const cwd = session.header.cwd
|
|
188
|
+
if (cwd === undefined || !isAbsolute(cwd)) throw new Error(`Session has no absolute workspace: ${sessionId}`)
|
|
189
|
+
|
|
190
|
+
const visibleWorkspace = resolve(cwd)
|
|
191
|
+
const workspace = await realpath(visibleWorkspace)
|
|
192
|
+
const pluginRoot = join(visibleWorkspace, '.dsh-vision-toolkit')
|
|
193
|
+
await ensureManagedDirectory(workspace, pluginRoot)
|
|
194
|
+
const temporaryRoot = join(pluginRoot, 'tmp')
|
|
195
|
+
await ensureManagedDirectory(workspace, temporaryRoot)
|
|
196
|
+
const requestedRoot = join(temporaryRoot, 'pasted-images')
|
|
197
|
+
const root = await ensureManagedDirectory(workspace, requestedRoot)
|
|
198
|
+
|
|
199
|
+
const sessionKey = createHash('sha256').update(sessionId).digest('hex').slice(0, 20)
|
|
200
|
+
const requestedSessionRoot = join(requestedRoot, sessionKey)
|
|
201
|
+
const sessionRoot = await ensureManagedDirectory(root, requestedSessionRoot)
|
|
202
|
+
ensurePathInside(root, sessionRoot)
|
|
203
|
+
return { writeRoot: sessionRoot, visibleRoot: requestedSessionRoot }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function writeImage(
|
|
207
|
+
req: IncomingMessage,
|
|
208
|
+
directory: string,
|
|
209
|
+
filename: string,
|
|
210
|
+
expectedBytes: number,
|
|
211
|
+
maxBytes: number,
|
|
212
|
+
): Promise<string> {
|
|
213
|
+
if (expectedBytes > maxBytes) throw new RangeError(`image exceeds the ${maxBytes}-byte paste limit`)
|
|
214
|
+
const id = randomUUID()
|
|
215
|
+
const finalPath = join(directory, `${id}-${filename}`)
|
|
216
|
+
const stagingPath = join(directory, `.${id}.partial`)
|
|
217
|
+
ensurePathInside(directory, finalPath)
|
|
218
|
+
ensurePathInside(directory, stagingPath)
|
|
219
|
+
|
|
220
|
+
const handle = await open(stagingPath, 'wx', 0o600)
|
|
221
|
+
let received = 0
|
|
222
|
+
try {
|
|
223
|
+
for await (const chunk of req) {
|
|
224
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
225
|
+
received += bytes.length
|
|
226
|
+
if (received > expectedBytes || received > maxBytes) throw new RangeError('pasted image body exceeds its declared size')
|
|
227
|
+
await handle.write(bytes)
|
|
228
|
+
}
|
|
229
|
+
if (received !== expectedBytes) {
|
|
230
|
+
throw new Error(`pasted image body size mismatch: expected ${expectedBytes}, received ${received}`)
|
|
231
|
+
}
|
|
232
|
+
await handle.sync()
|
|
233
|
+
await handle.close()
|
|
234
|
+
await rename(stagingPath, finalPath)
|
|
235
|
+
return finalPath
|
|
236
|
+
} catch (error) {
|
|
237
|
+
await handle.close().catch(() => {})
|
|
238
|
+
await rm(stagingPath, { force: true }).catch(() => {})
|
|
239
|
+
throw error
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Runtime limit face kept separate for focused backend tests. */
|
|
244
|
+
export interface PasteImageRuntime {
|
|
245
|
+
maxUploadBytes(): number
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Same-origin, live-Session-bound image upload endpoint. */
|
|
249
|
+
export class PastedImageBackend {
|
|
250
|
+
constructor(
|
|
251
|
+
private readonly ctx: Context,
|
|
252
|
+
private readonly runtime: PasteImageRuntime,
|
|
253
|
+
) {}
|
|
254
|
+
|
|
255
|
+
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
256
|
+
if (req.method !== 'POST') {
|
|
257
|
+
res.setHeader('Allow', 'POST')
|
|
258
|
+
requestError(res, 405, 'method-not-allowed', 'Use POST')
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
if (!sameOriginPost(req)) {
|
|
262
|
+
requestError(res, 403, 'origin-rejected', 'The request must originate from this DSH Web application')
|
|
263
|
+
return
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const url = new URL(req.url ?? PASTE_IMAGES_ROUTE, 'http://dsh.internal')
|
|
268
|
+
const sessionId = singleQuery(url, 'sessionId')
|
|
269
|
+
const size = declaredSize(url)
|
|
270
|
+
const mediaType = imageMediaType(req)
|
|
271
|
+
const filename = safePastedImageName(singleQuery(url, 'name'), mediaType)
|
|
272
|
+
const contentLength = req.headers['content-length']
|
|
273
|
+
if (contentLength !== undefined && Number(contentLength) !== size) {
|
|
274
|
+
throw new TypeError('Content-Length does not match the declared size')
|
|
275
|
+
}
|
|
276
|
+
const directory = await sessionPasteRoot(this.ctx, sessionId)
|
|
277
|
+
const writtenPath = await writeImage(req, directory.writeRoot, filename, size, this.runtime.maxUploadBytes())
|
|
278
|
+
const absolutePath = join(directory.visibleRoot, basename(writtenPath))
|
|
279
|
+
responseJson(res, 201, { ok: true, value: { absolutePath, filename, bytes: size } })
|
|
280
|
+
} catch (error) {
|
|
281
|
+
const status = error instanceof RangeError ? 413 : 400
|
|
282
|
+
this.ctx.logger.warn('dsh-vision-toolkit pasted image rejected: %s', message(error))
|
|
283
|
+
requestError(res, status, 'paste-image-rejected', message(error))
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|