pi-web-ui 0.68.1 → 0.68.2

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.
@@ -14,7 +14,7 @@ import { spawn } from "node:child_process";
14
14
  import { randomUUID } from "node:crypto";
15
15
  import { createRequire } from "node:module";
16
16
  import { existsSync, readFileSync, rmSync, statSync, mkdirSync, watch } from "node:fs";
17
- import { basename, dirname, join, resolve, sep } from "node:path";
17
+ import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
20
20
  import { Type } from "typebox";
@@ -35,7 +35,7 @@ import { WebUIContext, mockThemeProxy } from "./webui-context.js";
35
35
  import { makeEditSoftTool, SOFT_EDIT_TOOL_NAME } from "./edit-soft-tool.js";
36
36
  import { makeSubagentTools, subagentTitle, } from "./subagents.js";
37
37
  import { buildAttachmentMessages } from "./attachments.js";
38
- import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
38
+ import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
39
39
  import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
40
40
  import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
41
41
  const SNAPSHOT_INTERVAL_MS = 60;
@@ -215,6 +215,67 @@ function makeMarkersListTool(getActiveId, markerSvc) {
215
215
  },
216
216
  };
217
217
  }
218
+ /**
219
+ * 标准 pi 引擎的 ask_user_question 工具:模型调用时把问题桥到浏览器(复用 DSH
220
+ * 引擎的 question_pending/question_answer 协议,前端 DshQuestionDialog 富渲染),
221
+ * 阻塞 agent 循环直到用户在浏览器回答或取消。
222
+ *
223
+ * 标准 SDK 没有内建 ask_user_question,故由 pi-web-ui 以 customTool 注册(与
224
+ * bash/edit 同机制)。DSH 引擎走 goal-rpc 的 userQuestions provider,两者互不
225
+ * 冲突(各引擎各走各的)。
226
+ *
227
+ * askUser 签名带 {aborted} 快照而非完整 AbortSignal:customTool 的 execute 信号
228
+ * 服务于整个 agent 生命周期,这里按「已中止即拒绝」的最小语义处理,避免与其它
229
+ * 工具的取消逻辑纠缠。
230
+ */
231
+ export function makeAskUserQuestionTool(clientSession) {
232
+ const QuestionOptionSchema = Type.Object({
233
+ label: Type.String({ description: "Display label for the option" }),
234
+ description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
235
+ preview: Type.Optional(Type.String({
236
+ description: "Optional preview rendered below when this option is selected (markdown or HTML — use for mockups/code/config).",
237
+ })),
238
+ });
239
+ const QuestionSchema = Type.Object({
240
+ id: Type.String({ description: "Unique identifier for this question" }),
241
+ question: Type.String({ description: "The full question text to display (markdown/HTML ok)" }),
242
+ detail: Type.Optional(Type.String({ description: "Optional detail/context shown under the question" })),
243
+ header: Type.Optional(Type.String({ description: "Optional short header for this question" })),
244
+ options: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Available options to choose from" })),
245
+ multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options (default: false)" })),
246
+ });
247
+ return {
248
+ name: "ask_user_question",
249
+ label: "Ask the user",
250
+ description: "Ask the user focused questions to pin down ambiguous requirements. Use for clarifying the task, confirming decisions, or getting preferences. Each question renders a browser dialog with markdown/HTML rich text; options may carry a `preview`. Submit or cancel to resume.",
251
+ parameters: Type.Object({
252
+ questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
253
+ }),
254
+ execute: async (_id, params, signal) => {
255
+ const qs = params.questions;
256
+ if (!Array.isArray(qs) || qs.length === 0) {
257
+ throw new Error("ask_user_question requires at least one question");
258
+ }
259
+ const answers = await clientSession.askUser(qs, {
260
+ aborted: signal?.aborted,
261
+ });
262
+ if (answers === null) {
263
+ throw new Error("用户取消了提问");
264
+ }
265
+ // 工具结果:把每道题的回答拼成简洁文本给模型,同时留 details 供 UI 展示。
266
+ const lines = answers.map((a) => {
267
+ const q = qs.find((q) => q.id === a.id);
268
+ const label = a.selected.join(", ");
269
+ const custom = a.custom?.trim() ? ` (wrote: ${a.custom.trim()})` : "";
270
+ return `${q?.header ?? q?.id ?? a.id}: ${label || "(no selection)"}${custom}`;
271
+ });
272
+ return {
273
+ content: [{ type: "text", text: lines.join("\n") }],
274
+ details: { answers },
275
+ };
276
+ },
277
+ };
278
+ }
218
279
  /**
219
280
  * 插件结构化工具 → SDK ToolDefinition。
220
281
  * execute 返回值宽容处理:{content,details} 原样收编;字符串/对象包成文本块。
@@ -868,6 +929,7 @@ export class ClientSession {
868
929
  const active = sess.getActiveToolNames();
869
930
  const snippets = {};
870
931
  const guidelines = [];
932
+ const schemaEntries = [];
871
933
  for (const name of active) {
872
934
  const def = sess.getToolDefinition(name);
873
935
  if (!def)
@@ -876,6 +938,11 @@ export class ClientSession {
876
938
  snippets[name] = def.promptSnippet;
877
939
  if (def.promptGuidelines)
878
940
  guidelines.push(...def.promptGuidelines);
941
+ schemaEntries.push({
942
+ name,
943
+ description: def.description,
944
+ parameters: def.parameters,
945
+ });
879
946
  }
880
947
  const loader = sess.resourceLoader;
881
948
  const texts = resolveSectionTexts(this.composeInputs({
@@ -895,7 +962,7 @@ export class ClientSession {
895
962
  const ovs = this.settingsSvc.current.promptOverrides ?? {};
896
963
  const hasOverride = Object.values(ovs).some((v) => typeof v === "string" && v.trim());
897
964
  const rendered = !tpl && !hasOverride ? undefined : renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, ovs);
898
- return { texts, full: rendered ?? sess.systemPrompt };
965
+ return { texts, full: rendered ?? sess.systemPrompt, toolsSchema: buildToolsSchemaText(schemaEntries) };
899
966
  }
900
967
  catch {
901
968
  // Session not ready yet.
@@ -905,7 +972,7 @@ export class ClientSession {
905
972
  /** 设置面板预览用的 host 回调(见 SettingsHost.promptSnapshot):完整生效提示词
906
973
  * + 各来源默认(自动)内容。会话未就绪时给空值,面板保持可编辑但不预览。 */
907
974
  promptSnapshot() {
908
- return this.sessionPromptSnapshot() ?? { full: "", texts: {} };
975
+ return this.sessionPromptSnapshot() ?? { full: "", texts: {}, toolsSchema: "" };
909
976
  }
910
977
  /** Web-facing extension UI context (widgets, notifications). */
911
978
  webUi = new WebUIContext((msg) => this.emit(msg));
@@ -1000,6 +1067,14 @@ export class ClientSession {
1000
1067
  subagentTemplates;
1001
1068
  /** 内置标记服务(todo/notify/svc/rename 等,可全局/分组开关)。 */
1002
1069
  markerSvc;
1070
+ // -----------------------------------------------------------------------
1071
+ // 用户提问桥(标准 pi 引擎的 ask_user_question customTool):与 DSH 引擎的
1072
+ // question_pending/question_answer 同协议。模型调 ask_user_question 工具 →
1073
+ // 本桥发 question_pending 给浏览器 → 等 question_answer → resolve/reject
1074
+ // 工具结果(agent 循环阻塞)。一次只展示一个提问(agent 阻塞在工具执行)。
1075
+ // -----------------------------------------------------------------------
1076
+ questionSeq = 0;
1077
+ pendingQuestions = new Map();
1003
1078
  constructor(clientId, cwd, agentDir, stateStore) {
1004
1079
  this.clientId = clientId;
1005
1080
  this.cwd = cwd;
@@ -1276,6 +1351,10 @@ export class ClientSession {
1276
1351
  ...makeSubagentTools(this.subagentHost),
1277
1352
  // 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
1278
1353
  makeMarkersListTool(() => this.activeId, this.markerSvc),
1354
+ // 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
1355
+ // 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
1356
+ // DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
1357
+ makeAskUserQuestionTool(this),
1279
1358
  ],
1280
1359
  });
1281
1360
  // 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
@@ -2168,6 +2247,55 @@ export class ClientSession {
2168
2247
  resolveDialog(id, value) {
2169
2248
  this.webUi.resolveDialog(id, value);
2170
2249
  }
2250
+ // -----------------------------------------------------------------------
2251
+ // 用户提问桥(标准 pi 引擎 ask_user_question customTool)
2252
+ // -----------------------------------------------------------------------
2253
+ /** 标准引擎模型调 ask_user_question:发 question_pending 给浏览器并阻塞等待
2254
+ * question_answer。sig 为工具执行信号的当前状态(aborted → 立即 reject)。
2255
+ * 返回 answers(用户选中/自定义),或 null(用户取消)。 */
2256
+ askUser(questions, sig) {
2257
+ return new Promise((resolve, reject) => {
2258
+ if (sig?.aborted || this.disposed) {
2259
+ reject(new Error("ask_user_question 已中止"));
2260
+ return;
2261
+ }
2262
+ // 问卷开关(默认开):关 → 不弹对话框,立即报错让模型得知已禁用。
2263
+ if (this.settingsSvc.current.questionnaireEnabled === false) {
2264
+ reject(new Error("问卷功能已关闭,可在设置中重新开启"));
2265
+ return;
2266
+ }
2267
+ const id = `q-${++this.questionSeq}`;
2268
+ this.pendingQuestions.set(id, resolve);
2269
+ this.emit({
2270
+ type: "question_pending",
2271
+ id,
2272
+ questions,
2273
+ });
2274
+ });
2275
+ }
2276
+ /** 前端回答模型提问(question_answer → 恢复 askUser 的 Promise)。id 需匹配
2277
+ * pendingQuestions 中键;cancelled 或未匹配(例如用户早已切走)时按「取消」处理
2278
+ * —— 把挂起的提问全部 reject,让模型知道用户离开了。 */
2279
+ resolveQuestion(id, answers, cancelled) {
2280
+ const resolve = this.pendingQuestions.get(id);
2281
+ if (resolve) {
2282
+ this.pendingQuestions.delete(id);
2283
+ resolve(cancelled ? null : answers);
2284
+ }
2285
+ }
2286
+ /** 标准引擎的 question_answer 路由入口(index.ts 经 cs.answerQuestion?. 转发)。
2287
+ * DSH 引擎的 AgentService 也实现了同名方法,此处为 ClientSession 的转发。 */
2288
+ answerQuestion(id, answers, cancelled) {
2289
+ this.resolveQuestion(id, answers, cancelled);
2290
+ return Promise.resolve();
2291
+ }
2292
+ /** 关闭所有挂起提问(切对话 / dispose 时清理):以「取消」解析,避免模型挂死。 */
2293
+ cancelPendingQuestions() {
2294
+ for (const [, resolve] of this.pendingQuestions) {
2295
+ resolve(null);
2296
+ }
2297
+ this.pendingQuestions.clear();
2298
+ }
2171
2299
  /**
2172
2300
  * Whether the pi agent has at least one usable model. ModelRuntime's
2173
2301
  * available snapshot already accounts for models.json, auth.json, env-var
@@ -2188,65 +2316,32 @@ export class ClientSession {
2188
2316
  * probe). Cached machine-wide (same binary for every client) for 10s —
2189
2317
  * the check is only rerun after install or when the cache expires.
2190
2318
  *
2191
- * The probe runs ASYNCHRONOUSLY in the background: this getter serves the
2192
- * last known value and never blocks the event loop. It previously used
2193
- * spawnSync here, which could deadlock the whole server on Android/Termux:
2194
- * fork() inside a multi-threaded process (websocket handlers + snapshot
2195
- * serialization are active) occasionally left the forked child stuck
2196
- * between fork and exec (futex_wait) while the blocked main thread sat in
2197
- * spawnSync's pipe_read — the server kept running but stopped accepting
2198
- * connections. Observed reproducibly on Android/Termux (Node 26).
2319
+ * The probe is FORK-FREE: it scans PATH for the pi executable instead of
2320
+ * spawning `pi --version`. Do not reintroduce a spawn here ANY fork on
2321
+ * the main thread of this multi-threaded server can deadlock the whole
2322
+ * process on Android/Termux (issue #78): libuv's uv_spawn blocks its
2323
+ * caller reading the child's error pipe, and that pipe never closes when
2324
+ * the forked child deadlocks between fork and exec. This applies to
2325
+ * asynchronous spawns too — the previous async probe reproduced the hang.
2199
2326
  */
2200
2327
  static piCliProbe = null;
2201
- static piCliProbePending = false;
2202
2328
  static PI_CLI_PROBE_TTL_MS = 10_000;
2203
2329
  isPiCliInstalled() {
2204
2330
  const now = Date.now();
2205
2331
  const cached = ClientSession.piCliProbe;
2206
2332
  if (cached && now - cached.at < ClientSession.PI_CLI_PROBE_TTL_MS)
2207
2333
  return cached.installed;
2208
- ClientSession.refreshPiCliProbe();
2209
- return cached?.installed ?? false;
2334
+ const installed = ClientSession.piCliOnPath();
2335
+ ClientSession.piCliProbe = { at: now, installed };
2336
+ return installed;
2210
2337
  }
2211
- static refreshPiCliProbe() {
2212
- if (ClientSession.piCliProbePending)
2213
- return;
2214
- ClientSession.piCliProbePending = true;
2215
- let proc;
2216
- try {
2217
- proc = spawn("pi", ["--version"], {
2218
- stdio: "ignore",
2219
- // Windows: `pi` resolves to a pi.cmd shim — spawn can only
2220
- // exec those through a shell (else ENOENT).
2221
- shell: process.platform === "win32",
2222
- });
2223
- }
2224
- catch {
2225
- ClientSession.piCliProbe = { at: Date.now(), installed: false };
2226
- ClientSession.piCliProbePending = false;
2227
- return;
2338
+ static piCliOnPath() {
2339
+ const dirs = (process.env.PATH ?? "").split(delimiter);
2340
+ for (const dir of dirs) {
2341
+ if (dir && existsSync(join(dir, "pi")))
2342
+ return true;
2228
2343
  }
2229
- const finish = (installed) => {
2230
- ClientSession.piCliProbe = { at: Date.now(), installed };
2231
- ClientSession.piCliProbePending = false;
2232
- };
2233
- const timer = setTimeout(() => {
2234
- try {
2235
- proc.kill();
2236
- }
2237
- catch {
2238
- /* already exited */
2239
- }
2240
- finish(false);
2241
- }, 5000);
2242
- proc.on("error", () => {
2243
- clearTimeout(timer);
2244
- finish(false);
2245
- });
2246
- proc.on("close", (code) => {
2247
- clearTimeout(timer);
2248
- finish(code === 0);
2249
- });
2344
+ return false;
2250
2345
  }
2251
2346
  static invalidatePiCliProbe() {
2252
2347
  ClientSession.piCliProbe = null;
@@ -4327,6 +4422,8 @@ export class ClientSession {
4327
4422
  this.files.unwatchDir();
4328
4423
  this.files.unwatchGit();
4329
4424
  this.webUi.dispose();
4425
+ // 关闭所有挂起的用户提问(dispose 时以「取消」解析,避免模型挂死)。
4426
+ this.cancelPendingQuestions();
4330
4427
  this.bg.stop();
4331
4428
  for (const conv of this.convs.values()) {
4332
4429
  this.clearAllToolWatchdogs(conv);
@@ -200,10 +200,11 @@ export class ClientStateStore {
200
200
  promptOverrides,
201
201
  disabledSkills: stored?.disabledSkills ?? [],
202
202
  disabledExtensions: stored?.disabledExtensions ?? [],
203
- terminalToolsEnabled: stored?.terminalToolsEnabled ?? true,
203
+ terminalToolsEnabled: stored?.terminalToolsEnabled ?? false,
204
204
  terminalBash: stored?.terminalBash ?? false,
205
205
  terminalBashIdleMs: stored?.terminalBashIdleMs ?? 15_000,
206
206
  editSoftEnabled: stored?.editSoftEnabled ?? false,
207
+ questionnaireEnabled: stored?.questionnaireEnabled ?? true,
207
208
  thinkingWrap: stored?.thinkingWrap ?? false,
208
209
  toolsWrap: stored?.toolsWrap ?? true,
209
210
  visionBridgeEnabled: stored?.visionBridgeEnabled ?? true,
@@ -230,10 +231,11 @@ export class ClientStateStore {
230
231
  promptOverrides: { ...(settings.promptOverrides ?? cur.promptOverrides) },
231
232
  disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
232
233
  disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
233
- terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? true,
234
+ terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? false,
234
235
  terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
235
236
  terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
236
237
  editSoftEnabled: settings.editSoftEnabled ?? cur.editSoftEnabled ?? false,
238
+ questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
237
239
  thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
238
240
  toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
239
241
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
@@ -104,10 +104,11 @@ const DEFAULT_SETTINGS = {
104
104
  customSystemPrompt: "",
105
105
  disabledSkills: [],
106
106
  disabledExtensions: [],
107
- terminalToolsEnabled: true,
107
+ terminalToolsEnabled: false,
108
108
  terminalBash: false,
109
109
  terminalBashIdleMs: 15_000,
110
110
  editSoftEnabled: false,
111
+ questionnaireEnabled: true,
111
112
  thinkingWrap: false,
112
113
  toolsWrap: true,
113
114
  disabledPlugins: [],
@@ -221,6 +222,7 @@ export class DshClientSession {
221
222
  terminalBash: savedSettings.terminalBash,
222
223
  terminalBashIdleMs: savedSettings.terminalBashIdleMs,
223
224
  editSoftEnabled: savedSettings.editSoftEnabled,
225
+ questionnaireEnabled: savedSettings.questionnaireEnabled ?? true,
224
226
  thinkingWrap: savedSettings.thinkingWrap,
225
227
  toolsWrap: savedSettings.toolsWrap,
226
228
  disabledPlugins: savedSettings.disabledPlugins ?? [],
@@ -443,6 +445,11 @@ export class DshClientSession {
443
445
  else if (method === "question.pending") {
444
446
  // 模型 ask_user_question → 转发给浏览器对话框(deadline = 服务端超时时间戳)。
445
447
  const params0 = params;
448
+ // 问卷开关(默认开):关 → 不弹框,立即取消让模型得知已禁用。
449
+ if (this.settings.questionnaireEnabled === false) {
450
+ void this.answerQuestion(params0.id, [], true);
451
+ return;
452
+ }
446
453
  this.emit({
447
454
  type: "question_pending",
448
455
  id: params0.id,
@@ -461,6 +468,7 @@ export class DshClientSession {
461
468
  options: q.options.map((o) => ({
462
469
  label: String(o.label ?? ""),
463
470
  ...(typeof o.description === "string" ? { description: o.description } : {}),
471
+ ...(typeof o.preview === "string" ? { preview: o.preview } : {}),
464
472
  })),
465
473
  }
466
474
  : {}),
@@ -2060,6 +2068,7 @@ export class DshClientSession {
2060
2068
  terminalBash: this.settings.terminalBash,
2061
2069
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2062
2070
  editSoftEnabled: this.settings.editSoftEnabled,
2071
+ questionnaireEnabled: this.settings.questionnaireEnabled,
2063
2072
  thinkingWrap: this.settings.thinkingWrap,
2064
2073
  toolsWrap: this.settings.toolsWrap,
2065
2074
  visionBridgeEnabled: false,
@@ -2073,6 +2082,8 @@ export class DshClientSession {
2073
2082
  promptOverrides: {},
2074
2083
  effectiveSystemPrompt: this.settings.customSystemPrompt,
2075
2084
  promptSourceDefaults: {},
2085
+ // DSH 引擎不接标准 pi 的 customTool 工具 schema(走 goal-rpc),此处给空。
2086
+ toolsSchema: "",
2076
2087
  visionBridgeDefaultPrompt: "",
2077
2088
  visionModels: [],
2078
2089
  skills: this.skillsCache,
@@ -2108,6 +2119,8 @@ export class DshClientSession {
2108
2119
  this.settings.terminalBashIdleMs = partial.terminalBashIdleMs;
2109
2120
  if (partial.editSoftEnabled !== undefined)
2110
2121
  this.settings.editSoftEnabled = partial.editSoftEnabled;
2122
+ if (partial.questionnaireEnabled !== undefined)
2123
+ this.settings.questionnaireEnabled = partial.questionnaireEnabled;
2111
2124
  if (partial.thinkingWrap !== undefined)
2112
2125
  this.settings.thinkingWrap = partial.thinkingWrap;
2113
2126
  if (partial.toolsWrap !== undefined)
@@ -2135,6 +2148,7 @@ export class DshClientSession {
2135
2148
  terminalBash: this.settings.terminalBash,
2136
2149
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2137
2150
  editSoftEnabled: this.settings.editSoftEnabled,
2151
+ questionnaireEnabled: this.settings.questionnaireEnabled,
2138
2152
  thinkingWrap: this.settings.thinkingWrap,
2139
2153
  toolsWrap: this.settings.toolsWrap,
2140
2154
  disabledPlugins: this.settings.disabledPlugins,
@@ -66,18 +66,24 @@ export async function resolveRuntimeBase() {
66
66
  const base = runtimeBaseFor(adjacent);
67
67
  if (base) return base;
68
68
  }
69
+ // PATCHED (pi-web-ui #78 / Android-Termux deadlock): this used to run
70
+ // spawnSync("npm", ["root", "-g"]) as a last resort — a synchronous fork
71
+ // inside a multi-threaded process, which can deadlock on Android/Termux
72
+ // (child stuck between fork and exec while the caller blocks on libuv's
73
+ // spawn error pipe). Compute the npm global root without spawning instead.
69
74
  try {
70
- const { spawnSync } = await import("node:child_process");
71
- const res = spawnSync(process.platform === "win32" ? "npm" : "npm", ["root", "-g"], {
72
- encoding: "utf8",
73
- timeout: 15_000,
74
- windowsHide: true,
75
- ...(process.platform === "win32" ? { shell: true } : {}),
76
- });
77
- const root = String(res.stdout ?? "").trim();
78
- if (root) {
79
- const base = runtimeBaseFor(root);
80
- if (base) return base;
75
+ const prefixCandidates = [
76
+ process.env.NPM_CONFIG_PREFIX,
77
+ process.env.npm_config_prefix,
78
+ join(dirname(process.execPath), ".."),
79
+ ];
80
+ for (const prefix of prefixCandidates) {
81
+ if (!prefix) continue;
82
+ for (const root of [join(prefix, "lib", "node_modules"), join(prefix, "node_modules")]) {
83
+ if (!existsSync(root)) continue;
84
+ const base = runtimeBaseFor(root);
85
+ if (base) return base;
86
+ }
81
87
  }
82
88
  } catch {
83
89
  /* fall through */
@@ -16,7 +16,7 @@
16
16
  * all share one conversation list per project.
17
17
  * PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
18
18
  */
19
- import { existsSync } from "node:fs";
19
+ import { existsSync, readFileSync } from "node:fs";
20
20
  import { stat } from "node:fs/promises";
21
21
  import { createServer } from "node:http";
22
22
  import { createConnection } from "node:net";
@@ -36,6 +36,7 @@ import { startControlServer } from "./control-socket.js";
36
36
  import { scheduleUploadCleanup } from "./uploads.js";
37
37
  import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
38
38
  import { listThemes, resolveThemeFile } from "./themes.js";
39
+ import { installPack, isKnownPack, listPacks, readPackFile, removePack } from "./locales.js";
39
40
  import { PluginManager, resolvePluginClientFile, } from "./plugins.js";
40
41
  import { McpBridge } from "./mcp-bridge.js";
41
42
  /** 从 CLI 参数中取 flag 值:支持 --flag value 与 --flag=value 两种写法。
@@ -76,6 +77,19 @@ const ALLOW_ORIGINS = (process.env.PI_WEB_ALLOW_ORIGINS ?? "")
76
77
  * Authorization: Bearer / X-PI-Token 头、?token= 查询参数或 pi_web_token cookie
77
78
  * 任一匹配即可;供 0.0.0.0 / 反代等暴露场景兜底,未设置则行为不变。 */
78
79
  const AUTH_TOKEN = process.env.PI_WEB_TOKEN?.trim() ?? "";
80
+ /** 语言包下载根(语言包仓库的 raw 文件地址;版本 tag 优先、main 兜底,见 locales.ts)。 */
81
+ const LOCALE_BASE_URL = process.env.PI_WEB_LOCALE_BASE_URL?.trim() || "https://raw.githubusercontent.com/xing-shuyin/pi-web-ui";
82
+ /** 本包版本 —— 下载语言包时优先取同版本 tag,保证 key 对齐。 */
83
+ const APP_VERSION = (() => {
84
+ try {
85
+ // 注意:此处不能用下面的 pkgRoot 常量(TDZ)——直接调函数声明(已提升)。
86
+ const pkg = JSON.parse(readFileSync(join(resolvePkgRoot(), "package.json"), "utf8"));
87
+ return pkg.version ?? "";
88
+ }
89
+ catch {
90
+ return "";
91
+ }
92
+ })();
79
93
  // Root of the SDK default per-project session dirs — chat transcripts live in
80
94
  // <SESSION_DIR_ROOT>/--<cwd>--/, shared with the pi CLI/TUI (getAgentDir
81
95
  // honors PI_CODING_AGENT_DIR).
@@ -252,6 +266,51 @@ const USER_THEMES_DIR = join(DATA_DIR, "themes");
252
266
  app.get("/api/themes", (_req, res) => {
253
267
  res.json({ themes: listThemes(BUILTIN_THEMES_DIR, USER_THEMES_DIR) });
254
268
  });
269
+ // 语言包:核心只随包发布中英,其余按需下载到 <dataDir>/locales/<code>.json。
270
+ // 手工放进去的同名 JSON 也会被识别(离线安装)。PI_WEB_TOKEN 鉴权自动覆盖。
271
+ app.get("/api/locales", (_req, res) => {
272
+ res.json({ packs: listPacks(DATA_DIR) });
273
+ });
274
+ app.get("/api/locales/:code", (req, res) => {
275
+ const code = String(req.params.code ?? "");
276
+ if (!isKnownPack(code)) {
277
+ res.status(404).end("unknown locale");
278
+ return;
279
+ }
280
+ const pack = readPackFile(DATA_DIR, code);
281
+ if (!pack) {
282
+ res.status(404).end("locale not installed");
283
+ return;
284
+ }
285
+ res.setHeader("Cache-Control", "no-cache");
286
+ res.json(pack);
287
+ });
288
+ app.post("/api/locales/:code/install", async (req, res) => {
289
+ const code = String(req.params.code ?? "");
290
+ if (!isKnownPack(code)) {
291
+ res.status(400).json({ error: `unknown locale: ${code}` });
292
+ return;
293
+ }
294
+ try {
295
+ const meta = await installPack(DATA_DIR, code, { baseUrl: LOCALE_BASE_URL, version: APP_VERSION });
296
+ res.json({ ok: true, ...meta });
297
+ }
298
+ catch (e) {
299
+ res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
300
+ }
301
+ });
302
+ app.delete("/api/locales/:code", (req, res) => {
303
+ const code = String(req.params.code ?? "");
304
+ if (!isKnownPack(code)) {
305
+ res.status(404).end("unknown locale");
306
+ return;
307
+ }
308
+ if (!removePack(DATA_DIR, code)) {
309
+ res.status(404).end("locale not installed");
310
+ return;
311
+ }
312
+ res.json({ ok: true });
313
+ });
255
314
  // Serve a theme's full CSS file so the frontend can swap the whole stylesheet.
256
315
  // Registered before the SPA catch-all below (otherwise it'd return index.html).
257
316
  app.get("/themes/:id.css", (req, res) => {
@@ -826,6 +885,7 @@ wss.on("connection", (ws) => {
826
885
  terminalBash: msg.terminalBash,
827
886
  terminalBashIdleMs: msg.terminalBashIdleMs,
828
887
  editSoftEnabled: msg.editSoftEnabled,
888
+ questionnaireEnabled: msg.questionnaireEnabled,
829
889
  thinkingWrap: msg.thinkingWrap,
830
890
  toolsWrap: msg.toolsWrap,
831
891
  visionBridgeEnabled: msg.visionBridgeEnabled,