@anionex/dsh-vision-toolkit 0.1.39 → 0.1.40
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 +10 -2
- package/README.zh.md +10 -2
- package/assets/logo_eapi_dark.png +0 -0
- package/docs/aihubmix-gemini-vision.i18n.yaml +2 -2
- package/docs/aihubmix-gemini-vision.md +2 -2
- package/docs/aihubmix-gemini-vision.zh.md +2 -2
- package/docs/requirements-traceability/README.i18n.yaml +2 -2
- package/docs/requirements-traceability/README.md +1 -1
- package/docs/requirements-traceability/README.zh.md +1 -1
- package/lib/artifact-access.js +20 -2
- package/lib/artifact-access.js.map +1 -1
- package/lib/client.js +11 -3
- package/lib/client.js.map +1 -1
- package/lib/config.js +46 -2
- package/lib/config.js.map +1 -1
- package/lib/evidence-cache.js +2 -1
- package/lib/evidence-cache.js.map +1 -1
- package/lib/image-input-variants.js +61 -26
- package/lib/image-input-variants.js.map +1 -1
- package/lib/index.js +53 -6
- package/lib/index.js.map +1 -1
- package/lib/paste-images.js +67 -19
- package/lib/paste-images.js.map +1 -1
- package/lib/paths.js +214 -28
- package/lib/paths.js.map +1 -1
- package/lib/runtime-install.js +147 -4
- package/lib/runtime-install.js.map +1 -1
- package/lib/runtime-manager.js +76 -10
- package/lib/runtime-manager.js.map +1 -1
- package/lib/runtime.js +16 -10
- package/lib/runtime.js.map +1 -1
- package/lib/storage-history.js +154 -0
- package/lib/storage-history.js.map +1 -0
- package/lib/types/artifact-access.d.ts.map +1 -1
- package/lib/types/client/index.d.ts +6 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/config.d.ts +24 -1
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/evidence-cache.d.ts +1 -1
- package/lib/types/evidence-cache.d.ts.map +1 -1
- package/lib/types/image-input-variants.d.ts +7 -4
- package/lib/types/image-input-variants.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/paste-images.d.ts +12 -4
- package/lib/types/paste-images.d.ts.map +1 -1
- package/lib/types/paths.d.ts +31 -5
- package/lib/types/paths.d.ts.map +1 -1
- package/lib/types/runtime-install.d.ts +8 -0
- package/lib/types/runtime-install.d.ts.map +1 -1
- package/lib/types/runtime-manager.d.ts +28 -4
- package/lib/types/runtime-manager.d.ts.map +1 -1
- package/lib/types/runtime.d.ts +4 -1
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/storage-history.d.ts +63 -0
- package/lib/types/storage-history.d.ts.map +1 -0
- package/lib/types/web.d.ts.map +1 -1
- package/lib/web.js +9 -3
- package/lib/web.js.map +1 -1
- package/package.json +1 -1
- package/src/artifact-access.ts +22 -2
- package/src/client/index.tsx +18 -3
- package/src/config.ts +76 -2
- package/src/evidence-cache.ts +2 -1
- package/src/image-input-variants.ts +61 -20
- package/src/index.ts +65 -6
- package/src/paste-images.ts +81 -19
- package/src/paths.ts +249 -28
- package/src/runtime-install.ts +168 -5
- package/src/runtime-manager.ts +93 -10
- package/src/runtime.ts +15 -9
- package/src/storage-history.ts +172 -0
- package/src/web.ts +9 -2
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
LlmProviderInfo,
|
|
24
24
|
LlmResolvedModelInfo,
|
|
25
25
|
Message,
|
|
26
|
+
ResolvedRetryPolicy,
|
|
26
27
|
StreamChunk,
|
|
27
28
|
} from '@deepseek-ai/dsh-llm'
|
|
28
29
|
// Type-only imports activate the host service declarations on Context.
|
|
@@ -94,6 +95,18 @@ export function variantProviderId(upstream: string): string {
|
|
|
94
95
|
return `${VARIANT_PROVIDER_PREFIX}${upstream}`
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
/** Stable semantic key for detecting a provider retry-policy replacement. */
|
|
99
|
+
function retryPolicyKey(policy: ResolvedRetryPolicy): string {
|
|
100
|
+
const backoff = [policy.initialDelayMs, policy.maxDelayMs, policy.jitterRatio]
|
|
101
|
+
if (policy.mode === 'always') return JSON.stringify([policy.mode, ...backoff])
|
|
102
|
+
return JSON.stringify([
|
|
103
|
+
policy.mode,
|
|
104
|
+
policy.maxRetries,
|
|
105
|
+
[...policy.retryableCodes].sort(),
|
|
106
|
+
...backoff,
|
|
107
|
+
])
|
|
108
|
+
}
|
|
109
|
+
|
|
97
110
|
/**
|
|
98
111
|
* Whether one model earns an image-input variant: the host must positively
|
|
99
112
|
* declare it text-only. A model with unknown modalities is left alone — its
|
|
@@ -129,18 +142,21 @@ async function materializeImage(
|
|
|
129
142
|
data: Uint8Array,
|
|
130
143
|
extension: string,
|
|
131
144
|
sessionId: string | undefined,
|
|
145
|
+
storageDir: string | undefined,
|
|
132
146
|
): Promise<MaterializedImage> {
|
|
133
147
|
const session = sessionId === undefined ? undefined : ctx.sessions.get(sessionId as never)
|
|
134
148
|
const cwd = session?.header.cwd
|
|
135
149
|
if (sessionId !== undefined && cwd !== undefined && isAbsolute(cwd)) {
|
|
136
|
-
const root = await sessionPasteRoot(ctx, sessionId)
|
|
150
|
+
const root = await sessionPasteRoot(ctx, sessionId, storageDir)
|
|
137
151
|
const identity = createHash('sha256')
|
|
138
152
|
.update(`${sessionId}\u0000${String(block.attachment.attachmentId)}`)
|
|
139
153
|
.digest('hex')
|
|
140
154
|
.slice(0, 32)
|
|
141
|
-
const
|
|
155
|
+
const filename = `attachment-${identity}${extension}`
|
|
156
|
+
const writePath = join(root.writeRoot, filename)
|
|
157
|
+
const file = join(root.visibleRoot, filename)
|
|
142
158
|
try {
|
|
143
|
-
await writeFile(
|
|
159
|
+
await writeFile(writePath, Buffer.from(data), { mode: 0o600, flag: 'wx' })
|
|
144
160
|
} catch (error) {
|
|
145
161
|
if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
|
|
146
162
|
}
|
|
@@ -344,6 +360,7 @@ async function readImageBlock(
|
|
|
344
360
|
block: ImageBlock,
|
|
345
361
|
query: string,
|
|
346
362
|
sessionId?: string,
|
|
363
|
+
storageDir?: string,
|
|
347
364
|
): Promise<ContentBlock> {
|
|
348
365
|
const attachments = ctx.get('attachments')
|
|
349
366
|
const current = runtime()
|
|
@@ -358,7 +375,7 @@ async function readImageBlock(
|
|
|
358
375
|
let pathEvidence = ''
|
|
359
376
|
try {
|
|
360
377
|
const stored = await attachments.readImage(block.attachment)
|
|
361
|
-
const materialized = await materializeImage(ctx, block, stored.data, extension, sessionId)
|
|
378
|
+
const materialized = await materializeImage(ctx, block, stored.data, extension, sessionId, storageDir)
|
|
362
379
|
temporaryDirectory = materialized.temporaryDirectory
|
|
363
380
|
if (materialized.persistent) pathEvidence = imagePathEvidence(materialized.file)
|
|
364
381
|
|
|
@@ -401,6 +418,7 @@ async function readImageBlock(
|
|
|
401
418
|
* @param signal - the caller's cancellation for this conversion pass.
|
|
402
419
|
* @param sessionId - the live Session identity, when available.
|
|
403
420
|
* @param runtimeHash - stable fingerprint of the vision provider and evidence runtime.
|
|
421
|
+
* @param storageDir - optional shared plugin storage root.
|
|
404
422
|
* @returns the rewritten message list.
|
|
405
423
|
*/
|
|
406
424
|
export async function convertImagesToEvidence(
|
|
@@ -411,6 +429,7 @@ export async function convertImagesToEvidence(
|
|
|
411
429
|
signal?: AbortSignal,
|
|
412
430
|
sessionId?: string,
|
|
413
431
|
runtimeHash = 'process-only-runtime',
|
|
432
|
+
storageDir?: string,
|
|
414
433
|
): Promise<Message[]> {
|
|
415
434
|
const session = sessionId === undefined ? undefined : ctx.sessions.get(sessionId as never)
|
|
416
435
|
const sessionIdentity = session === undefined
|
|
@@ -467,7 +486,7 @@ export async function convertImagesToEvidence(
|
|
|
467
486
|
prompt: query,
|
|
468
487
|
runtimeHash,
|
|
469
488
|
}), () =>
|
|
470
|
-
readImageBlock(ctx, runtime, block, query, sessionId)),
|
|
489
|
+
readImageBlock(ctx, runtime, block, query, sessionId, storageDir)),
|
|
471
490
|
signal,
|
|
472
491
|
),
|
|
473
492
|
signal,
|
|
@@ -504,6 +523,7 @@ export class ImageInputVariantAdapter extends LlmAdapter {
|
|
|
504
523
|
private readonly runtime: () => VisionToolkitRuntime | undefined,
|
|
505
524
|
private readonly cache: EvidenceCache,
|
|
506
525
|
private readonly hidden: () => boolean = () => false,
|
|
526
|
+
private readonly startupStorageDirectory: () => string | undefined = () => undefined,
|
|
507
527
|
) {
|
|
508
528
|
super()
|
|
509
529
|
}
|
|
@@ -515,6 +535,10 @@ export class ImageInputVariantAdapter extends LlmAdapter {
|
|
|
515
535
|
}
|
|
516
536
|
}
|
|
517
537
|
|
|
538
|
+
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
|
|
539
|
+
return this.llm.providerRetryPolicy(this.upstream)
|
|
540
|
+
}
|
|
541
|
+
|
|
518
542
|
override async listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
|
519
543
|
const models = await this.llm.listModels(this.upstream)
|
|
520
544
|
return models.filter(shouldWrapModel).map((model) => ({
|
|
@@ -552,6 +576,7 @@ export class ImageInputVariantAdapter extends LlmAdapter {
|
|
|
552
576
|
|
|
553
577
|
override async *stream(options: GenerateOptions): AsyncGenerator<StreamChunk> {
|
|
554
578
|
const current = this.runtime()
|
|
579
|
+
const storageDir = current === undefined ? this.startupStorageDirectory() : current.storageDirectory
|
|
555
580
|
let captured: CapturedEvidenceRuntime | undefined
|
|
556
581
|
if (current !== undefined && options.messages.some(message => contentHasImage(message.content))) {
|
|
557
582
|
try {
|
|
@@ -573,7 +598,12 @@ export class ImageInputVariantAdapter extends LlmAdapter {
|
|
|
573
598
|
options.messages,
|
|
574
599
|
options.signal,
|
|
575
600
|
options.sessionId === undefined ? undefined : String(options.sessionId),
|
|
576
|
-
captured?.evidenceFingerprint ?? '
|
|
601
|
+
captured?.evidenceFingerprint ?? createHash('sha256')
|
|
602
|
+
.update('process-only-runtime')
|
|
603
|
+
.update('\0')
|
|
604
|
+
.update(storageDir ?? '')
|
|
605
|
+
.digest('hex'),
|
|
606
|
+
storageDir,
|
|
577
607
|
)
|
|
578
608
|
// Delegate through the host service under the upstream route: the variant
|
|
579
609
|
// is a wire-only facade, and the upstream route owns retry and replay.
|
|
@@ -809,13 +839,13 @@ export function installImageInputVariants(
|
|
|
809
839
|
ctx: Context,
|
|
810
840
|
getConfig: () => ResolvedVisionToolkitConfig,
|
|
811
841
|
getRuntime: () => VisionToolkitRuntime | undefined,
|
|
842
|
+
getStartupStorageDirectory: () => string | undefined = () => undefined,
|
|
812
843
|
): { dispose: () => void; reconcile: () => void } {
|
|
813
844
|
const evidenceStore = new SessionEvidenceStore(ctx)
|
|
814
845
|
const evidenceCache = new EvidenceCache(EVIDENCE_CACHE_LIMIT, evidenceStore)
|
|
815
|
-
const registrations = new Map<string, () => void>()
|
|
816
|
-
// The host snapshots adapter provider metadata
|
|
817
|
-
//
|
|
818
|
-
// every wrapper for the new names to reach the model selector.
|
|
846
|
+
const registrations = new Map<string, { dispose: () => void; retryPolicyKey: string }>()
|
|
847
|
+
// The host snapshots adapter provider metadata and retry policy at registration
|
|
848
|
+
// time, so changes to either require rebuilding the wrapper route.
|
|
819
849
|
let lastHidden = false
|
|
820
850
|
// Upstream ids observed missing and the timestamp of the first missing
|
|
821
851
|
// observation. A wrapper is only released after its upstream stays absent
|
|
@@ -832,12 +862,12 @@ export function installImageInputVariants(
|
|
|
832
862
|
let sweepQueued = false
|
|
833
863
|
|
|
834
864
|
const release = (upstream: string): void => {
|
|
835
|
-
const
|
|
836
|
-
if (
|
|
865
|
+
const registration = registrations.get(upstream)
|
|
866
|
+
if (registration === undefined) return
|
|
837
867
|
registrations.delete(upstream)
|
|
838
868
|
stale.delete(upstream)
|
|
839
869
|
try {
|
|
840
|
-
dispose()
|
|
870
|
+
registration.dispose()
|
|
841
871
|
} catch (error) {
|
|
842
872
|
ctx.logger.warn(
|
|
843
873
|
'dsh-vision-toolkit: image-input variant release failed for "%s": %s',
|
|
@@ -916,18 +946,28 @@ export function installImageInputVariants(
|
|
|
916
946
|
continue
|
|
917
947
|
}
|
|
918
948
|
const eligible = models.some(shouldWrapModel)
|
|
919
|
-
|
|
920
|
-
|
|
949
|
+
let upstreamRetryPolicyKey: string
|
|
950
|
+
try {
|
|
951
|
+
upstreamRetryPolicyKey = retryPolicyKey(llm.providerRetryPolicy(upstream))
|
|
952
|
+
} catch {
|
|
953
|
+
// Preserve an existing wrapper through a transient upstream registry
|
|
954
|
+
// gap; the next topology event or periodic sweep will retry the probe.
|
|
955
|
+
continue
|
|
956
|
+
}
|
|
957
|
+
let registration = registrations.get(upstream)
|
|
958
|
+
if (registration !== undefined && live.has(variantId)) {
|
|
921
959
|
// Re-read the live registry after the await: a rebuild inside the
|
|
922
960
|
// probe window can drop our wrapper without touching our map.
|
|
923
961
|
const liveNow = new Set(llm.listProviders().map(provider => provider.id))
|
|
924
962
|
if (liveNow.has(variantId)) {
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
963
|
+
if (eligible && registration.retryPolicyKey === upstreamRetryPolicyKey) continue
|
|
964
|
+
// Eligibility and retry policy are registration-time facts. Rebuild
|
|
965
|
+
// whenever either no longer matches the live upstream route.
|
|
966
|
+
release(upstream)
|
|
967
|
+
registration = undefined
|
|
928
968
|
}
|
|
929
969
|
}
|
|
930
|
-
if (
|
|
970
|
+
if (registration !== undefined) {
|
|
931
971
|
// Dead handle from a registry rebuild/reset: drop it and register a
|
|
932
972
|
// fresh wrapper below.
|
|
933
973
|
release(upstream)
|
|
@@ -945,9 +985,10 @@ export function installImageInputVariants(
|
|
|
945
985
|
getRuntime,
|
|
946
986
|
evidenceCache,
|
|
947
987
|
() => getConfig().imageInputVariants.hidden,
|
|
988
|
+
getStartupStorageDirectory,
|
|
948
989
|
),
|
|
949
990
|
)
|
|
950
|
-
registrations.set(upstream, dispose)
|
|
991
|
+
registrations.set(upstream, { dispose, retryPolicyKey: upstreamRetryPolicyKey })
|
|
951
992
|
stale.delete(upstream)
|
|
952
993
|
} catch (error) {
|
|
953
994
|
ctx.logger.warn(
|
package/src/index.ts
CHANGED
|
@@ -18,13 +18,16 @@ import { ArtifactAccessController, prepareArtifactAccessKey } from './artifact-a
|
|
|
18
18
|
import {
|
|
19
19
|
Config,
|
|
20
20
|
VISION_TOOLKIT_SETTINGS_NAMESPACE,
|
|
21
|
+
prepareWatchedSettingsGeneration,
|
|
21
22
|
resolveConfig,
|
|
23
|
+
type ResolvedVisionToolkitConfig,
|
|
22
24
|
type VisionToolkitConfig,
|
|
23
25
|
} from './config.ts'
|
|
24
26
|
import { VisionToolExposure } from './exposure.ts'
|
|
25
27
|
import { createPasteTakeoverResolver, installImageInputVariants } from './image-input-variants.ts'
|
|
26
28
|
import { VisionToolkitRuntimeManager } from './runtime-manager.ts'
|
|
27
29
|
import { VISION_SKILLS_SKILL } from './skill.ts'
|
|
30
|
+
import { StorageHistoryStore } from './storage-history.ts'
|
|
28
31
|
import { createVisionTools } from './tools.ts'
|
|
29
32
|
import { PLUGIN_VERSION } from './version.ts'
|
|
30
33
|
import { installVisionToolkitWeb, VisionToolkitWebBackend } from './web.ts'
|
|
@@ -51,8 +54,33 @@ export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Pro
|
|
|
51
54
|
const artifacts = new ArtifactAccessController(await prepareArtifactAccessKey())
|
|
52
55
|
const lifecycle = new AbortController()
|
|
53
56
|
const disposers: Array<() => void> = []
|
|
57
|
+
const storageHistory = new StorageHistoryStore(ctx)
|
|
58
|
+
disposers.push(() => { storageHistory.dispose() })
|
|
59
|
+
let storageHistoryWarningReported = false
|
|
54
60
|
let operationalDisposers: { activationTool: () => void; exposure: () => void; skill: () => void } | undefined
|
|
55
61
|
|
|
62
|
+
const persistStorageHistory = async (candidate: VisionToolkitConfig, required: boolean): Promise<void> => {
|
|
63
|
+
try {
|
|
64
|
+
const persisted = await storageHistory.persist(candidate)
|
|
65
|
+
if (persisted) return
|
|
66
|
+
const error = new Error(
|
|
67
|
+
'configured storage history requires @deepseek-ai/dsh-storage-domain when Settings cannot persist it',
|
|
68
|
+
)
|
|
69
|
+
if (required) throw error
|
|
70
|
+
if (!storageHistoryWarningReported) {
|
|
71
|
+
storageHistoryWarningReported = true
|
|
72
|
+
ctx.logger.warn('dsh-vision-toolkit: %s', error.message)
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (required) throw error
|
|
76
|
+
if (!storageHistoryWarningReported) {
|
|
77
|
+
storageHistoryWarningReported = true
|
|
78
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
79
|
+
ctx.logger.warn('dsh-vision-toolkit: configured storage history was not persisted. %s', message)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
const ensureOperational = (): void => {
|
|
57
85
|
if (!manager.ready || operationalDisposers !== undefined) return
|
|
58
86
|
const exposure = new VisionToolExposure(ctx, () => createVisionTools(
|
|
@@ -84,10 +112,16 @@ export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Pro
|
|
|
84
112
|
}
|
|
85
113
|
}
|
|
86
114
|
|
|
115
|
+
const initialConfig = await storageHistory.restore(settings.get())
|
|
87
116
|
try {
|
|
88
|
-
await manager.initialize(
|
|
117
|
+
await manager.initialize(initialConfig, candidate => persistStorageHistory(candidate.config, false))
|
|
89
118
|
ensureOperational()
|
|
90
119
|
} catch (error) {
|
|
120
|
+
const resolvedInitial = resolveConfig(initialConfig)
|
|
121
|
+
if (resolvedInitial.storageDir === undefined
|
|
122
|
+
|| manager.validatedStorageDirectory() === resolvedInitial.storageDir) {
|
|
123
|
+
await persistStorageHistory(initialConfig, false)
|
|
124
|
+
}
|
|
91
125
|
const message = error instanceof Error ? error.message : String(error)
|
|
92
126
|
ctx.logger.error(
|
|
93
127
|
'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',
|
|
@@ -97,29 +131,54 @@ export async function apply(ctx: Context, config: VisionToolkitConfig = {}): Pro
|
|
|
97
131
|
}
|
|
98
132
|
|
|
99
133
|
const backend = new VisionToolkitWebBackend(ctx, manager, artifacts, ensureOperational)
|
|
134
|
+
const currentConfig = (): ResolvedVisionToolkitConfig => manager.ready
|
|
135
|
+
? manager.currentConfig()
|
|
136
|
+
: resolveConfig(settings.get())
|
|
100
137
|
const pastedImages = new PastedImageBackend(ctx, {
|
|
101
138
|
maxUploadBytes: () => MAX_PASTE_IMAGE_BYTES,
|
|
139
|
+
storageGeneration: () => manager.storageGeneration(),
|
|
102
140
|
})
|
|
103
141
|
// Image-input variants register asynchronously once eligible routes exist;
|
|
104
142
|
// the runtime getter stays lazy so variants appear even when the runtime
|
|
105
143
|
// becomes ready after the first sweep.
|
|
106
144
|
const variants = installImageInputVariants(
|
|
107
145
|
ctx,
|
|
108
|
-
|
|
146
|
+
currentConfig,
|
|
109
147
|
() => manager.ready ? manager.current() : undefined,
|
|
148
|
+
() => manager.validatedStorageDirectory(),
|
|
110
149
|
)
|
|
111
150
|
installVisionToolkitWeb(
|
|
112
151
|
ctx,
|
|
113
152
|
backend,
|
|
114
153
|
artifacts,
|
|
115
154
|
pastedImages,
|
|
116
|
-
createPasteTakeoverResolver(ctx,
|
|
117
|
-
() => ({ hidden:
|
|
155
|
+
createPasteTakeoverResolver(ctx, currentConfig),
|
|
156
|
+
() => ({ hidden: currentConfig().imageInputVariants.hidden }),
|
|
118
157
|
)
|
|
119
158
|
disposers.push(variants.dispose)
|
|
120
|
-
disposers.push(settings.watch(async (next) => {
|
|
159
|
+
disposers.push(settings.watch(async (next, previous) => {
|
|
121
160
|
try {
|
|
122
|
-
await
|
|
161
|
+
const prepared = await prepareWatchedSettingsGeneration(
|
|
162
|
+
next,
|
|
163
|
+
previous,
|
|
164
|
+
ctx.settings.writable,
|
|
165
|
+
storageHistory => settings.update({ storageHistory }),
|
|
166
|
+
)
|
|
167
|
+
if (prepared.persistenceError !== undefined) {
|
|
168
|
+
const message = prepared.persistenceError instanceof Error
|
|
169
|
+
? prepared.persistenceError.message
|
|
170
|
+
: String(prepared.persistenceError)
|
|
171
|
+
ctx.logger.warn('dsh-vision-toolkit: activating Settings without persisting internal storage history. %s', message)
|
|
172
|
+
}
|
|
173
|
+
if (prepared.config === undefined) return
|
|
174
|
+
const candidate = await storageHistory.restore(prepared.config)
|
|
175
|
+
await manager.reconfigure(
|
|
176
|
+
candidate,
|
|
177
|
+
generation => persistStorageHistory(
|
|
178
|
+
generation.config,
|
|
179
|
+
prepared.requiresDurableStorageHistory === true,
|
|
180
|
+
),
|
|
181
|
+
)
|
|
123
182
|
ensureOperational()
|
|
124
183
|
variants.reconcile()
|
|
125
184
|
} catch (error) {
|
package/src/paste-images.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Plugin-managed storage for images pasted into the DSH Web composer. */
|
|
2
2
|
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto'
|
|
4
|
-
import {
|
|
4
|
+
import { constants as fsConstants } from 'node:fs'
|
|
5
|
+
import { copyFile, lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'
|
|
5
6
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
6
7
|
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
7
8
|
import type { Context } from '@deepseek-ai/cordis'
|
|
8
9
|
import type {} from '@deepseek-ai/dsh-session'
|
|
10
|
+
import { resolveWorkspaceStorage } from './paths.ts'
|
|
9
11
|
import { sameOriginPost } from './web-request.ts'
|
|
10
12
|
|
|
11
13
|
/** Exact route used by the browser paste integration. */
|
|
@@ -51,6 +53,7 @@ export interface PasteVerdict {
|
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
const MAX_NAME_BYTES = 180
|
|
56
|
+
const MAX_STORAGE_GENERATION_RETRIES = 4
|
|
54
57
|
|
|
55
58
|
/**
|
|
56
59
|
* Hard per-image upload ceiling for pastes. Files between the configured
|
|
@@ -151,7 +154,7 @@ export function safePastedImageName(raw: string, mediaType: string): string {
|
|
|
151
154
|
export function ensurePathInside(root: string, target: string): void {
|
|
152
155
|
const rel = relative(root, target)
|
|
153
156
|
if (rel !== '' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
|
|
154
|
-
throw new Error(`resolved pasted-image path escapes its
|
|
157
|
+
throw new Error(`resolved pasted-image path escapes its managed root: ${target}`)
|
|
155
158
|
}
|
|
156
159
|
}
|
|
157
160
|
|
|
@@ -168,7 +171,7 @@ async function ensureManagedDirectory(workspace: string, path: string): Promise<
|
|
|
168
171
|
}
|
|
169
172
|
const entry = await lstat(path)
|
|
170
173
|
if (entry.isSymbolicLink()) {
|
|
171
|
-
throw new Error(`resolved pasted-image path escapes its
|
|
174
|
+
throw new Error(`resolved pasted-image path escapes its managed root: symbolic link ${path}`)
|
|
172
175
|
}
|
|
173
176
|
if (!entry.isDirectory()) throw new Error(`pasted-image path is not a directory: ${path}`)
|
|
174
177
|
const canonical = await realpath(path)
|
|
@@ -178,29 +181,33 @@ async function ensureManagedDirectory(workspace: string, path: string): Promise<
|
|
|
178
181
|
|
|
179
182
|
/**
|
|
180
183
|
* Resolve the managed per-session image directory used by both browser pastes
|
|
181
|
-
* and native attachment bridging.
|
|
182
|
-
*
|
|
184
|
+
* and native attachment bridging. A configured shared storage root receives a
|
|
185
|
+
* stable workspace-specific child, keeping projects isolated without writing
|
|
186
|
+
* plugin files into the project directory.
|
|
183
187
|
*/
|
|
184
|
-
export async function sessionPasteRoot(
|
|
188
|
+
export async function sessionPasteRoot(
|
|
189
|
+
ctx: Context,
|
|
190
|
+
sessionId: string,
|
|
191
|
+
storageDir?: string,
|
|
192
|
+
): Promise<PasteRoot> {
|
|
185
193
|
const session = ctx.sessions.get(sessionId as never)
|
|
186
194
|
if (session === undefined) throw new Error(`live Session not found: ${sessionId}`)
|
|
187
195
|
const cwd = session.header.cwd
|
|
188
196
|
if (cwd === undefined || !isAbsolute(cwd)) throw new Error(`Session has no absolute workspace: ${sessionId}`)
|
|
189
197
|
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
const pluginRoot = join(visibleWorkspace, '.dsh-vision-toolkit')
|
|
193
|
-
await ensureManagedDirectory(workspace, pluginRoot)
|
|
198
|
+
const storage = await resolveWorkspaceStorage(resolve(cwd), storageDir)
|
|
199
|
+
const pluginRoot = storage.root
|
|
194
200
|
const temporaryRoot = join(pluginRoot, 'tmp')
|
|
195
|
-
await ensureManagedDirectory(
|
|
201
|
+
await ensureManagedDirectory(pluginRoot, temporaryRoot)
|
|
196
202
|
const requestedRoot = join(temporaryRoot, 'pasted-images')
|
|
197
|
-
const root = await ensureManagedDirectory(
|
|
203
|
+
const root = await ensureManagedDirectory(temporaryRoot, requestedRoot)
|
|
204
|
+
const visibleRoot = join(storage.visibleRoot, 'tmp', 'pasted-images')
|
|
198
205
|
|
|
199
206
|
const sessionKey = createHash('sha256').update(sessionId).digest('hex').slice(0, 20)
|
|
200
207
|
const requestedSessionRoot = join(requestedRoot, sessionKey)
|
|
201
208
|
const sessionRoot = await ensureManagedDirectory(root, requestedSessionRoot)
|
|
202
209
|
ensurePathInside(root, sessionRoot)
|
|
203
|
-
return { writeRoot: sessionRoot, visibleRoot:
|
|
210
|
+
return { writeRoot: sessionRoot, visibleRoot: join(visibleRoot, sessionKey) }
|
|
204
211
|
}
|
|
205
212
|
|
|
206
213
|
async function writeImage(
|
|
@@ -240,9 +247,38 @@ async function writeImage(
|
|
|
240
247
|
}
|
|
241
248
|
}
|
|
242
249
|
|
|
250
|
+
async function copyImage(
|
|
251
|
+
source: string,
|
|
252
|
+
directory: string,
|
|
253
|
+
filename: string,
|
|
254
|
+
): Promise<string> {
|
|
255
|
+
const id = randomUUID()
|
|
256
|
+
const finalPath = join(directory, `${id}-${filename}`)
|
|
257
|
+
const stagingPath = join(directory, `.${id}.partial`)
|
|
258
|
+
ensurePathInside(directory, finalPath)
|
|
259
|
+
ensurePathInside(directory, stagingPath)
|
|
260
|
+
try {
|
|
261
|
+
await copyFile(source, stagingPath, fsConstants.COPYFILE_EXCL)
|
|
262
|
+
await rename(stagingPath, finalPath)
|
|
263
|
+
return finalPath
|
|
264
|
+
} catch (error) {
|
|
265
|
+
await rm(stagingPath, { force: true }).catch(() => {})
|
|
266
|
+
throw error
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export interface PasteStorageGeneration {
|
|
271
|
+
generation: number
|
|
272
|
+
storageDir?: string
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
class PasteStorageChangedError extends Error {}
|
|
276
|
+
|
|
243
277
|
/** Runtime limit face kept separate for focused backend tests. */
|
|
244
278
|
export interface PasteImageRuntime {
|
|
245
279
|
maxUploadBytes(): number
|
|
280
|
+
storageDirectory?(): string | undefined
|
|
281
|
+
storageGeneration?(): PasteStorageGeneration
|
|
246
282
|
}
|
|
247
283
|
|
|
248
284
|
/** Same-origin, live-Session-bound image upload endpoint. */
|
|
@@ -252,6 +288,16 @@ export class PastedImageBackend {
|
|
|
252
288
|
private readonly runtime: PasteImageRuntime,
|
|
253
289
|
) {}
|
|
254
290
|
|
|
291
|
+
private storageGeneration(): PasteStorageGeneration {
|
|
292
|
+
const current = this.runtime.storageGeneration?.()
|
|
293
|
+
if (current !== undefined) return current
|
|
294
|
+
const storageDir = this.runtime.storageDirectory?.()
|
|
295
|
+
return {
|
|
296
|
+
generation: 0,
|
|
297
|
+
...(storageDir === undefined ? {} : { storageDir }),
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
255
301
|
async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
256
302
|
if (req.method !== 'POST') {
|
|
257
303
|
res.setHeader('Allow', 'POST')
|
|
@@ -263,6 +309,7 @@ export class PastedImageBackend {
|
|
|
263
309
|
return
|
|
264
310
|
}
|
|
265
311
|
|
|
312
|
+
let managedPath: string | undefined
|
|
266
313
|
try {
|
|
267
314
|
const url = new URL(req.url ?? PASTE_IMAGES_ROUTE, 'http://dsh.internal')
|
|
268
315
|
const sessionId = singleQuery(url, 'sessionId')
|
|
@@ -273,12 +320,27 @@ export class PastedImageBackend {
|
|
|
273
320
|
if (contentLength !== undefined && Number(contentLength) !== size) {
|
|
274
321
|
throw new TypeError('Content-Length does not match the declared size')
|
|
275
322
|
}
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
323
|
+
let storage = this.storageGeneration()
|
|
324
|
+
let directory = await sessionPasteRoot(this.ctx, sessionId, storage.storageDir)
|
|
325
|
+
managedPath = await writeImage(req, directory.writeRoot, filename, size, this.runtime.maxUploadBytes())
|
|
326
|
+
for (let attempt = 0; attempt < MAX_STORAGE_GENERATION_RETRIES; attempt += 1) {
|
|
327
|
+
const current = this.storageGeneration()
|
|
328
|
+
if (current.generation === storage.generation && current.storageDir === storage.storageDir) {
|
|
329
|
+
const absolutePath = join(directory.visibleRoot, basename(managedPath))
|
|
330
|
+
responseJson(res, 201, { ok: true, value: { absolutePath, filename, bytes: size } })
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
const nextDirectory = await sessionPasteRoot(this.ctx, sessionId, current.storageDir)
|
|
334
|
+
const migratedPath = await copyImage(managedPath, nextDirectory.writeRoot, filename)
|
|
335
|
+
await rm(managedPath, { force: true }).catch(() => {})
|
|
336
|
+
managedPath = migratedPath
|
|
337
|
+
directory = nextDirectory
|
|
338
|
+
storage = current
|
|
339
|
+
}
|
|
340
|
+
throw new PasteStorageChangedError('Vision Toolkit settings changed repeatedly during image copy; retry the paste')
|
|
280
341
|
} catch (error) {
|
|
281
|
-
|
|
342
|
+
if (managedPath !== undefined) await rm(managedPath, { force: true }).catch(() => {})
|
|
343
|
+
const status = error instanceof PasteStorageChangedError ? 409 : error instanceof RangeError ? 413 : 400
|
|
282
344
|
this.ctx.logger.warn('dsh-vision-toolkit pasted image rejected: %s', message(error))
|
|
283
345
|
requestError(res, status, 'paste-image-rejected', message(error))
|
|
284
346
|
}
|