@noob-stupid/dsh-plugin-console 0.3.20 → 0.3.24-beta.2
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 +24 -0
- package/README.zh.md +22 -0
- package/lib/client.js +365 -12
- package/lib/index.js +830 -8
- package/package.json +43 -39
package/lib/index.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
16
16
|
import { readFileSync, writeFileSync, existsSync, statSync, rmSync, readdirSync, mkdirSync, copyFileSync } from 'node:fs'
|
|
17
|
-
import { execFile } from 'node:child_process'
|
|
17
|
+
import { execFile, spawn } from 'node:child_process'
|
|
18
18
|
import { promisify } from 'node:util'
|
|
19
19
|
import { dirname, join, basename } from 'node:path'
|
|
20
20
|
import { tmpdir, homedir } from 'node:os'
|
|
@@ -236,6 +236,11 @@ async function readExtraBundleRows(profileDir) {
|
|
|
236
236
|
|
|
237
237
|
/** 从加载器读取 webserver 监听端口(默认 3080)。 */
|
|
238
238
|
function webPort(ctx) {
|
|
239
|
+
// 优先取运行时真实监听端口(自定义 --port / 系统分配端口也能正确校验 Host)
|
|
240
|
+
try {
|
|
241
|
+
const ws = ctx.webServer
|
|
242
|
+
if (ws && typeof ws.port === 'number' && ws.port > 0) return ws.port
|
|
243
|
+
} catch {}
|
|
239
244
|
for (const entry of ctx.loader.entries()) {
|
|
240
245
|
if (entry.options?.name === '@deepseek-ai/dsh-host-webserver') {
|
|
241
246
|
const port = entry.options?.config?.port
|
|
@@ -1828,6 +1833,11 @@ async function ensureBundlePatchIntegrity(profileDir, pkgName, userPatchPath) {
|
|
|
1828
1833
|
*/
|
|
1829
1834
|
const installJobs = new Map()
|
|
1830
1835
|
let installJobSeq = 0
|
|
1836
|
+
/** AI 赋能任务注册表(规划/执行均常驻服务端,面板轮询进度)。 */
|
|
1837
|
+
const aiJobs = new Map()
|
|
1838
|
+
let aiJobSeq = 0
|
|
1839
|
+
/** 组件注册表(服务型组件:启动/停止/状态控制按钮的数据源)。 */
|
|
1840
|
+
const COMPONENTS_FILE = join(homedir(), '.dsh', 'plugin-console', 'components.json')
|
|
1831
1841
|
/** 静态插件索引内存缓存(/market-index 用)。 */
|
|
1832
1842
|
let marketIndexCache = null
|
|
1833
1843
|
|
|
@@ -2413,7 +2423,626 @@ async function aiRepair(job, ctx, profileDir, candidates, lastError) {
|
|
|
2413
2423
|
}
|
|
2414
2424
|
}
|
|
2415
2425
|
|
|
2416
|
-
|
|
2426
|
+
// ============================================================
|
|
2427
|
+
// AI 赋能:文档驱动的通用组件部署(读文档 → 出计划 → 确认 → 执行 → 组件控制)
|
|
2428
|
+
// ============================================================
|
|
2429
|
+
|
|
2430
|
+
/** AI 赋能计划/执行任务视图(content 一律剥离,防密钥经轮询泄露)。 */
|
|
2431
|
+
function aiJobView(job) {
|
|
2432
|
+
return {
|
|
2433
|
+
jobId: job.id,
|
|
2434
|
+
source: job.source,
|
|
2435
|
+
status: job.status,
|
|
2436
|
+
stage: job.stage,
|
|
2437
|
+
error: job.error ?? null,
|
|
2438
|
+
type: job.plan?.type ?? null,
|
|
2439
|
+
displayName: job.plan?.displayName ?? null,
|
|
2440
|
+
summary: job.plan?.summary ?? null,
|
|
2441
|
+
servers: (job.plan?.servers ?? []).map((s) => ({ name: s.name, healthUrl: s.healthUrl ?? null, port: s.port ?? null })),
|
|
2442
|
+
steps: (job.plan?.steps ?? []).map((s, i) => ({
|
|
2443
|
+
index: i,
|
|
2444
|
+
action: s.action,
|
|
2445
|
+
description: s.description ?? '',
|
|
2446
|
+
path: typeof s.path === 'string' ? resolvePlaceholders(s.path, job) : null,
|
|
2447
|
+
package: s.package ?? null,
|
|
2448
|
+
url: typeof s.url === 'string' ? maskUrl(s.url) : null,
|
|
2449
|
+
name: s.name ?? null,
|
|
2450
|
+
})),
|
|
2451
|
+
stepStates: job.stepStates ?? [],
|
|
2452
|
+
progress: job.progress ?? { done: 0, total: (job.plan?.steps ?? []).length },
|
|
2453
|
+
logText: (job.logText ?? '').slice(-6000),
|
|
2454
|
+
createdAt: job.createdAt,
|
|
2455
|
+
finishedAt: job.finishedAt ?? null,
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
|
|
2459
|
+
function maskUrl(url) {
|
|
2460
|
+
try {
|
|
2461
|
+
const u = new URL(url)
|
|
2462
|
+
if (u.password !== '') u.password = '***'
|
|
2463
|
+
if (u.username !== '') u.username = '***'
|
|
2464
|
+
if (u.search !== '') u.search = ''
|
|
2465
|
+
return u.toString()
|
|
2466
|
+
} catch {
|
|
2467
|
+
return String(url).split(/[?#]/u)[0]
|
|
2468
|
+
}
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
/** 占位符:${home} 家目录 / ${profile} DSH profile 目录 / ${python} Python 解释器 / ${scripts} Python Scripts / ${node} node 可执行 / ${deepseekKey} DSH 凭据中的 DeepSeek 密钥。 */
|
|
2472
|
+
let deepseekKeyCache = null
|
|
2473
|
+
function readDeepSeekKey() {
|
|
2474
|
+
if (deepseekKeyCache !== null) return deepseekKeyCache
|
|
2475
|
+
try {
|
|
2476
|
+
const credFile = join(homedir(), '.dsh', '.credentials.yaml')
|
|
2477
|
+
if (existsSync(credFile)) {
|
|
2478
|
+
const m = /^\s*DEEPSEEK_API_KEY\s*:\s*(\S+)\s*$/mu.exec(readFileSync(credFile, 'utf8'))
|
|
2479
|
+
if (m) {
|
|
2480
|
+
deepseekKeyCache = m[1]
|
|
2481
|
+
return deepseekKeyCache
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
} catch {}
|
|
2485
|
+
deepseekKeyCache = ''
|
|
2486
|
+
return deepseekKeyCache
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
/**
|
|
2490
|
+
* AI 赋能模型配置(OpenViking VLM 用):
|
|
2491
|
+
* 1) 优先 ~/.dsh/plugin-console/ai-empower.json(独立区块,显式覆盖)
|
|
2492
|
+
* 格式:{ "vlm": { "provider": "...", "api_base": "...", "model": "..." }, "api_key": "sk-..." }
|
|
2493
|
+
* api_key 缺省时继承 DSH 凭据;vlm 缺省时整体回退 DSH 当前设置。
|
|
2494
|
+
* 2) 未配置区块 → 跟随 DSH:settings.yaml 的 agent-default-model + .credentials.yaml 的 DEEPSEEK_API_KEY。
|
|
2495
|
+
*/
|
|
2496
|
+
function readAiEmpowerConfig() {
|
|
2497
|
+
try {
|
|
2498
|
+
const cfgFile = join(homedir(), '.dsh', 'plugin-console', 'ai-empower.json')
|
|
2499
|
+
if (existsSync(cfgFile)) return JSON.parse(readFileSync(cfgFile, 'utf8'))
|
|
2500
|
+
} catch {}
|
|
2501
|
+
return null
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2504
|
+
function resolveVlmForOpenViking() {
|
|
2505
|
+
const custom = readAiEmpowerConfig()
|
|
2506
|
+
let base = null
|
|
2507
|
+
try {
|
|
2508
|
+
const settings = readFileSync(join(homedir(), '.dsh', 'settings.yaml'), 'utf8')
|
|
2509
|
+
const m = /agent-default-model:\s*[\s\S]*?provider:\s*(\S+)\s*\n\s*model:\s*(\S+)/u.exec(settings)
|
|
2510
|
+
const p = m?.[1] ?? ''
|
|
2511
|
+
if (p === 'deepseek-official' || p === 'deepseek') {
|
|
2512
|
+
base = { provider: 'openai', api_base: 'https://api.deepseek.com', model: m[2] ?? 'deepseek-v4-flash-vision-exp' }
|
|
2513
|
+
}
|
|
2514
|
+
} catch {}
|
|
2515
|
+
if (base === null) base = { provider: 'openai', api_base: 'https://api.deepseek.com', model: 'deepseek-v4-flash-vision-exp' }
|
|
2516
|
+
const vlm = custom?.vlm ?? base
|
|
2517
|
+
const apiKey = typeof custom?.api_key === 'string' && custom.api_key !== '' ? custom.api_key : readDeepSeekKey()
|
|
2518
|
+
return { provider: vlm.provider ?? 'openai', api_base: vlm.api_base ?? 'https://api.deepseek.com', model: vlm.model ?? 'deepseek-v4-flash-vision-exp', api_key: apiKey }
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
function resolvePlaceholders(value, jobOrEnv) {
|
|
2522
|
+
const env = jobOrEnv ?? {}
|
|
2523
|
+
const profile = env.profileDir ?? ''
|
|
2524
|
+
const python = resolvePythonPath()
|
|
2525
|
+
const home = homedir()
|
|
2526
|
+
return String(value)
|
|
2527
|
+
.replaceAll('${home}', home)
|
|
2528
|
+
.replaceAll('${profile}', profile)
|
|
2529
|
+
.replaceAll('${python}', python)
|
|
2530
|
+
.replaceAll('${scripts}', join(dirname(python), 'Scripts'))
|
|
2531
|
+
.replaceAll('${node}', process.execPath)
|
|
2532
|
+
.replaceAll('${deepseekKey}', readDeepSeekKey())
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
function resolvePythonPath() {
|
|
2536
|
+
const candidates = [join('E:', 'python314', 'python.exe'), join(homedir(), 'AppData', 'Local', 'Programs', 'Python', 'Python314', 'python.exe'), 'python.exe']
|
|
2537
|
+
for (const c of candidates) {
|
|
2538
|
+
if (existsSync(c)) return c
|
|
2539
|
+
}
|
|
2540
|
+
return 'python.exe'
|
|
2541
|
+
}
|
|
2542
|
+
|
|
2543
|
+
function findComponents() {
|
|
2544
|
+
try {
|
|
2545
|
+
const parsed = JSON.parse(readFileSync(COMPONENTS_FILE, 'utf8'))
|
|
2546
|
+
return Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.components) ? parsed.components : [])
|
|
2547
|
+
} catch {
|
|
2548
|
+
return []
|
|
2549
|
+
}
|
|
2550
|
+
}
|
|
2551
|
+
|
|
2552
|
+
function saveComponents(list) {
|
|
2553
|
+
mkdirSync(dirname(COMPONENTS_FILE), { recursive: true })
|
|
2554
|
+
writeFileSync(COMPONENTS_FILE, JSON.stringify(list, null, 2), 'utf8')
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
function compFind(id) {
|
|
2558
|
+
return findComponents().find((c) => c.id === id) ?? null
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
/** 组件 UI 地址:显式 uiUrl 优先,否则从 healthUrl 去 /health 或按端口推断。 */
|
|
2562
|
+
function compUiUrl(c) {
|
|
2563
|
+
if (typeof c?.uiUrl === 'string' && c.uiUrl !== '') return c.uiUrl
|
|
2564
|
+
if (typeof c?.healthUrl === 'string' && c.healthUrl !== '') return String(c.healthUrl).replace(/\/health$/u, '')
|
|
2565
|
+
if (c?.port) return `http://127.0.0.1:${c.port}/`
|
|
2566
|
+
return null
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
function compUpsert(record) {
|
|
2570
|
+
const list = findComponents()
|
|
2571
|
+
const idx = list.findIndex((c) => c.id === record.id)
|
|
2572
|
+
if (idx >= 0) list[idx] = { ...list[idx], ...record }
|
|
2573
|
+
else list.push(record)
|
|
2574
|
+
saveComponents(list)
|
|
2575
|
+
return record.id
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
function compRemove(id) {
|
|
2579
|
+
saveComponents(findComponents().filter((c) => c.id !== id))
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
/** 写入路径白名单:只允许 DSH profile、~/.dsh、~/.openviking、~/.cache/openviking、ASCII 数据根。 */
|
|
2583
|
+
function isAllowedWritePath(p, profileDir) {
|
|
2584
|
+
const norm = (s) => s.replace(/[\\/]+/gu, '/').replace(/\/+$/u, '')
|
|
2585
|
+
const target = norm(resolvePlaceholders(p, { profileDir }))
|
|
2586
|
+
const roots = [
|
|
2587
|
+
norm(homedir()) + '/.dsh',
|
|
2588
|
+
norm(homedir()) + '/.openviking',
|
|
2589
|
+
norm(homedir()) + '/.cache/openviking',
|
|
2590
|
+
norm(homedir()) + '/.local/share/openviking',
|
|
2591
|
+
norm(profileDir),
|
|
2592
|
+
'D:/OpenVikingData',
|
|
2593
|
+
]
|
|
2594
|
+
return roots.some((root) => target === root || target.startsWith(root + '/'))
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
/** run-cmd 可执行文件白名单(按 basename)。 */
|
|
2598
|
+
const ALLOWED_RUN_FILES = new Set(['curl.exe', 'curl', 'git.exe', 'git', 'node.exe', 'node', 'python.exe', 'python', 'py.exe', 'py', 'gh.exe', 'gh', 'npm.cmd', 'npm', 'ov.cmd', 'ov'])
|
|
2599
|
+
const DESTRUCTIVE_RE = /(^|\s)(rm\s+(-[a-zA-Z]*r)|rmdir\s+\/s|del\s+\/s|format\s+[a-zA-Z]:|Remove-Item\b|taskkill\s+\/im|reg\s+delete|schtasks\s+\/delete|shutdown|cipher\s+\/w)/iu
|
|
2600
|
+
|
|
2601
|
+
function isSafeRunCmd(file, args) {
|
|
2602
|
+
const base = basename(String(file)).toLowerCase()
|
|
2603
|
+
if (!ALLOWED_RUN_FILES.has(base)) return false
|
|
2604
|
+
const joined = [file, ...(args ?? [])].join(' ')
|
|
2605
|
+
return !DESTRUCTIVE_RE.test(joined) && !/[&|;`$<>]/u.test(joined)
|
|
2606
|
+
}
|
|
2607
|
+
|
|
2608
|
+
function jobLog(job, line) {
|
|
2609
|
+
job.logText = `${job.logText ?? ''}${line}\n`
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
/** 已知组件内置模板:跳过 AI 调研,直接产出经过验证的部署计划(当前仅 OpenViking)。 */
|
|
2613
|
+
function builtinPlanFor(source) {
|
|
2614
|
+
if (!/openviking/i.test(source)) return null
|
|
2615
|
+
const home = homedir()
|
|
2616
|
+
const storage = /^[\x00-\x7F]+$/u.test(home) ? `${home}/.openviking/data` : 'D:/OpenVikingData'
|
|
2617
|
+
const vlm = resolveVlmForOpenViking()
|
|
2618
|
+
// 注意:路径必须在此处(JSON.stringify 之前)使用真实值,由 stringify 统一转义;
|
|
2619
|
+
// 不能在 content 中保留 ${home} 占位符——写文件时替换会把反斜杠路径变成非法 JSON 转义。
|
|
2620
|
+
const ovConf = {
|
|
2621
|
+
server: { host: '127.0.0.1', port: 1933, cors_origins: ['*'] },
|
|
2622
|
+
vlm: { provider: vlm.provider, api_base: vlm.api_base, api_key: vlm.api_key, model: vlm.model },
|
|
2623
|
+
embedding: { dense: { provider: 'local', model: 'bge-small-zh-v1.5-f16', model_path: join(home, '.cache', 'openviking', 'models', 'bge-small-zh-v1.5-f16.gguf') } },
|
|
2624
|
+
storage: { workspace: storage },
|
|
2625
|
+
}
|
|
2626
|
+
const server = { name: 'openviking-server', file: '${python}', args: ['-m', 'openviking_cli.server_bootstrap'], cwd: '${home}/.openviking', healthUrl: 'http://127.0.0.1:1933/health', uiUrl: 'http://127.0.0.1:1933/studio', port: 1933 }
|
|
2627
|
+
return {
|
|
2628
|
+
type: 'service',
|
|
2629
|
+
displayName: 'OpenViking(本地记忆服务器)',
|
|
2630
|
+
summary: '安装 OpenViking 服务器(Python 3.14 + llama-cpp-python)、下载中文嵌入模型、写入 ov.conf(复用 DSH 的 DeepSeek 凭据、ASCII 存储路径)、启动并健康检查;完成后重启 DSH 会话即可出现 mcp__openviking__* 工具,pending 积压自动回放。',
|
|
2631
|
+
servers: [server],
|
|
2632
|
+
steps: [
|
|
2633
|
+
{ action: 'install-pip', package: 'openviking[local-embed]', description: '安装 OpenViking 服务器(含本地嵌入 llama-cpp-python)' },
|
|
2634
|
+
{ action: 'download', url: 'https://huggingface.co/CompendiumLabs/bge-small-zh-v1.5-gguf/resolve/main/bge-small-zh-v1.5-f16.gguf?download=true', path: '${home}/.cache/openviking/models/bge-small-zh-v1.5-f16.gguf', description: '下载中文本地嵌入模型 bge-small-zh-v1.5-f16(47MB,走 hf-mirror)' },
|
|
2635
|
+
{ action: 'write-file', path: '${home}/.openviking/ov.conf', content: JSON.stringify(ovConf, null, 2), description: '写入 ov.conf(DeepSeek VLM + 本地嵌入 + dev 免鉴权 + ASCII 存储路径)' },
|
|
2636
|
+
{ action: 'start-service', ...server, description: '启动 openviking-server(127.0.0.1:1933)' },
|
|
2637
|
+
{ action: 'wait-health', url: 'http://127.0.0.1:1933/health', timeoutMs: 90000, description: '等待健康检查通过' },
|
|
2638
|
+
],
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
|
|
2642
|
+
/** AI 赋能预案:把已知环境事实与踩坑点固化为提示,避免子代理每次重新踩坑。 */
|
|
2643
|
+
function aiEmpowerPresetFor(source) {
|
|
2644
|
+
const common = [
|
|
2645
|
+
'## 本机环境事实(务必遵守)',
|
|
2646
|
+
'- 网络:pypi.org 不通、清华镜像 403;pip 必须用 `--index-url https://mirrors.aliyun.com/pypi/simple/`。npm registry.npmjs.org 可能黑洞,pnpm 用 `--registry https://registry.npmmirror.com`。huggingface.co 被墙,模型文件一律用 `https://hf-mirror.com/<同一路径>`(执行器会自动把 huggingface.co 换成 hf-mirror.com,计划里也可直接写 hf-mirror)。',
|
|
2647
|
+
'- 用户名含中文(花火):任何写盘路径若含中文,各语言的 Rust/原生向量库会报 UnicodeDecodeError——服务数据目录必须纯 ASCII(如 `D:/OpenVikingData`)。',
|
|
2648
|
+
'- 凭据复用:DSH 的 DeepSeek 密钥在 `${home}/.dsh/.credentials.yaml`(YAML 行 `DEEPSEEK_API_KEY: sk-...`);模型选择在 `${home}/.dsh/settings.yaml` 的 `agent-default-model`(provider deepseek-official)。对外 OpenAI 兼容接口:api_base `https://api.deepseek.com`,模型名 `deepseek-v4-flash-vision-exp`。',
|
|
2649
|
+
'- 步骤解释用中文;每个动作一个步骤;步骤数尽量少;不得包含任何破坏性命令;服务类组件必须配 start-service + wait-health。',
|
|
2650
|
+
]
|
|
2651
|
+
if (/openviking/i.test(source)) {
|
|
2652
|
+
return [
|
|
2653
|
+
...common,
|
|
2654
|
+
'## OpenViking 已验证的部署事实(v0.4.17,Python 3.14)',
|
|
2655
|
+
'- 安装:`pip install openviking[local-embed]`(含 llama-cpp-python 0.3.35,cp310-abi3 轮子可用;无需加 --force-reinstall)。',
|
|
2656
|
+
'- 嵌入模型:`bge-small-zh-v1.5-f16.gguf`(47MB,512 维中文)下载到 `${home}/.cache/openviking/models/`(写 download 步骤;URL 写 huggingface.co 会被执行器自动换 hf-mirror.com)。',
|
|
2657
|
+
'- ov.conf 路径 `${home}/.openviking/ov.conf`,内容:vlm = { provider: openai, api_base: https://api.deepseek.com, model: deepseek-v4-flash-vision-exp, api_key: 从 `${home}/.dsh/.credentials.yaml` 读取并内联 };embedding.dense = { provider: local, model: bge-small-zh-v1.5-f16, model_path: <上面的 gguf 路径> };server = { host: 127.0.0.1, port: 1933 }(不要写 root_api_key,dev 免鉴权模式自动启用);storage.workspace = `${home}` 含非 ASCII 时写 `D:/OpenVikingData`,否则 `${home}/.openviking/data`。',
|
|
2658
|
+
'- 启动:start-service,file=`${python}`,args=`["-m","openviking_cli.server_bootstrap"]`,cwd=`${home}/.openviking`,healthUrl=`http://127.0.0.1:1933/health`,port=1933。服务器启动首次会建 `D:/OpenVikingData` 数据目录。',
|
|
2659
|
+
'- 部署完成后惯例:重启 DSH 会话后 mcp__openviking__* 工具出现,`${home}/.openviking/pending` 的积压消息会在新会话启动时自动回放——把这写进总结。',
|
|
2660
|
+
].join('\n')
|
|
2661
|
+
}
|
|
2662
|
+
return common.join('\n')
|
|
2663
|
+
}
|
|
2664
|
+
|
|
2665
|
+
/** AI 赋能:规划阶段——子代理只读调研文档,产出结构化部署计划(JSON)。 */
|
|
2666
|
+
async function aiEmpowerPlan(job, ctx, profileDir) {
|
|
2667
|
+
job.profileDir = profileDir
|
|
2668
|
+
job.status = 'running'
|
|
2669
|
+
job.stage = 'planning'
|
|
2670
|
+
let subagents = null
|
|
2671
|
+
try { subagents = ctx.get('subagents') } catch {}
|
|
2672
|
+
const startFn = subagents?.start
|
|
2673
|
+
if (typeof startFn !== 'function') {
|
|
2674
|
+
job.status = 'failed'
|
|
2675
|
+
job.error = 'AI 赋能需要 DSH 子代理服务(subagents),当前不可用'
|
|
2676
|
+
job.finishedAt = Date.now()
|
|
2677
|
+
return
|
|
2678
|
+
}
|
|
2679
|
+
let provider = 'spawn'
|
|
2680
|
+
try {
|
|
2681
|
+
const list = subagents.list?.() ?? []
|
|
2682
|
+
if (!list.includes(provider)) provider = list[0]
|
|
2683
|
+
if (provider === undefined) throw new Error('no provider')
|
|
2684
|
+
} catch (error) {
|
|
2685
|
+
job.status = 'failed'
|
|
2686
|
+
job.error = `没有可用的子代理提供方:${String(error)}`
|
|
2687
|
+
job.finishedAt = Date.now()
|
|
2688
|
+
return
|
|
2689
|
+
}
|
|
2690
|
+
const prompt = [
|
|
2691
|
+
'你是 DSH 插件控制台挂载的「AI 赋能」部署规划器。用户想把下面的组件完整部署到本机。',
|
|
2692
|
+
`目标来源:${job.source}`,
|
|
2693
|
+
'',
|
|
2694
|
+
'【任务】只做只读调研与规划,禁止执行任何安装/写文件/启动服务/杀进程命令(curl 的 GET 抓取、gh api 查询、读取文件是允许的)。',
|
|
2695
|
+
'调研材料:npm README / GitHub README / docs/ 目录 / package.json / install 说明。',
|
|
2696
|
+
'全部调研完成后,输出且只输出一个 ```json ... ``` 代码块,内容为部署计划;代码块之外不要输出任何文字。',
|
|
2697
|
+
'',
|
|
2698
|
+
'计划 schema(严格遵循)',
|
|
2699
|
+
'{',
|
|
2700
|
+
' "type": "pure-plugin" | "service" | "config-only",',
|
|
2701
|
+
' "displayName": "简短中文名",',
|
|
2702
|
+
' "summary": "一句话:这是什么、为何这样部署",',
|
|
2703
|
+
' "servers": [ { "name": "进程名", "file": "启动文件/命令", "args": ["参数..."], "cwd": "工作目录", "healthUrl": "http://127.0.0.1:端口/health", "port": 1933 } ],',
|
|
2704
|
+
' "steps": [ { "action": "...", "description": "中文说明", "...": "动作参数" } ]',
|
|
2705
|
+
'}',
|
|
2706
|
+
'',
|
|
2707
|
+
'动作与参数(每个动作一个步骤,按依赖顺序)',
|
|
2708
|
+
'- install-pip: {"package":"<包名[extras]>"}',
|
|
2709
|
+
'- install-npm: {"package":"<npm 包名>","directory":"${profile}"}',
|
|
2710
|
+
'- write-file: {"path":"<目标文件>","content":"<完整文件内容原文>"}',
|
|
2711
|
+
'- download: {"url":"https://...","path":"<保存路径>"}',
|
|
2712
|
+
'- run-cmd: {"file":"可执行文件","args":["..."]}(file 仅限 curl/git/node/python/py/gh/npm/ov)',
|
|
2713
|
+
'- start-service: {"name":"<唯一名>","file":"...","args":["..."],"cwd":"...","healthUrl":"...","port":数字}',
|
|
2714
|
+
'- wait-health: {"url":"http://127.0.0.1:端口/health","timeoutMs":60000}',
|
|
2715
|
+
'',
|
|
2716
|
+
'约束',
|
|
2717
|
+
'- 占位符只能用 ${home} ${profile} ${python} ${scripts} ${node};write-file 的 content 会被原样落盘',
|
|
2718
|
+
'- 纯 npm 插件:type=pure-plugin,只写 install-npm 步骤,不要 servers',
|
|
2719
|
+
'- 若用户提供的来源带 @tag(如 pkg@beta),install-npm 的 package 必须原样保留该 @tag,不得丢弃',
|
|
2720
|
+
'- 危险命令(rm -rf、del /s、format、Remove-Item 等)出现在计划里会被拒绝执行',
|
|
2721
|
+
'',
|
|
2722
|
+
'## 调研纪律(必须遵守)',
|
|
2723
|
+
'- 禁止递归扫描目录(Get-ChildItem -Recurse、find /、rg 全盘搜索);只允许:读 ~/.openviking/、~/.dsh 顶层文件(.credentials.yaml、settings.yaml)、文档缓存目录,以及 curl 单次直连抓取',
|
|
2724
|
+
'- 每条命令必须带超时:curl 用 --max-time 30;PowerShell 单命令控制在 15 秒内;抓取失败就跳过,不要反复重试同一 URL',
|
|
2725
|
+
'- 总调研时间预算 8 分钟;剩余时间必须全部用于生成 JSON 计划,禁止重复验证已经确定的事实',
|
|
2726
|
+
'',
|
|
2727
|
+
aiEmpowerPresetFor(job.source),
|
|
2728
|
+
].join('\n')
|
|
2729
|
+
|
|
2730
|
+
try {
|
|
2731
|
+
let parent = null
|
|
2732
|
+
try {
|
|
2733
|
+
const agents = ctx.get('agents')
|
|
2734
|
+
const list = typeof agents?.roots === 'function' ? agents.roots() : (typeof agents?.list === 'function' ? agents.list() : [])
|
|
2735
|
+
parent = list[0] ?? null
|
|
2736
|
+
} catch {}
|
|
2737
|
+
if (parent === null) {
|
|
2738
|
+
job.status = 'failed'
|
|
2739
|
+
job.error = 'AI 赋能无法借用活动会话(agents 注册表为空),请先开启一个会话再试'
|
|
2740
|
+
job.finishedAt = Date.now()
|
|
2741
|
+
return
|
|
2742
|
+
}
|
|
2743
|
+
const controller = new AbortController()
|
|
2744
|
+
job.abort = controller
|
|
2745
|
+
const run = await startFn.call(subagents, provider, {
|
|
2746
|
+
label: `ai-empower-plan-${String(job.source).split('/')[0]?.slice(0, 20) ?? 'component'}`,
|
|
2747
|
+
prompt: [{ type: 'text', text: prompt }],
|
|
2748
|
+
maxDepth: 1,
|
|
2749
|
+
signal: controller.signal,
|
|
2750
|
+
parent,
|
|
2751
|
+
})
|
|
2752
|
+
let settleFn = null
|
|
2753
|
+
try {
|
|
2754
|
+
const requireLocal = createRequire(join(profileDir, 'package.json'))
|
|
2755
|
+
;({ settleRun: settleFn } = requireLocal('@deepseek-ai/dsh-subagent'))
|
|
2756
|
+
} catch {}
|
|
2757
|
+
const settle = settleFn ?? (async (runHandle) => {
|
|
2758
|
+
try {
|
|
2759
|
+
const result = await runHandle.result
|
|
2760
|
+
return { status: result?.stopReason === 'completed' ? 'completed' : 'failed', detail: String(result?.stopReason ?? 'unknown') }
|
|
2761
|
+
} catch (error) {
|
|
2762
|
+
return { status: 'failed', detail: String(error) }
|
|
2763
|
+
}
|
|
2764
|
+
})
|
|
2765
|
+
const outcome = await Promise.race([
|
|
2766
|
+
settle(run),
|
|
2767
|
+
new Promise((resolve) => setTimeout(() => {
|
|
2768
|
+
controller.abort()
|
|
2769
|
+
resolve({ status: 'failed', detail: 'AI 赋能规划超时(15 分钟)' })
|
|
2770
|
+
}, 900000)),
|
|
2771
|
+
])
|
|
2772
|
+
if (outcome.status === 'completed') {
|
|
2773
|
+
const plan = parsePlanJson(String(outcome.output ?? outcome.detail ?? ''))
|
|
2774
|
+
if (plan === null) {
|
|
2775
|
+
job.status = 'failed'
|
|
2776
|
+
job.error = '子代理完成调研但未产出可解析的部署计划 JSON,请重试或手动部署'
|
|
2777
|
+
} else {
|
|
2778
|
+
job.plan = plan
|
|
2779
|
+
job.status = 'plan-ready'
|
|
2780
|
+
}
|
|
2781
|
+
} else {
|
|
2782
|
+
job.status = 'failed'
|
|
2783
|
+
job.error = `AI 赋能规划未成功(${outcome.detail ?? outcome.status})`
|
|
2784
|
+
}
|
|
2785
|
+
} catch (error) {
|
|
2786
|
+
job.status = 'failed'
|
|
2787
|
+
job.error = `AI 赋能规划异常:${error instanceof Error ? error.message : String(error)}`
|
|
2788
|
+
}
|
|
2789
|
+
job.finishedAt = Date.now()
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
/** 从子代理输出中提取 ```json ... ``` 计划。 */
|
|
2793
|
+
function parsePlanJson(text) {
|
|
2794
|
+
const m = /```json\s*([\s\S]*?)```/u.exec(text)
|
|
2795
|
+
const raw = m ? m[1] : text
|
|
2796
|
+
try {
|
|
2797
|
+
const plan = JSON.parse(raw)
|
|
2798
|
+
if (!plan || typeof plan !== 'object' || !Array.isArray(plan.steps)) return null
|
|
2799
|
+
for (const s of plan.steps) {
|
|
2800
|
+
if (typeof s.action !== 'string' || !['install-pip', 'install-npm', 'write-file', 'download', 'run-cmd', 'start-service', 'wait-health', 'register-component'].includes(s.action)) return null
|
|
2801
|
+
}
|
|
2802
|
+
return plan
|
|
2803
|
+
} catch {
|
|
2804
|
+
return null
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
/** AI 赋能:执行阶段——按用户勾选的步骤顺序执行,逐步回显日志。 */
|
|
2809
|
+
async function aiEmpowerExecute(job, ctx, profileDir, selected) {
|
|
2810
|
+
job.status = 'running'
|
|
2811
|
+
job.stage = 'executing'
|
|
2812
|
+
job.stepStates = (job.plan?.steps ?? []).map((_, i) => ({ index: i, status: 'skipped' }))
|
|
2813
|
+
if (job.abort) job.abort = null
|
|
2814
|
+
const controller = new AbortController()
|
|
2815
|
+
job.abort = controller
|
|
2816
|
+
const steps = job.plan?.steps ?? []
|
|
2817
|
+
const chosen = new Set((selected ?? steps.map((_, i) => i)).map((n) => Number(n)).filter((n) => Number.isInteger(n) && n >= 0 && n < steps.length))
|
|
2818
|
+
const chosenList = steps.map((s, i) => ({ s, i })).filter((x) => chosen.has(x.i))
|
|
2819
|
+
let done = 0
|
|
2820
|
+
for (const { s, i } of chosenList) {
|
|
2821
|
+
if (controller.signal.aborted) {
|
|
2822
|
+
job.status = 'failed'
|
|
2823
|
+
job.error = '已取消执行(用户中断)'
|
|
2824
|
+
break
|
|
2825
|
+
}
|
|
2826
|
+
job.stepStates[i].status = 'running'
|
|
2827
|
+
job.progress = { done, total: chosenList.length }
|
|
2828
|
+
jobLog(job, `▶ 步骤 ${i + 1}/${steps.length} [${s.action}] ${s.description ?? ''}`)
|
|
2829
|
+
try {
|
|
2830
|
+
await runAiStep(job, s, ctx, profileDir, controller.signal)
|
|
2831
|
+
job.stepStates[i].status = 'ok'
|
|
2832
|
+
done += 1
|
|
2833
|
+
jobLog(job, `✔ 步骤 ${i + 1} 完成`)
|
|
2834
|
+
} catch (error) {
|
|
2835
|
+
job.stepStates[i].status = 'fail'
|
|
2836
|
+
job.status = 'failed'
|
|
2837
|
+
job.error = `步骤 ${i + 1}(${s.action})失败:${error instanceof Error ? error.message : String(error)}`
|
|
2838
|
+
jobLog(job, `✘ 步骤 ${i + 1} 失败:${job.error}`)
|
|
2839
|
+
break
|
|
2840
|
+
}
|
|
2841
|
+
}
|
|
2842
|
+
if (job.status !== 'failed') {
|
|
2843
|
+
job.status = 'done'
|
|
2844
|
+
job.stage = 'done'
|
|
2845
|
+
}
|
|
2846
|
+
job.progress = { done, total: chosenList.length }
|
|
2847
|
+
job.finishedAt = Date.now()
|
|
2848
|
+
jobLog(job, job.status === 'done' ? '🎉 AI 赋能部署完成' : '⛔ AI 赋能部署中止')
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
/** 单步执行器:动作白名单 + 路径白名单 + 破坏性命令拦截。 */
|
|
2852
|
+
async function runAiStep(job, step, ctx, profileDir, signal) {
|
|
2853
|
+
const profile = profileDir
|
|
2854
|
+
switch (step.action) {
|
|
2855
|
+
case 'install-pip': {
|
|
2856
|
+
const pkg = String(step.package ?? '')
|
|
2857
|
+
if (!/^[A-Za-z0-9_\-\.\[\]=\s]+$/u.test(pkg) || pkg === '') throw new Error(`包名不合法:${pkg}`)
|
|
2858
|
+
const python = resolvePythonPath()
|
|
2859
|
+
const params = ['-m', 'pip', 'install', pkg, '--index-url', 'https://mirrors.aliyun.com/pypi/simple/', '--disable-pip-version-check']
|
|
2860
|
+
jobLog(job, ` 运行: ${python} ${params.join(' ')}`)
|
|
2861
|
+
await execFileAsync(python, params, { timeout: 900000, windowsHide: true, maxBuffer: 4 * 1024 * 1024, signal })
|
|
2862
|
+
return
|
|
2863
|
+
}
|
|
2864
|
+
case 'install-npm': {
|
|
2865
|
+
const pkg = String(step.package ?? '')
|
|
2866
|
+
if (!/^(@[A-Za-z0-9_\-\.]+\/)?[A-Za-z0-9_\-\.]+(@[A-Za-z0-9_\-\.]+)?$/u.test(pkg)) throw new Error(`包名不合法:${pkg}`)
|
|
2867
|
+
const dir = resolvePlaceholders(String(step.directory ?? profile), { profileDir: profile })
|
|
2868
|
+
const corepackJs = join(dirname(process.execPath), 'node_modules', 'corepack', 'dist', 'corepack.js')
|
|
2869
|
+
const params = [corepackJs, 'pnpm', 'add', pkg, '--dir', dir, '--registry', 'https://registry.npmmirror.com']
|
|
2870
|
+
jobLog(job, ` 运行: node ${params.join(' ')}`)
|
|
2871
|
+
await execFileAsync(process.execPath, params, { timeout: 900000, windowsHide: true, maxBuffer: 4 * 1024 * 1024, signal })
|
|
2872
|
+
return
|
|
2873
|
+
}
|
|
2874
|
+
case 'write-file': {
|
|
2875
|
+
const target = resolvePlaceholders(String(step.path ?? ''), { profileDir: profile })
|
|
2876
|
+
if (!isAllowedWritePath(target, profile)) throw new Error(`写入路径不在白名单内:${target}`)
|
|
2877
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
2878
|
+
let content = resolvePlaceholders(String(step.content ?? ''), { profileDir: profile })
|
|
2879
|
+
// JSON 内容防御:若计划生成方用了占位符导致非法转义(路径反斜杠未转义),在这里拦住并给出明确报错
|
|
2880
|
+
const trimmed = content.trim()
|
|
2881
|
+
if ((trimmed.startsWith('{') || trimmed.startsWith('[')) && content.length < 1024 * 1024) {
|
|
2882
|
+
try {
|
|
2883
|
+
JSON.parse(trimmed)
|
|
2884
|
+
} catch {
|
|
2885
|
+
throw new Error('write-file 的内容不是合法 JSON(占位符替换后可能残留未转义的反斜杠路径):请让计划以 JSON.stringify 后的原文提供 content')
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
writeFileSync(target, content, 'utf8')
|
|
2889
|
+
jobLog(job, ` 已写入 ${target}(${content.length} 字节)`)
|
|
2890
|
+
return
|
|
2891
|
+
}
|
|
2892
|
+
case 'download': {
|
|
2893
|
+
const url = maskUrl(String(step.url ?? ''))
|
|
2894
|
+
if (!/^https?:\/\//u.test(url)) throw new Error(`下载地址不合法:${url}`)
|
|
2895
|
+
const target = resolvePlaceholders(String(step.path ?? ''), { profileDir: profile })
|
|
2896
|
+
if (!isAllowedWritePath(target, profile)) throw new Error(`下载路径不在白名单内:${target}`)
|
|
2897
|
+
mkdirSync(dirname(target), { recursive: true })
|
|
2898
|
+
if (existsSync(target) && statSync(target).size > 1024 * 1024) {
|
|
2899
|
+
jobLog(job, ` 已存在,跳过下载:${target}(${statSync(target).size} 字节)`)
|
|
2900
|
+
return
|
|
2901
|
+
}
|
|
2902
|
+
const realUrl = String(step.url).replaceAll('https://huggingface.co', 'https://hf-mirror.com')
|
|
2903
|
+
jobLog(job, ` 下载 ${maskUrl(realUrl)} → ${target}`)
|
|
2904
|
+
await execFileAsync('curl.exe', ['-fSL', '-o', target, realUrl], { timeout: 900000, windowsHide: true, maxBuffer: 1024 * 1024, signal })
|
|
2905
|
+
jobLog(job, ` 下载完成(${statSync(target).size} 字节)`)
|
|
2906
|
+
return
|
|
2907
|
+
}
|
|
2908
|
+
case 'run-cmd': {
|
|
2909
|
+
const file = resolvePlaceholders(String(step.file ?? ''), { profileDir: profile })
|
|
2910
|
+
const args = (step.args ?? []).map((a) => resolvePlaceholders(String(a), { profileDir: profile }))
|
|
2911
|
+
if (!isSafeRunCmd(file, args)) throw new Error('run-cmd 未通过安全白名单校验(不允许的可执行文件或破坏性参数)')
|
|
2912
|
+
jobLog(job, ` 运行: ${[file, ...args].join(' ')}`)
|
|
2913
|
+
const { stdout, stderr } = await execFileAsync(file, args, { timeout: 600000, windowsHide: true, maxBuffer: 4 * 1024 * 1024, signal })
|
|
2914
|
+
jobLog(job, ` 输出: ${String(stdout ?? '').slice(-1500)}${stderr ? `(stderr: ${String(stderr).slice(-500)})` : ''}`)
|
|
2915
|
+
return
|
|
2916
|
+
}
|
|
2917
|
+
case 'start-service': {
|
|
2918
|
+
const name = String(step.name ?? 'service')
|
|
2919
|
+
const file = resolvePlaceholders(String(step.file ?? ''), { profileDir: profile })
|
|
2920
|
+
const args = (step.args ?? []).map((a) => resolvePlaceholders(String(a), { profileDir: profile }))
|
|
2921
|
+
const cwd = resolvePlaceholders(String(step.cwd ?? homedir()), { profileDir: profile })
|
|
2922
|
+
const healthUrl = typeof step.healthUrl === 'string' ? resolvePlaceholders(step.healthUrl, { profileDir: profile }) : null
|
|
2923
|
+
const uiUrl = typeof step.uiUrl === 'string' ? resolvePlaceholders(step.uiUrl, { profileDir: profile }) : null
|
|
2924
|
+
const port = Number(step.port ?? 0)
|
|
2925
|
+
if (!isSafeRunCmd(file, args)) throw new Error('start-service 未通过安全白名单校验')
|
|
2926
|
+
if (healthUrl !== null) {
|
|
2927
|
+
try {
|
|
2928
|
+
const probe = await fetch(healthUrl, { signal: AbortSignal.timeout(2500) })
|
|
2929
|
+
if (probe.ok) {
|
|
2930
|
+
const slugPre = name.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '')
|
|
2931
|
+
compUpsert({ id: slugPre || `svc-${Date.now()}`, name, kind: 'server', pid: null, file, args, cwd, healthUrl, uiUrl, port, repo: job.source, installedAt: Date.now() })
|
|
2932
|
+
jobLog(job, ` 服务健康已就绪,跳过启动(复用现有进程):${healthUrl}`)
|
|
2933
|
+
return
|
|
2934
|
+
}
|
|
2935
|
+
} catch {}
|
|
2936
|
+
}
|
|
2937
|
+
const child = spawn(file, args, { detached: true, stdio: 'ignore', windowsHide: true, cwd, shell: false })
|
|
2938
|
+
child.unref()
|
|
2939
|
+
const pid = child.pid
|
|
2940
|
+
const slug = name.toLowerCase().replace(/[^a-z0-9]+/gu, '-').replace(/^-|-$/gu, '')
|
|
2941
|
+
const id = slug || `svc-${Date.now()}`
|
|
2942
|
+
if (healthUrl) {
|
|
2943
|
+
const alive = await waitHealth(healthUrl, 60000, job, signal).catch(() => false)
|
|
2944
|
+
if (!alive) jobLog(job, ` ⚠ healthUrl ${healthUrl} 在 60s 内未就绪(进程 pid=${pid} 已启动,待手工排查)`)
|
|
2945
|
+
}
|
|
2946
|
+
compUpsert({ id, name, kind: 'server', pid, file, args, cwd, healthUrl, uiUrl, port, repo: job.source, installedAt: Date.now() })
|
|
2947
|
+
jobLog(job, ` 服务已启动:${name}(pid=${pid}${healthUrl ? `,healthUrl=${healthUrl}` : ''})`)
|
|
2948
|
+
return
|
|
2949
|
+
}
|
|
2950
|
+
case 'stop-service': {
|
|
2951
|
+
const id = String(step.id ?? '')
|
|
2952
|
+
const record = compFind(id)
|
|
2953
|
+
if (!record || !record.pid) throw new Error(`组件不存在或未记录 pid:${id || '(空)'}`)
|
|
2954
|
+
await execFileAsync('taskkill.exe', ['/PID', String(record.pid), '/F'], { timeout: 30000, windowsHide: true })
|
|
2955
|
+
compUpsert({ id, pid: null })
|
|
2956
|
+
jobLog(job, ` 服务已停止:${record.name}`)
|
|
2957
|
+
return
|
|
2958
|
+
}
|
|
2959
|
+
case 'wait-health': {
|
|
2960
|
+
const url = resolvePlaceholders(String(step.url ?? ''), { profileDir: profile })
|
|
2961
|
+
const timeoutMs = Number(step.timeoutMs ?? 60000)
|
|
2962
|
+
const ok = await waitHealth(url, timeoutMs, job, signal)
|
|
2963
|
+
if (!ok) throw new Error(`健康检查未通过:${url}(${timeoutMs}ms)`)
|
|
2964
|
+
return
|
|
2965
|
+
}
|
|
2966
|
+
case 'register-component': {
|
|
2967
|
+
const id = String(step.id ?? '')
|
|
2968
|
+
if (!id) throw new Error('register-component 缺少 id')
|
|
2969
|
+
compUpsert({ id, name: String(step.name ?? id), kind: String(step.kind ?? 'server'), pid: null, file: step.file ? resolvePlaceholders(String(step.file), { profileDir: profile }) : undefined, args: (step.args ?? []).map((a) => resolvePlaceholders(String(a), { profileDir: profile })), healthUrl: step.healthUrl ? resolvePlaceholders(String(step.healthUrl), { profileDir: profile }) : undefined, uiUrl: step.uiUrl ? resolvePlaceholders(String(step.uiUrl), { profileDir: profile }) : undefined, port: Number(step.port ?? 0), repo: job.source, installedAt: Date.now() })
|
|
2970
|
+
return
|
|
2971
|
+
}
|
|
2972
|
+
default:
|
|
2973
|
+
throw new Error(`未知动作:${step.action}`)
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
|
|
2977
|
+
async function waitHealth(url, timeoutMs, job, signal) {
|
|
2978
|
+
const deadline = Date.now() + timeoutMs
|
|
2979
|
+
let last = ''
|
|
2980
|
+
while (Date.now() < deadline) {
|
|
2981
|
+
if (signal?.aborted) return false
|
|
2982
|
+
try {
|
|
2983
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(3000) })
|
|
2984
|
+
if (res.ok) return true
|
|
2985
|
+
last = `HTTP ${res.status}`
|
|
2986
|
+
} catch (error) {
|
|
2987
|
+
last = error instanceof Error ? error.message : String(error)
|
|
2988
|
+
}
|
|
2989
|
+
jobLog(job, ` ⏳ 健康检查 ${url} … ${last}`)
|
|
2990
|
+
await new Promise((r) => setTimeout(r, 2000))
|
|
2991
|
+
}
|
|
2992
|
+
jobLog(job, ` ✘ 健康检查超时:${url}(最后状态 ${last})`)
|
|
2993
|
+
return false
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
/** 组件控制:启动。 */
|
|
2997
|
+
async function compStart(id) {
|
|
2998
|
+
const record = compFind(id)
|
|
2999
|
+
if (!record) throw new Error('组件不存在')
|
|
3000
|
+
if (record.pid && processAlive(record.pid)) return { started: false, note: '已在运行' }
|
|
3001
|
+
if (!record.file || !Array.isArray(record.args)) throw new Error('该组件没有记录启动命令')
|
|
3002
|
+
const child = spawn(record.file, record.args, { detached: true, stdio: 'ignore', windowsHide: true, cwd: record.cwd ?? homedir(), shell: false })
|
|
3003
|
+
child.unref()
|
|
3004
|
+
compUpsert({ id, pid: child.pid })
|
|
3005
|
+
return { started: true, pid: child.pid }
|
|
3006
|
+
}
|
|
3007
|
+
|
|
3008
|
+
/** 组件控制:停止。 */
|
|
3009
|
+
async function compStop(id) {
|
|
3010
|
+
const record = compFind(id)
|
|
3011
|
+
if (!record) throw new Error('组件不存在')
|
|
3012
|
+
if (record.pid) {
|
|
3013
|
+
try {
|
|
3014
|
+
await execFileAsync('taskkill.exe', ['/PID', String(record.pid), '/F'], { timeout: 30000, windowsHide: true })
|
|
3015
|
+
} catch {}
|
|
3016
|
+
}
|
|
3017
|
+
compUpsert({ id, pid: null })
|
|
3018
|
+
return { stopped: true }
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
/** 组件控制:状态(进程存活 + 健康探测;无 pid 但健康通过按运行中处理)。 */
|
|
3022
|
+
async function compStatus(id) {
|
|
3023
|
+
const record = compFind(id)
|
|
3024
|
+
if (!record) throw new Error('组件不存在')
|
|
3025
|
+
let healthy = null
|
|
3026
|
+
if (record.healthUrl) {
|
|
3027
|
+
try {
|
|
3028
|
+
const res = await fetch(record.healthUrl, { signal: AbortSignal.timeout(2500) })
|
|
3029
|
+
healthy = res.ok
|
|
3030
|
+
} catch {
|
|
3031
|
+
healthy = false
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
const running = Boolean(record.pid && processAlive(record.pid)) || healthy === true
|
|
3035
|
+
return { id, name: record.name, running, healthy, pid: record.pid ?? null, port: record.port ?? null }
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
function processAlive(pid) {
|
|
3039
|
+
try {
|
|
3040
|
+
process.kill(Number(pid), 0)
|
|
3041
|
+
return true
|
|
3042
|
+
} catch {
|
|
3043
|
+
return false
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
2417
3046
|
async function syncAggregateSubpackageVersions(profileDir, packageName, registries) {
|
|
2418
3047
|
const pkgPath = join(profileDir, 'node_modules', packageName, 'package.json')
|
|
2419
3048
|
if (!existsSync(pkgPath)) return []
|
|
@@ -2797,11 +3426,10 @@ async function handle(ctx, req, res) {
|
|
|
2797
3426
|
try { framework = detectFrameworkUpgrade(ctx) } catch {}
|
|
2798
3427
|
let selfVersion = null
|
|
2799
3428
|
try {
|
|
2800
|
-
const
|
|
2801
|
-
const selfPkg = JSON.parse(readFileSync(selfRequire.resolve('../package.json'), 'utf8'))
|
|
3429
|
+
const selfPkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'))
|
|
2802
3430
|
selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
|
|
2803
3431
|
} catch {}
|
|
2804
|
-
sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, selfVersion })
|
|
3432
|
+
sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, selfVersion, components: findComponents().map((c) => ({ id: c.id, name: c.name, kind: c.kind ?? 'server', pid: c.pid ?? null, port: c.port ?? null, healthUrl: c.healthUrl ?? null, uiUrl: compUiUrl(c) })) })
|
|
2805
3433
|
return
|
|
2806
3434
|
}
|
|
2807
3435
|
|
|
@@ -2930,8 +3558,22 @@ async function handle(ctx, req, res) {
|
|
|
2930
3558
|
}
|
|
2931
3559
|
|
|
2932
3560
|
if (method === 'GET' && pathname === `${ROUTE_PREFIX}/skills-installed`) {
|
|
2933
|
-
// 已安装技能清单(~/.dsh/skills
|
|
2934
|
-
|
|
3561
|
+
// 已安装技能清单(~/.dsh/skills 用户根)+ 插件自带技能(ctx.skills 聚合,只读展示)
|
|
3562
|
+
const skills = listInstalledSkills()
|
|
3563
|
+
let pluginSkills = []
|
|
3564
|
+
try {
|
|
3565
|
+
const skillsSvc = ctx.get('skills')
|
|
3566
|
+
const extra = await Promise.race([
|
|
3567
|
+
(async () => (typeof skillsSvc?.list === 'function' ? await skillsSvc.list({}) : []))(),
|
|
3568
|
+
new Promise((resolve) => setTimeout(() => resolve([]), 1500)),
|
|
3569
|
+
])
|
|
3570
|
+
for (const s of extra ?? []) {
|
|
3571
|
+
if (s && typeof s.name === 'string' && !skills.some((x) => x.name === s.name)) {
|
|
3572
|
+
pluginSkills.push({ name: s.name, description: s.description ?? null, provider: s.provider ?? null, system: true })
|
|
3573
|
+
}
|
|
3574
|
+
}
|
|
3575
|
+
} catch {}
|
|
3576
|
+
sendJson(res, 200, { ok: true, skills, pluginSkills })
|
|
2935
3577
|
return
|
|
2936
3578
|
}
|
|
2937
3579
|
|
|
@@ -3476,6 +4118,7 @@ async function handle(ctx, req, res) {
|
|
|
3476
4118
|
}
|
|
3477
4119
|
let latest = null
|
|
3478
4120
|
let next = null
|
|
4121
|
+
let beta = null
|
|
3479
4122
|
let depsOutdated = []
|
|
3480
4123
|
let error = null
|
|
3481
4124
|
let source = 'npm'
|
|
@@ -3486,6 +4129,7 @@ async function handle(ctx, req, res) {
|
|
|
3486
4129
|
const data = await fetchJsonUrl(`https://registry.npmmirror.com/${encoded}`)
|
|
3487
4130
|
latest = data?.['dist-tags']?.latest ?? null
|
|
3488
4131
|
next = data?.['dist-tags']?.next ?? null
|
|
4132
|
+
beta = data?.['dist-tags']?.beta ?? null
|
|
3489
4133
|
if (latest === null) throw new Error(`registry 无 dist-tags.latest(${packageName})`)
|
|
3490
4134
|
// 子包配套检查:最新版声明依赖 vs 本地实际版本
|
|
3491
4135
|
const patchPath = findPatchPath(ctx)
|
|
@@ -3563,7 +4207,7 @@ async function handle(ctx, req, res) {
|
|
|
3563
4207
|
error = `npm registry 查询失败且 GitHub 回退不可用(${fallbackError};${fbErr instanceof Error ? fbErr.message : String(fbErr)})`
|
|
3564
4208
|
}
|
|
3565
4209
|
}
|
|
3566
|
-
sendJson(res, 200, { ok: true, packageName, latest, next, depsOutdated, error, source })
|
|
4210
|
+
sendJson(res, 200, { ok: true, packageName, latest, next, beta, depsOutdated, error, source })
|
|
3567
4211
|
return
|
|
3568
4212
|
}
|
|
3569
4213
|
|
|
@@ -3982,6 +4626,49 @@ async function handle(ctx, req, res) {
|
|
|
3982
4626
|
return
|
|
3983
4627
|
}
|
|
3984
4628
|
|
|
4629
|
+
if (pathname === `${ROUTE_PREFIX}/clean-residuals`) {
|
|
4630
|
+
// 清理残余备份/旧子包:删除 .old-* 备份目录,以及聚合包未声明的 @linxin666 旧子包
|
|
4631
|
+
const profileDir = dirname(findPatchPath(ctx))
|
|
4632
|
+
const nodeModules = join(profileDir, 'node_modules')
|
|
4633
|
+
const removed = []
|
|
4634
|
+
// 1) 删除 .old-* 残余备份(顶层 + 作用域目录)
|
|
4635
|
+
const scanDirs = [nodeModules]
|
|
4636
|
+
try { if (existsSync(join(nodeModules, '@linxin666'))) scanDirs.push(join(nodeModules, '@linxin666')) } catch {}
|
|
4637
|
+
for (const dir of scanDirs) {
|
|
4638
|
+
try {
|
|
4639
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
4640
|
+
if (!entry.isDirectory()) continue
|
|
4641
|
+
if (!/\.old-/u.test(entry.name)) continue
|
|
4642
|
+
const target = join(dir, entry.name)
|
|
4643
|
+
rmSync(target, { recursive: true, force: true })
|
|
4644
|
+
removed.push(entry.name)
|
|
4645
|
+
}
|
|
4646
|
+
} catch {}
|
|
4647
|
+
}
|
|
4648
|
+
// 2) 删除聚合包未声明的 @linxin666 旧子包(以 dsh-web-ui-all 的 dependencies 为准)
|
|
4649
|
+
try {
|
|
4650
|
+
const allPkgPath = join(nodeModules, '@linxin666', 'dsh-web-ui-all', 'package.json')
|
|
4651
|
+
if (existsSync(allPkgPath)) {
|
|
4652
|
+
const allPkg = JSON.parse(readFileSync(allPkgPath, 'utf8'))
|
|
4653
|
+
const declared = new Set(Object.keys(allPkg.dependencies ?? {}).filter((n) => n.startsWith('@linxin666/')))
|
|
4654
|
+
declared.add('@linxin666/dsh-web-ui-all')
|
|
4655
|
+
const scoped = join(nodeModules, '@linxin666')
|
|
4656
|
+
for (const entry of readdirSync(scoped, { withFileTypes: true })) {
|
|
4657
|
+
if (!entry.isDirectory()) continue
|
|
4658
|
+
const full = '@linxin666/' + entry.name
|
|
4659
|
+
if (declared.has(full)) continue
|
|
4660
|
+
const pkgJson = join(scoped, entry.name, 'package.json')
|
|
4661
|
+
if (existsSync(pkgJson)) {
|
|
4662
|
+
rmSync(join(scoped, entry.name), { recursive: true, force: true })
|
|
4663
|
+
removed.push(full)
|
|
4664
|
+
}
|
|
4665
|
+
}
|
|
4666
|
+
}
|
|
4667
|
+
} catch {}
|
|
4668
|
+
sendJson(res, 200, { ok: true, removed, count: removed.length })
|
|
4669
|
+
return
|
|
4670
|
+
}
|
|
4671
|
+
|
|
3985
4672
|
if (pathname === `${ROUTE_PREFIX}/self-update`) {
|
|
3986
4673
|
// Hub 自身一键更新:下载 npm 最新 tarball 到当前 profile,成功后由前端重启生效
|
|
3987
4674
|
let selfVersion = null
|
|
@@ -4161,6 +4848,141 @@ async function handle(ctx, req, res) {
|
|
|
4161
4848
|
return
|
|
4162
4849
|
}
|
|
4163
4850
|
|
|
4851
|
+
if (pathname === `${ROUTE_PREFIX}/ai-empower/plan`) {
|
|
4852
|
+
const source = typeof body.source === 'string' ? body.source.trim() : ''
|
|
4853
|
+
if (source === '' || source.length > 200) {
|
|
4854
|
+
sendError(res, 400, '请提供要部署的组件来源(npm 包名或 GitHub 仓库)')
|
|
4855
|
+
return
|
|
4856
|
+
}
|
|
4857
|
+
const patchPath = findPatchPath(ctx)
|
|
4858
|
+
const profileDir = dirname(patchPath)
|
|
4859
|
+
const jobId = `ai-${Date.now()}-${++aiJobSeq}`
|
|
4860
|
+
const job = { id: jobId, source, status: 'running', stage: 'planning', createdAt: Date.now(), logText: '', stepStates: [] }
|
|
4861
|
+
aiJobs.set(jobId, job)
|
|
4862
|
+
const builtin = builtinPlanFor(source)
|
|
4863
|
+
if (builtin !== null) {
|
|
4864
|
+
job.plan = builtin
|
|
4865
|
+
job.status = 'plan-ready'
|
|
4866
|
+
job.stage = 'builtin'
|
|
4867
|
+
job.finishedAt = Date.now()
|
|
4868
|
+
sendJson(res, 200, { ok: true, jobId })
|
|
4869
|
+
return
|
|
4870
|
+
}
|
|
4871
|
+
aiEmpowerPlan(job, ctx, profileDir).catch((error) => {
|
|
4872
|
+
job.status = 'failed'
|
|
4873
|
+
job.error = `规划任务异常:${error instanceof Error ? error.message : String(error)}`
|
|
4874
|
+
job.finishedAt = Date.now()
|
|
4875
|
+
})
|
|
4876
|
+
sendJson(res, 200, { ok: true, jobId })
|
|
4877
|
+
return
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4880
|
+
if (pathname === `${ROUTE_PREFIX}/ai-empower/status`) {
|
|
4881
|
+
const jobId = typeof body.jobId === 'string' ? body.jobId : ''
|
|
4882
|
+
const job = aiJobs.get(jobId)
|
|
4883
|
+
if (!job) {
|
|
4884
|
+
sendError(res, 404, '没有这个 AI 赋能任务')
|
|
4885
|
+
return
|
|
4886
|
+
}
|
|
4887
|
+
sendJson(res, 200, { ok: true, ...aiJobView(job) })
|
|
4888
|
+
return
|
|
4889
|
+
}
|
|
4890
|
+
if (pathname === `${ROUTE_PREFIX}/ai-empower/run`) {
|
|
4891
|
+
const jobId = typeof body.jobId === 'string' ? body.jobId : ''
|
|
4892
|
+
const selected = Array.isArray(body.steps) ? body.steps : null
|
|
4893
|
+
const job = aiJobs.get(jobId)
|
|
4894
|
+
if (!job) {
|
|
4895
|
+
sendError(res, 404, '没有这个 AI 赋能任务')
|
|
4896
|
+
return
|
|
4897
|
+
}
|
|
4898
|
+
if (job.status !== 'plan-ready') {
|
|
4899
|
+
sendError(res, 400, '任务状态不是 plan-ready,无法执行')
|
|
4900
|
+
return
|
|
4901
|
+
}
|
|
4902
|
+
const patchPath = findPatchPath(ctx)
|
|
4903
|
+
const profileDir = dirname(patchPath)
|
|
4904
|
+
aiEmpowerExecute(job, ctx, profileDir, selected).catch((error) => {
|
|
4905
|
+
job.status = 'failed'
|
|
4906
|
+
job.error = `执行任务异常:${error instanceof Error ? error.message : String(error)}`
|
|
4907
|
+
job.finishedAt = Date.now()
|
|
4908
|
+
})
|
|
4909
|
+
sendJson(res, 200, { ok: true, jobId })
|
|
4910
|
+
return
|
|
4911
|
+
}
|
|
4912
|
+
|
|
4913
|
+
if (pathname === `${ROUTE_PREFIX}/ai-empower/cancel`) {
|
|
4914
|
+
const jobId = typeof body.jobId === 'string' ? body.jobId : ''
|
|
4915
|
+
const job = aiJobs.get(jobId)
|
|
4916
|
+
if (!job) {
|
|
4917
|
+
sendError(res, 404, '没有这个 AI 赋能任务')
|
|
4918
|
+
return
|
|
4919
|
+
}
|
|
4920
|
+
try { job.abort?.abort() } catch {}
|
|
4921
|
+
if (job.status === 'running' && job.stage === 'executing') {
|
|
4922
|
+
job.status = 'failed'
|
|
4923
|
+
job.error = '已取消执行(用户中断)'
|
|
4924
|
+
job.finishedAt = Date.now()
|
|
4925
|
+
}
|
|
4926
|
+
sendJson(res, 200, { ok: true, jobId })
|
|
4927
|
+
return
|
|
4928
|
+
}
|
|
4929
|
+
|
|
4930
|
+
if (pathname === `${ROUTE_PREFIX}/components`) {
|
|
4931
|
+
const list = findComponents()
|
|
4932
|
+
const enriched = []
|
|
4933
|
+
for (const c of list) {
|
|
4934
|
+
try {
|
|
4935
|
+
enriched.push(await compStatus(c.id))
|
|
4936
|
+
} catch {
|
|
4937
|
+
enriched.push({ id: c.id, name: c.name, running: false, healthy: null, pid: null, port: c.port ?? null })
|
|
4938
|
+
}
|
|
4939
|
+
}
|
|
4940
|
+
sendJson(res, 200, { ok: true, components: enriched.map((x) => ({ ...x, uiUrl: compUiUrl(compFind(x.id) ?? {}) })) })
|
|
4941
|
+
return
|
|
4942
|
+
}
|
|
4943
|
+
|
|
4944
|
+
if (pathname === `${ROUTE_PREFIX}/component/start`) {
|
|
4945
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
4946
|
+
if (id === '') {
|
|
4947
|
+
sendError(res, 400, '缺少组件 id')
|
|
4948
|
+
return
|
|
4949
|
+
}
|
|
4950
|
+
try {
|
|
4951
|
+
sendJson(res, 200, { ok: true, ...(await compStart(id)) })
|
|
4952
|
+
} catch (error) {
|
|
4953
|
+
sendError(res, 500, error instanceof Error ? error.message : String(error))
|
|
4954
|
+
}
|
|
4955
|
+
return
|
|
4956
|
+
}
|
|
4957
|
+
|
|
4958
|
+
if (pathname === `${ROUTE_PREFIX}/component/stop`) {
|
|
4959
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
4960
|
+
if (id === '') {
|
|
4961
|
+
sendError(res, 400, '缺少组件 id')
|
|
4962
|
+
return
|
|
4963
|
+
}
|
|
4964
|
+
try {
|
|
4965
|
+
sendJson(res, 200, { ok: true, ...(await compStop(id)) })
|
|
4966
|
+
} catch (error) {
|
|
4967
|
+
sendError(res, 500, error instanceof Error ? error.message : String(error))
|
|
4968
|
+
}
|
|
4969
|
+
return
|
|
4970
|
+
}
|
|
4971
|
+
|
|
4972
|
+
if (pathname === `${ROUTE_PREFIX}/component/status`) {
|
|
4973
|
+
const id = typeof body.id === 'string' ? body.id : ''
|
|
4974
|
+
if (id === '') {
|
|
4975
|
+
sendError(res, 400, '缺少组件 id')
|
|
4976
|
+
return
|
|
4977
|
+
}
|
|
4978
|
+
try {
|
|
4979
|
+
sendJson(res, 200, { ok: true, ...(await compStatus(id)) })
|
|
4980
|
+
} catch (error) {
|
|
4981
|
+
sendError(res, 500, error instanceof Error ? error.message : String(error))
|
|
4982
|
+
}
|
|
4983
|
+
return
|
|
4984
|
+
}
|
|
4985
|
+
|
|
4164
4986
|
if (pathname === `${ROUTE_PREFIX}/restart`) {
|
|
4165
4987
|
// 自带守护的自杀式重启:分离脚本杀掉本进程后,若端口无人监听则自动拉起服务。
|
|
4166
4988
|
// 不再依赖桌面端监督器(它并不总是会重启服务,曾导致用户需要重启电脑)。
|