zcode-pro 0.17.2 → 0.18.0

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.
@@ -15,12 +15,18 @@ function openExternal(url) {
15
15
  // 检查更新 → 升级(helper 后台任务,轮询进度,输出取最后一行、超长省略中间)→
16
16
  // 进行中可停止;关弹窗不中断升级,重开恢复显示。endpoint 为 /headroom 或 /rtk,
17
17
  // metaRefresh 在升级完成后刷新各面板自己的版本行与配置。
18
- function makeUpgradeControls({ endpoint, metaRefresh }) {
18
+ function makeUpgradeControls({ endpoint, metaRefresh, idleText }) {
19
19
  const L = t();
20
20
  const state = h('span', { class: 'min-w-0 flex-1 truncate text-left text-ui-xs/relaxed text-foreground-subtle' });
21
- // 失败原因在状态行直显红字(toast 只有 5 秒且易被弹窗遮挡)
21
+ // 失败原因在状态行直显红字(toast 只有 5 秒且易被弹窗遮挡)。
22
+ // idleText:空闲(无检查/升级活动)时该槽位显示调用方给的文本——rtk/headroom
23
+ // 面板用它显示本体位置;一旦有活动(检查中/下载进度/结果),活动文本顶替位置
24
+ const hasIdle = () => typeof idleText === 'function';
22
25
  const setState = (text, kind = '') => {
23
- state.textContent = text;
26
+ const shown = text || (hasIdle() ? (idleText() || '') : '');
27
+ // 「 · 」前缀:与前面的版本行无缝拼成「插件 x · rtk y · 状态/路径」一行;
28
+ // 错误信息不加前缀——它是独立提示,拼在版本行后会读成版本的一部分
29
+ state.textContent = shown ? (kind === 'error' ? shown : ` · ${shown}`) : '';
24
30
  state.className = 'min-w-0 flex-1 truncate text-left text-ui-xs/relaxed '
25
31
  + (kind === 'error' ? 'text-destructive' : 'text-foreground-subtle');
26
32
  };
@@ -129,6 +135,8 @@ function makeUpgradeControls({ endpoint, metaRefresh }) {
129
135
  const res = await rpc(endpoint + '/upgrade');
130
136
  if (res.ok && res.upgrade && res.upgrade.running) pollStart();
131
137
  })();
138
+ // 初始化为空闲态(有 idleText 时即显示位置等空闲文本)
139
+ setState('');
132
140
  return { btn, state, stop: stopPoll };
133
141
  }
134
142
 
@@ -323,7 +331,7 @@ export function openSettingsDialog() {
323
331
  const runPluginsUpdate = async () => {
324
332
  pluginsBtn.disabled = true;
325
333
  pluginsStatusLine.textContent = L.pluginsUpdating;
326
- const res = await rpc('/plugins/update', { method: 'POST', body: { installMissing: true } });
334
+ const res = await rpc('/plugins/update', { method: 'POST', body: {} });
327
335
  pluginsBtn.disabled = false;
328
336
  if (!res.ok) {
329
337
  pluginsStatusLine.textContent = '';
@@ -376,7 +384,8 @@ export function openSettingsDialog() {
376
384
  const paneVision = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
377
385
  const paneRtk = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
378
386
  const paneHeadroom = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
379
- const panes = { features: paneFeatures, styles: paneStyles, perf: panePerf, agents: paneAgents, proxy: paneProxy, sync: paneSync, vision: paneVision, headroom: paneHeadroom, rtk: paneRtk };
387
+ const paneDebug = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
388
+ const panes = { features: paneFeatures, styles: paneStyles, perf: panePerf, agents: paneAgents, proxy: paneProxy, sync: paneSync, vision: paneVision, headroom: paneHeadroom, rtk: paneRtk, debug: paneDebug };
380
389
  const tabDefs = [
381
390
  ['features', L.tabFeatures],
382
391
  ['styles', L.tabStyles],
@@ -387,6 +396,9 @@ export function openSettingsDialog() {
387
396
  ['vision', L.tabVision],
388
397
  ['headroom', L.tabHeadroom],
389
398
  ['rtk', L.tabRtk],
399
+ // “调试”标签页只对开发模式生效的用户显示(配置开关或 ZCODEPRO_DEV=1),
400
+ // 普通用户不可见
401
+ ...(config.devMode === true || config.devActive === true ? [['debug', L.tabDebug]] : []),
390
402
  ];
391
403
  const tablist = h('div', { role: 'tablist', 'aria-orientation': 'horizontal', class: 'zcodepro-tablist mt-4' });
392
404
  const renderTabs = () => tablist.replaceChildren(...tabDefs.map(([id, label]) =>
@@ -483,6 +495,7 @@ export function openSettingsDialog() {
483
495
  styleCell(L.rowGapName, L.rowGapDesc, 'rowGap'),
484
496
  styleCell(L.userLineHeightName, L.userLineHeightDesc, 'userLineHeight', { min: 1, max: 3, step: 0.05, unit: 'x' }),
485
497
  styleCell(L.lineHeightName, L.lineHeightDesc, 'lineHeight', { min: 1, max: 3, step: 0.05, unit: 'x' }),
498
+ styleCell(L.thinkingLineHeightName, L.thinkingLineHeightDesc, 'thinkingLineHeight', { min: 1, max: 3, step: 0.05, unit: 'x' }),
486
499
  styleCell(L.codeLineHeightName, L.codeLineHeightDesc, 'codeLineHeight', { min: 1, max: 3, step: 0.05, unit: 'x' }),
487
500
  styleCell(L.listSpacingName, L.listSpacingDesc, 'listSpacing'),
488
501
  styleCell(L.listItemSpacingName, L.listItemSpacingDesc, 'listItemSpacing'),
@@ -635,6 +648,12 @@ export function openSettingsDialog() {
635
648
  const proxyTestBtn = btnSmall(L.proxyTest, () => { void runProxyTest(); }, 'shrink-0');
636
649
  const runProxyTest = async () => {
637
650
  if (proxyTesting) return;
651
+ // 空地址不发请求(等网络超时才报「检测失败」的体验太差),立即提示
652
+ if (!proxyInput.value.trim()) {
653
+ proxyTestState.className = 'mt-2 text-ui-xs/relaxed text-amber-500';
654
+ proxyTestState.textContent = L.proxyTestEmpty;
655
+ return;
656
+ }
638
657
  proxyTesting = true;
639
658
  proxyTestBtn.disabled = true;
640
659
  proxyTestBtn.textContent = L.proxyTesting;
@@ -655,6 +674,39 @@ export function openSettingsDialog() {
655
674
  proxyTestState.className = 'mt-2 text-ui-xs/relaxed ' + (allOk ? 'text-foreground-subtle' : 'text-amber-500');
656
675
  proxyTestState.textContent = `${res.proxy}:${parts.join(' · ')}`;
657
676
  };
677
+ // 代理生效范围(即时保存,每个开关只改自己那项):插件市场更新与插件依赖
678
+ // 软件默认开,同步默认关——国内网盘直连即可,代理停了同步会跟着失败
679
+ const persistProxyScope = async (partial) => {
680
+ const res = await rpc('/config', { method: 'POST', body: { proxyScopes: partial } });
681
+ if (!res.ok) {
682
+ showToast(L.failed + ': ' + errText(res), 'error');
683
+ return false;
684
+ }
685
+ if (res.config && res.config.proxyScopes) config.proxyScopes = res.config.proxyScopes;
686
+ return true;
687
+ };
688
+ const proxyScopeWrap = h('div');
689
+ const renderProxyScopes = () => {
690
+ const sc = config.proxyScopes || {};
691
+ proxyScopeWrap.replaceChildren(
692
+ settingRow(L.proxyScopePlugins, L.proxyScopePluginsDesc, sc.plugins !== false, async () => {
693
+ const next = !(sc.plugins !== false);
694
+ if (await persistProxyScope({ plugins: next })) sc.plugins = next;
695
+ renderProxyScopes();
696
+ }),
697
+ settingRow(L.proxyScopeDeps, L.proxyScopeDepsDesc, sc.deps !== false, async () => {
698
+ const next = !(sc.deps !== false);
699
+ if (await persistProxyScope({ deps: next })) sc.deps = next;
700
+ renderProxyScopes();
701
+ }),
702
+ settingRow(L.proxyScopeSync, L.proxyScopeSyncDesc, sc.sync === true, async () => {
703
+ const next = !(sc.sync === true);
704
+ if (await persistProxyScope({ sync: next })) sc.sync = next;
705
+ renderProxyScopes();
706
+ }),
707
+ );
708
+ };
709
+ renderProxyScopes();
658
710
  paneProxy.append(
659
711
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.proxyDesc),
660
712
  h('div', { class: 'mt-3 flex items-center gap-2' },
@@ -663,6 +715,7 @@ export function openSettingsDialog() {
663
715
  proxyTestBtn),
664
716
  h('p', { class: 'mt-2 text-ui-xs/relaxed text-foreground-subtle' }, L.proxyHint),
665
717
  proxyTestState,
718
+ h('div', { class: 'mt-3 divide-y divide-border rounded-xl border border-border' }, proxyScopeWrap),
666
719
  );
667
720
 
668
721
  // “性能”:空闲项目内存回收(开关 + 两个时长)。idleReclaimStartupMinutes
@@ -712,8 +765,10 @@ export function openSettingsDialog() {
712
765
 
713
766
  // “同步”:把 ZCode Pro 设置与模型设置手动备份到 WebDAV / 从 WebDAV 恢复
714
767
  // (交互参考 Tampermonkey:URL/登录/密码 + 手动触发,无自动同步)。仅 WebDAV
715
- // 一种后端且本就只在点击时传输,不设“类型/禁用”下拉。表单显式保存;
716
- // 同步内容开关即时保存;下载会覆盖本机,先弹二次确认
768
+ // 一种后端且本就只在点击时传输,不设“类型/禁用”下拉。表单显式保存(旁有
769
+ // 「测试」用当前表单值验证连通);同步内容开关即时保存;下载先弹云端版本
770
+ // 列表(固定名 = 最新 + 带时间戳的历史),任选一个导入,导入会覆盖本机、
771
+ // 先弹二次确认
717
772
  const savedSync = (config.sync && typeof config.sync === 'object') ? config.sync
718
773
  : { url: '', dir: 'zcode-pro', login: '', password: '', include: { zcodepro: true, model: true }, lastPushAt: null, lastPullAt: null };
719
774
  const syncInputCls = 'h-8 w-full rounded-lg border border-border bg-input px-2.5 text-ui-sm text-foreground outline-none transition-shadow placeholder:text-foreground-subtle focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40';
@@ -761,6 +816,44 @@ export function openSettingsDialog() {
761
816
  syncCheckDirty();
762
817
  }
763
818
  };
819
+ // 「测试」:用表单当前值(先测再存)验证 WebDAV:连接与凭据 → 上传目录
820
+ // (缺则自动建,等同上传时的行为)→ 文件列表;结果仿“代理”标签页的检测,
821
+ // 一行显示各步耗时与云端备份文件数
822
+ const syncTestState = h('p', { class: 'mt-2 text-ui-xs/relaxed text-foreground-subtle' });
823
+ let syncTesting = false;
824
+ const syncTestBtn = btnSmall(L.syncTest, () => { void runSyncTest(); }, 'shrink-0');
825
+ const runSyncTest = async () => {
826
+ if (syncTesting) return;
827
+ // 没填 URL 不发请求(等超时才报“测试失败”的体验太差),立即提示
828
+ if (!syncUrlInput.value.trim()) {
829
+ syncTestState.className = 'mt-2 text-ui-xs/relaxed text-amber-500';
830
+ syncTestState.textContent = L.syncTestEmpty;
831
+ return;
832
+ }
833
+ syncTesting = true;
834
+ syncTestBtn.disabled = true;
835
+ syncTestBtn.textContent = L.syncTesting;
836
+ syncTestState.textContent = '';
837
+ syncTestState.className = 'mt-2 text-ui-xs/relaxed text-foreground-subtle';
838
+ const res = await rpc('/sync/test', { method: 'POST', body: { sync: syncFormValues() } });
839
+ syncTesting = false;
840
+ syncTestBtn.disabled = false;
841
+ syncTestBtn.textContent = L.syncTest;
842
+ if (!res.ok) {
843
+ syncTestState.className = 'mt-2 text-ui-xs/relaxed text-destructive';
844
+ syncTestState.textContent = L.syncTestFailed + ': ' + errText(res);
845
+ return;
846
+ }
847
+ const stepName = { connect: L.syncTestStepConnect, dir: L.syncTestStepDir, list: L.syncTestStepList };
848
+ const parts = (res.targets || []).map((t) =>
849
+ `${stepName[t.step] || t.step} ${t.ok ? '✓' + (t.ms != null ? ' ' + t.ms + 'ms' : '') : '✗ ' + (t.error || t.httpCode || '')}`.trim());
850
+ const allOk = (res.targets || []).length > 0 && (res.targets || []).every((t) => t.ok);
851
+ let host = syncUrlInput.value.trim();
852
+ try { host = new URL(host).host; } catch { /* 非法输入保留原文 */ }
853
+ syncTestState.className = 'mt-2 text-ui-xs/relaxed ' + (allOk ? 'text-foreground-subtle' : 'text-amber-500');
854
+ syncTestState.textContent = `${host}:${parts.join(' · ')}`
855
+ + (allOk && res.fileCount != null ? `(${L.syncTestFilesFound.replace('{n}', res.fileCount)})` : '');
856
+ };
764
857
  // 坚果云推荐行:外链经宿主 openExternal 打开系统浏览器(无该能力时只出纯文字)
765
858
  const JIANGUO_HELP = 'https://help.jianguoyun.com/?p=2064';
766
859
  const syncRecLine = h('p', { class: 'mt-1 text-ui-xs/relaxed text-foreground-subtle' });
@@ -798,61 +891,199 @@ export function openSettingsDialog() {
798
891
  );
799
892
  };
800
893
  renderSyncInclude();
801
- // 上次操作时间 + 上传/下载按钮;下载覆盖本机,需二次确认
894
+ // 上次操作时间 + 上传/下载按钮;上传固定名与历史版本双写,下载弹云端版本列表
802
895
  const fmtSyncTime = (ts) => (ts ? new Date(ts).toLocaleString() : L.syncNever);
803
896
  const syncStatusLine = h('div', { class: 'text-ui-xs/relaxed text-foreground-subtle' });
804
897
  const renderSyncStatus = (s) => {
805
898
  syncStatusLine.textContent = `${L.syncLastPush} ${fmtSyncTime(s.lastPushAt)} · ${L.syncLastPull} ${fmtSyncTime(s.lastPullAt)}`;
806
899
  };
807
900
  renderSyncStatus(savedSync);
808
- const syncUploadBtn = btnSmall(L.syncUpload, () => { void runSyncDirection('push'); }, 'shrink-0', 'primary');
809
- const syncDownloadBtn = btnSmall(L.syncDownload, () => { confirmSyncDownload(); }, 'shrink-0');
810
- const confirmSyncDownload = () => {
901
+ const syncUploadBtn = btnSmall(L.syncUpload, () => { void runSyncPush(); }, 'shrink-0', 'primary');
902
+ const syncDownloadBtn = btnSmall(L.syncDownload, () => { openSyncVersionPicker(); }, 'shrink-0');
903
+ // 「从云端下载」先弹版本列表(备份管理器的样子):列出云端目录里的备份文件
904
+ // (固定名 = 最新一版 + 带时间戳的历史),每行可导入到本机 / 从云端删除 /
905
+ // 另存到本机磁盘;导入按文件名前缀决定覆盖哪一项,不受“同步内容”勾选影响
906
+ const openSyncVersionPicker = () => {
811
907
  openDialog({
812
- title: L.syncConfirmTitle,
813
- description: L.syncConfirmDesc,
814
- width: 'sm:max-w-md',
815
- onMount: ({ body: confirmBody, close: closeConfirm }) => {
816
- confirmBody.append(
817
- dialogFooter(
818
- btnSecondary(L.cancel, () => closeConfirm()),
819
- btnPrimary(L.syncConfirmGo, () => { closeConfirm(); void runSyncDirection('pull'); }, 'min-w-24'),
820
- ),
821
- );
908
+ title: L.syncPickTitle,
909
+ description: L.syncPickDesc,
910
+ width: 'max-w-2xl',
911
+ dismissOnOutside: false,
912
+ onMount: async ({ body: pickBody, content, close: closePicker }) => {
913
+ // 标题栏 ✕(设置弹窗同款);h2 也是拖拽把手,✕ 上要拦住 mousedown 冒泡
914
+ const titleEl = content && content.firstElementChild;
915
+ if (titleEl && titleEl.tagName === 'H2') {
916
+ titleEl.classList.add('flex', 'w-full', 'items-center', 'justify-between');
917
+ titleEl.replaceChildren(
918
+ h('span', { class: 'flex min-w-0 items-center' }, L.syncPickTitle),
919
+ h('button', {
920
+ type: 'button',
921
+ class: 'zcodepro-close',
922
+ title: L.close,
923
+ 'aria-label': L.close,
924
+ onMousedown: (e) => { e.stopPropagation(); },
925
+ onClick: () => { closePicker(); },
926
+ }, '✕'),
927
+ );
928
+ }
929
+ const listWrap = h('div', { class: 'space-y-1' });
930
+ pickBody.append(listWrap);
931
+ const fmtSize = (n) => {
932
+ if (!Number.isFinite(n) || n <= 0) return '';
933
+ return n >= 1048576 ? (n / 1048576).toFixed(2) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB';
934
+ };
935
+ const setStatus = (el) => { listWrap.replaceChildren(el); };
936
+ const setRowBusy = (btns, busy) => { for (const b of btns) b.disabled = busy; };
937
+ const versionRow = (f) => {
938
+ // 固定名(zcodepro.json / model.json)不含时间戳段,始终是最新一版;
939
+ // 归属哪项同步内容由分组标题说明,行内不再重复
940
+ const isLatest = !/-\d{4}-\d{2}-\d{2}T/.test(f.name);
941
+ const importBtn = btnSmall(L.syncVerImport, () => { confirmImport(f, rowBtns, importBtn); }, 'shrink-0');
942
+ const deleteBtn = btnSmall(L.syncVerDelete, () => { confirmDelete(f, rowBtns, isLatest); }, 'shrink-0');
943
+ const saveBtn = btnSmall(L.syncVerSave, () => { void exportVersion(f, rowBtns, saveBtn); }, 'shrink-0');
944
+ const rowBtns = [importBtn, deleteBtn, saveBtn];
945
+ return h('div', { class: 'flex items-center gap-3 rounded-xl border border-border px-3 py-2' },
946
+ h('div', { class: 'min-w-0 flex-1' },
947
+ h('div', { class: 'flex min-w-0 items-center gap-1.5' },
948
+ h('span', { class: 'truncate font-mono text-ui-xs text-foreground', title: f.name }, f.name),
949
+ ...(isLatest ? [h('span', { class: 'shrink-0 rounded border border-border px-1.5 text-ui-xs text-foreground-subtle' }, L.syncVerLatest)] : [])),
950
+ h('div', { class: 'mt-0.5 text-ui-xs/relaxed text-foreground-subtle' },
951
+ [fmtSize(f.size), f.lastModified ? new Date(f.lastModified).toLocaleString() : ''].filter(Boolean).join(' · '))),
952
+ h('div', { class: 'flex shrink-0 items-center gap-1.5' }, importBtn, deleteBtn, saveBtn));
953
+ };
954
+ const loadList = async () => {
955
+ setStatus(h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.syncPickLoading));
956
+ const res = await rpc('/sync/versions', { method: 'POST', body: {} });
957
+ if (!res.ok) {
958
+ setStatus(h('p', { class: 'text-ui-sm text-destructive' }, L.failed + ': ' + errText(res)));
959
+ return;
960
+ }
961
+ const files = res.files || [];
962
+ if (!files.length) {
963
+ setStatus(h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.syncPickEmpty));
964
+ return;
965
+ }
966
+ // 按同步内容分组排列:组标题下固定名(最新)放最上面,历史版本按
967
+ // 时间新→旧往下——两组文件多了也不会按时间交错混排
968
+ const parts = [];
969
+ for (const [prefix, label] of [['zcodepro', L.syncIncludeZcp], ['model', L.syncIncludeModel]]) {
970
+ const group = files.filter((f) => f.name === `${prefix}.json` || f.name.startsWith(`${prefix}-`));
971
+ if (!group.length) continue;
972
+ const latest = group.find((f) => f.name === `${prefix}.json`);
973
+ const history = group.filter((f) => f !== latest)
974
+ .sort((a, b) => ((b.lastModified || 0) - (a.lastModified || 0)) || b.name.localeCompare(a.name));
975
+ parts.push(h('div', { class: 'px-1 pt-2 text-ui-xs font-medium text-foreground-subtle' },
976
+ `${label}${isZhLocale() ? '(' : ' ('}${group.length}${isZhLocale() ? ')' : ')'}`));
977
+ for (const f of [latest, ...history]) {
978
+ if (f) parts.push(versionRow(f));
979
+ }
980
+ }
981
+ listWrap.replaceChildren(...parts);
982
+ };
983
+ // 导入沿用原有的覆盖确认文案,并注明选中的是哪个文件
984
+ const confirmImport = (f, rowBtns, importBtn) => {
985
+ openDialog({
986
+ title: L.syncConfirmTitle,
987
+ width: 'sm:max-w-md',
988
+ onMount: ({ body: confirmBody, close: closeConfirm }) => {
989
+ confirmBody.append(
990
+ h('p', { class: 'mb-2 truncate font-mono text-ui-xs text-foreground-subtle', title: f.name }, f.name),
991
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.syncConfirmDesc),
992
+ dialogFooter(
993
+ btnSecondary(L.cancel, () => closeConfirm()),
994
+ btnPrimary(L.syncConfirmGo, () => { closeConfirm(); void importVersion(f, rowBtns, importBtn); }, 'min-w-24'),
995
+ ),
996
+ );
997
+ },
998
+ });
999
+ };
1000
+ const importVersion = async (f, rowBtns, importBtn) => {
1001
+ setRowBusy(rowBtns, true);
1002
+ importBtn.textContent = L.syncVerImporting;
1003
+ const res = await rpc('/sync/version/apply', { method: 'POST', body: { file: f.name } });
1004
+ setRowBusy(rowBtns, false);
1005
+ importBtn.textContent = L.syncVerImport;
1006
+ if (!res.ok) {
1007
+ showToast(L.failed + ': ' + errText(res), 'error');
1008
+ return;
1009
+ }
1010
+ closePicker();
1011
+ renderSyncStatus(res.sync || {});
1012
+ // 该版本已落到本机:刷新弹窗内开关显示,并让常驻功能立即按新配置生效
1013
+ clearConfigCache();
1014
+ window.dispatchEvent(new CustomEvent('zcodepro:config-changed'));
1015
+ Object.assign(config, await getConfig(true));
1016
+ refreshRows();
1017
+ renderToolbarSwitch();
1018
+ const names = (res.applied || [])
1019
+ .map((k) => (k === 'model' ? L.syncIncludeModel : L.syncIncludeZcp))
1020
+ .join(isZhLocale() ? '、' : ', ');
1021
+ showToast(names ? `${L.syncDownloaded}(${names})` : L.syncDownloaded, 'success');
1022
+ };
1023
+ const confirmDelete = (f, rowBtns, isLatest) => {
1024
+ openDialog({
1025
+ title: L.syncVerDeleteTitle,
1026
+ width: 'sm:max-w-md',
1027
+ onMount: ({ body: confirmBody, close: closeConfirm }) => {
1028
+ confirmBody.append(
1029
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' },
1030
+ L.syncVerDeleteDesc,
1031
+ h('span', { class: 'font-mono' }, f.name)),
1032
+ // 删“最新”文件会让旧版 zcode-pro 失去下载入口,多提醒一句
1033
+ ...(isLatest ? [h('p', { class: 'mt-2 text-ui-xs/relaxed text-amber-500' }, L.syncVerDeleteLatestNote)] : []),
1034
+ dialogFooter(
1035
+ btnSecondary(L.cancel, () => closeConfirm()),
1036
+ btnPrimary(L.syncVerDelete, () => { closeConfirm(); void deleteVersion(f, rowBtns); }, 'min-w-24'),
1037
+ ),
1038
+ );
1039
+ },
1040
+ });
1041
+ };
1042
+ const deleteVersion = async (f, rowBtns) => {
1043
+ setRowBusy(rowBtns, true);
1044
+ const res = await rpc('/sync/version/delete', { method: 'POST', body: { file: f.name } });
1045
+ setRowBusy(rowBtns, false);
1046
+ if (!res.ok) {
1047
+ showToast(L.failed + ': ' + errText(res), 'error');
1048
+ return;
1049
+ }
1050
+ showToast(L.syncVerDeleted, 'success');
1051
+ void loadList();
1052
+ };
1053
+ const exportVersion = async (f, rowBtns, saveBtn) => {
1054
+ setRowBusy(rowBtns, true);
1055
+ saveBtn.textContent = L.syncVerSaving;
1056
+ // 宿主会弹系统目录选择器选保存位置;用户取消时不作提示
1057
+ const res = await rpc('/sync/version/export', { method: 'POST', body: { file: f.name } });
1058
+ setRowBusy(rowBtns, false);
1059
+ saveBtn.textContent = L.syncVerSave;
1060
+ if (!res.ok) {
1061
+ if (res.code !== 'export-cancelled') showToast(L.failed + ': ' + errText(res), 'error');
1062
+ return;
1063
+ }
1064
+ showToast(`${L.syncVerSaved} ${res.path}`, 'success');
1065
+ };
1066
+ void loadList();
822
1067
  },
823
1068
  });
824
1069
  };
825
- const runSyncDirection = async (direction) => {
1070
+ // 上传:云端固定名(最新)与时间戳历史版本由服务端一并写入云端;下载期间按钮也
1071
+ // 禁用(列表在变,选了也会过期)
1072
+ const runSyncPush = async () => {
826
1073
  syncUploadBtn.disabled = true;
827
1074
  syncDownloadBtn.disabled = true;
828
- const btn = direction === 'push' ? syncUploadBtn : syncDownloadBtn;
829
- const idleText = btn.textContent;
830
- btn.textContent = direction === 'push' ? L.syncUploading : L.syncDownloading;
831
- const res = await rpc('/sync/run', { method: 'POST', body: { direction } });
1075
+ const idleText = syncUploadBtn.textContent;
1076
+ syncUploadBtn.textContent = L.syncUploading;
1077
+ const res = await rpc('/sync/run', { method: 'POST', body: { direction: 'push' } });
832
1078
  syncUploadBtn.disabled = false;
833
1079
  syncDownloadBtn.disabled = false;
834
- btn.textContent = idleText;
1080
+ syncUploadBtn.textContent = idleText;
835
1081
  if (!res.ok) {
836
1082
  showToast(L.failed + ': ' + errText(res), 'error');
837
1083
  return;
838
1084
  }
839
1085
  renderSyncStatus(res.sync || {});
840
- if (direction === 'pull') {
841
- // 云端配置已落到本机:刷新弹窗内开关显示,并让常驻功能立即按新配置生效
842
- clearConfigCache();
843
- window.dispatchEvent(new CustomEvent('zcodepro:config-changed'));
844
- Object.assign(config, await getConfig(true));
845
- refreshRows();
846
- renderToolbarSwitch();
847
- }
848
- // 下载 toast 标明本次覆盖了哪几项(两项独立成文件,可能只覆盖了一项)
849
- if (direction === 'push') showToast(L.syncUploaded, 'success');
850
- else {
851
- const names = (res.applied || [])
852
- .map((k) => (k === 'model' ? L.syncIncludeModel : L.syncIncludeZcp))
853
- .join(isZhLocale() ? '、' : ', ');
854
- showToast(names ? `${L.syncDownloaded}(${names})` : L.syncDownloaded, 'success');
855
- }
1086
+ showToast(L.syncUploaded, 'success');
856
1087
  };
857
1088
  paneSync.append(
858
1089
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.syncDesc),
@@ -865,7 +1096,8 @@ export function openSettingsDialog() {
865
1096
  h('div', { class: 'min-w-0' },
866
1097
  h('p', { class: 'text-ui-xs/relaxed text-foreground-subtle' }, L.syncFormHint),
867
1098
  syncRecLine),
868
- syncSaveBtn)),
1099
+ h('div', { class: 'flex shrink-0 items-center gap-1.5' }, syncSaveBtn, syncTestBtn))),
1100
+ syncTestState,
869
1101
  h('div', { class: 'mt-2 divide-y divide-border rounded-xl border border-border' }, syncIncludeWrap),
870
1102
  h('div', { class: 'mt-2 flex items-center justify-between gap-2 rounded-xl border border-border p-2' },
871
1103
  h('div', { class: 'min-w-0' }, syncStatusLine),
@@ -873,6 +1105,48 @@ export function openSettingsDialog() {
873
1105
  h('p', { class: 'mt-2 text-ui-xs/relaxed text-foreground-subtle' }, L.syncHint),
874
1106
  );
875
1107
 
1108
+ // “调试”:开发模式开关 + 重载按钮。重载 = 构建 → 换新启动器进程(新 helper
1109
+ // 代码与新注入脚本)→ 各 ZCode 窗口检测到换代自动整页刷新重新注入;
1110
+ // ZCode 本体与所有会话不受影响,不用重启 ZCode
1111
+ const persistDevMode = async (v) => {
1112
+ const res = await rpc('/config', { method: 'POST', body: { devMode: v } });
1113
+ if (!res.ok) {
1114
+ showToast(L.failed + ': ' + errText(res), 'error');
1115
+ return false;
1116
+ }
1117
+ if (res.config) config.devMode = res.config.devMode;
1118
+ return true;
1119
+ };
1120
+ const devReloadBtn = btnSmall(L.devReload, () => { void runDevReload(); }, 'shrink-0', 'primary');
1121
+ const runDevReload = async () => {
1122
+ devReloadBtn.disabled = true;
1123
+ devReloadBtn.textContent = L.devReloading;
1124
+ // 旧进程应答后即退出,fetch 可能中断,成败都进入下面的兜底刷新
1125
+ try { await rpc('/dev/reload', { method: 'POST', body: {} }); } catch { /* ignore */ }
1126
+ // 兜底:正常情况下新启动器注入时本页会自动刷新;2 秒没动静(交接失败)
1127
+ // 就自己刷新,宁可多刷一次也不能停在旧代码 + 失效 token 上
1128
+ setTimeout(() => { try { location.reload(); } catch { /* ignore */ } }, 2000);
1129
+ };
1130
+ const devModeWrap = h('div');
1131
+ const renderDevPane = () => {
1132
+ const dev = config.devMode === true;
1133
+ devModeWrap.replaceChildren(
1134
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.devModeDesc),
1135
+ h('div', { class: 'mt-3 divide-y divide-border rounded-xl border border-border' },
1136
+ settingRow(L.devMode, L.devModeRowDesc, dev, async () => {
1137
+ if (await persistDevMode(!dev)) renderDevPane();
1138
+ }),
1139
+ ...(dev ? [h('div', { class: 'flex items-center justify-between gap-2 px-2 py-1.5' },
1140
+ h('div', { class: 'min-w-0' },
1141
+ h('div', { class: 'text-ui-sm font-medium text-foreground' }, L.devReload),
1142
+ h('div', { class: 'mt-0.5 text-ui-xs/relaxed text-foreground-subtle' }, L.devReloadDesc)),
1143
+ devReloadBtn)] : []),
1144
+ ),
1145
+ );
1146
+ };
1147
+ renderDevPane();
1148
+ paneDebug.append(devModeWrap);
1149
+
876
1150
  // “视觉代理”:编辑 ~/.zcode/zcode-vision.json(zcode-vision 插件与 /vision-* 命令共用同一文件)。
877
1151
  // 代理列表顺序即执行链;结构性改动(开关/模式/排序/删增)即时保存,文本输入防抖保存。
878
1152
  // 注意:保存时链按全部代理重建——若用 /vision-chain 配过子集链,会被这里覆盖(两套入口语义如此,面板以列表为准)。
@@ -915,7 +1189,55 @@ export function openSettingsDialog() {
915
1189
  style: 'display:none',
916
1190
  });
917
1191
  const visionInputCls = 'h-8 w-full rounded-lg border border-border bg-input px-2.5 text-ui-sm text-foreground outline-none transition-shadow placeholder:text-foreground-subtle focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40';
1192
+ // 未装插件时的一键安装卡(与 Headroom 面板同款交互):helper 单插件安装,
1193
+ // 装完 onInstalled 原地恢复面板。rtk/vision 面板共用。
1194
+ const pluginInstallCard = (pluginName, onInstalled) => {
1195
+ const installBtn = btnSmall(L.headroomInstall, () => { void runInstall(); }, 'shrink-0');
1196
+ const installState = h('div', { class: 'mt-0.5 truncate text-ui-xs/relaxed text-foreground-subtle' });
1197
+ const runInstall = async () => {
1198
+ installBtn.disabled = true;
1199
+ installBtn.textContent = L.headroomInstalling;
1200
+ installState.textContent = '';
1201
+ const res = await rpc('/plugins/update', { method: 'POST', body: { name: pluginName } });
1202
+ if (!res.ok) {
1203
+ installBtn.disabled = false;
1204
+ installBtn.textContent = L.headroomInstall;
1205
+ showToast(L.pluginsUpdateFailed + ': ' + errText(res), 'error');
1206
+ return;
1207
+ }
1208
+ if (onInstalled) await onInstalled();
1209
+ showToast(L.pluginInstallDone.replaceAll('{name}', pluginName), 'success');
1210
+ };
1211
+ return h('div', { class: 'mt-3 flex items-center justify-between gap-2 rounded-xl border border-border p-2' },
1212
+ h('div', { class: 'min-w-0' },
1213
+ h('div', { class: 'text-ui-sm font-medium text-foreground' }, L.pluginInstallCardTitle.replaceAll('{name}', pluginName)),
1214
+ h('div', { class: 'mt-0.5 text-ui-xs/relaxed text-foreground-subtle' }, L.pluginInstallCardDesc),
1215
+ installState),
1216
+ installBtn);
1217
+ };
1218
+
918
1219
  const renderVision = () => {
1220
+ if (!visionRes.ok) {
1221
+ paneVision.replaceChildren(
1222
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.visionDesc),
1223
+ h('p', { class: 'mt-2 text-ui-sm text-destructive' }, L.visionLoadFailed + ': ' + errText(visionRes)),
1224
+ );
1225
+ return;
1226
+ }
1227
+ // 插件未装优先显示安装卡——判断只看钩子(visionRes.hook),与配置文件是否存在无关:
1228
+ // 配置是钩子消费的,没钩子就没功能;老用户卸载插件后 ~/.zcode/zcode-vision.json 仍在,
1229
+ // 若按配置存在与否分流会误入完整配置界面而毫无「未安装」提示
1230
+ if (!visionRes.hook) {
1231
+ paneVision.replaceChildren(
1232
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.visionDesc),
1233
+ pluginInstallCard('zcode-vision', async () => {
1234
+ const meta = await rpc('/vision');
1235
+ if (meta.ok) Object.assign(visionRes, meta);
1236
+ renderVision();
1237
+ }),
1238
+ );
1239
+ return;
1240
+ }
919
1241
  if (!visionCfg) {
920
1242
  const initBtn = btnSmall(L.visionAddProxy, async () => {
921
1243
  const res = await rpc('/vision', { method: 'POST', body: { config: VISION_DEFAULT_CFG } });
@@ -925,6 +1247,10 @@ export function openSettingsDialog() {
925
1247
  paneVision.replaceChildren(
926
1248
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.visionDesc),
927
1249
  ...(visionRes.ok ? [] : [h('p', { class: 'mt-2 text-ui-sm text-destructive' }, L.visionLoadFailed + ': ' + errText(visionRes))]),
1250
+ // 信息卡(仅版本行;配置未建,启用开关随配置出现)
1251
+ h('div', { class: 'mt-3 rounded-xl border border-border p-2' },
1252
+ h('div', { class: 'flex flex-wrap items-center gap-2' },
1253
+ h('span', { class: 'shrink-0 whitespace-nowrap text-ui-xs/relaxed text-foreground-subtle' }, `插件 ${visionRes.pluginVersion || '—'}`))),
928
1254
  h('div', { class: 'mt-3 flex justify-end' }, initBtn),
929
1255
  );
930
1256
  return;
@@ -1046,17 +1372,22 @@ export function openSettingsDialog() {
1046
1372
  });
1047
1373
  paneVision.replaceChildren(
1048
1374
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.visionDesc),
1049
- // 链模式 + 两个开关合到一行(顶部已有简介,开关不再单占一块、不带描述)
1375
+ // 信息卡:插件版本 + 启用图片识别开关(与 rtk/headroom 面板同款式)
1376
+ h('div', { class: 'mt-3 rounded-xl border border-border p-2' },
1377
+ h('div', { class: 'flex flex-wrap items-center gap-2' },
1378
+ h('span', { class: 'shrink-0 whitespace-nowrap text-ui-xs/relaxed text-foreground-subtle' }, `插件 ${visionRes.pluginVersion || '—'}`),
1379
+ h('div', { class: 'ml-auto' },
1380
+ settingRow(L.visionEnabled, '', visionCfg.enabled !== false, async () => {
1381
+ visionCfg.enabled = !(visionCfg.enabled !== false);
1382
+ await persistVision();
1383
+ renderVision();
1384
+ })))),
1385
+ // 链模式 + 强制拦截开关合到一行(启用开关已上移信息卡)
1050
1386
  h('div', { class: 'mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 rounded-xl border border-border p-1' },
1051
1387
  h('span', { class: 'ml-1.5 shrink-0 text-ui-sm font-medium text-foreground' }, L.visionMode),
1052
1388
  modeBtn('fallback', L.visionModeFallback, L.visionModeFallbackDesc),
1053
1389
  modeBtn('pipeline', L.visionModePipeline, L.visionModePipelineDesc),
1054
1390
  h('div', { class: 'ml-auto flex items-center gap-4' },
1055
- settingRow(L.visionEnabled, '', visionCfg.enabled !== false, async () => {
1056
- visionCfg.enabled = !(visionCfg.enabled !== false);
1057
- await persistVision();
1058
- renderVision();
1059
- }),
1060
1391
  settingRow(L.visionForceIntercept, '', visionCfg.forceIntercept !== false, async () => {
1061
1392
  visionCfg.forceIntercept = !(visionCfg.forceIntercept !== false);
1062
1393
  await persistVision();
@@ -1122,15 +1453,20 @@ export function openSettingsDialog() {
1122
1453
  return res.ok;
1123
1454
  };
1124
1455
  const rtkInputCls = 'h-8 w-full rounded-lg border border-border bg-input px-2.5 text-ui-sm text-foreground outline-none transition-shadow placeholder:text-foreground-subtle focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/40';
1125
- // rtk 本体(GitHub Releases 下载替换)的版本行与升级控件,渲染函数外创建一次
1456
+ // rtk 本体(GitHub Releases 下载替换)的版本行与升级控件,渲染函数外创建一次。
1457
+ // 信息卡单行:版本行 + 弹性状态槽(空闲显示本体位置,检查更新/升级时被活动
1458
+ // 日志顶替,见 makeUpgradeControls 的 idleText)+ 检查更新按钮 + 启用开关
1126
1459
  const rtkVersionLine = h('span', { class: 'shrink-0 whitespace-nowrap text-ui-xs/relaxed text-foreground-subtle' });
1460
+ let rtkLocText = '';
1127
1461
  const renderRtkVersion = (meta) => {
1128
- const v = meta.version || 'rtk';
1129
- rtkVersionLine.textContent = meta.binPath ? `${v} · ${meta.binPath}` : v;
1462
+ const v = String(meta.version || '').replace(/^rtk\s+/i, '').trim();
1463
+ rtkVersionLine.textContent = `插件 ${meta.pluginVersion || '—'}${v ? ` · rtk ${v}` : ''}`;
1464
+ rtkLocText = meta.binPath || '';
1130
1465
  };
1131
1466
  renderRtkVersion(rtkRes);
1132
1467
  const rtkUp = makeUpgradeControls({
1133
1468
  endpoint: '/rtk',
1469
+ idleText: () => rtkLocText,
1134
1470
  metaRefresh: async () => {
1135
1471
  const meta = await rpc('/rtk');
1136
1472
  if (meta.ok) {
@@ -1143,15 +1479,50 @@ export function openSettingsDialog() {
1143
1479
  rtkUpgradePollStop = rtkUp.stop;
1144
1480
  // 版本/路径/检查更新 + 启用 rtk 压缩开关合到一行(开关靠右,顶部已有简介不带描述)
1145
1481
  const rtkSwitchRow = h('div');
1146
- const rtkVersionCard = h('div', { class: 'mt-3 flex items-center gap-2 rounded-xl border border-border p-2' },
1147
- rtkVersionLine, rtkUp.state, rtkUp.btn, rtkSwitchRow);
1482
+ const rtkVersionCard = h('div', { class: 'mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-border p-2' },
1483
+ rtkVersionLine, rtkUp.state, rtkUp.btn,
1484
+ h('div', { class: 'ml-auto shrink-0' }, rtkSwitchRow));
1148
1485
  const rtkBody = h('div');
1149
- // 简介置顶(与 Headroom 面板一致),其下版本卡(含启用 rtk 压缩开关),再下白名单
1486
+ // 插件未装时的一键安装行(本体未装另有提示行),装完原地撤下
1487
+ const rtkPluginRow = h('div');
1488
+ const renderRtkPluginRow = () => {
1489
+ rtkPluginRow.replaceChildren(...(rtkRes.hook ? [] : [
1490
+ pluginInstallCard('rtk', async () => {
1491
+ const meta = await rpc('/rtk');
1492
+ if (meta.ok) {
1493
+ Object.assign(rtkRes, meta);
1494
+ // 版本行 span 与 rtkCfg 都是弹窗打开时的未装态快照,装完必须重算/重建
1495
+ renderRtkVersion(meta);
1496
+ // rtkCfg 整体重建(未装态时内置清单为空,不重建则白名单区块渲染旧空清单)
1497
+ rtkCfg = {
1498
+ installed: meta.installed !== false,
1499
+ version: meta.version || '',
1500
+ binPath: meta.binPath || '',
1501
+ mode: meta.mode === 'off' ? 'off' : 'hint',
1502
+ whitelist: Array.isArray(meta.whitelist) ? [...meta.whitelist] : [],
1503
+ builtinGit: Array.isArray(meta.builtinGit) ? [...meta.builtinGit] : [],
1504
+ builtinPlain: Array.isArray(meta.builtinPlain) ? [...meta.builtinPlain] : [],
1505
+ };
1506
+ }
1507
+ renderRtkPluginRow();
1508
+ renderRtk();
1509
+ }),
1510
+ ]));
1511
+ };
1512
+ renderRtkPluginRow();
1513
+ // 简介置顶(与 Headroom 面板一致),其下插件安装卡,再下版本卡(含启用 rtk 压缩开关),再下白名单
1150
1514
  paneRtk.append(
1151
1515
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.rtkDesc),
1516
+ rtkPluginRow,
1152
1517
  rtkVersionCard,
1153
1518
  rtkBody);
1154
1519
  const renderRtk = () => {
1520
+ // 插件未装:只显示安装卡,隐藏版本卡与全部设置项(与 vision/headroom 未装时一致)
1521
+ if (rtkRes.ok && !rtkRes.hook) {
1522
+ rtkVersionCard.style.display = 'none';
1523
+ rtkBody.replaceChildren();
1524
+ return;
1525
+ }
1155
1526
  rtkVersionCard.style.display = rtkCfg && rtkCfg.installed ? '' : 'none';
1156
1527
  if (!rtkCfg) {
1157
1528
  rtkBody.replaceChildren(
@@ -1195,7 +1566,45 @@ export function openSettingsDialog() {
1195
1566
  rtkBody.replaceChildren(
1196
1567
  ...(rtkCfg.installed
1197
1568
  ? []
1198
- : [h('p', { class: 'text-ui-sm text-amber-500' }, L.rtkNotInstalled)]),
1569
+ : [h('div', { class: 'flex items-center gap-2' },
1570
+ h('p', { class: 'min-w-0 flex-1 text-ui-sm text-amber-500' }, L.rtkNotInstalled),
1571
+ // 一键安装本体:与“检查更新”同一条 helper 升级管线(未装则装到 ~/.local/bin)
1572
+ (() => {
1573
+ const b = btnSmall(L.rtkInstallBin, () => { void run(); }, 'shrink-0');
1574
+ const run = async () => {
1575
+ b.disabled = true;
1576
+ b.textContent = L.headroomInstalling;
1577
+ const res = await rpc('/rtk', { method: 'POST', body: { upgrade: true } });
1578
+ if (!res.ok) {
1579
+ b.disabled = false;
1580
+ b.textContent = L.rtkInstallBin;
1581
+ showToast(L.failed + ': ' + errText(res), 'error');
1582
+ return;
1583
+ }
1584
+ const done = await new Promise((resolve) => {
1585
+ const iv = setInterval(async () => {
1586
+ const s = await rpc('/rtk/upgrade');
1587
+ if (s.ok && s.upgrade && !s.upgrade.running) { clearInterval(iv); resolve(s.upgrade); }
1588
+ }, 1200);
1589
+ // 超时也要 resolve:否则按钮永久停在“安装中…”
1590
+ setTimeout(() => { clearInterval(iv); resolve(null); }, 10 * 60 * 1000);
1591
+ });
1592
+ if (done && !done.error && done.version) {
1593
+ const meta = await rpc('/rtk');
1594
+ if (meta.ok) {
1595
+ Object.assign(rtkRes, meta);
1596
+ rtkCfg = { ...rtkCfg, installed: meta.installed !== false, version: meta.version || '', binPath: meta.binPath || '' };
1597
+ }
1598
+ showToast(L.rtkBinInstalled, 'success');
1599
+ renderRtk();
1600
+ } else {
1601
+ b.disabled = false;
1602
+ b.textContent = L.rtkInstallBin;
1603
+ showToast((done && done.error) || L.headroomInstallRetry, 'error');
1604
+ }
1605
+ };
1606
+ return b;
1607
+ })())]),
1199
1608
  h('div', { class: 'mt-2 rounded-xl border border-border p-2' },
1200
1609
  h('div', { class: 'text-ui-sm font-medium text-foreground' }, L.rtkWhitelistTitle),
1201
1610
  h('p', { class: 'mt-1 text-ui-xs/relaxed text-foreground-subtle' }, L.rtkWhitelistDesc),
@@ -1243,15 +1652,18 @@ export function openSettingsDialog() {
1243
1652
  // 可整面重渲染:未装插件时显示安装卡,安装成功后原地恢复完整面板。
1244
1653
  // 版本行与本体升级控件(pip 通道)在渲染函数外创建一次,重渲染时复用同一组节点
1245
1654
  const hrVersionLine = h('span', { class: 'shrink-0 whitespace-nowrap text-ui-xs/relaxed text-foreground-subtle' });
1655
+ let hrLocText = '';
1656
+ // 启用开关(与 rtk 面板同款,版本卡右上角):开 = ZCode 供应商走 headroom,
1657
+ // 关 = 直连上游;只改供应商地址,不动代理进程(headroom 可被其他程序共用)
1658
+ const hrSwitchRow = h('div');
1246
1659
  const renderHrVersion = (meta) => {
1247
- const parts = [];
1248
- if (meta.pluginVersion) parts.push(`${L.headroomVersionPlugin} ${meta.pluginVersion}`);
1249
- if (meta.version) parts.push(`${L.headroomVersionBin} ${meta.version}`);
1250
- hrVersionLine.textContent = parts.length ? parts.join(' · ') : `${L.headroomVersionPlugin} —`;
1660
+ hrVersionLine.textContent = `插件 ${meta.pluginVersion || '—'}${meta.version ? ` · headroom ${meta.version}` : ''}`;
1661
+ hrLocText = meta.binPath || '';
1251
1662
  };
1252
1663
  renderHrVersion(headroomRes);
1253
1664
  const hrUp = makeUpgradeControls({
1254
1665
  endpoint: '/headroom',
1666
+ idleText: () => hrLocText,
1255
1667
  metaRefresh: async () => {
1256
1668
  const meta = await rpc('/headroom');
1257
1669
  if (meta.ok) {
@@ -1278,7 +1690,7 @@ export function openSettingsDialog() {
1278
1690
  installBtn.disabled = true;
1279
1691
  installBtn.textContent = L.headroomInstalling;
1280
1692
  installState.textContent = '';
1281
- const res = await rpc('/plugins/update', { method: 'POST', body: { name: 'headroom', installMissing: true } });
1693
+ const res = await rpc('/plugins/update', { method: 'POST', body: { name: 'headroom' } });
1282
1694
  if (!res.ok) {
1283
1695
  installBtn.disabled = false;
1284
1696
  installBtn.textContent = L.headroomInstall;
@@ -1289,6 +1701,8 @@ export function openSettingsDialog() {
1289
1701
  if (meta.ok) Object.assign(headroomRes, meta);
1290
1702
  if (headroomRes.hook) {
1291
1703
  showToast(L.headroomInstalled, 'success');
1704
+ // 版本行 span 在弹窗打开时按未装态算过一次,装完必须重算
1705
+ renderHrVersion(headroomRes);
1292
1706
  renderHeadroomPane();
1293
1707
  } else {
1294
1708
  installBtn.disabled = false;
@@ -1312,16 +1726,65 @@ export function openSettingsDialog() {
1312
1726
  const hrRun = async (reqBody, warn) => {
1313
1727
  if (hrBusy) return false;
1314
1728
  hrBusy = true;
1315
- const res = await rpc('/headroom', { method: 'POST', body: reqBody });
1316
- hrBusy = false;
1729
+ const action = reqBody && reqBody.action;
1730
+ const isStart = action === 'start' || action === 'restart';
1731
+ const isStop = action === 'stop';
1732
+ // 生命周期按钮:点击瞬间就显示等待提示 + 计时(不等 RPC 返回——
1733
+ // RPC 里 helper 要杀/拉进程,期间 3-10 秒界面不能毫无反应)
1734
+ // RPC 先发出(不等返回——期间界面要显示等待反馈)
1735
+ const rpcDone = rpc('/headroom', { method: 'POST', body: reqBody });
1736
+ let timerIv = null;
1737
+ if (isStart || isStop) {
1738
+ const wantUp = isStart;
1739
+ const msg = action === 'restart' ? L.hrRestarting : isStart ? L.hrStarting : L.hrStopping;
1740
+ const doneMsg = isStart ? L.hrStarted : L.hrStopped;
1741
+ const timeoutMs = isStart ? 90 * 1000 : 30 * 1000;
1742
+ const t0 = Date.now();
1743
+ const timerSpan = h('span', { class: 'text-ui-sm text-foreground-subtle/70' }, msg + ' (0s)');
1744
+ hrStatusLine.replaceChildren(timerSpan);
1745
+ timerIv = setInterval(() => {
1746
+ timerSpan.textContent = msg + ` (${Math.round((Date.now() - t0) / 1000)}s)`;
1747
+ }, 1000);
1748
+ // RPC 完成后进入轮询(RPC 执行实际的 stop/start,可能要 3-10 秒)。
1749
+ // 轮询出结果前 hrBusy 一直占着:期间再点开关或按钮直接忽略,否则
1750
+ // 每次点击都会重发 start、把计时和启动流程从头来过
1751
+ void (async () => {
1752
+ const kick = await rpcDone;
1753
+ if (!kick.ok) return; // 失败由主流程提示并复位
1754
+ while (Date.now() - t0 < timeoutMs) {
1755
+ await new Promise((r) => setTimeout(r, 2000));
1756
+ const st = await rpc('/headroom/status');
1757
+ if (!st.ok) continue;
1758
+ const up = !!(st.status && st.status.up);
1759
+ if (up === wantUp) {
1760
+ clearInterval(timerIv);
1761
+ hrBusy = false;
1762
+ await hrRefresh();
1763
+ showToast(doneMsg, isStart ? 'success' : undefined);
1764
+ return;
1765
+ }
1766
+ }
1767
+ clearInterval(timerIv);
1768
+ hrBusy = false;
1769
+ if (isStart) showToast(L.hrStartTimeout, 'error');
1770
+ void hrRefresh();
1771
+ })();
1772
+ }
1773
+ const res = await rpcDone;
1317
1774
  if (!res.ok) {
1775
+ if (timerIv) clearInterval(timerIv);
1776
+ hrBusy = false;
1318
1777
  showToast(L.failed + ': ' + errText(res), 'error');
1778
+ void hrRefresh();
1319
1779
  return false;
1320
1780
  }
1321
1781
  if (res.config) Object.assign(hrCfg, res.config);
1322
1782
  if (warn) showToast(warn);
1323
- // backend/power 动作会重启代理,稍等其就绪再刷状态,避免闪现“未运行”
1324
- setTimeout(() => { void hrRefresh(); }, 1200);
1783
+ // 非生命周期动作(backend/power):稍等后刷一次
1784
+ if (!isStart && !isStop) {
1785
+ hrBusy = false;
1786
+ setTimeout(() => { void hrRefresh(); }, 1200);
1787
+ }
1325
1788
  return true;
1326
1789
  };
1327
1790
  // 状态值本地化:钩子输出的是 auto/cpu/ac/ok 等内部值,面板显示成人话
@@ -1360,6 +1823,23 @@ export function openSettingsDialog() {
1360
1823
  hrHintLine.textContent = s.saverHint || '';
1361
1824
  hrHintLine.style.display = s.saverHint ? '' : 'none';
1362
1825
  };
1826
+ // 启用开关不走 hrRun:与代理进程无关,点击即时生效,也不受生命周期等待锁影响
1827
+ const renderHrUsageSwitch = () => {
1828
+ const usageOn = !!(headroomRes.usage && headroomRes.usage.enabled);
1829
+ hrSwitchRow.replaceChildren(
1830
+ settingRow(L.pluginEnable, '', usageOn, async () => {
1831
+ const next = !usageOn;
1832
+ const res = await rpc('/headroom', { method: 'POST', body: { usage: next } });
1833
+ if (!res.ok) {
1834
+ showToast(L.failed + ': ' + errText(res), 'error');
1835
+ return;
1836
+ }
1837
+ if (res.usage) headroomRes.usage = res.usage;
1838
+ renderHrUsageSwitch();
1839
+ if (next && !res.changed && !(res.usage && res.usage.enabled)) showToast(L.hrUsageNone);
1840
+ }));
1841
+ };
1842
+ renderHrUsageSwitch();
1363
1843
  const hrRefreshBtn = btnSmall(L.headroomRefresh, () => { void hrRefresh(); });
1364
1844
  const hrStartBtn = btnSmall(L.headroomStart, () => { void hrRun({ action: 'start' }); });
1365
1845
  const hrRestartBtn = btnSmall(L.headroomRestart, () => { void hrRun({ action: 'restart' }); });
@@ -1453,14 +1933,60 @@ export function openSettingsDialog() {
1453
1933
  };
1454
1934
  updateHrPwrHint();
1455
1935
 
1936
+ // 本体未装:一键安装(pip 全新安装通道,helper 同一条升级任务管线);
1937
+ // 装完刷新 meta 并整面重渲染(版本行、本体状态、代理控制全部就位)
1938
+ const hrInstallBinBtn = (() => {
1939
+ const b = btnSmall(L.hrInstallBin, () => { void run(); }, 'shrink-0');
1940
+ let busy = false;
1941
+ const run = async () => {
1942
+ if (busy) return;
1943
+ busy = true;
1944
+ b.disabled = true;
1945
+ b.textContent = L.headroomInstalling;
1946
+ const res = await rpc('/headroom', { method: 'POST', body: { upgrade: true } });
1947
+ if (!res.ok) {
1948
+ b.disabled = false;
1949
+ b.textContent = L.hrInstallBin;
1950
+ showToast(L.failed + ': ' + errText(res), 'error');
1951
+ busy = false;
1952
+ return;
1953
+ }
1954
+ // pip 下载大包可持续数分钟,轮询后台任务到结束(超时 resolve,避免按钮卡死)
1955
+ const done = await new Promise((resolve) => {
1956
+ const iv = setInterval(async () => {
1957
+ const s = await rpc('/headroom/upgrade');
1958
+ if (s.ok && s.upgrade && !s.upgrade.running) { clearInterval(iv); resolve(s.upgrade); }
1959
+ }, 2000);
1960
+ setTimeout(() => { clearInterval(iv); resolve(null); }, 12 * 60 * 1000);
1961
+ });
1962
+ busy = false;
1963
+ if (done && !done.error && done.version) {
1964
+ const meta = await rpc('/headroom');
1965
+ if (meta.ok) {
1966
+ renderHrVersion(meta);
1967
+ Object.assign(headroomRes, meta);
1968
+ }
1969
+ showToast(L.hrBinInstalled, 'success');
1970
+ renderHeadroomPane();
1971
+ } else {
1972
+ b.disabled = false;
1973
+ b.textContent = L.hrInstallBin;
1974
+ showToast((done && done.error) || L.headroomInstallRetry, 'error');
1975
+ }
1976
+ };
1977
+ return b;
1978
+ })();
1979
+
1456
1980
  paneHeadroom.append(
1457
1981
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.headroomDesc),
1458
1982
  ...(headroomRes.installed === false
1459
- ? [h('p', { class: 'mt-2 text-ui-sm text-amber-500' }, L.headroomBinMissing)]
1983
+ ? [h('div', { class: 'mt-2 flex items-center gap-2' },
1984
+ h('p', { class: 'min-w-0 flex-1 text-ui-sm text-amber-500' }, L.headroomBinMissing),
1985
+ hrInstallBinBtn)]
1460
1986
  : []),
1461
- h('div', { class: 'mt-3 flex items-center justify-between gap-2 rounded-xl border border-border p-2' },
1462
- h('div', { class: 'flex min-w-0 items-center gap-2' }, hrVersionLine, hrUp.state),
1463
- hrUp.btn),
1987
+ h('div', { class: 'mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-border p-2' },
1988
+ hrVersionLine, hrUp.state, hrUp.btn,
1989
+ h('div', { class: 'ml-auto shrink-0' }, hrSwitchRow)),
1464
1990
  h('div', { class: 'mt-2 rounded-xl border border-border p-2' },
1465
1991
  h('div', { class: 'flex flex-wrap items-center justify-between gap-2' },
1466
1992
  h('span', { class: 'text-ui-sm font-medium text-foreground' }, L.headroomStatusTitle),
@@ -1497,6 +2023,7 @@ export function openSettingsDialog() {
1497
2023
  paneVision,
1498
2024
  paneHeadroom,
1499
2025
  paneRtk,
2026
+ paneDebug,
1500
2027
  );
1501
2028
  },
1502
2029
  });