@yottameta/yotta-skills-plugin 0.29.7 → 0.29.8

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.
@@ -12,7 +12,7 @@
12
12
  "authentication": "ON_INSTALL"
13
13
  },
14
14
  "category": "Developer Tools",
15
- "version": "0.29.7",
15
+ "version": "0.29.8",
16
16
  "description": "YuanGe (元阁) - orchestration routing, inventory, and one-command installer for the YottaMeta skill family (zero dependencies, Node.js only).",
17
17
  "interface": {
18
18
  "displayName": "元阁 yotta-skills",
@@ -12,7 +12,7 @@
12
12
  "authentication": "ON_INSTALL"
13
13
  },
14
14
  "category": "Developer Tools",
15
- "version": "0.29.7",
15
+ "version": "0.29.8",
16
16
  "description": "YuanGe (元阁) - orchestration routing, inventory, and one-command installer for the YottaMeta skill family (zero dependencies, Node.js only).",
17
17
  "interface": {
18
18
  "displayName": "元阁 yotta-skills",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yottameta/yotta-skills-plugin",
3
- "version": "0.29.7",
3
+ "version": "0.29.8",
4
4
  "description": "YuanGe (元阁) — orchestration and routing for the YottaMeta skill family, packaged as a YottaMeta Agent Plugin and DeepSeek Harness profile bundle.",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "yotta-skills",
4
- "version": "0.29.7",
4
+ "version": "0.29.8",
5
5
  "description": "YuanGe (元阁) - orchestration routing, inventory, and one-command installer for the YottaMeta skill family (zero dependencies, Node.js only).",
6
6
  "author": {
7
7
  "name": "YottaMeta",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: yotta-skills
3
- version: 0.29.7
3
+ version: 0.29.8
4
4
  description: 元阁 -- 元阁全家技能的总编排策划 + 编排路由 + 一键安装器 + 技能盘点 + 本机技能 Hub + 运行时 hook 适配。Hub 层:hub hosts 只读发现本机已装智能体与技能目录(文件系统优先,不读元忆;已核实 / 自动发现 / 桥接分类 + 状态细分:可用 / 残留(实体未确认)/ 未创建 / 仅标记);hub hosts add/remove/list/mark/exclude/include/set 注册自定义宿主目录、手动标记、不接管开关与目录覆盖(只注册不建目录、移除不删目录;残留清理 hub hosts remove --purge 默认预览、目录入回收站 7 天;YottaCode 不纳入接管);install-self / where 把元阁管理引擎独立安装(默认 ~/.yottaskills/yotta-skills,--dir 指定)并查看运行位置;hub config get/set/clear/rollback 持久化 Hub 位置(config.json;优先级 --hub > YOTTA_SKILLS_HUB > config > 默认;--move 迁移旧 Hub + 重链 + 回收站;lastMigration 记录 + 一键回滚 = 位置回退;仅切换且旧 Hub 非空时记录未迁移供面板一键迁移);hub cleanup-backups 清理历史备份 / 暂存残留(回收站 7 天);hub install / update 把技能装到 ~/.yottaskills/hub 单点真源(清单 27 + 特殊家族 5,特殊家族跟随各自 npm latest;update / hub update 只升不降,--force 才显式降级);hub link --all 用 Windows junction / POSIX symlink 分发到全部已核实宿主(默认范围;--include-discovered 显式纳入自动发现目录;锁 / 数据桥接目录永不链接),元技能在链接时收敛宿主旧副本(移入 ~/.yottaskills/trash 保留 7 天、版本闸门 Hub ≥ 宿主);hub unlink 只删链接、fail-closed;非元技能不参与更新(用户自行处理);hub status 显示来源、版本、链接与异常;view 启动本机技能枢纽面板(默认 127.0.0.1:8789,六视图 + 高级 CLI 页,写操作需页面令牌与确认);兼容 agentskills.io 技能格式、Vercel Labs skills CLI 的 .agents/skills 通用目录与 .skill-lock.json v3(只读)。路由层:--route / route_request 按需求摘要给出候选组合、调用顺序、角色、置信度、依据、已装/缺失状态与安装命令,只建议不自动安装;非元阁家族已装技能按 frontmatter description 机械匹配作并列候选(标注来源与未扫描状态,只读不自动调用);M1 记忆裁决层:usage enable/mark 本地结构化记录 + decide-memory / MCP decide_memory 输出 promote / hold / demote 只读建议(需授权 provider;只建议不删除、不自动写元忆);策划层:按场景给出「该组合哪几个元技能、组合强在哪、怎么组合使用(安装与调用均由用户确认后执行)」;安装层:一条命令把 YottaMeta 已发布的全部 yotta-* 技能装进指定智能体或目录(默认 --pin 锁死清单精确版本);盘点层:--inventory / --reindex 扫描本机已装技能生成/更新注册表,新装技能自动被发现(install/update 后自动 re-index,会话开工只建议跑本地 --reindex;更新检查走手动 --check 或后台 --check --scheduled);运行时适配层:hook capabilities / evaluate / bind / unbind 按宿主能力矩阵执行六个统一事件并留证降级,元信 before_install 已接入安装管线;自包含零依赖,不依赖任何元技能;MCP 按需加载且需用户确认后写入配置(可选:list_installed_skills/describe_skill/reindex/route_request/decide_memory,不常驻,未加载降级 CLI)。支持 --list 清单 / --route 路由 / usage 使用记录 / decide-memory 记忆裁决 / hub 技能 Hub / install-self 独立安装 / where 位置查看 / install / update / update --check(只读检查)/ update --check --scheduled(后台周检)/ update --auto(家族自动更新)/ hook 适配 / --inventory / --reindex / --dry-run 预览 / --pin(默认)/ --range。触发:需要批量安装或更新元阁全家技能、单点安装并分发到多个智能体、查看本机装了哪些智能体与技能目录、接管非元技能、按场景组合多个元技能、路由或判断该用哪些技能、判断哪些技能值得长期记忆、查看或记录技能使用信号、盘点或查看本机已装技能、重扫技能注册表、给某个智能体或目录一次性铺齐 yotta-* 技能、评估宿主 hook 能力、预览安装清单、锁版本安装、或用户说 元阁/装全家/一次装齐/yotta-skills/install-all/更新全家/检查更新/自动更新/hook 适配/该用哪个技能/路由技能/记忆裁决/技能该不该记住/盘点技能/查看已装技能/技能 Hub/单点安装/链接分发/接管技能/技能枢纽面板/图形化面板/独立安装元阁/查看元阁位置/持久化 Hub 位置/换 Hub 目录/迁移 Hub/迁移回滚/回滚 Hub 位置/清理备份残留/自定义宿主/残留清理/宿主状态 等。边界(Do NOT trigger):只做「组合策划 + 静态路由建议 + M1 记忆裁决只读建议 + 清单 + 下载 + 落位 + 汇总 + 盘点 + re-index + Hub 本机真源与链接分发 + hook 适配」,不含技能本体、不做技能内容开发、不 -g 污染全局、不自动安装缺失技能、不静默写宿主配置或全局记忆、不自动删除技能或记忆;家族安装先自举或调用元信装前门禁,DO NOT INSTALL 阻断,非元阁家族包不自动安装。
5
5
  license: MIT
6
6
  metadata:
@@ -1190,11 +1190,15 @@
1190
1190
  }
1191
1191
  function renderCliHelp(d) {
1192
1192
  if (!d || d.error) { $('#cliMeta').textContent = '帮助加载失败'; return; }
1193
- var total = 0;
1193
+ var total = 0, optTotal = (d.options || []).length;
1194
1194
  (d.groups || []).forEach(function (g) {
1195
- (g.commands || []).forEach(function (c) { total += 1 + (c.subcommands || []).length; });
1195
+ (g.commands || []).forEach(function (c) {
1196
+ total += 1 + (c.subcommands || []).length;
1197
+ optTotal += (c.options || []).length;
1198
+ (c.subcommands || []).forEach(function (s) { optTotal += (s.options || []).length; });
1199
+ });
1196
1200
  });
1197
- $('#cliMeta').textContent = 'v' + (d.version || '-') + ' · 命令 ' + total + ' · 选项 ' + (d.options || []).length;
1201
+ $('#cliMeta').textContent = 'v' + (d.version || '-') + ' · 命令 ' + total + ' · 选项 ' + optTotal;
1198
1202
  $('#cliQuick').innerHTML = (d.quick || []).map(function (g) {
1199
1203
  return '<div class="cmd-group"><h3>' + esc(g.group) + '</h3>' + (g.items || []).map(function (item) {
1200
1204
  var meta = findHelpEntry(d.groups, item.cmd) || {};
@@ -1210,7 +1214,10 @@
1210
1214
  ? '<div class="note">常用选项: ' + c.options.map(function (f) { return '<code>' + esc(f) + '</code>'; }).join(' ') + '</div>'
1211
1215
  : '';
1212
1216
  var subs = (c.subcommands || []).map(function (s) {
1213
- return '<div class="cmd"><code>' + esc('yotta-skills ' + s.usage) + '</code><span class="why">' + esc(s.what || '') + '</span><button class="btn small" data-copy-cmd="' + esc('yotta-skills ' + s.usage) + '">复制</button></div>';
1217
+ var sOpts = (s.options || []).length
1218
+ ? '<div class="note">选项: ' + s.options.map(function (f) { return '<code>' + esc(f) + '</code>'; }).join(' ') + '</div>'
1219
+ : '';
1220
+ return '<div class="cmd"><code>' + esc('yotta-skills ' + s.usage) + '</code><span class="why">' + esc(s.what || '') + '</span>' + sOpts + '<button class="btn small" data-copy-cmd="' + esc('yotta-skills ' + s.usage) + '">复制</button></div>';
1214
1221
  }).join('');
1215
1222
  return '<details class="cmd-detail"><summary><code>' + esc('yotta-skills ' + c.usage) + '</code> <span class="why">' + esc(c.what || '') + '</span></summary>' +
1216
1223
  (c.when ? '<div class="note">' + esc(c.when) + '</div>' : '') + opts + subs + '</details>';
@@ -62,11 +62,11 @@ const { COPY_SKIP, copyDir } = require('../lib/copy-tree');
62
62
  const { versionRelation } = require('../lib/skills-scan');
63
63
 
64
64
  const PKG_ROOT = path.join(__dirname, '..');
65
- let VERSION = '0.29.7';
65
+ let VERSION = '0.29.8';
66
66
  try { VERSION = require(path.join(PKG_ROOT, 'package.json')).version; } catch (_) { /* keep fallback */ }
67
67
 
68
68
  // @generated view-html:start
69
- const VIEW_HTML = "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow\">\n<meta name=\"yotta-view-token\" content=\"__YOTTA_VIEW_TOKEN__\">\n<link rel=\"icon\" href=\"data:,\">\n<title>元阁 · 技能枢纽</title>\n<style>\n :root{--bg:#0d1114;--bg2:#12171b;--panel:#171d22;--panel2:#1d242a;--line:rgba(148,190,170,.14);--line2:rgba(148,190,170,.28);\n --text:#e9efe9;--muted:#9aa8a0;--faint:#6d7b74;--acc:#5fbf8f;--acc-ink:#0d1a13;--warn:#e0b263;--danger:#e07a7a;--info:#8fb4de;\n --shadow:0 10px 28px rgba(0,0,0,.35)}\n body.light{--bg:#f2f5f1;--bg2:#e9efea;--panel:#ffffff;--panel2:#f6f9f5;--line:rgba(24,58,44,.12);--line2:rgba(24,58,44,.24);\n --text:#17211c;--muted:#5c6b63;--faint:#86958c;--acc:#2f7d59;--acc-ink:#ffffff;--warn:#9a6a14;--danger:#b23c3c;--info:#3c6ea5;\n --shadow:0 8px 22px rgba(24,58,44,.08)}\n *{box-sizing:border-box}[hidden]{display:none!important}\n html,body{margin:0;padding:0}\n body{background:var(--bg);color:var(--text);font:14px/1.6 \"Microsoft YaHei\",\"PingFang SC\",\"Noto Sans CJK SC\",system-ui,sans-serif;letter-spacing:0}\n button{font:inherit;color:inherit;cursor:pointer}\n input,textarea{font:inherit;color:var(--text);background:var(--panel2);border:1px solid var(--line2);border-radius:6px;padding:8px 11px;outline:0;width:100%}\n input:focus,textarea:focus{border-color:var(--acc)}\n textarea{min-height:76px;resize:vertical}\n code,kbd{font-family:ui-monospace,Consolas,\"Courier New\",monospace;font-size:12px}\n .sv{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}\n\n .shell{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh}\n .sidebar{background:var(--bg2);border-right:1px solid var(--line);padding:20px 14px;display:flex;flex-direction:column;gap:20px;position:sticky;top:0;height:100vh;min-width:0}\n .brand{display:flex;align-items:center;gap:10px;padding:0 6px}\n .brand-mark{width:34px;height:34px;border-radius:8px;background:rgba(95,191,143,.14);border:1px solid var(--line2);display:grid;place-items:center;color:var(--acc)}\n .brand-name{font-size:15px;font-weight:600}\n .brand-sub{font-size:11px;color:var(--faint)}\n .nav{display:flex;flex-direction:column;gap:3px;min-width:0}\n .nav button{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--muted);text-align:left}\n .nav button:hover{background:var(--panel);color:var(--text)}\n .nav button.active{background:var(--panel);color:var(--acc);border-color:var(--line)}\n .side-foot{margin-top:auto;display:flex;flex-direction:column;gap:8px;min-width:0}\n .privacy{display:flex;gap:8px;align-items:flex-start;padding:10px;border:1px solid var(--line);border-radius:8px;color:var(--muted);font-size:12px;background:var(--panel)}\n .privacy .sv{color:var(--acc);margin-top:2px}\n .ghost-btn{display:flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:6px;padding:8px}\n .ghost-btn:hover{color:var(--text);border-color:var(--line2)}\n .verline{padding:0 6px;color:var(--faint);font-size:11px}\n\n .main{min-width:0;padding:24px 28px 70px}\n .topbar{display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}\n .loadbar{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:13px;padding:10px 13px;border:1px dashed var(--line2);border-radius:8px;margin:0 0 14px;background:var(--panel)}\n .spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--line2);border-top-color:var(--acc);animation:spin .8s linear infinite;flex:none}\n @keyframes spin{to{transform:rotate(360deg)}}\n .page-title{font-size:19px;font-weight:600;margin:0}\n .page-sub{color:var(--faint);font-size:12px;margin-top:2px}\n .spacer{flex:1}\n .chip{display:flex;align-items:center;gap:7px;padding:5px 10px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:12px;white-space:nowrap}\n .chips{display:flex;gap:6px;flex-wrap:wrap}\n .chip{cursor:pointer;background:transparent}\n .chip.active{color:var(--acc);border-color:rgba(95,191,143,.45);background:rgba(95,191,143,.10)}\n .dot{width:7px;height:7px;border-radius:50%;background:var(--acc)}\n\n .view{display:none}.view.active{display:block}\n .grid{display:grid;gap:12px}.g4{grid-template-columns:repeat(4,minmax(0,1fr))}.g2{grid-template-columns:repeat(2,minmax(0,1fr))}\n .panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:15px;min-width:0}\n .panel h3{margin:0 0 4px;font-size:14px}\n .section{margin-top:16px}\n .shead{display:flex;align-items:baseline;gap:10px;margin-bottom:9px;flex-wrap:wrap}\n .shead h2{font-size:14px;margin:0}\n .label{color:var(--muted);font-size:12px}\n .value{font-size:25px;font-weight:600;margin:7px 0 2px}\n .delta{font-size:12px;color:var(--muted)}\n .mono{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all}\n\n .toolbar{display:flex;gap:9px;align-items:center;flex-wrap:wrap}\n .search{display:flex;align-items:center;gap:8px;min-width:230px;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:7px 11px;color:var(--muted)}\n .search input{background:transparent;border:0;padding:0;width:100%}\n .btn{display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:6px;border:1px solid var(--line2);background:transparent;color:var(--text);white-space:nowrap}\n .btn:hover{border-color:var(--acc)}\n .btn.primary{background:var(--acc);border-color:var(--acc);color:var(--acc-ink);font-weight:600}\n .btn.danger{border-color:rgba(224,122,122,.5);color:var(--danger)}\n .btn.small{padding:4px 9px;font-size:12px}\n .btn[disabled]{opacity:.45;cursor:not-allowed}\n .ibtn{width:29px;height:29px;display:grid;place-items:center;border:1px solid var(--line);background:transparent;border-radius:6px;color:var(--muted)}\n .ibtn:hover{color:var(--text);border-color:var(--line2)}\n\n table{width:100%;border-collapse:collapse}\n th,td{text-align:left;padding:10px 9px;border-bottom:1px solid var(--line);font-size:13px;vertical-align:top}\n th{color:var(--faint);font-weight:500;font-size:12px}\n tbody tr:hover{background:var(--panel2)}\n .cell-main{font-weight:600}\n .cell-sub{color:var(--faint);font-size:11px;word-break:break-all}\n\n .list{display:flex;flex-direction:column;gap:7px;margin-top:12px}\n .row{display:flex;align-items:center;gap:11px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel2);min-width:0}\n .row-main{flex:1;min-width:0}\n .row-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .row-meta{color:var(--faint);font-size:11px;margin-top:2px;word-break:break-all}\n .row-actions{display:flex;gap:6px;flex-wrap:wrap}\n .empty{color:var(--faint);text-align:center;padding:22px;border:1px dashed var(--line);border-radius:8px;margin-top:12px}\n\n .badge{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--line2);color:var(--muted);background:transparent;white-space:nowrap}\n .badge.ok{color:var(--acc);border-color:rgba(95,191,143,.45);background:rgba(95,191,143,.10)}\n .badge.warn{color:var(--warn);border-color:rgba(224,178,99,.45);background:rgba(224,178,99,.10)}\n .badge.danger{color:var(--danger);border-color:rgba(224,122,122,.45);background:rgba(224,122,122,.10)}\n .badge.info{color:var(--info);border-color:rgba(143,180,222,.45);background:rgba(143,180,222,.10)}\n .dot-mark{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:var(--faint)}\n .dot-mark.ok{background:var(--acc)}.dot-mark.warn{background:var(--warn)}.dot-mark.danger{background:var(--danger)}\n\n .banner{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border:1px solid var(--line2);border-radius:8px;background:var(--panel2)}\n .banner.ok{border-color:rgba(95,191,143,.45)}\n .banner.danger{border-color:rgba(224,122,122,.45)}\n .banner .sv{margin-top:2px;color:var(--acc)}\n .banner.danger .sv{color:var(--danger)}\n .banner-title{font-weight:600}\n .banner-body{color:var(--muted);font-size:12px;margin-top:2px}\n\n .kv{display:grid;grid-template-columns:96px 1fr;gap:5px 10px;font-size:13px;margin:10px 0}\n .kv dt{color:var(--faint)}.kv dd{margin:0;word-break:break-all}\n .variants{border-top:1px dashed var(--line);margin-top:8px;padding-top:8px;display:flex;flex-direction:column;gap:5px}\n .variant{display:flex;gap:9px;align-items:baseline;font-size:12px;color:var(--muted);flex-wrap:wrap}\n .check{width:16px;height:16px;flex:none;accent-color:var(--acc)}\n details summary{cursor:pointer;color:var(--muted);font-size:12px}\n\n .actionbar{position:sticky;bottom:0;margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:11px 13px;border:1px solid var(--line2);border-radius:8px;background:var(--bg2);box-shadow:var(--shadow)}\n .result-list{display:flex;flex-direction:column;gap:6px;margin-top:10px}\n .result{display:flex;gap:9px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font-size:12px}\n\n .mask{position:fixed;inset:0;background:rgba(0,0,0,.52);display:none;place-items:center;z-index:50;padding:18px}\n .mask.open{display:grid}\n .modal{width:min(600px,100%);max-height:86vh;overflow:auto;background:var(--panel);border:1px solid var(--line2);border-radius:8px;padding:18px;box-shadow:var(--shadow)}\n .modal h3{margin:0 0 10px;font-size:16px}\n .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}\n\n .toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);display:flex;flex-direction:column;gap:7px;align-items:center;z-index:60}\n .toast{background:var(--panel2);border:1px solid var(--line2);border-radius:999px;padding:8px 15px;box-shadow:var(--shadow);font-size:13px}\n\n .cmd-group{margin-top:14px}\n .cmd{display:flex;gap:10px;align-items:center;padding:9px 11px;border:1px solid var(--line);border-radius:6px;background:var(--panel2);margin-top:6px}\n .cmd code{flex:1;min-width:0;word-break:break-all;color:var(--text)}\n .cmd .why{color:var(--faint);font-size:11px}\n .note{color:var(--muted);font-size:12px;margin-top:8px}\n .cmd-detail{border:1px solid var(--line);border-radius:6px;background:var(--panel2);margin-top:8px;padding:0 11px;min-width:0}\n .cmd-detail summary{cursor:pointer;padding:9px 0;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}\n .cmd-detail .note{padding:0 0 8px}\n .cmd-detail .cmd{margin:0 0 8px}\n\n @media (max-width:980px){.g4{grid-template-columns:repeat(2,minmax(0,1fr))}}\n @media (max-width:760px){\n .shell{grid-template-columns:minmax(0,1fr)}\n .sidebar{position:static;height:auto;padding:13px;gap:11px}\n .nav{flex-direction:row;overflow-x:auto;width:100%;scrollbar-width:none;-webkit-overflow-scrolling:touch}\n .nav::-webkit-scrollbar{display:none}\n .nav button{white-space:nowrap;padding:7px 9px}\n .side-foot{margin-top:0;flex-direction:row;align-items:center}\n .privacy{display:none}\n .ghost-btn{flex:1;white-space:nowrap}\n .verline{white-space:nowrap;text-align:right}\n .main{padding:16px 14px 56px}\n .g4,.g2{grid-template-columns:minmax(0,1fr)}\n .search{min-width:100%}\n table{display:block;overflow-x:auto;white-space:nowrap}\n .row{flex-wrap:wrap}\n }\n</style>\n</head>\n<body>\n<div class=\"shell\">\n <aside class=\"sidebar\">\n <div class=\"brand\">\n <div class=\"brand-mark\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 3 4 7v4c0 4.4 3.4 8.4 8 10 4.6-1.6 8-5.6 8-10V7z\"/><path d=\"M9 12l2 2 4-4\"/></svg></div>\n <div><div class=\"brand-name\">元阁 · 技能枢纽</div><div class=\"brand-sub\">Hub 本地面板</div></div>\n </div>\n <nav class=\"nav\" id=\"nav\">\n <button data-view=\"overview\" class=\"active\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>概览</button>\n <button data-view=\"hosts\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"13\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 7.5h.01M7 16.5h.01\"/></svg>宿主矩阵</button>\n <button data-view=\"adopt\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 3v12\"/><path d=\"m7 10 5 5 5-5\"/><path d=\"M4 21h16\"/></svg>收编向导</button>\n <button data-view=\"links\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M10 13a5 5 0 0 0 7.1 0l2-2a5 5 0 0 0-7.1-7.1L11 4.9\"/><path d=\"M14 11a5 5 0 0 0-7.1 0l-2 2A5 5 0 0 0 12 20.1l1-1\"/></svg>链接与体检</button>\n <button data-view=\"records\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/><path d=\"M12 8v5l3 2\"/></svg>记录与回滚</button>\n <button data-view=\"route\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"6\" cy=\"6\" r=\"2.5\"/><circle cx=\"18\" cy=\"18\" r=\"2.5\"/><path d=\"M8.5 6H15a3 3 0 0 1 0 6h-6a3 3 0 0 0 0 6h6.5\"/></svg>路由与编排</button>\n <button data-view=\"cli\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 7 5 5-5 5\"/><path d=\"M12 19h8\"/></svg>高级 CLI</button>\n </nav>\n <div class=\"side-foot\">\n <div class=\"privacy\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/><path d=\"m9 12 2 2 4-4\"/></svg><span>本机运行 · 零远程<br>写操作需页面令牌与确认</span></div>\n <button class=\"ghost-btn\" id=\"themeBtn\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\"/></svg>切换主题</button>\n <div class=\"verline\" id=\"verLine\"></div>\n </div>\n </aside>\n\n <main class=\"main\">\n <div class=\"topbar\">\n <div><h1 class=\"page-title\" id=\"pageTitle\">概览</h1><div class=\"page-sub\" id=\"pageSub\">Hub 里有什么、健康吗、最近做了什么</div></div>\n <div class=\"spacer\"></div>\n <button class=\"btn\" id=\"refreshBtn\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/></svg>刷新</button>\n <div class=\"chip\"><span class=\"dot\"></span>本机 · 127.0.0.1</div>\n </div>\n <div class=\"loadbar\" id=\"loadbar\" hidden><span class=\"spinner\"></span><span id=\"loadbarText\">正在加载…</span></div>\n\n <section class=\"view active\" id=\"view-overview\">\n <div class=\"grid g4\" id=\"ovCards\"></div>\n <div class=\"section\" id=\"ovDoctor\"></div>\n <div class=\"section grid g2\">\n <div class=\"panel\"><h3>Hub 内容</h3><div class=\"faint\">技能真源在 Hub 目录,宿主目录通过链接分发。</div>\n <label class=\"search section\" style=\"min-width:100%\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"ovSkillFilter\" placeholder=\"过滤技能(输入后显示全部匹配)\"></label>\n <div class=\"list\" id=\"ovSkills\"></div>\n </div>\n <div class=\"panel\"><h3>最近动作</h3><div class=\"faint\">收编 / 链接 / 解除都写入 Hub 审计。</div><div class=\"list\" id=\"ovAudit\"></div></div>\n </div>\n <div class=\"section panel\">\n <h3>Hub 位置</h3>\n <div class=\"mono\" id=\"ovHubDir\">-</div>\n <div class=\"faint\" id=\"ovHubMeta\"></div>\n <div class=\"toolbar section\">\n <button class=\"btn small\" data-goto=\"adopt\">去收编</button>\n <button class=\"btn small\" data-goto=\"links\">去链接与体检</button>\n <button class=\"btn small\" data-goto=\"records\">查看记录</button>\n <button class=\"btn small\" id=\"copyHubDir\">复制路径</button>\n <button class=\"btn small\" id=\"hubConfigBtn\">设置新位置</button>\n <button class=\"btn small\" id=\"hubConfigClearBtn\" hidden>清除覆盖</button>\n </div>\n </div>\n <div class=\"section\" id=\"ovSwitchBanner\" hidden></div>\n <div class=\"section panel\" id=\"ovMigration\" hidden>\n <h3>最近一次迁移</h3>\n <div id=\"ovMigrationBody\"></div>\n <div class=\"toolbar section\">\n <button class=\"btn small danger\" id=\"migrationRollbackBtn\">回滚到原位置</button>\n <button class=\"btn small\" id=\"copyMigrationTrash\">复制回收站路径</button>\n </div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-hosts\">\n <div class=\"toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"hostSearch\" placeholder=\"过滤宿主 / 目录\"></label>\n <div class=\"chips\" id=\"hostStateChips\"></div>\n <button class=\"btn small\" id=\"hostAddBtn\">添加自定义目录</button>\n </div>\n <div class=\"section panel\">\n <div class=\"faint\" id=\"hostSummary\"></div>\n <table><thead><tr><th>宿主</th><th>技能目录</th><th>范围</th><th>目录技能</th><th>指向 Hub</th><th>异常</th><th>状态</th><th></th></tr></thead><tbody id=\"hostRows\"></tbody></table>\n <div class=\"empty\" id=\"hostEmpty\" hidden>没有匹配的宿主目录。</div>\n </div>\n <div class=\"section panel\"><h3>已安装标记(无技能目录的宿主)</h3><div class=\"list\" id=\"hostMarks\"></div></div>\n <div class=\"section panel\" id=\"hostExcludedWrap\" hidden>\n <h3>不接管名单</h3>\n <div class=\"faint\">这些宿主 / 目录在发现、显示、链接三层全部跳过(--include-discovered 也不纳入)。</div>\n <div class=\"list\" id=\"hostExcluded\"></div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-adopt\">\n <div class=\"grid g4\" id=\"adoptCards\"></div>\n <div class=\"section toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"adoptSearch\" placeholder=\"过滤技能 slug / 来源宿主\"></label>\n <button class=\"btn small\" id=\"adoptAll\">全选可收编</button>\n <button class=\"btn small\" id=\"adoptNone\">清空</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"adoptScanInfo\"></span>\n </div>\n <div class=\"list\" id=\"adoptList\"></div>\n <div class=\"empty\" id=\"adoptEmpty\" hidden>没有可收编的候选。先在宿主里安装技能,或刷新宿主矩阵。</div>\n <div class=\"actionbar\">\n <span class=\"label\">已选 <b id=\"adoptCount\">0</b> 项</span>\n <span class=\"spacer\"></span>\n <span class=\"faint\">默认复制保真、原目录保留;冲突项默认跳过;执行时逐项运行元信扫描。</span>\n <button class=\"btn primary\" id=\"adoptApply\">收编所选</button>\n </div>\n <div class=\"section panel\" id=\"adoptResultsWrap\" hidden><h3>收编结果</h3><div class=\"result-list\" id=\"adoptResults\"></div></div>\n </section>\n\n <section class=\"view\" id=\"view-links\">\n <div id=\"linkDoctor\"></div>\n <div class=\"section panel\">\n <h3>分发目标</h3>\n <div class=\"faint\">把 Hub 技能链接到宿主目录(Windows junction / POSIX symlink);默认范围 = 已核实宿主,自动发现目录需在 CLI 用 --include-discovered 显式纳入,锁 / 数据桥接目录永不链接;元技能链接时收敛旧副本(移入回收站,保留 7 天可恢复)。</div>\n <div class=\"list\" id=\"linkTargets\"></div>\n </div>\n <div class=\"section panel\">\n <h3>当前链接</h3>\n <table><thead><tr><th>技能</th><th>宿主</th><th>目标</th><th>状态</th><th></th></tr></thead><tbody id=\"linkRows\"></tbody></table>\n <div class=\"empty\" id=\"linkEmpty\" hidden>还没有任何链接。</div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-records\">\n <div class=\"toolbar\">\n <button class=\"btn small\" data-tab=\"snapshots\">回滚快照</button>\n <button class=\"btn small\" data-tab=\"evidence\">安装 / 回滚证据</button>\n <button class=\"btn small\" data-tab=\"audit\">Hub 审计</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"recordsInfo\"></span>\n </div>\n <div class=\"section panel\" id=\"recSnapshots\">\n <table><thead><tr><th>技能</th><th>版本</th><th>时间</th><th>来源目录</th><th>状态</th><th></th></tr></thead><tbody id=\"snapshotRows\"></tbody></table>\n <div class=\"empty\" id=\"snapshotEmpty\" hidden>没有安装快照。快照由 install / update 写入。</div>\n </div>\n <div class=\"section panel\" id=\"recEvidence\" hidden>\n <div class=\"list\" id=\"evidenceRows\"></div>\n <div class=\"empty\" id=\"evidenceEmpty\" hidden>没有证据记录。</div>\n </div>\n <div class=\"section panel\" id=\"recAudit\" hidden>\n <div class=\"list\" id=\"auditRows\"></div>\n <div class=\"empty\" id=\"auditEmpty\" hidden>没有 Hub 审计记录。</div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-route\">\n <div class=\"panel\">\n <h3>一句话需求 → 技能组合</h3>\n <div class=\"faint\">只读调用静态 playbook(7 组)+ 动态路由块;安装建议只给命令,不在网页执行。</div>\n <div class=\"toolbar section\">\n <label class=\"search\" style=\"flex:1\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"routeInput\" placeholder=\"例如:帮我做发布前质量检查\"></label>\n <button class=\"btn primary\" id=\"routeBtn\">给出组合</button>\n </div>\n </div>\n <div id=\"routeResult\" class=\"section\"></div>\n </section>\n\n <section class=\"view\" id=\"view-cli\">\n <div class=\"banner\">\n <svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 7 5 5-5 5\"/><path d=\"M12 19h8\"/></svg>\n <div><div class=\"banner-title\">高级 CLI(全量)</div><div class=\"banner-body\">全部命令 + 人话说明 + 可复制示例;网页只展示与复制,不代替终端执行安装、更新与删除。</div></div>\n </div>\n <div class=\"section panel\">\n <div class=\"shead\"><h2>命令速查</h2><span class=\"faint\" id=\"cliMeta\">加载中…</span></div>\n <div id=\"cliQuick\"></div>\n </div>\n <div class=\"section panel\">\n <details id=\"cliFullWrap\">\n <summary>完整命令与参数(点击展开)</summary>\n <div id=\"cliFull\"></div>\n </details>\n </div>\n </section>\n </main>\n</div>\n\n<div class=\"mask\" id=\"mask\"><div class=\"modal\" id=\"modal\"></div></div>\n<div class=\"toasts\" id=\"toasts\"></div>\n\n<script>\n(function () {\n var TOKEN = (document.querySelector('meta[name=\"yotta-view-token\"]') || {}).content || '';\n var state = { view: 'overview', overview: null, hosts: null, adopt: null, selected: {}, records: null, tab: 'snapshots', hostFilter: '', hostStateFilter: '', adoptFilter: '', ovSkillFilter: '' };\n\n function $(sel) { return document.querySelector(sel); }\n function esc(value) {\n return String(value === null || value === undefined ? '' : value)\n .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n }\n function fmtTime(value) {\n var ms = typeof value === 'number' ? value : Date.parse(value);\n if (!ms || isNaN(ms)) return value ? String(value) : '-';\n var d = new Date(ms);\n function p(n) { return n < 10 ? '0' + n : '' + n; }\n return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());\n }\n function toast(text) {\n var el = document.createElement('div');\n el.className = 'toast';\n el.textContent = text;\n $('#toasts').appendChild(el);\n setTimeout(function () { el.remove(); }, 3200);\n }\n function copyText(text) {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { toast('已复制'); }, function () { fallbackCopy(text); });\n } else fallbackCopy(text);\n }\n function fallbackCopy(text) {\n var ta = document.createElement('textarea');\n ta.value = text; document.body.appendChild(ta); ta.select();\n try { document.execCommand('copy'); toast('已复制'); } catch (e) { toast('复制失败,请手动选择'); }\n ta.remove();\n }\n async function api(path, opts) {\n opts = opts || {};\n var init = { method: opts.method || 'GET', headers: {} };\n if (opts.body !== undefined) {\n init.method = 'POST';\n init.headers['Content-Type'] = 'application/json';\n init.headers['X-Yotta-View-Token'] = TOKEN;\n init.body = JSON.stringify(opts.body);\n }\n var res, data = null;\n try { res = await fetch(path, init); } catch (e) { return { error: '无法连接本地服务:' + e.message }; }\n try { data = await res.json(); } catch (e) { data = null; }\n if (!res.ok) return data && data.error ? data : { error: '请求失败(HTTP ' + res.status + ')' };\n if (!data) return { error: '响应不是合法 JSON' };\n return data;\n }\n\n function openModal(title, bodyHtml, opts) {\n opts = opts || {};\n var html = '<h3>' + esc(title) + '</h3><div id=\"modalBody\">' + bodyHtml + '</div>' +\n '<div class=\"modal-actions\"><button class=\"btn\" data-modal=\"cancel\">' + esc(opts.cancelText || '取消') + '</button>' +\n (opts.okText === null ? '' : '<button class=\"btn ' + (opts.danger ? 'danger' : 'primary') + '\" data-modal=\"ok\">' + esc(opts.okText || '确认') + '</button>') +\n '</div>';\n $('#modal').innerHTML = html;\n $('#mask').classList.add('open');\n var ok = $('#modal').querySelector('[data-modal=\"ok\"]');\n $('#modal').querySelector('[data-modal=\"cancel\"]').onclick = closeModal;\n if (ok && opts.onOk) {\n ok.onclick = async function () {\n ok.disabled = true;\n try { await opts.onOk(); } finally { ok.disabled = false; }\n };\n }\n }\n function closeModal() { $('#mask').classList.remove('open'); $('#modal').innerHTML = ''; }\n function confirmModal(title, bodyHtml, okText, danger, onOk) {\n openModal(title, bodyHtml, { okText: okText, danger: danger, onOk: onOk });\n }\n $('#mask').addEventListener('click', function (e) { if (e.target === $('#mask')) closeModal(); });\n document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(); });\n\n var VIEWS = {\n overview: { title: '概览', sub: 'Hub 里有什么、健康吗、最近做了什么', load: loadOverview },\n hosts: { title: '宿主矩阵', sub: '装了哪些宿主、技能目录在哪、Hub 技能链到了谁', load: loadHosts },\n adopt: { title: '收编向导', sub: '有哪些可收编、哪些是重复副本、收编后会发生什么', load: loadAdopt },\n links: { title: '链接与体检', sub: '断链 / 漂移 / 目标缺失在哪、怎么修', load: loadLinks },\n records: { title: '记录与回滚', sub: '安装、收编、链接都做了什么,怎么退回', load: loadRecords },\n route: { title: '路由与编排', sub: '一句话需求该用哪些技能、缺什么(只读)', load: null },\n cli: { title: '高级 CLI', sub: '全量命令速查与复制(与 CLI --help 同源)', load: null }\n };\n var loadSeq = 0;\n function showLoad(text) {\n loadSeq += 1;\n $('#loadbarText').textContent = text;\n $('#loadbar').hidden = false;\n $('#refreshBtn').disabled = true;\n return loadSeq;\n }\n function hideLoad(seq) {\n if (seq !== undefined && seq !== loadSeq) return;\n $('#loadbar').hidden = true;\n $('#refreshBtn').disabled = false;\n }\n async function runLoad(name) {\n var v = VIEWS[name];\n if (!v) return;\n if (!v.load) { loadSeq += 1; hideLoad(); return; }\n var seq = showLoad('正在加载' + v.title + '…');\n try { await v.load(); } finally { hideLoad(seq); }\n }\n function switchView(name) {\n if (!VIEWS[name]) return;\n state.view = name;\n Array.prototype.forEach.call(document.querySelectorAll('#nav button'), function (b) { b.classList.toggle('active', b.dataset.view === name); });\n Array.prototype.forEach.call(document.querySelectorAll('.view'), function (v) { v.classList.toggle('active', v.id === 'view-' + name); });\n $('#pageTitle').textContent = VIEWS[name].title;\n $('#pageSub').textContent = VIEWS[name].sub;\n runLoad(name);\n }\n $('#nav').addEventListener('click', function (e) {\n var b = e.target.closest('button[data-view]');\n if (b) switchView(b.dataset.view);\n });\n document.addEventListener('click', function (e) {\n var g = e.target.closest('[data-goto]');\n if (g) switchView(g.dataset.goto);\n });\n $('#refreshBtn').onclick = function () { var v = VIEWS[state.view]; if (v && v.load) runLoad(state.view); else toast('已刷新'); };\n $('#themeBtn').onclick = function () {\n document.body.classList.toggle('light');\n try { localStorage.setItem('yotta-hub-theme', document.body.classList.contains('light') ? 'light' : 'dark'); } catch (e) {}\n };\n try { if (localStorage.getItem('yotta-hub-theme') === 'light') document.body.classList.add('light'); } catch (e) {}\n\n function statCard(label, value, delta, tone) {\n return '<div class=\"panel\"><div class=\"label\">' + esc(label) + '</div><div class=\"value\">' + esc(value) + '</div><div class=\"delta ' + (tone ? 'badge ' + tone : '') + '\">' + esc(delta) + '</div></div>';\n }\n function doctorBanner(doctor) {\n if (!doctor) return '';\n if (doctor.ok) {\n return '<div class=\"banner ok\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/><path d=\"m9 12 2 2 4-4\"/></svg><div><div class=\"banner-title\">体检通过</div><div class=\"banner-body\">警告 ' + doctor.summary.warnings + ' / 信息 ' + doctor.summary.info + ';结构性错误 0。</div></div></div>';\n }\n var bad = (doctor.checks || []).filter(function (c) { return !c.ok; }).slice(0, 6).map(function (c) {\n return '<div class=\"result\"><span class=\"badge ' + (c.severity === 'error' ? 'danger' : 'warn') + '\">' + esc(c.severity === 'error' ? '错误' : '警告') + '</span><span>' + esc(c.message) + '</span>' + (c.hint ? '<span class=\"faint\">修复:' + esc(c.hint) + '</span>' : '') + '</div>';\n }).join('');\n return '<div class=\"banner danger\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/><path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z\"/></svg><div style=\"flex:1\"><div class=\"banner-title\">体检未通过(错误 ' + doctor.summary.errors + ' / 警告 ' + doctor.summary.warnings + ')</div><div class=\"result-list\">' + bad + '</div></div></div>';\n }\n\n async function loadOverview() {\n var d = await api('/api/overview');\n if (d.error) { toast(d.error); return; }\n state.overview = d;\n $('#ovCards').innerHTML =\n statCard('Hub 技能', d.status.summary.skills, '外部来源 ' + d.status.summary.external, '') +\n statCard('已链接', d.status.summary.links, '宿主目录投影', 'ok') +\n statCard('异常链接', d.status.summary.brokenLinks, d.status.summary.brokenLinks ? '需要处理' : '全部正常', d.status.summary.brokenLinks ? 'danger' : 'ok') +\n statCard('Doctor', d.doctor.ok ? '通过' : '未通过', '错误 ' + d.doctor.summary.errors + ' / 警告 ' + d.doctor.summary.warnings, d.doctor.ok ? 'ok' : 'danger');\n $('#ovDoctor').innerHTML = doctorBanner(d.doctor);\n renderOverviewSkills();\n var audit = (d.audit && d.audit.recent) || [];\n $('#ovAudit').innerHTML = audit.length ? audit.slice().reverse().map(function (a) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(a.event || '动作') + ' · ' + esc(a.slug || '') + '</div><div class=\"row-meta\">' + esc(fmtTime(a.at)) + (a.verdict ? ' · ' + esc(a.verdict) : '') + '</div></div></div>';\n }).join('') : '<div class=\"empty\">还没有动作记录。</div>';\n $('#ovHubDir').textContent = d.hubDir;\n $('#copyHubDir').onclick = function () { copyText(d.hubDir); };\n var hubInfo = d.hub || {};\n var sourceNames = { flag: '--hub 参数', env: 'YOTTA_SKILLS_HUB 环境变量', config: 'config.json 配置', default: '默认位置' };\n var metaParts = ['来源: ' + (sourceNames[hubInfo.source] || hubInfo.source || '默认位置')];\n if (hubInfo.configured) metaParts.push('配置覆盖: ' + hubInfo.configured);\n if (hubInfo.restartRequired) metaParts.push('配置已指向 ' + (hubInfo.effectiveNow || '') + ',重启 view 后生效');\n $('#ovHubMeta').textContent = metaParts.join(' · ');\n $('#hubConfigClearBtn').hidden = !hubInfo.configured;\n renderMigrationBlock(d);\n }\n\n /* 0.29.5 S1/S2:迁移向导(迁移 / 仅切换)+ 最近迁移 + 一键回滚(与 CLI 同内核) */\n function renderMigrationBlock(d) {\n var mig = (d.migration && d.migration.last) || null;\n var pending = (d.migration && d.migration.switchPending) || null;\n var banner = $('#ovSwitchBanner');\n if (pending) {\n banner.hidden = false;\n banner.innerHTML = '<div class=\"banner danger\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/><path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z\"/></svg>' +\n '<div><div class=\"banner-title\">旧 Hub 还有 ' + esc(pending.skills) + ' 个技能未迁移</div>' +\n '<div class=\"banner-body\">旧位置 <span class=\"mono\">' + esc(pending.from) + '</span> · 当前位置 <span class=\"mono\">' + esc(d.hubDir) + '</span></div>' +\n '<div class=\"toolbar section\"><button class=\"btn small primary\" id=\"switchMigrateNow\">一键迁移到当前位置</button>' +\n '<button class=\"btn small\" id=\"switchMigrateOpen\">迁移到其它位置…</button></div></div></div>';\n $('#switchMigrateNow').onclick = function () {\n confirmModal('迁移旧 Hub 到当前位置?',\n '<p class=\"label\">将把 <span class=\"mono\">' + esc(pending.from) + '</span>(' + esc(pending.skills) + ' 个技能)复制到 <span class=\"mono\">' + esc(d.hubDir) + '</span>,逐技能校验后切换配置、重链全部宿主,旧 Hub 入回收站(7 天可回滚)。</p>',\n '开始迁移', false, async function () {\n await executeHubMigration({ hub: d.hubDir, mode: 'move' }, { from: pending.from });\n });\n };\n $('#switchMigrateOpen').onclick = function () { openHubConfigWizard(); };\n } else {\n banner.hidden = true;\n banner.innerHTML = '';\n }\n var box = $('#ovMigration');\n if (!mig) {\n box.hidden = true;\n return;\n }\n box.hidden = false;\n var kind = mig.kind === 'rollback' ? '回滚' : '迁移';\n var lines = [\n '最近一次' + kind + ':' + esc(mig.from) + ' → ' + esc(mig.to),\n '时间 ' + esc(fmtTime(mig.at)) + ' · 技能 ' + esc(mig.verifiedSkills) + ' · 重链 ' + esc(mig.relinkDirs) + ' 个宿主目录',\n ];\n if (mig.trashedTo) lines.push('回收站 ' + esc(mig.trashedTo) + '(剩余 ' + esc(mig.daysLeft) + ' 天)');\n if (mig.incomplete > 0) lines.push('⚠ 重链未全部完成(' + esc(mig.incomplete) + ' 项);旧 Hub 保留在 ' + esc(mig.oldHubKept || mig.from));\n if (mig.trashError) lines.push('⚠ 旧 Hub 移入回收站失败(已保留原位):' + esc(mig.trashError));\n $('#ovMigrationBody').innerHTML = '<div class=\"faint\">' + lines.join('<br>') + '</div>';\n $('#copyMigrationTrash').hidden = !mig.trashedTo;\n $('#copyMigrationTrash').onclick = function () { copyText(mig.trashedTo); };\n $('#migrationRollbackBtn').onclick = rollbackFlow;\n }\n\n function rewireModalButtons(opts) {\n var ok = $('#modal').querySelector('[data-modal=\"ok\"]');\n var cancel = $('#modal').querySelector('[data-modal=\"cancel\"]');\n if (ok && opts.okText) { ok.textContent = opts.okText; ok.disabled = false; ok.onclick = opts.onOk || closeModal; }\n if (ok && opts.hideOk) ok.hidden = true;\n if (cancel && opts.cancelText) { cancel.textContent = opts.cancelText; cancel.onclick = opts.onCancel || closeModal; }\n }\n\n function showMigrationResult(result) {\n var move = result.move || {};\n var done = !move.incomplete && !move.trashError;\n $('#modalBody').innerHTML =\n '<div class=\"result\"><span class=\"badge ' + (done ? 'ok' : 'danger') + '\">' + (done ? '迁移完成' : '部分完成') + '</span><span>' + esc(result.previous || '') + ' → ' + esc(result.hub || '') + '</span></div>' +\n '<div class=\"result\"><span class=\"badge ok\">校验</span><span>' + esc(move.verifiedSkills || 0) + ' 个技能复制校验通过</span></div>' +\n '<div class=\"result\"><span class=\"badge ok\">重链</span><span>' + esc(move.relinkDirs || 0) + ' 个宿主目录</span></div>' +\n (move.trashedTo ? '<div class=\"result\"><span class=\"badge ok\">旧 Hub</span><span class=\"mono\">' + esc(move.trashedTo) + '</span><span>已入回收站(7 天可恢复)</span></div>' : '') +\n (move.incomplete > 0 ? '<div class=\"result\"><span class=\"badge danger\">重链未全部完成</span><span>旧 Hub 保留在 <span class=\"mono\">' + esc(move.oldHubKept || move.from || '') + '</span>;重跑:yotta-skills hub link --all --force</span></div>' : '') +\n (move.trashError ? '<div class=\"result\"><span class=\"badge warn\">回收站</span><span>旧 Hub 移入回收站失败(已保留原位):' + esc(move.trashError) + '</span></div>' : '') +\n '<div class=\"note\">新位置从下次面板加载开始生效(已在运行的 view 需重启)。</div>';\n rewireModalButtons({\n okText: '关闭',\n onOk: closeModal,\n cancelText: '刷新面板',\n onCancel: function () { closeModal(); loadOverview(); },\n });\n }\n\n function showSwitchResult(result) {\n $('#modalBody').innerHTML =\n '<div class=\"result\"><span class=\"badge ok\">已切换</span><span>' + esc(result.previous || '') + ' → ' + esc(result.hub || '') + '</span></div>' +\n ((result.oldSkills || 0) > 0\n ? '<div class=\"result\"><span class=\"badge warn\">未迁移</span><span>旧 Hub 还有 ' + esc(result.oldSkills) + ' 个技能未迁移;概览会持续提示,可一键迁移。</span></div>'\n : '') +\n '<div class=\"note\">只切换了指针,没有移动任何文件;新位置从下次面板加载开始生效。</div>';\n rewireModalButtons({\n okText: '关闭',\n onOk: closeModal,\n cancelText: '刷新面板',\n onCancel: function () { closeModal(); loadOverview(); },\n });\n }\n\n async function executeHubMigration(body, opts) {\n opts = opts || {};\n var seq = showLoad(body.mode === 'switch' ? '正在切换 Hub 位置…' : '正在迁移 Hub(复制 → 校验 → 重链 → 回收站)…');\n var payload = Object.assign({ confirm: 'hub-config' }, body);\n if (opts.from) payload = Object.assign(payload, { from: opts.from });\n var r = await api('/api/hub/config', { body: payload });\n hideLoad(seq);\n if (r.error) {\n if (r.code === 'target-residue') return wizardResiduePrompt(body, r.error, opts);\n if (opts.inModal) {\n $('#hubWizardResult').innerHTML = '<div class=\"result\"><span class=\"badge danger\">失败</span><span>' + esc(r.error) + '</span></div>';\n } else {\n toast(r.error);\n }\n return null;\n }\n if (opts.inModal) {\n if (body.mode === 'switch') showSwitchResult(r);\n else showMigrationResult(r);\n }\n else {\n closeModal();\n toast('迁移完成:校验 ' + ((r.move && r.move.verifiedSkills) || 0) + ' 个技能 · 重链 ' + ((r.move && r.move.relinkDirs) || 0) + ' 个目录' + (r.move && r.move.incomplete ? '(重链未全部完成,见概览提示)' : ''));\n loadOverview();\n }\n return r;\n }\n\n function wizardResiduePrompt(body, error, opts) {\n var host = opts && opts.inModal ? $('#hubWizardResult') : $('#modalBody');\n if (!host) { toast(error); return; }\n host.innerHTML = '<div class=\"result\"><span class=\"badge warn\">残留</span><span>' + esc(error) + '</span></div>' +\n '<button class=\"btn small primary\" id=\"hubResidueClean\">清理残留并迁移</button>';\n $('#hubResidueClean').onclick = async function () {\n var seq = showLoad('正在清理残留并迁移…');\n var payload = Object.assign({ confirm: 'hub-config', cleanResidue: true }, body);\n if (opts && opts.from) payload.from = opts.from;\n var r = await api('/api/hub/config', { body: payload });\n hideLoad(seq);\n if (r.error) { toast(r.error); return; }\n if (opts && opts.inModal) showMigrationResult(r);\n else { closeModal(); toast('迁移完成'); loadOverview(); }\n };\n }\n\n function openHubConfigWizard() {\n var d = state.overview || {};\n var current = d.hubDir || '';\n var skillCount = (d.status && d.status.summary && d.status.summary.skills) || 0;\n var body = '<p class=\"label\">写入 config.json 持久覆盖(优先级低于 --hub 参数 / YOTTA_SKILLS_HUB)。</p>' +\n '<label class=\"label\">新的 Hub 目录</label>' +\n '<input id=\"hubNewPath\" placeholder=\"D:\\\\my-hub\" value=\"\">' +\n '<div class=\"note mono\">当前: ' + esc(current) + '(' + esc(skillCount) + ' 个技能)</div>' +\n '<div class=\"section\"><label class=\"label\"><input type=\"radio\" name=\"hubMode\" value=\"move\" checked style=\"width:auto\"> 迁移到新位置(推荐)</label>' +\n '<div class=\"faint\">复制 → 逐技能校验 → 切换配置 → 全宿主重链 → 旧 Hub 入回收站(7 天可回滚)。</div>' +\n '<label class=\"label section\"><input type=\"radio\" name=\"hubMode\" value=\"switch\" style=\"width:auto\"> 仅切换位置(保留旧内容)</label>' +\n '<div class=\"faint\">只改指针,不移动任何文件;旧 Hub 内容保留,概览会提示未迁移。</div></div>' +\n '<div id=\"hubWizardResult\" class=\"section\"></div>';\n openModal('设置 Hub 位置', body, {\n okText: '执行',\n onOk: async function () {\n var value = ($('#hubNewPath').value || '').trim();\n if (!value) { toast('先填写新的 Hub 目录'); return; }\n var mode = document.querySelector('input[name=\"hubMode\"]:checked').value;\n if (mode === 'switch' && skillCount > 0) {\n $('#hubWizardResult').innerHTML = '<div class=\"result\"><span class=\"badge warn\">确认</span><span>旧 Hub 还有 ' + esc(skillCount) + ' 个技能未迁移。仅切换位置不会移动它们。</span></div>' +\n '<button class=\"btn small danger\" id=\"hubSwitchConfirm\">确认仅切换</button>';\n $('#hubSwitchConfirm').onclick = function () { executeHubMigration({ hub: value, mode: 'switch' }, { inModal: true }); };\n return;\n }\n await executeHubMigration({ hub: value, mode: mode }, { inModal: true });\n },\n });\n }\n\n function rollbackFlow() {\n api('/api/hub/config').then(function (d) {\n if (d.error) { toast(d.error); return; }\n var rb = d.rollback || {};\n if (!rb.lastMigration) { toast('没有可回滚的迁移记录'); return; }\n if (!rb.ok) {\n confirmModal('回滚被阻断', '<p class=\"label\">' + esc((rb.blocked || []).join(';') || rb.error || '') + '</p><div class=\"note\">回滚 fail-closed(不合并);处理阻断项后重试。</div>', null, true, null);\n return;\n }\n confirmModal('回滚 Hub 位置?',\n '<p class=\"label\">将把当前 Hub 内容复制回原位置:<span class=\"mono\">' + esc(rb.current) + '</span> → <span class=\"mono\">' + esc(rb.target) + '</span>(复制校验 → 切换 → 重链 → 当前 Hub 入回收站 7 天)。</p>' +\n '<div class=\"note\">位置回退(用当前内容),不是恢复旧快照。</div>',\n '执行回滚', true, async function () {\n var seq = showLoad('正在回滚 Hub 位置(复制 → 校验 → 重链 → 回收站)…');\n var r = await api('/api/hub/config', { body: { rollback: true, confirm: 'hub-config' } });\n hideLoad(seq);\n if (r.error) {\n if (r.code === 'target-residue') { wizardResiduePrompt({ rollback: true }, r.error, {}); return; }\n toast(r.error); return;\n }\n closeModal();\n toast('回滚完成:' + ((r.move && r.move.verifiedSkills) || 0) + ' 个技能已回到 ' + (r.hub || ''));\n loadOverview();\n });\n });\n }\n\n $('#hubConfigBtn').onclick = openHubConfigWizard;\n $('#hubConfigClearBtn').onclick = function () {\n confirmModal('清除 Hub 位置覆盖?', '<p class=\"label\">将删除 config.json 中的 Hub 覆盖,恢复 --hub &gt; 环境变量 &gt; 默认位置的解析。不会移动任何文件。</p>', '清除覆盖', true, async function () {\n var r = await api('/api/hub/config', { body: { clear: true, confirm: 'hub-config' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已清除覆盖;重启 view 后生效');\n loadOverview();\n });\n };\n\n function renderOverviewSkills() {\n var d = state.overview;\n if (!d) return;\n var skills = (d.status && d.status.skills) || [];\n var filter = state.ovSkillFilter.trim().toLowerCase();\n var list = filter\n ? skills.filter(function (s) { return String(s.slug || '').toLowerCase().indexOf(filter) >= 0; })\n : skills.slice(0, 8);\n $('#ovSkills').innerHTML = list.length ? list.map(function (s) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(s.slug) + '</div><div class=\"row-meta\">v' + esc(s.version || '-') + ' · ' + esc(s.origin === 'external' ? '外部来源' : '元阁家族') + ' · 链接 ' + (s.links || []).length + '</div></div>' +\n '<span class=\"badge ' + (s.status === 'present' ? 'ok' : 'danger') + '\">' + esc(s.status === 'present' ? '已入库' : '缺失') + '</span>' +\n '<div class=\"row-actions\"><button class=\"btn small danger\" data-remove-skill=\"' + esc(s.slug) + '\">删除</button></div></div>';\n }).join('') : '<div class=\"empty\">' + (filter ? '没有匹配的技能。' : 'Hub 还是空的。先在「收编向导」把已装技能收编进来。') + '</div>';\n }\n $('#ovSkillFilter').addEventListener('input', function () {\n state.ovSkillFilter = $('#ovSkillFilter').value || '';\n renderOverviewSkills();\n });\n $('#ovSkills').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-remove-skill]');\n if (!b) return;\n var slug = b.dataset.removeSkill;\n var plan = await api('/api/skills/remove-plan?slug=' + encodeURIComponent(slug));\n if (!plan || plan.error) { toast((plan && plan.error) || '影响预览失败'); return; }\n var targets = plan.targets || [];\n var willUnlink = targets.filter(function (t) { return t.action === 'unlink'; });\n var kept = targets.filter(function (t) { return t.action === 'keep'; });\n var rows = targets.map(function (t) {\n var tone = t.action === 'unlink' ? 'warn' : t.action === 'keep' ? 'info' : '';\n return '<div class=\"result\"><span class=\"badge ' + tone + '\">' + esc(t.action === 'unlink' ? '将清理' : t.action === 'keep' ? '保留' : '无动作') + '</span><span class=\"mono\">' + esc(t.dir) + '</span><span class=\"faint\">' + esc(t.note || '') + '</span></div>';\n }).join('');\n var body = '<p class=\"label\">将清理 ' + willUnlink.length + ' 条宿主链接(含死链)' +\n (plan.hubPresent ? ',并把 Hub 目录移入回收站(保留 7 天)' : ';Hub 目录已缺失,仅清理残留') +\n ';保留 ' + kept.length + ' 项宿主真副本 / 外部链接。</p>' +\n '<div class=\"result-list\">' + (rows || '<div class=\"faint\">没有需要处理的宿主目录。</div>') + '</div>' +\n '<div class=\"section\"><label class=\"label\">输入完整技能名确认:' + esc(slug) + '</label><input id=\"removeConfirmSlug\" placeholder=\"' + esc(slug) + '\"></div>';\n confirmModal('删除 Hub 技能 ' + slug + '?', body, '删除', true, async function () {\n var typed = ($('#removeConfirmSlug') && $('#removeConfirmSlug').value || '').trim();\n if (typed !== slug) { toast('技能名不匹配'); return; }\n var r = await api('/api/skills/remove', { body: { slug: slug, confirm: 'remove', confirmSlug: typed } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast(r.verdict === 'cleanup-only' ? '已清理残留(Hub 目录原本缺失)' : '已删除 ' + slug + '(目录已入回收站)');\n loadOverview();\n });\n });\n\n async function loadHosts() {\n var d = await api('/api/hosts');\n if (d.error) { toast(d.error); return; }\n state.hosts = d;\n var s = d.summary.states || {};\n $('#hostSummary').textContent = '可用 ' + (s.available || 0) + ' · 残留 ' + (s.orphan || 0) + ' · 未创建 ' + (s.missing || 0) + ' · 仅标记 ' + (s.markerOnly || 0) + (s.ignored ? ' · 已忽略 ' + s.ignored : '') + (d.summary.excluded ? ' · 不接管 ' + d.summary.excluded : '') + ' · 链接 ' + d.summary.links + '(异常 ' + d.summary.brokenLinks + ')';\n renderHostStateChips();\n renderHosts();\n var marks = d.unpaired || [];\n $('#hostMarks').innerHTML = marks.length ? marks.map(function (m) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(m.label) + '</div><div class=\"row-meta\">' + esc(m.source || '') + ' · 未发现技能目录</div></div><span class=\"badge info\">仅标记</span></div>';\n }).join('') : '<div class=\"empty\">没有未配对的宿主标记。</div>';\n renderHostExcluded(d.excluded || []);\n }\n function renderHostExcluded(list) {\n $('#hostExcludedWrap').hidden = list.length === 0;\n $('#hostExcluded').innerHTML = list.map(function (item) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(item.label || item.value) + '</div><div class=\"row-meta mono\">' + esc(item.value) + ' · ' + esc(item.kind === 'agent' ? '智能体' : '目录') + '</div></div><div class=\"row-actions\"><button class=\"btn small\" data-host-include=\"' + esc(item.value) + '\">恢复接管</button></div></div>';\n }).join('');\n }\n function renderHostStateChips() {\n var d = state.hosts; if (!d) return;\n var s = d.summary.states || {};\n var chips = [\n ['', '全部'],\n ['available', '可用 ' + (s.available || 0)],\n ['orphan', '残留 ' + (s.orphan || 0)],\n ['missing', '未创建 ' + (s.missing || 0)],\n ['marker-only', '仅标记 ' + (s.markerOnly || 0)],\n ];\n $('#hostStateChips').innerHTML = chips.map(function (c) {\n return '<button class=\"chip' + (state.hostStateFilter === c[0] ? ' active' : '') + '\" data-host-state=\"' + esc(c[0]) + '\">' + esc(c[1]) + '</button>';\n }).join('');\n }\n function hostStateBadge(h) {\n if (h.bridgeOnly) return '<span class=\"badge info\">桥接</span>';\n var map = { available: ['ok', '可用'], orphan: ['warn', '残留'], missing: ['', '未创建'], 'marker-only': ['info', '仅标记'], ignored: ['', '已忽略'] };\n var item = map[h.state] || ['', h.state || '未知'];\n return '<span class=\"badge ' + item[0] + '\">' + esc(item[1]) + (h.stateSource === 'manual' ? '·手动' : '') + '</span>' + (h.brokenLinks ? ' <span class=\"badge danger\">异常</span>' : '');\n }\n function renderHosts() {\n var d = state.hosts; if (!d) return;\n var q = state.hostFilter.toLowerCase();\n var sf = state.hostStateFilter;\n var rows = (d.hosts || []).filter(function (h) {\n if (sf && h.state !== sf) return false;\n if (!q) return true;\n return String(h.label || '').toLowerCase().includes(q) || String(h.dir || '').toLowerCase().includes(q);\n });\n $('#hostEmpty').hidden = rows.length > 0;\n var overrideIds = {};\n ((d.overrides) || []).forEach(function (o) { overrideIds[String(o.agentId).toLowerCase()] = true; });\n $('#hostRows').innerHTML = rows.map(function (h) {\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链</span>'\n : (h.detection === 'user-registry' ? '<span class=\"badge info\">用户注册</span>'\n : (h.detection === 'override' ? '<span class=\"badge ok\">目录覆盖</span>'\n : (h.verified ? '<span class=\"badge ok\">已核实</span>'\n : ((h.detection === 'discovered' || h.detection === 'workspace') ? '<span class=\"badge warn\">自动发现</span>' : '<span class=\"badge warn\">映射·未核实</span>'))));\n var action = '';\n if (h.exists && h.state === 'orphan' && !h.bridgeOnly) action += '<button class=\"btn small danger\" data-host-purge=\"' + esc(h.dir) + '\">清理</button> ';\n if (h.state === 'ignored') action += '<button class=\"btn small\" data-host-mark=\"' + esc(h.dir) + '\" data-mark-state=\"available\">标记可用</button> ';\n else if (h.exists && !h.bridgeOnly) action += '<button class=\"btn small\" data-host-mark=\"' + esc(h.dir) + '\" data-mark-state=\"ignored\">忽略</button> ';\n if (h.agentId && !h.bridgeOnly) {\n if (overrideIds[String(h.agentId).toLowerCase()]) action += '<button class=\"btn small\" data-host-clear=\"' + esc(h.agentId) + '\">恢复默认</button> ';\n else action += '<button class=\"btn small\" data-host-edit=\"' + esc(h.agentId) + '\" data-host-dir=\"' + esc(h.dir) + '\" data-host-label=\"' + esc(h.label) + '\">编辑目录</button> ';\n }\n if (!h.bridgeOnly) action += '<button class=\"btn small\" data-host-exclude=\"' + esc(h.agentId || h.dir) + '\">不接管</button> ';\n if (h.detection === 'user-registry') action += '<button class=\"btn small\" data-host-unregister=\"' + esc(h.dir) + '\">移除注册</button>';\n return '<tr><td><div class=\"cell-main\">' + esc(h.label) + '</div><div class=\"cell-sub\">' + esc(h.agentId || (h.known ? '已知宿主' : h.detection || '自动发现')) + '</div></td>' +\n '<td><div class=\"mono\">' + esc(h.dir) + '</div><button class=\"btn small\" data-copy=\"' + esc(h.dir) + '\">复制</button></td>' +\n '<td>' + scopeBadge + '</td>' +\n '<td>' + esc(h.skillCount || 0) + '</td><td>' + esc(h.hubLinks || 0) + '</td><td>' + esc(h.brokenLinks || 0) + '</td><td>' + hostStateBadge(h) + '</td><td>' + action + '</td></tr>';\n }).join('');\n }\n $('#hostSearch').addEventListener('input', function (e) { state.hostFilter = e.target.value; renderHosts(); });\n $('#hostStateChips').addEventListener('click', function (e) {\n var b = e.target.closest('[data-host-state]');\n if (!b) return;\n state.hostStateFilter = b.dataset.hostState;\n renderHostStateChips();\n renderHosts();\n });\n $('#hostAddBtn').addEventListener('click', function () {\n var body = '<p class=\"label\">注册自定义宿主目录:纳入发现 / 分发 / 收编。不创建目录、不改宿主配置。</p>' +\n '<div class=\"section\"><label class=\"label\">目录(必须已存在)</label><input id=\"hostAddDir\" placeholder=\"例如 D:\\\\my-agent\\\\skills\"></div>' +\n '<div class=\"section\"><label class=\"label\">名称(可选,默认目录名)</label><input id=\"hostAddLabel\" placeholder=\"我的宿主\"></div>';\n openModal('添加自定义目录', body, {\n okText: '注册',\n onOk: async function () {\n var dir = ($('#hostAddDir') && $('#hostAddDir').value || '').trim();\n var label = ($('#hostAddLabel') && $('#hostAddLabel').value || '').trim();\n if (!dir) { toast('请填写目录'); return; }\n var r = await api('/api/hosts/add', { body: { dir: dir, label: label } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已注册:' + ((r.entry && r.entry.label) || dir));\n loadHosts();\n },\n });\n });\n async function openHostPurgeModal(dir) {\n var plan = await api('/api/hosts/purge-plan?dir=' + encodeURIComponent(dir));\n if (plan.error) { toast(plan.error); return; }\n var body = '<p class=\"label\">只删除指向 Hub 的链接(含死链);非 Hub 链接 / 非技能内容随目录保留在回收站(7 天可恢复)。</p>' +\n '<div class=\"result\"><span class=\"badge warn\">将解除链接</span><b>' + esc(plan.unlinkCount) + '</b><span>条(保留 ' + esc(plan.keptLinks) + ' 非 Hub 链接 / ' + esc(plan.keptEntries) + ' 非链接内容)</span></div>' +\n '<div class=\"mono\">' + esc(dir) + '</div>' +\n '<div class=\"section\"><label class=\"label\">输入 purge 确认执行:</label><input id=\"hostPurgeConfirm\" placeholder=\"purge\"></div>';\n confirmModal('清理残留目录?', body, '清理(移入回收站)', true, async function () {\n var typed = ($('#hostPurgeConfirm') && $('#hostPurgeConfirm').value || '').trim();\n if (typed !== 'purge') { toast('确认串不匹配(输入 purge)'); return; }\n var r = await api('/api/hosts/purge', { body: { dir: dir, confirm: 'purge' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已清理:解除 ' + r.unlinkCount + ' 条链接;回收站保留 7 天');\n loadHosts();\n });\n }\n function openHostEditModal(agentId, dir, label) {\n var body = '<p class=\"label\">覆盖「' + esc(label) + '」的技能目录(写入 hosts.json,不改宿主配置)。被覆盖的旧目录不再进入默认发现 / 链接。</p>' +\n '<div class=\"section\"><label class=\"label\">新目录(必须已存在)</label><input id=\"hostEditDir\" value=\"' + esc(dir) + '\"></div>';\n openModal('编辑目录 · ' + esc(agentId), body, {\n okText: '保存覆盖',\n onOk: async function () {\n var value = ($('#hostEditDir') && $('#hostEditDir').value || '').trim();\n if (!value) { toast('请填写目录'); return; }\n var r = await api('/api/hosts/set', { body: { agentId: agentId, dir: value } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已覆盖目录');\n loadHosts();\n },\n });\n }\n $('#hostRows').addEventListener('click', async function (e) {\n var copyBtn = e.target.closest('[data-copy]');\n if (copyBtn) { copyText(copyBtn.dataset.copy); return; }\n var purgeBtn = e.target.closest('[data-host-purge]');\n if (purgeBtn) { openHostPurgeModal(purgeBtn.dataset.hostPurge); return; }\n var editBtn = e.target.closest('[data-host-edit]');\n if (editBtn) {\n openHostEditModal(editBtn.dataset.hostEdit, editBtn.dataset.hostDir, editBtn.dataset.hostLabel);\n return;\n }\n var clearBtn = e.target.closest('[data-host-clear]');\n if (clearBtn) {\n var clearAgent = clearBtn.dataset.hostClear;\n confirmModal('恢复默认目录?', '<p class=\"label\">删除目录覆盖,恢复内置映射。</p><div class=\"mono\">' + esc(clearAgent) + '</div>', '恢复默认', false, async function () {\n var r = await api('/api/hosts/set', { body: { agentId: clearAgent, clear: true } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已恢复内置映射');\n loadHosts();\n });\n return;\n }\n var excludeBtn = e.target.closest('[data-host-exclude]');\n if (excludeBtn) {\n var excludeTarget = excludeBtn.dataset.hostExclude;\n confirmModal('加入「不接管」名单?', '<p class=\"label\">该宿主 / 目录将从发现、显示、链接三层全部跳过;已建立的链接不受影响(可用 CLI hub unlink 清理)。</p><div class=\"mono\">' + esc(excludeTarget) + '</div>', '不接管', false, async function () {\n var r = await api('/api/hosts/exclude', { body: { target: excludeTarget } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已加入不接管名单');\n loadHosts();\n });\n return;\n }\n var markBtn = e.target.closest('[data-host-mark]');\n if (markBtn) {\n var r = await api('/api/hosts/mark', { body: { dir: markBtn.dataset.hostMark, state: markBtn.dataset.markState } });\n if (r.error) { toast(r.error); return; }\n toast('已标记:' + markBtn.dataset.markState);\n loadHosts();\n return;\n }\n var unregBtn = e.target.closest('[data-host-unregister]');\n if (unregBtn) {\n var dir = unregBtn.dataset.hostUnregister;\n confirmModal('移除注册?', '<p class=\"label\">只移除自定义宿主注册,不删除目录、不改宿主配置。</p><div class=\"mono\">' + esc(dir) + '</div>', '移除注册', false, async function () {\n var r = await api('/api/hosts/remove', { body: { dir: dir } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已移除注册(目录保留)');\n loadHosts();\n });\n }\n });\n $('#hostExcluded').addEventListener('click', async function (e) {\n var includeBtn = e.target.closest('[data-host-include]');\n if (!includeBtn) return;\n var r = await api('/api/hosts/include', { body: { target: includeBtn.dataset.hostInclude } });\n if (r.error) { toast(r.error); return; }\n toast('已恢复接管');\n loadHosts();\n });\n\n async function loadAdopt() {\n var d = await api('/api/adopt/scan');\n if (d.error) { toast(d.error); return; }\n state.adopt = d;\n state.selected = {};\n $('#adoptCards').innerHTML =\n statCard('候选技能', d.summary.candidates, '已装宿主里的技能', '') +\n statCard('多副本冲突', d.summary.conflicts, '版本 / 哈希不一致', d.summary.conflicts ? 'warn' : 'ok') +\n statCard('已在 Hub', d.summary.alreadyInHub, '默认跳过', 'info') +\n statCard('扫描引擎', d.scan && d.scan.available ? '可用' : '未找到', d.scan && d.scan.available ? '收编时逐项扫描' : '可显式降级(CLI)', d.scan && d.scan.available ? 'ok' : 'warn');\n $('#adoptScanInfo').textContent = '生成于 ' + fmtTime(d.generatedAt);\n renderAdopt();\n }\n function renderAdopt() {\n var d = state.adopt; if (!d) return;\n var q = state.adoptFilter.toLowerCase();\n var candidates = (d.candidates || []).filter(function (c) {\n if (!q) return true;\n return String(c.slug || '').toLowerCase().includes(q) || String(c.sourceHost || '').toLowerCase().includes(q);\n });\n $('#adoptEmpty').hidden = candidates.length > 0;\n $('#adoptList').innerHTML = candidates.map(function (c) {\n var variants = (c.variants || []).map(function (v) {\n return '<div class=\"variant\"><span class=\"badge\">' + esc(v.host) + '</span><span class=\"mono\">' + esc(v.dir) + '</span><span>v' + esc(v.version || '-') + '</span></div>';\n }).join('');\n return '<div class=\"row\"><input class=\"check\" type=\"checkbox\" data-slug=\"' + esc(c.slug) + '\"' + (state.selected[c.slug] ? ' checked' : '') + (c.inHub ? ' disabled' : '') + '>' +\n '<div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge\">v' + esc(c.version || '-') + '</span>' +\n (c.inHub ? ' <span class=\"badge info\">已在 Hub</span>' : '') +\n (c.conflict ? ' <span class=\"badge warn\">多副本冲突</span>' : '') + '</div>' +\n '<div class=\"row-meta\">来源:' + esc(c.sourceHost || '-') + ' · ' + esc(c.source || '') + '</div>' +\n (c.variants && c.variants.length > 1 ? '<details><summary>' + c.variants.length + ' 个副本</summary><div class=\"variants\">' + variants + '</div></details>' : '') +\n '</div></div>';\n }).join('');\n updateAdoptCount();\n }\n function updateAdoptCount() {\n var n = Object.keys(state.selected).filter(function (k) { return state.selected[k]; }).length;\n $('#adoptCount').textContent = String(n);\n $('#adoptApply').disabled = n === 0;\n }\n $('#adoptSearch').addEventListener('input', function (e) { state.adoptFilter = e.target.value; renderAdopt(); });\n $('#adoptList').addEventListener('change', function (e) {\n var box = e.target.closest('input[data-slug]');\n if (!box) return;\n if (box.checked) state.selected[box.dataset.slug] = true; else delete state.selected[box.dataset.slug];\n updateAdoptCount();\n });\n $('#adoptAll').onclick = function () {\n ((state.adopt && state.adopt.candidates) || []).forEach(function (c) { if (!c.inHub) state.selected[c.slug] = true; });\n renderAdopt();\n };\n $('#adoptNone').onclick = function () { state.selected = {}; renderAdopt(); };\n $('#adoptApply').onclick = function () {\n var slugs = Object.keys(state.selected).filter(function (k) { return state.selected[k]; });\n if (!slugs.length) { toast('还没有选择技能'); return; }\n var preview = slugs.slice(0, 24).join('、') + (slugs.length > 24 ? ' 等 ' + slugs.length + ' 项' : '');\n confirmModal('收编 ' + slugs.length + ' 个技能到 Hub?', '<p class=\"label\">将复制到 Hub(原目录保留);已在 Hub 或冲突项按默认策略处理;执行时逐项运行元信扫描,high / critical 会阻断。</p><div class=\"mono\">' + esc(preview) + '</div>', '开始收编', false, async function () {\n var r = await api('/api/adopt/apply', { body: { include: slugs } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var order = { imported: 0, blocked: 1, conflict: 2, skip: 3, error: 4 };\n var results = (r.results || []).slice().sort(function (a, b) { return (order[a.status] || 9) - (order[b.status] || 9); });\n $('#adoptResultsWrap').hidden = false;\n $('#adoptResults').innerHTML = results.map(function (x) {\n var tone = x.status === 'imported' ? 'ok' : x.status === 'error' || x.status === 'blocked' ? 'danger' : 'warn';\n return '<div class=\"result\"><span class=\"badge ' + tone + '\">' + esc(x.status) + '</span><b>' + esc(x.slug) + '</b><span>' + esc(x.note || '') + '</span>' + (x.scanVerdict ? '<span class=\"faint\">verdict: ' + esc(x.scanVerdict) + '</span>' : '') + '</div>';\n }).join('');\n toast('收编完成:导入 ' + (r.summary ? r.summary.imported : 0) + ' / 阻断 ' + (r.summary ? r.summary.blocked : 0));\n loadOverview();\n loadAdopt();\n });\n };\n\n async function loadLinks() {\n var results = await Promise.all([api('/api/doctor'), api('/api/links'), api('/api/hosts')]);\n var doctor = results[0], links = results[1], hosts = results[2];\n if (doctor.error || links.error || hosts.error) { toast((doctor.error || links.error || hosts.error)); return; }\n $('#linkDoctor').innerHTML = doctorBanner(doctor);\n var existing = (hosts.hosts || []).filter(function (h) { return h.exists; });\n $('#linkTargets').innerHTML = existing.length ? existing.map(function (h) {\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链接</span>' : h.verified ? '<span class=\"badge ok\">已核实</span>' : '<span class=\"badge warn\">自动发现</span>';\n var action = h.bridgeOnly ? '' : '<button class=\"btn small\" data-link-plan=\"' + esc(h.dir) + '\" data-label=\"' + esc(h.label) + '\">预览链接</button>';\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(h.label) + ' ' + scopeBadge + '</div><div class=\"row-meta mono\">' + esc(h.dir) + '</div><div class=\"row-meta\">目录技能 ' + esc(h.skillCount || 0) + ' · 指向 Hub ' + esc(h.hubLinks || 0) + (h.brokenLinks ? ' · 异常 ' + h.brokenLinks : '') + '</div></div><div class=\"row-actions\">' + action + '</div></div>';\n }).join('') : '<div class=\"empty\">尚未发现可用的宿主技能目录。</div>';\n var rows = links.links || [];\n $('#linkEmpty').hidden = rows.length > 0;\n $('#linkRows').innerHTML = rows.map(function (l) {\n var tone = l.status === 'ok' ? 'ok' : l.status === 'broken' ? 'danger' : 'warn';\n return '<tr><td><div class=\"cell-main\">' + esc(l.slug) + '</div><div class=\"cell-sub\">' + esc(l.label || l.agent || '-') + '</div></td>' +\n '<td class=\"mono\">' + esc(l.dir) + '</td><td class=\"mono\">' + esc(l.target) + '</td>' +\n '<td><span class=\"badge ' + tone + '\">' + esc(l.status) + '</span></td>' +\n '<td><button class=\"btn small danger\" data-unlink=\"' + esc(l.target) + '\" data-dir=\"' + esc(l.dir) + '\" data-slug=\"' + esc(l.slug) + '\">解除</button></td></tr>';\n }).join('');\n }\n $('#linkTargets').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-link-plan]');\n if (!b) return;\n var dir = b.dataset.linkPlan;\n var plan = await api('/api/links/plan?dir=' + encodeURIComponent(dir));\n if (plan.error) { toast(plan.error); return; }\n var would = plan.results.filter(function (r) { return r.status === 'would-link'; });\n var converge = plan.results.filter(function (r) { return r.status === 'would-converge'; });\n var skipped = plan.results.filter(function (r) { return r.status === 'skipped'; });\n var conflicts = plan.results.filter(function (r) { return r.status === 'conflict'; });\n var convergeMoves = converge.reduce(function (n, r) { return n + ((r.moves && r.moves.length) || 0); }, 0);\n var linkable = would.length + converge.length;\n var body = '<p class=\"label\">目标:' + esc(plan.label || '') + ' · <span class=\"mono\">' + esc(dir) + '</span></p>' +\n '<div class=\"result\"><span class=\"badge ok\">将建立</span><b>' + linkable + '</b><span>个链接</span></div>' +\n (converge.length ? '<div class=\"result\"><span class=\"badge warn\">将收敛</span><b>' + convergeMoves + '</b><span>份旧副本 → 回收站(保留 7 天,可恢复):' + esc(converge.map(function (r) { return r.slug; }).join('、')) + '</span></div>' : '') +\n (skipped.length ? '<div class=\"result\"><span class=\"badge warn\">跳过</span><b>' + skipped.length + '</b><span>项(Hub 版本旧于宿主 / 版本无法解析):' + esc(skipped.map(function (r) { return r.slug; }).join('、')) + '</span></div>' : '') +\n (conflicts.length ? '<div class=\"result\"><span class=\"badge warn\">跳过冲突</span><b>' + conflicts.length + '</b><span>个同名目录 / 链接(默认不覆盖)</span></div>' : '') +\n (linkable ? '<details><summary>查看明细</summary><div class=\"variants\">' + would.concat(converge).map(function (r) { return '<div class=\"variant\"><span class=\"mono\">' + esc(r.slug) + '</span></div>'; }).join('') + '</div></details>' : '<p class=\"faint\">没有可建立的链接。</p>');\n openModal('链接预览', body, {\n okText: linkable ? '执行链接' : null,\n onOk: async function () {\n var r = await api('/api/links/apply', { body: { targetDir: dir } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var done = (r.results || []).filter(function (x) { return x.status === 'linked'; }).length;\n var moved = (r.results || []).reduce(function (n, x) { return n + ((x.moved && x.moved.length) || 0); }, 0);\n toast('已建立 ' + done + ' 个链接' + (moved ? ';收敛 ' + moved + ' 份旧副本(回收站 7 天)' : ''));\n loadLinks();\n },\n });\n });\n $('#linkRows').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-unlink]');\n if (!b) return;\n var target = b.dataset.unlink;\n var dir = b.dataset.dir;\n var slug = b.dataset.slug;\n confirmModal('解除链接 ' + slug + '?', '<p class=\"label\">只删除宿主目录中的链接,Hub 真源保留。目标不是指向 Hub 的链接时会 fail-closed 拒绝。</p><div class=\"mono\">' + esc(target) + '</div>', '解除链接', true, async function () {\n var r = await api('/api/links/remove', { body: { targetDir: dir, slugs: [slug], confirm: 'unlink' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已解除 ' + slug);\n loadLinks();\n });\n });\n\n async function loadRecords() {\n var d = await api('/api/records?limit=200');\n if (d.error) { toast(d.error); return; }\n state.records = d;\n var valid = (d.snapshots || []).filter(function (s) { return s.valid; }).length;\n $('#recordsInfo').textContent = '快照 ' + valid + ' / ' + (d.snapshots || []).length + ' · 证据 ' + (d.evidence.entries || []).length + ' · 审计 ' + (d.audit.entries || []).length;\n renderSnapshots();\n var ev = (d.evidence.entries || []).slice().reverse();\n $('#evidenceEmpty').hidden = ev.length > 0;\n $('#evidenceRows').innerHTML = ev.map(function (x) {\n var tone = x.decision === 'ok' ? 'ok' : x.decision === 'fail' ? 'danger' : 'info';\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(x.event || 'event') + ' · ' + esc(x.skill || x.slug || '-') + '</div><div class=\"row-meta\">' + esc(fmtTime(x.ts || x.at)) + (x.version ? ' · v' + esc(x.version) : '') + (x.via ? ' · via ' + esc(x.via) : '') + '</div></div><span class=\"badge ' + tone + '\">' + esc(x.decision || '记录') + '</span></div>';\n }).join('');\n var aud = (d.audit.entries || []).slice().reverse();\n $('#auditEmpty').hidden = aud.length > 0;\n $('#auditRows').innerHTML = aud.map(function (x) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(x.event || 'audit') + ' · ' + esc(x.slug || '') + '</div><div class=\"row-meta\">' + esc(fmtTime(x.at)) + (x.verdict ? ' · ' + esc(x.verdict) : '') + (x.sourceHost ? ' · ' + esc(x.sourceHost) : '') + '</div></div></div>';\n }).join('');\n }\n function renderSnapshots() {\n var d = state.records; if (!d) return;\n var rows = d.snapshots || [];\n $('#snapshotEmpty').hidden = rows.length > 0;\n $('#snapshotRows').innerHTML = rows.map(function (s) {\n var canRollback = s.valid && s.source;\n return '<tr><td><div class=\"cell-main\">' + esc(s.slug) + '</div><div class=\"cell-sub\">' + esc(s.name) + '</div></td>' +\n '<td>v' + esc(s.version || '-') + '</td><td>' + esc(fmtTime(s.createdAt || s.mtime_ms)) + '</td><td class=\"mono\">' + esc(s.source || '-') + '</td>' +\n '<td>' + (s.valid ? '<span class=\"badge ok\">可用</span>' : '<span class=\"badge danger\">不可用</span>') + (s.legacy ? ' <span class=\"badge warn\">旧格式</span>' : '') + (s.reason ? '<div class=\"cell-sub\">' + esc(s.reason) + '</div>' : '') + '</td>' +\n '<td>' + (canRollback ? '<button class=\"btn small danger\" data-rollback=\"' + esc(s.path) + '\" data-slug=\"' + esc(s.slug) + '\" data-version=\"' + esc(s.version || '') + '\" data-source=\"' + esc(s.source || '') + '\">回滚</button>' : '<span class=\"faint\">CLI 处理</span>') + '</td></tr>';\n }).join('');\n }\n document.querySelectorAll('#view-records [data-tab]').forEach(function (b) {\n b.onclick = function () {\n state.tab = b.dataset.tab;\n Array.prototype.forEach.call(document.querySelectorAll('#view-records [data-tab]'), function (x) { x.classList.toggle('primary', x === b); });\n $('#recSnapshots').hidden = state.tab !== 'snapshots';\n $('#recEvidence').hidden = state.tab !== 'evidence';\n $('#recAudit').hidden = state.tab !== 'audit';\n };\n });\n document.querySelector('#view-records [data-tab=\"snapshots\"]').classList.add('primary');\n $('#snapshotRows').addEventListener('click', function (e) {\n var b = e.target.closest('[data-rollback]');\n if (!b) return;\n var slug = b.dataset.slug, snap = b.dataset.rollback, source = b.dataset.source;\n confirmModal('回滚 ' + slug + ' 到 v' + b.dataset.version + '?', '<p class=\"label\">将用快照覆盖安装来源目录;恢复后自动体检并写入证据。</p><div class=\"mono\">' + esc(source) + '</div>', '执行回滚', true, async function () {\n var r = await api('/api/rollback/apply', { body: { slug: slug, snapshot: snap, confirm: 'rollback' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast(r.ok ? '回滚完成(doctor 通过)' : '回滚完成,但体检未通过');\n loadRecords();\n loadOverview();\n });\n });\n\n $('#routeBtn').onclick = runRoute;\n $('#routeInput').addEventListener('keydown', function (e) { if (e.key === 'Enter') runRoute(); });\n async function runRoute() {\n var request = $('#routeInput').value.trim();\n if (!request) { toast('先输入一句需求'); return; }\n var seq = showLoad('正在生成路由与编排…');\n $('#routeBtn').disabled = true;\n var d = await api('/api/route?request=' + encodeURIComponent(request));\n hideLoad(seq);\n $('#routeBtn').disabled = false;\n if (d.error) { toast(d.error); return; }\n var skills = (d.skills || []).map(function (s) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + s.order + '. ' + esc(s.slug) + ' <span class=\"badge ' + (s.installed ? 'ok' : 'warn') + '\">' + esc(s.installed ? '已装' + (s.version ? ' v' + s.version : '') : '缺失') + '</span></div><div class=\"row-meta\">' + esc(s.role || '') + '</div></div></div>';\n }).join('');\n var missing = (d.missing_skills || []).length\n ? '<div class=\"section panel\"><h3>缺失技能</h3><div class=\"toolbar\"><span class=\"mono\">' + esc((d.missing_skills || []).map(function (s) { return s.slug; }).join(', ')) + '</span><button class=\"btn small\" data-copy-cmd=\"' + esc(d.install_command || '') + '\">复制安装命令</button></div><div class=\"note\">安装与信任判断由你决定;元信装前扫描默认启用。</div></div>'\n : '';\n var others = (d.other_skill_candidates || []).length\n ? '<div class=\"section panel\"><h3>其他已装技能候选</h3>' + d.other_skill_candidates.map(function (c) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge info\">v' + esc(c.version) + '</span></div><div class=\"row-meta\">' + esc((c.matched_terms || []).join('、')) + ' · ' + esc(c.description || '') + '</div></div></div>';\n }).join('') + '<div class=\"note\">只读 frontmatter 机械匹配;使用前请自行确认。</div></div>'\n : '';\n $('#routeResult').innerHTML = '<div class=\"panel\"><div class=\"shead\"><h2>' + esc(d.playbook.name) + '</h2><span class=\"badge\">置信度 ' + esc(d.confidence) + '</span></div>' +\n '<div class=\"label\">' + esc(d.playbook.intent) + '</div><div class=\"list\">' + skills + '</div>' +\n '<div class=\"note\">动态路由:' + esc((d.dynamic && d.dynamic.status) || '未启用') + ' · 匹配词:' + esc((d.matched_keywords || []).join('、') || '无') + '</div></div>' + missing + others;\n }\n $('#routeResult').addEventListener('click', function (e) {\n var b = e.target.closest('[data-copy-cmd]');\n if (b) copyText(b.dataset.copyCmd);\n });\n\n /* 高级 CLI:/api/help 单一真源(与 CLI --help 同源) */\n function findHelpEntry(groups, ref) {\n var seg = String(ref).split(' ');\n for (var i = 0; i < groups.length; i++) {\n var cmds = groups[i].commands || [];\n for (var j = 0; j < cmds.length; j++) {\n var c = cmds[j];\n if (c.name !== seg[0]) continue;\n if (seg.length === 1) return c;\n var subs = c.subcommands || [];\n for (var k = 0; k < subs.length; k++) if (subs[k].name === seg[1]) return subs[k];\n }\n }\n return null;\n }\n function renderCliHelp(d) {\n if (!d || d.error) { $('#cliMeta').textContent = '帮助加载失败'; return; }\n var total = 0;\n (d.groups || []).forEach(function (g) {\n (g.commands || []).forEach(function (c) { total += 1 + (c.subcommands || []).length; });\n });\n $('#cliMeta').textContent = 'v' + (d.version || '-') + ' · 命令 ' + total + ' · 选项 ' + (d.options || []).length;\n $('#cliQuick').innerHTML = (d.quick || []).map(function (g) {\n return '<div class=\"cmd-group\"><h3>' + esc(g.group) + '</h3>' + (g.items || []).map(function (item) {\n var meta = findHelpEntry(d.groups, item.cmd) || {};\n return '<div class=\"cmd\"><code>' + esc(item.example) + '</code><span class=\"why\">' + esc(meta.what || '') +\n (item.note ? ' · ' + esc(item.note) : '') + '</span>' +\n (item.danger ? '<span class=\"badge danger\">危险</span>' : '') +\n '<button class=\"btn small\" data-copy-cmd=\"' + esc(item.example) + '\">复制</button></div>';\n }).join('') + '</div>';\n }).join('');\n var full = (d.groups || []).map(function (g) {\n return '<div class=\"cmd-group\"><h3>' + esc(g.group) + '</h3>' + (g.commands || []).map(function (c) {\n var opts = (c.options || []).length\n ? '<div class=\"note\">常用选项: ' + c.options.map(function (f) { return '<code>' + esc(f) + '</code>'; }).join(' ') + '</div>'\n : '';\n var subs = (c.subcommands || []).map(function (s) {\n return '<div class=\"cmd\"><code>' + esc('yotta-skills ' + s.usage) + '</code><span class=\"why\">' + esc(s.what || '') + '</span><button class=\"btn small\" data-copy-cmd=\"' + esc('yotta-skills ' + s.usage) + '\">复制</button></div>';\n }).join('');\n return '<details class=\"cmd-detail\"><summary><code>' + esc('yotta-skills ' + c.usage) + '</code> <span class=\"why\">' + esc(c.what || '') + '</span></summary>' +\n (c.when ? '<div class=\"note\">' + esc(c.when) + '</div>' : '') + opts + subs + '</details>';\n }).join('') + '</div>';\n }).join('');\n full += '<div class=\"cmd-group\"><h3>完整选项</h3>' + (d.options || []).map(function (o) {\n return '<div class=\"cmd\"><code>' + esc(o.flag + (o.arg ? ' ' + o.arg : '')) + '</code><span class=\"why\">' + esc(o.what || '') +\n (o.when ? ' · ' + esc(o.when) : '') + (o.caution ? ' · ⚠ ' + esc(o.caution) : '') + '</span></div>';\n }).join('') + '</div>';\n $('#cliFull').innerHTML = full;\n }\n api('/api/help').then(renderCliHelp);\n document.addEventListener('click', function (e) {\n var b = e.target.closest('#view-cli [data-copy-cmd]');\n if (b) copyText(b.dataset.copyCmd);\n });\n\n (async function boot() {\n var s = await api('/api/status');\n if (s && !s.error) $('#verLine').textContent = 'v' + s.version + ' · ' + (s.standard || '');\n else $('#verLine').textContent = '未连接本地服务';\n runLoad('overview');\n })();\n})();\n</script>\n</body>\n</html>\n";
69
+ const VIEW_HTML = "<!doctype html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"robots\" content=\"noindex,nofollow\">\n<meta name=\"yotta-view-token\" content=\"__YOTTA_VIEW_TOKEN__\">\n<link rel=\"icon\" href=\"data:,\">\n<title>元阁 · 技能枢纽</title>\n<style>\n :root{--bg:#0d1114;--bg2:#12171b;--panel:#171d22;--panel2:#1d242a;--line:rgba(148,190,170,.14);--line2:rgba(148,190,170,.28);\n --text:#e9efe9;--muted:#9aa8a0;--faint:#6d7b74;--acc:#5fbf8f;--acc-ink:#0d1a13;--warn:#e0b263;--danger:#e07a7a;--info:#8fb4de;\n --shadow:0 10px 28px rgba(0,0,0,.35)}\n body.light{--bg:#f2f5f1;--bg2:#e9efea;--panel:#ffffff;--panel2:#f6f9f5;--line:rgba(24,58,44,.12);--line2:rgba(24,58,44,.24);\n --text:#17211c;--muted:#5c6b63;--faint:#86958c;--acc:#2f7d59;--acc-ink:#ffffff;--warn:#9a6a14;--danger:#b23c3c;--info:#3c6ea5;\n --shadow:0 8px 22px rgba(24,58,44,.08)}\n *{box-sizing:border-box}[hidden]{display:none!important}\n html,body{margin:0;padding:0}\n body{background:var(--bg);color:var(--text);font:14px/1.6 \"Microsoft YaHei\",\"PingFang SC\",\"Noto Sans CJK SC\",system-ui,sans-serif;letter-spacing:0}\n button{font:inherit;color:inherit;cursor:pointer}\n input,textarea{font:inherit;color:var(--text);background:var(--panel2);border:1px solid var(--line2);border-radius:6px;padding:8px 11px;outline:0;width:100%}\n input:focus,textarea:focus{border-color:var(--acc)}\n textarea{min-height:76px;resize:vertical}\n code,kbd{font-family:ui-monospace,Consolas,\"Courier New\",monospace;font-size:12px}\n .sv{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}\n\n .shell{display:grid;grid-template-columns:232px minmax(0,1fr);min-height:100vh}\n .sidebar{background:var(--bg2);border-right:1px solid var(--line);padding:20px 14px;display:flex;flex-direction:column;gap:20px;position:sticky;top:0;height:100vh;min-width:0}\n .brand{display:flex;align-items:center;gap:10px;padding:0 6px}\n .brand-mark{width:34px;height:34px;border-radius:8px;background:rgba(95,191,143,.14);border:1px solid var(--line2);display:grid;place-items:center;color:var(--acc)}\n .brand-name{font-size:15px;font-weight:600}\n .brand-sub{font-size:11px;color:var(--faint)}\n .nav{display:flex;flex-direction:column;gap:3px;min-width:0}\n .nav button{display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:6px;border:1px solid transparent;background:transparent;color:var(--muted);text-align:left}\n .nav button:hover{background:var(--panel);color:var(--text)}\n .nav button.active{background:var(--panel);color:var(--acc);border-color:var(--line)}\n .side-foot{margin-top:auto;display:flex;flex-direction:column;gap:8px;min-width:0}\n .privacy{display:flex;gap:8px;align-items:flex-start;padding:10px;border:1px solid var(--line);border-radius:8px;color:var(--muted);font-size:12px;background:var(--panel)}\n .privacy .sv{color:var(--acc);margin-top:2px}\n .ghost-btn{display:flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:6px;padding:8px}\n .ghost-btn:hover{color:var(--text);border-color:var(--line2)}\n .verline{padding:0 6px;color:var(--faint);font-size:11px}\n\n .main{min-width:0;padding:24px 28px 70px}\n .topbar{display:flex;align-items:center;gap:12px;margin-bottom:20px;flex-wrap:wrap}\n .loadbar{display:flex;align-items:center;gap:9px;color:var(--muted);font-size:13px;padding:10px 13px;border:1px dashed var(--line2);border-radius:8px;margin:0 0 14px;background:var(--panel)}\n .spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--line2);border-top-color:var(--acc);animation:spin .8s linear infinite;flex:none}\n @keyframes spin{to{transform:rotate(360deg)}}\n .page-title{font-size:19px;font-weight:600;margin:0}\n .page-sub{color:var(--faint);font-size:12px;margin-top:2px}\n .spacer{flex:1}\n .chip{display:flex;align-items:center;gap:7px;padding:5px 10px;border:1px solid var(--line);border-radius:999px;color:var(--muted);font-size:12px;white-space:nowrap}\n .chips{display:flex;gap:6px;flex-wrap:wrap}\n .chip{cursor:pointer;background:transparent}\n .chip.active{color:var(--acc);border-color:rgba(95,191,143,.45);background:rgba(95,191,143,.10)}\n .dot{width:7px;height:7px;border-radius:50%;background:var(--acc)}\n\n .view{display:none}.view.active{display:block}\n .grid{display:grid;gap:12px}.g4{grid-template-columns:repeat(4,minmax(0,1fr))}.g2{grid-template-columns:repeat(2,minmax(0,1fr))}\n .panel{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:15px;min-width:0}\n .panel h3{margin:0 0 4px;font-size:14px}\n .section{margin-top:16px}\n .shead{display:flex;align-items:baseline;gap:10px;margin-bottom:9px;flex-wrap:wrap}\n .shead h2{font-size:14px;margin:0}\n .label{color:var(--muted);font-size:12px}\n .value{font-size:25px;font-weight:600;margin:7px 0 2px}\n .delta{font-size:12px;color:var(--muted)}\n .mono{font-family:ui-monospace,Consolas,monospace;font-size:12px;word-break:break-all}\n\n .toolbar{display:flex;gap:9px;align-items:center;flex-wrap:wrap}\n .search{display:flex;align-items:center;gap:8px;min-width:230px;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:7px 11px;color:var(--muted)}\n .search input{background:transparent;border:0;padding:0;width:100%}\n .btn{display:inline-flex;align-items:center;gap:7px;padding:7px 13px;border-radius:6px;border:1px solid var(--line2);background:transparent;color:var(--text);white-space:nowrap}\n .btn:hover{border-color:var(--acc)}\n .btn.primary{background:var(--acc);border-color:var(--acc);color:var(--acc-ink);font-weight:600}\n .btn.danger{border-color:rgba(224,122,122,.5);color:var(--danger)}\n .btn.small{padding:4px 9px;font-size:12px}\n .btn[disabled]{opacity:.45;cursor:not-allowed}\n .ibtn{width:29px;height:29px;display:grid;place-items:center;border:1px solid var(--line);background:transparent;border-radius:6px;color:var(--muted)}\n .ibtn:hover{color:var(--text);border-color:var(--line2)}\n\n table{width:100%;border-collapse:collapse}\n th,td{text-align:left;padding:10px 9px;border-bottom:1px solid var(--line);font-size:13px;vertical-align:top}\n th{color:var(--faint);font-weight:500;font-size:12px}\n tbody tr:hover{background:var(--panel2)}\n .cell-main{font-weight:600}\n .cell-sub{color:var(--faint);font-size:11px;word-break:break-all}\n\n .list{display:flex;flex-direction:column;gap:7px;margin-top:12px}\n .row{display:flex;align-items:center;gap:11px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel2);min-width:0}\n .row-main{flex:1;min-width:0}\n .row-title{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n .row-meta{color:var(--faint);font-size:11px;margin-top:2px;word-break:break-all}\n .row-actions{display:flex;gap:6px;flex-wrap:wrap}\n .empty{color:var(--faint);text-align:center;padding:22px;border:1px dashed var(--line);border-radius:8px;margin-top:12px}\n\n .badge{display:inline-block;font-size:11px;padding:1px 8px;border-radius:999px;border:1px solid var(--line2);color:var(--muted);background:transparent;white-space:nowrap}\n .badge.ok{color:var(--acc);border-color:rgba(95,191,143,.45);background:rgba(95,191,143,.10)}\n .badge.warn{color:var(--warn);border-color:rgba(224,178,99,.45);background:rgba(224,178,99,.10)}\n .badge.danger{color:var(--danger);border-color:rgba(224,122,122,.45);background:rgba(224,122,122,.10)}\n .badge.info{color:var(--info);border-color:rgba(143,180,222,.45);background:rgba(143,180,222,.10)}\n .dot-mark{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:var(--faint)}\n .dot-mark.ok{background:var(--acc)}.dot-mark.warn{background:var(--warn)}.dot-mark.danger{background:var(--danger)}\n\n .banner{display:flex;gap:10px;align-items:flex-start;padding:12px 14px;border:1px solid var(--line2);border-radius:8px;background:var(--panel2)}\n .banner.ok{border-color:rgba(95,191,143,.45)}\n .banner.danger{border-color:rgba(224,122,122,.45)}\n .banner .sv{margin-top:2px;color:var(--acc)}\n .banner.danger .sv{color:var(--danger)}\n .banner-title{font-weight:600}\n .banner-body{color:var(--muted);font-size:12px;margin-top:2px}\n\n .kv{display:grid;grid-template-columns:96px 1fr;gap:5px 10px;font-size:13px;margin:10px 0}\n .kv dt{color:var(--faint)}.kv dd{margin:0;word-break:break-all}\n .variants{border-top:1px dashed var(--line);margin-top:8px;padding-top:8px;display:flex;flex-direction:column;gap:5px}\n .variant{display:flex;gap:9px;align-items:baseline;font-size:12px;color:var(--muted);flex-wrap:wrap}\n .check{width:16px;height:16px;flex:none;accent-color:var(--acc)}\n details summary{cursor:pointer;color:var(--muted);font-size:12px}\n\n .actionbar{position:sticky;bottom:0;margin-top:14px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:11px 13px;border:1px solid var(--line2);border-radius:8px;background:var(--bg2);box-shadow:var(--shadow)}\n .result-list{display:flex;flex-direction:column;gap:6px;margin-top:10px}\n .result{display:flex;gap:9px;align-items:baseline;flex-wrap:wrap;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font-size:12px}\n\n .mask{position:fixed;inset:0;background:rgba(0,0,0,.52);display:none;place-items:center;z-index:50;padding:18px}\n .mask.open{display:grid}\n .modal{width:min(600px,100%);max-height:86vh;overflow:auto;background:var(--panel);border:1px solid var(--line2);border-radius:8px;padding:18px;box-shadow:var(--shadow)}\n .modal h3{margin:0 0 10px;font-size:16px}\n .modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:14px}\n\n .toasts{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);display:flex;flex-direction:column;gap:7px;align-items:center;z-index:60}\n .toast{background:var(--panel2);border:1px solid var(--line2);border-radius:999px;padding:8px 15px;box-shadow:var(--shadow);font-size:13px}\n\n .cmd-group{margin-top:14px}\n .cmd{display:flex;gap:10px;align-items:center;padding:9px 11px;border:1px solid var(--line);border-radius:6px;background:var(--panel2);margin-top:6px}\n .cmd code{flex:1;min-width:0;word-break:break-all;color:var(--text)}\n .cmd .why{color:var(--faint);font-size:11px}\n .note{color:var(--muted);font-size:12px;margin-top:8px}\n .cmd-detail{border:1px solid var(--line);border-radius:6px;background:var(--panel2);margin-top:8px;padding:0 11px;min-width:0}\n .cmd-detail summary{cursor:pointer;padding:9px 0;display:flex;gap:8px;align-items:baseline;flex-wrap:wrap}\n .cmd-detail .note{padding:0 0 8px}\n .cmd-detail .cmd{margin:0 0 8px}\n\n @media (max-width:980px){.g4{grid-template-columns:repeat(2,minmax(0,1fr))}}\n @media (max-width:760px){\n .shell{grid-template-columns:minmax(0,1fr)}\n .sidebar{position:static;height:auto;padding:13px;gap:11px}\n .nav{flex-direction:row;overflow-x:auto;width:100%;scrollbar-width:none;-webkit-overflow-scrolling:touch}\n .nav::-webkit-scrollbar{display:none}\n .nav button{white-space:nowrap;padding:7px 9px}\n .side-foot{margin-top:0;flex-direction:row;align-items:center}\n .privacy{display:none}\n .ghost-btn{flex:1;white-space:nowrap}\n .verline{white-space:nowrap;text-align:right}\n .main{padding:16px 14px 56px}\n .g4,.g2{grid-template-columns:minmax(0,1fr)}\n .search{min-width:100%}\n table{display:block;overflow-x:auto;white-space:nowrap}\n .row{flex-wrap:wrap}\n }\n</style>\n</head>\n<body>\n<div class=\"shell\">\n <aside class=\"sidebar\">\n <div class=\"brand\">\n <div class=\"brand-mark\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 3 4 7v4c0 4.4 3.4 8.4 8 10 4.6-1.6 8-5.6 8-10V7z\"/><path d=\"M9 12l2 2 4-4\"/></svg></div>\n <div><div class=\"brand-name\">元阁 · 技能枢纽</div><div class=\"brand-sub\">Hub 本地面板</div></div>\n </div>\n <nav class=\"nav\" id=\"nav\">\n <button data-view=\"overview\" class=\"active\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 10.5 12 3l9 7.5\"/><path d=\"M5 9.5V21h14V9.5\"/></svg>概览</button>\n <button data-view=\"hosts\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><rect x=\"3\" y=\"4\" width=\"18\" height=\"7\" rx=\"2\"/><rect x=\"3\" y=\"13\" width=\"18\" height=\"7\" rx=\"2\"/><path d=\"M7 7.5h.01M7 16.5h.01\"/></svg>宿主矩阵</button>\n <button data-view=\"adopt\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 3v12\"/><path d=\"m7 10 5 5 5-5\"/><path d=\"M4 21h16\"/></svg>收编向导</button>\n <button data-view=\"links\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M10 13a5 5 0 0 0 7.1 0l2-2a5 5 0 0 0-7.1-7.1L11 4.9\"/><path d=\"M14 11a5 5 0 0 0-7.1 0l-2 2A5 5 0 0 0 12 20.1l1-1\"/></svg>链接与体检</button>\n <button data-view=\"records\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/><path d=\"M12 8v5l3 2\"/></svg>记录与回滚</button>\n <button data-view=\"route\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"6\" cy=\"6\" r=\"2.5\"/><circle cx=\"18\" cy=\"18\" r=\"2.5\"/><path d=\"M8.5 6H15a3 3 0 0 1 0 6h-6a3 3 0 0 0 0 6h6.5\"/></svg>路由与编排</button>\n <button data-view=\"cli\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 7 5 5-5 5\"/><path d=\"M12 19h8\"/></svg>高级 CLI</button>\n </nav>\n <div class=\"side-foot\">\n <div class=\"privacy\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/><path d=\"m9 12 2 2 4-4\"/></svg><span>本机运行 · 零远程<br>写操作需页面令牌与确认</span></div>\n <button class=\"ghost-btn\" id=\"themeBtn\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"12\" cy=\"12\" r=\"4\"/><path d=\"M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4\"/></svg>切换主题</button>\n <div class=\"verline\" id=\"verLine\"></div>\n </div>\n </aside>\n\n <main class=\"main\">\n <div class=\"topbar\">\n <div><h1 class=\"page-title\" id=\"pageTitle\">概览</h1><div class=\"page-sub\" id=\"pageSub\">Hub 里有什么、健康吗、最近做了什么</div></div>\n <div class=\"spacer\"></div>\n <button class=\"btn\" id=\"refreshBtn\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M3 12a9 9 0 1 0 3-6.7\"/><path d=\"M3 4v5h5\"/></svg>刷新</button>\n <div class=\"chip\"><span class=\"dot\"></span>本机 · 127.0.0.1</div>\n </div>\n <div class=\"loadbar\" id=\"loadbar\" hidden><span class=\"spinner\"></span><span id=\"loadbarText\">正在加载…</span></div>\n\n <section class=\"view active\" id=\"view-overview\">\n <div class=\"grid g4\" id=\"ovCards\"></div>\n <div class=\"section\" id=\"ovDoctor\"></div>\n <div class=\"section grid g2\">\n <div class=\"panel\"><h3>Hub 内容</h3><div class=\"faint\">技能真源在 Hub 目录,宿主目录通过链接分发。</div>\n <label class=\"search section\" style=\"min-width:100%\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"ovSkillFilter\" placeholder=\"过滤技能(输入后显示全部匹配)\"></label>\n <div class=\"list\" id=\"ovSkills\"></div>\n </div>\n <div class=\"panel\"><h3>最近动作</h3><div class=\"faint\">收编 / 链接 / 解除都写入 Hub 审计。</div><div class=\"list\" id=\"ovAudit\"></div></div>\n </div>\n <div class=\"section panel\">\n <h3>Hub 位置</h3>\n <div class=\"mono\" id=\"ovHubDir\">-</div>\n <div class=\"faint\" id=\"ovHubMeta\"></div>\n <div class=\"toolbar section\">\n <button class=\"btn small\" data-goto=\"adopt\">去收编</button>\n <button class=\"btn small\" data-goto=\"links\">去链接与体检</button>\n <button class=\"btn small\" data-goto=\"records\">查看记录</button>\n <button class=\"btn small\" id=\"copyHubDir\">复制路径</button>\n <button class=\"btn small\" id=\"hubConfigBtn\">设置新位置</button>\n <button class=\"btn small\" id=\"hubConfigClearBtn\" hidden>清除覆盖</button>\n </div>\n </div>\n <div class=\"section\" id=\"ovSwitchBanner\" hidden></div>\n <div class=\"section panel\" id=\"ovMigration\" hidden>\n <h3>最近一次迁移</h3>\n <div id=\"ovMigrationBody\"></div>\n <div class=\"toolbar section\">\n <button class=\"btn small danger\" id=\"migrationRollbackBtn\">回滚到原位置</button>\n <button class=\"btn small\" id=\"copyMigrationTrash\">复制回收站路径</button>\n </div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-hosts\">\n <div class=\"toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"hostSearch\" placeholder=\"过滤宿主 / 目录\"></label>\n <div class=\"chips\" id=\"hostStateChips\"></div>\n <button class=\"btn small\" id=\"hostAddBtn\">添加自定义目录</button>\n </div>\n <div class=\"section panel\">\n <div class=\"faint\" id=\"hostSummary\"></div>\n <table><thead><tr><th>宿主</th><th>技能目录</th><th>范围</th><th>目录技能</th><th>指向 Hub</th><th>异常</th><th>状态</th><th></th></tr></thead><tbody id=\"hostRows\"></tbody></table>\n <div class=\"empty\" id=\"hostEmpty\" hidden>没有匹配的宿主目录。</div>\n </div>\n <div class=\"section panel\"><h3>已安装标记(无技能目录的宿主)</h3><div class=\"list\" id=\"hostMarks\"></div></div>\n <div class=\"section panel\" id=\"hostExcludedWrap\" hidden>\n <h3>不接管名单</h3>\n <div class=\"faint\">这些宿主 / 目录在发现、显示、链接三层全部跳过(--include-discovered 也不纳入)。</div>\n <div class=\"list\" id=\"hostExcluded\"></div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-adopt\">\n <div class=\"grid g4\" id=\"adoptCards\"></div>\n <div class=\"section toolbar\">\n <label class=\"search\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"adoptSearch\" placeholder=\"过滤技能 slug / 来源宿主\"></label>\n <button class=\"btn small\" id=\"adoptAll\">全选可收编</button>\n <button class=\"btn small\" id=\"adoptNone\">清空</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"adoptScanInfo\"></span>\n </div>\n <div class=\"list\" id=\"adoptList\"></div>\n <div class=\"empty\" id=\"adoptEmpty\" hidden>没有可收编的候选。先在宿主里安装技能,或刷新宿主矩阵。</div>\n <div class=\"actionbar\">\n <span class=\"label\">已选 <b id=\"adoptCount\">0</b> 项</span>\n <span class=\"spacer\"></span>\n <span class=\"faint\">默认复制保真、原目录保留;冲突项默认跳过;执行时逐项运行元信扫描。</span>\n <button class=\"btn primary\" id=\"adoptApply\">收编所选</button>\n </div>\n <div class=\"section panel\" id=\"adoptResultsWrap\" hidden><h3>收编结果</h3><div class=\"result-list\" id=\"adoptResults\"></div></div>\n </section>\n\n <section class=\"view\" id=\"view-links\">\n <div id=\"linkDoctor\"></div>\n <div class=\"section panel\">\n <h3>分发目标</h3>\n <div class=\"faint\">把 Hub 技能链接到宿主目录(Windows junction / POSIX symlink);默认范围 = 已核实宿主,自动发现目录需在 CLI 用 --include-discovered 显式纳入,锁 / 数据桥接目录永不链接;元技能链接时收敛旧副本(移入回收站,保留 7 天可恢复)。</div>\n <div class=\"list\" id=\"linkTargets\"></div>\n </div>\n <div class=\"section panel\">\n <h3>当前链接</h3>\n <table><thead><tr><th>技能</th><th>宿主</th><th>目标</th><th>状态</th><th></th></tr></thead><tbody id=\"linkRows\"></tbody></table>\n <div class=\"empty\" id=\"linkEmpty\" hidden>还没有任何链接。</div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-records\">\n <div class=\"toolbar\">\n <button class=\"btn small\" data-tab=\"snapshots\">回滚快照</button>\n <button class=\"btn small\" data-tab=\"evidence\">安装 / 回滚证据</button>\n <button class=\"btn small\" data-tab=\"audit\">Hub 审计</button>\n <span class=\"spacer\"></span>\n <span class=\"label\" id=\"recordsInfo\"></span>\n </div>\n <div class=\"section panel\" id=\"recSnapshots\">\n <table><thead><tr><th>技能</th><th>版本</th><th>时间</th><th>来源目录</th><th>状态</th><th></th></tr></thead><tbody id=\"snapshotRows\"></tbody></table>\n <div class=\"empty\" id=\"snapshotEmpty\" hidden>没有安装快照。快照由 install / update 写入。</div>\n </div>\n <div class=\"section panel\" id=\"recEvidence\" hidden>\n <div class=\"list\" id=\"evidenceRows\"></div>\n <div class=\"empty\" id=\"evidenceEmpty\" hidden>没有证据记录。</div>\n </div>\n <div class=\"section panel\" id=\"recAudit\" hidden>\n <div class=\"list\" id=\"auditRows\"></div>\n <div class=\"empty\" id=\"auditEmpty\" hidden>没有 Hub 审计记录。</div>\n </div>\n </section>\n\n <section class=\"view\" id=\"view-route\">\n <div class=\"panel\">\n <h3>一句话需求 → 技能组合</h3>\n <div class=\"faint\">只读调用静态 playbook(7 组)+ 动态路由块;安装建议只给命令,不在网页执行。</div>\n <div class=\"toolbar section\">\n <label class=\"search\" style=\"flex:1\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg><input id=\"routeInput\" placeholder=\"例如:帮我做发布前质量检查\"></label>\n <button class=\"btn primary\" id=\"routeBtn\">给出组合</button>\n </div>\n </div>\n <div id=\"routeResult\" class=\"section\"></div>\n </section>\n\n <section class=\"view\" id=\"view-cli\">\n <div class=\"banner\">\n <svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"m4 7 5 5-5 5\"/><path d=\"M12 19h8\"/></svg>\n <div><div class=\"banner-title\">高级 CLI(全量)</div><div class=\"banner-body\">全部命令 + 人话说明 + 可复制示例;网页只展示与复制,不代替终端执行安装、更新与删除。</div></div>\n </div>\n <div class=\"section panel\">\n <div class=\"shead\"><h2>命令速查</h2><span class=\"faint\" id=\"cliMeta\">加载中…</span></div>\n <div id=\"cliQuick\"></div>\n </div>\n <div class=\"section panel\">\n <details id=\"cliFullWrap\">\n <summary>完整命令与参数(点击展开)</summary>\n <div id=\"cliFull\"></div>\n </details>\n </div>\n </section>\n </main>\n</div>\n\n<div class=\"mask\" id=\"mask\"><div class=\"modal\" id=\"modal\"></div></div>\n<div class=\"toasts\" id=\"toasts\"></div>\n\n<script>\n(function () {\n var TOKEN = (document.querySelector('meta[name=\"yotta-view-token\"]') || {}).content || '';\n var state = { view: 'overview', overview: null, hosts: null, adopt: null, selected: {}, records: null, tab: 'snapshots', hostFilter: '', hostStateFilter: '', adoptFilter: '', ovSkillFilter: '' };\n\n function $(sel) { return document.querySelector(sel); }\n function esc(value) {\n return String(value === null || value === undefined ? '' : value)\n .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;').replace(/'/g, '&#39;');\n }\n function fmtTime(value) {\n var ms = typeof value === 'number' ? value : Date.parse(value);\n if (!ms || isNaN(ms)) return value ? String(value) : '-';\n var d = new Date(ms);\n function p(n) { return n < 10 ? '0' + n : '' + n; }\n return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());\n }\n function toast(text) {\n var el = document.createElement('div');\n el.className = 'toast';\n el.textContent = text;\n $('#toasts').appendChild(el);\n setTimeout(function () { el.remove(); }, 3200);\n }\n function copyText(text) {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n navigator.clipboard.writeText(text).then(function () { toast('已复制'); }, function () { fallbackCopy(text); });\n } else fallbackCopy(text);\n }\n function fallbackCopy(text) {\n var ta = document.createElement('textarea');\n ta.value = text; document.body.appendChild(ta); ta.select();\n try { document.execCommand('copy'); toast('已复制'); } catch (e) { toast('复制失败,请手动选择'); }\n ta.remove();\n }\n async function api(path, opts) {\n opts = opts || {};\n var init = { method: opts.method || 'GET', headers: {} };\n if (opts.body !== undefined) {\n init.method = 'POST';\n init.headers['Content-Type'] = 'application/json';\n init.headers['X-Yotta-View-Token'] = TOKEN;\n init.body = JSON.stringify(opts.body);\n }\n var res, data = null;\n try { res = await fetch(path, init); } catch (e) { return { error: '无法连接本地服务:' + e.message }; }\n try { data = await res.json(); } catch (e) { data = null; }\n if (!res.ok) return data && data.error ? data : { error: '请求失败(HTTP ' + res.status + ')' };\n if (!data) return { error: '响应不是合法 JSON' };\n return data;\n }\n\n function openModal(title, bodyHtml, opts) {\n opts = opts || {};\n var html = '<h3>' + esc(title) + '</h3><div id=\"modalBody\">' + bodyHtml + '</div>' +\n '<div class=\"modal-actions\"><button class=\"btn\" data-modal=\"cancel\">' + esc(opts.cancelText || '取消') + '</button>' +\n (opts.okText === null ? '' : '<button class=\"btn ' + (opts.danger ? 'danger' : 'primary') + '\" data-modal=\"ok\">' + esc(opts.okText || '确认') + '</button>') +\n '</div>';\n $('#modal').innerHTML = html;\n $('#mask').classList.add('open');\n var ok = $('#modal').querySelector('[data-modal=\"ok\"]');\n $('#modal').querySelector('[data-modal=\"cancel\"]').onclick = closeModal;\n if (ok && opts.onOk) {\n ok.onclick = async function () {\n ok.disabled = true;\n try { await opts.onOk(); } finally { ok.disabled = false; }\n };\n }\n }\n function closeModal() { $('#mask').classList.remove('open'); $('#modal').innerHTML = ''; }\n function confirmModal(title, bodyHtml, okText, danger, onOk) {\n openModal(title, bodyHtml, { okText: okText, danger: danger, onOk: onOk });\n }\n $('#mask').addEventListener('click', function (e) { if (e.target === $('#mask')) closeModal(); });\n document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(); });\n\n var VIEWS = {\n overview: { title: '概览', sub: 'Hub 里有什么、健康吗、最近做了什么', load: loadOverview },\n hosts: { title: '宿主矩阵', sub: '装了哪些宿主、技能目录在哪、Hub 技能链到了谁', load: loadHosts },\n adopt: { title: '收编向导', sub: '有哪些可收编、哪些是重复副本、收编后会发生什么', load: loadAdopt },\n links: { title: '链接与体检', sub: '断链 / 漂移 / 目标缺失在哪、怎么修', load: loadLinks },\n records: { title: '记录与回滚', sub: '安装、收编、链接都做了什么,怎么退回', load: loadRecords },\n route: { title: '路由与编排', sub: '一句话需求该用哪些技能、缺什么(只读)', load: null },\n cli: { title: '高级 CLI', sub: '全量命令速查与复制(与 CLI --help 同源)', load: null }\n };\n var loadSeq = 0;\n function showLoad(text) {\n loadSeq += 1;\n $('#loadbarText').textContent = text;\n $('#loadbar').hidden = false;\n $('#refreshBtn').disabled = true;\n return loadSeq;\n }\n function hideLoad(seq) {\n if (seq !== undefined && seq !== loadSeq) return;\n $('#loadbar').hidden = true;\n $('#refreshBtn').disabled = false;\n }\n async function runLoad(name) {\n var v = VIEWS[name];\n if (!v) return;\n if (!v.load) { loadSeq += 1; hideLoad(); return; }\n var seq = showLoad('正在加载' + v.title + '…');\n try { await v.load(); } finally { hideLoad(seq); }\n }\n function switchView(name) {\n if (!VIEWS[name]) return;\n state.view = name;\n Array.prototype.forEach.call(document.querySelectorAll('#nav button'), function (b) { b.classList.toggle('active', b.dataset.view === name); });\n Array.prototype.forEach.call(document.querySelectorAll('.view'), function (v) { v.classList.toggle('active', v.id === 'view-' + name); });\n $('#pageTitle').textContent = VIEWS[name].title;\n $('#pageSub').textContent = VIEWS[name].sub;\n runLoad(name);\n }\n $('#nav').addEventListener('click', function (e) {\n var b = e.target.closest('button[data-view]');\n if (b) switchView(b.dataset.view);\n });\n document.addEventListener('click', function (e) {\n var g = e.target.closest('[data-goto]');\n if (g) switchView(g.dataset.goto);\n });\n $('#refreshBtn').onclick = function () { var v = VIEWS[state.view]; if (v && v.load) runLoad(state.view); else toast('已刷新'); };\n $('#themeBtn').onclick = function () {\n document.body.classList.toggle('light');\n try { localStorage.setItem('yotta-hub-theme', document.body.classList.contains('light') ? 'light' : 'dark'); } catch (e) {}\n };\n try { if (localStorage.getItem('yotta-hub-theme') === 'light') document.body.classList.add('light'); } catch (e) {}\n\n function statCard(label, value, delta, tone) {\n return '<div class=\"panel\"><div class=\"label\">' + esc(label) + '</div><div class=\"value\">' + esc(value) + '</div><div class=\"delta ' + (tone ? 'badge ' + tone : '') + '\">' + esc(delta) + '</div></div>';\n }\n function doctorBanner(doctor) {\n if (!doctor) return '';\n if (doctor.ok) {\n return '<div class=\"banner ok\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z\"/><path d=\"m9 12 2 2 4-4\"/></svg><div><div class=\"banner-title\">体检通过</div><div class=\"banner-body\">警告 ' + doctor.summary.warnings + ' / 信息 ' + doctor.summary.info + ';结构性错误 0。</div></div></div>';\n }\n var bad = (doctor.checks || []).filter(function (c) { return !c.ok; }).slice(0, 6).map(function (c) {\n return '<div class=\"result\"><span class=\"badge ' + (c.severity === 'error' ? 'danger' : 'warn') + '\">' + esc(c.severity === 'error' ? '错误' : '警告') + '</span><span>' + esc(c.message) + '</span>' + (c.hint ? '<span class=\"faint\">修复:' + esc(c.hint) + '</span>' : '') + '</div>';\n }).join('');\n return '<div class=\"banner danger\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/><path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z\"/></svg><div style=\"flex:1\"><div class=\"banner-title\">体检未通过(错误 ' + doctor.summary.errors + ' / 警告 ' + doctor.summary.warnings + ')</div><div class=\"result-list\">' + bad + '</div></div></div>';\n }\n\n async function loadOverview() {\n var d = await api('/api/overview');\n if (d.error) { toast(d.error); return; }\n state.overview = d;\n $('#ovCards').innerHTML =\n statCard('Hub 技能', d.status.summary.skills, '外部来源 ' + d.status.summary.external, '') +\n statCard('已链接', d.status.summary.links, '宿主目录投影', 'ok') +\n statCard('异常链接', d.status.summary.brokenLinks, d.status.summary.brokenLinks ? '需要处理' : '全部正常', d.status.summary.brokenLinks ? 'danger' : 'ok') +\n statCard('Doctor', d.doctor.ok ? '通过' : '未通过', '错误 ' + d.doctor.summary.errors + ' / 警告 ' + d.doctor.summary.warnings, d.doctor.ok ? 'ok' : 'danger');\n $('#ovDoctor').innerHTML = doctorBanner(d.doctor);\n renderOverviewSkills();\n var audit = (d.audit && d.audit.recent) || [];\n $('#ovAudit').innerHTML = audit.length ? audit.slice().reverse().map(function (a) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(a.event || '动作') + ' · ' + esc(a.slug || '') + '</div><div class=\"row-meta\">' + esc(fmtTime(a.at)) + (a.verdict ? ' · ' + esc(a.verdict) : '') + '</div></div></div>';\n }).join('') : '<div class=\"empty\">还没有动作记录。</div>';\n $('#ovHubDir').textContent = d.hubDir;\n $('#copyHubDir').onclick = function () { copyText(d.hubDir); };\n var hubInfo = d.hub || {};\n var sourceNames = { flag: '--hub 参数', env: 'YOTTA_SKILLS_HUB 环境变量', config: 'config.json 配置', default: '默认位置' };\n var metaParts = ['来源: ' + (sourceNames[hubInfo.source] || hubInfo.source || '默认位置')];\n if (hubInfo.configured) metaParts.push('配置覆盖: ' + hubInfo.configured);\n if (hubInfo.restartRequired) metaParts.push('配置已指向 ' + (hubInfo.effectiveNow || '') + ',重启 view 后生效');\n $('#ovHubMeta').textContent = metaParts.join(' · ');\n $('#hubConfigClearBtn').hidden = !hubInfo.configured;\n renderMigrationBlock(d);\n }\n\n /* 0.29.5 S1/S2:迁移向导(迁移 / 仅切换)+ 最近迁移 + 一键回滚(与 CLI 同内核) */\n function renderMigrationBlock(d) {\n var mig = (d.migration && d.migration.last) || null;\n var pending = (d.migration && d.migration.switchPending) || null;\n var banner = $('#ovSwitchBanner');\n if (pending) {\n banner.hidden = false;\n banner.innerHTML = '<div class=\"banner danger\"><svg class=\"sv\" viewBox=\"0 0 24 24\"><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/><path d=\"M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z\"/></svg>' +\n '<div><div class=\"banner-title\">旧 Hub 还有 ' + esc(pending.skills) + ' 个技能未迁移</div>' +\n '<div class=\"banner-body\">旧位置 <span class=\"mono\">' + esc(pending.from) + '</span> · 当前位置 <span class=\"mono\">' + esc(d.hubDir) + '</span></div>' +\n '<div class=\"toolbar section\"><button class=\"btn small primary\" id=\"switchMigrateNow\">一键迁移到当前位置</button>' +\n '<button class=\"btn small\" id=\"switchMigrateOpen\">迁移到其它位置…</button></div></div></div>';\n $('#switchMigrateNow').onclick = function () {\n confirmModal('迁移旧 Hub 到当前位置?',\n '<p class=\"label\">将把 <span class=\"mono\">' + esc(pending.from) + '</span>(' + esc(pending.skills) + ' 个技能)复制到 <span class=\"mono\">' + esc(d.hubDir) + '</span>,逐技能校验后切换配置、重链全部宿主,旧 Hub 入回收站(7 天可回滚)。</p>',\n '开始迁移', false, async function () {\n await executeHubMigration({ hub: d.hubDir, mode: 'move' }, { from: pending.from });\n });\n };\n $('#switchMigrateOpen').onclick = function () { openHubConfigWizard(); };\n } else {\n banner.hidden = true;\n banner.innerHTML = '';\n }\n var box = $('#ovMigration');\n if (!mig) {\n box.hidden = true;\n return;\n }\n box.hidden = false;\n var kind = mig.kind === 'rollback' ? '回滚' : '迁移';\n var lines = [\n '最近一次' + kind + ':' + esc(mig.from) + ' → ' + esc(mig.to),\n '时间 ' + esc(fmtTime(mig.at)) + ' · 技能 ' + esc(mig.verifiedSkills) + ' · 重链 ' + esc(mig.relinkDirs) + ' 个宿主目录',\n ];\n if (mig.trashedTo) lines.push('回收站 ' + esc(mig.trashedTo) + '(剩余 ' + esc(mig.daysLeft) + ' 天)');\n if (mig.incomplete > 0) lines.push('⚠ 重链未全部完成(' + esc(mig.incomplete) + ' 项);旧 Hub 保留在 ' + esc(mig.oldHubKept || mig.from));\n if (mig.trashError) lines.push('⚠ 旧 Hub 移入回收站失败(已保留原位):' + esc(mig.trashError));\n $('#ovMigrationBody').innerHTML = '<div class=\"faint\">' + lines.join('<br>') + '</div>';\n $('#copyMigrationTrash').hidden = !mig.trashedTo;\n $('#copyMigrationTrash').onclick = function () { copyText(mig.trashedTo); };\n $('#migrationRollbackBtn').onclick = rollbackFlow;\n }\n\n function rewireModalButtons(opts) {\n var ok = $('#modal').querySelector('[data-modal=\"ok\"]');\n var cancel = $('#modal').querySelector('[data-modal=\"cancel\"]');\n if (ok && opts.okText) { ok.textContent = opts.okText; ok.disabled = false; ok.onclick = opts.onOk || closeModal; }\n if (ok && opts.hideOk) ok.hidden = true;\n if (cancel && opts.cancelText) { cancel.textContent = opts.cancelText; cancel.onclick = opts.onCancel || closeModal; }\n }\n\n function showMigrationResult(result) {\n var move = result.move || {};\n var done = !move.incomplete && !move.trashError;\n $('#modalBody').innerHTML =\n '<div class=\"result\"><span class=\"badge ' + (done ? 'ok' : 'danger') + '\">' + (done ? '迁移完成' : '部分完成') + '</span><span>' + esc(result.previous || '') + ' → ' + esc(result.hub || '') + '</span></div>' +\n '<div class=\"result\"><span class=\"badge ok\">校验</span><span>' + esc(move.verifiedSkills || 0) + ' 个技能复制校验通过</span></div>' +\n '<div class=\"result\"><span class=\"badge ok\">重链</span><span>' + esc(move.relinkDirs || 0) + ' 个宿主目录</span></div>' +\n (move.trashedTo ? '<div class=\"result\"><span class=\"badge ok\">旧 Hub</span><span class=\"mono\">' + esc(move.trashedTo) + '</span><span>已入回收站(7 天可恢复)</span></div>' : '') +\n (move.incomplete > 0 ? '<div class=\"result\"><span class=\"badge danger\">重链未全部完成</span><span>旧 Hub 保留在 <span class=\"mono\">' + esc(move.oldHubKept || move.from || '') + '</span>;重跑:yotta-skills hub link --all --force</span></div>' : '') +\n (move.trashError ? '<div class=\"result\"><span class=\"badge warn\">回收站</span><span>旧 Hub 移入回收站失败(已保留原位):' + esc(move.trashError) + '</span></div>' : '') +\n '<div class=\"note\">新位置从下次面板加载开始生效(已在运行的 view 需重启)。</div>';\n rewireModalButtons({\n okText: '关闭',\n onOk: closeModal,\n cancelText: '刷新面板',\n onCancel: function () { closeModal(); loadOverview(); },\n });\n }\n\n function showSwitchResult(result) {\n $('#modalBody').innerHTML =\n '<div class=\"result\"><span class=\"badge ok\">已切换</span><span>' + esc(result.previous || '') + ' → ' + esc(result.hub || '') + '</span></div>' +\n ((result.oldSkills || 0) > 0\n ? '<div class=\"result\"><span class=\"badge warn\">未迁移</span><span>旧 Hub 还有 ' + esc(result.oldSkills) + ' 个技能未迁移;概览会持续提示,可一键迁移。</span></div>'\n : '') +\n '<div class=\"note\">只切换了指针,没有移动任何文件;新位置从下次面板加载开始生效。</div>';\n rewireModalButtons({\n okText: '关闭',\n onOk: closeModal,\n cancelText: '刷新面板',\n onCancel: function () { closeModal(); loadOverview(); },\n });\n }\n\n async function executeHubMigration(body, opts) {\n opts = opts || {};\n var seq = showLoad(body.mode === 'switch' ? '正在切换 Hub 位置…' : '正在迁移 Hub(复制 → 校验 → 重链 → 回收站)…');\n var payload = Object.assign({ confirm: 'hub-config' }, body);\n if (opts.from) payload = Object.assign(payload, { from: opts.from });\n var r = await api('/api/hub/config', { body: payload });\n hideLoad(seq);\n if (r.error) {\n if (r.code === 'target-residue') return wizardResiduePrompt(body, r.error, opts);\n if (opts.inModal) {\n $('#hubWizardResult').innerHTML = '<div class=\"result\"><span class=\"badge danger\">失败</span><span>' + esc(r.error) + '</span></div>';\n } else {\n toast(r.error);\n }\n return null;\n }\n if (opts.inModal) {\n if (body.mode === 'switch') showSwitchResult(r);\n else showMigrationResult(r);\n }\n else {\n closeModal();\n toast('迁移完成:校验 ' + ((r.move && r.move.verifiedSkills) || 0) + ' 个技能 · 重链 ' + ((r.move && r.move.relinkDirs) || 0) + ' 个目录' + (r.move && r.move.incomplete ? '(重链未全部完成,见概览提示)' : ''));\n loadOverview();\n }\n return r;\n }\n\n function wizardResiduePrompt(body, error, opts) {\n var host = opts && opts.inModal ? $('#hubWizardResult') : $('#modalBody');\n if (!host) { toast(error); return; }\n host.innerHTML = '<div class=\"result\"><span class=\"badge warn\">残留</span><span>' + esc(error) + '</span></div>' +\n '<button class=\"btn small primary\" id=\"hubResidueClean\">清理残留并迁移</button>';\n $('#hubResidueClean').onclick = async function () {\n var seq = showLoad('正在清理残留并迁移…');\n var payload = Object.assign({ confirm: 'hub-config', cleanResidue: true }, body);\n if (opts && opts.from) payload.from = opts.from;\n var r = await api('/api/hub/config', { body: payload });\n hideLoad(seq);\n if (r.error) { toast(r.error); return; }\n if (opts && opts.inModal) showMigrationResult(r);\n else { closeModal(); toast('迁移完成'); loadOverview(); }\n };\n }\n\n function openHubConfigWizard() {\n var d = state.overview || {};\n var current = d.hubDir || '';\n var skillCount = (d.status && d.status.summary && d.status.summary.skills) || 0;\n var body = '<p class=\"label\">写入 config.json 持久覆盖(优先级低于 --hub 参数 / YOTTA_SKILLS_HUB)。</p>' +\n '<label class=\"label\">新的 Hub 目录</label>' +\n '<input id=\"hubNewPath\" placeholder=\"D:\\\\my-hub\" value=\"\">' +\n '<div class=\"note mono\">当前: ' + esc(current) + '(' + esc(skillCount) + ' 个技能)</div>' +\n '<div class=\"section\"><label class=\"label\"><input type=\"radio\" name=\"hubMode\" value=\"move\" checked style=\"width:auto\"> 迁移到新位置(推荐)</label>' +\n '<div class=\"faint\">复制 → 逐技能校验 → 切换配置 → 全宿主重链 → 旧 Hub 入回收站(7 天可回滚)。</div>' +\n '<label class=\"label section\"><input type=\"radio\" name=\"hubMode\" value=\"switch\" style=\"width:auto\"> 仅切换位置(保留旧内容)</label>' +\n '<div class=\"faint\">只改指针,不移动任何文件;旧 Hub 内容保留,概览会提示未迁移。</div></div>' +\n '<div id=\"hubWizardResult\" class=\"section\"></div>';\n openModal('设置 Hub 位置', body, {\n okText: '执行',\n onOk: async function () {\n var value = ($('#hubNewPath').value || '').trim();\n if (!value) { toast('先填写新的 Hub 目录'); return; }\n var mode = document.querySelector('input[name=\"hubMode\"]:checked').value;\n if (mode === 'switch' && skillCount > 0) {\n $('#hubWizardResult').innerHTML = '<div class=\"result\"><span class=\"badge warn\">确认</span><span>旧 Hub 还有 ' + esc(skillCount) + ' 个技能未迁移。仅切换位置不会移动它们。</span></div>' +\n '<button class=\"btn small danger\" id=\"hubSwitchConfirm\">确认仅切换</button>';\n $('#hubSwitchConfirm').onclick = function () { executeHubMigration({ hub: value, mode: 'switch' }, { inModal: true }); };\n return;\n }\n await executeHubMigration({ hub: value, mode: mode }, { inModal: true });\n },\n });\n }\n\n function rollbackFlow() {\n api('/api/hub/config').then(function (d) {\n if (d.error) { toast(d.error); return; }\n var rb = d.rollback || {};\n if (!rb.lastMigration) { toast('没有可回滚的迁移记录'); return; }\n if (!rb.ok) {\n confirmModal('回滚被阻断', '<p class=\"label\">' + esc((rb.blocked || []).join(';') || rb.error || '') + '</p><div class=\"note\">回滚 fail-closed(不合并);处理阻断项后重试。</div>', null, true, null);\n return;\n }\n confirmModal('回滚 Hub 位置?',\n '<p class=\"label\">将把当前 Hub 内容复制回原位置:<span class=\"mono\">' + esc(rb.current) + '</span> → <span class=\"mono\">' + esc(rb.target) + '</span>(复制校验 → 切换 → 重链 → 当前 Hub 入回收站 7 天)。</p>' +\n '<div class=\"note\">位置回退(用当前内容),不是恢复旧快照。</div>',\n '执行回滚', true, async function () {\n var seq = showLoad('正在回滚 Hub 位置(复制 → 校验 → 重链 → 回收站)…');\n var r = await api('/api/hub/config', { body: { rollback: true, confirm: 'hub-config' } });\n hideLoad(seq);\n if (r.error) {\n if (r.code === 'target-residue') { wizardResiduePrompt({ rollback: true }, r.error, {}); return; }\n toast(r.error); return;\n }\n closeModal();\n toast('回滚完成:' + ((r.move && r.move.verifiedSkills) || 0) + ' 个技能已回到 ' + (r.hub || ''));\n loadOverview();\n });\n });\n }\n\n $('#hubConfigBtn').onclick = openHubConfigWizard;\n $('#hubConfigClearBtn').onclick = function () {\n confirmModal('清除 Hub 位置覆盖?', '<p class=\"label\">将删除 config.json 中的 Hub 覆盖,恢复 --hub &gt; 环境变量 &gt; 默认位置的解析。不会移动任何文件。</p>', '清除覆盖', true, async function () {\n var r = await api('/api/hub/config', { body: { clear: true, confirm: 'hub-config' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已清除覆盖;重启 view 后生效');\n loadOverview();\n });\n };\n\n function renderOverviewSkills() {\n var d = state.overview;\n if (!d) return;\n var skills = (d.status && d.status.skills) || [];\n var filter = state.ovSkillFilter.trim().toLowerCase();\n var list = filter\n ? skills.filter(function (s) { return String(s.slug || '').toLowerCase().indexOf(filter) >= 0; })\n : skills.slice(0, 8);\n $('#ovSkills').innerHTML = list.length ? list.map(function (s) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(s.slug) + '</div><div class=\"row-meta\">v' + esc(s.version || '-') + ' · ' + esc(s.origin === 'external' ? '外部来源' : '元阁家族') + ' · 链接 ' + (s.links || []).length + '</div></div>' +\n '<span class=\"badge ' + (s.status === 'present' ? 'ok' : 'danger') + '\">' + esc(s.status === 'present' ? '已入库' : '缺失') + '</span>' +\n '<div class=\"row-actions\"><button class=\"btn small danger\" data-remove-skill=\"' + esc(s.slug) + '\">删除</button></div></div>';\n }).join('') : '<div class=\"empty\">' + (filter ? '没有匹配的技能。' : 'Hub 还是空的。先在「收编向导」把已装技能收编进来。') + '</div>';\n }\n $('#ovSkillFilter').addEventListener('input', function () {\n state.ovSkillFilter = $('#ovSkillFilter').value || '';\n renderOverviewSkills();\n });\n $('#ovSkills').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-remove-skill]');\n if (!b) return;\n var slug = b.dataset.removeSkill;\n var plan = await api('/api/skills/remove-plan?slug=' + encodeURIComponent(slug));\n if (!plan || plan.error) { toast((plan && plan.error) || '影响预览失败'); return; }\n var targets = plan.targets || [];\n var willUnlink = targets.filter(function (t) { return t.action === 'unlink'; });\n var kept = targets.filter(function (t) { return t.action === 'keep'; });\n var rows = targets.map(function (t) {\n var tone = t.action === 'unlink' ? 'warn' : t.action === 'keep' ? 'info' : '';\n return '<div class=\"result\"><span class=\"badge ' + tone + '\">' + esc(t.action === 'unlink' ? '将清理' : t.action === 'keep' ? '保留' : '无动作') + '</span><span class=\"mono\">' + esc(t.dir) + '</span><span class=\"faint\">' + esc(t.note || '') + '</span></div>';\n }).join('');\n var body = '<p class=\"label\">将清理 ' + willUnlink.length + ' 条宿主链接(含死链)' +\n (plan.hubPresent ? ',并把 Hub 目录移入回收站(保留 7 天)' : ';Hub 目录已缺失,仅清理残留') +\n ';保留 ' + kept.length + ' 项宿主真副本 / 外部链接。</p>' +\n '<div class=\"result-list\">' + (rows || '<div class=\"faint\">没有需要处理的宿主目录。</div>') + '</div>' +\n '<div class=\"section\"><label class=\"label\">输入完整技能名确认:' + esc(slug) + '</label><input id=\"removeConfirmSlug\" placeholder=\"' + esc(slug) + '\"></div>';\n confirmModal('删除 Hub 技能 ' + slug + '?', body, '删除', true, async function () {\n var typed = ($('#removeConfirmSlug') && $('#removeConfirmSlug').value || '').trim();\n if (typed !== slug) { toast('技能名不匹配'); return; }\n var r = await api('/api/skills/remove', { body: { slug: slug, confirm: 'remove', confirmSlug: typed } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast(r.verdict === 'cleanup-only' ? '已清理残留(Hub 目录原本缺失)' : '已删除 ' + slug + '(目录已入回收站)');\n loadOverview();\n });\n });\n\n async function loadHosts() {\n var d = await api('/api/hosts');\n if (d.error) { toast(d.error); return; }\n state.hosts = d;\n var s = d.summary.states || {};\n $('#hostSummary').textContent = '可用 ' + (s.available || 0) + ' · 残留 ' + (s.orphan || 0) + ' · 未创建 ' + (s.missing || 0) + ' · 仅标记 ' + (s.markerOnly || 0) + (s.ignored ? ' · 已忽略 ' + s.ignored : '') + (d.summary.excluded ? ' · 不接管 ' + d.summary.excluded : '') + ' · 链接 ' + d.summary.links + '(异常 ' + d.summary.brokenLinks + ')';\n renderHostStateChips();\n renderHosts();\n var marks = d.unpaired || [];\n $('#hostMarks').innerHTML = marks.length ? marks.map(function (m) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(m.label) + '</div><div class=\"row-meta\">' + esc(m.source || '') + ' · 未发现技能目录</div></div><span class=\"badge info\">仅标记</span></div>';\n }).join('') : '<div class=\"empty\">没有未配对的宿主标记。</div>';\n renderHostExcluded(d.excluded || []);\n }\n function renderHostExcluded(list) {\n $('#hostExcludedWrap').hidden = list.length === 0;\n $('#hostExcluded').innerHTML = list.map(function (item) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(item.label || item.value) + '</div><div class=\"row-meta mono\">' + esc(item.value) + ' · ' + esc(item.kind === 'agent' ? '智能体' : '目录') + '</div></div><div class=\"row-actions\"><button class=\"btn small\" data-host-include=\"' + esc(item.value) + '\">恢复接管</button></div></div>';\n }).join('');\n }\n function renderHostStateChips() {\n var d = state.hosts; if (!d) return;\n var s = d.summary.states || {};\n var chips = [\n ['', '全部'],\n ['available', '可用 ' + (s.available || 0)],\n ['orphan', '残留 ' + (s.orphan || 0)],\n ['missing', '未创建 ' + (s.missing || 0)],\n ['marker-only', '仅标记 ' + (s.markerOnly || 0)],\n ];\n $('#hostStateChips').innerHTML = chips.map(function (c) {\n return '<button class=\"chip' + (state.hostStateFilter === c[0] ? ' active' : '') + '\" data-host-state=\"' + esc(c[0]) + '\">' + esc(c[1]) + '</button>';\n }).join('');\n }\n function hostStateBadge(h) {\n if (h.bridgeOnly) return '<span class=\"badge info\">桥接</span>';\n var map = { available: ['ok', '可用'], orphan: ['warn', '残留'], missing: ['', '未创建'], 'marker-only': ['info', '仅标记'], ignored: ['', '已忽略'] };\n var item = map[h.state] || ['', h.state || '未知'];\n return '<span class=\"badge ' + item[0] + '\">' + esc(item[1]) + (h.stateSource === 'manual' ? '·手动' : '') + '</span>' + (h.brokenLinks ? ' <span class=\"badge danger\">异常</span>' : '');\n }\n function renderHosts() {\n var d = state.hosts; if (!d) return;\n var q = state.hostFilter.toLowerCase();\n var sf = state.hostStateFilter;\n var rows = (d.hosts || []).filter(function (h) {\n if (sf && h.state !== sf) return false;\n if (!q) return true;\n return String(h.label || '').toLowerCase().includes(q) || String(h.dir || '').toLowerCase().includes(q);\n });\n $('#hostEmpty').hidden = rows.length > 0;\n var overrideIds = {};\n ((d.overrides) || []).forEach(function (o) { overrideIds[String(o.agentId).toLowerCase()] = true; });\n $('#hostRows').innerHTML = rows.map(function (h) {\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链</span>'\n : (h.detection === 'user-registry' ? '<span class=\"badge info\">用户注册</span>'\n : (h.detection === 'override' ? '<span class=\"badge ok\">目录覆盖</span>'\n : (h.verified ? '<span class=\"badge ok\">已核实</span>'\n : ((h.detection === 'discovered' || h.detection === 'workspace') ? '<span class=\"badge warn\">自动发现</span>' : '<span class=\"badge warn\">映射·未核实</span>'))));\n var action = '';\n if (h.exists && h.state === 'orphan' && !h.bridgeOnly) action += '<button class=\"btn small danger\" data-host-purge=\"' + esc(h.dir) + '\">清理</button> ';\n if (h.state === 'ignored') action += '<button class=\"btn small\" data-host-mark=\"' + esc(h.dir) + '\" data-mark-state=\"available\">标记可用</button> ';\n else if (h.exists && !h.bridgeOnly) action += '<button class=\"btn small\" data-host-mark=\"' + esc(h.dir) + '\" data-mark-state=\"ignored\">忽略</button> ';\n if (h.agentId && !h.bridgeOnly) {\n if (overrideIds[String(h.agentId).toLowerCase()]) action += '<button class=\"btn small\" data-host-clear=\"' + esc(h.agentId) + '\">恢复默认</button> ';\n else action += '<button class=\"btn small\" data-host-edit=\"' + esc(h.agentId) + '\" data-host-dir=\"' + esc(h.dir) + '\" data-host-label=\"' + esc(h.label) + '\">编辑目录</button> ';\n }\n if (!h.bridgeOnly) action += '<button class=\"btn small\" data-host-exclude=\"' + esc(h.agentId || h.dir) + '\">不接管</button> ';\n if (h.detection === 'user-registry') action += '<button class=\"btn small\" data-host-unregister=\"' + esc(h.dir) + '\">移除注册</button>';\n return '<tr><td><div class=\"cell-main\">' + esc(h.label) + '</div><div class=\"cell-sub\">' + esc(h.agentId || (h.known ? '已知宿主' : h.detection || '自动发现')) + '</div></td>' +\n '<td><div class=\"mono\">' + esc(h.dir) + '</div><button class=\"btn small\" data-copy=\"' + esc(h.dir) + '\">复制</button></td>' +\n '<td>' + scopeBadge + '</td>' +\n '<td>' + esc(h.skillCount || 0) + '</td><td>' + esc(h.hubLinks || 0) + '</td><td>' + esc(h.brokenLinks || 0) + '</td><td>' + hostStateBadge(h) + '</td><td>' + action + '</td></tr>';\n }).join('');\n }\n $('#hostSearch').addEventListener('input', function (e) { state.hostFilter = e.target.value; renderHosts(); });\n $('#hostStateChips').addEventListener('click', function (e) {\n var b = e.target.closest('[data-host-state]');\n if (!b) return;\n state.hostStateFilter = b.dataset.hostState;\n renderHostStateChips();\n renderHosts();\n });\n $('#hostAddBtn').addEventListener('click', function () {\n var body = '<p class=\"label\">注册自定义宿主目录:纳入发现 / 分发 / 收编。不创建目录、不改宿主配置。</p>' +\n '<div class=\"section\"><label class=\"label\">目录(必须已存在)</label><input id=\"hostAddDir\" placeholder=\"例如 D:\\\\my-agent\\\\skills\"></div>' +\n '<div class=\"section\"><label class=\"label\">名称(可选,默认目录名)</label><input id=\"hostAddLabel\" placeholder=\"我的宿主\"></div>';\n openModal('添加自定义目录', body, {\n okText: '注册',\n onOk: async function () {\n var dir = ($('#hostAddDir') && $('#hostAddDir').value || '').trim();\n var label = ($('#hostAddLabel') && $('#hostAddLabel').value || '').trim();\n if (!dir) { toast('请填写目录'); return; }\n var r = await api('/api/hosts/add', { body: { dir: dir, label: label } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已注册:' + ((r.entry && r.entry.label) || dir));\n loadHosts();\n },\n });\n });\n async function openHostPurgeModal(dir) {\n var plan = await api('/api/hosts/purge-plan?dir=' + encodeURIComponent(dir));\n if (plan.error) { toast(plan.error); return; }\n var body = '<p class=\"label\">只删除指向 Hub 的链接(含死链);非 Hub 链接 / 非技能内容随目录保留在回收站(7 天可恢复)。</p>' +\n '<div class=\"result\"><span class=\"badge warn\">将解除链接</span><b>' + esc(plan.unlinkCount) + '</b><span>条(保留 ' + esc(plan.keptLinks) + ' 非 Hub 链接 / ' + esc(plan.keptEntries) + ' 非链接内容)</span></div>' +\n '<div class=\"mono\">' + esc(dir) + '</div>' +\n '<div class=\"section\"><label class=\"label\">输入 purge 确认执行:</label><input id=\"hostPurgeConfirm\" placeholder=\"purge\"></div>';\n confirmModal('清理残留目录?', body, '清理(移入回收站)', true, async function () {\n var typed = ($('#hostPurgeConfirm') && $('#hostPurgeConfirm').value || '').trim();\n if (typed !== 'purge') { toast('确认串不匹配(输入 purge)'); return; }\n var r = await api('/api/hosts/purge', { body: { dir: dir, confirm: 'purge' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已清理:解除 ' + r.unlinkCount + ' 条链接;回收站保留 7 天');\n loadHosts();\n });\n }\n function openHostEditModal(agentId, dir, label) {\n var body = '<p class=\"label\">覆盖「' + esc(label) + '」的技能目录(写入 hosts.json,不改宿主配置)。被覆盖的旧目录不再进入默认发现 / 链接。</p>' +\n '<div class=\"section\"><label class=\"label\">新目录(必须已存在)</label><input id=\"hostEditDir\" value=\"' + esc(dir) + '\"></div>';\n openModal('编辑目录 · ' + esc(agentId), body, {\n okText: '保存覆盖',\n onOk: async function () {\n var value = ($('#hostEditDir') && $('#hostEditDir').value || '').trim();\n if (!value) { toast('请填写目录'); return; }\n var r = await api('/api/hosts/set', { body: { agentId: agentId, dir: value } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已覆盖目录');\n loadHosts();\n },\n });\n }\n $('#hostRows').addEventListener('click', async function (e) {\n var copyBtn = e.target.closest('[data-copy]');\n if (copyBtn) { copyText(copyBtn.dataset.copy); return; }\n var purgeBtn = e.target.closest('[data-host-purge]');\n if (purgeBtn) { openHostPurgeModal(purgeBtn.dataset.hostPurge); return; }\n var editBtn = e.target.closest('[data-host-edit]');\n if (editBtn) {\n openHostEditModal(editBtn.dataset.hostEdit, editBtn.dataset.hostDir, editBtn.dataset.hostLabel);\n return;\n }\n var clearBtn = e.target.closest('[data-host-clear]');\n if (clearBtn) {\n var clearAgent = clearBtn.dataset.hostClear;\n confirmModal('恢复默认目录?', '<p class=\"label\">删除目录覆盖,恢复内置映射。</p><div class=\"mono\">' + esc(clearAgent) + '</div>', '恢复默认', false, async function () {\n var r = await api('/api/hosts/set', { body: { agentId: clearAgent, clear: true } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已恢复内置映射');\n loadHosts();\n });\n return;\n }\n var excludeBtn = e.target.closest('[data-host-exclude]');\n if (excludeBtn) {\n var excludeTarget = excludeBtn.dataset.hostExclude;\n confirmModal('加入「不接管」名单?', '<p class=\"label\">该宿主 / 目录将从发现、显示、链接三层全部跳过;已建立的链接不受影响(可用 CLI hub unlink 清理)。</p><div class=\"mono\">' + esc(excludeTarget) + '</div>', '不接管', false, async function () {\n var r = await api('/api/hosts/exclude', { body: { target: excludeTarget } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已加入不接管名单');\n loadHosts();\n });\n return;\n }\n var markBtn = e.target.closest('[data-host-mark]');\n if (markBtn) {\n var r = await api('/api/hosts/mark', { body: { dir: markBtn.dataset.hostMark, state: markBtn.dataset.markState } });\n if (r.error) { toast(r.error); return; }\n toast('已标记:' + markBtn.dataset.markState);\n loadHosts();\n return;\n }\n var unregBtn = e.target.closest('[data-host-unregister]');\n if (unregBtn) {\n var dir = unregBtn.dataset.hostUnregister;\n confirmModal('移除注册?', '<p class=\"label\">只移除自定义宿主注册,不删除目录、不改宿主配置。</p><div class=\"mono\">' + esc(dir) + '</div>', '移除注册', false, async function () {\n var r = await api('/api/hosts/remove', { body: { dir: dir } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已移除注册(目录保留)');\n loadHosts();\n });\n }\n });\n $('#hostExcluded').addEventListener('click', async function (e) {\n var includeBtn = e.target.closest('[data-host-include]');\n if (!includeBtn) return;\n var r = await api('/api/hosts/include', { body: { target: includeBtn.dataset.hostInclude } });\n if (r.error) { toast(r.error); return; }\n toast('已恢复接管');\n loadHosts();\n });\n\n async function loadAdopt() {\n var d = await api('/api/adopt/scan');\n if (d.error) { toast(d.error); return; }\n state.adopt = d;\n state.selected = {};\n $('#adoptCards').innerHTML =\n statCard('候选技能', d.summary.candidates, '已装宿主里的技能', '') +\n statCard('多副本冲突', d.summary.conflicts, '版本 / 哈希不一致', d.summary.conflicts ? 'warn' : 'ok') +\n statCard('已在 Hub', d.summary.alreadyInHub, '默认跳过', 'info') +\n statCard('扫描引擎', d.scan && d.scan.available ? '可用' : '未找到', d.scan && d.scan.available ? '收编时逐项扫描' : '可显式降级(CLI)', d.scan && d.scan.available ? 'ok' : 'warn');\n $('#adoptScanInfo').textContent = '生成于 ' + fmtTime(d.generatedAt);\n renderAdopt();\n }\n function renderAdopt() {\n var d = state.adopt; if (!d) return;\n var q = state.adoptFilter.toLowerCase();\n var candidates = (d.candidates || []).filter(function (c) {\n if (!q) return true;\n return String(c.slug || '').toLowerCase().includes(q) || String(c.sourceHost || '').toLowerCase().includes(q);\n });\n $('#adoptEmpty').hidden = candidates.length > 0;\n $('#adoptList').innerHTML = candidates.map(function (c) {\n var variants = (c.variants || []).map(function (v) {\n return '<div class=\"variant\"><span class=\"badge\">' + esc(v.host) + '</span><span class=\"mono\">' + esc(v.dir) + '</span><span>v' + esc(v.version || '-') + '</span></div>';\n }).join('');\n return '<div class=\"row\"><input class=\"check\" type=\"checkbox\" data-slug=\"' + esc(c.slug) + '\"' + (state.selected[c.slug] ? ' checked' : '') + (c.inHub ? ' disabled' : '') + '>' +\n '<div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge\">v' + esc(c.version || '-') + '</span>' +\n (c.inHub ? ' <span class=\"badge info\">已在 Hub</span>' : '') +\n (c.conflict ? ' <span class=\"badge warn\">多副本冲突</span>' : '') + '</div>' +\n '<div class=\"row-meta\">来源:' + esc(c.sourceHost || '-') + ' · ' + esc(c.source || '') + '</div>' +\n (c.variants && c.variants.length > 1 ? '<details><summary>' + c.variants.length + ' 个副本</summary><div class=\"variants\">' + variants + '</div></details>' : '') +\n '</div></div>';\n }).join('');\n updateAdoptCount();\n }\n function updateAdoptCount() {\n var n = Object.keys(state.selected).filter(function (k) { return state.selected[k]; }).length;\n $('#adoptCount').textContent = String(n);\n $('#adoptApply').disabled = n === 0;\n }\n $('#adoptSearch').addEventListener('input', function (e) { state.adoptFilter = e.target.value; renderAdopt(); });\n $('#adoptList').addEventListener('change', function (e) {\n var box = e.target.closest('input[data-slug]');\n if (!box) return;\n if (box.checked) state.selected[box.dataset.slug] = true; else delete state.selected[box.dataset.slug];\n updateAdoptCount();\n });\n $('#adoptAll').onclick = function () {\n ((state.adopt && state.adopt.candidates) || []).forEach(function (c) { if (!c.inHub) state.selected[c.slug] = true; });\n renderAdopt();\n };\n $('#adoptNone').onclick = function () { state.selected = {}; renderAdopt(); };\n $('#adoptApply').onclick = function () {\n var slugs = Object.keys(state.selected).filter(function (k) { return state.selected[k]; });\n if (!slugs.length) { toast('还没有选择技能'); return; }\n var preview = slugs.slice(0, 24).join('、') + (slugs.length > 24 ? ' 等 ' + slugs.length + ' 项' : '');\n confirmModal('收编 ' + slugs.length + ' 个技能到 Hub?', '<p class=\"label\">将复制到 Hub(原目录保留);已在 Hub 或冲突项按默认策略处理;执行时逐项运行元信扫描,high / critical 会阻断。</p><div class=\"mono\">' + esc(preview) + '</div>', '开始收编', false, async function () {\n var r = await api('/api/adopt/apply', { body: { include: slugs } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var order = { imported: 0, blocked: 1, conflict: 2, skip: 3, error: 4 };\n var results = (r.results || []).slice().sort(function (a, b) { return (order[a.status] || 9) - (order[b.status] || 9); });\n $('#adoptResultsWrap').hidden = false;\n $('#adoptResults').innerHTML = results.map(function (x) {\n var tone = x.status === 'imported' ? 'ok' : x.status === 'error' || x.status === 'blocked' ? 'danger' : 'warn';\n return '<div class=\"result\"><span class=\"badge ' + tone + '\">' + esc(x.status) + '</span><b>' + esc(x.slug) + '</b><span>' + esc(x.note || '') + '</span>' + (x.scanVerdict ? '<span class=\"faint\">verdict: ' + esc(x.scanVerdict) + '</span>' : '') + '</div>';\n }).join('');\n toast('收编完成:导入 ' + (r.summary ? r.summary.imported : 0) + ' / 阻断 ' + (r.summary ? r.summary.blocked : 0));\n loadOverview();\n loadAdopt();\n });\n };\n\n async function loadLinks() {\n var results = await Promise.all([api('/api/doctor'), api('/api/links'), api('/api/hosts')]);\n var doctor = results[0], links = results[1], hosts = results[2];\n if (doctor.error || links.error || hosts.error) { toast((doctor.error || links.error || hosts.error)); return; }\n $('#linkDoctor').innerHTML = doctorBanner(doctor);\n var existing = (hosts.hosts || []).filter(function (h) { return h.exists; });\n $('#linkTargets').innerHTML = existing.length ? existing.map(function (h) {\n var scopeBadge = h.bridgeOnly ? '<span class=\"badge info\">桥接·不链接</span>' : h.verified ? '<span class=\"badge ok\">已核实</span>' : '<span class=\"badge warn\">自动发现</span>';\n var action = h.bridgeOnly ? '' : '<button class=\"btn small\" data-link-plan=\"' + esc(h.dir) + '\" data-label=\"' + esc(h.label) + '\">预览链接</button>';\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(h.label) + ' ' + scopeBadge + '</div><div class=\"row-meta mono\">' + esc(h.dir) + '</div><div class=\"row-meta\">目录技能 ' + esc(h.skillCount || 0) + ' · 指向 Hub ' + esc(h.hubLinks || 0) + (h.brokenLinks ? ' · 异常 ' + h.brokenLinks : '') + '</div></div><div class=\"row-actions\">' + action + '</div></div>';\n }).join('') : '<div class=\"empty\">尚未发现可用的宿主技能目录。</div>';\n var rows = links.links || [];\n $('#linkEmpty').hidden = rows.length > 0;\n $('#linkRows').innerHTML = rows.map(function (l) {\n var tone = l.status === 'ok' ? 'ok' : l.status === 'broken' ? 'danger' : 'warn';\n return '<tr><td><div class=\"cell-main\">' + esc(l.slug) + '</div><div class=\"cell-sub\">' + esc(l.label || l.agent || '-') + '</div></td>' +\n '<td class=\"mono\">' + esc(l.dir) + '</td><td class=\"mono\">' + esc(l.target) + '</td>' +\n '<td><span class=\"badge ' + tone + '\">' + esc(l.status) + '</span></td>' +\n '<td><button class=\"btn small danger\" data-unlink=\"' + esc(l.target) + '\" data-dir=\"' + esc(l.dir) + '\" data-slug=\"' + esc(l.slug) + '\">解除</button></td></tr>';\n }).join('');\n }\n $('#linkTargets').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-link-plan]');\n if (!b) return;\n var dir = b.dataset.linkPlan;\n var plan = await api('/api/links/plan?dir=' + encodeURIComponent(dir));\n if (plan.error) { toast(plan.error); return; }\n var would = plan.results.filter(function (r) { return r.status === 'would-link'; });\n var converge = plan.results.filter(function (r) { return r.status === 'would-converge'; });\n var skipped = plan.results.filter(function (r) { return r.status === 'skipped'; });\n var conflicts = plan.results.filter(function (r) { return r.status === 'conflict'; });\n var convergeMoves = converge.reduce(function (n, r) { return n + ((r.moves && r.moves.length) || 0); }, 0);\n var linkable = would.length + converge.length;\n var body = '<p class=\"label\">目标:' + esc(plan.label || '') + ' · <span class=\"mono\">' + esc(dir) + '</span></p>' +\n '<div class=\"result\"><span class=\"badge ok\">将建立</span><b>' + linkable + '</b><span>个链接</span></div>' +\n (converge.length ? '<div class=\"result\"><span class=\"badge warn\">将收敛</span><b>' + convergeMoves + '</b><span>份旧副本 → 回收站(保留 7 天,可恢复):' + esc(converge.map(function (r) { return r.slug; }).join('、')) + '</span></div>' : '') +\n (skipped.length ? '<div class=\"result\"><span class=\"badge warn\">跳过</span><b>' + skipped.length + '</b><span>项(Hub 版本旧于宿主 / 版本无法解析):' + esc(skipped.map(function (r) { return r.slug; }).join('、')) + '</span></div>' : '') +\n (conflicts.length ? '<div class=\"result\"><span class=\"badge warn\">跳过冲突</span><b>' + conflicts.length + '</b><span>个同名目录 / 链接(默认不覆盖)</span></div>' : '') +\n (linkable ? '<details><summary>查看明细</summary><div class=\"variants\">' + would.concat(converge).map(function (r) { return '<div class=\"variant\"><span class=\"mono\">' + esc(r.slug) + '</span></div>'; }).join('') + '</div></details>' : '<p class=\"faint\">没有可建立的链接。</p>');\n openModal('链接预览', body, {\n okText: linkable ? '执行链接' : null,\n onOk: async function () {\n var r = await api('/api/links/apply', { body: { targetDir: dir } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n var done = (r.results || []).filter(function (x) { return x.status === 'linked'; }).length;\n var moved = (r.results || []).reduce(function (n, x) { return n + ((x.moved && x.moved.length) || 0); }, 0);\n toast('已建立 ' + done + ' 个链接' + (moved ? ';收敛 ' + moved + ' 份旧副本(回收站 7 天)' : ''));\n loadLinks();\n },\n });\n });\n $('#linkRows').addEventListener('click', async function (e) {\n var b = e.target.closest('[data-unlink]');\n if (!b) return;\n var target = b.dataset.unlink;\n var dir = b.dataset.dir;\n var slug = b.dataset.slug;\n confirmModal('解除链接 ' + slug + '?', '<p class=\"label\">只删除宿主目录中的链接,Hub 真源保留。目标不是指向 Hub 的链接时会 fail-closed 拒绝。</p><div class=\"mono\">' + esc(target) + '</div>', '解除链接', true, async function () {\n var r = await api('/api/links/remove', { body: { targetDir: dir, slugs: [slug], confirm: 'unlink' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast('已解除 ' + slug);\n loadLinks();\n });\n });\n\n async function loadRecords() {\n var d = await api('/api/records?limit=200');\n if (d.error) { toast(d.error); return; }\n state.records = d;\n var valid = (d.snapshots || []).filter(function (s) { return s.valid; }).length;\n $('#recordsInfo').textContent = '快照 ' + valid + ' / ' + (d.snapshots || []).length + ' · 证据 ' + (d.evidence.entries || []).length + ' · 审计 ' + (d.audit.entries || []).length;\n renderSnapshots();\n var ev = (d.evidence.entries || []).slice().reverse();\n $('#evidenceEmpty').hidden = ev.length > 0;\n $('#evidenceRows').innerHTML = ev.map(function (x) {\n var tone = x.decision === 'ok' ? 'ok' : x.decision === 'fail' ? 'danger' : 'info';\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(x.event || 'event') + ' · ' + esc(x.skill || x.slug || '-') + '</div><div class=\"row-meta\">' + esc(fmtTime(x.ts || x.at)) + (x.version ? ' · v' + esc(x.version) : '') + (x.via ? ' · via ' + esc(x.via) : '') + '</div></div><span class=\"badge ' + tone + '\">' + esc(x.decision || '记录') + '</span></div>';\n }).join('');\n var aud = (d.audit.entries || []).slice().reverse();\n $('#auditEmpty').hidden = aud.length > 0;\n $('#auditRows').innerHTML = aud.map(function (x) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(x.event || 'audit') + ' · ' + esc(x.slug || '') + '</div><div class=\"row-meta\">' + esc(fmtTime(x.at)) + (x.verdict ? ' · ' + esc(x.verdict) : '') + (x.sourceHost ? ' · ' + esc(x.sourceHost) : '') + '</div></div></div>';\n }).join('');\n }\n function renderSnapshots() {\n var d = state.records; if (!d) return;\n var rows = d.snapshots || [];\n $('#snapshotEmpty').hidden = rows.length > 0;\n $('#snapshotRows').innerHTML = rows.map(function (s) {\n var canRollback = s.valid && s.source;\n return '<tr><td><div class=\"cell-main\">' + esc(s.slug) + '</div><div class=\"cell-sub\">' + esc(s.name) + '</div></td>' +\n '<td>v' + esc(s.version || '-') + '</td><td>' + esc(fmtTime(s.createdAt || s.mtime_ms)) + '</td><td class=\"mono\">' + esc(s.source || '-') + '</td>' +\n '<td>' + (s.valid ? '<span class=\"badge ok\">可用</span>' : '<span class=\"badge danger\">不可用</span>') + (s.legacy ? ' <span class=\"badge warn\">旧格式</span>' : '') + (s.reason ? '<div class=\"cell-sub\">' + esc(s.reason) + '</div>' : '') + '</td>' +\n '<td>' + (canRollback ? '<button class=\"btn small danger\" data-rollback=\"' + esc(s.path) + '\" data-slug=\"' + esc(s.slug) + '\" data-version=\"' + esc(s.version || '') + '\" data-source=\"' + esc(s.source || '') + '\">回滚</button>' : '<span class=\"faint\">CLI 处理</span>') + '</td></tr>';\n }).join('');\n }\n document.querySelectorAll('#view-records [data-tab]').forEach(function (b) {\n b.onclick = function () {\n state.tab = b.dataset.tab;\n Array.prototype.forEach.call(document.querySelectorAll('#view-records [data-tab]'), function (x) { x.classList.toggle('primary', x === b); });\n $('#recSnapshots').hidden = state.tab !== 'snapshots';\n $('#recEvidence').hidden = state.tab !== 'evidence';\n $('#recAudit').hidden = state.tab !== 'audit';\n };\n });\n document.querySelector('#view-records [data-tab=\"snapshots\"]').classList.add('primary');\n $('#snapshotRows').addEventListener('click', function (e) {\n var b = e.target.closest('[data-rollback]');\n if (!b) return;\n var slug = b.dataset.slug, snap = b.dataset.rollback, source = b.dataset.source;\n confirmModal('回滚 ' + slug + ' 到 v' + b.dataset.version + '?', '<p class=\"label\">将用快照覆盖安装来源目录;恢复后自动体检并写入证据。</p><div class=\"mono\">' + esc(source) + '</div>', '执行回滚', true, async function () {\n var r = await api('/api/rollback/apply', { body: { slug: slug, snapshot: snap, confirm: 'rollback' } });\n if (r.error) { toast(r.error); return; }\n closeModal();\n toast(r.ok ? '回滚完成(doctor 通过)' : '回滚完成,但体检未通过');\n loadRecords();\n loadOverview();\n });\n });\n\n $('#routeBtn').onclick = runRoute;\n $('#routeInput').addEventListener('keydown', function (e) { if (e.key === 'Enter') runRoute(); });\n async function runRoute() {\n var request = $('#routeInput').value.trim();\n if (!request) { toast('先输入一句需求'); return; }\n var seq = showLoad('正在生成路由与编排…');\n $('#routeBtn').disabled = true;\n var d = await api('/api/route?request=' + encodeURIComponent(request));\n hideLoad(seq);\n $('#routeBtn').disabled = false;\n if (d.error) { toast(d.error); return; }\n var skills = (d.skills || []).map(function (s) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + s.order + '. ' + esc(s.slug) + ' <span class=\"badge ' + (s.installed ? 'ok' : 'warn') + '\">' + esc(s.installed ? '已装' + (s.version ? ' v' + s.version : '') : '缺失') + '</span></div><div class=\"row-meta\">' + esc(s.role || '') + '</div></div></div>';\n }).join('');\n var missing = (d.missing_skills || []).length\n ? '<div class=\"section panel\"><h3>缺失技能</h3><div class=\"toolbar\"><span class=\"mono\">' + esc((d.missing_skills || []).map(function (s) { return s.slug; }).join(', ')) + '</span><button class=\"btn small\" data-copy-cmd=\"' + esc(d.install_command || '') + '\">复制安装命令</button></div><div class=\"note\">安装与信任判断由你决定;元信装前扫描默认启用。</div></div>'\n : '';\n var others = (d.other_skill_candidates || []).length\n ? '<div class=\"section panel\"><h3>其他已装技能候选</h3>' + d.other_skill_candidates.map(function (c) {\n return '<div class=\"row\"><div class=\"row-main\"><div class=\"row-title\">' + esc(c.slug) + ' <span class=\"badge info\">v' + esc(c.version) + '</span></div><div class=\"row-meta\">' + esc((c.matched_terms || []).join('、')) + ' · ' + esc(c.description || '') + '</div></div></div>';\n }).join('') + '<div class=\"note\">只读 frontmatter 机械匹配;使用前请自行确认。</div></div>'\n : '';\n $('#routeResult').innerHTML = '<div class=\"panel\"><div class=\"shead\"><h2>' + esc(d.playbook.name) + '</h2><span class=\"badge\">置信度 ' + esc(d.confidence) + '</span></div>' +\n '<div class=\"label\">' + esc(d.playbook.intent) + '</div><div class=\"list\">' + skills + '</div>' +\n '<div class=\"note\">动态路由:' + esc((d.dynamic && d.dynamic.status) || '未启用') + ' · 匹配词:' + esc((d.matched_keywords || []).join('、') || '无') + '</div></div>' + missing + others;\n }\n $('#routeResult').addEventListener('click', function (e) {\n var b = e.target.closest('[data-copy-cmd]');\n if (b) copyText(b.dataset.copyCmd);\n });\n\n /* 高级 CLI:/api/help 单一真源(与 CLI --help 同源) */\n function findHelpEntry(groups, ref) {\n var seg = String(ref).split(' ');\n for (var i = 0; i < groups.length; i++) {\n var cmds = groups[i].commands || [];\n for (var j = 0; j < cmds.length; j++) {\n var c = cmds[j];\n if (c.name !== seg[0]) continue;\n if (seg.length === 1) return c;\n var subs = c.subcommands || [];\n for (var k = 0; k < subs.length; k++) if (subs[k].name === seg[1]) return subs[k];\n }\n }\n return null;\n }\n function renderCliHelp(d) {\n if (!d || d.error) { $('#cliMeta').textContent = '帮助加载失败'; return; }\n var total = 0, optTotal = (d.options || []).length;\n (d.groups || []).forEach(function (g) {\n (g.commands || []).forEach(function (c) {\n total += 1 + (c.subcommands || []).length;\n optTotal += (c.options || []).length;\n (c.subcommands || []).forEach(function (s) { optTotal += (s.options || []).length; });\n });\n });\n $('#cliMeta').textContent = 'v' + (d.version || '-') + ' · 命令 ' + total + ' · 选项 ' + optTotal;\n $('#cliQuick').innerHTML = (d.quick || []).map(function (g) {\n return '<div class=\"cmd-group\"><h3>' + esc(g.group) + '</h3>' + (g.items || []).map(function (item) {\n var meta = findHelpEntry(d.groups, item.cmd) || {};\n return '<div class=\"cmd\"><code>' + esc(item.example) + '</code><span class=\"why\">' + esc(meta.what || '') +\n (item.note ? ' · ' + esc(item.note) : '') + '</span>' +\n (item.danger ? '<span class=\"badge danger\">危险</span>' : '') +\n '<button class=\"btn small\" data-copy-cmd=\"' + esc(item.example) + '\">复制</button></div>';\n }).join('') + '</div>';\n }).join('');\n var full = (d.groups || []).map(function (g) {\n return '<div class=\"cmd-group\"><h3>' + esc(g.group) + '</h3>' + (g.commands || []).map(function (c) {\n var opts = (c.options || []).length\n ? '<div class=\"note\">常用选项: ' + c.options.map(function (f) { return '<code>' + esc(f) + '</code>'; }).join(' ') + '</div>'\n : '';\n var subs = (c.subcommands || []).map(function (s) {\n var sOpts = (s.options || []).length\n ? '<div class=\"note\">选项: ' + s.options.map(function (f) { return '<code>' + esc(f) + '</code>'; }).join(' ') + '</div>'\n : '';\n return '<div class=\"cmd\"><code>' + esc('yotta-skills ' + s.usage) + '</code><span class=\"why\">' + esc(s.what || '') + '</span>' + sOpts + '<button class=\"btn small\" data-copy-cmd=\"' + esc('yotta-skills ' + s.usage) + '\">复制</button></div>';\n }).join('');\n return '<details class=\"cmd-detail\"><summary><code>' + esc('yotta-skills ' + c.usage) + '</code> <span class=\"why\">' + esc(c.what || '') + '</span></summary>' +\n (c.when ? '<div class=\"note\">' + esc(c.when) + '</div>' : '') + opts + subs + '</details>';\n }).join('') + '</div>';\n }).join('');\n full += '<div class=\"cmd-group\"><h3>完整选项</h3>' + (d.options || []).map(function (o) {\n return '<div class=\"cmd\"><code>' + esc(o.flag + (o.arg ? ' ' + o.arg : '')) + '</code><span class=\"why\">' + esc(o.what || '') +\n (o.when ? ' · ' + esc(o.when) : '') + (o.caution ? ' · ⚠ ' + esc(o.caution) : '') + '</span></div>';\n }).join('') + '</div>';\n $('#cliFull').innerHTML = full;\n }\n api('/api/help').then(renderCliHelp);\n document.addEventListener('click', function (e) {\n var b = e.target.closest('#view-cli [data-copy-cmd]');\n if (b) copyText(b.dataset.copyCmd);\n });\n\n (async function boot() {\n var s = await api('/api/status');\n if (s && !s.error) $('#verLine').textContent = 'v' + s.version + ' · ' + (s.standard || '');\n else $('#verLine').textContent = '未连接本地服务';\n runLoad('overview');\n })();\n})();\n</script>\n</body>\n</html>\n";
70
70
  // @generated view-html:end
71
71
 
72
72
  function loadManifest() {
@@ -197,10 +197,10 @@
197
197
  ]
198
198
  },
199
199
  "yotta-memory": {
200
- "version": "0.22.5",
201
- "treeHash": "sha256:7355dbaa40444ca89038dfeb27f43920cad497b87b2ce772118964ca724521c7",
200
+ "version": "0.22.6",
201
+ "treeHash": "sha256:43ac2d65b3784b9a0e64adffaf17cc0c3c595e8eb5c7883cc5ab93b58694228e",
202
202
  "files": 18,
203
- "bytes": 1319748,
203
+ "bytes": 1335695,
204
204
  "mode": "strict",
205
205
  "exceptions": [
206
206
  {
@@ -27,7 +27,7 @@ import subprocess
27
27
  import sys
28
28
  from pathlib import Path
29
29
 
30
- VERSION = "0.29.7"
30
+ VERSION = "0.29.8"
31
31
  TOOL_NAME = "yotta-skills"
32
32
  CN_NAME = "元阁"
33
33
  MCP_PROTOCOL_MODERN = "2026-07-28"
@@ -28,10 +28,10 @@
28
28
  "desc": "跨会话/跨项目通用工作流协议(状态就近存)"
29
29
  },
30
30
  {
31
- "slug": "yotta-memory",
32
- "name": "元忆",
33
- "pkg": "@yottameta/yotta-memory",
34
- "version": "0.22.5",
31
+ "slug": "yotta-memory",
32
+ "name": "元忆",
33
+ "pkg": "@yottameta/yotta-memory",
34
+ "version": "0.22.6",
35
35
  "domain": "memory",
36
36
  "runtimePayload": ["bin"],
37
37
  "desc": "有权限边界的文件式智能体记忆(FACT/PREF/BOUND/COMMIT)"