pi-web-ui 0.64.0 → 0.64.1

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.
Files changed (44) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +504 -504
  3. package/README.zh-CN.md +427 -427
  4. package/bin/pi-web-ui.mjs +1809 -1809
  5. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
  6. package/deploy/nginx-subpath.conf +88 -88
  7. package/deploy/pi-web-ui-task.xml +54 -54
  8. package/deploy/pi-web-ui.service +31 -31
  9. package/dist/server/agent-service.js +45 -22
  10. package/dist/server/attachments.js +3 -3
  11. package/dist/server/dsh/dsh-agent-service.js +27 -7
  12. package/dist/server/dsh/runtime/cordis.yml +1 -1
  13. package/dist/server/dsh/runtime/goal-rpc.mjs +645 -645
  14. package/dist/server/dsh/runtime/launcher.mjs +164 -164
  15. package/dist/server/dsh/runtime/override.patch.yml +71 -71
  16. package/dist/server/dsh/runtime/runtime-root.mjs +86 -86
  17. package/dist/server/files-service.js +6 -6
  18. package/dist/server/goal-service.js +4 -1
  19. package/dist/server/index.js +2 -2
  20. package/dist/server/marker-service.js +8 -3
  21. package/dist/server/markers/builtins/rename.js +3 -3
  22. package/dist/server/model-admin.js +12 -6
  23. package/dist/server/plugins.js +4 -4
  24. package/dist/server/slash-commands.js +3 -0
  25. package/dist/server/terminals.js +25 -15
  26. package/dist/server/vision-bridge.js +9 -9
  27. package/dist/server/webui-context.js +2 -2
  28. package/extensions/webui.ts +190 -190
  29. package/package.json +109 -109
  30. package/themes/cyberpunk.css +81 -81
  31. package/themes/dazzle.css +81 -81
  32. package/themes/md-preview.css +98 -98
  33. package/themes/white.css +160 -160
  34. package/web/dist/assets/TerminalPanel-C5VIdPmn.js +6 -0
  35. package/web/dist/assets/{index-_1vyEugI.js → index-BmDs6ukf.js} +19 -19
  36. package/web/dist/assets/{index-CMgDryBL.css → index-DVnuQrK5.css} +1 -1
  37. package/web/dist/favicon.svg +8 -8
  38. package/web/dist/index.html +21 -21
  39. package/web/dist/manifest.webmanifest +50 -50
  40. package/web/dist/sw.js +126 -126
  41. package/web/public/favicon.svg +8 -8
  42. package/web/public/manifest.webmanifest +50 -50
  43. package/web/public/sw.js +126 -126
  44. package/web/dist/assets/TerminalPanel-DNkAT34Z.js +0 -2
@@ -657,14 +657,14 @@ export class FilesService {
657
657
  * for the target dir (the recursive watcher may not cover it on posix).
658
658
  */
659
659
  async uploadFile(relDir, name, data) {
660
- const emitErr = (text) => this.host.emit({ type: "notice", level: "error", text });
660
+ const emitErr = (text, textEn) => this.host.emit({ type: "notice", level: "error", text, textEn });
661
661
  try {
662
662
  const root = this.host.getCwd();
663
663
  let wp;
664
664
  if (relDir) {
665
665
  wp = workspacePath(resolve(root), relDir);
666
666
  if (!wp) {
667
- emitErr(`路径超出工作区:${relDir}`);
667
+ emitErr(`路径超出工作区:${relDir}`, `Path outside workspace: ${relDir}`);
668
668
  return;
669
669
  }
670
670
  }
@@ -678,16 +678,16 @@ export class FilesService {
678
678
  const abs = resolve(wp.abs, safe);
679
679
  const rawRel = relative(root, abs);
680
680
  if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)) {
681
- emitErr(`文件名不合法:${name}`);
681
+ emitErr(`文件名不合法:${name}`, `Invalid file name: ${name}`);
682
682
  return;
683
683
  }
684
684
  const buf = Buffer.from(data, "base64");
685
685
  if (buf.length === 0) {
686
- emitErr(`空文件:${name}`);
686
+ emitErr(`空文件:${name}`, `Empty file: ${name}`);
687
687
  return;
688
688
  }
689
689
  if (buf.length > MAX_UPLOAD_BYTES) {
690
- emitErr(`文件过大:${name}(上限 ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`);
690
+ emitErr(`文件过大:${name}(上限 ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`, `File too large: ${name} (max ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`);
691
691
  return;
692
692
  }
693
693
  mkdirSync(wp.abs, { recursive: true });
@@ -707,7 +707,7 @@ export class FilesService {
707
707
  });
708
708
  }
709
709
  catch (err) {
710
- emitErr(`上传文件失败:${err.message}`);
710
+ emitErr(`上传文件失败:${err.message}`, `Upload failed: ${err.message}`);
711
711
  }
712
712
  }
713
713
  /**
@@ -611,7 +611,10 @@ export class GoalService {
611
611
  g.status = "已手动停止,目标审查已中止";
612
612
  g.statusEn = "Stopped manually, goal review aborted";
613
613
  this.emitGoalStatus();
614
- return "⏹ 已手动停止,目标审查已中止(想继续可重新设定目标)";
614
+ return {
615
+ text: "⏹ 已手动停止,目标审查已中止(想继续可重新设定目标)",
616
+ textEn: "⏹ Stopped manually, goal review aborted (set a new goal to continue)",
617
+ };
615
618
  }
616
619
  return null;
617
620
  }
@@ -803,10 +803,10 @@ wss.on("connection", (ws) => {
803
803
  case "plugin_settings": {
804
804
  const r = pluginMgr.savePluginSettings(msg.pluginId, msg.values ?? {});
805
805
  if (r.error) {
806
- cs?.emitNotice("error", `插件设置保存失败:${r.error}`);
806
+ cs?.emitNotice("error", `插件设置保存失败:${r.error}`, `Failed to save plugin settings: ${r.error}`);
807
807
  }
808
808
  else {
809
- cs?.emitNotice("info", "插件设置已保存");
809
+ cs?.emitNotice("info", "插件设置已保存", "Plugin settings saved");
810
810
  }
811
811
  break;
812
812
  }
@@ -155,8 +155,8 @@ export class MarkerService {
155
155
  const state = getOrInit(token.tool);
156
156
  const ctx = {
157
157
  conversationId,
158
- notify: (msg, level) => {
159
- this.host.emit({ type: "notice", level: level ?? "info", text: msg });
158
+ notify: (msg, level, msgEn) => {
159
+ this.host.emit({ type: "notice", level: level ?? "info", text: msg, textEn: msgEn });
160
160
  },
161
161
  renameConversation: (title) => {
162
162
  this.host.renameConversation(conversationId, title);
@@ -183,7 +183,12 @@ export class MarkerService {
183
183
  dirty.add(token.tool);
184
184
  }
185
185
  else if (result.error) {
186
- this.host.emit({ type: "notice", level: "warning", text: `[${token.tool}] ${result.error}` });
186
+ this.host.emit({
187
+ type: "notice",
188
+ level: "warning",
189
+ text: `[${token.tool}] ${result.error}`,
190
+ textEn: `[${token.tool}] ${result.error}`,
191
+ });
187
192
  }
188
193
  }
189
194
  for (const ns of dirty) {
@@ -43,7 +43,7 @@ export const renameMarker = {
43
43
  return { applied: false, error: "当前环境不支持重命名" };
44
44
  try {
45
45
  ctx.renameConversation(title);
46
- ctx.notify(`已重命名为:${title}`, "info");
46
+ ctx.notify(`已重命名为:${title}`, "info", `Renamed to: ${title}`);
47
47
  return { applied: true, feedback: `renamed to "${title}"` };
48
48
  }
49
49
  catch (e) {
@@ -69,7 +69,7 @@ export const renameAliasMarker = {
69
69
  return { applied: false, error: "当前环境不支持重命名" };
70
70
  try {
71
71
  ctx.renameConversation(trimmed);
72
- ctx.notify(`已重命名为:${trimmed}`, "info");
72
+ ctx.notify(`已重命名为:${trimmed}`, "info", `Renamed to: ${trimmed}`);
73
73
  return { applied: true, feedback: `renamed to "${trimmed}"` };
74
74
  }
75
75
  catch (e) {
@@ -92,7 +92,7 @@ export const titleAliasMarker = {
92
92
  if (!ctx.renameConversation)
93
93
  return { applied: false, error: "当前环境不支持重命名" };
94
94
  ctx.renameConversation(title.slice(0, 80));
95
- ctx.notify(`已重命名为:${title.slice(0, 80)}`, "info");
95
+ ctx.notify(`已重命名为:${title.slice(0, 80)}`, "info", `Renamed to: ${title.slice(0, 80)}`);
96
96
  return { applied: true, feedback: `renamed` };
97
97
  },
98
98
  overlay: undefined,
@@ -575,19 +575,19 @@ export class ModelAdminService {
575
575
  */
576
576
  async cloneProvider(providerId, reqId) {
577
577
  const pid = providerId.trim();
578
- const fail = (error) => {
579
- this.host.emit({ type: "notice", level: "error", text: error });
578
+ const fail = (error, errorEn) => {
579
+ this.host.emit({ type: "notice", level: "error", text: error, textEn: errorEn });
580
580
  this.host.emit({ type: "clone_provider_result", reqId, ok: false, error });
581
581
  };
582
582
  try {
583
583
  if (!pid) {
584
- fail("请填写服务商 ID");
584
+ fail("请填写服务商 ID", "Enter a provider ID");
585
585
  return;
586
586
  }
587
587
  const mr = this.host.modelRuntime();
588
588
  const p = mr.getProvider(pid);
589
589
  if (!p) {
590
- fail(`供应商 ${pid} 不存在`);
590
+ fail(`供应商 ${pid} 不存在`, `Provider ${pid} does not exist`);
591
591
  return;
592
592
  }
593
593
  const noBaseUrl = !p.baseUrl;
@@ -617,7 +617,7 @@ export class ModelAdminService {
617
617
  models = readModels();
618
618
  }
619
619
  if (models.length === 0) {
620
- fail(`${pid} 的模型列表为空,无法复制(请稍后重试)`);
620
+ fail(`${pid} 的模型列表为空,无法复制(请稍后重试)`, `Model list for ${pid} is empty, cannot clone (retry later)`);
621
621
  return;
622
622
  }
623
623
  // 供应商级 api 取占比最高,模型保留全量去重(避免 muse-spark 被过滤)
@@ -652,11 +652,14 @@ export class ModelAdminService {
652
652
  text: noBaseUrl
653
653
  ? `📋 已复制 ${pid} → ${newId}(${kept.length} 个模型),该供应商无远程 baseUrl,已生成模板请手动填写 baseUrl 和新的 API 密钥后保存`
654
654
  : `📋 已复制 ${pid} → ${newId}(${kept.length} 个模型),请填入新的 API 密钥后保存`,
655
+ textEn: noBaseUrl
656
+ ? `📋 Cloned ${pid} → ${newId} (${kept.length} models); this provider has no remote baseUrl — template generated, fill in baseUrl and a new API key, then save`
657
+ : `📋 Cloned ${pid} → ${newId} (${kept.length} models); fill in the new API key, then save`,
655
658
  });
656
659
  this.host.emit({ type: "clone_provider_result", reqId, ok: true, config, configs: [config] });
657
660
  }
658
661
  catch (err) {
659
- fail(`复制服务商失败:${err.message}`);
662
+ fail(`复制服务商失败:${err.message}`, `Failed to clone provider: ${err.message}`);
660
663
  }
661
664
  this.host.flushSnapshot();
662
665
  }
@@ -1015,6 +1018,9 @@ export class ModelAdminService {
1015
1018
  text: added > 0
1016
1019
  ? `🔄 已刷新 ${pid}:新增 ${added} 个模型,共 ${merged.length} 个`
1017
1020
  : `🔄 已刷新 ${pid}:无新增模型(共 ${merged.length} 个)`,
1021
+ textEn: added > 0
1022
+ ? `🔄 Refreshed ${pid}: ${added} new models, ${merged.length} total`
1023
+ : `🔄 Refreshed ${pid}: no new models (${merged.length} total)`,
1018
1024
  });
1019
1025
  return done(true, { added, total: merged.length });
1020
1026
  }
@@ -308,7 +308,7 @@ export class PluginManager {
308
308
  if (prev === key)
309
309
  return; // 同版本能力清单,不再打扰
310
310
  const list = perms.length ? perms.join(", ") : "无";
311
- this.notifyAll(perms.length ? "warning" : "info", `插件「${info.name}」已激活(${prev ? "能力清单变更" : "首次安装"};声明能力:${list})——请确认来源可信`);
311
+ this.notifyAll(perms.length ? "warning" : "info", `插件「${info.name}」已激活(${prev ? "能力清单变更" : "首次安装"};声明能力:${list})——请确认来源可信`, `Plugin "${info.name}" activated (${prev ? "capability list changed" : "first install"}; declared: ${list}) — verify the source is trusted`);
312
312
  writeFileSync(markerFile, JSON.stringify({ v: 1, key, perms }), "utf8");
313
313
  }
314
314
  catch (err) {
@@ -344,8 +344,8 @@ export class PluginManager {
344
344
  this.deliverAll({ type: "plugin_data", pluginId, payload });
345
345
  }
346
346
  /** 系统通知:发给所有 socket(复用 notice 消息,前端 toast 展示)。 */
347
- notifyAll(level, text) {
348
- this.deliverAll({ type: "notice", level, text });
347
+ notifyAll(level, text, textEn) {
348
+ this.deliverAll({ type: "notice", level, text, textEn });
349
349
  }
350
350
  /** 给指定客户端定向发一条插件消息;找不到该 socket 时静默忽略。 */
351
351
  sendTo(clientId, pluginId, payload) {
@@ -708,7 +708,7 @@ export class PluginManager {
708
708
  const self = this; // 对象字面量 getter 里不能用插件宿主的 this (oxlint no-this-alias: 誤報, getter closure 需要 host)
709
709
  const host = {
710
710
  broadcast: (payload) => this.broadcast(info.id, payload),
711
- notify: (level, text) => this.notifyAll(level, text),
711
+ notify: (level, text, textEn) => this.notifyAll(level, text, textEn),
712
712
  sendTo: (clientId, payload) => this.sendTo(clientId, info.id, payload),
713
713
  onMessage: (h) => {
714
714
  handlers.add(h);
@@ -174,6 +174,9 @@ export class SlashCommandsService {
174
174
  text: current
175
175
  ? `当前模型:${current.name}(${current.provider}/${current.id})。用法:/model <名称>`
176
176
  : `用法:/model <名称>`,
177
+ textEn: current
178
+ ? `Current model: ${current.name} (${current.provider}/${current.id}). Usage: /model <name>`
179
+ : `Usage: /model <name>`,
177
180
  });
178
181
  return true;
179
182
  }
@@ -47,8 +47,8 @@ export function resolveCommandCwd(cwd, pwd) {
47
47
  /** Read the command list; missing file → empty list; malformed → empty list + warning text. */
48
48
  export async function loadCommands(workspaceRoot) {
49
49
  const path = commandsFilePath(workspaceRoot);
50
- const { commands, warning } = await readCommandsFile(path);
51
- return { commands, path, warning };
50
+ const { commands, warning, warningEn } = await readCommandsFile(path);
51
+ return { commands, path, warning, warningEn };
52
52
  }
53
53
  async function readCommandsFile(path) {
54
54
  if (!existsSync(path))
@@ -61,6 +61,7 @@ async function readCommandsFile(path) {
61
61
  return {
62
62
  commands: [],
63
63
  warning: `读取命令文件失败:${err.message}`,
64
+ warningEn: `Failed to read commands file: ${err.message}`,
64
65
  };
65
66
  }
66
67
  let parsed;
@@ -68,7 +69,11 @@ async function readCommandsFile(path) {
68
69
  parsed = JSON.parse(raw);
69
70
  }
70
71
  catch {
71
- return { commands: [], warning: `命令文件不是有效 JSON:${path}` };
72
+ return {
73
+ commands: [],
74
+ warning: `命令文件不是有效 JSON:${path}`,
75
+ warningEn: `Commands file is not valid JSON: ${path}`,
76
+ };
72
77
  }
73
78
  if (Array.isArray(parsed)) {
74
79
  // Tolerate a bare array: [{name, command, cwd}]
@@ -92,7 +97,7 @@ async function readCommandsFile(path) {
92
97
  .map((c) => ({ name: c.name, command: c.command, cwd: c.cwd })),
93
98
  };
94
99
  }
95
- return { commands: [], warning: `命令文件格式不正确:${path}` };
100
+ return { commands: [], warning: `命令文件格式不正确:${path}`, warningEn: `Commands file has a bad format: ${path}` };
96
101
  }
97
102
  /** Persist the command list, creating .pi/ if needed. */
98
103
  export async function saveCommandsFile(workspaceRoot, commands) {
@@ -104,7 +109,11 @@ export async function saveCommandsFile(workspaceRoot, commands) {
104
109
  return { path };
105
110
  }
106
111
  catch (err) {
107
- return { path, error: `保存命令文件失败:${err.message}` };
112
+ return {
113
+ path,
114
+ error: `保存命令文件失败:${err.message}`,
115
+ errorEn: `Failed to save commands file: ${err.message}`,
116
+ };
108
117
  }
109
118
  }
110
119
  const isWindows = process.platform === "win32";
@@ -278,6 +287,7 @@ export function queryTerminalOutput(output, q, running = false, exitCode = null)
278
287
  .filter((l) => {
279
288
  const t = l.trim();
280
289
  return (!/^\[pi-exit:\d+\]$/.test(t) &&
290
+ !/^\[pi-term-exit:-?\d+\]$/.test(t) &&
281
291
  !t.startsWith("[进程已退出") &&
282
292
  !t.startsWith("[Process exited") &&
283
293
  !/^\[.*(已退出|exited)/.test(t));
@@ -1173,13 +1183,13 @@ export class TerminalManager {
1173
1183
  if (!entry || entry.exited)
1174
1184
  return;
1175
1185
  this.disarmIdleWatch(entry);
1176
- // Flush queued output BEFORE the exit banner so ordering is preserved.
1186
+ // Flush queued output BEFORE the exit marker so ordering is preserved.
1187
+ // Banner text is CLIENT-rendered (live locale); the stream carries only
1188
+ // a machine sentinel the client strips and replaces.
1177
1189
  this.flushPending(entry);
1178
- const banner = entry.locale === "en"
1179
- ? `\r\n\x1b[90m[Process exited with code ${exitCode}]\x1b[0m\r\n`
1180
- : `\r\n\x1b[90m[进程已退出,退出码 ${exitCode}]\x1b[0m\r\n`;
1181
- this.appendOutput(entry, banner);
1182
- this.emit({ type: "terminal_output", terminalId: id, data: banner });
1190
+ const marker = `\r\n[pi-term-exit:${exitCode}]\r\n`;
1191
+ this.appendOutput(entry, marker);
1192
+ this.emit({ type: "terminal_output", terminalId: id, data: marker });
1183
1193
  entry.exited = true;
1184
1194
  // 终端退出 → 未命中的输出观察器以 null 回调(宿主可据此通知「终端已关闭」)。
1185
1195
  const pendingWatches = entry.watches;
@@ -1532,10 +1542,10 @@ export const TERMINAL_TOOL_NAMES = [
1532
1542
  /** System-prompt guidance teaching the model WHEN to prefer the terminal tools
1533
1543
  * over one-shot bash. Without it models almost never pick them — bash returns
1534
1544
  * complete output in a single call, so it always wins on convenience. */
1535
- export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The bash tool stays the DEFAULT for ordinary commands - it runs in a visible terminal and returns the full output (persist=false, one-shot terminal that exits when the command finishes). Switch to the bash tool's persist=true, or to the terminal tools, when:
1536
- - The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin). For these, prefer bash({ persist: true }) which runs it in the persistent 'ai-bash' terminal and returns immediately; then drive it with terminal_input / terminal_key (and terminal_read) on terminalId='ai-bash'.
1537
- - You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
1538
- - The user explicitly asks you to work in the visible terminal panel.
1545
+ export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The bash tool stays the DEFAULT for ordinary commands - it runs in a visible terminal and returns the full output (persist=false, one-shot terminal that exits when the command finishes). Switch to the bash tool's persist=true, or to the terminal tools, when:
1546
+ - The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin). For these, prefer bash({ persist: true }) which runs it in the persistent 'ai-bash' terminal and returns immediately; then drive it with terminal_input / terminal_key (and terminal_read) on terminalId='ai-bash'.
1547
+ - You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
1548
+ - The user explicitly asks you to work in the visible terminal panel.
1539
1549
  Use head/tail on bash to trim verbose output instead of piping through head/tail. Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.`;
1540
1550
  /** Build the agent-facing persistent terminal tools for one conversation. */
1541
1551
  export function makePersistentTerminalTools(terminals, cwd) {
@@ -35,15 +35,15 @@ export function findVisionModels(runtime) {
35
35
  * Exported so the settings panel can offer a custom prompt (append to this
36
36
  * default or replace it entirely).
37
37
  */
38
- export const SYSTEM_PROMPT = `You are a vision bridge for a text-only language model. You receive one or more images and must transcribe them into precise, structured text evidence so another model that cannot see images can answer questions about them accurately.
39
-
40
- Follow these rules:
41
- 1. Transcribe ALL visible text verbatim, preserving wording, spelling, punctuation and line breaks. This is the most important part — the reader relies on your transcription, not on the image.
42
- 2. Describe the layout in reading order: headers, paragraphs, lists, tables, buttons, panels — say what appears where.
43
- 3. For tables/charts/diagrams: read axes, scales (note log scale), legend entries, series names, highlighted points and their coordinates, and any data values you can discern.
44
- 4. Name entities: people, products, companies, colors, style, objects, actions.
45
- 5. If part of the image is too blurry/low-resolution to read, say "(读不清)" or "unclear" for that part — NEVER invent or guess content you cannot see.
46
- 6. If there are multiple images, address them in order (图 1 / Image 1, 图 2 / Image 2, ...).
38
+ export const SYSTEM_PROMPT = `You are a vision bridge for a text-only language model. You receive one or more images and must transcribe them into precise, structured text evidence so another model that cannot see images can answer questions about them accurately.
39
+
40
+ Follow these rules:
41
+ 1. Transcribe ALL visible text verbatim, preserving wording, spelling, punctuation and line breaks. This is the most important part — the reader relies on your transcription, not on the image.
42
+ 2. Describe the layout in reading order: headers, paragraphs, lists, tables, buttons, panels — say what appears where.
43
+ 3. For tables/charts/diagrams: read axes, scales (note log scale), legend entries, series names, highlighted points and their coordinates, and any data values you can discern.
44
+ 4. Name entities: people, products, companies, colors, style, objects, actions.
45
+ 5. If part of the image is too blurry/low-resolution to read, say "(读不清)" or "unclear" for that part — NEVER invent or guess content you cannot see.
46
+ 6. If there are multiple images, address them in order (图 1 / Image 1, 图 2 / Image 2, ...).
47
47
  7. Output only the transcript. No preamble, no commentary about the image itself.`;
48
48
  /**
49
49
  * Assemble the final vision-model system prompt from the settings-panel prefs.
@@ -115,8 +115,8 @@ export class WebUIContext {
115
115
  });
116
116
  }
117
117
  // -- notifications --------------------------------------------------------
118
- notify = (message, type) => {
119
- this.emit({ type: "notice", level: type ?? "info", text: message });
118
+ notify = (message, type, messageEn) => {
119
+ this.emit({ type: "notice", level: type ?? "info", text: message, textEn: messageEn });
120
120
  };
121
121
  // -- footer status (pi-lens "LSP Inactive", pi-cache-optimizer cache stats) --
122
122
  statuses = new Map();