larkway 0.3.7 → 0.3.9

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.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Larkway 管理面 — 原生 ES module SPA,无框架、无构建步骤。
5
5
  *
6
- * Token 解析:server 注入 window.__LARKWAY_TOKEN__ > ?token= 回退。
6
+ * Token 解析:server 注入 window.__LK_BOOT_TOKEN__ > ?token= 回退。
7
7
  * 所有 /api/* 请求自动带 X-Larkway-Token header。
8
8
  * secret 绝不显示真值(gitlab_token_env 变量名是内部细节,UI 不暴露)。
9
9
  *
@@ -37,7 +37,6 @@ const ICONS = {
37
37
  scan: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 8V6a2 2 0 0 1 2-2h2M16 4h2a2 2 0 0 1 2 2v2M20 16v2a2 2 0 0 1-2 2h-2M8 20H6a2 2 0 0 1-2-2v-2"/></svg>`,
38
38
  box: `<svg class="icon icon-sm" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 8 12 3 3 8v8l9 5 9-5Z"/><path d="m3 8 9 5 9-5M12 13v8"/></svg>`,
39
39
  plus: `<svg class="icon icon-sm" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>`,
40
- // ── 公司中心库专用(centralData.jsx CC_ICON + LK_ICON 复刻)──
41
40
  repo: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 6a2 2 0 0 1 2-2h11l3 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM16 4v3h3M7 13h8M7 16h5"/></svg>`,
42
41
  branch: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 4v9M6 20a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM6 6a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM18 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM18 6c0 5-6 4-6 9"/></svg>`,
43
42
  folder: `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>`,
@@ -101,7 +100,7 @@ function heartSVG(liveKey, w = 72, h = 22, sw = 1.8) {
101
100
  const PLACEHOLDER = "__LARKWAY_TOKEN__";
102
101
 
103
102
  function resolveToken() {
104
- const injected = window.__LARKWAY_TOKEN__;
103
+ const injected = window.__LK_BOOT_TOKEN__;
105
104
  if (typeof injected === "string" && injected && injected !== PLACEHOLDER) {
106
105
  return injected;
107
106
  }
@@ -287,7 +286,7 @@ function deleteAssistantDialog(bot) {
287
286
  <div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px 14px;display:flex;align-items:flex-start;gap:10px;margin-bottom:20px">
288
287
  <input type="checkbox" id="del-ack-chk" style="margin-top:3px;accent-color:#dc2626;flex-shrink:0" />
289
288
  <label for="del-ack-chk" style="font-size:13.5px;color:var(--text);cursor:pointer;line-height:1.5">
290
- 我明白删除后它会停止服务,且不会自动同步到公司中心库。
289
+ 我明白删除后它会停止服务。
291
290
  </label>
292
291
  </div>
293
292
  </div>
@@ -326,110 +325,6 @@ function deleteAssistantDialog(bot) {
326
325
  });
327
326
  }
328
327
 
329
- // ---------------------------------------------------------------------------
330
- // uploadConfirmDialog — 正式上传(晋升 push)二次确认弹窗
331
- // 复刻 centralPromote.jsx LkUploadConfirm:红 upload 徽章 + 标题 + 三条「会发生
332
- // 什么」+ ack 勾选 gate + 红「确认上传」。不可逆外发,样式走 destructive 红。
333
- // ---------------------------------------------------------------------------
334
-
335
- /**
336
- * 弹出「正式上传到公司中心库」二次确认弹窗,返回 Promise<boolean>。
337
- * 勾选「我明白这是不可逆外发」前确认按钮 disabled。点确认 → true;取消/Esc/点背景 → false。
338
- *
339
- * @param {{name:string}} bot 要上传的助手(显示名)
340
- * @param {{name?:string,branch?:string}|null} repo 中心库信息(展示推到哪)
341
- * @returns {Promise<boolean>}
342
- */
343
- function uploadConfirmDialog(bot, repo) {
344
- return new Promise((resolve) => {
345
- let resolved = false;
346
- function done(result) {
347
- if (resolved) return;
348
- resolved = true;
349
- document.removeEventListener("keydown", onKey);
350
- backdrop.remove();
351
- resolve(result);
352
- }
353
- function onKey(e) {
354
- if (e.key === "Escape") done(false);
355
- }
356
-
357
- const botName = esc(bot?.name || "这个助手");
358
- const repoName = esc(repo?.name || "公司中心库");
359
- const branch = esc(repo?.branch || "main");
360
-
361
- // 三条「会发生什么」(upload / users / lock)
362
- const bullets = [
363
- [ICONS.upload, `推送到 ${repoName}@${branch}`],
364
- [ICONS.users, "团队任何人都能同步、复用这一份"],
365
- [ICONS.lock, "只上传配置 —— 密钥、本机 .env 永远不会被推上去"],
366
- ];
367
-
368
- const uploadBadge = `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" style="width:19px;height:19px"><path d="M12 15V3M8 7l4-4 4 4"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>`;
369
- const uploadIcon15 = `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" style="width:15px;height:15px;flex-shrink:0"><path d="M12 15V3M8 7l4-4 4 4"/><path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2"/></svg>`;
370
-
371
- const backdrop = document.createElement("div");
372
- backdrop.className = "modal-backdrop";
373
- backdrop.setAttribute("role", "dialog");
374
- backdrop.setAttribute("aria-modal", "true");
375
-
376
- backdrop.innerHTML = `
377
- <div class="modal" style="max-width:520px">
378
- <div class="modal-body" style="padding:24px 26px 0">
379
- <div style="display:flex;gap:14px;align-items:flex-start">
380
- <div style="flex-shrink:0;width:40px;height:40px;border-radius:11px;background:var(--destructive-weak);border:1px solid #fecaca;color:var(--destructive-strong);display:flex;align-items:center;justify-content:center">
381
- ${uploadBadge}
382
- </div>
383
- <div style="min-width:0">
384
- <div style="font-size:18px;font-weight:700;color:var(--text);line-height:1.3">正式上传「${botName}」?</div>
385
- <p style="margin:6px 0 0;font-size:13.5px;color:var(--muted);line-height:1.58">
386
- 这会把它推送到团队仓库 <b style="color:var(--text);font-family:ui-monospace,monospace">${repoName}</b>,<b style="color:var(--destructive-text)">所有人同步都能拉到</b>。推上去之后<b style="color:var(--destructive-text)">没法自动撤回</b>(要撤得让工程师改仓库)。
387
- </p>
388
- </div>
389
- </div>
390
- <ul style="list-style:none;margin:14px 0 0;padding:0;display:flex;flex-direction:column;gap:8px">
391
- ${bullets
392
- .map(
393
- ([ic, tx]) =>
394
- `<li style="display:flex;align-items:center;gap:9px;font-size:13px;color:#334155"><span style="color:var(--faint);flex-shrink:0;display:inline-flex">${ic}</span>${esc(tx)}</li>`,
395
- )
396
- .join("")}
397
- </ul>
398
- <label style="display:flex;align-items:flex-start;gap:9px;margin:16px 0 0;padding:12px 14px;border-radius:10px;background:var(--bg);border:1px solid var(--border);cursor:pointer">
399
- <input type="checkbox" id="up-ack-chk" style="margin-top:2px;width:16px;height:16px;accent-color:var(--destructive-strong);flex-shrink:0;cursor:pointer" />
400
- <span style="font-size:13px;color:var(--text);line-height:1.5">我明白这是不可逆的外发,全团队同步都会拉到这一份。</span>
401
- </label>
402
- </div>
403
- <div class="modal-footer" style="padding:18px 26px 22px">
404
- <div class="modal-btns">
405
- <button class="btn" id="up-cancel" type="button">取消</button>
406
- <button class="btn btn-del-confirm" id="up-confirm" type="button" disabled>
407
- ${uploadIcon15} 确认上传
408
- </button>
409
- </div>
410
- </div>
411
- </div>
412
- `;
413
-
414
- const ackChk = backdrop.querySelector("#up-ack-chk");
415
- const confirmBtn = backdrop.querySelector("#up-confirm");
416
- ackChk.addEventListener("change", () => {
417
- confirmBtn.disabled = !ackChk.checked;
418
- });
419
- backdrop.addEventListener("click", (e) => {
420
- if (e.target === backdrop) done(false);
421
- });
422
- backdrop.querySelector("#up-cancel").addEventListener("click", () => done(false));
423
- confirmBtn.addEventListener("click", () => {
424
- if (!ackChk.checked) return;
425
- done(true);
426
- });
427
- document.addEventListener("keydown", onKey);
428
-
429
- document.body.appendChild(backdrop);
430
- backdrop.querySelector("#up-cancel").focus();
431
- });
432
- }
433
328
 
434
329
  /**
435
330
  * 删除助手流程:弹确认 → DELETE /api/bot/:id → 更新 state → toast。
@@ -474,16 +369,16 @@ async function doDeleteBot(id) {
474
369
  // ---------------------------------------------------------------------------
475
370
 
476
371
  const state = {
477
- /** @type {"local"|"central"} */
372
+ /** @type {"local"} */
478
373
  mode: "local",
479
374
  /** @type {Array<{id:string,name:string,description:string,avatar:string|null}>} */
480
375
  bots: [],
481
376
  /** @type {string|null} */
482
377
  selected: null,
483
- /** 中心上下文是否可用 */
484
- centralAvailable: false,
485
378
  /** Bridge 进程状态(来自 GET /api/bridge;null = 未知)。 */
486
379
  bridge: /** @type {{running:boolean,pid:number|null,platform:string,mode:string}|null} */ (null),
380
+ /** Runtime requirement diagnostics from GET /api/runtime/requirements. */
381
+ requirements: /** @type {{requirements:Array<any>,missingRequired:Array<any>,missingOptional:Array<any>}|null} */ (null),
487
382
  /**
488
383
  * 每个 bot 的实时在线状态(来自 GET /api/status 的 bots[]),按 id 索引。
489
384
  * "serving"=🟢正常服务中 / "degraded"=🟡连接异常 / "offline"=🔴未运行掉线 / null=状态未知。
@@ -515,21 +410,6 @@ const state = {
515
410
  /** 当前详情 memory 是否有未保存改动。 */
516
411
  memoryDirty: false,
517
412
 
518
- // ── 公司中心库(connect / roster / sync)──────────────────────────────
519
- /** 是否已连接中心库(config.json.centralConfig 已设)。来自 GET /api/central/status。 */
520
- centralConnected: false,
521
- /** 已连接时的仓库信息 { name, url, branch, path }。 */
522
- centralRepo: /** @type {{name:string,url:string,branch:string,path:string}|null} */ (null),
523
- /** 最近同步时间(ms epoch,来自 status.lastSyncMs);null = 未知。 */
524
- centralLastSyncMs: /** @type {number|null} */ (null),
525
- /** 中心库共享助手数(来自 status.sharedCount)。 */
526
- centralSharedCount: 0,
527
- /** 中心库只读名册 [{id,name,desc,by,updated,commit,chats,repos}]。来自 GET /api/central/bots。 */
528
- centralBots: /** @type {Array<{id:string,name:string,desc:string,by:string,updated:string,commit:string,chats:number,repos:number,avatar:string|null}>} */ ([]),
529
- /** 来源条同步态:'fresh'(已是最新) | 'updates'(有更新可拉) | 'syncing' | 'unknown'。 */
530
- centralSyncState: /** @type {"fresh"|"updates"|"syncing"|"unknown"} */ ("unknown"),
531
- /** 可更新项数(added+updated+removed),驱动来源条「N 项可更新」pill。 */
532
- centralUpdateCount: 0,
533
413
  /**
534
414
  * 待重启提示:来自 GET /api/status 的 pendingRestart 字段。
535
415
  * newCount = 有 yaml 无 status.json(新增 bot,待重启上线);
@@ -825,24 +705,9 @@ async function loadBackends() {
825
705
  * 选中项用 indigo 交互色;未就绪提示用中性 slate(绝不染状态色)。
826
706
  *
827
707
  * @param {string} value 当前选中的 backend id
828
- * @param {boolean} [readOnly] 只读态(中心库展示)
829
708
  * @param {string} [containerId] 容器 id(用于 change 事件冒泡)
830
709
  */
831
- function lkBackendSelectHTML(value, readOnly = false, containerId = "") {
832
- if (readOnly) {
833
- const b = lkBackend(value);
834
- return (
835
- `<div class="lk-bk-readonly">` +
836
- lkBackendMonoHTML(value, "lg") +
837
- `<div style="min-width:0;flex:1">` +
838
- `<div class="lk-bk-readonly-name">${esc(b.name)}</div>` +
839
- `<div class="lk-bk-readonly-vendor">${esc(b.vendor)}</div>` +
840
- `</div>` +
841
- `<span class="lk-bk-readonly-lock">${ICONS.lock} 只读</span>` +
842
- `</div>`
843
- );
844
- }
845
-
710
+ function lkBackendSelectHTML(value, containerId = "") {
846
711
  const items = LK_BACKEND_ORDER.map((id) => {
847
712
  const b = lkBackend(id);
848
713
  const sel = id === value;
@@ -1004,6 +869,34 @@ function effLive(id) {
1004
869
  return bridgeRunning ? botLiveness(id) : "offline";
1005
870
  }
1006
871
 
872
+ function missingRequiredForBot(id) {
873
+ const missing = state.requirements?.missingRequired;
874
+ if (!Array.isArray(missing)) return [];
875
+ return missing.filter((req) => Array.isArray(req.botIds) && req.botIds.includes(id));
876
+ }
877
+
878
+ function missingRequiredAll() {
879
+ return Array.isArray(state.requirements?.missingRequired) ? state.requirements.missingRequired : [];
880
+ }
881
+
882
+ function requirementShortLabel(req) {
883
+ if (!req) return "运行依赖";
884
+ if (req.command === "lark-cli") return "飞书 CLI";
885
+ if (req.kind === "secret") return req.label === "Git access token env" ? "Git 访问令牌" : req.label;
886
+ return req.label || req.command || "运行依赖";
887
+ }
888
+
889
+ function requirementInstallText(req) {
890
+ if (!req) return "";
891
+ if (req.command === "lark-cli") return "安装并配置 lark-cli 后重启服务。";
892
+ if (req.kind === "secret") return req.installHint || "在看板里粘贴 Git access token,或补齐 bot yaml 的 git_token_env。";
893
+ return req.installHint || "安装缺失的 CLI 后重启服务。";
894
+ }
895
+
896
+ function primaryMissingForBot(id) {
897
+ return missingRequiredForBot(id)[0] ?? null;
898
+ }
899
+
1007
900
  /**
1008
901
  * pendingNew:bridge 在跑且存在从未上报过 status 的 bot(unknown liveness)的数量。
1009
902
  * 对应 LkServiceIndicator pendingNew prop。
@@ -1125,6 +1018,8 @@ function buildStatusActionPanel(liveKey, busyAction = null, moreOpen = false) {
1125
1018
  };
1126
1019
 
1127
1020
  const primaryBusy = busyAction === fx.primary.action;
1021
+ const heading = fx.heading;
1022
+ const say = fx.say;
1128
1023
 
1129
1024
  // Build "more" section for degraded
1130
1025
  let moreSection = "";
@@ -1182,8 +1077,8 @@ function buildStatusActionPanel(liveKey, busyAction = null, moreOpen = false) {
1182
1077
  iconSvg + `</span>` +
1183
1078
  `<div style="min-width:0;flex:1">` +
1184
1079
  `<div style="font-size:12.5px;font-weight:700;letter-spacing:.04em;color:${sc.text};margin-bottom:4px">` +
1185
- esc(fx.heading) + `</div>` +
1186
- `<p style="margin:0;font-size:14px;line-height:1.55;color:#334155">${esc(fx.say)}</p>` +
1080
+ esc(heading) + `</div>` +
1081
+ `<p style="margin:0;font-size:14px;line-height:1.55;color:#334155">${esc(say)}</p>` +
1187
1082
  `<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:13px;align-items:center">` +
1188
1083
  fixBtnPrimary(primaryBusy) +
1189
1084
  (fx.secondary ? fixBtnSecondary(fx.secondary) : "") +
@@ -1390,589 +1285,12 @@ function formatDuration(ms) {
1390
1285
  // ---------------------------------------------------------------------------
1391
1286
 
1392
1287
  function renderContextSwitch() {
1393
- for (const btn of document.querySelectorAll(".ctx-btn")) {
1394
- btn.classList.toggle("is-active", btn.dataset.mode === state.mode);
1395
- }
1396
- // 「公司中心库」段永远可点 —— 未连接时进去看的是连接引导卡(这正是要进的页)。
1397
- const central = document.getElementById("ctx-central");
1398
- if (central) central.disabled = false;
1399
-
1400
- // 侧栏底部按钮:本机显「添加新助手」;中心(只读)隐藏添加,改在 renderBotList 注入
1401
- // 「去本机添加/晋升」。旧的 #btn-sync(从公司中心库拉取)由来源条的同步入口替代,常隐藏。
1402
- const btnAdd = document.getElementById("btn-add");
1403
- if (btnAdd) btnAdd.style.display = state.mode === "central" ? "none" : "";
1404
- const btnSync = document.getElementById("btn-sync");
1405
- if (btnSync) btnSync.style.display = "none";
1406
-
1407
- // 中心库(只读)= 无「添加」按钮,改放「去本机添加/晋升」引导(CcRoster 底部)。
1408
- const actions = document.getElementById("sidebar-actions");
1409
- if (actions) {
1410
- let goLocal = document.getElementById("cc-go-local-block");
1411
- if (state.mode === "central") {
1412
- if (!goLocal) {
1413
- goLocal = document.createElement("button");
1414
- goLocal.id = "cc-go-local-block";
1415
- goLocal.type = "button";
1416
- goLocal.className = "cc-go-local-block";
1417
- goLocal.innerHTML = `${ICONS.plus} 去「本机」添加 / 晋升`;
1418
- goLocal.addEventListener("click", () => switchContext("local"));
1419
- actions.appendChild(goLocal);
1420
- }
1421
- goLocal.style.display = "";
1422
- } else if (goLocal) {
1423
- goLocal.style.display = "none";
1424
- }
1425
- }
1426
- }
1427
-
1428
- // ---------------------------------------------------------------------------
1429
- // 渲染:公司中心库来源条(LkSyncBar 复刻)
1430
- // ---------------------------------------------------------------------------
1431
-
1432
- /** 把「距上次同步的毫秒数」换算成「N 分钟前 / 刚刚 / N 小时前」。 */
1433
- function formatSyncAgo(lastSyncMs) {
1434
- if (typeof lastSyncMs !== "number" || !isFinite(lastSyncMs)) return null;
1435
- const diff = Date.now() - lastSyncMs;
1436
- if (diff < 0) return "刚刚";
1437
- const min = Math.floor(diff / 60000);
1438
- if (min < 1) return "刚刚";
1439
- if (min < 60) return `${min} 分钟前`;
1440
- const hr = Math.floor(min / 60);
1441
- if (hr < 24) return `${hr} 小时前`;
1442
- return `${Math.floor(hr / 24)} 天前`;
1288
+ // Static local-only mode — show hostname badge, nothing to toggle.
1289
+ const hostEl = document.getElementById("ctx-host");
1290
+ if (hostEl) hostEl.textContent = location.hostname ? " · " + location.hostname : "";
1443
1291
  }
1444
1292
 
1445
- /**
1446
- * 渲染顶部来源条:来自 <仓库>@<分支> · 最近同步 N 分钟前 + 同步入口。
1447
- * 仅在 central 已连接态显示;其余态隐藏。
1448
- * 状态:'fresh'(已是最新) / 'updates'(有更新可拉) / 'syncing' / 'unknown'(检查更新)。
1449
- */
1450
- function renderCentralSourceBar() {
1451
- const bar = document.getElementById("central-source-bar");
1452
- if (!bar) return;
1453
1293
 
1454
- if (state.mode !== "central" || !state.centralConnected || !state.centralRepo) {
1455
- bar.hidden = true;
1456
- bar.innerHTML = "";
1457
- return;
1458
- }
1459
-
1460
- const r = state.centralRepo;
1461
- const syncState = state.centralSyncState;
1462
- const ago = syncState === "syncing" ? "…" : (formatSyncAgo(state.centralLastSyncMs) ?? "未知");
1463
-
1464
- // 右侧状态指示(updates pill / fresh 平静态)
1465
- let statusBadge = "";
1466
- if (syncState === "updates") {
1467
- statusBadge = `<span class="csb-updates">${state.centralUpdateCount} 项可更新</span>`;
1468
- } else if (syncState === "fresh") {
1469
- statusBadge = `<span class="csb-fresh">${ICONS.check} 已是最新</span>`;
1470
- }
1471
-
1472
- const hasUpdates = syncState === "updates";
1473
- const syncing = syncState === "syncing";
1474
- const btnLabel = syncing ? "正在同步…" : hasUpdates ? "拉取最新" : "检查更新";
1475
- const btnIcon = syncing
1476
- ? `<span class="spinner" style="width:14px;height:14px;border-width:2px"></span>`
1477
- : ICONS.pull;
1478
-
1479
- bar.innerHTML =
1480
- `<span class="csb-from">` +
1481
- ICONS.repo +
1482
- `<span>来自 <b class="csb-repo">${esc(r.name)}</b></span>` +
1483
- `<span class="csb-branch">${ICONS.branch}${esc(r.branch)}</span>` +
1484
- `<span class="csb-sep">·</span>` +
1485
- `<span class="csb-synced">最近同步 ${esc(ago)}</span>` +
1486
- `</span>` +
1487
- `<span class="csb-right">` +
1488
- statusBadge +
1489
- `<button type="button" id="btn-central-sync" class="csb-sync-btn${hasUpdates ? " has-updates" : ""}"${syncing ? " disabled" : ""}>` +
1490
- btnIcon + esc(btnLabel) +
1491
- `</button>` +
1492
- // 已连接设置块(改配置 / 断开)—— LkConnectedBlock 复刻,放在同步入口右侧
1493
- `<button type="button" id="btn-central-edit" class="csb-sync-btn" title="改中心库配置">${ICONS.gear} 改配置</button>` +
1494
- `<button type="button" id="btn-central-disconnect" class="csb-sync-btn csb-disconnect-btn" title="断开公司中心库">断开</button>` +
1495
- `</span>`;
1496
- bar.hidden = false;
1497
-
1498
- bar.querySelector("#btn-central-sync")?.addEventListener("click", () => doCentralSync());
1499
- bar.querySelector("#btn-central-edit")?.addEventListener("click", () => openConnectFlow(true));
1500
- bar.querySelector("#btn-central-disconnect")?.addEventListener("click", () => doCentralDisconnect());
1501
- }
1502
-
1503
- // ---------------------------------------------------------------------------
1504
- // 公司中心库:连接状态 + 名册拉取
1505
- // ---------------------------------------------------------------------------
1506
-
1507
- /**
1508
- * 拉 GET /api/central/status,写入 state.central*。
1509
- * connected = config.json.centralConfig 已设;best-effort 回填 repo/head/sharedCount/lastSyncMs。
1510
- * 失败时降级:connected 保持已知值(不抛),让 UI 仍能渲染连接引导。
1511
- */
1512
- async function loadCentralStatus() {
1513
- const res = await api("GET", "/api/central/status");
1514
- if (!res.ok || !res.json) {
1515
- state.centralConnected = false;
1516
- state.centralRepo = null;
1517
- return;
1518
- }
1519
- const j = res.json;
1520
- state.centralConnected = j.connected === true;
1521
- state.centralRepo = j.repo ?? null;
1522
- state.centralLastSyncMs = typeof j.lastSyncMs === "number" ? j.lastSyncMs : null;
1523
- state.centralSharedCount = typeof j.sharedCount === "number" ? j.sharedCount : 0;
1524
- // 与 /api/context 的 centralAvailable 对齐(连接=可用)
1525
- state.centralAvailable = state.centralConnected;
1526
- }
1527
-
1528
- /**
1529
- * 拉 GET /api/central/bots → state.centralBots(只读名册,含 by/updated/commit)。
1530
- * @returns {Promise<boolean>} 成功与否
1531
- */
1532
- async function loadCentralBots() {
1533
- const res = await api("GET", "/api/central/bots");
1534
- if (!res.ok) {
1535
- state.centralBots = [];
1536
- return false;
1537
- }
1538
- state.centralBots = Array.isArray(res.json?.bots) ? res.json.bots : [];
1539
- return true;
1540
- }
1541
-
1542
- // ---------------------------------------------------------------------------
1543
- // 公司中心库:连接流弹窗(form → connecting → connected/failed)
1544
- // 复刻 centralConnect.jsx LkConnectFlow。connecting 调 POST /api/central/config。
1545
- // ---------------------------------------------------------------------------
1546
-
1547
- /** 连接流弹窗的瞬态(表单值 + 连上后回填的仓库)。 */
1548
- const connectFlow = {
1549
- /** @type {{url:string,branch:string,path:string}} */
1550
- values: { url: "", branch: "main", path: "bots/" },
1551
- /** @type {{name:string,url:string,branch:string,path:string}|null} */
1552
- repo: null,
1553
- /** @type {{kind?:string,error?:string}|null} */
1554
- fail: null,
1555
- };
1556
-
1557
- /** 打开连接流弹窗(默认 form 态;edit=true 时预填现有配置)。 */
1558
- function openConnectFlow(edit = false) {
1559
- if (edit && state.centralRepo) {
1560
- connectFlow.values = {
1561
- url: state.centralRepo.url ?? "",
1562
- branch: state.centralRepo.branch ?? "main",
1563
- path: state.centralRepo.path ?? "bots/",
1564
- };
1565
- } else {
1566
- connectFlow.values = { url: "", branch: "main", path: "bots/" };
1567
- }
1568
- connectFlow.repo = null;
1569
- connectFlow.fail = null;
1570
- renderConnectFlow("form");
1571
- const backdrop = document.getElementById("connect-backdrop");
1572
- if (backdrop) backdrop.hidden = false;
1573
- document.getElementById("cc-url")?.focus();
1574
- }
1575
-
1576
- function closeConnectFlow() {
1577
- const backdrop = document.getElementById("connect-backdrop");
1578
- if (backdrop) backdrop.hidden = true;
1579
- }
1580
-
1581
- /** 连接弹窗 header(indigo link 徽章 + 标题 + 副标题 + 关闭)。 */
1582
- function connectHead(title, sub) {
1583
- return (
1584
- `<div style="padding:22px 26px 0">` +
1585
- `<div style="display:flex;align-items:center;justify-content:space-between">` +
1586
- `<h3 style="margin:0;font-size:18px;font-weight:700;color:var(--text);display:flex;align-items:center;gap:9px">` +
1587
- `<span style="width:30px;height:30px;border-radius:9px;background:var(--br-soft);border:1px solid var(--br-edge);color:var(--br);display:inline-flex;align-items:center;justify-content:center">${ICONS.link2}</span>` +
1588
- esc(title) +
1589
- `</h3>` +
1590
- `<button type="button" id="cc-close" aria-label="关闭" style="display:inline-flex;padding:6px;border:none;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer">${ICONS.x}</button>` +
1591
- `</div>` +
1592
- (sub ? `<p style="margin:8px 0 0;font-size:13.5px;color:var(--muted);line-height:1.55">${esc(sub)}</p>` : "") +
1593
- `</div>`
1594
- );
1595
- }
1596
-
1597
- /**
1598
- * 渲染连接流弹窗的某个 phase。
1599
- * @param {"form"|"connecting"|"connected"|"failed"} phase
1600
- */
1601
- function renderConnectFlow(phase) {
1602
- const modal = document.getElementById("connect-modal");
1603
- if (!modal) return;
1604
- const v = connectFlow.values;
1605
-
1606
- if (phase === "connecting") {
1607
- const steps = ["找到这个仓库", "验证这台机器有没有权限", `读取 ${v.path || "bots/"} 里的助手`];
1608
- modal.innerHTML =
1609
- connectHead("正在连接…", "在测试这台电脑能不能访问这个仓库。不会改动仓库里任何东西。") +
1610
- `<div style="padding:20px 26px 26px">` +
1611
- `<div style="display:flex;flex-direction:column;align-items:center;gap:14px;padding:18px 0 22px">` +
1612
- `<span class="spinner spinner-lg"></span>` +
1613
- `<code style="font-size:12.5px;color:var(--muted);font-family:ui-monospace,monospace;word-break:break-all;text-align:center;max-width:380px">${esc(v.url)}</code>` +
1614
- `</div>` +
1615
- `<ul style="list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:10px">` +
1616
- steps.map((s, i) =>
1617
- `<li style="display:flex;align-items:center;gap:10px;font-size:13.5px;color:${i === 0 ? "var(--text)" : "var(--faint)"}">` +
1618
- (i === 0
1619
- ? `<span class="spinner" style="width:15px;height:15px;border-width:2px"></span>`
1620
- : `<span style="width:15px;height:15px;border-radius:50%;border:1.6px solid var(--border);flex-shrink:0"></span>`) +
1621
- esc(s) + `</li>`
1622
- ).join("") +
1623
- `</ul></div>`;
1624
- // connecting 态不可关闭(无 close 按钮)
1625
- return;
1626
- }
1627
-
1628
- if (phase === "connected") {
1629
- const r = connectFlow.repo ?? state.centralRepo ?? { name: "", branch: "main", path: "bots/" };
1630
- const rows = [
1631
- ["repo", "仓库", r.name, true],
1632
- ["branch", "分支", r.branch, false],
1633
- ["folder", "助手目录", r.path, false],
1634
- ];
1635
- modal.innerHTML =
1636
- `<div style="padding:26px 26px 0;display:flex;gap:14px">` +
1637
- `<span style="flex-shrink:0;width:40px;height:40px;border-radius:11px;background:${LIVE_SOFT.serving};border:1px solid ${LIVE_EDGE.serving};color:${LIVE_COLOR.serving};display:flex;align-items:center;justify-content:center">${ICONS.check}</span>` +
1638
- `<div style="min-width:0">` +
1639
- `<h3 style="margin:0;font-size:18px;font-weight:700;color:var(--text)">连上了</h3>` +
1640
- `<p style="margin:6px 0 0;font-size:13.5px;color:var(--muted);line-height:1.55">这台电脑已经能访问公司中心库了。现在去本机详情页,<b style="color:var(--br-text)">晋升</b>按钮就能点了。</p>` +
1641
- `</div></div>` +
1642
- `<div style="margin:16px 26px 0;border-radius:12px;border:1px solid var(--border);overflow:hidden">` +
1643
- rows.map(([ic, k, val, mono], i) =>
1644
- `<div style="display:flex;align-items:center;gap:10px;padding:11px 14px;${i ? "border-top:1px solid var(--border);" : ""}background:${i % 2 ? "var(--surface)" : "var(--bg)"}">` +
1645
- ICONS[ic] +
1646
- `<span style="font-size:12.5px;color:var(--faint);width:64px">${esc(k)}</span>` +
1647
- `<span style="font-size:13.5px;font-weight:600;color:var(--text);${mono ? "font-family:ui-monospace,monospace" : ""}">${esc(val ?? "")}</span>` +
1648
- `</div>`
1649
- ).join("") +
1650
- `</div>` +
1651
- `<p style="margin:14px 26px 0;font-size:13px;color:var(--muted);display:flex;align-items:center;gap:8px">${ICONS.users} 中心库里现在有 <b style="color:var(--text)">${state.centralSharedCount}</b> 个共享助手,去「公司中心库」看看。</p>` +
1652
- `<div style="display:flex;justify-content:flex-end;gap:10px;padding:20px 26px 22px">` +
1653
- `<button type="button" id="cc-done" class="btn btn-primary">${ICONS.check} 完成</button>` +
1654
- `</div>`;
1655
- modal.querySelector("#cc-done")?.addEventListener("click", () => {
1656
- closeConnectFlow();
1657
- // 连上后进入中心库视图并加载名册。
1658
- // 注意:若此前已在「公司中心库」上下文(从引导卡进来),switchContext('central')
1659
- // 会因 mode 未变而 no-op,所以这里直接强制刷新中心视图。
1660
- void enterCentralConnected();
1661
- });
1662
- return;
1663
- }
1664
-
1665
- if (phase === "failed") {
1666
- const f = connectFlow.fail ?? {};
1667
- const reasons = [
1668
- ["地址可能写错了", "核对一下仓库地址有没有漏字符、对不对。"],
1669
- ["这台机器没有访问权限", "让工程师把你这台电脑的 SSH key 加进仓库,或换 HTTPS + 令牌。"],
1670
- ];
1671
- const errLine = f.error
1672
- ? `<div style="margin:14px 26px 0;font-size:12.5px;color:var(--destructive-text);line-height:1.5">${esc(f.error)}</div>`
1673
- : "";
1674
- modal.innerHTML =
1675
- `<div style="padding:24px 26px 0;display:flex;gap:14px">` +
1676
- `<span style="flex-shrink:0;width:40px;height:40px;border-radius:11px;background:${LIVE_SOFT.offline};border:1px solid ${LIVE_EDGE.offline};color:${LIVE_COLOR.offline};display:flex;align-items:center;justify-content:center">${ICONS.warn}</span>` +
1677
- `<div style="min-width:0">` +
1678
- `<h3 style="margin:0;font-size:18px;font-weight:700;color:var(--text)">仓库连不上</h3>` +
1679
- `<p style="margin:6px 0 0;font-size:13.5px;color:var(--muted);line-height:1.55">没改动任何东西。多半是下面两种情况之一:</p>` +
1680
- `</div></div>` +
1681
- `<ul style="list-style:none;margin:14px 26px 0;padding:0;display:flex;flex-direction:column;gap:10px">` +
1682
- reasons.map(([h, d], i) =>
1683
- `<li style="display:flex;gap:11px;align-items:flex-start;padding:12px 14px;border-radius:10px;background:${LIVE_SOFT.offline};border:1px solid ${LIVE_EDGE.offline}">` +
1684
- `<span style="flex-shrink:0;width:20px;height:20px;margin-top:1px;border-radius:999px;background:var(--surface);border:1px solid ${LIVE_EDGE.offline};color:${LIVE_TEXT.offline};font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center">${i + 1}</span>` +
1685
- `<div><div style="font-size:13.5px;font-weight:600;color:#334155">${esc(h)}</div><div style="font-size:12.5px;color:var(--muted);line-height:1.5;margin-top:1px">${esc(d)}</div></div>` +
1686
- `</li>`
1687
- ).join("") +
1688
- `</ul>` +
1689
- errLine +
1690
- `<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;padding:20px 26px 22px">` +
1691
- `<button type="button" id="cc-edit" class="btn">改一下配置</button>` +
1692
- `<button type="button" id="cc-retry" class="btn btn-primary">${ICONS.refresh} 重试</button>` +
1693
- `</div>`;
1694
- modal.querySelector("#cc-edit")?.addEventListener("click", () => renderConnectFlow("form"));
1695
- modal.querySelector("#cc-retry")?.addEventListener("click", () => submitConnect());
1696
- return;
1697
- }
1698
-
1699
- // ── form(默认)──
1700
- modal.innerHTML =
1701
- connectHead(
1702
- "连接公司中心库",
1703
- "把一个 git 仓库连上,团队的助手就能互相同步。地址一般让工程师帮你配一次,之后就不用管了。",
1704
- ) +
1705
- `<div style="padding:18px 26px 0;display:flex;flex-direction:column;gap:16px">` +
1706
- ccField("仓库地址", "团队放助手配置的 git 仓库。SSH 或 HTTPS 都行。不知道填啥?问下工程师。",
1707
- `<input id="cc-url" class="cc-input" value="${esc(v.url)}" placeholder="git@gitlab.公司.com:team/larkway-bots.git" />`) +
1708
- `<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px">` +
1709
- ccField("分支", "一般就是 main,别动。", `<input id="cc-branch" class="cc-input" value="${esc(v.branch)}" />`) +
1710
- ccField("助手放在仓库哪个目录", "团队约定的目录,默认就行。", `<input id="cc-path" class="cc-input" value="${esc(v.path)}" />`) +
1711
- `</div>` +
1712
- `<div style="display:flex;align-items:center;gap:8px;padding:10px 13px;border-radius:10px;background:var(--bg);border:1px solid var(--border);font-size:12.5px;color:var(--muted)">${ICONS.lock} 只做一次只读连接测试,不会往仓库里写任何东西。</div>` +
1713
- `</div>` +
1714
- `<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;padding:20px 26px 22px">` +
1715
- `<button type="button" id="cc-cancel" class="btn">取消</button>` +
1716
- `<button type="button" id="cc-submit" class="btn btn-primary">${ICONS.link2} 连接</button>` +
1717
- `</div>`;
1718
-
1719
- const urlEl = modal.querySelector("#cc-url");
1720
- const submitEl = modal.querySelector("#cc-submit");
1721
- const syncCan = () => {
1722
- const can = (urlEl?.value ?? "").trim().length > 6;
1723
- if (submitEl) submitEl.disabled = !can;
1724
- };
1725
- syncCan();
1726
- urlEl?.addEventListener("input", syncCan);
1727
- modal.querySelector("#cc-close")?.addEventListener("click", () => closeConnectFlow());
1728
- modal.querySelector("#cc-cancel")?.addEventListener("click", () => closeConnectFlow());
1729
- submitEl?.addEventListener("click", () => submitConnect());
1730
- }
1731
-
1732
- /** label + 说人话 help + 输入(CcField 复刻)。 */
1733
- function ccField(label, help, inputHTML) {
1734
- return (
1735
- `<div style="display:flex;flex-direction:column;gap:6px">` +
1736
- `<label style="font-size:14px;font-weight:600;color:var(--text)">${esc(label)}</label>` +
1737
- (help ? `<p style="margin:0;font-size:12.5px;color:var(--muted);line-height:1.5">${esc(help)}</p>` : "") +
1738
- inputHTML +
1739
- `</div>`
1740
- );
1741
- }
1742
-
1743
- /**
1744
- * 提交连接:读表单 → connecting 态 → POST /api/central/config →
1745
- * ok → connected 态 + 刷新 status;否则 → failed 态(说人话,不甩堆栈)。
1746
- */
1747
- async function submitConnect() {
1748
- const modal = document.getElementById("connect-modal");
1749
- // form 态时从输入读取最新值;retry 时沿用上次 values
1750
- const urlEl = modal?.querySelector("#cc-url");
1751
- if (urlEl) {
1752
- connectFlow.values = {
1753
- url: (modal.querySelector("#cc-url")?.value ?? "").trim(),
1754
- branch: (modal.querySelector("#cc-branch")?.value ?? "").trim() || "main",
1755
- path: (modal.querySelector("#cc-path")?.value ?? "").trim() || "bots/",
1756
- };
1757
- }
1758
- const v = connectFlow.values;
1759
- if (!v.url || v.url.length <= 6) {
1760
- toast("请填写仓库地址。", "warn");
1761
- return;
1762
- }
1763
-
1764
- renderConnectFlow("connecting");
1765
- const res = await api("POST", "/api/central/config", {
1766
- url: v.url,
1767
- branch: v.branch,
1768
- path: v.path,
1769
- });
1770
-
1771
- if (res.ok && res.json?.ok === true) {
1772
- connectFlow.repo = res.json.repo ?? null;
1773
- // 刷新连接状态(取 sharedCount 等)
1774
- await loadCentralStatus();
1775
- renderConnectFlow("connected");
1776
- } else {
1777
- const j = res.json ?? {};
1778
- connectFlow.fail = { kind: j.kind, error: j.error };
1779
- renderConnectFlow("failed");
1780
- }
1781
- }
1782
-
1783
- // ---------------------------------------------------------------------------
1784
- // 公司中心库:断开连接
1785
- // ---------------------------------------------------------------------------
1786
-
1787
- async function doCentralDisconnect() {
1788
- const ok = await confirmDialog({
1789
- title: "断开公司中心库?",
1790
- body:
1791
- "断开后这台电脑不再跟团队中心库同步,「晋升」也会停用。本机已有的助手不受影响,继续在本机跑。\n\n随时可以再连回来。",
1792
- confirmText: "断开",
1793
- confirmDanger: true,
1794
- });
1795
- if (!ok) return;
1796
- const res = await api("POST", "/api/central/disconnect");
1797
- if (!res.ok || res.json?.ok !== true) {
1798
- toast(`断开失败:${res.json?.error ?? res.status}`, "error");
1799
- return;
1800
- }
1801
- toast("已断开公司中心库", "ok");
1802
- state.centralConnected = false;
1803
- state.centralRepo = null;
1804
- state.centralAvailable = false;
1805
- // 后端断开后会把 mode 重置为 local;前端跟随切回本机
1806
- state.mode = "local";
1807
- state.selected = null;
1808
- renderContextSwitch();
1809
- renderCentralSourceBar();
1810
- renderBotDetail(null);
1811
- await loadBots();
1812
- renderServiceIndicator();
1813
- }
1814
-
1815
- // ---------------------------------------------------------------------------
1816
- // 公司中心库:同步(中心 → 本地)—— preview(dry-run)+ ack gate + apply
1817
- // 复刻 centralSync.jsx LkSyncPreview。preview 走 GET /api/central/sync/preview,
1818
- // apply 走 POST /api/central/sync/apply。移除类醒目(amber)+ ack 勾选。
1819
- // ---------------------------------------------------------------------------
1820
-
1821
- async function doCentralSync() {
1822
- state.centralSyncState = "syncing";
1823
- renderCentralSourceBar();
1824
-
1825
- const res = await api("GET", "/api/central/sync/preview");
1826
- if (!res.ok) {
1827
- state.centralSyncState = "unknown";
1828
- renderCentralSourceBar();
1829
- toast(`同步预览失败:${res.json?.error ?? res.status}`, "error");
1830
- return;
1831
- }
1832
- const preview = {
1833
- added: Array.isArray(res.json?.added) ? res.json.added : [],
1834
- updated: Array.isArray(res.json?.updated) ? res.json.updated : [],
1835
- removed: Array.isArray(res.json?.removed) ? res.json.removed : [],
1836
- };
1837
- // 「N 项可更新」只数中心 sync 会真正应用的增量(added + updated)。removed(本机自建未晋升的
1838
- // bot)本机自管、绝不删,不该算「可更新」—— 否则本地独有 bot 会让来源条永远显示「1 项可更新」,
1839
- // 点进去又走删除路径(2026-06-01 误删 larkway-2 的连锁起点)。
1840
- const total = preview.added.length + preview.updated.length;
1841
- state.centralUpdateCount = total;
1842
- state.centralSyncState = total > 0 ? "updates" : "fresh";
1843
- renderCentralSourceBar();
1844
-
1845
- if (total === 0) {
1846
- toast("已是最新,没有要同步的改动。", "ok");
1847
- return;
1848
- }
1849
- showCentralSyncModal(preview);
1850
- }
1851
-
1852
- /** 一行变更项(CcChangeRow):added=绿 / updated=indigo / removed=amber。 */
1853
- function ccChangeRow(kind, item) {
1854
- const map = {
1855
- added: { c: LIVE_COLOR.serving, soft: LIVE_SOFT.serving, edge: LIVE_EDGE.serving, text: LIVE_TEXT.serving, icon: ICONS.plus, tag: "新增" },
1856
- updated: { c: BR.c, soft: BR.soft, edge: BR.edge, text: BR.text, icon: ICONS.refresh, tag: "更新" },
1857
- removed: { c: LIVE_COLOR.degraded, soft: LIVE_SOFT.degraded, edge: LIVE_EDGE.degraded, text: LIVE_TEXT.degraded, icon: ICONS.trash, tag: "移除" },
1858
- }[kind];
1859
- const removed = kind === "removed";
1860
- const by = item.by ? `<span style="font-size:11.5px;color:var(--faint)">· ${esc(item.by)}</span>` : "";
1861
- const note = removed
1862
- ? "中心库里已被移除 —— 同步后本机这份(由中心库管理)也会删掉。"
1863
- : (item.note ?? "");
1864
- return (
1865
- `<div class="cc-change-row${removed ? " is-removed" : ""}">` +
1866
- `<span class="cc-change-badge" style="border:1px solid ${map.edge};color:${map.c}">${map.icon}</span>` +
1867
- `<div style="min-width:0;flex:1">` +
1868
- `<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">` +
1869
- `<span style="font-size:14px;font-weight:600;color:var(--text)">${esc(item.name)}</span>` +
1870
- `<span class="cc-change-tag" style="color:${map.text};background:${map.soft};border:1px solid ${map.edge}">${map.tag}</span>` +
1871
- by +
1872
- `</div>` +
1873
- `<div style="font-size:12.5px;color:${removed ? map.text : "var(--muted)"};line-height:1.5;margin-top:2px">${esc(note)}</div>` +
1874
- `</div></div>`
1875
- );
1876
- }
1877
-
1878
- /** 同步预览 modal(复用 #modal-backdrop)。移除>0 时 ack gate。 */
1879
- function showCentralSyncModal(preview) {
1880
- const backdrop = document.getElementById("modal-backdrop");
1881
- const modal = document.getElementById("modal");
1882
- if (!backdrop || !modal) return;
1883
- // 同步弹窗:设计稿 520px / 圆角 16px,加 class 后关闭时清掉
1884
- modal.classList.add("modal--sync");
1885
-
1886
- const nAdd = preview.added.length, nUpd = preview.updated.length;
1887
- // 中心 sync 只做增量:added(中心有本机没有)+ updated(两边都有、中心更新)。
1888
- // `preview.removed` = 「本机有、中心没有」,即本机自建没晋升过的 bot —— 本机自管,中心 sync
1889
- // 绝不删它(见下方 prune=false)。所以这里不计入改动、不展示为「移除」、不做 ack gate,
1890
- // 避免把用户自己的本地 bot 误报成「会被移除」吓人(2026-06-01 误删 larkway-2 的教训)。
1891
- const nRem = 0;
1892
- const total = nAdd + nUpd;
1893
-
1894
- const sumCell = (n, label, c) =>
1895
- `<span style="display:inline-flex;align-items:baseline;gap:5px"><b style="font-size:16px;font-weight:800;color:${n ? c : "var(--faint)"}">${n}</b><span style="font-size:12.5px;color:var(--muted)">${label}</span></span>`;
1896
- const sep = `<span style="width:1px;height:20px;background:var(--border)"></span>`;
1897
-
1898
- const removeWarn = "";
1899
-
1900
- const list =
1901
- preview.added.map((it) => ccChangeRow("added", it)).join("") +
1902
- preview.updated.map((it) => ccChangeRow("updated", it)).join("");
1903
-
1904
- const ackBlock = "";
1905
-
1906
- modal.querySelector(".modal-header")?.remove();
1907
- modal.querySelector(".modal-header-sync")?.remove();
1908
- modal.innerHTML =
1909
- `<div class="modal-header-sync">` +
1910
- `<div class="modal-header-sync-row">` +
1911
- `<h3><span class="sync-icon-badge">${ICONS.pull}</span>同步前先看一眼</h3>` +
1912
- `<button class="sync-close-btn" id="cc-sync-close-x" type="button" aria-label="关闭">${ICONS.x}</button>` +
1913
- `</div>` +
1914
- `<p>把中心库最新的拉到本机。下面是会发生的改动,确认了再应用。</p>` +
1915
- `</div>` +
1916
- `<div class="modal-body">` +
1917
- `<div style="display:flex;align-items:center;gap:20px;padding:12px 16px;border-radius:11px;background:var(--bg);border:1px solid var(--border)">` +
1918
- sumCell(nAdd, "新增", LIVE_COLOR.serving) + sep + sumCell(nUpd, "更新", BR.c) + sep + sumCell(nRem, "移除", LIVE_COLOR.degraded) +
1919
- `</div>` +
1920
- removeWarn +
1921
- `<div style="margin:16px 0 0;display:flex;flex-direction:column;gap:8px">${list}</div>` +
1922
- ackBlock +
1923
- `</div>` +
1924
- `<div class="modal-footer">` +
1925
- `<div class="modal-btns">` +
1926
- `<button class="btn" id="cc-sync-cancel" type="button">先不同步</button>` +
1927
- `<button class="btn btn-primary" id="cc-sync-apply" type="button">${ICONS.pull} 应用更新(${total})</button>` +
1928
- `</div></div>`;
1929
-
1930
- const applyBtn = modal.querySelector("#cc-sync-apply");
1931
- const ackChk = modal.querySelector("#cc-sync-ack");
1932
- if (ackChk && applyBtn) {
1933
- applyBtn.disabled = true;
1934
- ackChk.addEventListener("change", () => { applyBtn.disabled = !ackChk.checked; });
1935
- }
1936
-
1937
- backdrop.hidden = false;
1938
-
1939
- const closeSync = () => { backdrop.hidden = true; modal.classList.remove("modal--sync"); };
1940
- modal.querySelector("#cc-sync-cancel")?.addEventListener("click", closeSync);
1941
- modal.querySelector("#cc-sync-close-x")?.addEventListener("click", closeSync);
1942
- applyBtn?.addEventListener("click", async () => {
1943
- if (applyBtn.disabled) return;
1944
- // 安全:中心库 sync 永不自动删本机 bot。planSync 的 `removed` = 「本机有、中心没有」,
1945
- // 它把「本机自建从没晋升过的 bot」(如本地新建测试 bot)和「曾被中心管理后从中心删掉的」
1946
- // 混为一谈 —— 旧代码 `nRem>0` 就 prune=true 会把前者一并硬删(2026-06-01 误删 larkway-2)。
1947
- // 在 provenance-aware prune 落地前,这里强制 false:本机自管是安全默认,中心 sync 只增量
1948
- // 拉 added/updated,绝不删本机。真要清理本机多余 bot 走「拉取最新」里显式勾选 prune 的流程。
1949
- const prune = false;
1950
- const restore = btnLoading(applyBtn, "正在应用…");
1951
- const res = await api("POST", "/api/central/sync/apply", { prune });
1952
- restore();
1953
- backdrop.hidden = true;
1954
- modal.classList.remove("modal--sync");
1955
- if (!res.ok || res.json?.ok !== true) {
1956
- toast(`同步失败:${res.json?.error ?? res.status}`, "error");
1957
- return;
1958
- }
1959
- const j = res.json;
1960
- const parts = [];
1961
- if ((j.added ?? []).length) parts.push(`新增 ${j.added.length}`);
1962
- if ((j.updated ?? []).length) parts.push(`更新 ${j.updated.length}`);
1963
- if ((j.removed ?? []).length) parts.push(`移除 ${j.removed.length}`);
1964
- toast(`同步完成。${parts.join("、") || "无变化"}`, "ok");
1965
- if ((j.warnings ?? []).length) {
1966
- for (const w of j.warnings) console.warn("[larkway central sync]", w);
1967
- }
1968
- // 同步后刷新中心库状态 + 名册(本机也变了,但当前在中心视图,刷中心名册)
1969
- state.centralSyncState = "fresh";
1970
- state.centralUpdateCount = 0;
1971
- await loadCentralStatus();
1972
- renderCentralSourceBar();
1973
- await loadBots();
1974
- });
1975
- }
1976
1294
 
1977
1295
  // ---------------------------------------------------------------------------
1978
1296
  // 渲染:主机状态 pill
@@ -2088,12 +1406,11 @@ function renderDetailBanner() {
2088
1406
  * 一行(名字下方的「● 连接异常/正常服务中 · N 秒前心跳」+ 头像光环)是 renderBotDetail
2089
1407
  * 选中时一次性渲染的,poll 不碰它 → bot 恢复在线后 hero pill 卡在旧的「连接异常」+ 旧心跳。
2090
1408
  * 这里在每次 /api/status 落地后,用 acRefreshHero 按 state.liveness/lastSeenMs 重建 hero body
2091
- * (它读 AC 面板的实时表单值,所以不会丢用户正在编辑的输入)。仅本机可改模式生效:
2092
- * 中心库(只读)hero 不显在线/心跳,且没有 AC 面板可读。
1409
+ * (它读 AC 面板的实时表单值,所以不会丢用户正在编辑的输入)
2093
1410
  */
2094
1411
  function refreshDetailHero() {
2095
1412
  const id = state.selected;
2096
- if (!id || state.mode === "central") return;
1413
+ if (!id) return;
2097
1414
  const panel = document.getElementById("detail");
2098
1415
  if (!panel || !panel.querySelector("#detail-hero-body")) return;
2099
1416
  if (!panel.querySelector("#ac-panel")) return; // 编辑式面板未挂载(加载中/占位)
@@ -2118,19 +1435,12 @@ function renderBotListLoading() {
2118
1435
  }
2119
1436
  }
2120
1437
 
2121
- /** 更新名册顶部计数文案「这台电脑上的 N 个助手」(中心库时换措辞)。 */
1438
+ /** 更新名册顶部计数文案「这台电脑上的 N 个助手」。 */
2122
1439
  function renderRosterCount() {
2123
1440
  const el = document.getElementById("roster-count-text");
2124
1441
  if (!el) return;
2125
1442
  const n = state.bots.length;
2126
- if (n === 0) {
2127
- el.textContent =
2128
- state.mode === "central" ? "中心库还没有共享助手" : "这台电脑上还没有助手";
2129
- } else if (state.mode === "central") {
2130
- el.textContent = `公司中心库的 ${n} 个助手`;
2131
- } else {
2132
- el.textContent = `这台电脑上的 ${n} 个助手`;
2133
- }
1443
+ el.textContent = n === 0 ? "这台电脑上还没有助手" : `这台电脑上的 ${n} 个助手`;
2134
1444
  }
2135
1445
 
2136
1446
  /**
@@ -2141,7 +1451,7 @@ function renderRosterCount() {
2141
1451
  */
2142
1452
  function renderRosterRestartStatus() {
2143
1453
  const el = document.getElementById("roster-restart-status");
2144
- if (!el || state.mode === "central") return;
1454
+ if (!el) return;
2145
1455
  const rs = state.restart;
2146
1456
  if (rs.status === "restarting") {
2147
1457
  const total = state.bots.length;
@@ -2217,7 +1527,6 @@ function renderBotList() {
2217
1527
  }
2218
1528
 
2219
1529
  // 添加/更新条目(顺序也要对得上编号 01/02 → 按 state.bots 顺序 re-append)
2220
- const readonly = state.mode === "central";
2221
1530
  state.bots.forEach((bot, i) => {
2222
1531
  let li = list.querySelector(`li[data-bot-id="${CSS.escape(bot.id)}"]`);
2223
1532
  if (!li) {
@@ -2239,40 +1548,13 @@ function renderBotList() {
2239
1548
  li.classList.toggle("is-selected", selected);
2240
1549
  const num = String(i + 1).padStart(2, "0");
2241
1550
 
2242
- // ── 中心库只读名册行(CcRosterItem):头像 + 名字 + 「<by> 晋升 · <updated>」──
2243
- // 中心库存「配置」不是「进程」→ 不显在线/心跳,只显谁晋升的 + 更新时间;无删除。
2244
- if (readonly) {
2245
- li.classList.add("cc-roster-item");
2246
- const meta = state.centralBots.find((b) => b.id === bot.id) ?? {};
2247
- const by = meta.by || "";
2248
- const updated = meta.updated || "";
2249
- const byLine = by || updated
2250
- ? `<span class="cc-roster-by">${ICONS.upload} ${esc(by)}${by ? " 晋升" : ""}${updated ? ` · ${esc(updated)}` : ""}</span>`
2251
- : `<span class="cc-roster-by">${ICONS.upload} 共享助手</span>`;
2252
- // ⑦ 中心库名册:用 bot.avatar 飞书头像,没有才回退首字母
2253
- const ccAvatar = state.avatars[bot.id] ?? bot.avatar ?? null;
2254
- li.innerHTML =
2255
- `<span class="roster-num">${num}</span>` +
2256
- avatarHTML(bot.id, bot.name, ccAvatar, "list", "unknown").replace(
2257
- /<span class="avatar-dot[^>]*><\/span>/,
2258
- "", // 中心库无在线状态,去掉头像角标圆点
2259
- ) +
2260
- `<span class="roster-meta">` +
2261
- `<span class="bot-name">${esc(bot.name)}</span>` +
2262
- `<span class="roster-state">${byLine}</span>` +
2263
- `</span>`;
2264
- return;
2265
- }
2266
-
2267
1551
  // ── BL-18:显示覆盖 —— restarting/timeout 时用 restartDisplayLive 替代真实 liveKey ─
2268
1552
  const realLiveKey = botLiveness(bot.id);
2269
1553
  const dispLiveKey = restartDisplayLive(realLiveKey, state.restart.status);
2270
1554
  const liveKey = dispLiveKey;
2271
1555
  const live = LIVENESS[liveKey] ?? LIVENESS.unknown;
2272
1556
  const avatar = state.avatars[bot.id] ?? bot.avatar ?? null;
2273
- const delBtn = readonly
2274
- ? ""
2275
- : `<button class="lk-del" type="button" aria-label="删除 ${esc(bot.name)}" title="删除助手">${ICONS.trash}</button>`;
1557
+ const delBtn = `<button class="lk-del" type="button" aria-label="删除 ${esc(bot.name)}" title="删除助手">${ICONS.trash}</button>`;
2276
1558
  // 名册头部:重启中显示进度「已连回 N/total」
2277
1559
  // (已在名册标题区渲染,不再重复到行级)
2278
1560
  // LkAttentionPill / LkRestartPill:
@@ -2329,14 +1611,12 @@ function renderBotList() {
2329
1611
  `</span>` +
2330
1612
  delBtn;
2331
1613
  // Wire del button after innerHTML (DOM replaced)
2332
- if (!readonly) {
2333
- const delEl = li.querySelector(".lk-del");
2334
- if (delEl) {
2335
- delEl.addEventListener("click", (e) => {
2336
- e.stopPropagation();
2337
- doDeleteBot(bot.id);
2338
- });
2339
- }
1614
+ const delEl = li.querySelector(".lk-del");
1615
+ if (delEl) {
1616
+ delEl.addEventListener("click", (e) => {
1617
+ e.stopPropagation();
1618
+ doDeleteBot(bot.id);
1619
+ });
2340
1620
  }
2341
1621
  });
2342
1622
  }
@@ -2474,11 +1754,9 @@ function buildStepFlowHTML() {
2474
1754
 
2475
1755
  /**
2476
1756
  * 构建 LkEmptyState 的完整 HTML。
2477
- * @param {'local'|'central'} variant
2478
1757
  * @param {boolean} serviceRunning false 时在 CTA 下加温和提示
2479
1758
  */
2480
- function buildEmptyStateHTML(variant, serviceRunning) {
2481
- const central = variant === "central";
1759
+ function buildEmptyStateHTML(serviceRunning) {
2482
1760
  const c = BR.c;
2483
1761
  const soft = BR.soft;
2484
1762
  const edge = BR.edge;
@@ -2495,23 +1773,8 @@ function buildEmptyStateHTML(variant, serviceRunning) {
2495
1773
  const bgStyle = `radial-gradient(120% 90% at 50% 0%, ${lkHexA(c, 0.05)} 0%, ${surface} 56%)`;
2496
1774
 
2497
1775
  let mainContent;
2498
- if (central) {
2499
- // 中心库变体:只读引导
2500
- const switchBtn = `<button class="es-central-switch-btn" type="button"
2501
- style="margin-top:24px;display:inline-flex;align-items:center;gap:8px;padding:11px 20px;font-size:14.5px;font-weight:700;font-family:inherit;color:${brText};background:${soft};border:1px solid ${edge};border-radius:11px;cursor:pointer">
2502
- 切到「本机」去添加
2503
- <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
2504
- </button>`;
2505
-
2506
- mainContent = `
2507
- <h1 style="margin:20px 0 0;font-size:30px;font-weight:800;letter-spacing:-.03em;line-height:1.12;color:${text}">公司中心库还没有共享助手</h1>
2508
- <p style="margin:12px 0 0;font-size:16px;color:${muted};line-height:1.62;max-width:460px">
2509
- 中心库是<b style="color:#334155">只读</b>的展示窗。先在<b style="color:${brText}">「本机」</b>配好一个助手,再把它<b style="color:#334155">晋升到这里</b>,全组就都能看到、复用。
2510
- </p>
2511
- ${switchBtn}
2512
- `;
2513
- } else {
2514
- // 本机变体:欢迎 + CTA
1776
+ {
1777
+ // 本机:欢迎 + CTA
2515
1778
  const ctaBtn = `<button id="es-add-btn" type="button"
2516
1779
  class="es-cta-btn"
2517
1780
  style="display:inline-flex;align-items:center;gap:9px;padding:14px 28px;font-size:16px;font-weight:700;font-family:inherit;color:#fff;cursor:pointer;background:${c};border:none;border-radius:13px;box-shadow:0 4px 14px ${lkHexA(c, 0.26)};transition:background .15s,box-shadow .18s,transform .18s">
@@ -2546,7 +1809,7 @@ function buildEmptyStateHTML(variant, serviceRunning) {
2546
1809
  }
2547
1810
 
2548
1811
  // 底部安心语(两变体共有)
2549
- const shieldNote = `<div style="margin-top:${central ? 34 : 26}px;display:inline-flex;align-items:center;gap:7px;font-size:12.5px;color:${faint}">
1812
+ const shieldNote = `<div style="margin-top:26px;display:inline-flex;align-items:center;gap:7px;font-size:12.5px;color:${faint}">
2550
1813
  <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l7 3v6c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V6z"/></svg>
2551
1814
  全程不碰终端;密钥只存在你本机,别人看不到
2552
1815
  </div>`;
@@ -2560,56 +1823,9 @@ function buildEmptyStateHTML(variant, serviceRunning) {
2560
1823
  </div>`;
2561
1824
  }
2562
1825
 
2563
- /**
2564
- * 构建 LkConnectGuide(未连接引导卡)的 HTML —— 复刻 centralConnect.jsx。
2565
- * 母题:本机一份 → 仓库 → 同事各自一份(共享);CTA = indigo「连接公司中心库」。
2566
- */
2567
- function buildConnectGuideHTML() {
2568
- const c = BR.c, soft = BR.soft, text = "#1e293b", muted = "#64748b", faint = "#94a3b8", brText = BR.text;
2569
- const bgStyle = `radial-gradient(120% 90% at 50% 0%, ${lkHexA(c, 0.05)} 0%, #fff 56%)`;
2570
-
2571
- // 连接母题插画(中央仓库 + 三个同事机器 + 双向同步弧线/流动光点)
2572
- const machines = [[34, 38], [34, 94], [266, 66]]
2573
- .map(([x, y]) =>
2574
- `<g><rect x="${x - 20}" y="${y - 14}" width="40" height="28" rx="7" fill="#fff" stroke="#cbd5e1" stroke-width="1.5"/>` +
2575
- `<circle cx="${x - 9}" cy="${y}" r="3.5" fill="#cbd5e1"/>` +
2576
- `<rect x="${x - 2}" y="${y - 4}" width="16" height="3.5" rx="1.75" fill="#e2e8f0"/>` +
2577
- `<rect x="${x - 2}" y="${y + 3}" width="11" height="3" rx="1.5" fill="#eef0f4"/></g>`,
2578
- ).join("");
2579
- const arcs = [[54, 44, 123, 56], [54, 88, 123, 78], [246, 66, 177, 67]]
2580
- .map((p, i) => {
2581
- const d = `M${p[0]} ${p[1]} Q ${(p[0] + p[2]) / 2} ${(p[1] + p[3]) / 2 - 14}, ${p[2]} ${p[3]}`;
2582
- return (
2583
- `<g><path d="${d}" fill="none" stroke="${lkHexA(c, 0.4)}" stroke-width="1.8" stroke-dasharray="3 4" stroke-linecap="round"/>` +
2584
- `<circle r="2.6" fill="${c}">` +
2585
- `<animateMotion dur="2.6s" begin="${i * 0.5}s" repeatCount="indefinite" path="${d}"/>` +
2586
- `<animate attributeName="opacity" dur="2.6s" begin="${i * 0.5}s" repeatCount="indefinite" values="0;1;1;0" keyTimes="0;0.15;0.8;1"/>` +
2587
- `</circle></g>`
2588
- );
2589
- }).join("");
2590
- const svg =
2591
- `<svg viewBox="0 0 300 132" width="272" height="120" aria-hidden="true" style="display:block">` +
2592
- `<ellipse cx="150" cy="66" rx="132" ry="58" fill="${lkHexA(c, 0.05)}"/>` +
2593
- `<rect x="123" y="44" width="54" height="46" rx="11" fill="#fff" stroke="${c}" stroke-width="1.8"/>` +
2594
- `<path d="M135 58h30M135 67h30M135 76h20" stroke="${lkHexA(c, 0.5)}" stroke-width="2" stroke-linecap="round"/>` +
2595
- machines + arcs +
2596
- `</svg>`;
2597
-
2598
- return (
2599
- `<div class="es-root" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;box-sizing:border-box;padding:40px 32px;background:${bgStyle};overflow-y:auto">` +
2600
- `<div style="width:100%;max-width:520px;display:flex;flex-direction:column;align-items:center;text-align:center">` +
2601
- svg +
2602
- `<h1 style="margin:18px 0 0;font-size:27px;font-weight:800;letter-spacing:-.025em;line-height:1.14;color:${text};text-wrap:balance">连接公司中心库,和团队共享助手</h1>` +
2603
- `<p style="margin:11px 0 0;font-size:15.5px;color:${muted};line-height:1.62;max-width:440px;text-wrap:pretty">把本机调好的助手<b style="color:${brText}">晋升</b>到团队的中心库,别的同事一拉就能用同一份;别人晋升的,你也<b style="color:#334155">一键同步</b>下来。</p>` +
2604
- `<button id="cc-guide-connect" type="button" class="es-cta-btn" style="margin-top:24px;display:inline-flex;align-items:center;gap:9px;padding:13px 26px;font-size:15.5px;font-weight:700;font-family:inherit;color:#fff;background:${c};border:none;border-radius:12px;cursor:pointer;box-shadow:0 4px 14px ${lkHexA(c, 0.26)};transition:background .15s">${ICONS.link2} 连接公司中心库</button>` +
2605
- `<div style="margin-top:18px;display:inline-flex;align-items:center;gap:7px;font-size:12.5px;color:${faint}">${ICONS.info} 第一次连可能要工程师帮忙给个仓库地址;连好之后日常同步、晋升你自己点就行</div>` +
2606
- `</div></div>`
2607
- );
2608
- }
2609
1826
 
2610
1827
  // ---------------------------------------------------------------------------
2611
1828
  // 详情区「空态」(名册有助手但未选中)—— 复刻 Claude Design empty-detail 稿。
2612
- // 两个变体:本机(可改,indigo)/ 公司中心库(只读,slate)。设计稿 emptyDetail.jsx。
2613
1829
  // ---------------------------------------------------------------------------
2614
1830
 
2615
1831
  /** 空态用的 icon path(复合 path 按 'M' 拆段,见 edIcon)。 */
@@ -2697,34 +1913,20 @@ function edRail(steps) {
2697
1913
  `</div>`;
2698
1914
  }
2699
1915
 
2700
- /** 详情区空态 HTML(ro=公司中心库只读变体)。 */
2701
- function buildEmptyDetailHTML(ro) {
2702
- if (ro) {
2703
- return `<div class="lk-ed lk-ed--ro" style="container-type:inline-size;container-name:lk-ed">` +
2704
- `<div class="lk-ed__inner">${edHeroSVG(true, 348)}` +
2705
- `<span class="lk-ed__eyebrow">${edIcon(ED_ICON.lock, 14, 2.1)} 公司中心库 · 只读</span>` +
2706
- `<h2 class="lk-ed__title">选一个共享助手,看它怎么配的</h2>` +
2707
- `<p class="lk-ed__body">这里是全组共享的助手,内容<b>只读</b> —— 能看它的配置和职责,照着用。想改?先回<b class="br">「本机」</b>改好,再<b>「晋升」</b>覆盖回来。</p>` +
2708
- `<div class="lk-ed__actions"><button type="button" id="ed-go-local" class="lk-ed__btn lk-ed__btn--soft">${edIcon(ED_ICON.arrowL, 16, 2.1)} 回「本机」去改配置</button></div>` +
2709
- edRail([
2710
- { d: ED_ICON.eye, t: "在这里浏览", s: "只读看配置 / 职责" },
2711
- { d: ED_ICON.edit, t: "回本机修改", s: "改动只能在本机做" },
2712
- { d: ED_ICON.upload, t: "晋升覆盖", s: "推上来,全组更新" },
2713
- ]) +
2714
- `<span class="lk-ed__foot">${edIcon(ED_ICON.lock, 14, 1.7)} 中心库只读;任何改动都要从本机晋升上来</span></div></div>`;
2715
- }
1916
+ /** 详情区空态 HTML(本机 · 可改)。 */
1917
+ function buildEmptyDetailHTML() {
2716
1918
  return `<div class="lk-ed" style="container-type:inline-size;container-name:lk-ed">` +
2717
1919
  `<div class="lk-ed__inner">${edHeroSVG(false, 348)}` +
2718
1920
  `<span class="lk-ed__eyebrow">${edIcon(ED_ICON.edit, 14, 2)} 本机 · 可改</span>` +
2719
1921
  `<h2 class="lk-ed__title">从左边选一个助手,开始配置</h2>` +
2720
- `<p class="lk-ed__body">在<b>「本机」</b>里可以改它的名字、介绍、能用它的群、能改的代码仓库,还有它的职责说明。改好后用<b class="br">「晋升」</b>把配置交给公司中心库,别的电脑就能拉到同一份。</p>` +
1922
+ `<p class="lk-ed__body">在这里可以改它的名字、介绍、能用它的群、能改的代码仓库,还有它的职责说明。改好点<b class="br">「保存」</b>就写入本机生效,同事在群里 @ 它就能用。</p>` +
2721
1923
  `<div class="lk-ed__actions">` +
2722
1924
  `<button type="button" id="ed-add-bot" class="lk-ed__btn lk-ed__btn--primary">${edIcon(ED_ICON.plus, 18, 2.2)} 添加新助手</button>` +
2723
1925
  `<button type="button" id="ed-pick" class="lk-ed__btn lk-ed__btn--ghost">${edIcon(ED_ICON.arrowL, 16, 2)} 从名册里挑一个</button></div>` +
2724
1926
  edRail([
2725
1927
  { d: ED_ICON.sliders, t: "配置", s: "改名字、群、仓库、职责" },
2726
- { d: ED_ICON.upload, t: "晋升", s: "交给公司中心库" },
2727
- { d: ED_ICON.users, t: "全组复用", s: "别的电脑拉同一份" },
1928
+ { d: ED_ICON.edit, t: "保存", s: "写入本机即生效" },
1929
+ { d: ED_ICON.users, t: "群里 @", s: "同事直接用" },
2728
1930
  ]) +
2729
1931
  `<span class="lk-ed__foot">${edIcon(ED_ICON.shield, 14, 1.7)} 全程不碰终端;密钥只存在你本机,别人看不到</span></div></div>`;
2730
1932
  }
@@ -2733,16 +1935,11 @@ function buildEmptyDetailHTML(ro) {
2733
1935
  function renderDetailPlaceholder() {
2734
1936
  const placeholder = document.getElementById("detail-placeholder");
2735
1937
  if (!placeholder) return;
2736
- const ro = state.mode === "central";
2737
- placeholder.innerHTML = buildEmptyDetailHTML(ro);
2738
- if (ro) {
2739
- placeholder.querySelector("#ed-go-local")?.addEventListener("click", () => switchContext("local"));
2740
- } else {
2741
- placeholder.querySelector("#ed-add-bot")?.addEventListener("click", () => openOnboardModal());
2742
- placeholder.querySelector("#ed-pick")?.addEventListener("click", () => {
2743
- if (state.bots[0]) selectBot(state.bots[0].id);
2744
- });
2745
- }
1938
+ placeholder.innerHTML = buildEmptyDetailHTML();
1939
+ placeholder.querySelector("#ed-add-bot")?.addEventListener("click", () => openOnboardModal());
1940
+ placeholder.querySelector("#ed-pick")?.addEventListener("click", () => {
1941
+ if (state.bots[0]) selectBot(state.bots[0].id);
1942
+ });
2746
1943
  }
2747
1944
 
2748
1945
  /**
@@ -2760,26 +1957,6 @@ function renderEmptyOrPlaceholder() {
2760
1957
  const existingEs = detail.querySelector(".es-panel");
2761
1958
  const panel = detail.querySelector(".detail-panel");
2762
1959
 
2763
- // 中心库未连接 → 全屏连接引导卡(LkConnectGuide),优先级高于空态。
2764
- const centralDisconnected = state.mode === "central" && !state.centralConnected;
2765
-
2766
- if (centralDisconnected) {
2767
- if (placeholder) placeholder.style.display = "none";
2768
- if (panel) panel.style.display = "none";
2769
- if (!existingEs) {
2770
- const esEl = document.createElement("div");
2771
- esEl.className = "es-panel";
2772
- esEl.style.cssText = "flex:1;min-height:0;display:flex;flex-direction:column;height:100%";
2773
- detail.appendChild(esEl);
2774
- }
2775
- const es = detail.querySelector(".es-panel");
2776
- if (es) {
2777
- es.innerHTML = buildConnectGuideHTML();
2778
- es.querySelector("#cc-guide-connect")?.addEventListener("click", () => openConnectFlow());
2779
- }
2780
- return;
2781
- }
2782
-
2783
1960
  if (state.bots.length === 0 && !state.selected) {
2784
1961
  // 零助手 → 空态全屏
2785
1962
  if (placeholder) placeholder.style.display = "none";
@@ -2793,9 +1970,8 @@ function renderEmptyOrPlaceholder() {
2793
1970
  }
2794
1971
  const es = detail.querySelector(".es-panel");
2795
1972
  if (es) {
2796
- const variant = state.mode === "central" ? "central" : "local";
2797
1973
  const serviceRunning = state.bridge?.running ?? false;
2798
- es.innerHTML = buildEmptyStateHTML(variant, serviceRunning);
1974
+ es.innerHTML = buildEmptyStateHTML(serviceRunning);
2799
1975
  wireEmptyStateEvents(es);
2800
1976
  }
2801
1977
  } else {
@@ -2803,7 +1979,7 @@ function renderEmptyOrPlaceholder() {
2803
1979
  if (existingEs) existingEs.remove();
2804
1980
  if (panel) panel.style.display = "";
2805
1981
  if (!state.selected && placeholder) {
2806
- renderDetailPlaceholder(); // 按当前 mode 渲染本机/只读空态变体
1982
+ renderDetailPlaceholder();
2807
1983
  placeholder.style.display = "";
2808
1984
  }
2809
1985
  }
@@ -2811,14 +1987,10 @@ function renderEmptyOrPlaceholder() {
2811
1987
 
2812
1988
  /** 接线空态内的事件。*/
2813
1989
  function wireEmptyStateEvents(container) {
2814
- // 本机变体:CTA → openOnboardModal
1990
+ // CTA → openOnboardModal
2815
1991
  const addBtn = container.querySelector("#es-add-btn");
2816
1992
  addBtn?.addEventListener("click", () => openOnboardModal());
2817
1993
 
2818
- // central 变体:「切到本机」→ switchContext('local')
2819
- const switchBtn = container.querySelector(".es-central-switch-btn");
2820
- switchBtn?.addEventListener("click", () => switchContext("local"));
2821
-
2822
1994
  // CTA hover 效果
2823
1995
  addBtn?.addEventListener("mouseenter", () => {
2824
1996
  addBtn.style.background = "#4338ca";
@@ -2866,8 +2038,6 @@ async function renderBotDetail(id) {
2866
2038
  }
2867
2039
  panel.innerHTML = `<div class="loading-block"><span class="spinner spinner-lg"></span><span>正在打开助手…</span></div>`;
2868
2040
 
2869
- const readonly = state.mode === "central";
2870
-
2871
2041
  // 并行拉配置 + memory
2872
2042
  const [botRes, memRes] = await Promise.all([
2873
2043
  api("GET", `/api/bot/${encodeURIComponent(id)}`),
@@ -2889,17 +2059,7 @@ async function renderBotDetail(id) {
2889
2059
  state.formDirty = false;
2890
2060
  state.memoryDirty = false;
2891
2061
 
2892
- // 中心库 = 只读展示窗(CcReadonlyDetail):by/updated/commit + 回本机引导 + 只读 memory。
2893
- if (readonly) {
2894
- panel.innerHTML = buildCentralDetailHTML(id, bot, memContent);
2895
- panel.classList.remove("panel-enter");
2896
- void panel.offsetWidth;
2897
- panel.classList.add("panel-enter");
2898
- panel.querySelector("#cc-detail-golocal")?.addEventListener("click", () => switchContext("local"));
2899
- return;
2900
- }
2901
-
2902
- panel.innerHTML = buildDetailHTML(id, bot, memContent, readonly);
2062
+ panel.innerHTML = buildDetailHTML(id, bot, memContent);
2903
2063
  // 切 bot 丝滑:详情区淡入(reduced-motion 下 transition 被禁,无白闪)
2904
2064
  panel.classList.remove("panel-enter");
2905
2065
  void panel.offsetWidth; // 强制 reflow 重新触发动画
@@ -2907,137 +2067,13 @@ async function renderBotDetail(id) {
2907
2067
  // AC 面板头像渲染(#ac-avatar-wrap 由 buildAgentConfigHTML 留空)
2908
2068
  const avatar = state.avatars[id] ?? bot.avatar ?? null;
2909
2069
  const liveKey = botLiveness(id);
2910
- acRenderAvatar(panel, id, bot.name || id, avatar, liveKey);
2911
- wireDetailEvents(panel, id, bot, readonly);
2912
- renderDetailBanner();
2913
- loadRecentEvents(id, panel);
2914
- }
2915
-
2916
- /**
2917
- * 构建中心库只读详情(CcReadonlyDetail 复刻):
2918
- * hero(标题 + 描述 + by/updated/commit + 谁能用它/能改仓库/谁晋升的) + 只读横幅(回本机改) + 只读职责说明。
2919
- * 中心库不显在线/心跳;memory 即「职责说明」,只读;密钥不显示。
2920
- */
2921
- function buildCentralDetailHTML(id, bot, memContent) {
2922
- const meta = state.centralBots.find((b) => b.id === id) ?? {};
2923
- const by = meta.by || "—";
2924
- const updated = meta.updated || "—";
2925
- const commit = meta.commit || "—";
2926
- const name = bot.name ?? id;
2927
- const desc = bot.description ?? "";
2928
- const chats = Array.isArray(bot.chats) ? bot.chats.length : 0;
2929
- const repos = Array.isArray(bot.repos) ? bot.repos.length : 0;
2930
-
2931
- // L1 权限层(只读):能改仓库 / repos 明细 / 令牌变量名(脱敏,绝不显真值)+ 约束。
2932
- const repoList = Array.isArray(bot.repos) ? bot.repos : [];
2933
- const codeAccess = !!(bot.gitlab_token_env || repoList.length > 0);
2934
- const turnLimit = bot.turn_taking_limit ?? 10;
2935
- // chats 元素可能是字符串(oc_…)或 {label/chat_id} 对象;统一成可读文案。
2936
- const chatLabel = (c) =>
2937
- typeof c === "string" ? c : (c?.label || c?.chat_id || c?.id || JSON.stringify(c));
2938
- const chatsList = Array.isArray(bot.chats) ? bot.chats.map(chatLabel) : [];
2939
-
2940
- // 只读卡片外壳(与「它的职责说明」同款)。
2941
- const roCard = (title, inner) =>
2942
- `<div style="background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:22px 24px;box-shadow:var(--shadow-sm);margin-bottom:16px">` +
2943
- `<h4 style="margin:0 0 14px;font-size:16px;font-weight:700;color:var(--text)">${esc(title)}</h4>` +
2944
- inner +
2945
- `</div>`;
2946
- const roKv = (label, value) =>
2947
- `<div style="display:flex;gap:12px;align-items:baseline;padding:7px 0">` +
2948
- `<span style="font-size:12.5px;color:var(--faint);width:104px;flex-shrink:0">${esc(label)}</span>` +
2949
- `<span style="font-size:13.5px;color:var(--text);line-height:1.5">${esc(value)}</span></div>`;
2950
-
2951
- // 「它的权限」卡内容
2952
- const permInner = codeAccess
2953
- ? `<div style="display:inline-flex;align-items:center;gap:7px;font-size:14px;font-weight:600;color:${LIVE_TEXT.serving}">${ICONS.check} 能改代码仓库 · 通过 GitLab 令牌</div>` +
2954
- (repoList.length
2955
- ? `<div style="margin-top:12px;display:flex;flex-direction:column;gap:8px">` +
2956
- repoList.map((r) =>
2957
- `<div style="padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg)">` +
2958
- `<div style="font-size:13.5px;font-weight:600;color:var(--text);font-family:ui-monospace,monospace">${esc(r.slug || "—")} <span style="color:var(--faint);font-weight:400">@ ${esc(r.branch || "main")}</span></div>` +
2959
- (r.url ? `<div style="margin-top:3px;font-size:12px;color:var(--muted);font-family:ui-monospace,monospace;word-break:break-all">${esc(r.url)}</div>` : "") +
2960
- `</div>`
2961
- ).join("") +
2962
- `</div>`
2963
- : `<p style="margin:10px 0 0;font-size:13px;color:var(--muted);line-height:1.55">未列具体仓库 —— agent 用令牌自己判断需要哪些、自己 clone。</p>`) +
2964
- (bot.gitlab_token_env
2965
- ? `<p style="margin:12px 0 0;font-size:12px;color:var(--faint);display:flex;align-items:center;gap:6px">${ICONS.lock} 令牌已配置,真值只存本机 <code>.env</code>(0600),这里看不到。</p>`
2966
- : "")
2967
- : `<div style="display:inline-flex;align-items:center;gap:7px;font-size:14px;color:var(--muted)">${ICONS.lock} 纯答疑,不碰代码。</div>`;
2968
-
2969
- // 「行为约束」卡内容
2970
- const constraintInner =
2971
- roKv("谁能 @ 它", chatsList.length ? chatsList.join("、") : "任何群都能 @(没限制)") +
2972
- roKv("最多连做", `${turnLimit} 步就停下问人`);
2973
-
2974
- const metaCell = (label, value, tone) =>
2975
- `<div style="display:flex;flex-direction:column;gap:3px">` +
2976
- `<span style="font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)">${esc(label)}</span>` +
2977
- `<span style="font-size:13.5px;font-weight:600;color:${tone || "var(--text)"}">${esc(value)}</span>` +
2978
- `</div>`;
2979
-
2980
- const chatVal = chats === 0 ? "任何群都能 @" : `仅 ${chats} 个群`;
2981
- const repoVal = repos === 0 ? "纯答疑" : `${repos} 个仓库`;
2982
- const botBackend = bot.backend || LK_BACKEND_DEFAULT;
2983
-
2984
- // 底座列:用 chip(sm + mono)内联显示,套进 metaCell value 位置
2985
- const backendCellValue =
2986
- `<span style="display:inline-flex">${lkBackendChipHTML(botBackend, { size: "sm", mono: true })}</span>`;
2987
- const metaCellRaw = (label, innerHTML) =>
2988
- `<div style="display:flex;flex-direction:column;gap:3px">` +
2989
- `<span style="font-size:10.5px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)">${esc(label)}</span>` +
2990
- innerHTML +
2991
- `</div>`;
2992
-
2993
- return (
2994
- // hero band
2995
- `<div style="padding:30px 44px 24px;background:linear-gradient(180deg,var(--bg) 0%,var(--surface) 100%);border-bottom:1px solid var(--border)">` +
2996
- `<div style="max-width:800px;display:flex;align-items:flex-start;gap:22px">` +
2997
- avatarHTML(id, name, bot.avatar ?? null, "list", "unknown").replace(/avatar-list/, "avatar-list cc-detail-avatar").replace(/<span class="avatar-dot[^>]*><\/span>/, "") +
2998
- `<div style="flex:1;min-width:0;padding-top:2px">` +
2999
- `<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">` +
3000
- `<span style="font-size:11px;font-weight:700;letter-spacing:.12em;text-transform:uppercase;color:var(--faint)">公司中心库 · 共享助手</span>` +
3001
- `<span style="display:inline-flex;align-items:center;gap:5px;padding:2px 9px;border-radius:999px;font-size:11.5px;font-weight:700;color:var(--muted);background:var(--bg);border:1px solid var(--border)">${ICONS.lock} 只读</span>` +
3002
- `</div>` +
3003
- `<h1 style="margin:0 0 8px;font-size:31px;font-weight:800;letter-spacing:-.025em;line-height:1.06;color:var(--text)">${esc(name)}</h1>` +
3004
- `<p style="margin:0 0 14px;font-size:15.5px;color:var(--muted);line-height:1.55;max-width:540px">${esc(desc || "(还没填介绍)")}</p>` +
3005
- `<div style="display:flex;align-items:center;gap:14px;flex-wrap:wrap;font-size:13px;color:var(--muted)">` +
3006
- `<span style="display:inline-flex;align-items:center;gap:6px">${ICONS.upload} ${esc(by)} 晋升</span>` +
3007
- `<span style="color:var(--faint)">·</span><span>更新于 ${esc(updated)}</span>` +
3008
- `<span style="color:var(--faint)">·</span><span style="font-family:ui-monospace,monospace">commit ${esc(commit)}</span>` +
3009
- `</div></div></div>` +
3010
- `<div style="max-width:800px;margin-top:22px;padding-top:18px;border-top:1px solid var(--border);display:grid;grid-template-columns:repeat(4,1fr);gap:16px">` +
3011
- metaCell("谁能用它", chatVal, chats === 0 ? BR.text : null) +
3012
- metaCell("能改仓库", repoVal, null) +
3013
- metaCell("谁晋升的", by, null) +
3014
- metaCellRaw("底座", backendCellValue) +
3015
- `</div></div>` +
3016
- // body
3017
- `<div style="padding:24px 44px 36px"><div style="max-width:800px">` +
3018
- // 只读横幅 + 回本机引导
3019
- `<div class="cc-readonly-banner">${ICONS.lock}` +
3020
- `<div style="min-width:0;flex:1">` +
3021
- `<div class="cc-readonly-banner-title">这是中心库里的只读副本</div>` +
3022
- `<div class="cc-readonly-banner-sub">中心库不能直接改。想改?回「本机」改好那一份,再<b>晋升</b>覆盖上来。</div>` +
3023
- `</div>` +
3024
- `<button type="button" id="cc-detail-golocal" class="cc-go-local-btn">回本机改 ${ICONS.arrowRight}</button>` +
3025
- `</div>` +
3026
- // 只读底座展示卡
3027
- roCard("底座", lkBackendSelectHTML(botBackend, true)) +
3028
- // L1 权限层(只读):它能改哪些仓库 + 令牌变量名(脱敏)
3029
- roCard("它的权限", permInner) +
3030
- // 约束层(只读):谁能 @ + 最多连做几步
3031
- roCard("行为约束", constraintInner) +
3032
- // 只读职责说明(memory)
3033
- `<div style="background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:22px 24px;box-shadow:var(--shadow-sm)">` +
3034
- `<h4 style="margin:0 0 14px;font-size:16px;font-weight:700;color:var(--text)">它的职责说明</h4>` +
3035
- `<div style="width:100%;padding:12px 14px;border:1px solid var(--border);border-radius:10px;background:var(--bg);font-size:13.5px;line-height:1.65;color:#334155;font-family:ui-monospace,'Cascadia Code',monospace;white-space:pre-wrap">${esc(memContent || "(没有职责说明)")}</div>` +
3036
- `<p style="margin:12px 0 0;font-size:12px;color:var(--faint);display:flex;align-items:center;gap:6px">${ICONS.lock} 只读 —— 在这里看不到、也改不了真密钥;每个人同步到本机后用自己的密钥跑。</p>` +
3037
- `</div></div></div>`
3038
- );
2070
+ acRenderAvatar(panel, id, bot.name || id, avatar, liveKey);
2071
+ wireDetailEvents(panel, id, bot);
2072
+ renderDetailBanner();
2073
+ loadRecentEvents(id, panel);
3039
2074
  }
3040
2075
 
2076
+
3041
2077
  /**
3042
2078
  * 构建 hero band 内层(名字 / 介绍 / meta strip)—— 依赖 P.form 的实时值,
3043
2079
  * 输入时由 refreshHero() 重渲染,与编辑式 hero 的「所见即所改」一致。
@@ -3046,7 +2082,7 @@ function buildCentralDetailHTML(id, bot, memContent) {
3046
2082
  * @param {{name:string,description:string,chatCount:number,repoCount:number}} f 当前表单值
3047
2083
  * @param {boolean} readonly
3048
2084
  */
3049
- function buildHeroInner(id, bot, f, readonly) {
2085
+ function buildHeroInner(id, bot, f) {
3050
2086
  const idx = state.bots.findIndex((b) => b.id === id);
3051
2087
  const num = String((idx < 0 ? 0 : idx) + 1).padStart(2, "0");
3052
2088
  // ⑤ hero 内状态条:BL-18 重启窗口内用 restartDisplayLive 覆盖;否则 effLive
@@ -3057,10 +2093,8 @@ function buildHeroInner(id, bot, f, readonly) {
3057
2093
  const dispLiveKey = (isR || isTimeout) ? restartDisplayLive(realLive, rs.status) : realLive;
3058
2094
  const liveKey = dispLiveKey;
3059
2095
 
3060
- const editBadge = readonly
3061
- ? `<span class="hero-badge hero-badge-ro">${ICONS.lock} 只读</span>`
3062
- : `<span class="hero-badge">${ICONS.check} 可改</span>`;
3063
- const eyebrowLabel = readonly ? "公司中心库 · 第" : "本机助手 · 第";
2096
+ const editBadge = `<span class="hero-badge">${ICONS.check} 可改</span>`;
2097
+ const eyebrowLabel = "本机助手 · 第";
3064
2098
 
3065
2099
  // 状态条(1.5px edge pill):重启中 → sky「重启中 · 预期内,正在恢复」;否则正常
3066
2100
  const statusPillText = isR
@@ -3109,11 +2143,9 @@ function buildHeroInner(id, bot, f, readonly) {
3109
2143
  `<span style="display:inline-flex">${lkBackendChipHTML(bot.backend || LK_BACKEND_DEFAULT, { size: "sm", mono: true })}</span>` +
3110
2144
  `</div>`;
3111
2145
 
3112
- const heroDelBtn = readonly
3113
- ? ""
3114
- : `<button class="btn btn-hero-del" id="btn-hero-del" type="button" title="删除助手">` +
3115
- `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" style="width:14px;height:14px;flex-shrink:0"><path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></svg>` +
3116
- ` 删除</button>`;
2146
+ const heroDelBtn = `<button class="btn btn-hero-del" id="btn-hero-del" type="button" title="删除助手">` +
2147
+ `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" style="width:14px;height:14px;flex-shrink:0"><path d="M3 6h18M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6"/></svg>` +
2148
+ ` 删除</button>`;
3117
2149
 
3118
2150
  // 触点②:hero kicker 行,紧跟眉批加 backend chip(mono + vendor)
3119
2151
  const backendKickerChip = lkBackendChipHTML(bot.backend || LK_BACKEND_DEFAULT, { mono: true, vendor: true });
@@ -3295,37 +2327,44 @@ function normalizeRepos(raw) {
3295
2327
  if (!Array.isArray(raw)) return [];
3296
2328
  return raw.map((r) => {
3297
2329
  if (typeof r === "string") {
3298
- const [slug, branch = "master"] = r.split(":").map((s) => s.trim());
2330
+ const [slug, branch = "main"] = r.split(":").map((s) => s.trim());
3299
2331
  return { slug, branch, url: "" };
3300
2332
  }
3301
- return { slug: r.slug ?? "", branch: r.branch ?? "master", url: r.url ?? "" };
2333
+ return { slug: r.slug ?? "", branch: r.branch ?? "main", url: r.url ?? "" };
3302
2334
  });
3303
2335
  }
3304
2336
 
2337
+ function inferRepoSlugFromUrl(url) {
2338
+ const text = String(url ?? "").trim();
2339
+ if (!text) return "";
2340
+ const stripGit = (s) => s.replace(/\.git$/i, "").replace(/^\/+|\/+$/g, "");
2341
+
2342
+ const scp = /^[A-Za-z0-9_.-]+@[^:\s]+:(.+)$/.exec(text);
2343
+ if (scp) return stripGit(scp[1] ?? "");
2344
+
2345
+ try {
2346
+ const parsed = new URL(text);
2347
+ return stripGit(parsed.pathname);
2348
+ } catch {
2349
+ return "";
2350
+ }
2351
+ }
2352
+
3305
2353
  /**
3306
- * 生成一个仓库行的 HTML(可读 / 可删)。
2354
+ * 生成一个仓库行的 HTML。UI 只暴露 Git 地址;slug/branch 都是内部细节。
3307
2355
  * @param {number} idx
3308
2356
  * @param {{slug:string,branch:string,url:string}} repo
3309
2357
  */
3310
2358
  function acRepoRowHTML(idx, repo) {
3311
2359
  const xIcon = `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>`;
2360
+ const displayUrl = repo.url || "";
3312
2361
  return (
3313
2362
  `<div class="ac-repo-row" data-repo-idx="${idx}">` +
3314
- `<div class="ac-repo-grid">` +
3315
- `<div class="ac-field">` +
3316
- `<label class="ac-label" for="ac-repo-slug-${idx}">组 / 项目</label>` +
3317
- `<input id="ac-repo-slug-${idx}" class="ac-input ac-mono" type="text" placeholder="chuckwu0/larkway" spellcheck="false" data-repo="slug" data-repo-idx="${idx}" value="${esc(repo.slug)}" />` +
3318
- `</div>` +
3319
2363
  `<div class="ac-field">` +
3320
- `<label class="ac-label" for="ac-repo-branch-${idx}">分支</label>` +
3321
- `<input id="ac-repo-branch-${idx}" class="ac-input ac-mono" type="text" placeholder="main" spellcheck="false" data-repo="branch" data-repo-idx="${idx}" value="${esc(repo.branch)}" />` +
3322
- `</div>` +
2364
+ `<label class="ac-label" for="ac-repo-url-${idx}">仓库 Git 地址 <span class="ac-required">必填</span></label>` +
2365
+ `<input id="ac-repo-url-${idx}" class="ac-input ac-mono" type="text" placeholder="git@github.com:org/repo.git" spellcheck="false" data-repo="url" data-repo-idx="${idx}" value="${esc(displayUrl)}" />` +
3323
2366
  `</div>` +
3324
- `<div class="ac-field" style="margin-top:8px">` +
3325
- `<label class="ac-label" for="ac-repo-url-${idx}">clone 地址 <span class="ac-optional">私有库必需</span></label>` +
3326
- `<input id="ac-repo-url-${idx}" class="ac-input ac-mono" type="text" placeholder="git@your-gitlab.example.com:group/repo.git" spellcheck="false" data-repo="url" data-repo-idx="${idx}" value="${esc(repo.url)}" />` +
3327
- `</div>` +
3328
- `<button type="button" class="ac-repo-del" title="移除这个仓库" data-repo-idx="${idx}" aria-label="移除仓库 ${esc(repo.slug || idx + 1)}">` +
2367
+ `<button type="button" class="ac-repo-del" title="移除这个仓库" data-repo-idx="${idx}" aria-label="移除仓库 ${esc(repo.slug || repo.url || idx + 1)}">` +
3329
2368
  xIcon +
3330
2369
  `</button>` +
3331
2370
  `</div>`
@@ -3355,8 +2394,8 @@ function acPillHTML(tone, iconD, text) {
3355
2394
  function buildAgentConfigHTML(bot, memContent, mode, prefill) {
3356
2395
  const isCreate = mode === "create";
3357
2396
  const repos = normalizeRepos(bot.repos);
3358
- // ① 用 gitlab_token_env 非空来判断「已配置」——新保存契约:值存后端,前端只知道「有/无」
3359
- const gitlabConfigured = !!(bot.gitlab_token_env);
2397
+ // ① 用 git_token_env(兼容旧 gitlab_token_env)非空来判断「已配置」——新保存契约:值存后端,前端只知道「有/无」
2398
+ const gitlabConfigured = !!(bot.git_token_env || bot.gitlab_token_env);
3360
2399
  const codeAccess = !!(gitlabConfigured || repos.length > 0);
3361
2400
  const chatsVal = Array.isArray(bot.chats) ? bot.chats.join("\n") : (bot.chats || "");
3362
2401
  const turnLimit = bot.turn_taking_limit ?? 10;
@@ -3367,7 +2406,8 @@ function buildAgentConfigHTML(bot, memContent, mode, prefill) {
3367
2406
  // 层二 summary 药丸(收起态)
3368
2407
  const permSummaryHTML = codeAccess
3369
2408
  ? acPillHTML("brand", "m16 18 6-6-6-6M8 6l-6 6 6 6", "可访问代码仓库") +
3370
- acPillHTML("muted", "M21 8 12 3 3 8v8l9 5 9-5ZM3 8l9 5 9-5M12 13v8", repos.length ? `预热 ${repos.length} 个仓库` : "agent 自己 clone")
2409
+ acPillHTML("muted", "M21 8 12 3 3 8v8l9 5 9-5ZM3 8l9 5 9-5M12 13v8", repos.length ? `${repos.length} 个仓库` : "待添加仓库") +
2410
+ acPillHTML("muted", "M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4", gitlabConfigured ? "Agent 级 Git 身份" : "沿用本机 Git 身份")
3371
2411
  : acPillHTML("muted", "M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4", "纯对话 · 不碰任何代码仓库");
3372
2412
 
3373
2413
  // 层三 summary 药丸
@@ -3450,7 +2490,7 @@ function buildAgentConfigHTML(bot, memContent, mode, prefill) {
3450
2490
  `<div class="ac-access-toggle-row${codeAccess ? " is-on" : ""}" id="ac-access-row">` +
3451
2491
  `<div class="ac-access-info">` +
3452
2492
  `<div class="ac-access-title">给它访问代码仓库的权限</div>` +
3453
- `<p class="ac-access-desc">通过配一个 <b>GitLab 访问令牌</b> 来表达。读和改都用这一个令牌 —— <b>没有读 / 写之分</b>,agent 看任务自己定。不开 = 纯对话 / 自带知识答疑。</p>` +
2493
+ `<p class="ac-access-desc">打开后给这个 Agent 配一组仓库地址;它只有这些 repo 作为默认工作范围。Git 身份是 Agent 级的:不填就沿用这台机器现有的 SSH key、credential helper 或环境变量。</p>` +
3454
2494
  `</div>` +
3455
2495
  `<button type="button" class="ac-toggle${codeAccess ? " is-on" : ""}" id="ac-code-access-btn" role="switch" aria-checked="${codeAccess}" title="开 / 关代码访问权限">` +
3456
2496
  `<span class="ac-toggle-thumb"></span>` +
@@ -3461,40 +2501,43 @@ function buildAgentConfigHTML(bot, memContent, mode, prefill) {
3461
2501
  `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0;color:var(--faint)"><path d="M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4"/></svg>` +
3462
2502
  `当前这个 agent 不访问任何代码仓库。需要它读 / 改代码时,再打开上面的开关。` +
3463
2503
  `</div>` +
3464
- // 开启态:令牌 + 仓库
2504
+ // 开启态:仓库 + Agent 级 Git 身份
3465
2505
  `<div class="ac-access-fields" id="ac-access-fields" ${codeAccess ? "" : 'style="display:none"'}>` +
2506
+ // 仓库列表
2507
+ `<div class="ac-repos-section">` +
2508
+ `<div class="ac-repos-header">` +
2509
+ `<span class="ac-repos-title">这个 Agent 可碰的仓库</span>` +
2510
+ `<span class="ac-optional-badge">可多个</span>` +
2511
+ `</div>` +
2512
+ `<p class="ac-hint">每行只填 clone 地址。分支默认 main;同一个 Agent 的所有仓库共用下面的 Git 身份。</p>` +
2513
+ `<div class="ac-repos-empty" id="ac-repos-empty" ${repos.length ? 'style="display:none"' : ""}><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Z"/></svg>还没有仓库。需要访问代码时,请添加仓库 Git 地址。</div>` +
2514
+ `<div class="ac-repos-list" id="ac-repos-list" ${repos.length ? "" : 'style="display:none"'}>${repos.map((r, i) => acRepoRowHTML(i, r)).join("")}</div>` +
2515
+ `<button type="button" class="ac-add-repo-btn" id="ac-add-repo-btn">` +
2516
+ `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>` +
2517
+ ` 添加仓库` +
2518
+ `</button>` +
2519
+ `</div>` + // ac-repos-section
2520
+ `<details class="ac-token-advanced">` +
2521
+ `<summary>Git 身份 <span>可选,作用于上面所有仓库</span></summary>` +
3466
2522
  `<div class="ac-field">` +
3467
- `<label class="ac-label" for="ac-gitlab-token">GitLab Access Token</label>` +
3468
- `<p class="ac-hint">把 GitLab access token 粘这里。只存本机 <code>~/.larkway/.env</code>(权限 0600),不回显、不外发。</p>` +
2523
+ `<label class="ac-label" for="ac-gitlab-token">Agent 级 Git Access Token <span class="ac-optional">可选</span></label>` +
2524
+ `<p class="ac-hint">只配置一个,供这个 Agent 的所有仓库使用。GitHub fine-grained PAT 可以在 GitHub 里只勾选这些仓库;这里只保存 token 真值到本机 <code>~/.larkway/.env</code>(权限 0600),页面不回显。</p>` +
3469
2525
  (gitlabConfigured
3470
2526
  ? `<div class="ac-token-configured" id="ac-token-configured">` +
3471
- `<span class="ac-token-mask">${ICONS.lock} 已配置 <span style="letter-spacing:.12em">••••••</span></span>` +
2527
+ `<span class="ac-token-mask">${ICONS.lock} 已配置此 Agent 的 Git 身份 <span style="letter-spacing:.12em">••••••</span></span>` +
3472
2528
  `<button type="button" class="btn btn-sm ac-token-reset-btn" id="ac-token-reset-btn">重新设置</button>` +
3473
2529
  `</div>` +
3474
2530
  `<div class="ac-secret-wrap" id="ac-token-input-wrap" style="display:none">` +
3475
2531
  `<svg class="ac-secret-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4"/></svg>` +
3476
- `<input id="ac-gitlab-token" name="gitlab_token_value" class="ac-input ac-secret-input" type="password" autocomplete="new-password" placeholder="粘贴新 token(只存本机,不回显)" value="" />` +
2532
+ `<input id="ac-gitlab-token" name="gitlab_token_value" class="ac-input ac-secret-input" type="password" autocomplete="new-password" placeholder="粘贴新的 Agent 级 token" value="" />` +
3477
2533
  `</div>`
3478
2534
  : `<div class="ac-secret-wrap" id="ac-token-input-wrap">` +
3479
2535
  `<svg class="ac-secret-icon" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4"/></svg>` +
3480
- `<input id="ac-gitlab-token" name="gitlab_token_value" class="ac-input ac-secret-input" type="password" autocomplete="new-password" placeholder="粘贴 GitLab access token(只存本机,不回显)" value="" />` +
2536
+ `<input id="ac-gitlab-token" name="gitlab_token_value" class="ac-input ac-secret-input" type="password" autocomplete="new-password" placeholder="可留空,或粘贴 Agent token" value="" />` +
3481
2537
  `</div>`
3482
2538
  ) +
3483
2539
  `</div>` +
3484
- // 仓库列表
3485
- `<div class="ac-repos-section">` +
3486
- `<div class="ac-repos-header">` +
3487
- `<span class="ac-repos-title">预热这些仓库</span>` +
3488
- `<span class="ac-optional-badge">可选</span>` +
3489
- `</div>` +
3490
- `<p class="ac-hint">列出主要会用到的仓库 → 我们提前 clone 好,agent 一上来就有,提速。<b>留空也行</b> —— agent 会用上面的令牌自己判断需要哪些、自己 clone。</p>` +
3491
- `<div class="ac-repos-empty" id="ac-repos-empty" ${repos.length ? 'style="display:none"' : ""}><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink:0"><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Z"/></svg>未列仓库 —— agent 会用令牌自己理解需要哪些、自己 clone。</div>` +
3492
- `<div class="ac-repos-list" id="ac-repos-list" ${repos.length ? "" : 'style="display:none"'}>${repos.map((r, i) => acRepoRowHTML(i, r)).join("")}</div>` +
3493
- `<button type="button" class="ac-add-repo-btn" id="ac-add-repo-btn">` +
3494
- `<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>` +
3495
- ` 添加仓库` +
3496
- `</button>` +
3497
- `</div>` + // ac-repos-section
2540
+ `</details>` +
3498
2541
  `</div>` + // ac-access-fields
3499
2542
  `</div>` + // ac-perm-body
3500
2543
  `</section>` +
@@ -3652,17 +2695,9 @@ function wireAgentConfigEvents(panel, id, bot) {
3652
2695
  if (accessRow) accessRow.classList.toggle("is-on", on);
3653
2696
  if (noAccessHint) noAccessHint.style.display = on ? "none" : "";
3654
2697
  if (accessFields) accessFields.style.display = on ? "" : "none";
3655
- // 关 → 清令牌 + 仓库
3656
- if (!on) {
3657
- if (gitlabInput) gitlabInput.value = "";
3658
- // 隐藏密码框,重置 tokenReset 标记(关闭等于「清除」)
3659
- const configured = ac.querySelector("#ac-token-configured");
3660
- const inputWrap = ac.querySelector("#ac-token-input-wrap");
3661
- if (configured) configured.style.display = "none";
3662
- if (inputWrap) { inputWrap.style.display = ""; } // 关闭态直接露空框,保存时会带 ""
3663
- delete ac.dataset.tokenReset;
3664
- acClearRepos(ac);
3665
- }
2698
+ // 关掉代码访问只改变本次保存的序列化结果,不立即清空表单里的仓库行。
2699
+ // 这样用户误点开关后再打开,未保存的仓库地址还在。
2700
+ if (!on && gitlabInput) gitlabInput.value = "";
3666
2701
  // open 徽标
3667
2702
  if (permLayerTitleRow) {
3668
2703
  let badge = permLayerTitleRow.querySelector(".ac-open-badge");
@@ -3770,11 +2805,10 @@ function readAgentConfigValues(panel) {
3770
2805
  if (codeAccess) {
3771
2806
  const repoRows = ac.querySelectorAll(".ac-repo-row");
3772
2807
  for (const row of repoRows) {
3773
- const slug = (row.querySelector("[data-repo='slug']")?.value ?? "").trim();
3774
- const branch = (row.querySelector("[data-repo='branch']")?.value ?? "").trim() || "master";
3775
- const url = (row.querySelector("[data-repo='url']")?.value ?? "").trim();
3776
- if (slug) {
3777
- const r = { slug, branch };
2808
+ const url = (row.querySelector("[data-repo='url']")?.value ?? "").trim();
2809
+ const inferredSlug = inferRepoSlugFromUrl(url);
2810
+ if (inferredSlug || url) {
2811
+ const r = { slug: inferredSlug, branch: "main" };
3778
2812
  if (url) r.url = url;
3779
2813
  repos.push(r);
3780
2814
  }
@@ -3803,12 +2837,46 @@ function readAgentConfigValues(panel) {
3803
2837
 
3804
2838
  function acGetRepos(ac) {
3805
2839
  const rows = ac.querySelectorAll(".ac-repo-row");
3806
- return Array.from(rows).map((row) => ({
3807
- slug: (row.querySelector("[data-repo='slug']")?.value ?? "").trim(),
3808
- branch: (row.querySelector("[data-repo='branch']")?.value ?? "").trim() || "master",
3809
- url: (row.querySelector("[data-repo='url']")?.value ?? "").trim(),
3810
- _idx: parseInt(row.dataset.repoIdx ?? "-1", 10),
3811
- }));
2840
+ return Array.from(rows).map((row) => {
2841
+ const url = (row.querySelector("[data-repo='url']")?.value ?? "").trim();
2842
+ return {
2843
+ slug: inferRepoSlugFromUrl(url),
2844
+ branch: "main",
2845
+ url,
2846
+ _idx: parseInt(row.dataset.repoIdx ?? "-1", 10),
2847
+ };
2848
+ });
2849
+ }
2850
+
2851
+ function validateCodeAccessConfig(panel) {
2852
+ const ac = panel?.querySelector?.("#ac-panel") ?? panel;
2853
+ if (!ac) return true;
2854
+ const codeAccessBtn = ac.querySelector("#ac-code-access-btn");
2855
+ const codeAccess = codeAccessBtn?.getAttribute("aria-checked") === "true";
2856
+ if (!codeAccess) return true;
2857
+
2858
+ const repos = acGetRepos(ac);
2859
+ if (repos.length === 0) {
2860
+ toast("已打开代码访问,请至少添加一个仓库。", "warn");
2861
+ ac.querySelector("#ac-add-repo-btn")?.focus();
2862
+ return false;
2863
+ }
2864
+
2865
+ for (const repo of repos) {
2866
+ const row = ac.querySelector(`.ac-repo-row[data-repo-idx="${repo._idx}"]`);
2867
+ const urlInput = row?.querySelector("[data-repo='url']");
2868
+ if (!repo.url) {
2869
+ toast("请填写代码仓库 Git 地址。Git Access Token 是选填。", "warn");
2870
+ urlInput?.focus();
2871
+ return false;
2872
+ }
2873
+ if (!repo.slug) {
2874
+ toast("无法从 Git 地址识别仓库,请检查地址格式。", "warn");
2875
+ urlInput?.focus();
2876
+ return false;
2877
+ }
2878
+ }
2879
+ return true;
3812
2880
  }
3813
2881
 
3814
2882
  function acRebuildRepoRows(ac) {
@@ -3836,8 +2904,7 @@ function acAddRepo(ac) {
3836
2904
  list.appendChild(tmp.firstElementChild);
3837
2905
  list.style.display = "";
3838
2906
  if (empty) empty.style.display = "none";
3839
- // 聚焦新行 slug 输入
3840
- list.querySelector(`#ac-repo-slug-${newIdx}`)?.focus();
2907
+ list.querySelector(`#ac-repo-url-${newIdx}`)?.focus();
3841
2908
  acUpdatePermSummary(ac);
3842
2909
  ac.dispatchEvent(new Event("ac-change", { bubbles: true }));
3843
2910
  }
@@ -3855,14 +2922,6 @@ function acRemoveRepo(ac, idx) {
3855
2922
  ac.dispatchEvent(new Event("ac-change", { bubbles: true }));
3856
2923
  }
3857
2924
 
3858
- function acClearRepos(ac) {
3859
- const list = ac.querySelector("#ac-repos-list");
3860
- const empty = ac.querySelector("#ac-repos-empty");
3861
- if (list) { list.innerHTML = ""; list.style.display = "none"; }
3862
- if (empty) empty.style.display = "";
3863
- acUpdatePermSummary(ac);
3864
- }
3865
-
3866
2925
  // ── summary 药丸实时更新 ─────────────────────────────────────────────────────
3867
2926
 
3868
2927
  function acUpdatePermSummary(ac) {
@@ -3874,11 +2933,19 @@ function acUpdatePermSummary(ac) {
3874
2933
  const repoCount = ac.querySelectorAll(".ac-repo-row").length;
3875
2934
  const html = codeAccess
3876
2935
  ? acPillHTML("brand", "m16 18 6-6-6-6M8 6l-6 6 6 6", "可访问代码仓库") +
3877
- acPillHTML("muted", "M21 8 12 3 3 8v8l9 5 9-5ZM3 8l9 5 9-5M12 13v8", repoCount ? `预热 ${repoCount} 个仓库` : "agent 自己 clone")
2936
+ acPillHTML("muted", "M21 8 12 3 3 8v8l9 5 9-5ZM3 8l9 5 9-5M12 13v8", repoCount ? `${repoCount} 个仓库` : "待添加仓库") +
2937
+ acPillHTML("muted", "M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4", acHasAgentGitIdentity(ac) ? "Agent 级 Git 身份" : "沿用本机 Git 身份")
3878
2938
  : acPillHTML("muted", "M5 11h14a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1ZM8 11V7a4 4 0 0 1 8 0v4", "纯对话 · 不碰任何代码仓库");
3879
2939
  summaryEl.innerHTML = html;
3880
2940
  }
3881
2941
 
2942
+ function acHasAgentGitIdentity(ac) {
2943
+ const typedValue = ac.querySelector("#ac-gitlab-token")?.value ?? "";
2944
+ if (typedValue.trim()) return true;
2945
+ const configuredEl = ac.querySelector("#ac-token-configured");
2946
+ return !!(configuredEl && configuredEl.style.display !== "none");
2947
+ }
2948
+
3882
2949
  function acUpdateAdvSummary(ac) {
3883
2950
  const summaryEl = ac.querySelector("#ac-adv-summary");
3884
2951
  if (!summaryEl) return;
@@ -4116,7 +3183,7 @@ function wireRecentEvents(panel, id) {
4116
3183
  * hero band + status action 保留;配置表单 + memory 由 AC 面板统一承载。
4117
3184
  * secret 只显示变量名。
4118
3185
  */
4119
- function buildDetailHTML(id, bot, memContent, readonly) {
3186
+ function buildDetailHTML(id, bot, memContent) {
4120
3187
  // 详情头像(hero,右下角状态角标 + 健康脉冲环);头像 URL 复用列表/status 同源
4121
3188
  const avatar = state.avatars[id] ?? bot.avatar ?? null;
4122
3189
  // ⑤ hero 颜色以后端 effLive 为准:bridge 停时即使 status 说 serving,也强制 offline
@@ -4135,61 +3202,32 @@ function buildDetailHTML(id, bot, memContent, readonly) {
4135
3202
  chatCount: chatLines.length,
4136
3203
  repoCount: repos.length,
4137
3204
  },
4138
- readonly,
4139
3205
  );
4140
3206
 
4141
3207
  // 状态可操作化面板
4142
3208
  const effLiveKey = effLive(id);
4143
- const statusActionPanel = !readonly && effLiveKey !== "serving"
3209
+ const statusActionPanel = effLiveKey !== "serving"
4144
3210
  ? `<div id="detail-status-action">${buildStatusActionPanel(effLiveKey, null, false)}</div>`
4145
3211
  : `<div id="detail-status-action"></div>`;
4146
3212
 
4147
- const centralReadonlyNotice = readonly
4148
- ? `<div class="notice notice-warn">${ICONS.lock}<div>中心库的配置是只读的,改本地后用「晋升」推上去。</div></div>`
4149
- : "";
4150
-
4151
3213
  // 三层 AC 配置面板(edit 态)
4152
3214
  const acPanelHTML = buildAgentConfigHTML(bot, memContent, "edit");
4153
3215
 
4154
- // 触点④(编辑表单顶部):底座选择卡(仅本机 edit 模式)
4155
- // backendKickerChip 已在 kicker 展示;这里用完整 select 控件让用户改
3216
+ // 触点④(编辑表单顶部):底座选择卡
4156
3217
  const _configuredBk4 = bot.backend || LK_BACKEND_DEFAULT;
4157
3218
  const _runningBk4 = state.runningBackends[id] ?? null;
4158
- // BL-17:底座不一致 badge(详情区,md)— 只在本机 edit 模式 + bridge 在跑时显示
3219
+ // BL-17:底座不一致 badge(详情区,md)— 只在 bridge 在跑时显示
4159
3220
  const bkMismatchDetail =
4160
- !readonly && state.bridge?.running && isBackendMismatch(_runningBk4, _configuredBk4)
3221
+ state.bridge?.running && isBackendMismatch(_runningBk4, _configuredBk4)
4161
3222
  ? `<div style="margin-top:10px">${backendMismatchBadgeHTML(_runningBk4, _configuredBk4, "md")}` +
4162
3223
  `<span style="margin-left:8px;font-size:12px;color:var(--muted)">右上角重启服务后生效</span></div>`
4163
3224
  : "";
4164
- const backendCardHTML = !readonly ? (
4165
- `<div class="lk-bk-card" id="lk-bk-card">` +
3225
+ const backendCardHTML = `<div class="lk-bk-card" id="lk-bk-card">` +
4166
3226
  `<h4 class="lk-bk-card-title">${ICONS.box} 用哪个底座驱动它</h4>` +
4167
3227
  `<p class="lk-bk-card-desc">底座决定这个助手背后跑哪个 CLI agent。默认 Codex;切换后需重启服务生效。</p>` +
4168
- lkBackendSelectHTML(_configuredBk4, false, `bk-edit-${id}`) +
3228
+ lkBackendSelectHTML(_configuredBk4, `bk-edit-${id}`) +
4169
3229
  bkMismatchDetail +
4170
- `</div>`
4171
- ) : "";
4172
-
4173
- // 晋升区(仅本机非只读时显示)
4174
- const promoteSection = !readonly ? `
4175
- <div class="form-section promote-section" id="promote-section">
4176
- <h4 class="section-title">${ICONS.box} 交给公司统一管理(晋升)</h4>
4177
- <p class="section-desc">${
4178
- state.centralAvailable
4179
- ? `已连上 <b style="font-family:ui-monospace,monospace;color:var(--text)">${esc(state.centralRepo?.name ?? "公司中心库")}</b>。先暂存只动本机副本;正式上传会推给全团队。`
4180
- : "把这个本地助手交给公司中心库,其它机器就能拉到同一份。"
4181
- }</p>
4182
- <div class="promote-actions">
4183
- <button class="btn${state.centralAvailable ? "" : " is-disabled"}" id="btn-promote-no-push" type="button"${state.centralAvailable ? "" : " disabled"}>${ICONS.box} 先暂存到本机副本(不上传)</button>
4184
- <button class="btn btn-upload${state.centralAvailable ? "" : " is-disabled"}" id="btn-promote-push" type="button"${state.centralAvailable ? "" : " disabled"}>${ICONS.upload} 正式上传到公司中心库</button>
4185
- </div>
4186
- ${
4187
- state.centralAvailable
4188
- ? `<p class="promote-hint">${ICONS.warn}<span><b style="color:var(--destructive-text)">正式上传</b>会推送到团队仓库,所有人都拉得到,<b style="color:var(--destructive-text)">推后无法自动撤销</b> —— 点了会再确认一次。</span></p>`
4189
- : `<div class="promote-connect-hint">${ICONS.link2}<span>得先<b>连接公司中心库</b>,这两个按钮才能用。</span><button type="button" id="btn-promote-connect" class="btn btn-primary btn-sm">${ICONS.link2} 去连接</button></div>`
4190
- }
4191
- </div>
4192
- ` : "";
3230
+ `</div>`;
4193
3231
 
4194
3232
  return `
4195
3233
  <!-- 编辑式 hero band(健康=indigo soft 渐变,异常=status soft 渐变) -->
@@ -4204,7 +3242,6 @@ function buildDetailHTML(id, bot, memContent, readonly) {
4204
3242
  <div class="config-area">
4205
3243
  <div class="status-banner" id="detail-status-banner" role="status" aria-live="polite" hidden></div>
4206
3244
  ${statusActionPanel}
4207
- ${centralReadonlyNotice}
4208
3245
 
4209
3246
  ${buildRecentEventsPanelHTML(id)}
4210
3247
 
@@ -4215,8 +3252,6 @@ function buildDetailHTML(id, bot, memContent, readonly) {
4215
3252
  <div class="ac-panel-wrap" id="ac-panel-wrap">
4216
3253
  ${acPanelHTML}
4217
3254
  </div>
4218
-
4219
- ${promoteSection}
4220
3255
  </div>
4221
3256
  `;
4222
3257
  }
@@ -4301,16 +3336,13 @@ function wireRestartPanelButtons(panel) {
4301
3336
  }
4302
3337
 
4303
3338
  /** 为详情区内的按钮绑定事件(三层 AC 面板版)。 */
4304
- function wireDetailEvents(panel, id, bot, readonly) {
3339
+ function wireDetailEvents(panel, id, bot) {
4305
3340
  wireRecentEvents(panel, id);
4306
- // Wire status-action fix buttons (always, even in readonly — fix buttons only appear in non-readonly)
4307
3341
  const statusActionContainer = panel.querySelector("#detail-status-action");
4308
3342
  if (statusActionContainer) wireStatusActionButtons(statusActionContainer);
4309
3343
  // Wire restart panel buttons (timeout panel: logs/restart/rescan)
4310
3344
  wireRestartPanelButtons(panel);
4311
3345
 
4312
- if (readonly) return;
4313
-
4314
3346
  // ── 三层 AC 面板:交互 + 脏检测 + 保存 ──────────────────────────────────
4315
3347
  wireAgentConfigEvents(panel, id, bot);
4316
3348
 
@@ -4372,7 +3404,6 @@ function wireDetailEvents(panel, id, bot, readonly) {
4372
3404
  chatCount: chatLines.length,
4373
3405
  repoCount: repos.length,
4374
3406
  },
4375
- false,
4376
3407
  );
4377
3408
  // re-wire hero del btn
4378
3409
  heroBody.querySelector("#btn-hero-del")?.addEventListener("click", () => doDeleteBot(id));
@@ -4393,34 +3424,6 @@ function wireDetailEvents(panel, id, bot, readonly) {
4393
3424
  btnHeroDel.addEventListener("click", () => doDeleteBot(id));
4394
3425
  }
4395
3426
 
4396
- // promote 不推送
4397
- const btnPromoteNoP = panel.querySelector("#btn-promote-no-push");
4398
- if (btnPromoteNoP) {
4399
- btnPromoteNoP.addEventListener("click", async () => {
4400
- if (btnPromoteNoP.disabled) return;
4401
- await doPromote(btnPromoteNoP, id, false, panel);
4402
- });
4403
- }
4404
-
4405
- // promote 并推送(不可逆外发 → LkUploadConfirm 样式二次确认弹窗)
4406
- const btnPromoteP = panel.querySelector("#btn-promote-push");
4407
- if (btnPromoteP) {
4408
- btnPromoteP.addEventListener("click", async () => {
4409
- if (btnPromoteP.disabled) return;
4410
- const confirmed = await uploadConfirmDialog(
4411
- { name: bot.name || id },
4412
- state.centralRepo,
4413
- );
4414
- if (!confirmed) return;
4415
- await doPromote(btnPromoteP, id, true, panel);
4416
- });
4417
- }
4418
-
4419
- // 未连接态:「去连接」→ 打开连接流弹窗(连上后 centralAvailable 变 true,晋升按钮启用)
4420
- const btnPromoteConnect = panel.querySelector("#btn-promote-connect");
4421
- if (btnPromoteConnect) {
4422
- btnPromoteConnect.addEventListener("click", () => openConnectFlow());
4423
- }
4424
3427
  }
4425
3428
 
4426
3429
  /**
@@ -4435,13 +3438,12 @@ function acRefreshHero(panel, id, bot) {
4435
3438
  const vals = readAgentConfigValues(panel);
4436
3439
  const chatCount = Array.isArray(vals.chats) ? vals.chats.length : 0;
4437
3440
  const repoCount = Array.isArray(vals.repos) ? vals.repos.length : 0;
4438
- const readonly = false;
4439
3441
  body.innerHTML = buildHeroInner(id, { ...bot, name: vals.name, description: vals.description }, {
4440
3442
  name: vals.name ?? "",
4441
3443
  description: vals.description ?? "",
4442
3444
  chatCount,
4443
3445
  repoCount,
4444
- }, readonly);
3446
+ });
4445
3447
  // re-wire del button(内容重建后需重绑)
4446
3448
  const btnHeroDel = body.querySelector("#btn-hero-del");
4447
3449
  if (btnHeroDel) btnHeroDel.addEventListener("click", () => doDeleteBot(id));
@@ -4465,7 +3467,7 @@ function readFormValues(form) {
4465
3467
  .map((s) => s.trim())
4466
3468
  .filter(Boolean)
4467
3469
  .map((line) => {
4468
- const [slug, branch = "master"] = line.split(":").map((p) => p.trim());
3470
+ const [slug, branch = "main"] = line.split(":").map((p) => p.trim());
4469
3471
  return { slug, branch };
4470
3472
  });
4471
3473
  const peers = peersRaw
@@ -4569,6 +3571,8 @@ async function saveMemory(panel, id) {
4569
3571
  * memory 随「保存」一起提交。序列化字段见 readAgentConfigValues。
4570
3572
  */
4571
3573
  async function saveAcBot(panel, id) {
3574
+ if (!validateCodeAccessConfig(panel)) return;
3575
+
4572
3576
  const vals = readAgentConfigValues(panel);
4573
3577
  const memContent = vals._memContent ?? "";
4574
3578
 
@@ -4606,190 +3610,7 @@ async function saveAcBot(panel, id) {
4606
3610
  }
4607
3611
  }
4608
3612
 
4609
- /** commit hash → 短 7 位(便于运营报给工程师);取不到给原值。 */
4610
- function shortHash(sha) {
4611
- return typeof sha === "string" && sha.length >= 7 ? sha.slice(0, 7) : (sha ?? "?");
4612
- }
4613
-
4614
- /**
4615
- * 晋升:暂存(本地 indigo)/ 正式上传(红,不可逆外发)。
4616
- * 成功 → toast 给 commit 短 hash;失败按 kind 走「怎么办」:
4617
- * - behind → 要先同步别人的改动,带「去同步」入口
4618
- * - noperm → 没写权限,提示找工程师开权限
4619
- * - 其它 → 原样人话错误
4620
- * 增强后的 POST /api/promote/:id:成功 { ok:true, commit, pushed };
4621
- * 失败(HTTP 200){ ok:false, kind:"behind"|"noperm"|"other", error };
4622
- * 未连接(HTTP 409){ error }。
4623
- * @param {HTMLElement} btn
4624
- * @param {string} id
4625
- * @param {boolean} push true=正式上传 / false=仅暂存本机
4626
- * @param {HTMLElement|null} [panel] 详情面板根元素,有则在 promote-section 内联渲染结果
4627
- */
4628
- async function doPromote(btn, id, push, panel, _extraBody) {
4629
- const restore = btnLoading(btn, push ? "正在上传…" : "暂存中…");
4630
- const body = { push, ...((_extraBody) ? _extraBody : {}) };
4631
- const res = await api("POST", `/api/promote/${encodeURIComponent(id)}`, body);
4632
- restore();
4633
-
4634
- // ⑧ 409 有两种情况:requiresConfirm(推 main 需要二次确认) 或 未连接中心库
4635
- if (res.status === 409) {
4636
- if (res.json?.requiresConfirm) {
4637
- const branch = res.json.branch ?? "main";
4638
- const confirmed = await confirmDialog({
4639
- title: "推送到主干分支",
4640
- body: `你正要晋升到公司主干 \`${branch}\`!确认?此操作会覆盖团队共享内容,推后无法自动撤销。`,
4641
- confirmText: "确认晋升",
4642
- confirmDanger: true,
4643
- });
4644
- if (confirmed) {
4645
- await doPromote(btn, id, push, panel, { confirmMain: true });
4646
- }
4647
- return;
4648
- }
4649
- toast("未连接公司中心库,无法晋升。先在顶部「公司中心库」里连接一次。", "error");
4650
- return;
4651
- }
4652
-
4653
- // 网络/服务器层错误(无 json.ok 字段)
4654
- if (!res.ok && res.json?.ok === undefined) {
4655
- toast(`失败:${res.json?.error ?? res.status}`, "error");
4656
- return;
4657
- }
4658
-
4659
- const j = res.json ?? {};
4660
-
4661
- // 业务失败(HTTP 200 + ok:false + kind)
4662
- if (j.ok === false) {
4663
- if (j.kind === "behind") {
4664
- // 落后:内联渲染 amber 失败面板 + 「先同步再上传」按钮
4665
- renderPromoteFailPanel(panel, "behind", null, async () => {
4666
- if (state.mode !== "central") await switchContext("central");
4667
- if (state.centralConnected) await doCentralSync();
4668
- });
4669
- return;
4670
- }
4671
- if (j.kind === "noperm") {
4672
- // 无权限:内联渲染红色失败面板 + 「重试」按钮
4673
- renderPromoteFailPanel(panel, "noperm", j.error ?? null, async () => {
4674
- await doPromote(btn, id, push, panel);
4675
- });
4676
- return;
4677
- }
4678
- // 其它失败:内联渲染红色失败面板
4679
- renderPromoteFailPanel(panel, "other", j.error ?? null, async () => {
4680
- await doPromote(btn, id, push, panel);
4681
- });
4682
- return;
4683
- }
4684
-
4685
- // 成功:commit 短 hash + 是否真推到了中心库
4686
- const commit = shortHash(j.commit ?? j.sha);
4687
- if (j.pushed) {
4688
- const botName = state.bots.find((b) => b.id === id)?.name ?? id;
4689
- const repoPath = state.centralRepo?.path ?? "bots/";
4690
- renderPromoteSuccessPanel(panel, botName, commit, repoPath);
4691
- } else {
4692
- toast(`已暂存到本机副本(未上传)· commit ${commit}`, "ok");
4693
- }
4694
- }
4695
-
4696
- /**
4697
- * 在 promote-section 内渲染上传成功内联面板(复刻 LkPromoteCard phase==='success')。
4698
- * 绿色 check 徽章 + 「已上传」标题 + larkway sync 提示 + 路径/commit + 「去中心库看看」。
4699
- * @param {HTMLElement|null} panel
4700
- * @param {string} botName
4701
- * @param {string} commit 短 7 位 hash
4702
- * @param {string} repoPath "bots/" 之类
4703
- */
4704
- function renderPromoteSuccessPanel(panel, botName, commit, repoPath) {
4705
- const section = panel?.querySelector(".promote-section");
4706
- if (!section) { toast(`已上传「${esc(botName)}」到公司中心库 · commit ${commit}`, "ok"); return; }
4707
-
4708
- const gC = LIVE_COLOR.serving, gE = LIVE_EDGE.serving, gS = LIVE_SOFT.serving, gT = LIVE_TEXT.serving;
4709
- const checkSVG = `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" style="width:19px;height:19px;stroke-width:2.4"><path d="M20 6 9 17l-5-5"/></svg>`;
4710
- const arrowSVG = `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" style="width:15px;height:15px"><path d="M5 12h14M13 6l6 6-6 6"/></svg>`;
4711
-
4712
- section.innerHTML =
4713
- `<h4 class="section-title">${ICONS.box} 交给公司统一管理(晋升)</h4>` +
4714
- `<div class="promote-result-panel" style="background:${gS};border:1px solid ${gE}">` +
4715
- `<span class="prp-badge" style="border:1px solid ${gE};color:${gC}">${checkSVG}</span>` +
4716
- `<div class="prp-body">` +
4717
- `<div class="prp-title" style="color:${gT}">已上传「${esc(botName)}」到中心库</div>` +
4718
- `<div class="prp-desc">同事 <code style="font-family:ui-monospace,monospace;background:#fff;padding:1px 5px;border-radius:5px;border:1px solid var(--border)">larkway sync</code> 一下就能拉到这一份了。</div>` +
4719
- `<div class="prp-meta">` +
4720
- `<span style="display:inline-flex;align-items:center;gap:5px">${ICONS.folder} ${esc(repoPath)}${esc(botName)}/</span>` +
4721
- `<span>·</span>` +
4722
- `<span style="font-family:ui-monospace,monospace">commit ${esc(commit)}</span>` +
4723
- `</div>` +
4724
- `<div class="prp-actions">` +
4725
- `<button class="btn" id="prp-go-central" type="button" style="background:var(--br-soft);border:1px solid var(--br-edge);color:var(--br-text);font-size:13.5px;padding:8px 15px;border-radius:9px">去「公司中心库」看看 ${arrowSVG}</button>` +
4726
- `</div>` +
4727
- `</div>` +
4728
- `</div>`;
4729
-
4730
- section.querySelector("#prp-go-central")?.addEventListener("click", () => switchContext("central"));
4731
- }
4732
-
4733
- /**
4734
- * 在 promote-section 内渲染上传失败内联面板(复刻 LkPromoteCard phase==='failed')。
4735
- * behind=amber 「推不上去」;noperm/other=红「上传失败」。
4736
- * @param {HTMLElement|null} panel
4737
- * @param {"behind"|"noperm"|"other"} kind
4738
- * @param {string|null} errorMsg
4739
- * @param {function():Promise<void>} onAction 主 CTA 点击回调(同步 / 重试)
4740
- */
4741
- function renderPromoteFailPanel(panel, kind, errorMsg, onAction) {
4742
- const section = panel?.querySelector(".promote-section");
4743
- const behind = kind === "behind";
4744
-
4745
- if (!section) {
4746
- // 无 panel 降级 toast
4747
- if (behind) {
4748
- toast("推不上去 —— 中心库有别人的新改动,得先同步再上传。", "error");
4749
- } else {
4750
- toast(`上传失败${errorMsg ? ":" + errorMsg : ""}`, "error");
4751
- }
4752
- return;
4753
- }
4754
-
4755
- // amber(behind) or red(noperm/other)
4756
- const sc = behind
4757
- ? { c: LIVE_COLOR.degraded, soft: LIVE_SOFT.degraded, edge: LIVE_EDGE.degraded, text: LIVE_TEXT.degraded }
4758
- : { c: "#dc2626", soft: "#fef2f2", edge: "#fecaca", text: "#b91c1c" };
4759
-
4760
- const warnSVG = `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" style="width:18px;height:18px"><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"/><path d="M12 9v4M12 17h.01"/></svg>`;
4761
- const pullSVG = `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" style="width:15px;height:15px"><path d="M12 3v12M8 11l4 4 4-4M4 21h16"/></svg>`;
4762
- const refreshSVG = `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" style="width:15px;height:15px"><path d="M3 12a9 9 0 0 1 15-6.7L21 8M21 3v5h-5M21 12a9 9 0 0 1-15 6.7L3 16M3 21v-5h5"/></svg>`;
4763
-
4764
- const titleText = behind ? "推不上去 —— 中心库有新改动" : "上传失败";
4765
- const descText = behind
4766
- ? "别人在你之前也晋升了东西。你得先把那些「同步」拉下来,跟你的合到一起,再上传一次。"
4767
- : (errorMsg
4768
- ? esc(errorMsg)
4769
- : "这台机器可能没有往团队仓库写的权限。让工程师给你开一下,或换一台有权限的机器再试。");
4770
- const ctaLabel = behind ? "先同步,再上传" : "重试";
4771
- const ctaIcon = behind ? pullSVG : refreshSVG;
4772
-
4773
- section.innerHTML =
4774
- `<h4 class="section-title">${ICONS.box} 交给公司统一管理(晋升)</h4>` +
4775
- `<div class="promote-result-panel" style="background:${sc.soft};border:1px solid ${sc.edge}">` +
4776
- `<span class="prp-badge" style="border:1px solid ${sc.edge};color:${sc.c}">${warnSVG}</span>` +
4777
- `<div class="prp-body">` +
4778
- `<div class="prp-title" style="color:${sc.text}">${titleText}</div>` +
4779
- `<p class="prp-desc">${descText}</p>` +
4780
- `<div class="prp-actions">` +
4781
- `<button class="btn" id="prp-cta" type="button" style="background:var(--br);border:none;color:#fff;font-size:13.5px;padding:8px 15px;border-radius:9px">${ctaIcon} ${ctaLabel}</button>` +
4782
- `<button class="btn" id="prp-detail" type="button" style="border:1px solid var(--border);background:#fff;color:var(--muted);font-size:13.5px;padding:8px 15px;border-radius:9px">${ICONS.code} 查看详情</button>` +
4783
- `</div>` +
4784
- `</div>` +
4785
- `</div>`;
4786
3613
 
4787
- section.querySelector("#prp-cta")?.addEventListener("click", () => { void onAction(); });
4788
- section.querySelector("#prp-detail")?.addEventListener("click", () => {
4789
- const detail = errorMsg ?? (behind ? "中心库有新改动,需先同步。" : "无权限写入团队仓库。");
4790
- toast(detail, "error");
4791
- });
4792
- }
4793
3614
 
4794
3615
  // ---------------------------------------------------------------------------
4795
3616
  // 行为:Bridge 服务控制(B3)
@@ -4819,7 +3640,6 @@ async function refreshBridgeStatus() {
4819
3640
  * - restart.status='timeout' → 红「重启异常」+「再试一次」(照 LkServiceRestartChip timeout)
4820
3641
  * - pendingNew>0 → amber提示+重启
4821
3642
  * - running+ok → 绿chip+重启服务幽灵按钮(照 LkServiceRestartChip serving)
4822
- * central 只读模式:操作按钮隐藏(本机操作无意义)。
4823
3643
  */
4824
3644
  function renderServiceIndicator() {
4825
3645
  const container = document.getElementById("bridge-indicator");
@@ -4827,7 +3647,7 @@ function renderServiceIndicator() {
4827
3647
 
4828
3648
  const b = state.bridge;
4829
3649
  const running = b?.running ?? false;
4830
- const readonly = state.mode === "central";
3650
+ const readonly = false;
4831
3651
 
4832
3652
  if (!b) {
4833
3653
  // 未知态(/api/bridge 没拉到):中性灰 chip,不报警也不显绿。
@@ -4840,7 +3660,7 @@ function renderServiceIndicator() {
4840
3660
  }
4841
3661
 
4842
3662
  // 状态(running)与按钮(readonly)是两个独立维度:服务没跑/有新助手时**始终**显示
4843
- // 报警状态条;只读上下文(中心库)只是省掉「本机操作」按钮,绝不把状态改成绿色。
3663
+ // 报警状态条:服务异常时绝不把状态改成绿色。
4844
3664
  const svcBtn = (id, borderColor, icon, label, action) =>
4845
3665
  readonly
4846
3666
  ? ""
@@ -4851,22 +3671,10 @@ function renderServiceIndicator() {
4851
3671
  icon + label +
4852
3672
  `</button>`;
4853
3673
 
4854
- if (!running) {
4855
- // running=false 红报警条(+ indigo 启动按钮,只读时省略)
4856
- container.innerHTML =
4857
- `<div class="lk-svc-indicator lk-svc-indicator--offline">` +
4858
- `<span class="lk-svc-label">` +
4859
- `<span class="bridge-dot is-stopped" style="margin-right:6px"></span>` +
4860
- `服务未运行` +
4861
- `<span class="lk-svc-sublabel"> · 助手都不会回复</span>` +
4862
- `</span>` +
4863
- svcBtn("btn-svc-start", "#fecaca", ICONS.zap, "启动服务", "start") +
4864
- `</div>`;
4865
- container.querySelector("#btn-svc-start")?.addEventListener("click", () => doFixAction("start"));
4866
- return;
4867
- }
4868
-
4869
- // ── BL-18:中心库只读时不显重启过渡态(本机运行概念) ─────────────────────────
3674
+ // ── BL-18:启动/重启过渡态 ─────────────────────
3675
+ // Must run before the `!running` branch: after clicking "启动服务", the
3676
+ // backend has accepted the start but detectBridgeStatus may still report
3677
+ // running=false for a few seconds while bridge boots and bots reconnect.
4870
3678
  if (!readonly) {
4871
3679
  const rs = state.restart;
4872
3680
 
@@ -4913,6 +3721,21 @@ function renderServiceIndicator() {
4913
3721
  }
4914
3722
  }
4915
3723
 
3724
+ if (!running) {
3725
+ // running=false → 红报警条(+ indigo 启动按钮,只读时省略)
3726
+ container.innerHTML =
3727
+ `<div class="lk-svc-indicator lk-svc-indicator--offline">` +
3728
+ `<span class="lk-svc-label">` +
3729
+ `<span class="bridge-dot is-stopped" style="margin-right:6px"></span>` +
3730
+ `服务未运行` +
3731
+ `<span class="lk-svc-sublabel"> · 助手都不会回复</span>` +
3732
+ `</span>` +
3733
+ svcBtn("btn-svc-start", "#fecaca", ICONS.zap, "启动服务", "start") +
3734
+ `</div>`;
3735
+ container.querySelector("#btn-svc-start")?.addEventListener("click", () => doFixAction("start"));
3736
+ return;
3737
+ }
3738
+
4916
3739
  // 综合 pendingRestart(后端感知新增/已删)与本地 pendingNewCount(客户端推算)
4917
3740
  // 后端字段更权威;本地计算作回退(后端未返回时)。
4918
3741
  const pr = state.pendingRestart;
@@ -4962,7 +3785,7 @@ function renderServiceIndicator() {
4962
3785
  return;
4963
3786
  }
4964
3787
 
4965
- // 中心库只读 → 旧版绿 chip(无按钮)
3788
+ // 绿 chip(serving 正常态)
4966
3789
  container.innerHTML =
4967
3790
  `<span class="lk-svc-indicator lk-svc-indicator--ok">` +
4968
3791
  `<span class="bridge-dot is-running" style="margin-right:6px"></span>` +
@@ -5030,29 +3853,33 @@ async function doFixAction(action, triggerEl = null) {
5030
3853
  const errMsg = res.json?.message ?? res.json?.error ?? res.status;
5031
3854
  toast(`操作失败:${errMsg}`, "error");
5032
3855
  // Refresh status even on failure
3856
+ await refreshRuntimeRequirements();
5033
3857
  await refreshBridgeStatus();
5034
3858
  await pollStatus();
5035
3859
  if (state.selected) rerenderStatusAction(state.selected);
5036
3860
  return;
5037
3861
  }
5038
3862
 
5039
- // ── BL-18:POST 成功后 → 进入 restarting 过渡态(restart 动作) ─────────
5040
- if (action === "restart") {
5041
- // 重置任何旧的机器状态,进入 restarting
5042
- stopRestartTicker();
5043
- state.restart = { status: "restarting", startedAt: Date.now(), elapsed: 0 };
5044
- toast("正在重启服务 —— 好了会自动转回正常,不用重复点", "info");
5045
- // 全量刷新三触点(顶栏+名册+hero)
5046
- renderServiceIndicator();
5047
- renderBotList();
5048
- if (state.selected) refreshDetailHero();
5049
- // 启动 1s ticker 刷新已用时显示
5050
- startRestartTicker();
5051
- } else {
5052
- toast("服务已启动", "ok");
5053
- }
3863
+ // ── BL-18:POST 成功后 → 进入 restarting 过渡态(start/restart 都走同一套) ──
3864
+ // "启动服务" "重启服务" 都调用同一个后端 restart endpoint。返回成功只
3865
+ // 代表 supervisor 已接受请求,不代表 bridge 已连回飞书或 bot 已写心跳。
3866
+ stopRestartTicker();
3867
+ state.restart = { status: "restarting", startedAt: Date.now(), elapsed: 0 };
3868
+ toast(
3869
+ action === "start"
3870
+ ? "正在启动服务 —— 连上后会自动转回正常,不用重复点"
3871
+ : "正在重启服务 —— 好了会自动转回正常,不用重复点",
3872
+ "info",
3873
+ );
3874
+ // 全量刷新三触点(顶栏+名册+hero)
3875
+ renderServiceIndicator();
3876
+ renderBotList();
3877
+ if (state.selected) refreshDetailHero();
3878
+ // 启动 1s ticker 刷新已用时显示
3879
+ startRestartTicker();
5054
3880
 
5055
3881
  // Refresh bridge + liveness
3882
+ await refreshRuntimeRequirements();
5056
3883
  await refreshBridgeStatus();
5057
3884
  await pollStatus();
5058
3885
  if (state.selected) rerenderStatusAction(state.selected);
@@ -5068,6 +3895,7 @@ async function doFixAction(action, triggerEl = null) {
5068
3895
  const BURST_INTERVAL = 2000;
5069
3896
  _restartPollHandle = setInterval(async () => {
5070
3897
  burstCount++;
3898
+ await refreshRuntimeRequirements();
5071
3899
  await refreshBridgeStatus();
5072
3900
  await pollStatus();
5073
3901
  if (state.selected) rerenderStatusAction(state.selected);
@@ -5230,114 +4058,6 @@ async function doBridgeRestart(triggerBtn = null, successMsg = null) {
5230
4058
  await refreshBridgeStatus();
5231
4059
  }
5232
4060
 
5233
- // ---------------------------------------------------------------------------
5234
- // 行为:上下文切换
5235
- // ---------------------------------------------------------------------------
5236
-
5237
- async function switchContext(mode) {
5238
- if (mode === state.mode) return;
5239
-
5240
- // ── 切到公司中心库 ──────────────────────────────────────────────────────
5241
- if (mode === "central") {
5242
- // 先确认连接状态 —— 未连接也允许进入(进去看的是连接引导卡)。
5243
- await loadCentralStatus();
5244
- if (!state.centralConnected) {
5245
- // 未连接:不调 POST /api/context(后端会 409),只在前端切视图显引导。
5246
- state.mode = "central";
5247
- state.selected = null;
5248
- state.bots = [];
5249
- renderContextSwitch();
5250
- renderCentralSourceBar();
5251
- renderBotList();
5252
- renderBotDetail(null);
5253
- renderServiceIndicator();
5254
- return;
5255
- }
5256
- // 已连接:正常切 context
5257
- const res = await api("POST", "/api/context", { mode });
5258
- if (!res.ok) {
5259
- toast(`切换失败:${res.json?.error ?? res.status}`, "error");
5260
- return;
5261
- }
5262
- state.mode = res.json?.mode ?? mode;
5263
- state.selected = null;
5264
- renderContextSwitch();
5265
- renderCentralSourceBar();
5266
- renderBotDetail(null);
5267
- await loadBots();
5268
- // 进中心库后台算一次同步预览,驱动来源条「N 项可更新 / 已是最新」
5269
- void refreshCentralSyncState();
5270
- renderServiceIndicator();
5271
- return;
5272
- }
5273
-
5274
- // ── 切回本机 ────────────────────────────────────────────────────────────
5275
- const res = await api("POST", "/api/context", { mode });
5276
- if (!res.ok) {
5277
- toast(`切换失败:${res.json?.error ?? res.status}`, "error");
5278
- return;
5279
- }
5280
- state.mode = res.json?.mode ?? mode;
5281
- state.selected = null;
5282
- renderContextSwitch();
5283
- renderCentralSourceBar();
5284
- // 清空详情
5285
- renderBotDetail(null);
5286
- await loadBots();
5287
- // 切换上下文后刷新 bridge 指示(central 模式下按钮要隐藏)
5288
- renderServiceIndicator();
5289
- }
5290
-
5291
- /**
5292
- * 强制进入「已连接的公司中心库」视图并刷新名册 + 来源条。
5293
- * 用于连接成功后(此时 mode 可能已是 central,switchContext 会 no-op)。
5294
- */
5295
- async function enterCentralConnected() {
5296
- await loadCentralStatus();
5297
- if (!state.centralConnected) {
5298
- // 极端情况下连接态丢失 → 退回引导
5299
- state.mode = "central";
5300
- state.selected = null;
5301
- state.bots = [];
5302
- renderContextSwitch();
5303
- renderCentralSourceBar();
5304
- renderBotList();
5305
- renderBotDetail(null);
5306
- return;
5307
- }
5308
- // 确保后端上下文也切到 central(幂等;失败不阻断前端展示)
5309
- await api("POST", "/api/context", { mode: "central" });
5310
- state.mode = "central";
5311
- state.selected = null;
5312
- renderContextSwitch();
5313
- renderCentralSourceBar();
5314
- renderBotDetail(null);
5315
- await loadBots();
5316
- void refreshCentralSyncState();
5317
- renderServiceIndicator();
5318
- }
5319
-
5320
- /**
5321
- * 后台算一次同步预览,只为驱动来源条状态(不弹 modal)。
5322
- * 失败静默(来源条退回「检查更新」)。
5323
- */
5324
- async function refreshCentralSyncState() {
5325
- if (state.mode !== "central" || !state.centralConnected) return;
5326
- const res = await api("GET", "/api/central/sync/preview");
5327
- if (!res.ok) {
5328
- state.centralSyncState = "unknown";
5329
- renderCentralSourceBar();
5330
- return;
5331
- }
5332
- // 只数会真正应用的增量(added + updated);removed(本机自建未晋升)本机自管不删,见上。
5333
- const total =
5334
- (res.json?.added?.length ?? 0) +
5335
- (res.json?.updated?.length ?? 0);
5336
- state.centralUpdateCount = total;
5337
- state.centralSyncState = total > 0 ? "updates" : "fresh";
5338
- renderCentralSourceBar();
5339
- }
5340
-
5341
4061
  // ---------------------------------------------------------------------------
5342
4062
  // 行为:选中 bot
5343
4063
  // ---------------------------------------------------------------------------
@@ -5360,30 +4080,6 @@ function selectBot(id) {
5360
4080
  async function loadBots(opts = {}) {
5361
4081
  if (!opts.silent) renderBotListLoading();
5362
4082
 
5363
- // 中心库:只读名册走 GET /api/central/bots(含 by/updated/commit),不显在线/心跳。
5364
- if (state.mode === "central") {
5365
- const ok = await loadCentralBots();
5366
- if (!ok) {
5367
- if (!opts.silent) toast("加载中心库名册失败", "error");
5368
- state.bots = [];
5369
- } else {
5370
- // 归一化成名册行需要的 {id,name,description,avatar}(中心库 yaml 现在带飞书头像,用它)
5371
- state.bots = state.centralBots.map((b) => ({
5372
- id: b.id,
5373
- name: b.name ?? b.id,
5374
- description: b.desc ?? "",
5375
- avatar: b.avatar ?? null,
5376
- backend: b.backend || LK_BACKEND_DEFAULT,
5377
- }));
5378
- // 中心库头像并入 state.avatars,名册行 + 详情区都能用(与本机分支一致)
5379
- for (const b of state.centralBots) {
5380
- if (b && typeof b.id === "string" && b.avatar) state.avatars[b.id] = b.avatar;
5381
- }
5382
- }
5383
- renderBotList();
5384
- return;
5385
- }
5386
-
5387
4083
  const res = await api("GET", "/api/bots");
5388
4084
  if (!res.ok) {
5389
4085
  if (!opts.silent) toast(`加载助手列表失败:${res.json?.error ?? res.status}`, "error");
@@ -5397,91 +4093,6 @@ async function loadBots(opts = {}) {
5397
4093
  renderBotList();
5398
4094
  }
5399
4095
 
5400
- // ---------------------------------------------------------------------------
5401
- // 行为:从中心同步(先 dryRun,再二次确认)
5402
- // ---------------------------------------------------------------------------
5403
-
5404
- async function doSync() {
5405
- const btn = document.getElementById("btn-sync");
5406
- const restore = btnLoading(btn, "拉取预览中…");
5407
- const dryRes = await api("POST", "/api/sync", { dryRun: true });
5408
- restore();
5409
- if (!dryRes.ok) {
5410
- toast(`同步预览失败:${dryRes.json?.error ?? dryRes.status}`, "error");
5411
- return;
5412
- }
5413
- const plan = dryRes.json?.plan ?? { added: [], updated: [], removed: [], unchanged: [] };
5414
- showSyncModal(plan);
5415
- }
5416
-
5417
- function showSyncModal(plan) {
5418
- const backdrop = document.getElementById("modal-backdrop");
5419
- const body = document.getElementById("modal-body");
5420
- const footer = document.getElementById("modal-footer");
5421
- if (!backdrop || !body || !footer) return;
5422
-
5423
- const rows = [
5424
- { label: "新增", items: plan.added, cls: "ok" },
5425
- { label: "更新", items: plan.updated, cls: "warn" },
5426
- { label: "本机独有(中心库没有,默认保留)", items: plan.removed, cls: "dim" },
5427
- { label: "无变化", items: plan.unchanged, cls: "dim" },
5428
- ];
5429
-
5430
- let html = "";
5431
- for (const row of rows) {
5432
- if (!Array.isArray(row.items) || row.items.length === 0) continue;
5433
- html += `<div class="sync-group"><span class="sync-label sync-${row.cls}">${esc(row.label)}(${row.items.length})</span>`;
5434
- html += `<ul class="sync-list">${row.items.map((id) => `<li>${esc(id)}</li>`).join("")}</ul></div>`;
5435
- }
5436
- if (!html) html = `<p class="dim">没有变化,本机已与公司中心库一致。</p>`;
5437
-
5438
- body.innerHTML = html;
5439
- footer.innerHTML = `
5440
- <label class="prune-label">
5441
- <input type="checkbox" id="chk-prune" />
5442
- 同时删除本机独有的助手(prune)
5443
- </label>
5444
- <div class="modal-btns">
5445
- <button class="btn" id="modal-cancel" type="button">取消</button>
5446
- <button class="btn btn-primary" id="modal-confirm" type="button">确认拉取</button>
5447
- </div>
5448
- `;
5449
-
5450
- backdrop.hidden = false;
5451
-
5452
- document.getElementById("modal-cancel")?.addEventListener("click", () => {
5453
- backdrop.hidden = true;
5454
- });
5455
-
5456
- document.getElementById("modal-confirm")?.addEventListener("click", async () => {
5457
- const prune = document.getElementById("chk-prune")?.checked ?? false;
5458
- const confirmBtn = document.getElementById("modal-confirm");
5459
- const restore = btnLoading(confirmBtn, "拉取中…");
5460
- const res = await api("POST", "/api/sync", { dryRun: false, prune });
5461
- restore();
5462
- backdrop.hidden = true;
5463
- if (!res.ok) {
5464
- toast(`同步失败:${res.json?.error ?? res.status}`, "error");
5465
- return;
5466
- }
5467
- const result = res.json?.result ?? {};
5468
- const parts = [];
5469
- if ((result.applied ?? []).length) parts.push(`应用 ${result.applied.length}`);
5470
- if ((result.pruned ?? []).length) parts.push(`删除 ${result.pruned.length}`);
5471
- if ((result.skipped ?? []).length) parts.push(`跳过 ${result.skipped.length}`);
5472
- toast(`同步完成。${parts.join("、") || "无变化"}`, "ok");
5473
- if ((res.json?.warnings ?? []).length) {
5474
- for (const w of res.json.warnings) console.warn("[larkway sync]", w);
5475
- }
5476
- await loadBots();
5477
- // 如果当前选中 bot 已被删除,清空详情
5478
- if (state.selected && !state.bots.find((b) => b.id === state.selected)) {
5479
- state.selected = null;
5480
- renderBotDetail(null);
5481
- }
5482
- });
5483
- }
5484
-
5485
4096
  // ---------------------------------------------------------------------------
5486
4097
  // 行为:添加新助手(页面内扫码开通)
5487
4098
  //
@@ -5749,7 +4360,7 @@ function renderOnboardNameForm(prefill, sessionId) {
5749
4360
  `<div class="lk-bk-card" id="ob2-bk-card" style="margin:0 0 18px">` +
5750
4361
  `<h4 class="lk-bk-card-title" style="font-size:14.5px">${ICONS.box} 用哪个底座` +
5751
4362
  `<span style="font-weight:400;color:var(--faint);font-size:13px;margin-left:4px">默认 Codex</span></h4>` +
5752
- lkBackendSelectHTML(LK_BACKEND_DEFAULT, false, "ob2-bk") +
4363
+ lkBackendSelectHTML(LK_BACKEND_DEFAULT, "ob2-bk") +
5753
4364
  `</div>`;
5754
4365
 
5755
4366
  modal.innerHTML =
@@ -5792,6 +4403,7 @@ function renderOnboardNameForm(prefill, sessionId) {
5792
4403
  async function submitOnboardName(prefill, sessionId, modal) {
5793
4404
  if (!modal) modal = document.getElementById("onboard-modal");
5794
4405
  const sid = sessionId ?? onboard.sessionId;
4406
+ if (!validateCodeAccessConfig(modal)) return;
5795
4407
 
5796
4408
  // 从 AC 面板读取表单值
5797
4409
  const vals = readAgentConfigValues(modal);
@@ -5952,23 +4564,11 @@ function renderOnboardError(msg) {
5952
4564
  // ---------------------------------------------------------------------------
5953
4565
 
5954
4566
  function wireEvents() {
5955
- // 上下文切换
5956
- for (const btn of document.querySelectorAll(".ctx-btn")) {
5957
- btn.addEventListener("click", () => switchContext(btn.dataset.mode));
5958
- }
5959
-
5960
4567
  // 刷新列表
5961
4568
  document.getElementById("btn-refresh")?.addEventListener("click", () => loadBots());
5962
4569
 
5963
- // 从中心同步
5964
- document.getElementById("btn-sync")?.addEventListener("click", () => doSync());
5965
-
5966
4570
  // 添加新助手:页面内扫码开通(POST /api/onboard/start → 轮询 → 落盘)
5967
4571
  document.getElementById("btn-add")?.addEventListener("click", () => {
5968
- if (state.mode === "central") {
5969
- toast("「公司中心库」是只读的,新建助手请先切到「本机」。", "warn");
5970
- return;
5971
- }
5972
4572
  openOnboardModal();
5973
4573
  });
5974
4574
 
@@ -5979,13 +4579,6 @@ function wireEvents() {
5979
4579
  }
5980
4580
  });
5981
4581
 
5982
- // 连接公司中心库 modal 背景点击关闭(connecting 态正在测网络,不打断)
5983
- document.getElementById("connect-backdrop")?.addEventListener("click", (e) => {
5984
- if (e.target !== e.currentTarget) return;
5985
- if (document.getElementById("connect-modal")?.querySelector(".spinner-lg")) return;
5986
- closeConnectFlow();
5987
- });
5988
-
5989
4582
  // 添加新助手 modal 背景点击 → 走取消(中止后端 + 清定时器)
5990
4583
  document.getElementById("onboard-backdrop")?.addEventListener("click", (e) => {
5991
4584
  if (e.target === e.currentTarget) {
@@ -5998,7 +4591,7 @@ function wireEvents() {
5998
4591
  await doBridgeRestart(e.currentTarget);
5999
4592
  });
6000
4593
 
6001
- // Esc 关闭 modal(同步预览直接关;添加新助手走取消清理;连接弹窗 connecting 态不打断)
4594
+ // Esc 关闭 modal(同步预览直接关;添加新助手走取消清理)
6002
4595
  document.addEventListener("keydown", (e) => {
6003
4596
  if (e.key !== "Escape") return;
6004
4597
  const ob = document.getElementById("onboard-backdrop");
@@ -6006,12 +4599,6 @@ function wireEvents() {
6006
4599
  closeOnboardModal();
6007
4600
  return;
6008
4601
  }
6009
- const cb = document.getElementById("connect-backdrop");
6010
- if (cb && !cb.hidden) {
6011
- if (document.getElementById("connect-modal")?.querySelector(".spinner-lg")) return;
6012
- closeConnectFlow();
6013
- return;
6014
- }
6015
4602
  const bd = document.getElementById("modal-backdrop");
6016
4603
  if (bd && !bd.hidden) bd.hidden = true;
6017
4604
  });
@@ -6024,32 +4611,27 @@ async function boot() {
6024
4611
 
6025
4612
  wireEvents();
6026
4613
 
6027
- // 拉初始上下文状态(mode + centralAvailable)
4614
+ // 拉初始上下文状态(version + hostname)
6028
4615
  const ctxRes = await api("GET", "/api/context");
6029
4616
  if (ctxRes.ok) {
6030
- state.mode = ctxRes.json?.mode ?? "local";
6031
- state.centralAvailable = ctxRes.json?.centralAvailable ?? false;
4617
+ state.mode = "local";
6032
4618
  const ver = ctxRes.json?.version;
6033
4619
  const verEl = document.getElementById("brand-ver");
6034
4620
  if (verEl && ver) verEl.textContent = "v" + ver;
4621
+ // 填充本机 hostname badge(浏览器侧地址,本机即 127.0.0.1)
4622
+ const hostEl = document.getElementById("ctx-host");
4623
+ const host = location.hostname || "";
4624
+ if (hostEl && host) hostEl.textContent = " · " + host;
6035
4625
  }
6036
4626
 
6037
- // 拉中心库连接状态(driver:连接引导 vs 已连接名册 + 来源条)
6038
- await loadCentralStatus();
6039
-
6040
4627
  renderContextSwitch();
6041
- renderCentralSourceBar();
6042
4628
 
6043
4629
  // 拉 backend 注册表(驱动底座选择就绪态;失败静默)
6044
4630
  void loadBackends();
6045
4631
 
6046
4632
  // 拉 bot 列表(先于首次状态轮询,确保左侧条目存在好让圆点落上去)
6047
4633
  await loadBots();
6048
-
6049
- // 若初始就处于已连接的中心库上下文,后台算同步预览驱动来源条
6050
- if (state.mode === "central" && state.centralConnected) {
6051
- void refreshCentralSyncState();
6052
- }
4634
+ await refreshRuntimeRequirements();
6053
4635
 
6054
4636
  // 拉 bridge 服务状态并渲染顶栏指示
6055
4637
  await refreshBridgeStatus();
@@ -6057,6 +4639,7 @@ async function boot() {
6057
4639
  // 首次拉状态 + 每 15s 轮询(实时在线状态可视化)
6058
4640
  await pollStatus();
6059
4641
  setInterval(pollStatus, 15000);
4642
+ setInterval(refreshRuntimeRequirements, 30000);
6060
4643
  }
6061
4644
 
6062
4645
  /**
@@ -6068,4 +4651,11 @@ async function pollStatus() {
6068
4651
  renderStatus(res.ok ? res.json : null);
6069
4652
  }
6070
4653
 
4654
+ async function refreshRuntimeRequirements() {
4655
+ const res = await api("GET", "/api/runtime/requirements");
4656
+ state.requirements = res.ok ? res.json : null;
4657
+ renderServiceIndicator();
4658
+ if (state.selected) rerenderStatusAction(state.selected);
4659
+ }
4660
+
6071
4661
  boot();