pi-web-ui 0.80.0 → 0.80.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.
@@ -45,6 +45,7 @@ import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSub
45
45
  import { makeDelegateTaskTool } from "./delegate-task.js";
46
46
  import { buildAttachmentMessages } from "./attachments.js";
47
47
  import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
48
+ import { launchOrigin, toServiceInfo } from "./launch-origin.js";
48
49
  import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
49
50
  import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
50
51
  const SNAPSHOT_INTERVAL_MS = 60;
@@ -127,7 +128,9 @@ Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK
127
128
  * terminal) plus head/tail (post-processed on the returned output) so the parameter
128
129
  * schema stays consistent with the terminal-backed tool.
129
130
  */
130
- export function makeKillableBashTool(cwd, kills) {
131
+ export function makeKillableBashTool(cwd, kills,
132
+ /** per-call 返回文本的服务端语言(默认英文);工具 definition 走 bilingual 内联双语。 */
133
+ lang = () => "en") {
131
134
  const base = createLocalBashOperations();
132
135
  const tool = createBashTool(cwd, {
133
136
  operations: {
@@ -176,7 +179,7 @@ export function makeKillableBashTool(cwd, kills) {
176
179
  // head/tail 后处理(native 无终端,直接截返回行即可)。
177
180
  const p = params;
178
181
  if ((p?.head || p?.tail) && result?.content?.[0]?.text != null) {
179
- result.content[0].text = applyHeadTail(result.content[0].text, p.head, p.tail);
182
+ result.content[0].text = applyHeadTail(result.content[0].text, p.head, p.tail, lang());
180
183
  }
181
184
  return result;
182
185
  },
@@ -1408,7 +1411,9 @@ export class ClientSession {
1408
1411
  // 「默认 bash 覆盖」开关(terminalBash)关 → 原生 SDK bash(纯进程、不开终端);
1409
1412
  // 开 → 终端接管 bash(persist 决定一次性/持久,可静默自动转后台)。
1410
1413
  customTools: [
1411
- makeAdaptiveBashTool(makeKillableBashTool(effectiveCwd, this.bashKills), makeTerminalBashTool(terminals, {
1414
+ makeAdaptiveBashTool(
1415
+ // issue #91:bash 返回按客户端 UI 语言出中英(英文默认)。
1416
+ makeKillableBashTool(effectiveCwd, this.bashKills, () => this.getLang()), makeTerminalBashTool(terminals, {
1412
1417
  cwd: effectiveCwd,
1413
1418
  // 设置开 = 用终端;此分支里 persist 未显式给时默认一次性(false)。
1414
1419
  defaultPersist: () => false,
@@ -2707,6 +2712,8 @@ export class ClientSession {
2707
2712
  cwd: () => this.cwd,
2708
2713
  getSession: () => this.session,
2709
2714
  newChat: () => this.newChat(),
2715
+ // /new <prompt>: deliver the text as the new session's first prompt.
2716
+ prompt: (text) => this.prompt(text),
2710
2717
  setModel: (id) => this.setModel(id),
2711
2718
  setCwd: (path) => this.setCwd(path),
2712
2719
  setThinking: (level) => this.setThinking(level),
@@ -3528,9 +3535,13 @@ export class ClientSession {
3528
3535
  });
3529
3536
  }
3530
3537
  }
3538
+ /** 新建/切到一个空白对话。返回值 = 「当前活动对话就是一个可以接收首条的
3539
+ * 空白新对话」——/new <prompt> 只在 true 时投递首条提示;false 表示没能进入
3540
+ * 新对话(准入关闭 / 同项目对话数达上限 / runtime 创建失败),此时照发会把
3541
+ * 首条提示投进用户原本正在用的那个对话里。 */
3531
3542
  async newChat() {
3532
3543
  if (this.quiesceBlocked())
3533
- return;
3544
+ return false;
3534
3545
  // Reuse an already-open blank conversation instead of piling up new ones
3535
3546
  // on every click: if the active chat has no messages it IS the new chat
3536
3547
  // (focus already on it); otherwise switch to the first blank one (under
@@ -3548,7 +3559,7 @@ export class ClientSession {
3548
3559
  const active = this.conv;
3549
3560
  if (active && isBlank(active)) {
3550
3561
  this.flushSnapshot();
3551
- return;
3562
+ return true;
3552
3563
  }
3553
3564
  for (const conv of this.convs.values()) {
3554
3565
  if (conv.id === this.activeId)
@@ -3556,7 +3567,7 @@ export class ClientSession {
3556
3567
  if (isBlank(conv)) {
3557
3568
  await this.switchConversation(conv.id);
3558
3569
  this.flushSnapshot();
3559
- return;
3570
+ return true;
3560
3571
  }
3561
3572
  }
3562
3573
  // Cap is per project — conversations of other projects keep their own
@@ -3570,7 +3581,7 @@ export class ClientSession {
3570
3581
  text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),请先打开某个对话并离开(不继续对话)以移出列表`,
3571
3582
  textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}). Open one and leave it (without continuing) to remove it from the list.`,
3572
3583
  });
3573
- return;
3584
+ return false;
3574
3585
  }
3575
3586
  // The outgoing conversation is left behind — apply the running-list
3576
3587
  // lifecycle. Removal is deferred until the new chat exists so the active
@@ -3579,6 +3590,7 @@ export class ClientSession {
3579
3590
  // Carry the model chosen in the active chat over to the new chat so it
3580
3591
  // doesn't silently revert to the ModelRuntime default model.
3581
3592
  const prevModel = this.conv.session.agent.state.model ?? null;
3593
+ let ready = false;
3582
3594
  try {
3583
3595
  const conversationId = this.nextConversationId();
3584
3596
  const terminals = this.makeTerminalManager(conversationId, this.cwd);
@@ -3617,6 +3629,7 @@ export class ClientSession {
3617
3629
  void this.pushSlashCommands();
3618
3630
  // 新对话即当前打开 → 插件重拉(轨迹视图跟随)。
3619
3631
  this.notifyConversationChanged();
3632
+ ready = true;
3620
3633
  }
3621
3634
  catch (err) {
3622
3635
  this.emit({
@@ -3627,6 +3640,7 @@ export class ClientSession {
3627
3640
  });
3628
3641
  }
3629
3642
  this.flushSnapshot();
3643
+ return ready;
3630
3644
  }
3631
3645
  /**
3632
3646
  * The active conversation is being left (new_chat / switch_conversation /
@@ -5163,6 +5177,7 @@ export class AgentService {
5163
5177
  connectedClients: this.socketCount,
5164
5178
  activeConversations: this.activeConversations(),
5165
5179
  pendingMessages: this.pendingMessages(),
5180
+ service: toServiceInfo(launchOrigin()),
5166
5181
  };
5167
5182
  }
5168
5183
  /** Get or create the session for a client, racing attach calls safely. */
@@ -38,6 +38,7 @@ import { TerminalManager, loadCommands, saveCommandsFile } from "../terminals.js
38
38
  import { saveUpload } from "../uploads.js";
39
39
  import { checkAll as checkAllUpdates, collectTargets } from "../update-check.js";
40
40
  import { previewKind } from "../text-sniff.js";
41
+ import { launchOrigin, toServiceInfo } from "../launch-origin.js";
41
42
  import { DshRuntime, loadDeepSeekKey } from "./dsh-client.js";
42
43
  import { DshStreamAccumulator, assistantMessageEventToUiMessage, toolResultEventToUiMessage, userMessageEventToUiMessage, } from "./dsh-serialize.js";
43
44
  import { firstUserText, findSessionFilesForCwd, readSessionLog, replayEventsToMessages } from "./dsh-sessions.js";
@@ -1137,13 +1138,15 @@ export class DshClientSession {
1137
1138
  }
1138
1139
  this.emit({ type: "conversations", conversations: list, activeId: this.activeId });
1139
1140
  }
1141
+ /** 语义同 pi 引擎的 newChat:true = 当前活动对话是可接收首条的空白新对话
1142
+ * (/new <prompt> 靠它决定要不要把首条提示发出去)。 */
1140
1143
  async newChat() {
1141
1144
  if (this.quiesceBlocked())
1142
- return;
1145
+ return false;
1143
1146
  const active = this.conv;
1144
1147
  if (active.messages.length === 0 && active.terminals.list().length === 0) {
1145
1148
  this.flushSnapshot();
1146
- return;
1149
+ return true;
1147
1150
  }
1148
1151
  for (const conv of this.convs.values()) {
1149
1152
  if (conv.id === this.activeId)
@@ -1151,7 +1154,7 @@ export class DshClientSession {
1151
1154
  if (conv.messages.length === 0) {
1152
1155
  this.switchConversation(conv.id);
1153
1156
  this.flushSnapshot();
1154
- return;
1157
+ return true;
1155
1158
  }
1156
1159
  }
1157
1160
  const openInProject = [...this.convs.values()].filter((c) => c.cwd === this.cwd).length;
@@ -1162,7 +1165,7 @@ export class DshClientSession {
1162
1165
  text: `当前项目运行的对话已达上限(${MAX_OPEN_CONVERSATIONS} 个)`,
1163
1166
  textEn: `This project already has the max open conversations (${MAX_OPEN_CONVERSATIONS}).`,
1164
1167
  });
1165
- return;
1168
+ return false;
1166
1169
  }
1167
1170
  // 旧对话保留(listed 生命周期简化:不主动移除)。
1168
1171
  const prevModel = this.model;
@@ -1174,6 +1177,7 @@ export class DshClientSession {
1174
1177
  this.emitGoalStatus();
1175
1178
  this.pushTerminals();
1176
1179
  this.flushSnapshot();
1180
+ return true;
1177
1181
  }
1178
1182
  async switchConversation(id) {
1179
1183
  if (!this.convs.has(id) || id === this.activeId)
@@ -2854,9 +2858,14 @@ export class DshClientSession {
2854
2858
  /** 拦截执行斜杠命令;返回 true 表示已处理(不发给模型)。 */
2855
2859
  async execSlash(name, args) {
2856
2860
  switch (name) {
2857
- case "new":
2858
- await this.newChat();
2861
+ case "new": {
2862
+ const first = args.trim();
2863
+ // /new <prompt>:与 pi 引擎一致(共用 NATIVE_COMMANDS 的提示词),
2864
+ // 仅当真的落在空白新对话上才投递首条提示,否则会把提示误发进当前对话。
2865
+ if ((await this.newChat()) && first)
2866
+ await this.prompt(first);
2859
2867
  return true;
2868
+ }
2860
2869
  case "model": {
2861
2870
  if (!args.trim()) {
2862
2871
  this.emit({
@@ -3421,6 +3430,7 @@ export class DshAgentService {
3421
3430
  connectedClients: this.socketCount,
3422
3431
  activeConversations: this.activeConversations(),
3423
3432
  pendingMessages: this.pendingMessages(),
3433
+ service: toServiceInfo(launchOrigin()),
3424
3434
  };
3425
3435
  }
3426
3436
  async attach(clientId, send) {
@@ -37,6 +37,7 @@ import { scheduleUploadCleanup } from "./uploads.js";
37
37
  import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
38
38
  import { listThemes, resolveThemeFile } from "./themes.js";
39
39
  import { isManaged, managedRefusal } from "./managed.js";
40
+ import { launchOrigin, toServiceInfo } from "./launch-origin.js";
40
41
  import { parseTabs, tabsRefusal } from "./tabs.js";
41
42
  import { installPack, isKnownPack, listPacks, loadServerStrings, readPackFile, removePack, unloadServerStrings, } from "./locales.js";
42
43
  import { PluginManager, resolvePluginClientFile, } from "./plugins.js";
@@ -194,6 +195,13 @@ if (AUTH_TOKEN) {
194
195
  const ENGINE = process.env.PI_WEB_ENGINE === "dsh" ? "dsh" : "pi";
195
196
  /** PI_WEB_MANAGED=1: this instance is updated by whoever deploys it. */
196
197
  const MANAGED = isManaged();
198
+ /** Who started this process: a platform service manager (launchd / systemd /
199
+ * Windows watchdog — i.e. `pi-web-ui server start|install`) or nothing
200
+ * (foreground / dev / Docker). Decides whether the UPDATE panel offers
201
+ * "restart service" and what quitting means (see scheduleQuit). */
202
+ const ORIGIN = launchOrigin();
203
+ /** 下发给浏览器的服务信息(null = 没有 supervisor)。 */
204
+ const SERVICE_INFO = toServiceInfo(ORIGIN);
197
205
  /** PI_WEB_TABS: the tabs this instance offers. null = all of them, as before. */
198
206
  const TABS = parseTabs();
199
207
  app.get("/api/health", (_req, res) => {
@@ -665,8 +673,12 @@ service.onClientCwdChanged = (cwd) => pluginMgr.notifyCwd(cwd);
665
673
  function scheduleQuit() {
666
674
  const isLaunchd = process.platform === "darwin" && process.ppid === 1;
667
675
  const isSystemd = process.platform === "linux" && !!process.env.INVOCATION_ID;
676
+ // Windows `server install` runs the server under the powershell watchdog
677
+ // launcher (`while ($true) { node …; Start-Sleep 10 }`) — exiting brings it
678
+ // back within ~10s, same contract as launchd/systemd (see launch-origin.ts).
679
+ const isWinWatchdog = ORIGIN.supervisor === "windows-watchdog";
668
680
  const inDocker = existsSync("/.dockerenv");
669
- if (isLaunchd || isSystemd || inDocker) {
681
+ if (isLaunchd || isSystemd || isWinWatchdog || inDocker) {
670
682
  setTimeout(() => {
671
683
  console.log("pi-web-ui:quit — shutting down (supervisor will restart)…");
672
684
  if (isSystemd)
@@ -922,6 +934,30 @@ wss.on("connection", (ws) => {
922
934
  case "check_updates_all":
923
935
  void cs.checkUpdatesAll(msg.force === true);
924
936
  break;
937
+ case "restart_service": {
938
+ // Same effect as `pi-web-ui server restart`: this process exits and its
939
+ // supervisor brings it back (launchd/systemd immediately, the Windows
940
+ // watchdog within ~10s). Refused without a supervisor — exiting there
941
+ // would just stop the server the user is looking at.
942
+ if (!ORIGIN.supervisor) {
943
+ send({
944
+ type: "notice",
945
+ level: "error",
946
+ text: "当前实例不是由 pi-web-ui 服务启动的(前台运行),无法自动重启;请在终端里重启,或先用 pi-web-ui server install 安装服务。",
947
+ textEn: "This instance runs in the foreground, not as a pi-web-ui service — nothing would bring it back. Restart it in its terminal, or install the service with `pi-web-ui server install`.",
948
+ });
949
+ break;
950
+ }
951
+ send({
952
+ type: "notice",
953
+ level: "info",
954
+ text: "正在重启服务…页面会在服务恢复后自动重连。",
955
+ textEn: "Restarting the service… this page reconnects once it is back.",
956
+ });
957
+ // Let the notice (and this socket's backlog) flush before we go down.
958
+ setTimeout(() => void scheduleQuit(), 400);
959
+ break;
960
+ }
925
961
  case "dialog_response":
926
962
  cs.resolveDialog(msg.id, msg.value);
927
963
  break;
@@ -1153,6 +1189,7 @@ wss.on("connection", (ws) => {
1153
1189
  appVersion: appVersion(),
1154
1190
  managed: MANAGED,
1155
1191
  tabs: TABS ? [...TABS] : undefined,
1192
+ service: SERVICE_INFO ?? undefined,
1156
1193
  });
1157
1194
  // Plugin catalog: re-scan + activate new dirs on every attach so
1158
1195
  // freshly dropped plugins show up without a server restart.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * launch-origin — 这个进程是被谁启动的?
3
+ *
4
+ * 界面里有一处需要知道答案:更新面板的「重启服务」按钮。它只有在**退出后会被
5
+ * 自动拉起**的实例上才有意义(`pi-web-ui server start` 起的是这种),前台
6
+ * `pi-web-ui` / `npm run dev` 起的实例点它 = 把服务关掉不回来了。
7
+ *
8
+ * 判定的是「有没有 supervisor 会在本进程退出后把它拉起来」,一共三种:
9
+ * - **launchd**(macOS,`server install` 写的 plist)——launchd 给每个 job 设
10
+ * `XPC_SERVICE_NAME`,据此认出我们生成的 label(com.xingshuyin.pi-web-ui /
11
+ * com.<name>.server);
12
+ * - **systemd**(Linux,`Restart=always`)——systemd 给每个 unit 设
13
+ * `INVOCATION_ID`;unit 名从 /proc/self/cgroup 里取(`…/pi-web-ui.service`);
14
+ * - **windows-watchdog**(Windows,`server install` 写的 ps1:
15
+ * `while ($true) { node …; Start-Sleep 10 }`)——没有环境变量可依,靠 PID
16
+ * 文件反查:ps1 把自己的 PID 写进 `%APPDATA%\pi-web-ui\<name>.pid`,而 node
17
+ * 是它的直接子进程,所以 `process.ppid` 等于文件里那个 PID。同名 ps1 里必须
18
+ * 真的有 watchdog 循环 —— 桌面快捷方式的 ps1 也写同一个 PID 文件,但它
19
+ * (未安装服务时)是前台跑,退出不会回来。
20
+ *
21
+ * 新版 `server install` 另外烘焙 `PI_WEB_LAUNCHED_BY=service` +
22
+ * `PI_WEB_SERVICE_NAME=<name>`(最高优先级、最明确的一路);已装好的老服务没有
23
+ * 这两个变量,所以上面的运行时判据必须保留。
24
+ *
25
+ * Docker **刻意不算**:容器由编排/`restart:` 策略管,重启容器不是这个按钮该干的事。
26
+ *
27
+ * 纯函数(env / ppid / fs 读取全部可注入)便于单测;`launchOrigin()` 是进程级
28
+ * 缓存的一次性探测,服务端多处共用同一份结论。
29
+ */
30
+ import { readdirSync, readFileSync } from "node:fs";
31
+ import { homedir } from "node:os";
32
+ import { join } from "node:path";
33
+ /** 默认服务名(`server install` 没传 --name 时)。 */
34
+ export const DEFAULT_SERVICE_NAME = "pi-web-ui";
35
+ /** 默认 launchd label —— 与 bin/pi-web-ui.mjs 的 serviceLabel() 保持一致。 */
36
+ const LAUNCHD_LABEL_DEFAULT = "com.xingshuyin.pi-web-ui";
37
+ /** Windows 启动器目录(CLI 的 winServiceDir 同源)。 */
38
+ const WIN_SERVICE_DIR_NAME = "pi-web-ui";
39
+ function defaultListDir(dir) {
40
+ try {
41
+ return readdirSync(dir);
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
47
+ function defaultReadFile(path) {
48
+ try {
49
+ return readFileSync(path, "utf8");
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ /** Windows 启动器目录:`%APPDATA%\pi-web-ui`(CLI 的 winServiceDir)。 */
56
+ export function winServiceDir(env = process.env) {
57
+ const appData = env.APPDATA?.trim() || join(homedir(), "AppData", "Roaming");
58
+ return join(appData, WIN_SERVICE_DIR_NAME);
59
+ }
60
+ /** launchd label → 服务名;不是我们生成的 label 返回 null。 */
61
+ function nameFromLaunchdLabel(label) {
62
+ if (label === LAUNCHD_LABEL_DEFAULT)
63
+ return DEFAULT_SERVICE_NAME;
64
+ // serviceLabel(`--name foo`) = com.foo.server
65
+ const m = /^com\.(.+)\.server$/.exec(label);
66
+ return m?.[1] ?? null;
67
+ }
68
+ /** systemd cgroup 内容 → unit 名(`0::/system.slice/pi-web-ui.service`)。 */
69
+ function nameFromCgroup(content) {
70
+ const m = /([A-Za-z0-9_.@-]+)\.service/.exec(content ?? "");
71
+ return m?.[1];
72
+ }
73
+ /** `PI_WEB_LAUNCHED_BY=service` 时的 supervisor(按平台推导)。 */
74
+ function supervisorForPlatform(platform) {
75
+ if (platform === "win32")
76
+ return "windows-watchdog";
77
+ if (platform === "darwin")
78
+ return "launchd";
79
+ return "systemd";
80
+ }
81
+ /** Windows:PID 文件里的 PID === 本进程的 ppid,且同名 ps1 里确实有 watchdog 循环。 */
82
+ function detectWindows(input, env) {
83
+ const dir = input.winServiceDir ?? winServiceDir(env);
84
+ const listDir = input.listDir ?? defaultListDir;
85
+ const readFile = input.readFile ?? defaultReadFile;
86
+ const ppid = input.ppid ?? process.ppid;
87
+ for (const file of listDir(dir)) {
88
+ if (!file.endsWith(".pid"))
89
+ continue;
90
+ const raw = readFile(join(dir, file));
91
+ if (raw === null)
92
+ continue;
93
+ if (Number(raw.trim()) !== ppid)
94
+ continue;
95
+ const name = file.slice(0, -".pid".length);
96
+ const ps1 = readFile(join(dir, `${name}.ps1`));
97
+ if (ps1 && ps1.includes("while ($true)"))
98
+ return { supervisor: "windows-watchdog", name };
99
+ }
100
+ return { supervisor: null };
101
+ }
102
+ /**
103
+ * 探测启动来源。`PI_WEB_LAUNCHED_BY=service` 优先(新版 install 烘焙的),
104
+ * 否则按平台识别 supervisor 自己的运行时痕迹。
105
+ */
106
+ export function detectLaunchOrigin(input = {}) {
107
+ const env = input.env ?? process.env;
108
+ const platform = input.platform ?? process.platform;
109
+ const serviceName = env.PI_WEB_SERVICE_NAME?.trim() || undefined;
110
+ if ((env.PI_WEB_LAUNCHED_BY ?? "").trim().toLowerCase() === "service") {
111
+ return { supervisor: supervisorForPlatform(platform), name: serviceName ?? DEFAULT_SERVICE_NAME };
112
+ }
113
+ if (platform === "darwin") {
114
+ const label = env.XPC_SERVICE_NAME?.trim();
115
+ if (!label)
116
+ return { supervisor: null };
117
+ const name = nameFromLaunchdLabel(label);
118
+ return name ? { supervisor: "launchd", name } : { supervisor: null };
119
+ }
120
+ if (platform === "linux") {
121
+ if (!env.INVOCATION_ID)
122
+ return { supervisor: null };
123
+ const readCgroup = input.readCgroup ?? defaultReadCgroup;
124
+ return { supervisor: "systemd", name: serviceName ?? nameFromCgroup(readCgroup()) ?? DEFAULT_SERVICE_NAME };
125
+ }
126
+ if (platform === "win32")
127
+ return detectWindows(input, env);
128
+ return { supervisor: null };
129
+ }
130
+ function defaultReadCgroup() {
131
+ return defaultReadFile("/proc/self/cgroup");
132
+ }
133
+ let cached = null;
134
+ /** 进程级缓存的一次性探测(服务端多处共用:ready 消息 / quit 语义 / 控制 socket)。 */
135
+ export function launchOrigin() {
136
+ cached ??= detectLaunchOrigin();
137
+ return cached;
138
+ }
139
+ /** 仅测试用:清掉缓存。 */
140
+ export function resetLaunchOriginCache() {
141
+ cached = null;
142
+ }
143
+ /** 下发给浏览器的服务信息(null = 没有 supervisor,界面不提供「重启服务」)。 */
144
+ export function toServiceInfo(origin) {
145
+ if (!origin.supervisor)
146
+ return null;
147
+ return { name: origin.name ?? DEFAULT_SERVICE_NAME, supervisor: origin.supervisor };
148
+ }
@@ -3,7 +3,13 @@
3
3
  * prompt() — without this they'd be sent to the model as plain text). Keep in
4
4
  * sync with exec(). */
5
5
  export const NATIVE_COMMANDS = [
6
- { name: "new", description: "新建对话", descriptionEn: "New chat" },
6
+ {
7
+ name: "new",
8
+ description: "新建对话(可带首条提示:/new <提示>)",
9
+ descriptionEn: "New chat (optional first prompt: /new <prompt>)",
10
+ argumentHint: "[提示]",
11
+ argumentHintEn: "[prompt]",
12
+ },
7
13
  {
8
14
  name: "name",
9
15
  description: "重命名当前会话",
@@ -135,9 +141,19 @@ export class SlashCommandsService {
135
141
  * name is not a native command (the prompt falls through to the SDK). */
136
142
  async exec(name, args) {
137
143
  switch (name) {
138
- case "new":
139
- await this.host.newChat();
144
+ case "new": {
145
+ const first = args.trim();
146
+ const ready = await this.host.newChat();
147
+ // /new <prompt>: deliver the text as the new session's first
148
+ // prompt, exactly as if typed after the switch. Empty = old
149
+ // behavior (blank chat, no send). Only when the switch actually
150
+ // landed on a blank chat — newChat() reports false when it bailed
151
+ // (cap reached / runtime creation failed) and sending anyway would
152
+ // drop the text into the conversation the user was already in.
153
+ if (ready !== false && first && this.host.prompt)
154
+ await this.host.prompt(first);
140
155
  return true;
156
+ }
141
157
  case "name": {
142
158
  const trimmed = args.trim();
143
159
  if (!trimmed) {
@@ -1366,18 +1366,24 @@ let oneShotBashSeq = 0;
1366
1366
  /** 应用 head / tail 参数到输出顶层行(替代 `| head` / `| tail` 管道——管道会
1367
1367
  * 缓冲输出、让可见终端全程哑火,还容易白白触发静默解阻)。两者同时给时先
1368
1368
  * 截头再截尾。 */
1369
- export function applyHeadTail(text, head, tail) {
1369
+ export function applyHeadTail(text, head, tail, lang = "en") {
1370
1370
  // 只对真实数据行切片;省略提示行单独存,最后再包回输出,避免提示行在
1371
1371
  // head+tail 组合时被当成数据行参与第二次截取(导致尾部少截一行)。
1372
1372
  let data = text.split("\n");
1373
1373
  let headNote = null;
1374
1374
  let tailNote = null;
1375
1375
  if (head && head > 0 && data.length > head) {
1376
- headNote = `…(后 ${data.length - head} 行已省略)`;
1376
+ const n = data.length - head;
1377
+ headNote = pick(lang, `…(后 ${n} 行已省略)`, `…[${n} lines omitted below]…`, "terminals.headtail.omitted.below", {
1378
+ n,
1379
+ });
1377
1380
  data = data.slice(0, head);
1378
1381
  }
1379
1382
  if (tail && tail > 0 && data.length > tail) {
1380
- tailNote = `…(前 ${data.length - tail} 行已省略)`;
1383
+ const n = data.length - tail;
1384
+ tailNote = pick(lang, `…(前 ${n} 行已省略)`, `…[${n} lines omitted above]…`, "terminals.headtail.omitted.above", {
1385
+ n,
1386
+ });
1381
1387
  data = data.slice(-tail);
1382
1388
  }
1383
1389
  const parts = [];
@@ -1460,10 +1466,12 @@ export function makeTerminalBashTool(terminals, opts) {
1460
1466
  const redirect = stripped && limiter.kind === "tail" ? detectStdoutRedirect(runCommand) : null;
1461
1467
  const tailFile = redirect ? { file: redirect.file, lines: limiter.lines ?? 10 } : undefined;
1462
1468
  // 复杂子表达式先 hoist 成干净 const(issue #91 v2:vars key 不写复杂表达式)。
1463
- const limiterSegment = limiter.segment;
1464
- const limiterTailLines = limiter.lines ?? 10;
1465
- const limiterTailZh = limiter.kind === "tail" ? `本次返回末尾 ${limiterTailLines} 行。` : "本次返回全部输出。";
1466
- const limiterTailEn = limiter.kind === "tail"
1469
+ // 注意:limiter 对「没有尾部限输出管道」的命令是 null(issue #121)——
1470
+ // 这些 const 一律走可选链,只在 stripped=true(limiterNote 才被取用)时才有意义。
1471
+ const limiterSegment = limiter?.segment ?? "";
1472
+ const limiterTailLines = limiter?.lines ?? 10;
1473
+ const limiterTailZh = limiter?.kind === "tail" ? `本次返回末尾 ${limiterTailLines} 行。` : "本次返回全部输出。";
1474
+ const limiterTailEn = limiter?.kind === "tail"
1467
1475
  ? `Returning the last ${limiterTailLines} lines this time.`
1468
1476
  : "Returning the full output this time.";
1469
1477
  const limiterNote = stripped
@@ -1497,7 +1505,7 @@ export function makeTerminalBashTool(terminals, opts) {
1497
1505
  if (m) {
1498
1506
  terminals.setSentinelPending(termId, false);
1499
1507
  closeOneShot();
1500
- const text = applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail);
1508
+ const text = applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang);
1501
1509
  return {
1502
1510
  content: [
1503
1511
  {
@@ -1517,7 +1525,7 @@ export function makeTerminalBashTool(terminals, opts) {
1517
1525
  }
1518
1526
  // 静默解阻(仅持久终端):转后台 + 注册完成观察器,立即把控制权还给模型。
1519
1527
  if (persist && idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
1520
- return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail), Math.round((Date.now() - lastDataAt) / 1000), lang);
1528
+ return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail, lang), Math.round((Date.now() - lastDataAt) / 1000), lang);
1521
1529
  }
1522
1530
  }
1523
1531
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.80.0",
3
+ "version": "0.80.2",
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": {
@@ -81,7 +81,7 @@
81
81
  "dependencies": {
82
82
  "@deepseek-ai/dsh-sdk-jsonrpc-server": "^0.1.1-rc.2",
83
83
  "@deepseek-ai/dsh-sdk-protocol": "^0.1.1-rc.2",
84
- "@earendil-works/pi-coding-agent": "^0.84.4",
84
+ "@earendil-works/pi-coding-agent": "^0.85.1",
85
85
  "@xterm/addon-fit": "^0.11.0",
86
86
  "@xterm/xterm": "^6.0.0",
87
87
  "compression": "^1.8.1",
@@ -43,5 +43,14 @@
43
43
  "descriptionEn": "Run trajectory: task → thinking → tools → file changes → result timeline with replay and node details.",
44
44
  "source": "xing-shuyin/pi-web-ui/plugins/run-trace",
45
45
  "homepage": "https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/run-trace"
46
+ },
47
+ {
48
+ "id": "legado-web",
49
+ "name": "legado-web",
50
+ "icon": "📖",
51
+ "description": "Legado 阅读(文本源):搜索 / 发现 / 详情 / 目录 / 正文,书源 JSON 与安卓版兼容,可导入、检测、删废源;内置跨域+GBK 代理与本地存储,并给 AI 配了修源接口(读规则、读书源文件、逐步诊断链路、试规则验证)。",
52
+ "descriptionEn": "Legado reader (text book sources): search / explore / info / TOC / content with Android-compatible book source JSON, plus source import and health checking. Ships its own CORS+GBK proxy, local store, and AI tools to diagnose and repair book sources.",
53
+ "source": "xing-shuyin/pi-web-ui/plugins/legado-web",
54
+ "homepage": "https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/legado-web"
46
55
  }
47
56
  ]
@@ -1,4 +1,4 @@
1
- import{a as l,j as n}from"./markdown-D3PKeHAZ.js";import{u as P,b as O,T as M,a as p,c as W,F as Y,d as z,e as Z,f as H,g as ee,h as ne,i as te,j as se,r as ae}from"./index-D7BpHJ59.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-w24rH0km.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(h,u)=>(c=Number(u),`\r
1
+ import{a as l,j as n}from"./markdown-D3PKeHAZ.js";import{u as P,b as O,T as M,a as p,c as W,F as Y,d as z,e as Z,f as H,g as ee,h as ne,i as te,j as se,r as ae}from"./index-DtBJSe33.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-w24rH0km.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(h,u)=>(c=Number(u),`\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:s,cwd:h,title:u,active:w,running:f,exitCode:k,register:g}){const C=l.useRef(null),j=l.useRef(null),{locale:o}=P(),y=l.useRef(o);y.current=o;const E=s?JSON.stringify(s):"";l.useEffect(()=>{const m=C.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}),v=new ie;r.loadAddon(v),r.open(m),j.current={term:r,fit:v},w&&r.focus();const b=()=>{r.options.theme=O()};window.addEventListener(M,b),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 _=g(t,c,{write:d=>r.write(le(d).clean),dispose:()=>r.dispose()}),$=()=>{try{v.fit(),p({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.cols,rows:r.rows})}catch{}},B=requestAnimationFrame(()=>{try{v.fit()}catch{}s?p({type:"run_command",terminalId:c,conversationId:t,command:s,cols:r.cols,rows:r.rows}):p({type:"terminal_create",terminalId:c,title:u,locale:y.current,conversationId:t,cwd:h,cols:r.cols,rows:r.rows})}),R=r.onData(d=>{p({type:"terminal_input",terminalId:c,conversationId:t,data:d})});let T=null;return typeof ResizeObserver<"u"&&(T=new ResizeObserver(()=>{m.offsetWidth>0&&m.offsetHeight>0&&$()}),T.observe(m)),()=>{cancelAnimationFrame(B),R.dispose(),window.removeEventListener(M,b),T?.disconnect(),_(),r.dispose(),j.current=null}},[t,c,E,g]),l.useEffect(()=>{if(!w)return;const m=requestAnimationFrame(()=>{const r=j.current;if(r){try{r.fit.fit(),p({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.term.cols,rows:r.term.rows})}catch{}r.term.focus()}});return()=>cancelAnimationFrame(m)},[w]);const{t:F}=P(),x=l.useRef(void 0);return l.useEffect(()=>{if(f===x.current||(x.current=f,f!==!1))return;const m=j.current;m&&m.term.write(`\r
4
4
  \x1B[90m${F("exitBanner",{code:k??""})}\x1B[0m\r