pi-web-ui 0.71.0 → 0.72.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.
package/CHANGELOG.md CHANGED
@@ -10,7 +10,28 @@
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
- 暂无未发布内容。
13
+ ## [0.72.0] — 2026-09-09
14
+
15
+ ### Added
16
+
17
+ - 模型报错自动重试次数设置(`retryMaxAttempts`,对话设置):大模型 API 出错时按次数自动重试;次数用完本轮停止并标红,最后一轮红色报错旁有「重试」按钮(`retry_last`,协议 v15),手动再跑一轮;设为 0 则失败即停。面板值覆盖注入 SDK 默认(含子代理与会话重建)。
18
+ - 发布时翻译增量自动公示:`scripts/i18n-diff.mjs`(对比 base tag,统计前端 `zh/en` 与服务端 `pick` 新增/变更的 key)与 `scripts/release-notes.mjs`(拼 GitHub Release 说明,`### i18n` 现场生成;`npm run changelog:i18n` 自动维护本节);打 tag 推送后 Action 自动创建/更新 Release。
19
+
20
+ ### Fixed
21
+
22
+ - 多密钥按项目自愈:删除密钥时所有引用该密钥的项目跟随接管密钥(无剩余则解绑);清空服务商密钥时清掉全部项目的残留引用;切换到不存在的密钥不再种下 stale 引用;切项目自动恢复改为静默 + 不存在即删引用,切项目不再刷屏报错。
23
+
24
+ ### Changed
25
+
26
+ - 设置「消息显示」改名「对话」(中英 + 8 语言包同步)。
27
+
28
+ <!-- auto-i18n:start -->
29
+ ### i18n
30
+
31
+ - 前端新增 key(4):`modelRetryAttempts`、`modelRetryHint`、`retryNow`、`retryLastTip`
32
+ - 前端中文变更(1):`settingsMessageDisplay`
33
+ - 前端英文变更(1):`settingsMessageDisplay`
34
+ <!-- auto-i18n:end -->
14
35
 
15
36
  ## [0.71.0] — 2026-09-09
16
37
 
@@ -273,7 +294,8 @@
273
294
  - 0.35.1(2026-08-27):编辑重问保留附件(#18)+ 全窗口拖放(#19)。
274
295
  - 0.29.0(2026-08-23):全局搜索弹窗(Ctrl+K)+ 消息列表惰性窗口化。
275
296
 
276
- [Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.71.0...main
297
+ [Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.72.0...main
298
+ [0.72.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.72.0
277
299
  [0.71.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.71.0
278
300
  [0.70.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.70.0
279
301
  [0.69.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.69.0
@@ -724,6 +724,8 @@ export class ClientSession {
724
724
  conv.listed = true;
725
725
  conv.title = subagentTitle(prompt);
726
726
  this.convs.set(conv.id, conv);
727
+ // 子代理不走 bindSession——这里同样注入面板的重试次数覆盖。
728
+ this.applyRetryOverrides();
727
729
  // 扩展绑定(rpc 模式;uiContext 只给无害的 mock theme/status 槽,避免与主对话
728
730
  // 的 widget 冲突。无 uiContext 时扩展的 ctx.ui.theme.fg 会打到 TUI 真 theme
729
731
  // 代理上抛 "Theme not initialized",每个扩展一条 error toast。)
@@ -1535,6 +1537,10 @@ export class ClientSession {
1535
1537
  },
1536
1538
  });
1537
1539
  conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
1540
+ // 新会话 / 切换会话 / 强杀重建的必经之路:刚创建的 runtime 用的是 SDK
1541
+ // 默认重试 3 次——这里把面板的 retryMaxAttempts 覆盖注入,否则“设了 6
1542
+ // 次还是按 3 次重试”。已存在会话重复注入是幂等的(同值覆盖)。
1543
+ this.applyRetryOverrides();
1538
1544
  this.scheduleSnapshot();
1539
1545
  this.webUi.refresh();
1540
1546
  this.startWidgetsTimer();
@@ -2671,8 +2677,11 @@ export class ClientSession {
2671
2677
  setProviderApiKey(provider, apiKey) {
2672
2678
  return this.modelAdmin.setProviderApiKey(provider, apiKey);
2673
2679
  }
2674
- clearProviderApiKey(provider) {
2675
- return this.modelAdmin.clearProviderApiKey(provider);
2680
+ async clearProviderApiKey(provider) {
2681
+ await this.modelAdmin.clearProviderApiKey(provider);
2682
+ // The provider is back to unconfigured — drop its key preference in
2683
+ // EVERY project, otherwise each project switch re-tries a restore.
2684
+ this.stateStore.deleteProviderEverywhere(provider.trim());
2676
2685
  }
2677
2686
  listProviders() {
2678
2687
  return this.modelAdmin.listProviders();
@@ -2708,23 +2717,27 @@ export class ClientSession {
2708
2717
  this.stateStore.saveProjectProviderKey(this.clientId, this.cwd, provider, active);
2709
2718
  }
2710
2719
  async activateProviderKey(provider, keyName) {
2711
- await this.modelAdmin.activateProviderKey(provider, keyName);
2712
- this.stateStore.saveProjectProviderKey(this.clientId, this.cwd, provider, keyName);
2720
+ const ok = await this.modelAdmin.activateProviderKey(provider, keyName);
2721
+ // Only remember existing keys — a failed switch (deleted key) must not
2722
+ // plant a stale reference that errors on every later project switch.
2723
+ if (ok)
2724
+ this.stateStore.saveProjectProviderKey(this.clientId, this.cwd, provider, keyName);
2725
+ else
2726
+ this.stateStore.deleteProjectProviderKey(this.clientId, this.cwd, provider);
2713
2727
  }
2714
2728
  async removeProviderKey(provider, keyName) {
2715
2729
  await this.modelAdmin.removeProviderKey(provider, keyName);
2716
- const saved = this.stateStore.getProjectProviderKey(this.clientId, this.cwd, provider);
2717
- if (saved === keyName) {
2718
- const active = this.modelAdmin.getActiveKeyName(provider);
2719
- if (active)
2720
- this.stateStore.saveProjectProviderKey(this.clientId, this.cwd, provider, active);
2721
- else
2722
- this.stateStore.deleteProjectProviderKey(this.clientId, this.cwd, provider);
2723
- }
2730
+ // The deletion may have been made from another project: every project
2731
+ // still pinned to the deleted key must follow the key that took over
2732
+ // (or drop the pin when no keys remain), not just the current one.
2733
+ const active = this.modelAdmin.getActiveKeyName(provider);
2734
+ this.stateStore.repointDeletedKeyEverywhere(provider, keyName, active);
2724
2735
  }
2725
2736
  /** Restore per-project provider keys when entering a project. For each
2726
2737
  * provider that has a saved key for `cwd`, activate it if it differs from
2727
- * the current global active. Silent no notice spam on project switch. */
2738
+ * the current global active. Silent + self-healing: a saved key deleted
2739
+ * elsewhere is dropped without notifying (a noisy error here is what
2740
+ * haunted project switches after a key deletion). */
2728
2741
  async restoreProjectProviderKeysForCwd(cwd) {
2729
2742
  const saved = this.stateStore.getProjectProviderKeys(this.clientId, cwd);
2730
2743
  if (!saved)
@@ -2733,15 +2746,17 @@ export class ClientSession {
2733
2746
  const cur = this.modelAdmin.getActiveKeyName(provider);
2734
2747
  if (cur === keyName)
2735
2748
  continue;
2736
- try {
2737
- await this.modelAdmin.activateProviderKey(provider, keyName);
2738
- }
2739
- catch {
2740
- // saved key may have been deleted — ignore
2749
+ if (!this.modelAdmin.hasProviderKey(provider, keyName)) {
2750
+ this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
2751
+ continue;
2741
2752
  }
2753
+ const ok = await this.modelAdmin.activateProviderKey(provider, keyName, { silent: true });
2754
+ if (!ok)
2755
+ this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
2742
2756
  }
2743
2757
  }
2744
- /** When a model is set, ensure its provider's per-project key is restored. */
2758
+ /** When a model is set, ensure its provider's per-project key is restored.
2759
+ * Silent + self-healing like the bulk restore above. */
2745
2760
  async restoreKeyForModel(modelId, cwd) {
2746
2761
  const slash = modelId.indexOf("/");
2747
2762
  if (slash <= 0)
@@ -2753,10 +2768,13 @@ export class ClientSession {
2753
2768
  const cur = this.modelAdmin.getActiveKeyName(provider);
2754
2769
  if (cur === saved)
2755
2770
  return;
2756
- try {
2757
- await this.modelAdmin.activateProviderKey(provider, saved);
2771
+ if (!this.modelAdmin.hasProviderKey(provider, saved)) {
2772
+ this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
2773
+ return;
2758
2774
  }
2759
- catch { }
2775
+ const ok = await this.modelAdmin.activateProviderKey(provider, saved, { silent: true });
2776
+ if (!ok)
2777
+ this.stateStore.deleteProjectProviderKey(this.clientId, cwd, provider);
2760
2778
  }
2761
2779
  /** Remember the just-selected model (and the key that was active for its
2762
2780
  * provider) for the current project. Called IMMEDIATELY on model selection —
@@ -3129,6 +3147,87 @@ export class ClientSession {
3129
3147
  await this.interruptRun(this.conv, "已停止");
3130
3148
  this.flushSnapshot();
3131
3149
  }
3150
+ /** 手动重试上次失败的模型调用:自动重试次数(retryMaxAttempts)用完后
3151
+ * 本轮已停止并标红,用户点「重试」再触发一轮 LLM 调用。不新增用户气泡——
3152
+ * 用 display:false 的 custom 消息 triggerTurn 续跑,模型基于完整上下文
3153
+ * (含上次报错)继续生成。流式中 / 无可重试失败时只发 notice 拒绝。 */
3154
+ async retryLast() {
3155
+ const conv = this.conv;
3156
+ try {
3157
+ if (this.quiesceBlocked())
3158
+ return;
3159
+ const s = this.session;
3160
+ if (s.isStreaming) {
3161
+ this.emit({
3162
+ type: "notice",
3163
+ level: "info",
3164
+ text: "对话正在生成中,无需重试",
3165
+ textEn: "The conversation is still generating — no need to retry",
3166
+ });
3167
+ return;
3168
+ }
3169
+ if (conv.retryState) {
3170
+ this.emit({
3171
+ type: "notice",
3172
+ level: "info",
3173
+ text: "正在自动重试中,稍候即可",
3174
+ textEn: "Auto-retry is in progress — please wait",
3175
+ });
3176
+ return;
3177
+ }
3178
+ // 最后一轮失败的证据:末尾 stopReason=error 的 assistant 消息。
3179
+ let failed = null;
3180
+ try {
3181
+ const msgs = s.agent.state.messages;
3182
+ for (let i = msgs.length - 1; i >= 0; i--) {
3183
+ const m = msgs[i];
3184
+ if (m.role !== "assistant")
3185
+ continue;
3186
+ if ((typeof m.errorMessage === "string" && m.errorMessage.trim()) || m.stopReason === "error") {
3187
+ failed = m;
3188
+ }
3189
+ break;
3190
+ }
3191
+ }
3192
+ catch {
3193
+ // 会话替换中——按无可重试处理
3194
+ }
3195
+ if (!failed) {
3196
+ this.emit({
3197
+ type: "notice",
3198
+ level: "info",
3199
+ text: "没有可重试的失败:上一轮没有报错结束",
3200
+ textEn: "Nothing to retry: the last turn did not end with an error",
3201
+ });
3202
+ return;
3203
+ }
3204
+ // 轨迹用:下一轮 agent_start 消费(否则插件回退为「继续执行」)。
3205
+ conv.pendingTask = "手动重试上次失败的模型请求";
3206
+ await s.sendCustomMessage({
3207
+ customType: "manual-retry",
3208
+ content: [
3209
+ {
3210
+ type: "text",
3211
+ text: "(系统:用户点击了「重试」。请基于完整上下文重新发起上一次失败的模型请求,继续完成用户的任务。)",
3212
+ },
3213
+ ],
3214
+ display: false,
3215
+ }, { triggerTurn: true });
3216
+ conv.promptedSinceActive = true;
3217
+ conv.lastActiveAt = Date.now();
3218
+ conv.lastSdkEventAt = Date.now();
3219
+ conv.stallNoticed = false;
3220
+ }
3221
+ catch (err) {
3222
+ this.emit({
3223
+ type: "notice",
3224
+ level: "error",
3225
+ text: `手动重试失败:${err.message}`,
3226
+ textEn: `Manual retry failed: ${err.message}`,
3227
+ });
3228
+ }
3229
+ this.flushSnapshot();
3230
+ }
3132
3231
  /**
3133
3232
  * Remove ONE queued prompt text (the ✕ on a pending bubble) so it is neither
3134
3233
  * shown nor eventually delivered. The pi SDK has no per-item queue API, so we
@@ -331,6 +331,62 @@ export class ClientStateStore {
331
331
  }
332
332
  this.save();
333
333
  }
334
+ /** Remove one provider from EVERY project's saved keys (all clients, all
335
+ * cwds) — e.g. the provider was cleared and returned to unconfigured.
336
+ * Returns the number of entries removed. */
337
+ deleteProviderEverywhere(provider) {
338
+ const all = this.load();
339
+ let removed = 0;
340
+ for (const state of Object.values(all)) {
341
+ const map = state.projectProviderKeys;
342
+ if (!map)
343
+ continue;
344
+ for (const [cwd, inner] of Object.entries(map)) {
345
+ if (inner && provider in inner) {
346
+ delete inner[provider];
347
+ removed++;
348
+ if (Object.keys(inner).length === 0)
349
+ delete map[cwd];
350
+ }
351
+ }
352
+ if (map && Object.keys(map).length === 0)
353
+ delete state.projectProviderKeys;
354
+ }
355
+ if (removed > 0)
356
+ this.save();
357
+ return removed;
358
+ }
359
+ /** Fix every project that still references a deleted key: point it at the
360
+ * key that took over (`newActive`), or drop the reference when the
361
+ * provider has no keys left (`newActive` null). A key deletion made in
362
+ * one project must not keep haunting every other project that once used
363
+ * the same key on every project switch. Returns entries touched. */
364
+ repointDeletedKeyEverywhere(provider, deletedKeyName, newActive) {
365
+ const all = this.load();
366
+ let touched = 0;
367
+ for (const state of Object.values(all)) {
368
+ const map = state.projectProviderKeys;
369
+ if (!map)
370
+ continue;
371
+ for (const [cwd, inner] of Object.entries(map)) {
372
+ if (inner?.[provider] !== deletedKeyName)
373
+ continue;
374
+ if (newActive)
375
+ inner[provider] = newActive;
376
+ else {
377
+ delete inner[provider];
378
+ if (Object.keys(inner).length === 0)
379
+ delete map[cwd];
380
+ }
381
+ touched++;
382
+ }
383
+ if (map && Object.keys(map).length === 0)
384
+ delete state.projectProviderKeys;
385
+ }
386
+ if (touched > 0)
387
+ this.save();
388
+ return touched;
389
+ }
334
390
  /** Get the model the user last selected in a project, or undefined. */
335
391
  getProjectModel(clientId, cwd) {
336
392
  return this.load()[clientId]?.projectModels?.[cwd];
@@ -1560,6 +1560,65 @@ export class DshClientSession {
1560
1560
  textEn: "The DSH engine cannot stop bash alone (stop the whole conversation instead)",
1561
1561
  });
1562
1562
  }
1563
+ /** 手动重试上次失败的模型调用:找到最后一条用户提问并重发一次
1564
+ * (DSH 运行时以 prompt 驱动回合,无 triggerTurn 语义)。流式中 /
1565
+ * 无可重试失败时只发 notice 拒绝。 */
1566
+ async retryLast() {
1567
+ const conv = this.conv;
1568
+ try {
1569
+ if (this.quiesceBlocked())
1570
+ return;
1571
+ if (conv.isStreaming) {
1572
+ this.emit({
1573
+ type: "notice",
1574
+ level: "info",
1575
+ text: "对话正在生成中,无需重试",
1576
+ textEn: "The conversation is still generating — no need to retry",
1577
+ });
1578
+ return;
1579
+ }
1580
+ let failed = false;
1581
+ for (let i = conv.messages.length - 1; i >= 0; i--) {
1582
+ const m = conv.messages[i];
1583
+ if (m.role !== "assistant")
1584
+ continue;
1585
+ failed = !!m.errorMessage;
1586
+ break;
1587
+ }
1588
+ if (!failed) {
1589
+ this.emit({
1590
+ type: "notice",
1591
+ level: "info",
1592
+ text: "没有可重试的失败:上一轮没有报错结束",
1593
+ textEn: "Nothing to retry: the last turn did not end with an error",
1594
+ });
1595
+ return;
1596
+ }
1597
+ let lastUser = "";
1598
+ for (let i = conv.messages.length - 1; i >= 0; i--) {
1599
+ const m = conv.messages[i];
1600
+ if (m.role !== "user")
1601
+ continue;
1602
+ lastUser = m.content
1603
+ .map((c) => ("text" in c ? c.text : ""))
1604
+ .join("")
1605
+ .trim();
1606
+ if (lastUser)
1607
+ break;
1608
+ }
1609
+ const lang = this.getLang();
1610
+ await this.prompt(lastUser || pick(lang, "请继续", "Please continue", "dsh.prompt.continue"));
1611
+ }
1612
+ catch (err) {
1613
+ this.emit({
1614
+ type: "notice",
1615
+ level: "error",
1616
+ text: `手动重试失败:${err.message}`,
1617
+ textEn: `Manual retry failed: ${err.message}`,
1618
+ });
1619
+ }
1620
+ this.flushSnapshot();
1621
+ }
1563
1622
  // -----------------------------------------------------------------------
1564
1623
  // 后台任务
1565
1624
  // -----------------------------------------------------------------------
@@ -705,6 +705,9 @@ wss.on("connection", (ws) => {
705
705
  case "abort_bash":
706
706
  void cs.abortBash();
707
707
  break;
708
+ case "retry_last":
709
+ void cs.retryLast();
710
+ break;
708
711
  case "kill_background_server":
709
712
  void cs.killBackgroundServer(msg.port, msg.taskId);
710
713
  break;
@@ -941,6 +944,7 @@ wss.on("connection", (ws) => {
941
944
  visionBridgePromptMode: msg.visionBridgePromptMode,
942
945
  visionBridgePrompt: msg.visionBridgePrompt,
943
946
  subagentDefaultModel: msg.subagentDefaultModel,
947
+ retryMaxAttempts: msg.retryMaxAttempts,
944
948
  reviewPrompt: msg.reviewPrompt,
945
949
  reviewDisabledSkills: msg.reviewDisabledSkills,
946
950
  markersEnabled: msg.markersEnabled,
@@ -177,6 +177,21 @@ export class ModelAdminService {
177
177
  const data = this.readProviderKeys();
178
178
  return data[provider]?.activeKeyName ?? null;
179
179
  }
180
+ /** Whether a named key still exists for a provider (no side effects, no
181
+ * notices). Restore paths use this to drop stale per-project references
182
+ * silently instead of going through activate (which notifies). */
183
+ hasProviderKey(provider, keyName) {
184
+ const pid = provider.trim();
185
+ const targetName = keyName.trim();
186
+ if (!pid || !targetName)
187
+ return false;
188
+ try {
189
+ return this.readProviderKeys()[pid]?.keys.some((k) => k.name === targetName) ?? false;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ }
180
195
  /** Seed a provider's key list from an EXISTING auth.json credential (legacy
181
196
  * configs written before the multi-key store existed) so the store stays
182
197
  * authoritative and the UI shows the current active key immediately even
@@ -373,36 +388,44 @@ export class ModelAdminService {
373
388
  this.host.flushSnapshot();
374
389
  }
375
390
  /** Make a stored API key the ACTIVE one for a built-in provider by NAME (the
376
- * server resolves the stored value from the name). */
377
- async activateProviderKey(provider, keyName) {
391
+ * server resolves the stored value from the name). Returns true when the
392
+ * key is (now) active, false when it doesn't exist or the switch failed.
393
+ * `silent` suppresses all notices — for automatic project restores, which
394
+ * must self-heal stale references without spamming the user. */
395
+ async activateProviderKey(provider, keyName, opts) {
378
396
  const pid = provider.trim();
379
397
  const targetName = keyName.trim();
398
+ const silent = opts?.silent === true;
399
+ const notice = (msg) => {
400
+ if (!silent)
401
+ this.host.emit(msg);
402
+ };
380
403
  try {
381
404
  const data = this.readProviderKeys();
382
405
  const entry = data[pid];
383
406
  const target = entry?.keys.find((k) => k.name === targetName);
384
407
  if (!target) {
385
- this.host.emit({
408
+ notice({
386
409
  type: "notice",
387
410
  level: "error",
388
411
  text: `${pid} 的密钥「${targetName}」不存在`,
389
412
  textEn: `Key "${targetName}" for ${pid} does not exist`,
390
413
  });
391
- return;
414
+ return false;
392
415
  }
393
416
  if (entry.activeKeyName === targetName) {
394
- this.host.emit({
417
+ notice({
395
418
  type: "notice",
396
419
  level: "info",
397
420
  text: `「${targetName}」已是当前密钥`,
398
421
  textEn: `"${targetName}" is already the active key`,
399
422
  });
400
- return;
423
+ return true;
401
424
  }
402
425
  entry.activeKeyName = targetName;
403
426
  this.writeProviderKeys(data);
404
427
  await this.applyActiveKey(pid, target.apiKey);
405
- this.host.emit({
428
+ notice({
406
429
  type: "notice",
407
430
  level: "info",
408
431
  text: `⚡ 已切换到 ${pid} 的「${targetName}」`,
@@ -411,16 +434,21 @@ export class ModelAdminService {
411
434
  await this.host.pushModels();
412
435
  await this.listProviders();
413
436
  this.listProviderKeys();
437
+ return true;
414
438
  }
415
439
  catch (err) {
416
- this.host.emit({
440
+ notice({
417
441
  type: "notice",
418
442
  level: "error",
419
443
  text: `切换密钥失败:${err.message}`,
420
444
  textEn: `Failed to switch key: ${err.message}`,
421
445
  });
446
+ return false;
447
+ }
448
+ finally {
449
+ if (!silent)
450
+ this.host.flushSnapshot();
422
451
  }
423
- this.host.flushSnapshot();
424
452
  }
425
453
  /** Remove a stored API key by NAME. If it was active, the first remaining key
426
454
  * becomes active (or the provider returns to unconfigured when no key is left). */
@@ -8,4 +8,4 @@
8
8
  * its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
9
9
  * verifies the two never drift.
10
10
  */
11
- export const PROTOCOL_VERSION = 14;
11
+ export const PROTOCOL_VERSION = 15;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.71.0",
3
+ "version": "0.72.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -63,6 +63,9 @@
63
63
  "test:unit": "vitest run",
64
64
  "test:smoke": "node tests/run-smoke.mjs",
65
65
  "check:protocol": "node scripts/check-protocol-sync.mjs",
66
+ "i18n:diff": "node scripts/i18n-diff.mjs",
67
+ "release:notes": "node scripts/release-notes.mjs",
68
+ "changelog:i18n": "node scripts/release-notes.mjs --unreleased",
66
69
  "test:freeze": "node tests/freeze-test.mjs",
67
70
  "start": "node dist/server/index.js",
68
71
  "format": "prettier --write server/ web/src/ extensions/ scripts/ bin/ tests/*.mjs tests/unit/ *.mjs",
@@ -93,5 +93,5 @@
93
93
  --chip-bg: color-mix(in srgb, var(--bg-elev2) var(--wallpaper-panel-alpha, 62%), transparent);
94
94
  --msgs-bg: color-mix(in srgb, var(--bg-elev) var(--wallpaper-panel-alpha, 62%), transparent);
95
95
  --inputbox-bg: color-mix(in srgb, var(--bg-elev2) var(--wallpaper-panel-alpha, 62%), transparent);
96
- --menu-bg: var(--chip-bg);
96
+ --menu-bg: var(--bg-elev2);
97
97
  }
package/themes/dazzle.css CHANGED
@@ -93,5 +93,5 @@
93
93
  --chip-bg: color-mix(in srgb, var(--bg-elev2) var(--wallpaper-panel-alpha, 62%), transparent);
94
94
  --msgs-bg: color-mix(in srgb, var(--bg-elev) var(--wallpaper-panel-alpha, 62%), transparent);
95
95
  --inputbox-bg: color-mix(in srgb, var(--bg-elev2) var(--wallpaper-panel-alpha, 62%), transparent);
96
- --menu-bg: var(--chip-bg);
96
+ --menu-bg: var(--bg-elev2);
97
97
  }
@@ -93,7 +93,7 @@
93
93
  --chip-bg: color-mix(in srgb, var(--bg-elev2) var(--wallpaper-panel-alpha, 62%), transparent);
94
94
  --msgs-bg: color-mix(in srgb, var(--bg-elev) var(--wallpaper-panel-alpha, 62%), transparent);
95
95
  --inputbox-bg: color-mix(in srgb, var(--bg-elev2) var(--wallpaper-panel-alpha, 62%), transparent);
96
- --menu-bg: var(--chip-bg);
96
+ --menu-bg: var(--bg-elev2);
97
97
  }
98
98
 
99
99
  /* ---- ambient gradient(镜像 .fp-markdown 预览底色,覆盖整个窗口)---- */
@@ -66,8 +66,8 @@
66
66
  /* ---- 壁纸适配:三大区域(顶栏/底栏/左右面板)背景链式引用此变量,
67
67
  从默认 62% 降到 45% → 容器色更薄、图更透(45% 容器色 + 55% 透图)---- */
68
68
  --wallpaper-panel-alpha: 45%;
69
- /* 下拉弹窗(声音/语言/主题/模型/思考强度):实色底保密集列表可读,
70
- 覆盖 styles.css 默认的半透 --menu-bg */
69
+ /* 下拉弹窗(声音/语言/主题/模型/思考强度/版本更新):实色底保密集列表可读
70
+ (与 styles.css 默认一致,这里显式锁定,防止跟随壁纸半透)。 */
71
71
  --menu-bg: var(--bg-elev2);
72
72
  /* 压暗略放轻(默认 0.78):让图更鲜活,文字靠压暗与半透明底板仍可读 */
73
73
  --bg-image-dim: 0.72;
package/themes/white.css CHANGED
@@ -93,7 +93,7 @@
93
93
  --chip-bg: color-mix(in srgb, var(--bg-elev2) var(--wallpaper-panel-alpha, 62%), transparent);
94
94
  --msgs-bg: color-mix(in srgb, var(--bg-elev) var(--wallpaper-panel-alpha, 62%), transparent);
95
95
  --inputbox-bg: color-mix(in srgb, var(--bg-elev2) var(--wallpaper-panel-alpha, 62%), transparent);
96
- --menu-bg: var(--chip-bg);
96
+ --menu-bg: var(--bg-elev2);
97
97
  }
98
98
 
99
99
  /* ---- syntax highlighting (overrides static github-dark import) ---- */
@@ -1,4 +1,4 @@
1
- import{a as l,j as n}from"./markdown-Cpo0pNcR.js";import{u as P,b as O,T as M,a as W,F as Y,c as z,d as Z,e as H,f as ee,g as ne,h as te,i as se,r as ae}from"./index-Cue012aF.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-CtudoG1_.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(a,x)=>(c=Number(x),`\r
1
+ import{a as l,j as n}from"./markdown-Cpo0pNcR.js";import{u as P,b as O,T as M,a as W,F as Y,c as z,d as Z,e as H,f as ee,g as ne,h as te,i as se,r as ae}from"./index-CBZtMKV2.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-CtudoG1_.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(a,x)=>(c=Number(x),`\r
2
2
  `)).replace(/\r?\n?\x1b\[90m\[(?:进程已退出,退出码 |Process exited with code )-?\d+\]\x1b\[0m\r?\n?/g,`\r
3
3
  `),exitCode:c}}function ce({conversationId:t,terminalId:c,command:p,cwd:a,title:x,active:u,running:v,exitCode:j,send:h,register:C}){const T=l.useRef(null),y=l.useRef(null),{locale:o}=P(),N=l.useRef(o);N.current=o;const E=p?JSON.stringify(p):"";l.useEffect(()=>{const m=T.current;if(!m)return;const r=new re({theme:O(),fontFamily:'"SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',fontSize:13,cursorBlink:!0,scrollback:8e3}),b=new ie;r.loadAddon(b),r.open(m),y.current={term:r,fit:b},u&&r.focus();const g=()=>{r.options.theme=O()};window.addEventListener(M,g),r.attachCustomKeyEventHandler(d=>{if(d.type!=="keydown")return!0;const S=d.key?.toLowerCase();if((d.ctrlKey||d.metaKey)&&S==="v")return!1;if(d.ctrlKey&&!d.shiftKey&&!d.altKey&&S==="c"&&r.hasSelection()){const D=r.textarea;return D&&(D.value=r.getSelection(),D.select()),!1}return!0});const _=C(t,c,{write:d=>r.write(le(d).clean),dispose:()=>r.dispose()}),$=()=>{try{b.fit(),h({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.cols,rows:r.rows})}catch{}},B=requestAnimationFrame(()=>{try{b.fit()}catch{}h(p?{type:"run_command",terminalId:c,conversationId:t,command:p,cols:r.cols,rows:r.rows}:{type:"terminal_create",terminalId:c,title:x,locale:N.current,conversationId:t,cwd:a,cols:r.cols,rows:r.rows})}),R=r.onData(d=>{h({type:"terminal_input",terminalId:c,conversationId:t,data:d})});let k=null;return typeof ResizeObserver<"u"&&(k=new ResizeObserver(()=>{m.offsetWidth>0&&m.offsetHeight>0&&$()}),k.observe(m)),()=>{cancelAnimationFrame(B),R.dispose(),window.removeEventListener(M,g),k?.disconnect(),_(),r.dispose(),y.current=null}},[t,c,E,h,C]),l.useEffect(()=>{if(!u)return;const m=requestAnimationFrame(()=>{const r=y.current;if(r){try{r.fit.fit(),h({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.term.cols,rows:r.term.rows})}catch{}r.term.focus()}});return()=>cancelAnimationFrame(m)},[u]);const{t:F}=P(),f=l.useRef(void 0);return l.useEffect(()=>{if(v===f.current||(f.current=v,v!==!1))return;const m=y.current;m&&m.term.write(`\r
4
4
  \x1B[90m${F("exitBanner",{code:j??""})}\x1B[0m\r