@anionex/dsh-vision-toolkit 0.1.28 → 0.1.29
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.md +6 -4
- package/README.zh.md +6 -4
- package/assets/python-bootstrap.json +47 -0
- package/lib/exposure.js +27 -5
- package/lib/exposure.js.map +1 -1
- package/lib/runtime-install.js +291 -5
- package/lib/runtime-install.js.map +1 -1
- package/lib/skill.js +2 -2
- package/lib/skill.js.map +1 -1
- package/lib/types/exposure.d.ts +4 -0
- package/lib/types/exposure.d.ts.map +1 -1
- package/lib/types/runtime-install.d.ts +30 -0
- package/lib/types/runtime-install.d.ts.map +1 -1
- package/package.json +6 -4
- package/src/exposure.ts +28 -6
- package/src/runtime-install.ts +337 -7
- package/src/skill.ts +2 -2
package/src/runtime-install.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { createHash, randomUUID } from 'node:crypto'
|
|
10
10
|
import {
|
|
11
|
+
access,
|
|
12
|
+
chmod,
|
|
11
13
|
lstat,
|
|
12
14
|
mkdir,
|
|
13
15
|
mkdtemp,
|
|
@@ -20,10 +22,16 @@ import {
|
|
|
20
22
|
utimes,
|
|
21
23
|
writeFile,
|
|
22
24
|
} from 'node:fs/promises'
|
|
25
|
+
import { createWriteStream } from 'node:fs'
|
|
23
26
|
import { homedir } from 'node:os'
|
|
24
27
|
import { dirname, join, resolve } from 'node:path'
|
|
28
|
+
import { Transform } from 'node:stream'
|
|
29
|
+
import { pipeline } from 'node:stream/promises'
|
|
25
30
|
import { fileURLToPath } from 'node:url'
|
|
26
31
|
import type { Context } from '@deepseek-ai/cordis'
|
|
32
|
+
import { x as extractTar } from 'tar'
|
|
33
|
+
import { EnvHttpProxyAgent } from 'undici'
|
|
34
|
+
import { request as undiciRequest } from 'undici'
|
|
27
35
|
import type { ResolvedVisionToolkitConfig } from './config.ts'
|
|
28
36
|
import { VisionToolkitError } from './errors.ts'
|
|
29
37
|
import { UPSTREAM_COMMIT, UPSTREAM_REPOSITORY, UPSTREAM_VERSION } from './version.ts'
|
|
@@ -252,10 +260,317 @@ async function pythonMetadata(
|
|
|
252
260
|
}
|
|
253
261
|
}
|
|
254
262
|
|
|
255
|
-
|
|
263
|
+
interface PythonBootstrapArtifact {
|
|
264
|
+
url: string
|
|
265
|
+
sha256: string
|
|
266
|
+
size: number
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
interface PythonBootstrapManifest {
|
|
270
|
+
schemaVersion: 1
|
|
271
|
+
pythonVersion: string
|
|
272
|
+
buildTag: string
|
|
273
|
+
artifacts: Record<string, PythonBootstrapArtifact>
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const PYTHON_BOOTSTRAP_MANIFEST_PATH = join(PACKAGE_ROOT, 'assets', 'python-bootstrap.json')
|
|
277
|
+
const PYTHON_DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000
|
|
278
|
+
const PYTHON_DOWNLOAD_ATTEMPTS = 3
|
|
279
|
+
|
|
280
|
+
async function readPythonBootstrapManifest(): Promise<PythonBootstrapManifest> {
|
|
281
|
+
let parsed: unknown
|
|
282
|
+
try {
|
|
283
|
+
parsed = JSON.parse(await readFile(PYTHON_BOOTSTRAP_MANIFEST_PATH, 'utf8'))
|
|
284
|
+
} catch (error) {
|
|
285
|
+
throw new VisionToolkitError('runtime', `python bootstrap manifest is unreadable: ${PYTHON_BOOTSTRAP_MANIFEST_PATH}`, { cause: error })
|
|
286
|
+
}
|
|
287
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
288
|
+
throw new VisionToolkitError('runtime', 'python bootstrap manifest is not an object')
|
|
289
|
+
}
|
|
290
|
+
const manifest = parsed as Partial<PythonBootstrapManifest>
|
|
291
|
+
if (
|
|
292
|
+
manifest.schemaVersion !== 1
|
|
293
|
+
|| typeof manifest.pythonVersion !== 'string'
|
|
294
|
+
|| !/^3\.\d+\.\d+$/u.test(manifest.pythonVersion)
|
|
295
|
+
|| typeof manifest.buildTag !== 'string'
|
|
296
|
+
|| !/^\d{8}$/u.test(manifest.buildTag)
|
|
297
|
+
|| typeof manifest.artifacts !== 'object'
|
|
298
|
+
|| manifest.artifacts === null
|
|
299
|
+
) {
|
|
300
|
+
throw new VisionToolkitError('runtime', 'python bootstrap manifest is invalid')
|
|
301
|
+
}
|
|
302
|
+
for (const [target, artifact] of Object.entries(manifest.artifacts)) {
|
|
303
|
+
if (
|
|
304
|
+
typeof artifact !== 'object'
|
|
305
|
+
|| artifact === null
|
|
306
|
+
|| typeof artifact.url !== 'string'
|
|
307
|
+
|| !artifact.url.startsWith('https://github.com/astral-sh/python-build-standalone/releases/download/')
|
|
308
|
+
|| typeof artifact.sha256 !== 'string'
|
|
309
|
+
|| !/^[a-f0-9]{64}$/u.test(artifact.sha256)
|
|
310
|
+
|| !Number.isInteger(artifact.size)
|
|
311
|
+
|| artifact.size <= 0
|
|
312
|
+
) {
|
|
313
|
+
throw new VisionToolkitError('runtime', `python bootstrap manifest has an invalid artifact: ${target}`)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return manifest as PythonBootstrapManifest
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Map Node platform/arch to the pinned artifact name, including musl Linux. */
|
|
320
|
+
export function pythonBootstrapTarget(platform: string, arch: string, musl: boolean): string {
|
|
321
|
+
return platform === 'linux' && musl ? `${platform}-${arch}-musl` : `${platform}-${arch}`
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function runningMusl(): Promise<boolean> {
|
|
325
|
+
if (process.platform !== 'linux') return false
|
|
326
|
+
try {
|
|
327
|
+
await access(join('/', 'etc', 'alpine-release'))
|
|
328
|
+
return true
|
|
329
|
+
} catch {
|
|
330
|
+
// Fall through to the loader-name probe.
|
|
331
|
+
}
|
|
332
|
+
const loader = process.arch === 'arm64' ? 'aarch64' : 'x86_64'
|
|
333
|
+
try {
|
|
334
|
+
await access(join('/', 'lib', `ld-musl-${loader}.so.1`))
|
|
335
|
+
return true
|
|
336
|
+
} catch {
|
|
337
|
+
return false
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
interface DownloadResponse {
|
|
342
|
+
statusCode: number
|
|
343
|
+
headers: Record<string, string | string[] | undefined>
|
|
344
|
+
body: NodeJS.ReadableStream
|
|
345
|
+
close: () => Promise<void>
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
type DownloadRequest = (url: string, signal: AbortSignal) => Promise<DownloadResponse>
|
|
349
|
+
|
|
350
|
+
const DOWNLOAD_HOSTS = new Set([
|
|
351
|
+
'github.com',
|
|
352
|
+
'objects.githubusercontent.com',
|
|
353
|
+
'release-assets.githubusercontent.com',
|
|
354
|
+
])
|
|
355
|
+
|
|
356
|
+
async function defaultDownloadRequest(url: string, signal: AbortSignal): Promise<DownloadResponse> {
|
|
357
|
+
const dispatcher = new EnvHttpProxyAgent()
|
|
358
|
+
let current = url
|
|
359
|
+
try {
|
|
360
|
+
for (let redirects = 0; ; redirects++) {
|
|
361
|
+
const host = new URL(current).hostname
|
|
362
|
+
if (!DOWNLOAD_HOSTS.has(host)) throw new Error(`download redirected outside GitHub: ${host}`)
|
|
363
|
+
const response = await undiciRequest(current, {
|
|
364
|
+
dispatcher,
|
|
365
|
+
signal,
|
|
366
|
+
headers: { 'user-agent': 'dsh-vision-toolkit' },
|
|
367
|
+
})
|
|
368
|
+
if (response.statusCode >= 300 && response.statusCode < 400) {
|
|
369
|
+
const location = response.headers.location
|
|
370
|
+
await response.body.dump().catch(() => {})
|
|
371
|
+
if (typeof location !== 'string' || location.length === 0 || redirects >= 5) {
|
|
372
|
+
throw new Error('download redirected too many times or without a Location header')
|
|
373
|
+
}
|
|
374
|
+
current = new URL(location, current).toString()
|
|
375
|
+
continue
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
statusCode: response.statusCode,
|
|
379
|
+
headers: response.headers,
|
|
380
|
+
body: response.body,
|
|
381
|
+
close: () => dispatcher.close().catch(() => {}),
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} catch (error) {
|
|
385
|
+
await dispatcher.close().catch(() => {})
|
|
386
|
+
throw error
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function downloadBundledPythonOnce(
|
|
391
|
+
artifact: PythonBootstrapArtifact,
|
|
392
|
+
destination: string,
|
|
393
|
+
requestImpl: DownloadRequest = defaultDownloadRequest,
|
|
394
|
+
): Promise<void> {
|
|
395
|
+
const signal = AbortSignal.timeout(PYTHON_DOWNLOAD_TIMEOUT_MS)
|
|
396
|
+
let response: DownloadResponse
|
|
397
|
+
try {
|
|
398
|
+
response = await requestImpl(artifact.url, signal)
|
|
399
|
+
} catch (error) {
|
|
400
|
+
throw new Error(`download request failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
401
|
+
}
|
|
402
|
+
try {
|
|
403
|
+
if (response.statusCode !== 200) throw new Error(`download returned HTTP ${response.statusCode}`)
|
|
404
|
+
const hash = createHash('sha256')
|
|
405
|
+
let bytes = 0
|
|
406
|
+
const hasher = new Transform({
|
|
407
|
+
transform(chunk, _encoding, callback) {
|
|
408
|
+
hash.update(chunk as Buffer)
|
|
409
|
+
bytes += (chunk as Buffer).length
|
|
410
|
+
callback(null, chunk)
|
|
411
|
+
},
|
|
412
|
+
})
|
|
413
|
+
await pipeline(response.body, hasher, createWriteStream(destination))
|
|
414
|
+
if (bytes !== artifact.size) throw new Error(`size mismatch: expected ${artifact.size}, received ${bytes}`)
|
|
415
|
+
if (hash.digest('hex') !== artifact.sha256) throw new Error('sha256 mismatch')
|
|
416
|
+
} finally {
|
|
417
|
+
await response.close()
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function downloadBundledPython(
|
|
422
|
+
artifact: PythonBootstrapArtifact,
|
|
423
|
+
destination: string,
|
|
424
|
+
requestImpl: DownloadRequest,
|
|
425
|
+
): Promise<void> {
|
|
426
|
+
let lastError: unknown
|
|
427
|
+
for (let attempt = 0; attempt < PYTHON_DOWNLOAD_ATTEMPTS; attempt++) {
|
|
428
|
+
try {
|
|
429
|
+
await downloadBundledPythonOnce(artifact, destination, requestImpl)
|
|
430
|
+
return
|
|
431
|
+
} catch (error) {
|
|
432
|
+
lastError = error
|
|
433
|
+
if (attempt + 1 < PYTHON_DOWNLOAD_ATTEMPTS) {
|
|
434
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 500 * 2 ** attempt))
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
throw lastError
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Cross-process directory lock with a stale-lock timeout and heartbeat,
|
|
443
|
+
* matching the managed-runtime lock semantics.
|
|
444
|
+
*/
|
|
445
|
+
async function withDirectoryLock<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
|
|
446
|
+
const owner = randomUUID()
|
|
447
|
+
let acquired = false
|
|
448
|
+
await mkdir(dirname(lockPath), { recursive: true })
|
|
449
|
+
try {
|
|
450
|
+
await mkdir(lockPath, { recursive: false })
|
|
451
|
+
acquired = true
|
|
452
|
+
await writeFile(join(lockPath, 'owner'), `${owner}\n`, { flag: 'wx' })
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (acquired) {
|
|
455
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => {})
|
|
456
|
+
throw error
|
|
457
|
+
}
|
|
458
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
|
459
|
+
const started = Date.now()
|
|
460
|
+
while (Date.now() - started < PREPARE_TIMEOUT_MS) {
|
|
461
|
+
try {
|
|
462
|
+
const info = await stat(lockPath)
|
|
463
|
+
if (Date.now() - info.mtimeMs > LOCK_STALE_MS) {
|
|
464
|
+
await rm(lockPath, { recursive: true, force: true })
|
|
465
|
+
return withDirectoryLock(lockPath, fn)
|
|
466
|
+
}
|
|
467
|
+
} catch {
|
|
468
|
+
return withDirectoryLock(lockPath, fn)
|
|
469
|
+
}
|
|
470
|
+
await new Promise(resolveWait => setTimeout(resolveWait, 250))
|
|
471
|
+
}
|
|
472
|
+
throw new VisionToolkitError('runtime', 'timed out waiting for another process to prepare the bundled Python')
|
|
473
|
+
}
|
|
474
|
+
const heartbeat = setInterval(() => {
|
|
475
|
+
const now = new Date()
|
|
476
|
+
void utimes(lockPath, now, now).catch(() => {})
|
|
477
|
+
}, LOCK_HEARTBEAT_MS)
|
|
478
|
+
heartbeat.unref()
|
|
479
|
+
try {
|
|
480
|
+
return await fn()
|
|
481
|
+
} finally {
|
|
482
|
+
clearInterval(heartbeat)
|
|
483
|
+
try {
|
|
484
|
+
if ((await readFile(join(lockPath, 'owner'), 'utf8')).trim() === owner) {
|
|
485
|
+
await rm(lockPath, { recursive: true, force: true })
|
|
486
|
+
}
|
|
487
|
+
} catch {
|
|
488
|
+
// The lock was already removed or replaced.
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export async function acquireBundledPython(
|
|
494
|
+
ctx: Context,
|
|
495
|
+
stateRoot: string,
|
|
496
|
+
cwd: string,
|
|
497
|
+
manifestOverride?: PythonBootstrapManifest,
|
|
498
|
+
requestImpl?: DownloadRequest,
|
|
499
|
+
): Promise<{ command: RuntimeCommand; version: string }> {
|
|
500
|
+
const manifest = manifestOverride ?? await readPythonBootstrapManifest()
|
|
501
|
+
const target = pythonBootstrapTarget(process.platform, process.arch, await runningMusl())
|
|
502
|
+
const artifact = manifest.artifacts[target]
|
|
503
|
+
if (artifact === undefined) {
|
|
504
|
+
throw new VisionToolkitError(
|
|
505
|
+
'runtime',
|
|
506
|
+
`no bundled Python ${manifest.pythonVersion} artifact for ${target}; install Python 3.11+ or configure runtime.python`,
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
const root = join(stateRoot, 'python-bootstrap', `${manifest.pythonVersion}-${target}`)
|
|
510
|
+
const interpreter = process.platform === 'win32' ? join(root, 'python.exe') : join(root, 'bin', 'python3')
|
|
511
|
+
const command: RuntimeCommand = { program: interpreter, prefix: [], display: interpreter }
|
|
512
|
+
const cached = await pythonMetadata(ctx, command, cwd)
|
|
513
|
+
if (cached !== undefined) return { command, version: cached.version }
|
|
514
|
+
await withDirectoryLock(`${root}.lock`, async () => {
|
|
515
|
+
const ready = await pythonMetadata(ctx, command, cwd)
|
|
516
|
+
if (ready !== undefined) return
|
|
517
|
+
const parent = dirname(root)
|
|
518
|
+
await mkdir(parent, { recursive: true })
|
|
519
|
+
await rm(root, { recursive: true, force: true })
|
|
520
|
+
const work = await mkdtemp(join(parent, '.python-bootstrap-'))
|
|
521
|
+
try {
|
|
522
|
+
const archive = join(work, 'python.tar.gz')
|
|
523
|
+
const extractDir = join(work, 'extract')
|
|
524
|
+
await mkdir(extractDir, { recursive: true })
|
|
525
|
+
try {
|
|
526
|
+
await downloadBundledPython(artifact, archive, requestImpl ?? defaultDownloadRequest)
|
|
527
|
+
} catch (error) {
|
|
528
|
+
throw new VisionToolkitError(
|
|
529
|
+
'runtime',
|
|
530
|
+
`bundled Python ${manifest.pythonVersion} could not be downloaded for ${target} (${error instanceof Error ? error.message : String(error)}); install Python 3.11+ or configure runtime.python`,
|
|
531
|
+
{ cause: error },
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
await extractTar({ file: archive, cwd: extractDir, strip: 1 })
|
|
536
|
+
} catch (error) {
|
|
537
|
+
throw new VisionToolkitError(
|
|
538
|
+
'runtime',
|
|
539
|
+
`bundled Python ${manifest.pythonVersion} could not be extracted for ${target}`,
|
|
540
|
+
{ cause: error },
|
|
541
|
+
)
|
|
542
|
+
}
|
|
543
|
+
const extractedInterpreter = process.platform === 'win32'
|
|
544
|
+
? join(extractDir, 'python.exe')
|
|
545
|
+
: join(extractDir, 'bin', 'python3')
|
|
546
|
+
try {
|
|
547
|
+
await access(extractedInterpreter)
|
|
548
|
+
} catch (error) {
|
|
549
|
+
throw new VisionToolkitError(
|
|
550
|
+
'runtime',
|
|
551
|
+
`bundled Python ${manifest.pythonVersion} for ${target} is missing its interpreter`,
|
|
552
|
+
{ cause: error },
|
|
553
|
+
)
|
|
554
|
+
}
|
|
555
|
+
if (process.platform !== 'win32') await chmod(extractedInterpreter, 0o755)
|
|
556
|
+
await rename(extractDir, root)
|
|
557
|
+
} finally {
|
|
558
|
+
await rm(work, { recursive: true, force: true })
|
|
559
|
+
}
|
|
560
|
+
})
|
|
561
|
+
const metadata = await pythonMetadata(ctx, command, cwd)
|
|
562
|
+
if (metadata === undefined) {
|
|
563
|
+
throw new VisionToolkitError('runtime', 'bundled Python did not start after extraction')
|
|
564
|
+
}
|
|
565
|
+
return { command, version: metadata.version }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export async function resolveBootstrapPython(
|
|
256
569
|
ctx: Context,
|
|
257
570
|
configured: string | undefined,
|
|
258
571
|
cwd: string,
|
|
572
|
+
manifestOverride?: PythonBootstrapManifest,
|
|
573
|
+
requestImpl?: DownloadRequest,
|
|
259
574
|
): Promise<{ command: RuntimeCommand; version: string; major: number; minor: number }> {
|
|
260
575
|
const candidates: RuntimeCommand[] = configured === undefined
|
|
261
576
|
? process.platform === 'win32'
|
|
@@ -275,12 +590,27 @@ async function resolveBootstrapPython(
|
|
|
275
590
|
return { command, ...metadata }
|
|
276
591
|
}
|
|
277
592
|
}
|
|
278
|
-
|
|
279
|
-
'runtime',
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
593
|
+
if (configured !== undefined) {
|
|
594
|
+
throw new VisionToolkitError('runtime', `vision-toolkit requires Python 3.11 or newer: ${configured}`)
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
const stateRoot = visionToolkitStateRoot()
|
|
598
|
+
await mkdir(stateRoot, { recursive: true })
|
|
599
|
+
const bundled = await acquireBundledPython(ctx, stateRoot, cwd, manifestOverride, requestImpl)
|
|
600
|
+
return {
|
|
601
|
+
command: bundled.command,
|
|
602
|
+
version: bundled.version,
|
|
603
|
+
major: Number.parseInt(bundled.version.split('.')[0] ?? '', 10),
|
|
604
|
+
minor: Number.parseInt(bundled.version.split('.')[1] ?? '', 10),
|
|
605
|
+
}
|
|
606
|
+
} catch (error) {
|
|
607
|
+
if (error instanceof VisionToolkitError) throw error
|
|
608
|
+
throw new VisionToolkitError(
|
|
609
|
+
'runtime',
|
|
610
|
+
'vision-toolkit requires Python 3.11 or newer; tried python3, python, and the Windows py launcher, and automatic bundled-Python preparation failed',
|
|
611
|
+
{ cause: error },
|
|
612
|
+
)
|
|
613
|
+
}
|
|
284
614
|
}
|
|
285
615
|
|
|
286
616
|
/** Persistent per-DSH-home cache root shared by runtime and Web support files. */
|
package/src/skill.ts
CHANGED
|
@@ -24,8 +24,8 @@ export const VISION_TOOLS_SKILL_CONTENT = readFileSync(
|
|
|
24
24
|
/** Runtime skill registration mounted only after every native tool is ready. */
|
|
25
25
|
export const VISION_TOOLS_SKILL: SkillRegistration = {
|
|
26
26
|
name: VISION_TOOLS_SKILL_NAME,
|
|
27
|
-
description: '
|
|
28
|
-
whenToUse: '
|
|
27
|
+
description: '把截图或设计图还原为 UI(网页、应用界面或组件),生成 HTML/CSS 或项目现有前端代码;也支持图片问答、长截图/聊天记录 OCR、元素定位与盘点、裁剪、前景提取、像素对比、取色、SVG 描摹和 HTML 转截图。当任务涉及图片理解、UI 还原/重建(rebuild or restore a UI from a screenshot)、视觉回归、像素坐标、基于截图的 GUI 操作、可复用图片/SVG 素材、图表还原或长截图 OCR 时使用。',
|
|
28
|
+
whenToUse: '任务依赖图片文字/内容、像素坐标、截图转 UI 重建、视觉回归、可复用图片/SVG 素材、图表还原、基于截图的 GUI 操作或长截图 OCR 时使用。',
|
|
29
29
|
source: 'runtime',
|
|
30
30
|
resourceBase: {
|
|
31
31
|
kind: 'directory',
|