pi-web-ui 0.20.1 → 0.21.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.
@@ -12,7 +12,7 @@
12
12
  */
13
13
  import { spawn } from "node:child_process";
14
14
  import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, watch, } from "node:fs";
15
- import { dirname, join, relative, resolve, sep } from "node:path";
15
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
18
18
  import { Type } from "typebox";
@@ -750,6 +750,15 @@ async function readDirForUI(abs, rel) {
750
750
  out.length = MAX;
751
751
  return { entries: out, truncated };
752
752
  }
753
+ /** Stable identity of an extension for the enable/disable toggle: the npm
754
+ * spec for packages (survives version bumps), the resolved entry path
755
+ * otherwise. */
756
+ function extensionKey(e) {
757
+ const src = e.sourceInfo;
758
+ if (src?.origin === "package" && src.source)
759
+ return src.source;
760
+ return src?.path ?? e.path;
761
+ }
753
762
  /**
754
763
  * Persists which workspace each browser client last used + which workspaces it
755
764
  * has opened, so a server restart / page reload restores the same project and
@@ -820,6 +829,40 @@ class ClientStateStore {
820
829
  };
821
830
  this.save();
822
831
  }
832
+ /** Last-used settings-panel state for a client, or defaults. */
833
+ getSettings(clientId) {
834
+ const s = this.load()[clientId];
835
+ return {
836
+ promptMode: s?.settings?.promptMode === "replace" ? "replace" : "append",
837
+ customSystemPrompt: s?.settings?.customSystemPrompt ?? "",
838
+ disabledSkills: s?.settings?.disabledSkills ?? [],
839
+ disabledExtensions: s?.settings?.disabledExtensions ?? [],
840
+ };
841
+ }
842
+ /** Persist the client's settings-panel state (partial merge). */
843
+ saveSettings(clientId, settings) {
844
+ const all = this.load();
845
+ const state = (all[clientId] ??= { projects: [] });
846
+ const cur = state.settings ?? {};
847
+ state.settings = {
848
+ promptMode: settings.promptMode ?? cur.promptMode ?? "append",
849
+ customSystemPrompt: settings.customSystemPrompt ?? cur.customSystemPrompt ?? "",
850
+ disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
851
+ disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
852
+ };
853
+ this.save();
854
+ }
855
+ /** Named settings presets for a client (empty if never saved). */
856
+ getPresets(clientId) {
857
+ return this.load()[clientId]?.presets ?? [];
858
+ }
859
+ /** Persist the client's named settings presets. */
860
+ savePresets(clientId, presets) {
861
+ const all = this.load();
862
+ const state = (all[clientId] ??= { projects: [] });
863
+ state.presets = presets;
864
+ this.save();
865
+ }
823
866
  }
824
867
  /** Hard cap on how long ONE tool call may run before the watchdog aborts the
825
868
  * session. The SDK bash tool has NO default timeout, so a command that never
@@ -914,6 +957,20 @@ export class ClientSession {
914
957
  /** Guard: the goal wizard and the review loop are mutually exclusive — a
915
958
  * wizard in flight stops review triggers (and vice versa). */
916
959
  goalWizardRunning = false;
960
+ /** Settings-panel state (system prompt + disabled skills/extensions). The
961
+ * resource-loader overrides in makeRuntimeFactory() read this at every
962
+ * reload(), so session.reload() applies changes to the running runtime. */
963
+ settings;
964
+ /** Named settings presets (saved combos the user can re-apply). */
965
+ presets = [];
966
+ /** Settings changed while the run was streaming — the runtime reload is
967
+ * deferred to the next agent_end so an in-flight run is never torn down. */
968
+ pendingSettingsReload = false;
969
+ /** Full (incl. disabled) skill/extension lists seen so far — disabled
970
+ * entries disappear from the loader after reload, so this cache keeps them
971
+ * visible (and re-enableable) in the settings panel. */
972
+ knownSkills = new Map();
973
+ knownExtensions = new Map();
917
974
  /** Aborts the currently-running goal wizard (user clicked ✗ / timed out). Drives
918
975
  * the in-flight goal_ask dialog to resolve as cancelled and (via the run
919
976
  * signal) stops the wizard session's agent run. Recreated per wizard. */
@@ -993,6 +1050,8 @@ export class ClientSession {
993
1050
  this.cwd = cwd;
994
1051
  this.agentDir = agentDir;
995
1052
  this.stateStore = stateStore;
1053
+ this.settings = stateStore.getSettings(clientId);
1054
+ this.presets = stateStore.getPresets(clientId);
996
1055
  }
997
1056
  static async create(clientId, cwd, stateStore) {
998
1057
  const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
@@ -1040,16 +1099,41 @@ export class ClientSession {
1040
1099
  const services = await createAgentSessionServices({
1041
1100
  cwd: effectiveCwd,
1042
1101
  modelRuntime: this.sharedModelRuntime,
1043
- ...(process.platform === "win32"
1044
- ? {
1045
- // Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端是
1046
- // 交互式 TTY——注入约束避免 heredoc/交互/长驻命令挂死整个会话;
1047
- // GBK 老中文文件让模型改用终端按正确编码读(iconv/chcp/Get-Content)。
1048
- resourceLoaderOptions: {
1049
- systemPromptOverride: (base) => base ? `${base}\n\n${WINDOWS_PERSONA}` : WINDOWS_PERSONA,
1050
- },
1051
- }
1052
- : {}),
1102
+ // 设置面板钩子(官方 SDK 的 resourceLoader overrides):三个 override
1103
+ // 在每次 resourceLoader.reload() 时重放,且读取 this.settings 的当前
1104
+ // 值——因此 session.reload() 即可让系统提示词 / 技能 / 插件开关生效,
1105
+ // 新对话(新 runtime)也会自动带上当前设置。
1106
+ resourceLoaderOptions: {
1107
+ // 系统提示词:replace 模式整体替换;append 模式追加到提示词末尾。
1108
+ systemPromptOverride: (base) => this.settings.promptMode === "replace" &&
1109
+ this.settings.customSystemPrompt.trim()
1110
+ ? this.settings.customSystemPrompt
1111
+ : base,
1112
+ appendSystemPromptOverride: (base) => {
1113
+ const out = [...base];
1114
+ const custom = this.settings.customSystemPrompt.trim();
1115
+ if (this.settings.promptMode === "append" && custom) {
1116
+ out.push(custom);
1117
+ }
1118
+ if (process.platform === "win32") {
1119
+ // Windows 专属 persona:bash 工具跑 Git Bash 且无默认超时、终端
1120
+ // 是交互式 TTY——注入约束避免 heredoc/交互/长驻命令挂死整个会话;
1121
+ // GBK 老中文文件让模型改用终端按正确编码读(iconv/chcp/Get-Content)。
1122
+ out.push(WINDOWS_PERSONA);
1123
+ }
1124
+ return out;
1125
+ },
1126
+ // 技能开关:禁用的技能从系统提示词和 /skill: 目录中剔除。
1127
+ skillsOverride: (res) => ({
1128
+ ...res,
1129
+ skills: res.skills.filter((s) => !this.settings.disabledSkills.includes(s.name)),
1130
+ }),
1131
+ // 插件开关:禁用的扩展整个卸载(工具 / 命令随之消失)。
1132
+ extensionsOverride: (res) => ({
1133
+ ...res,
1134
+ extensions: res.extensions.filter((e) => !this.settings.disabledExtensions.includes(extensionKey(e))),
1135
+ }),
1136
+ },
1053
1137
  });
1054
1138
  return {
1055
1139
  ...(await createAgentSessionFromServices({
@@ -1117,6 +1201,9 @@ export class ClientSession {
1117
1201
  // Reconnect: push the remembered goal prefs (model choice, rounds cap,
1118
1202
  // locked) so the goal bar restores them on reload — "全局记忆".
1119
1203
  this.emitGoalStatus();
1204
+ // Reconnect: push the settings panel state (prompt text/mode, skill &
1205
+ // extension toggles, saved presets).
1206
+ this.pushSettings();
1120
1207
  }
1121
1208
  detachSink(send) {
1122
1209
  this.sinks.delete(send);
@@ -1327,6 +1414,13 @@ export class ClientSession {
1327
1414
  !this.disposed) {
1328
1415
  void this.runGoalReview(conv);
1329
1416
  }
1417
+ // Deferred settings reload: settings (system prompt / skills /
1418
+ // extensions) changed while the run was streaming — applying now
1419
+ // would have torn down the in-flight run.
1420
+ if (this.pendingSettingsReload && !this.disposed) {
1421
+ this.pendingSettingsReload = false;
1422
+ void this.applySettingsReload();
1423
+ }
1330
1424
  break;
1331
1425
  }
1332
1426
  case "entry_appended":
@@ -2275,6 +2369,174 @@ export class ClientSession {
2275
2369
  this.emit({ type: "slash_commands", commands });
2276
2370
  }
2277
2371
  // ---------------------------------------------------------------------------
2372
+ // Settings (system prompt / skills / extensions / presets)
2373
+ // ---------------------------------------------------------------------------
2374
+ /** Push the full settings state (current settings + loaded skills/extensions
2375
+ * with enabled flags + saved presets). Pushed on attach and after every
2376
+ * settings change. */
2377
+ pushSettings() {
2378
+ const disabledSkills = new Set(this.settings.disabledSkills);
2379
+ const disabledExts = new Set(this.settings.disabledExtensions);
2380
+ try {
2381
+ // Refresh the cache with the CURRENTLY loaded set (post-filter).
2382
+ for (const s of this.session.resourceLoader.getSkills().skills) {
2383
+ this.knownSkills.set(s.name, {
2384
+ name: s.name,
2385
+ description: s.description,
2386
+ enabled: true,
2387
+ });
2388
+ }
2389
+ for (const e of this.session.resourceLoader.getExtensions().extensions) {
2390
+ const id = extensionKey(e);
2391
+ const p = e.sourceInfo?.path ?? e.path;
2392
+ this.knownExtensions.set(id, {
2393
+ id,
2394
+ name: e.sourceInfo?.origin === "package" && e.sourceInfo.source
2395
+ ? e.sourceInfo.source
2396
+ : basename(p),
2397
+ path: p,
2398
+ enabled: true,
2399
+ });
2400
+ }
2401
+ }
2402
+ catch {
2403
+ // Session not ready yet — keep whatever we already know.
2404
+ }
2405
+ // Disabled entries are filtered out of the loader — keep them in the
2406
+ // panel (with the last-known description) so they can be re-enabled.
2407
+ for (const name of this.settings.disabledSkills) {
2408
+ if (!this.knownSkills.has(name)) {
2409
+ this.knownSkills.set(name, { name, description: "", enabled: false });
2410
+ }
2411
+ }
2412
+ for (const id of this.settings.disabledExtensions) {
2413
+ if (!this.knownExtensions.has(id)) {
2414
+ this.knownExtensions.set(id, {
2415
+ id,
2416
+ name: id.startsWith("npm:") ? id : basename(id),
2417
+ path: "",
2418
+ enabled: false,
2419
+ });
2420
+ }
2421
+ }
2422
+ const skills = [...this.knownSkills.values()]
2423
+ .map((s) => ({ ...s, enabled: !disabledSkills.has(s.name) }))
2424
+ .sort((a, b) => a.name.localeCompare(b.name));
2425
+ const extensions = [...this.knownExtensions.values()]
2426
+ .map((e) => ({ ...e, enabled: !disabledExts.has(e.id) }))
2427
+ .sort((a, b) => a.name.localeCompare(b.name));
2428
+ this.emit({
2429
+ type: "settings_state",
2430
+ settings: {
2431
+ promptMode: this.settings.promptMode,
2432
+ customSystemPrompt: this.settings.customSystemPrompt,
2433
+ disabledSkills: [...this.settings.disabledSkills],
2434
+ disabledExtensions: [...this.settings.disabledExtensions],
2435
+ skills,
2436
+ extensions,
2437
+ presets: this.presets.map((p) => ({ ...p })),
2438
+ },
2439
+ });
2440
+ }
2441
+ /** Persist + apply a partial settings update (prompt text/mode, toggles). */
2442
+ async setSettings(partial) {
2443
+ if (partial.promptMode !== undefined)
2444
+ this.settings.promptMode = partial.promptMode;
2445
+ if (partial.customSystemPrompt !== undefined) {
2446
+ this.settings.customSystemPrompt = partial.customSystemPrompt;
2447
+ }
2448
+ if (partial.disabledSkills !== undefined) {
2449
+ this.settings.disabledSkills = partial.disabledSkills;
2450
+ }
2451
+ if (partial.disabledExtensions !== undefined) {
2452
+ this.settings.disabledExtensions = partial.disabledExtensions;
2453
+ }
2454
+ this.stateStore.saveSettings(this.clientId, this.settings);
2455
+ this.pushSettings();
2456
+ await this.applyRuntimeSettings();
2457
+ }
2458
+ /** Save the CURRENT settings as a named preset (overwrites if exists). */
2459
+ async savePreset(name) {
2460
+ const n = name.trim();
2461
+ if (!n) {
2462
+ this.emit({ type: "notice", level: "error", text: "预设名称不能为空" });
2463
+ return;
2464
+ }
2465
+ const preset = {
2466
+ name: n,
2467
+ promptMode: this.settings.promptMode,
2468
+ customSystemPrompt: this.settings.customSystemPrompt,
2469
+ disabledSkills: [...this.settings.disabledSkills],
2470
+ disabledExtensions: [...this.settings.disabledExtensions],
2471
+ };
2472
+ const existing = this.presets.findIndex((p) => p.name === n);
2473
+ if (existing >= 0)
2474
+ this.presets[existing] = preset;
2475
+ else
2476
+ this.presets.push(preset);
2477
+ this.stateStore.savePresets(this.clientId, this.presets);
2478
+ this.pushSettings();
2479
+ }
2480
+ /** Replace the current settings with the named preset and apply it. */
2481
+ async applyPreset(name) {
2482
+ const p = this.presets.find((x) => x.name === name);
2483
+ if (!p) {
2484
+ this.emit({ type: "notice", level: "error", text: `预设不存在:${name}` });
2485
+ return;
2486
+ }
2487
+ this.settings = {
2488
+ promptMode: p.promptMode,
2489
+ customSystemPrompt: p.customSystemPrompt,
2490
+ disabledSkills: [...p.disabledSkills],
2491
+ disabledExtensions: [...p.disabledExtensions],
2492
+ };
2493
+ this.stateStore.saveSettings(this.clientId, this.settings);
2494
+ this.pushSettings();
2495
+ await this.applyRuntimeSettings();
2496
+ }
2497
+ /** Remove a named preset. */
2498
+ async deletePreset(name) {
2499
+ this.presets = this.presets.filter((p) => p.name !== name);
2500
+ this.stateStore.savePresets(this.clientId, this.presets);
2501
+ this.pushSettings();
2502
+ }
2503
+ /**
2504
+ * Make settings changes effective in the running runtime. The resource-loader
2505
+ * overrides read this.settings at call time, so a reload re-applies them.
2506
+ * Reloading mid-stream would tear down the in-flight run — defer instead.
2507
+ */
2508
+ async applyRuntimeSettings() {
2509
+ if (this.disposed)
2510
+ return;
2511
+ if (this.session.isStreaming) {
2512
+ this.pendingSettingsReload = true;
2513
+ this.emit({
2514
+ type: "notice",
2515
+ level: "info",
2516
+ text: "当前回复进行中,设置将在回复结束后自动应用",
2517
+ });
2518
+ return;
2519
+ }
2520
+ await this.applySettingsReload();
2521
+ }
2522
+ /** session.reload() + refresh the slash-command catalog + push state. */
2523
+ async applySettingsReload() {
2524
+ try {
2525
+ await this.session.reload();
2526
+ await this.pushSlashCommands();
2527
+ this.pushSettings();
2528
+ this.flushSnapshot();
2529
+ this.emit({ type: "notice", level: "info", text: "设置已应用" });
2530
+ }
2531
+ catch (err) {
2532
+ this.emit({
2533
+ type: "notice",
2534
+ level: "error",
2535
+ text: `设置应用失败:${err.message}`,
2536
+ });
2537
+ }
2538
+ }
2539
+ // ---------------------------------------------------------------------------
2278
2540
  // Commands
2279
2541
  // ---------------------------------------------------------------------------
2280
2542
  async prompt(text, attachments) {
@@ -363,6 +363,26 @@ wss.on("connection", (ws) => {
363
363
  locked: msg.locked,
364
364
  });
365
365
  break;
366
+ case "get_settings":
367
+ cs.pushSettings();
368
+ break;
369
+ case "set_settings":
370
+ void cs.setSettings({
371
+ promptMode: msg.promptMode,
372
+ customSystemPrompt: msg.customSystemPrompt,
373
+ disabledSkills: msg.disabledSkills,
374
+ disabledExtensions: msg.disabledExtensions,
375
+ });
376
+ break;
377
+ case "save_preset":
378
+ void cs.savePreset(msg.name);
379
+ break;
380
+ case "apply_preset":
381
+ void cs.applyPreset(msg.name);
382
+ break;
383
+ case "delete_preset":
384
+ void cs.deletePreset(msg.name);
385
+ break;
366
386
  default:
367
387
  break;
368
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.20.1",
3
+ "version": "0.21.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
  "type": "module",