zcode-pro 0.17.2 → 0.19.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.
@@ -2,6 +2,7 @@
2
2
  // 顶部标签页切换(视觉参考侧栏“分组/项目”切换);配置写入 helper(~/.zcode/zcodepro.json)。
3
3
  import { h, t, rpc, getConfig, clearConfigCache, errText, HELPER_URL, isZhLocale } from '../core.js';
4
4
  import { openDialog, dialogFooter, btnPrimary, btnSecondary, btnSmall, settingRow, ensureStyle, showToast, numberField, unitField } from '../ui.js';
5
+ import { comboField, providerOptionsOf, modelOptionsOf, modelPlaceholderOf, isManualConn } from '../provider-fields.js';
5
6
  import { refreshAliases } from './alias.js';
6
7
  import { applyStyles, STYLE_DEFAULTS, showStyleHighlight, hideStyleHighlight } from './styles.js';
7
8
  import { copyToClipboard, hCopyIcon } from './copy-path.js';
@@ -15,12 +16,18 @@ function openExternal(url) {
15
16
  // 检查更新 → 升级(helper 后台任务,轮询进度,输出取最后一行、超长省略中间)→
16
17
  // 进行中可停止;关弹窗不中断升级,重开恢复显示。endpoint 为 /headroom 或 /rtk,
17
18
  // metaRefresh 在升级完成后刷新各面板自己的版本行与配置。
18
- function makeUpgradeControls({ endpoint, metaRefresh }) {
19
+ function makeUpgradeControls({ endpoint, metaRefresh, idleText }) {
19
20
  const L = t();
20
21
  const state = h('span', { class: 'min-w-0 flex-1 truncate text-left text-ui-xs/relaxed text-foreground-subtle' });
21
- // 失败原因在状态行直显红字(toast 只有 5 秒且易被弹窗遮挡)
22
+ // 失败原因在状态行直显红字(toast 只有 5 秒且易被弹窗遮挡)。
23
+ // idleText:空闲(无检查/升级活动)时该槽位显示调用方给的文本——rtk/headroom
24
+ // 面板用它显示本体位置;一旦有活动(检查中/下载进度/结果),活动文本顶替位置
25
+ const hasIdle = () => typeof idleText === 'function';
22
26
  const setState = (text, kind = '') => {
23
- state.textContent = text;
27
+ const shown = text || (hasIdle() ? (idleText() || '') : '');
28
+ // 「 · 」前缀:与前面的版本行无缝拼成「插件 x · rtk y · 状态/路径」一行;
29
+ // 错误信息不加前缀——它是独立提示,拼在版本行后会读成版本的一部分
30
+ state.textContent = shown ? (kind === 'error' ? shown : ` · ${shown}`) : '';
24
31
  state.className = 'min-w-0 flex-1 truncate text-left text-ui-xs/relaxed '
25
32
  + (kind === 'error' ? 'text-destructive' : 'text-foreground-subtle');
26
33
  };
@@ -129,6 +136,8 @@ function makeUpgradeControls({ endpoint, metaRefresh }) {
129
136
  const res = await rpc(endpoint + '/upgrade');
130
137
  if (res.ok && res.upgrade && res.upgrade.running) pollStart();
131
138
  })();
139
+ // 初始化为空闲态(有 idleText 时即显示位置等空闲文本)
140
+ setState('');
132
141
  return { btn, state, stop: stopPoll };
133
142
  }
134
143
 
@@ -141,8 +150,10 @@ export function openSettingsDialog() {
141
150
  openDialog({
142
151
  title: L.settingsTitle,
143
152
  // 宽度类必须用宿主样式表已有的工具类(注入的类名不会生成 CSS):
144
- // sm:max-w-3xl 不存在会失去约束拉满全屏;max-w-2xl=42rem 存在且尺寸合适
145
- width: 'max-w-2xl',
153
+ // sm:max-w-3xl 不存在会失去约束拉满全屏;max-w-3xl=48rem 存在——英文界面
154
+ // 标签栏较长,42rem 会挤出一块空白并触发横向滚动
155
+ width: 'max-w-3xl',
156
+ bodyClass: 'mt-1',
146
157
  overlay: 'none',
147
158
  draggable: true,
148
159
  posKey: 'settings',
@@ -194,7 +205,11 @@ export function openSettingsDialog() {
194
205
  const config = await getConfig(true);
195
206
  const agentsRes = await rpc('/agents');
196
207
  const visionRes = await rpc('/vision');
197
- const visionProvidersRes = await rpc('/vision/providers');
208
+ const titleGenRes = await rpc('/title-gen');
209
+ const titleGenProvidersRes = await rpc('/title-gen/providers');
210
+ // 供应商表(含各供应商可选模型):视觉代理的模型下拉也用这份
211
+ const titleGenProviders = titleGenProvidersRes.ok && Array.isArray(titleGenProvidersRes.providers)
212
+ ? titleGenProvidersRes.providers : [];
198
213
  const rtkRes = await rpc('/rtk');
199
214
  const headroomRes = await rpc('/headroom');
200
215
 
@@ -217,7 +232,7 @@ export function openSettingsDialog() {
217
232
  'data-zcodepro-status': '1',
218
233
  },
219
234
  h('span', { class: 'inline-block size-2 rounded-full bg-emerald-500' }),
220
- h('span', { class: 'text-foreground-subtle/70' }, `${L.version} ${health.version} · ${HELPER_URL}`)
235
+ h('span', { class: 'text-foreground-subtle/70' }, `${health.version} · ${HELPER_URL}`)
221
236
  );
222
237
 
223
238
  const rows = h('div', { class: 'divide-y divide-border rounded-xl border border-border' });
@@ -323,7 +338,7 @@ export function openSettingsDialog() {
323
338
  const runPluginsUpdate = async () => {
324
339
  pluginsBtn.disabled = true;
325
340
  pluginsStatusLine.textContent = L.pluginsUpdating;
326
- const res = await rpc('/plugins/update', { method: 'POST', body: { installMissing: true } });
341
+ const res = await rpc('/plugins/update', { method: 'POST', body: {} });
327
342
  pluginsBtn.disabled = false;
328
343
  if (!res.ok) {
329
344
  pluginsStatusLine.textContent = '';
@@ -370,25 +385,30 @@ export function openSettingsDialog() {
370
385
  );
371
386
  const paneStyles = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
372
387
  const panePerf = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
373
- const paneAgents = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
388
+ const paneAgents = h('div', { role: 'tabpanel', class: 'mt-2', style: 'display:none' });
374
389
  const paneProxy = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
375
390
  const paneSync = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
376
391
  const paneVision = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
377
392
  const paneRtk = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
378
393
  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 };
394
+ const paneTitleGen = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
395
+ const paneDebug = h('div', { role: 'tabpanel', class: 'mt-4', style: 'display:none' });
396
+ const panes = { features: paneFeatures, styles: paneStyles, perf: panePerf, agents: paneAgents, proxy: paneProxy, sync: paneSync, vision: paneVision, headroom: paneHeadroom, rtk: paneRtk, debug: paneDebug };
380
397
  const tabDefs = [
381
398
  ['features', L.tabFeatures],
399
+ ['agents', L.tabBehavior],
382
400
  ['styles', L.tabStyles],
383
401
  ['perf', L.tabPerformance],
384
- ['agents', L.tabAgents],
385
402
  ['proxy', L.tabProxy],
386
403
  ['sync', L.tabSync],
387
404
  ['vision', L.tabVision],
388
405
  ['headroom', L.tabHeadroom],
389
406
  ['rtk', L.tabRtk],
407
+ // “调试”标签页只对开发模式生效的用户显示(配置开关或 ZCODEPRO_DEV=1),
408
+ // 普通用户不可见
409
+ ...(config.devMode === true || config.devActive === true ? [['debug', L.tabDebug]] : []),
390
410
  ];
391
- const tablist = h('div', { role: 'tablist', 'aria-orientation': 'horizontal', class: 'zcodepro-tablist mt-4' });
411
+ const tablist = h('div', { role: 'tablist', 'aria-orientation': 'horizontal', class: 'zcodepro-tablist mt-1' });
392
412
  const renderTabs = () => tablist.replaceChildren(...tabDefs.map(([id, label]) =>
393
413
  h('button', {
394
414
  type: 'button', role: 'tab', class: 'zcodepro-tab',
@@ -483,6 +503,7 @@ export function openSettingsDialog() {
483
503
  styleCell(L.rowGapName, L.rowGapDesc, 'rowGap'),
484
504
  styleCell(L.userLineHeightName, L.userLineHeightDesc, 'userLineHeight', { min: 1, max: 3, step: 0.05, unit: 'x' }),
485
505
  styleCell(L.lineHeightName, L.lineHeightDesc, 'lineHeight', { min: 1, max: 3, step: 0.05, unit: 'x' }),
506
+ styleCell(L.thinkingLineHeightName, L.thinkingLineHeightDesc, 'thinkingLineHeight', { min: 1, max: 3, step: 0.05, unit: 'x' }),
486
507
  styleCell(L.codeLineHeightName, L.codeLineHeightDesc, 'codeLineHeight', { min: 1, max: 3, step: 0.05, unit: 'x' }),
487
508
  styleCell(L.listSpacingName, L.listSpacingDesc, 'listSpacing'),
488
509
  styleCell(L.listItemSpacingName, L.listItemSpacingDesc, 'listItemSpacing'),
@@ -568,7 +589,7 @@ export function openSettingsDialog() {
568
589
  // “全局提示词”:编辑 ~/.zcode/AGENTS.md(helper /agents 端点读写)。
569
590
  // 大段文本不做即存即生效——显式保存;未修改时保存按钮禁用
570
591
  const agentsArea = h('textarea', {
571
- class: 'zcodepro-textarea',
592
+ class: 'zcodepro-textarea zcodepro-textarea-lg',
572
593
  placeholder: L.agentsPlaceholder,
573
594
  spellcheck: 'false',
574
595
  });
@@ -597,7 +618,11 @@ export function openSettingsDialog() {
597
618
  agentsSaveBtn.disabled = false;
598
619
  }
599
620
  };
600
- paneAgents.append(
621
+ // “行为”标签页:二级标签切换「全局提示词 / 任务名」。全局提示词编辑
622
+ // ~/.zcode/AGENTS.md;任务名(智能起名)面板复用 paneTitleGen(原本是
623
+ // 一级标签页,收进来后不再单独出现在顶部标签栏)
624
+ const agentsPromptPane = h('div', { class: 'mt-4' });
625
+ agentsPromptPane.append(
601
626
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.agentsDesc),
602
627
  ...(agentsFailed
603
628
  ? [h('p', { class: 'mt-2 text-ui-sm text-destructive' }, L.agentsLoadFailed + ': ' + errText(agentsRes))]
@@ -605,6 +630,27 @@ export function openSettingsDialog() {
605
630
  h('div', { class: 'mt-3' }, agentsArea),
606
631
  h('div', { class: 'mt-3 flex justify-end' }, agentsSaveBtn),
607
632
  );
633
+ let agentsSubTab = 'prompt';
634
+ const agentsSubPanes = { prompt: agentsPromptPane, titleGen: paneTitleGen };
635
+ const agentsSubList = h('div', { role: 'tablist', 'aria-orientation': 'horizontal', class: 'zcodepro-tablist' });
636
+ const renderAgentsSubTabs = () => {
637
+ agentsSubList.replaceChildren(...[
638
+ ['prompt', L.tabAgents],
639
+ ['titleGen', L.tabTitleGen],
640
+ ].map(([id, label]) => h('button', {
641
+ type: 'button', role: 'tab', class: 'zcodepro-tab',
642
+ 'aria-selected': String(agentsSubTab === id),
643
+ 'data-state': agentsSubTab === id ? 'active' : 'inactive',
644
+ onClick: () => {
645
+ agentsSubTab = id;
646
+ for (const [pid, pane] of Object.entries(agentsSubPanes)) pane.style.display = pid === id ? '' : 'none';
647
+ renderAgentsSubTabs();
648
+ },
649
+ }, label)));
650
+ };
651
+ for (const [pid, pane] of Object.entries(agentsSubPanes)) pane.style.display = pid === agentsSubTab ? '' : 'none';
652
+ renderAgentsSubTabs();
653
+ paneAgents.append(agentsSubList, agentsPromptPane, paneTitleGen);
608
654
 
609
655
  // “代理”:为 helper 发起的网络访问(插件市场更新/安装、headroom 本体升级)设
610
656
  // HTTP 代理;保存即生效(helper 子进程环境变量注入),不影响 ZCode 应用与模型请求。
@@ -635,6 +681,12 @@ export function openSettingsDialog() {
635
681
  const proxyTestBtn = btnSmall(L.proxyTest, () => { void runProxyTest(); }, 'shrink-0');
636
682
  const runProxyTest = async () => {
637
683
  if (proxyTesting) return;
684
+ // 空地址不发请求(等网络超时才报「检测失败」的体验太差),立即提示
685
+ if (!proxyInput.value.trim()) {
686
+ proxyTestState.className = 'mt-2 text-ui-xs/relaxed text-amber-500';
687
+ proxyTestState.textContent = L.proxyTestEmpty;
688
+ return;
689
+ }
638
690
  proxyTesting = true;
639
691
  proxyTestBtn.disabled = true;
640
692
  proxyTestBtn.textContent = L.proxyTesting;
@@ -655,6 +707,39 @@ export function openSettingsDialog() {
655
707
  proxyTestState.className = 'mt-2 text-ui-xs/relaxed ' + (allOk ? 'text-foreground-subtle' : 'text-amber-500');
656
708
  proxyTestState.textContent = `${res.proxy}:${parts.join(' · ')}`;
657
709
  };
710
+ // 代理生效范围(即时保存,每个开关只改自己那项):插件市场更新与插件依赖
711
+ // 软件默认开,同步默认关——国内网盘直连即可,代理停了同步会跟着失败
712
+ const persistProxyScope = async (partial) => {
713
+ const res = await rpc('/config', { method: 'POST', body: { proxyScopes: partial } });
714
+ if (!res.ok) {
715
+ showToast(L.failed + ': ' + errText(res), 'error');
716
+ return false;
717
+ }
718
+ if (res.config && res.config.proxyScopes) config.proxyScopes = res.config.proxyScopes;
719
+ return true;
720
+ };
721
+ const proxyScopeWrap = h('div');
722
+ const renderProxyScopes = () => {
723
+ const sc = config.proxyScopes || {};
724
+ proxyScopeWrap.replaceChildren(
725
+ settingRow(L.proxyScopePlugins, L.proxyScopePluginsDesc, sc.plugins !== false, async () => {
726
+ const next = !(sc.plugins !== false);
727
+ if (await persistProxyScope({ plugins: next })) sc.plugins = next;
728
+ renderProxyScopes();
729
+ }),
730
+ settingRow(L.proxyScopeDeps, L.proxyScopeDepsDesc, sc.deps !== false, async () => {
731
+ const next = !(sc.deps !== false);
732
+ if (await persistProxyScope({ deps: next })) sc.deps = next;
733
+ renderProxyScopes();
734
+ }),
735
+ settingRow(L.proxyScopeSync, L.proxyScopeSyncDesc, sc.sync === true, async () => {
736
+ const next = !(sc.sync === true);
737
+ if (await persistProxyScope({ sync: next })) sc.sync = next;
738
+ renderProxyScopes();
739
+ }),
740
+ );
741
+ };
742
+ renderProxyScopes();
658
743
  paneProxy.append(
659
744
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.proxyDesc),
660
745
  h('div', { class: 'mt-3 flex items-center gap-2' },
@@ -663,6 +748,7 @@ export function openSettingsDialog() {
663
748
  proxyTestBtn),
664
749
  h('p', { class: 'mt-2 text-ui-xs/relaxed text-foreground-subtle' }, L.proxyHint),
665
750
  proxyTestState,
751
+ h('div', { class: 'mt-3 divide-y divide-border rounded-xl border border-border' }, proxyScopeWrap),
666
752
  );
667
753
 
668
754
  // “性能”:空闲项目内存回收(开关 + 两个时长)。idleReclaimStartupMinutes
@@ -712,8 +798,10 @@ export function openSettingsDialog() {
712
798
 
713
799
  // “同步”:把 ZCode Pro 设置与模型设置手动备份到 WebDAV / 从 WebDAV 恢复
714
800
  // (交互参考 Tampermonkey:URL/登录/密码 + 手动触发,无自动同步)。仅 WebDAV
715
- // 一种后端且本就只在点击时传输,不设“类型/禁用”下拉。表单显式保存;
716
- // 同步内容开关即时保存;下载会覆盖本机,先弹二次确认
801
+ // 一种后端且本就只在点击时传输,不设“类型/禁用”下拉。表单显式保存(旁有
802
+ // 「测试」用当前表单值验证连通);同步内容开关即时保存;下载先弹云端版本
803
+ // 列表(固定名 = 最新 + 带时间戳的历史),任选一个导入,导入会覆盖本机、
804
+ // 先弹二次确认
717
805
  const savedSync = (config.sync && typeof config.sync === 'object') ? config.sync
718
806
  : { url: '', dir: 'zcode-pro', login: '', password: '', include: { zcodepro: true, model: true }, lastPushAt: null, lastPullAt: null };
719
807
  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 +849,44 @@ export function openSettingsDialog() {
761
849
  syncCheckDirty();
762
850
  }
763
851
  };
852
+ // 「测试」:用表单当前值(先测再存)验证 WebDAV:连接与凭据 → 上传目录
853
+ // (缺则自动建,等同上传时的行为)→ 文件列表;结果仿“代理”标签页的检测,
854
+ // 一行显示各步耗时与云端备份文件数
855
+ const syncTestState = h('p', { class: 'mt-2 text-ui-xs/relaxed text-foreground-subtle' });
856
+ let syncTesting = false;
857
+ const syncTestBtn = btnSmall(L.syncTest, () => { void runSyncTest(); }, 'shrink-0');
858
+ const runSyncTest = async () => {
859
+ if (syncTesting) return;
860
+ // 没填 URL 不发请求(等超时才报“测试失败”的体验太差),立即提示
861
+ if (!syncUrlInput.value.trim()) {
862
+ syncTestState.className = 'mt-2 text-ui-xs/relaxed text-amber-500';
863
+ syncTestState.textContent = L.syncTestEmpty;
864
+ return;
865
+ }
866
+ syncTesting = true;
867
+ syncTestBtn.disabled = true;
868
+ syncTestBtn.textContent = L.syncTesting;
869
+ syncTestState.textContent = '';
870
+ syncTestState.className = 'mt-2 text-ui-xs/relaxed text-foreground-subtle';
871
+ const res = await rpc('/sync/test', { method: 'POST', body: { sync: syncFormValues() } });
872
+ syncTesting = false;
873
+ syncTestBtn.disabled = false;
874
+ syncTestBtn.textContent = L.syncTest;
875
+ if (!res.ok) {
876
+ syncTestState.className = 'mt-2 text-ui-xs/relaxed text-destructive';
877
+ syncTestState.textContent = L.syncTestFailed + ': ' + errText(res);
878
+ return;
879
+ }
880
+ const stepName = { connect: L.syncTestStepConnect, dir: L.syncTestStepDir, list: L.syncTestStepList };
881
+ const parts = (res.targets || []).map((t) =>
882
+ `${stepName[t.step] || t.step} ${t.ok ? '✓' + (t.ms != null ? ' ' + t.ms + 'ms' : '') : '✗ ' + (t.error || t.httpCode || '')}`.trim());
883
+ const allOk = (res.targets || []).length > 0 && (res.targets || []).every((t) => t.ok);
884
+ let host = syncUrlInput.value.trim();
885
+ try { host = new URL(host).host; } catch { /* 非法输入保留原文 */ }
886
+ syncTestState.className = 'mt-2 text-ui-xs/relaxed ' + (allOk ? 'text-foreground-subtle' : 'text-amber-500');
887
+ syncTestState.textContent = `${host}:${parts.join(' · ')}`
888
+ + (allOk && res.fileCount != null ? `(${L.syncTestFilesFound.replace('{n}', res.fileCount)})` : '');
889
+ };
764
890
  // 坚果云推荐行:外链经宿主 openExternal 打开系统浏览器(无该能力时只出纯文字)
765
891
  const JIANGUO_HELP = 'https://help.jianguoyun.com/?p=2064';
766
892
  const syncRecLine = h('p', { class: 'mt-1 text-ui-xs/relaxed text-foreground-subtle' });
@@ -798,61 +924,199 @@ export function openSettingsDialog() {
798
924
  );
799
925
  };
800
926
  renderSyncInclude();
801
- // 上次操作时间 + 上传/下载按钮;下载覆盖本机,需二次确认
927
+ // 上次操作时间 + 上传/下载按钮;上传固定名与历史版本双写,下载弹云端版本列表
802
928
  const fmtSyncTime = (ts) => (ts ? new Date(ts).toLocaleString() : L.syncNever);
803
929
  const syncStatusLine = h('div', { class: 'text-ui-xs/relaxed text-foreground-subtle' });
804
930
  const renderSyncStatus = (s) => {
805
931
  syncStatusLine.textContent = `${L.syncLastPush} ${fmtSyncTime(s.lastPushAt)} · ${L.syncLastPull} ${fmtSyncTime(s.lastPullAt)}`;
806
932
  };
807
933
  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 = () => {
934
+ const syncUploadBtn = btnSmall(L.syncUpload, () => { void runSyncPush(); }, 'shrink-0', 'primary');
935
+ const syncDownloadBtn = btnSmall(L.syncDownload, () => { openSyncVersionPicker(); }, 'shrink-0');
936
+ // 「从云端下载」先弹版本列表(备份管理器的样子):列出云端目录里的备份文件
937
+ // (固定名 = 最新一版 + 带时间戳的历史),每行可导入到本机 / 从云端删除 /
938
+ // 另存到本机磁盘;导入按文件名前缀决定覆盖哪一项,不受“同步内容”勾选影响
939
+ const openSyncVersionPicker = () => {
811
940
  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
- );
941
+ title: L.syncPickTitle,
942
+ description: L.syncPickDesc,
943
+ width: 'max-w-2xl',
944
+ dismissOnOutside: false,
945
+ onMount: async ({ body: pickBody, content, close: closePicker }) => {
946
+ // 标题栏 ✕(设置弹窗同款);h2 也是拖拽把手,✕ 上要拦住 mousedown 冒泡
947
+ const titleEl = content && content.firstElementChild;
948
+ if (titleEl && titleEl.tagName === 'H2') {
949
+ titleEl.classList.add('flex', 'w-full', 'items-center', 'justify-between');
950
+ titleEl.replaceChildren(
951
+ h('span', { class: 'flex min-w-0 items-center' }, L.syncPickTitle),
952
+ h('button', {
953
+ type: 'button',
954
+ class: 'zcodepro-close',
955
+ title: L.close,
956
+ 'aria-label': L.close,
957
+ onMousedown: (e) => { e.stopPropagation(); },
958
+ onClick: () => { closePicker(); },
959
+ }, '✕'),
960
+ );
961
+ }
962
+ const listWrap = h('div', { class: 'space-y-1' });
963
+ pickBody.append(listWrap);
964
+ const fmtSize = (n) => {
965
+ if (!Number.isFinite(n) || n <= 0) return '';
966
+ return n >= 1048576 ? (n / 1048576).toFixed(2) + ' MB' : Math.max(1, Math.round(n / 1024)) + ' KB';
967
+ };
968
+ const setStatus = (el) => { listWrap.replaceChildren(el); };
969
+ const setRowBusy = (btns, busy) => { for (const b of btns) b.disabled = busy; };
970
+ const versionRow = (f) => {
971
+ // 固定名(zcodepro.json / model.json)不含时间戳段,始终是最新一版;
972
+ // 归属哪项同步内容由分组标题说明,行内不再重复
973
+ const isLatest = !/-\d{4}-\d{2}-\d{2}T/.test(f.name);
974
+ const importBtn = btnSmall(L.syncVerImport, () => { confirmImport(f, rowBtns, importBtn); }, 'shrink-0');
975
+ const deleteBtn = btnSmall(L.syncVerDelete, () => { confirmDelete(f, rowBtns, isLatest); }, 'shrink-0');
976
+ const saveBtn = btnSmall(L.syncVerSave, () => { void exportVersion(f, rowBtns, saveBtn); }, 'shrink-0');
977
+ const rowBtns = [importBtn, deleteBtn, saveBtn];
978
+ return h('div', { class: 'flex items-center gap-3 rounded-xl border border-border px-3 py-2' },
979
+ h('div', { class: 'min-w-0 flex-1' },
980
+ h('div', { class: 'flex min-w-0 items-center gap-1.5' },
981
+ h('span', { class: 'truncate font-mono text-ui-xs text-foreground', title: f.name }, f.name),
982
+ ...(isLatest ? [h('span', { class: 'shrink-0 rounded border border-border px-1.5 text-ui-xs text-foreground-subtle' }, L.syncVerLatest)] : [])),
983
+ h('div', { class: 'mt-0.5 text-ui-xs/relaxed text-foreground-subtle' },
984
+ [fmtSize(f.size), f.lastModified ? new Date(f.lastModified).toLocaleString() : ''].filter(Boolean).join(' · '))),
985
+ h('div', { class: 'flex shrink-0 items-center gap-1.5' }, importBtn, deleteBtn, saveBtn));
986
+ };
987
+ const loadList = async () => {
988
+ setStatus(h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.syncPickLoading));
989
+ const res = await rpc('/sync/versions', { method: 'POST', body: {} });
990
+ if (!res.ok) {
991
+ setStatus(h('p', { class: 'text-ui-sm text-destructive' }, L.failed + ': ' + errText(res)));
992
+ return;
993
+ }
994
+ const files = res.files || [];
995
+ if (!files.length) {
996
+ setStatus(h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.syncPickEmpty));
997
+ return;
998
+ }
999
+ // 按同步内容分组排列:组标题下固定名(最新)放最上面,历史版本按
1000
+ // 时间新→旧往下——两组文件多了也不会按时间交错混排
1001
+ const parts = [];
1002
+ for (const [prefix, label] of [['zcodepro', L.syncIncludeZcp], ['model', L.syncIncludeModel]]) {
1003
+ const group = files.filter((f) => f.name === `${prefix}.json` || f.name.startsWith(`${prefix}-`));
1004
+ if (!group.length) continue;
1005
+ const latest = group.find((f) => f.name === `${prefix}.json`);
1006
+ const history = group.filter((f) => f !== latest)
1007
+ .sort((a, b) => ((b.lastModified || 0) - (a.lastModified || 0)) || b.name.localeCompare(a.name));
1008
+ parts.push(h('div', { class: 'px-1 pt-2 text-ui-xs font-medium text-foreground-subtle' },
1009
+ `${label}${isZhLocale() ? '(' : ' ('}${group.length}${isZhLocale() ? ')' : ')'}`));
1010
+ for (const f of [latest, ...history]) {
1011
+ if (f) parts.push(versionRow(f));
1012
+ }
1013
+ }
1014
+ listWrap.replaceChildren(...parts);
1015
+ };
1016
+ // 导入沿用原有的覆盖确认文案,并注明选中的是哪个文件
1017
+ const confirmImport = (f, rowBtns, importBtn) => {
1018
+ openDialog({
1019
+ title: L.syncConfirmTitle,
1020
+ width: 'sm:max-w-md',
1021
+ onMount: ({ body: confirmBody, close: closeConfirm }) => {
1022
+ confirmBody.append(
1023
+ h('p', { class: 'mb-2 truncate font-mono text-ui-xs text-foreground-subtle', title: f.name }, f.name),
1024
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.syncConfirmDesc),
1025
+ dialogFooter(
1026
+ btnSecondary(L.cancel, () => closeConfirm()),
1027
+ btnPrimary(L.syncConfirmGo, () => { closeConfirm(); void importVersion(f, rowBtns, importBtn); }, 'min-w-24'),
1028
+ ),
1029
+ );
1030
+ },
1031
+ });
1032
+ };
1033
+ const importVersion = async (f, rowBtns, importBtn) => {
1034
+ setRowBusy(rowBtns, true);
1035
+ importBtn.textContent = L.syncVerImporting;
1036
+ const res = await rpc('/sync/version/apply', { method: 'POST', body: { file: f.name } });
1037
+ setRowBusy(rowBtns, false);
1038
+ importBtn.textContent = L.syncVerImport;
1039
+ if (!res.ok) {
1040
+ showToast(L.failed + ': ' + errText(res), 'error');
1041
+ return;
1042
+ }
1043
+ closePicker();
1044
+ renderSyncStatus(res.sync || {});
1045
+ // 该版本已落到本机:刷新弹窗内开关显示,并让常驻功能立即按新配置生效
1046
+ clearConfigCache();
1047
+ window.dispatchEvent(new CustomEvent('zcodepro:config-changed'));
1048
+ Object.assign(config, await getConfig(true));
1049
+ refreshRows();
1050
+ renderToolbarSwitch();
1051
+ const names = (res.applied || [])
1052
+ .map((k) => (k === 'model' ? L.syncIncludeModel : L.syncIncludeZcp))
1053
+ .join(isZhLocale() ? '、' : ', ');
1054
+ showToast(names ? `${L.syncDownloaded}(${names})` : L.syncDownloaded, 'success');
1055
+ };
1056
+ const confirmDelete = (f, rowBtns, isLatest) => {
1057
+ openDialog({
1058
+ title: L.syncVerDeleteTitle,
1059
+ width: 'sm:max-w-md',
1060
+ onMount: ({ body: confirmBody, close: closeConfirm }) => {
1061
+ confirmBody.append(
1062
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' },
1063
+ L.syncVerDeleteDesc,
1064
+ h('span', { class: 'font-mono' }, f.name)),
1065
+ // 删“最新”文件会让旧版 zcode-pro 失去下载入口,多提醒一句
1066
+ ...(isLatest ? [h('p', { class: 'mt-2 text-ui-xs/relaxed text-amber-500' }, L.syncVerDeleteLatestNote)] : []),
1067
+ dialogFooter(
1068
+ btnSecondary(L.cancel, () => closeConfirm()),
1069
+ btnPrimary(L.syncVerDelete, () => { closeConfirm(); void deleteVersion(f, rowBtns); }, 'min-w-24'),
1070
+ ),
1071
+ );
1072
+ },
1073
+ });
1074
+ };
1075
+ const deleteVersion = async (f, rowBtns) => {
1076
+ setRowBusy(rowBtns, true);
1077
+ const res = await rpc('/sync/version/delete', { method: 'POST', body: { file: f.name } });
1078
+ setRowBusy(rowBtns, false);
1079
+ if (!res.ok) {
1080
+ showToast(L.failed + ': ' + errText(res), 'error');
1081
+ return;
1082
+ }
1083
+ showToast(L.syncVerDeleted, 'success');
1084
+ void loadList();
1085
+ };
1086
+ const exportVersion = async (f, rowBtns, saveBtn) => {
1087
+ setRowBusy(rowBtns, true);
1088
+ saveBtn.textContent = L.syncVerSaving;
1089
+ // 宿主会弹系统目录选择器选保存位置;用户取消时不作提示
1090
+ const res = await rpc('/sync/version/export', { method: 'POST', body: { file: f.name } });
1091
+ setRowBusy(rowBtns, false);
1092
+ saveBtn.textContent = L.syncVerSave;
1093
+ if (!res.ok) {
1094
+ if (res.code !== 'export-cancelled') showToast(L.failed + ': ' + errText(res), 'error');
1095
+ return;
1096
+ }
1097
+ showToast(`${L.syncVerSaved} ${res.path}`, 'success');
1098
+ };
1099
+ void loadList();
822
1100
  },
823
1101
  });
824
1102
  };
825
- const runSyncDirection = async (direction) => {
1103
+ // 上传:云端固定名(最新)与时间戳历史版本由服务端一并写入云端;下载期间按钮也
1104
+ // 禁用(列表在变,选了也会过期)
1105
+ const runSyncPush = async () => {
826
1106
  syncUploadBtn.disabled = true;
827
1107
  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 } });
1108
+ const idleText = syncUploadBtn.textContent;
1109
+ syncUploadBtn.textContent = L.syncUploading;
1110
+ const res = await rpc('/sync/run', { method: 'POST', body: { direction: 'push' } });
832
1111
  syncUploadBtn.disabled = false;
833
1112
  syncDownloadBtn.disabled = false;
834
- btn.textContent = idleText;
1113
+ syncUploadBtn.textContent = idleText;
835
1114
  if (!res.ok) {
836
1115
  showToast(L.failed + ': ' + errText(res), 'error');
837
1116
  return;
838
1117
  }
839
1118
  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
- }
1119
+ showToast(L.syncUploaded, 'success');
856
1120
  };
857
1121
  paneSync.append(
858
1122
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.syncDesc),
@@ -865,7 +1129,8 @@ export function openSettingsDialog() {
865
1129
  h('div', { class: 'min-w-0' },
866
1130
  h('p', { class: 'text-ui-xs/relaxed text-foreground-subtle' }, L.syncFormHint),
867
1131
  syncRecLine),
868
- syncSaveBtn)),
1132
+ h('div', { class: 'flex shrink-0 items-center gap-1.5' }, syncSaveBtn, syncTestBtn))),
1133
+ syncTestState,
869
1134
  h('div', { class: 'mt-2 divide-y divide-border rounded-xl border border-border' }, syncIncludeWrap),
870
1135
  h('div', { class: 'mt-2 flex items-center justify-between gap-2 rounded-xl border border-border p-2' },
871
1136
  h('div', { class: 'min-w-0' }, syncStatusLine),
@@ -873,6 +1138,48 @@ export function openSettingsDialog() {
873
1138
  h('p', { class: 'mt-2 text-ui-xs/relaxed text-foreground-subtle' }, L.syncHint),
874
1139
  );
875
1140
 
1141
+ // “调试”:开发模式开关 + 重载按钮。重载 = 构建 → 换新启动器进程(新 helper
1142
+ // 代码与新注入脚本)→ 各 ZCode 窗口检测到换代自动整页刷新重新注入;
1143
+ // ZCode 本体与所有会话不受影响,不用重启 ZCode
1144
+ const persistDevMode = async (v) => {
1145
+ const res = await rpc('/config', { method: 'POST', body: { devMode: v } });
1146
+ if (!res.ok) {
1147
+ showToast(L.failed + ': ' + errText(res), 'error');
1148
+ return false;
1149
+ }
1150
+ if (res.config) config.devMode = res.config.devMode;
1151
+ return true;
1152
+ };
1153
+ const devReloadBtn = btnSmall(L.devReload, () => { void runDevReload(); }, 'shrink-0', 'primary');
1154
+ const runDevReload = async () => {
1155
+ devReloadBtn.disabled = true;
1156
+ devReloadBtn.textContent = L.devReloading;
1157
+ // 旧进程应答后即退出,fetch 可能中断,成败都进入下面的兜底刷新
1158
+ try { await rpc('/dev/reload', { method: 'POST', body: {} }); } catch { /* ignore */ }
1159
+ // 兜底:正常情况下新启动器注入时本页会自动刷新;2 秒没动静(交接失败)
1160
+ // 就自己刷新,宁可多刷一次也不能停在旧代码 + 失效 token 上
1161
+ setTimeout(() => { try { location.reload(); } catch { /* ignore */ } }, 2000);
1162
+ };
1163
+ const devModeWrap = h('div');
1164
+ const renderDevPane = () => {
1165
+ const dev = config.devMode === true;
1166
+ devModeWrap.replaceChildren(
1167
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.devModeDesc),
1168
+ h('div', { class: 'mt-3 divide-y divide-border rounded-xl border border-border' },
1169
+ settingRow(L.devMode, L.devModeRowDesc, dev, async () => {
1170
+ if (await persistDevMode(!dev)) renderDevPane();
1171
+ }),
1172
+ ...(dev ? [h('div', { class: 'flex items-center justify-between gap-2 px-2 py-1.5' },
1173
+ h('div', { class: 'min-w-0' },
1174
+ h('div', { class: 'text-ui-sm font-medium text-foreground' }, L.devReload),
1175
+ h('div', { class: 'mt-0.5 text-ui-xs/relaxed text-foreground-subtle' }, L.devReloadDesc)),
1176
+ devReloadBtn)] : []),
1177
+ ),
1178
+ );
1179
+ };
1180
+ renderDevPane();
1181
+ paneDebug.append(devModeWrap);
1182
+
876
1183
  // “视觉代理”:编辑 ~/.zcode/zcode-vision.json(zcode-vision 插件与 /vision-* 命令共用同一文件)。
877
1184
  // 代理列表顺序即执行链;结构性改动(开关/模式/排序/删增)即时保存,文本输入防抖保存。
878
1185
  // 注意:保存时链按全部代理重建——若用 /vision-chain 配过子集链,会被这里覆盖(两套入口语义如此,面板以列表为准)。
@@ -894,8 +1201,6 @@ export function openSettingsDialog() {
894
1201
  skipMinutes: 30,
895
1202
  forceIntercept: true,
896
1203
  };
897
- // 供应商下拉数据:合并 zcode 两张供应商表(config.json 的 provider 与 provider_config.json 的 providerRules)
898
- const visionProviders = visionProvidersRes.ok && Array.isArray(visionProvidersRes.providers) ? visionProvidersRes.providers : [];
899
1204
  let visionCfg = visionRes.ok && visionRes.config && typeof visionRes.config === 'object'
900
1205
  ? structuredClone(visionRes.config)
901
1206
  : null;
@@ -915,7 +1220,55 @@ export function openSettingsDialog() {
915
1220
  style: 'display:none',
916
1221
  });
917
1222
  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';
1223
+ // 未装插件时的一键安装卡(与 Headroom 面板同款交互):helper 单插件安装,
1224
+ // 装完 onInstalled 原地恢复面板。rtk/vision 面板共用。
1225
+ const pluginInstallCard = (pluginName, onInstalled) => {
1226
+ const installBtn = btnSmall(L.headroomInstall, () => { void runInstall(); }, 'shrink-0');
1227
+ const installState = h('div', { class: 'mt-0.5 truncate text-ui-xs/relaxed text-foreground-subtle' });
1228
+ const runInstall = async () => {
1229
+ installBtn.disabled = true;
1230
+ installBtn.textContent = L.headroomInstalling;
1231
+ installState.textContent = '';
1232
+ const res = await rpc('/plugins/update', { method: 'POST', body: { name: pluginName } });
1233
+ if (!res.ok) {
1234
+ installBtn.disabled = false;
1235
+ installBtn.textContent = L.headroomInstall;
1236
+ showToast(L.pluginsUpdateFailed + ': ' + errText(res), 'error');
1237
+ return;
1238
+ }
1239
+ if (onInstalled) await onInstalled();
1240
+ showToast(L.pluginInstallDone.replaceAll('{name}', pluginName), 'success');
1241
+ };
1242
+ return h('div', { class: 'mt-3 flex items-center justify-between gap-2 rounded-xl border border-border p-2' },
1243
+ h('div', { class: 'min-w-0' },
1244
+ h('div', { class: 'text-ui-sm font-medium text-foreground' }, L.pluginInstallCardTitle.replaceAll('{name}', pluginName)),
1245
+ h('div', { class: 'mt-0.5 text-ui-xs/relaxed text-foreground-subtle' }, L.pluginInstallCardDesc),
1246
+ installState),
1247
+ installBtn);
1248
+ };
1249
+
918
1250
  const renderVision = () => {
1251
+ if (!visionRes.ok) {
1252
+ paneVision.replaceChildren(
1253
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.visionDesc),
1254
+ h('p', { class: 'mt-2 text-ui-sm text-destructive' }, L.visionLoadFailed + ': ' + errText(visionRes)),
1255
+ );
1256
+ return;
1257
+ }
1258
+ // 插件未装优先显示安装卡——判断只看钩子(visionRes.hook),与配置文件是否存在无关:
1259
+ // 配置是钩子消费的,没钩子就没功能;老用户卸载插件后 ~/.zcode/zcode-vision.json 仍在,
1260
+ // 若按配置存在与否分流会误入完整配置界面而毫无「未安装」提示
1261
+ if (!visionRes.hook) {
1262
+ paneVision.replaceChildren(
1263
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.visionDesc),
1264
+ pluginInstallCard('zcode-vision', async () => {
1265
+ const meta = await rpc('/vision');
1266
+ if (meta.ok) Object.assign(visionRes, meta);
1267
+ renderVision();
1268
+ }),
1269
+ );
1270
+ return;
1271
+ }
919
1272
  if (!visionCfg) {
920
1273
  const initBtn = btnSmall(L.visionAddProxy, async () => {
921
1274
  const res = await rpc('/vision', { method: 'POST', body: { config: VISION_DEFAULT_CFG } });
@@ -925,6 +1278,10 @@ export function openSettingsDialog() {
925
1278
  paneVision.replaceChildren(
926
1279
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.visionDesc),
927
1280
  ...(visionRes.ok ? [] : [h('p', { class: 'mt-2 text-ui-sm text-destructive' }, L.visionLoadFailed + ': ' + errText(visionRes))]),
1281
+ // 信息卡(仅版本行;配置未建,启用开关随配置出现)
1282
+ h('div', { class: 'mt-3 rounded-xl border border-border p-2' },
1283
+ h('div', { class: 'flex flex-wrap items-center gap-2' },
1284
+ h('span', { class: 'shrink-0 whitespace-nowrap text-ui-xs/relaxed text-foreground-subtle' }, `插件 ${visionRes.pluginVersion || '—'}`))),
928
1285
  h('div', { class: 'mt-3 flex justify-end' }, initBtn),
929
1286
  );
930
1287
  return;
@@ -957,6 +1314,8 @@ export function openSettingsDialog() {
957
1314
  return h('label', { class: 'flex shrink-0 items-center gap-1.5', title },
958
1315
  h('span', { class: 'text-ui-xs font-medium text-foreground-subtle' }, label), n);
959
1316
  };
1317
+ // 接口地址 / API Key 是否显示走 provider-fields 的共用规则
1318
+ //(isManualConn:不跟随或供应商已删才显示,连接其余由跟随对象负责)
960
1319
  const cards = visionCfg.proxies.map((p, i) => h('div', { class: 'mt-2 rounded-xl border border-border p-2' },
961
1320
  h('div', { class: 'flex flex-wrap items-center gap-2' },
962
1321
  h('span', { class: 'shrink-0 rounded-md bg-surface px-1.5 py-0.5 text-ui-xs tabular-nums text-foreground-subtle' }, String(i + 1)),
@@ -987,43 +1346,35 @@ export function openSettingsDialog() {
987
1346
  smallBtn(L.visionRemove, () => { visionCfg.proxies.splice(i, 1); void persistVision(); renderVision(); }),
988
1347
  ),
989
1348
  h('div', { class: 'mt-2 grid grid-cols-1 gap-2 sm:grid-cols-2' },
990
- field(L.visionUseProvider, (() => {
991
- // 组合框:下拉(不跟随/session/全部供应商)+ 手动输入;手输失焦校验,未知供应商给提示
992
- const wrap = h('div', { class: 'flex flex-col gap-1' });
993
- const input = h('input', { type: 'text', value: p.useProvider || '', placeholder: L.visionUseProviderHint, class: visionInputCls });
994
- const warn = h('p', { class: 'text-ui-xs text-destructive', style: 'display:none' }, L.visionUseProviderUnknown);
995
- const isKnown = (v) => !v || v === 'session'
996
- || visionProviders.some((pr) => pr.id === v || pr.name === v || (pr.aliases || []).includes(v));
997
- const sel = h('select', { class: visionInputCls });
998
- const syncSelect = () => {
999
- const cur = (p.useProvider || '').trim();
1000
- const opts = [h('option', { value: '' }, L.visionUseProviderNone), h('option', { value: 'session' }, L.visionUseProviderSession)];
1001
- for (const pr of visionProviders) {
1002
- opts.push(h('option', { value: pr.id }, pr.name && pr.name !== pr.id ? `${pr.name}(${pr.id})` : pr.id));
1003
- }
1004
- if (cur && !opts.some((o) => o.value === cur)) opts.push(h('option', { value: cur }, `${cur}(${L.visionUseProvider})`));
1005
- sel.replaceChildren(...opts);
1006
- sel.value = cur;
1007
- };
1008
- sel.addEventListener('change', () => {
1009
- input.value = sel.value;
1010
- p.useProvider = sel.value.trim();
1011
- warn.style.display = 'none';
1349
+ // 供应商与模型走 provider-fields 共用逻辑:跟随会话时模型下拉为
1350
+ // 全部供应商模型汇总,具体供应商为其模型列表;已删 id 标注「已删除」
1351
+ field(L.visionUseProvider, comboField(p.useProvider || '',
1352
+ providerOptionsOf(titleGenProviders, L),
1353
+ (v, src) => {
1354
+ p.useProvider = v;
1012
1355
  saveVisionSoon();
1013
- });
1014
- input.addEventListener('input', () => { p.useProvider = input.value.trim(); saveVisionSoon(); });
1015
- input.addEventListener('blur', () => {
1016
- p.useProvider = input.value.trim();
1017
- warn.style.display = isKnown(p.useProvider) ? 'none' : '';
1018
- syncSelect();
1019
- });
1020
- syncSelect();
1021
- wrap.append(sel, input, warn);
1022
- return wrap;
1023
- })()),
1024
- field(L.visionModel, textIn(p.model, (v) => { p.model = v; saveVisionSoon(); })),
1025
- field(L.visionBaseUrl, textIn(p.baseUrl, (v) => { p.baseUrl = v; saveVisionSoon(); })),
1026
- field(L.visionApiKey + ' · ' + L.visionApiKeyHint, textIn(p.apiKey, (v) => { p.apiKey = v; saveVisionSoon(); }, { type: 'password' })),
1356
+ // 下拉切换后重渲染整个面板:模型下拉选项随新供应商变化,接口
1357
+ // 地址/API Key 字段按「不跟随或供应商已删才显示」重新显隐
1358
+ if (src === 'select') renderVision();
1359
+ },
1360
+ { placeholder: L.titleGenProviderSession, staleLabel: L.titleGenProviderDeleted })),
1361
+ // 跟随会话时模型下拉头部有「跟随当前会话当前模型」(值为空,
1362
+ // 插件 ≥1.6.1 在调用时解析为会话当前模型);具体供应商为其模型列表
1363
+ field(L.visionModel, comboField(p.model || '',
1364
+ modelOptionsOf(p.useProvider, titleGenProviders, {
1365
+ followSession: (p.useProvider || '').trim() === 'session',
1366
+ followSessionLabel: L.titleGenModelFollowSession,
1367
+ }),
1368
+ (v) => { p.model = v; saveVisionSoon(); },
1369
+ {
1370
+ placeholder: modelPlaceholderOf(p.useProvider, L),
1371
+ emptyLabel: (p.useProvider || '').trim() === 'session'
1372
+ ? L.titleGenModelFollowSession : L.titleGenModelEmpty,
1373
+ })),
1374
+ ...(isManualConn(p.useProvider, titleGenProviders) ? [
1375
+ field(L.visionBaseUrl, textIn(p.baseUrl, (v) => { p.baseUrl = v; saveVisionSoon(); })),
1376
+ field(L.visionApiKey + ' · ' + L.visionApiKeyHint, textIn(p.apiKey, (v) => { p.apiKey = v; saveVisionSoon(); }, { type: 'password' })),
1377
+ ] : []),
1027
1378
  ),
1028
1379
  (() => {
1029
1380
  const n = h('textarea', { class: 'zcodepro-textarea zcodepro-textarea-sm', rows: '3', placeholder: L.visionPrompt });
@@ -1046,17 +1397,22 @@ export function openSettingsDialog() {
1046
1397
  });
1047
1398
  paneVision.replaceChildren(
1048
1399
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.visionDesc),
1049
- // 链模式 + 两个开关合到一行(顶部已有简介,开关不再单占一块、不带描述)
1400
+ // 信息卡:插件版本 + 启用图片识别开关(与 rtk/headroom 面板同款式)
1401
+ h('div', { class: 'mt-3 rounded-xl border border-border p-2' },
1402
+ h('div', { class: 'flex flex-wrap items-center gap-2' },
1403
+ h('span', { class: 'shrink-0 whitespace-nowrap text-ui-xs/relaxed text-foreground-subtle' }, `插件 ${visionRes.pluginVersion || '—'}`),
1404
+ h('div', { class: 'ml-auto' },
1405
+ settingRow(L.visionEnabled, '', visionCfg.enabled !== false, async () => {
1406
+ visionCfg.enabled = !(visionCfg.enabled !== false);
1407
+ await persistVision();
1408
+ renderVision();
1409
+ })))),
1410
+ // 链模式 + 强制拦截开关合到一行(启用开关已上移信息卡)
1050
1411
  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
1412
  h('span', { class: 'ml-1.5 shrink-0 text-ui-sm font-medium text-foreground' }, L.visionMode),
1052
1413
  modeBtn('fallback', L.visionModeFallback, L.visionModeFallbackDesc),
1053
1414
  modeBtn('pipeline', L.visionModePipeline, L.visionModePipelineDesc),
1054
1415
  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
1416
  settingRow(L.visionForceIntercept, '', visionCfg.forceIntercept !== false, async () => {
1061
1417
  visionCfg.forceIntercept = !(visionCfg.forceIntercept !== false);
1062
1418
  await persistVision();
@@ -1122,15 +1478,20 @@ export function openSettingsDialog() {
1122
1478
  return res.ok;
1123
1479
  };
1124
1480
  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 下载替换)的版本行与升级控件,渲染函数外创建一次
1481
+ // rtk 本体(GitHub Releases 下载替换)的版本行与升级控件,渲染函数外创建一次。
1482
+ // 信息卡单行:版本行 + 弹性状态槽(空闲显示本体位置,检查更新/升级时被活动
1483
+ // 日志顶替,见 makeUpgradeControls 的 idleText)+ 检查更新按钮 + 启用开关
1126
1484
  const rtkVersionLine = h('span', { class: 'shrink-0 whitespace-nowrap text-ui-xs/relaxed text-foreground-subtle' });
1485
+ let rtkLocText = '';
1127
1486
  const renderRtkVersion = (meta) => {
1128
- const v = meta.version || 'rtk';
1129
- rtkVersionLine.textContent = meta.binPath ? `${v} · ${meta.binPath}` : v;
1487
+ const v = String(meta.version || '').replace(/^rtk\s+/i, '').trim();
1488
+ rtkVersionLine.textContent = `插件 ${meta.pluginVersion || '—'}${v ? ` · rtk ${v}` : ''}`;
1489
+ rtkLocText = meta.binPath || '';
1130
1490
  };
1131
1491
  renderRtkVersion(rtkRes);
1132
1492
  const rtkUp = makeUpgradeControls({
1133
1493
  endpoint: '/rtk',
1494
+ idleText: () => rtkLocText,
1134
1495
  metaRefresh: async () => {
1135
1496
  const meta = await rpc('/rtk');
1136
1497
  if (meta.ok) {
@@ -1143,15 +1504,50 @@ export function openSettingsDialog() {
1143
1504
  rtkUpgradePollStop = rtkUp.stop;
1144
1505
  // 版本/路径/检查更新 + 启用 rtk 压缩开关合到一行(开关靠右,顶部已有简介不带描述)
1145
1506
  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);
1507
+ const rtkVersionCard = h('div', { class: 'mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-border p-2' },
1508
+ rtkVersionLine, rtkUp.state, rtkUp.btn,
1509
+ h('div', { class: 'ml-auto shrink-0' }, rtkSwitchRow));
1148
1510
  const rtkBody = h('div');
1149
- // 简介置顶(与 Headroom 面板一致),其下版本卡(含启用 rtk 压缩开关),再下白名单
1511
+ // 插件未装时的一键安装行(本体未装另有提示行),装完原地撤下
1512
+ const rtkPluginRow = h('div');
1513
+ const renderRtkPluginRow = () => {
1514
+ rtkPluginRow.replaceChildren(...(rtkRes.hook ? [] : [
1515
+ pluginInstallCard('rtk', async () => {
1516
+ const meta = await rpc('/rtk');
1517
+ if (meta.ok) {
1518
+ Object.assign(rtkRes, meta);
1519
+ // 版本行 span 与 rtkCfg 都是弹窗打开时的未装态快照,装完必须重算/重建
1520
+ renderRtkVersion(meta);
1521
+ // rtkCfg 整体重建(未装态时内置清单为空,不重建则白名单区块渲染旧空清单)
1522
+ rtkCfg = {
1523
+ installed: meta.installed !== false,
1524
+ version: meta.version || '',
1525
+ binPath: meta.binPath || '',
1526
+ mode: meta.mode === 'off' ? 'off' : 'hint',
1527
+ whitelist: Array.isArray(meta.whitelist) ? [...meta.whitelist] : [],
1528
+ builtinGit: Array.isArray(meta.builtinGit) ? [...meta.builtinGit] : [],
1529
+ builtinPlain: Array.isArray(meta.builtinPlain) ? [...meta.builtinPlain] : [],
1530
+ };
1531
+ }
1532
+ renderRtkPluginRow();
1533
+ renderRtk();
1534
+ }),
1535
+ ]));
1536
+ };
1537
+ renderRtkPluginRow();
1538
+ // 简介置顶(与 Headroom 面板一致),其下插件安装卡,再下版本卡(含启用 rtk 压缩开关),再下白名单
1150
1539
  paneRtk.append(
1151
1540
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.rtkDesc),
1541
+ rtkPluginRow,
1152
1542
  rtkVersionCard,
1153
1543
  rtkBody);
1154
1544
  const renderRtk = () => {
1545
+ // 插件未装:只显示安装卡,隐藏版本卡与全部设置项(与 vision/headroom 未装时一致)
1546
+ if (rtkRes.ok && !rtkRes.hook) {
1547
+ rtkVersionCard.style.display = 'none';
1548
+ rtkBody.replaceChildren();
1549
+ return;
1550
+ }
1155
1551
  rtkVersionCard.style.display = rtkCfg && rtkCfg.installed ? '' : 'none';
1156
1552
  if (!rtkCfg) {
1157
1553
  rtkBody.replaceChildren(
@@ -1195,7 +1591,45 @@ export function openSettingsDialog() {
1195
1591
  rtkBody.replaceChildren(
1196
1592
  ...(rtkCfg.installed
1197
1593
  ? []
1198
- : [h('p', { class: 'text-ui-sm text-amber-500' }, L.rtkNotInstalled)]),
1594
+ : [h('div', { class: 'flex items-center gap-2' },
1595
+ h('p', { class: 'min-w-0 flex-1 text-ui-sm text-amber-500' }, L.rtkNotInstalled),
1596
+ // 一键安装本体:与“检查更新”同一条 helper 升级管线(未装则装到 ~/.local/bin)
1597
+ (() => {
1598
+ const b = btnSmall(L.rtkInstallBin, () => { void run(); }, 'shrink-0');
1599
+ const run = async () => {
1600
+ b.disabled = true;
1601
+ b.textContent = L.headroomInstalling;
1602
+ const res = await rpc('/rtk', { method: 'POST', body: { upgrade: true } });
1603
+ if (!res.ok) {
1604
+ b.disabled = false;
1605
+ b.textContent = L.rtkInstallBin;
1606
+ showToast(L.failed + ': ' + errText(res), 'error');
1607
+ return;
1608
+ }
1609
+ const done = await new Promise((resolve) => {
1610
+ const iv = setInterval(async () => {
1611
+ const s = await rpc('/rtk/upgrade');
1612
+ if (s.ok && s.upgrade && !s.upgrade.running) { clearInterval(iv); resolve(s.upgrade); }
1613
+ }, 1200);
1614
+ // 超时也要 resolve:否则按钮永久停在“安装中…”
1615
+ setTimeout(() => { clearInterval(iv); resolve(null); }, 10 * 60 * 1000);
1616
+ });
1617
+ if (done && !done.error && done.version) {
1618
+ const meta = await rpc('/rtk');
1619
+ if (meta.ok) {
1620
+ Object.assign(rtkRes, meta);
1621
+ rtkCfg = { ...rtkCfg, installed: meta.installed !== false, version: meta.version || '', binPath: meta.binPath || '' };
1622
+ }
1623
+ showToast(L.rtkBinInstalled, 'success');
1624
+ renderRtk();
1625
+ } else {
1626
+ b.disabled = false;
1627
+ b.textContent = L.rtkInstallBin;
1628
+ showToast((done && done.error) || L.headroomInstallRetry, 'error');
1629
+ }
1630
+ };
1631
+ return b;
1632
+ })())]),
1199
1633
  h('div', { class: 'mt-2 rounded-xl border border-border p-2' },
1200
1634
  h('div', { class: 'text-ui-sm font-medium text-foreground' }, L.rtkWhitelistTitle),
1201
1635
  h('p', { class: 'mt-1 text-ui-xs/relaxed text-foreground-subtle' }, L.rtkWhitelistDesc),
@@ -1243,15 +1677,18 @@ export function openSettingsDialog() {
1243
1677
  // 可整面重渲染:未装插件时显示安装卡,安装成功后原地恢复完整面板。
1244
1678
  // 版本行与本体升级控件(pip 通道)在渲染函数外创建一次,重渲染时复用同一组节点
1245
1679
  const hrVersionLine = h('span', { class: 'shrink-0 whitespace-nowrap text-ui-xs/relaxed text-foreground-subtle' });
1680
+ let hrLocText = '';
1681
+ // 启用开关(与 rtk 面板同款,版本卡右上角):开 = ZCode 供应商走 headroom,
1682
+ // 关 = 直连上游;只改供应商地址,不动代理进程(headroom 可被其他程序共用)
1683
+ const hrSwitchRow = h('div');
1246
1684
  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} —`;
1685
+ hrVersionLine.textContent = `插件 ${meta.pluginVersion || '—'}${meta.version ? ` · headroom ${meta.version}` : ''}`;
1686
+ hrLocText = meta.binPath || '';
1251
1687
  };
1252
1688
  renderHrVersion(headroomRes);
1253
1689
  const hrUp = makeUpgradeControls({
1254
1690
  endpoint: '/headroom',
1691
+ idleText: () => hrLocText,
1255
1692
  metaRefresh: async () => {
1256
1693
  const meta = await rpc('/headroom');
1257
1694
  if (meta.ok) {
@@ -1278,7 +1715,7 @@ export function openSettingsDialog() {
1278
1715
  installBtn.disabled = true;
1279
1716
  installBtn.textContent = L.headroomInstalling;
1280
1717
  installState.textContent = '';
1281
- const res = await rpc('/plugins/update', { method: 'POST', body: { name: 'headroom', installMissing: true } });
1718
+ const res = await rpc('/plugins/update', { method: 'POST', body: { name: 'headroom' } });
1282
1719
  if (!res.ok) {
1283
1720
  installBtn.disabled = false;
1284
1721
  installBtn.textContent = L.headroomInstall;
@@ -1289,6 +1726,8 @@ export function openSettingsDialog() {
1289
1726
  if (meta.ok) Object.assign(headroomRes, meta);
1290
1727
  if (headroomRes.hook) {
1291
1728
  showToast(L.headroomInstalled, 'success');
1729
+ // 版本行 span 在弹窗打开时按未装态算过一次,装完必须重算
1730
+ renderHrVersion(headroomRes);
1292
1731
  renderHeadroomPane();
1293
1732
  } else {
1294
1733
  installBtn.disabled = false;
@@ -1312,16 +1751,65 @@ export function openSettingsDialog() {
1312
1751
  const hrRun = async (reqBody, warn) => {
1313
1752
  if (hrBusy) return false;
1314
1753
  hrBusy = true;
1315
- const res = await rpc('/headroom', { method: 'POST', body: reqBody });
1316
- hrBusy = false;
1754
+ const action = reqBody && reqBody.action;
1755
+ const isStart = action === 'start' || action === 'restart';
1756
+ const isStop = action === 'stop';
1757
+ // 生命周期按钮:点击瞬间就显示等待提示 + 计时(不等 RPC 返回——
1758
+ // RPC 里 helper 要杀/拉进程,期间 3-10 秒界面不能毫无反应)
1759
+ // RPC 先发出(不等返回——期间界面要显示等待反馈)
1760
+ const rpcDone = rpc('/headroom', { method: 'POST', body: reqBody });
1761
+ let timerIv = null;
1762
+ if (isStart || isStop) {
1763
+ const wantUp = isStart;
1764
+ const msg = action === 'restart' ? L.hrRestarting : isStart ? L.hrStarting : L.hrStopping;
1765
+ const doneMsg = isStart ? L.hrStarted : L.hrStopped;
1766
+ const timeoutMs = isStart ? 90 * 1000 : 30 * 1000;
1767
+ const t0 = Date.now();
1768
+ const timerSpan = h('span', { class: 'text-ui-sm text-foreground-subtle/70' }, msg + ' (0s)');
1769
+ hrStatusLine.replaceChildren(timerSpan);
1770
+ timerIv = setInterval(() => {
1771
+ timerSpan.textContent = msg + ` (${Math.round((Date.now() - t0) / 1000)}s)`;
1772
+ }, 1000);
1773
+ // RPC 完成后进入轮询(RPC 执行实际的 stop/start,可能要 3-10 秒)。
1774
+ // 轮询出结果前 hrBusy 一直占着:期间再点开关或按钮直接忽略,否则
1775
+ // 每次点击都会重发 start、把计时和启动流程从头来过
1776
+ void (async () => {
1777
+ const kick = await rpcDone;
1778
+ if (!kick.ok) return; // 失败由主流程提示并复位
1779
+ while (Date.now() - t0 < timeoutMs) {
1780
+ await new Promise((r) => setTimeout(r, 2000));
1781
+ const st = await rpc('/headroom/status');
1782
+ if (!st.ok) continue;
1783
+ const up = !!(st.status && st.status.up);
1784
+ if (up === wantUp) {
1785
+ clearInterval(timerIv);
1786
+ hrBusy = false;
1787
+ await hrRefresh();
1788
+ showToast(doneMsg, isStart ? 'success' : undefined);
1789
+ return;
1790
+ }
1791
+ }
1792
+ clearInterval(timerIv);
1793
+ hrBusy = false;
1794
+ if (isStart) showToast(L.hrStartTimeout, 'error');
1795
+ void hrRefresh();
1796
+ })();
1797
+ }
1798
+ const res = await rpcDone;
1317
1799
  if (!res.ok) {
1800
+ if (timerIv) clearInterval(timerIv);
1801
+ hrBusy = false;
1318
1802
  showToast(L.failed + ': ' + errText(res), 'error');
1803
+ void hrRefresh();
1319
1804
  return false;
1320
1805
  }
1321
1806
  if (res.config) Object.assign(hrCfg, res.config);
1322
1807
  if (warn) showToast(warn);
1323
- // backend/power 动作会重启代理,稍等其就绪再刷状态,避免闪现“未运行”
1324
- setTimeout(() => { void hrRefresh(); }, 1200);
1808
+ // 非生命周期动作(backend/power):稍等后刷一次
1809
+ if (!isStart && !isStop) {
1810
+ hrBusy = false;
1811
+ setTimeout(() => { void hrRefresh(); }, 1200);
1812
+ }
1325
1813
  return true;
1326
1814
  };
1327
1815
  // 状态值本地化:钩子输出的是 auto/cpu/ac/ok 等内部值,面板显示成人话
@@ -1360,6 +1848,23 @@ export function openSettingsDialog() {
1360
1848
  hrHintLine.textContent = s.saverHint || '';
1361
1849
  hrHintLine.style.display = s.saverHint ? '' : 'none';
1362
1850
  };
1851
+ // 启用开关不走 hrRun:与代理进程无关,点击即时生效,也不受生命周期等待锁影响
1852
+ const renderHrUsageSwitch = () => {
1853
+ const usageOn = !!(headroomRes.usage && headroomRes.usage.enabled);
1854
+ hrSwitchRow.replaceChildren(
1855
+ settingRow(L.pluginEnable, '', usageOn, async () => {
1856
+ const next = !usageOn;
1857
+ const res = await rpc('/headroom', { method: 'POST', body: { usage: next } });
1858
+ if (!res.ok) {
1859
+ showToast(L.failed + ': ' + errText(res), 'error');
1860
+ return;
1861
+ }
1862
+ if (res.usage) headroomRes.usage = res.usage;
1863
+ renderHrUsageSwitch();
1864
+ if (next && !res.changed && !(res.usage && res.usage.enabled)) showToast(L.hrUsageNone);
1865
+ }));
1866
+ };
1867
+ renderHrUsageSwitch();
1363
1868
  const hrRefreshBtn = btnSmall(L.headroomRefresh, () => { void hrRefresh(); });
1364
1869
  const hrStartBtn = btnSmall(L.headroomStart, () => { void hrRun({ action: 'start' }); });
1365
1870
  const hrRestartBtn = btnSmall(L.headroomRestart, () => { void hrRun({ action: 'restart' }); });
@@ -1453,14 +1958,60 @@ export function openSettingsDialog() {
1453
1958
  };
1454
1959
  updateHrPwrHint();
1455
1960
 
1961
+ // 本体未装:一键安装(pip 全新安装通道,helper 同一条升级任务管线);
1962
+ // 装完刷新 meta 并整面重渲染(版本行、本体状态、代理控制全部就位)
1963
+ const hrInstallBinBtn = (() => {
1964
+ const b = btnSmall(L.hrInstallBin, () => { void run(); }, 'shrink-0');
1965
+ let busy = false;
1966
+ const run = async () => {
1967
+ if (busy) return;
1968
+ busy = true;
1969
+ b.disabled = true;
1970
+ b.textContent = L.headroomInstalling;
1971
+ const res = await rpc('/headroom', { method: 'POST', body: { upgrade: true } });
1972
+ if (!res.ok) {
1973
+ b.disabled = false;
1974
+ b.textContent = L.hrInstallBin;
1975
+ showToast(L.failed + ': ' + errText(res), 'error');
1976
+ busy = false;
1977
+ return;
1978
+ }
1979
+ // pip 下载大包可持续数分钟,轮询后台任务到结束(超时 resolve,避免按钮卡死)
1980
+ const done = await new Promise((resolve) => {
1981
+ const iv = setInterval(async () => {
1982
+ const s = await rpc('/headroom/upgrade');
1983
+ if (s.ok && s.upgrade && !s.upgrade.running) { clearInterval(iv); resolve(s.upgrade); }
1984
+ }, 2000);
1985
+ setTimeout(() => { clearInterval(iv); resolve(null); }, 12 * 60 * 1000);
1986
+ });
1987
+ busy = false;
1988
+ if (done && !done.error && done.version) {
1989
+ const meta = await rpc('/headroom');
1990
+ if (meta.ok) {
1991
+ renderHrVersion(meta);
1992
+ Object.assign(headroomRes, meta);
1993
+ }
1994
+ showToast(L.hrBinInstalled, 'success');
1995
+ renderHeadroomPane();
1996
+ } else {
1997
+ b.disabled = false;
1998
+ b.textContent = L.hrInstallBin;
1999
+ showToast((done && done.error) || L.headroomInstallRetry, 'error');
2000
+ }
2001
+ };
2002
+ return b;
2003
+ })();
2004
+
1456
2005
  paneHeadroom.append(
1457
2006
  h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.headroomDesc),
1458
2007
  ...(headroomRes.installed === false
1459
- ? [h('p', { class: 'mt-2 text-ui-sm text-amber-500' }, L.headroomBinMissing)]
2008
+ ? [h('div', { class: 'mt-2 flex items-center gap-2' },
2009
+ h('p', { class: 'min-w-0 flex-1 text-ui-sm text-amber-500' }, L.headroomBinMissing),
2010
+ hrInstallBinBtn)]
1460
2011
  : []),
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),
2012
+ h('div', { class: 'mt-3 flex flex-wrap items-center gap-2 rounded-xl border border-border p-2' },
2013
+ hrVersionLine, hrUp.state, hrUp.btn,
2014
+ h('div', { class: 'ml-auto shrink-0' }, hrSwitchRow)),
1464
2015
  h('div', { class: 'mt-2 rounded-xl border border-border p-2' },
1465
2016
  h('div', { class: 'flex flex-wrap items-center justify-between gap-2' },
1466
2017
  h('span', { class: 'text-ui-sm font-medium text-foreground' }, L.headroomStatusTitle),
@@ -1485,18 +2036,170 @@ export function openSettingsDialog() {
1485
2036
  };
1486
2037
  renderHeadroomPane();
1487
2038
 
2039
+ // “任务名提示词”(位于“行为”标签页的二级标签):新会话开始作答时 →
2040
+ // 按自定义供应商/提示词生成标题并按手动重命名写库。配置读写走 /title-gen
2041
+ //(存 zcodepro.json);供应商下拉数据走 /title-gen/providers(与视觉代理
2042
+ // 同源,但只列 helper 能直连调用的条目)。
2043
+ // 结构性改动(开关/供应商/协议)即时保存,提示词与文本输入防抖保存。
2044
+ let titleGenCfg = titleGenRes.ok && titleGenRes.titleGen && typeof titleGenRes.titleGen === 'object'
2045
+ ? structuredClone(titleGenRes.titleGen) : null;
2046
+ const titleGenDefaultPrompt = titleGenRes.ok && typeof titleGenRes.defaultPrompt === 'string'
2047
+ ? titleGenRes.defaultPrompt : '';
2048
+ let titleGenSaveTimer = null;
2049
+ const persistTitleGen = async () => {
2050
+ const res = await rpc('/title-gen', { method: 'POST', body: { titleGen: titleGenCfg } });
2051
+ clearConfigCache();
2052
+ if (!res.ok) showToast(L.failed + ': ' + errText(res), 'error');
2053
+ return res.ok;
2054
+ };
2055
+ const saveTitleGenSoon = () => {
2056
+ clearTimeout(titleGenSaveTimer);
2057
+ titleGenSaveTimer = setTimeout(() => { void persistTitleGen(); }, 500);
2058
+ };
2059
+ const renderTitleGenPane = () => {
2060
+ if (!titleGenCfg) {
2061
+ paneTitleGen.replaceChildren(
2062
+ h('p', { class: 'text-ui-sm/relaxed text-foreground-subtle' }, L.titleGenShortDesc),
2063
+ h('p', { class: 'mt-2 text-ui-sm text-destructive' }, L.visionLoadFailed + ': ' + errText(titleGenRes)),
2064
+ );
2065
+ return;
2066
+ }
2067
+ const inputCls = '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';
2068
+ const field = (labelText, node) => h('label', { class: 'block min-w-0' },
2069
+ h('span', { class: 'mb-1 block text-ui-xs font-medium text-foreground-subtle' }, labelText), node);
2070
+ // 供应商 / 模型 / 地址密钥共用 provider-fields 的规则(详见该模块说明)。
2071
+ // 换供应商后重渲染:模型下拉换成新供应商的模型、地址字段按模式显隐;
2072
+ // 手动输入过的模型保留(不在新列表里时显示为「手动输入」项,是否改选
2073
+ // 由用户决定)
2074
+ const showInlineConn = isManualConn(titleGenCfg.provider, titleGenProviders);
2075
+ const providerCombo = comboField(
2076
+ titleGenCfg.provider || '',
2077
+ providerOptionsOf(titleGenProviders, L),
2078
+ (v, src) => {
2079
+ titleGenCfg.provider = v;
2080
+ if (src === 'select') { void persistTitleGen(); renderTitleGenPane(); }
2081
+ else saveTitleGenSoon();
2082
+ },
2083
+ { placeholder: L.titleGenProviderSession, staleLabel: L.titleGenProviderDeleted });
2084
+ const modelCombo = comboField(
2085
+ titleGenCfg.model || '',
2086
+ // 跟随会话时选项表头部有“跟随当前会话当前模型”(值为空,生成时用
2087
+ // 会话当前模型),选了具体模型后也能再切回它
2088
+ modelOptionsOf(titleGenCfg.provider, titleGenProviders, {
2089
+ followSession: true,
2090
+ followSessionLabel: L.titleGenModelFollowSession,
2091
+ }),
2092
+ (v) => { titleGenCfg.model = v; saveTitleGenSoon(); },
2093
+ {
2094
+ placeholder: modelPlaceholderOf(titleGenCfg.provider, L),
2095
+ emptyLabel: (titleGenCfg.provider || '').trim() === 'session'
2096
+ ? L.titleGenModelFollowSession : L.titleGenModelEmpty,
2097
+ });
2098
+ // 接口地址 / API Key / 协议(显隐见上方 showInlineConn):不跟随时由
2099
+ // 用户填写;供应商被删时展示保存的副本,仍可直连或改填
2100
+ const textIn = (value, onInput, { type = 'text', placeholder = '' } = {}) => {
2101
+ const n = h('input', { type, value: value || '', placeholder, class: inputCls });
2102
+ n.addEventListener('input', () => onInput(n.value));
2103
+ return n;
2104
+ };
2105
+ const formatSel = h('select', { class: inputCls },
2106
+ h('option', { value: 'anthropic' }, 'anthropic'),
2107
+ h('option', { value: 'openai' }, 'openai'));
2108
+ formatSel.value = titleGenCfg.format === 'openai' ? 'openai' : 'anthropic';
2109
+ formatSel.addEventListener('change', () => {
2110
+ titleGenCfg.format = formatSel.value;
2111
+ void persistTitleGen();
2112
+ });
2113
+ const inlineConnCard = h('div', {
2114
+ class: 'mt-2 grid grid-cols-1 gap-2 rounded-xl border border-border p-2 sm:grid-cols-3',
2115
+ style: showInlineConn ? '' : 'display:none',
2116
+ },
2117
+ field(L.titleGenBaseUrl, textIn(titleGenCfg.baseUrl, (v) => { titleGenCfg.baseUrl = v; saveTitleGenSoon(); }, { placeholder: 'https://…' })),
2118
+ field(L.titleGenApiKey, textIn(titleGenCfg.apiKey, (v) => { titleGenCfg.apiKey = v; saveTitleGenSoon(); }, { type: 'password' })),
2119
+ field(L.titleGenFormat, formatSel));
2120
+ // 提示词:留空 = 内置默认;「查看默认提示词」把默认值载入编辑框供修改
2121
+ const promptArea = h('textarea', {
2122
+ class: 'zcodepro-textarea zcodepro-textarea-sm', rows: '8',
2123
+ placeholder: L.titleGenPromptPlaceholder,
2124
+ });
2125
+ promptArea.value = titleGenCfg.prompt || '';
2126
+ promptArea.addEventListener('input', () => { titleGenCfg.prompt = promptArea.value; saveTitleGenSoon(); });
2127
+ // 测试:用示例首条消息实际生成一次(不写库),验证供应商/模型/提示词
2128
+ // 连通;示例文本随界面语言取中英文版
2129
+ const sampleText = isZhLocale()
2130
+ ? '帮我看下这个报错是什么原因:NullPointerException at com.example.OrderService.calculate(OrderService.java:87)'
2131
+ : 'Why does my Spring Boot app throw NullPointerException intermittently in OrderService.calculate?';
2132
+ const sampleInput = h('input', {
2133
+ type: 'text', class: inputCls + ' min-w-0 flex-1',
2134
+ value: sampleText,
2135
+ });
2136
+ let testBtn;
2137
+ const testState = h('span', { class: 'min-w-0 flex-1 truncate text-ui-xs/relaxed text-foreground-subtle' });
2138
+ testBtn = btnSmall(L.titleGenTest, async () => {
2139
+ testBtn.disabled = true;
2140
+ testBtn.textContent = L.titleGenTesting;
2141
+ testState.textContent = L.titleGenTesting;
2142
+ const res = await rpc('/title-gen/run', {
2143
+ method: 'POST',
2144
+ body: { text: sampleInput.value, test: true },
2145
+ });
2146
+ testBtn.disabled = false;
2147
+ testBtn.textContent = L.titleGenTest;
2148
+ if (res && res.ok) {
2149
+ testState.textContent = L.titleGenTestOk + res.title;
2150
+ showToast(res.title, 'success');
2151
+ } else {
2152
+ testState.textContent = (res && errText(res)) || L.titleGenTestFailed;
2153
+ testState.className = 'min-w-0 flex-1 truncate text-ui-xs/relaxed text-destructive';
2154
+ showToast(L.titleGenTestFailed + ': ' + (res ? errText(res) : ''), 'error');
2155
+ }
2156
+ }, 'shrink-0');
2157
+ paneTitleGen.replaceChildren(
2158
+ // 信息卡:一句话简介在左,启用开关右对齐
2159
+ h('div', { class: 'mt-3 flex items-center justify-between gap-2 rounded-xl border border-border p-2' },
2160
+ h('span', { class: 'min-w-0 text-ui-sm/relaxed text-foreground-subtle' }, L.titleGenShortDesc),
2161
+ h('div', { class: 'shrink-0' }, settingRow(L.titleGenEnabled, '', titleGenCfg.enabled === true, async () => {
2162
+ titleGenCfg.enabled = !(titleGenCfg.enabled === true);
2163
+ await persistTitleGen();
2164
+ renderTitleGenPane();
2165
+ }))),
2166
+ // 供应商 + 模型
2167
+ h('div', { class: 'mt-2 grid grid-cols-1 gap-2 rounded-xl border border-border p-2 sm:grid-cols-2' },
2168
+ field(L.titleGenProvider, providerCombo),
2169
+ field(L.titleGenModel, modelCombo)),
2170
+ inlineConnCard,
2171
+ // 提示词 + 载入默认
2172
+ h('div', { class: 'mt-2 rounded-xl border border-border p-2' },
2173
+ h('div', { class: 'mb-1 flex items-center justify-between gap-2' },
2174
+ h('span', { class: 'text-ui-xs font-medium text-foreground-subtle' }, L.titleGenPrompt),
2175
+ btnSmall(L.titleGenPromptReset, () => {
2176
+ promptArea.value = titleGenDefaultPrompt;
2177
+ titleGenCfg.prompt = titleGenDefaultPrompt;
2178
+ void persistTitleGen();
2179
+ promptArea.focus();
2180
+ })),
2181
+ promptArea),
2182
+ // 测试
2183
+ h('div', { class: 'mt-2 flex items-center gap-2 rounded-xl border border-border p-2' },
2184
+ sampleInput, testBtn),
2185
+ testState,
2186
+ );
2187
+ };
2188
+ renderTitleGenPane();
2189
+
1488
2190
  body.append(
1489
2191
  statusLine,
1490
2192
  tablist,
1491
2193
  paneFeatures,
2194
+ paneAgents,
1492
2195
  paneStyles,
1493
2196
  panePerf,
1494
- paneAgents,
1495
2197
  paneProxy,
1496
2198
  paneSync,
1497
2199
  paneVision,
1498
2200
  paneHeadroom,
1499
2201
  paneRtk,
2202
+ paneDebug,
1500
2203
  );
1501
2204
  },
1502
2205
  });