@noob-stupid/dsh-plugin-console 0.3.21 → 0.3.24

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/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 selfRequire = createRequire(import.meta.url)
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
- sendJson(res, 200, { ok: true, skills: listInstalledSkills() })
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
 
@@ -4204,6 +4848,141 @@ async function handle(ctx, req, res) {
4204
4848
  return
4205
4849
  }
4206
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
+
4207
4986
  if (pathname === `${ROUTE_PREFIX}/restart`) {
4208
4987
  // 自带守护的自杀式重启:分离脚本杀掉本进程后,若端口无人监听则自动拉起服务。
4209
4988
  // 不再依赖桌面端监督器(它并不总是会重启服务,曾导致用户需要重启电脑)。