@oversk7/pi-web-dsh-style 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,51 +4,98 @@
4
4
 
5
5
  **Your agent. Your workspace. Any screen.**
6
6
 
7
- 为 [Pi Coding Agent](https://pi.dev) 打开一个浏览器窗口。
7
+ A browser interface for [Pi Coding Agent](https://pi.dev), inspired by DeepSeek Harness.
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/%40oversk7%2Fpi-web-dsh-style?style=flat-square&color=18181b)](https://www.npmjs.com/package/@oversk7/pi-web-dsh-style)
10
- [![Windows](https://img.shields.io/badge/platform-Windows-18181b?style=flat-square)](#quick-start)
11
- [![MIT](https://img.shields.io/badge/license-MIT-18181b?style=flat-square)](LICENSE)
10
+ [![Windows](https://img.shields.io/badge/platform-Windows-18181b?style=flat-square)](#requirements)
11
+ [![MIT](https://img.shields.io/badge/license-MIT-18181b?style=flat-square)](https://github.com/oversk7/pi-web-dsh-style/blob/main/LICENSE)
12
12
 
13
- [快速开始](#quick-start) · [使用指南](https://github.com/oversk7/pi-web-dsh-style/blob/main/docs/guide.md) · [Pi 扩展目录](https://pi.dev/packages/@oversk7/pi-web-dsh-style) · [反馈与建议](https://github.com/oversk7/pi-web-dsh-style/issues)
13
+ [English](#english) · [简体中文](#简体中文) · [Pi package](https://pi.dev/packages/@oversk7/pi-web-dsh-style) · [Issues](https://github.com/oversk7/pi-web-dsh-style/issues)
14
14
 
15
15
  </div>
16
16
 
17
- ---
17
+ ## English
18
18
 
19
- 灵感来自 DeepSeek Harness 的界面,熟悉的 Pi 工作流。
20
- 在桌面展开工作区,在手机接着看进度。模型、工具和会话仍运行在自己的电脑上。
19
+ Pi Web brings your Pi sessions to desktop and mobile browsers. Run multiple workspaces and sessions, inspect streaming replies and tool calls, preview files, revisit conversation branches, and use models, slash commands, extensions and skills. Pi and its tools keep running on your own computer. Choose light or dark themes and a custom chat background.
21
20
 
22
- ## Highlights
21
+ ### Requirements
23
22
 
24
- - **并行工作** — 多工作区、多会话,随时切换正在进行的任务。
25
- - **保留上下文** — 浏览会话分支,回到之前的检查点,继续探索。
26
- - **看清过程** — 流式回答、工具调用、思考过程和文件预览。
27
- - **随处接入** — 桌面与手机,共用同一套会话;支持局域网和自托管中转。
28
- - **延续习惯** — 模型切换、斜杠命令、扩展与技能,融入浏览器界面。
29
- - **自己的风格** — 明暗主题、配色与自定义背景。
23
+ - Windows 10 or 11
24
+ - Node.js 22.19 or later
25
+ - Pi Coding Agent 0.85.0 or later
30
26
 
31
- ## Quick start
27
+ ### Quick start
32
28
 
33
- 需要 **Windows 10/11**、**Node.js ≥ 22.19** 和 **Pi ≥ 0.85.0**。
29
+ Install the extension:
34
30
 
35
31
  ```powershell
36
32
  pi install npm:@oversk7/pi-web-dsh-style
37
33
  ```
38
34
 
39
- 重启 Pi,然后启动:
35
+ Restart Pi, then launch the web interface:
40
36
 
41
37
  ```powershell
42
38
  pi --web
43
39
  ```
44
40
 
45
- 也可以在已有 Pi 终端中输入 `/web`。
41
+ You can also enter `/web` in an existing Pi terminal. The page opens on your computer; the default local address is `http://127.0.0.1:18789`. To stop the service, run `/web --stop` or exit the host Pi process. Closing the browser tab alone does not stop it.
46
42
 
47
- 手机访问从 **设置 → 手机访问** 开始。更多配置见[使用指南](https://github.com/oversk7/pi-web-dsh-style/blob/main/docs/guide.md)。
43
+ The interface starts in Chinese. To switch to English, open **设置 (Settings) → 外观 (Appearance) → 界面语言 (Interface language) → English**. Your choice is saved in this browser.
48
44
 
49
- ## Contributing
45
+ ### Phone and remote access
50
46
 
51
- 欢迎 Issue 和 PR。Bug、想法、使用体验,都可以从[这里](https://github.com/oversk7/pi-web-dsh-style/issues)开始。
47
+ For a phone on the same trusted Wi-Fi, open **Settings → Connection** on the computer, choose **Local network**, and save. Set a password under **Settings → Security** before signing in from your phone. Open the access URL shown in Connection. You can also start with `pi --web-lan` or `/web --lan`. Keep your computer awake and allow Node.js through the Windows firewall for your trusted network. Direct LAN access uses HTTP, so use it only on a trusted network.
48
+
49
+ For access outside your LAN, select **Server relay** under **Settings → Connection**. This requires your own server with frp (`frps` and an STCP visitor `frpc`), Nginx, and an HTTPS domain. Enter your server address, frps port and token, public HTTPS URL, and the Windows `frpc.exe` path. Download the generated `frps.toml`, `visitor.toml`, and `nginx.conf` from the settings page; adjust the certificate paths and deploy them on your server. The computer running Pi must stay online. See the [detailed guide (Chinese)](https://github.com/oversk7/pi-web-dsh-style/blob/main/docs/guide.md) for the full relay procedure.
50
+
51
+ ### Contributing
52
+
53
+ Issues and pull requests are welcome. To check a local checkout:
54
+
55
+ ```powershell
56
+ git clone https://github.com/oversk7/pi-web-dsh-style.git
57
+ cd pi-web-dsh-style
58
+ npm install
59
+ npm run verify
60
+ ```
61
+
62
+ ## 简体中文
63
+
64
+ Pi Web 为 [Pi Coding Agent](https://pi.dev) 提供浏览器界面,灵感来自 DeepSeek Harness。在桌面或手机上切换多个工作区与会话,查看流式回答、工具调用和文件预览,浏览会话分支,并使用模型、斜杠命令、扩展与技能。Pi 和工具仍运行在自己的电脑上。支持明暗主题和自定义聊天背景。
65
+
66
+ ### 系统要求
67
+
68
+ - Windows 10 或 11
69
+ - Node.js 22.19 或更高版本
70
+ - Pi Coding Agent 0.85.0 或更高版本
71
+
72
+ ### 快速开始
73
+
74
+ 安装扩展:
75
+
76
+ ```powershell
77
+ pi install npm:@oversk7/pi-web-dsh-style
78
+ ```
79
+
80
+ 重启 Pi,然后启动网页界面:
81
+
82
+ ```powershell
83
+ pi --web
84
+ ```
85
+
86
+ 也可以在已有的 Pi 终端中输入 `/web`。网页会在本机打开,默认地址为 `http://127.0.0.1:18789`。执行 `/web --stop` 或退出宿主 Pi 可停止服务;仅关闭浏览器标签页不会停止服务。
87
+
88
+ 界面默认使用中文。打开 **设置 → 外观 → 界面语言**,即可一键切换中文或 English;选择会保存在当前浏览器。
89
+
90
+ ### 手机与远程访问
91
+
92
+ 手机与电脑在同一可信 Wi-Fi 下时,在电脑网页的 **设置 → 连接** 中选择 **局域网** 并保存,然后到 **设置 → 安全** 设置访问密码。手机打开连接页面显示的地址并输入密码即可使用。也可以用 `pi --web-lan` 或 `/web --lan` 启动。电脑需保持运行,Windows 防火墙需允许 Node.js 在可信网络内通信。局域网直连使用 HTTP,请只在可信网络使用。
93
+
94
+ 跨网络访问可在 **设置 → 连接** 中选择 **服务器中转**。这需要自有服务器运行 frp(`frps` 与 STCP 访问端 `frpc`)、Nginx 和 HTTPS 域名。填写服务器地址、frps 端口及密钥、公开 HTTPS 地址和电脑上的 `frpc.exe` 路径;从设置页下载生成的 `frps.toml`、`visitor.toml` 与 `nginx.conf`,调整证书路径后部署到服务器。运行 Pi 的电脑需保持在线。完整步骤见[使用指南](https://github.com/oversk7/pi-web-dsh-style/blob/main/docs/guide.md)。
95
+
96
+ ### 参与开发
97
+
98
+ 欢迎通过 [Issues](https://github.com/oversk7/pi-web-dsh-style/issues) 或 PR 反馈问题和改进。在本地验证源码:
52
99
 
53
100
  ```powershell
54
101
  git clone https://github.com/oversk7/pi-web-dsh-style.git
@@ -57,12 +104,12 @@ npm install
57
104
  npm run verify
58
105
  ```
59
106
 
60
- ## Credits
107
+ ## Credits / 致谢
61
108
 
62
- Built on [Pi](https://pi.dev). Inspired by [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
109
+ Built on [Pi](https://pi.dev). Inspired by [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness). This is an independent community extension and is not affiliated with either project.
63
110
 
64
- 这是一个社区扩展,与 Pi 和 DeepSeek 官方无隶属关系。
111
+ 基于 [Pi](https://pi.dev),界面灵感来自 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)。这是独立的社区扩展,与双方官方均无隶属关系。
65
112
 
66
- 另一个小工具:[pi-pwsh-notify](https://github.com/oversk7/pi-pwsh-notify) — 为 Pi 带来 PowerShell 7 与后台任务通知。
113
+ Another extension by the same author / 同一作者的其他扩展:[pi-pwsh-notify](https://github.com/oversk7/pi-pwsh-notify) (PowerShell 7 tools and background job notifications / PowerShell 7 工具与后台任务通知)。
67
114
 
68
- [MIT](LICENSE) · [Third-party notices](THIRD_PARTY_NOTICES.md)
115
+ [MIT](https://github.com/oversk7/pi-web-dsh-style/blob/main/LICENSE) · [Third-party notices / 第三方许可](https://github.com/oversk7/pi-web-dsh-style/blob/main/THIRD_PARTY_NOTICES.md)
package/docs/guide.md CHANGED
@@ -53,7 +53,7 @@ pi --web
53
53
 
54
54
  ### 手机通过局域网访问
55
55
 
56
- 电脑和手机连接同一 Wi-Fi,在电脑本机网页“设置 → 手机访问”中选择“局域网”并保存。也可以执行 `/web --lan`,或直接以 `pi --web-lan` 启动。首次使用时,在同一设置中设置固定密码;之后手机打开显示的地址,输入该密码即可登录。界面只显示一个手机访问地址,优先使用 Wi-Fi,并过滤 WSL、Hyper-V 等虚拟网卡。
56
+ 电脑和手机连接同一 Wi-Fi,在电脑本机网页“设置 → 连接”中选择“局域网”并保存。也可以执行 `/web --lan`,或直接以 `pi --web-lan` 启动。首次使用时,在“设置 → 安全”中设置固定密码;之后手机打开连接页面显示的地址,输入该密码即可登录。界面只显示一个手机访问地址,优先使用 Wi-Fi,并过滤 WSL、Hyper-V 等虚拟网卡。
57
57
 
58
58
  手机登录状态保留 30 天。密码在服务重启后继续有效,可在电脑本机网页中修改;修改密码后,已登录的手机需要重新登录。
59
59
 
@@ -70,7 +70,7 @@ pi --web
70
70
  电脑运行扩展和原生 `frpc`,服务器运行 `frps`、作为 STCP 访问端的 `frpc` 以及 Nginx。手机通过服务器的 HTTPS 域名访问,适用于校园网客户端隔离等无法直连的网络。电脑上的 `frpc` 由扩展启动和关闭,中转请求直接进入扩展的 Web 服务。
71
71
 
72
72
  1. 从 [frp 官方 Releases](https://github.com/fatedier/frp/releases) 下载电脑对应架构的 Windows 版本,将 `frpc.exe` 解压到固定位置。客户端和服务器建议使用相同版本(已验证 0.70.1)。
73
- 2. 在电脑网页“设置 → 手机访问”选择“服务器中转”,填写服务器域名或 IPv4、frps 端口(默认 `7000`)、frps 认证密钥、手机 HTTPS 入口(如 `https://web.example.com`),以及 `frpc.exe` 路径。路径留空时从 PATH 查找 `frpc`;相对路径以状态目录为基准。认证密钥留空会保留已保存的值。
73
+ 2. 在电脑网页“设置 → 连接”选择“服务器中转”,填写服务器域名或 IPv4、frps 端口(默认 `7000`)、frps 认证密钥、手机 HTTPS 入口(如 `https://web.example.com`),以及 `frpc.exe` 路径。路径留空时从 PATH 查找 `frpc`;相对路径以状态目录为基准。认证密钥留空会保留已保存的值。
74
74
  3. 保存后,下载设置中生成的 `frps.toml`、`visitor.toml` 和 `nginx.conf`。隧道名称和私有密钥自动生成;隧道名称及服务器内部端口可在高级配置中调整。将域名 DNS 指向服务器,并为该域名准备有效的 HTTPS 证书。
75
75
  4. 服务器安装原生 frp 和 Nginx。新服务器使用 `frps -c frps.toml`,访问端使用 `frpc -c visitor.toml`,并用 systemd 等服务管理器保持运行。已有 frps 时复用现有端口和认证密钥,保留其配置。把生成的 Nginx 配置中的证书路径替换成实际证书路径,再安装到 Nginx 的站点配置目录,执行 `nginx -t` 验证并重载。
76
76
 
package/index.ts CHANGED
@@ -165,6 +165,7 @@ export default function (pi: ExtensionAPI) {
165
165
  });
166
166
 
167
167
  pi.on("session_start", async (_event, ctx) => {
168
+ if (ctx.mode === "rpc") await ctx.modelRegistry.refresh({ allowNetwork: false });
168
169
  const resumeAfterReload = reloadState.resumeWebAfterReload;
169
170
  const connection = reloadState.connection;
170
171
  reloadState.resumeWebAfterReload = false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oversk7/pi-web-dsh-style",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "description": "An unofficial DSH-styled Windows browser UI for pi coding agent with configurable LAN and native frp relay access",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/transcript.ts CHANGED
@@ -8,6 +8,9 @@ export interface RenderedBlock {
8
8
  name?: string;
9
9
  arguments?: unknown;
10
10
  argumentsText?: string;
11
+ subagent?: { type: string; task: string; prompt: string };
12
+ subagentFeedback?: { target: string; type: string; task: string; message: string };
13
+ subagentResult?: { target: string; type: string; task: string };
11
14
  result?: { content: string; isError: boolean; details?: unknown };
12
15
  running?: boolean;
13
16
  }
@@ -37,11 +40,56 @@ export interface RenderedMessage {
37
40
  }
38
41
 
39
42
  export function browserMessages(messages: RenderedMessage[]): RenderedMessage[] {
43
+ type AgentIdentity = { id: string; type: string; task: string };
44
+ const agents = new Map<string, AgentIdentity>();
45
+ const names = new Map<string, string>();
46
+ const types = new Map<string, string | null>();
47
+ const text = (value: unknown): string => typeof value === "string" ? value : "";
48
+ const lookup = (target: string): AgentIdentity | undefined => agents.get(target) || agents.get(names.get(target) || types.get(target.toLowerCase()) || "");
40
49
  return messages.map((message) => !message.blocks ? message : {
41
50
  ...message,
42
51
  blocks: message.blocks.map((block) => {
43
52
  if (block.type !== "toolCall") return block;
44
- const { arguments: _arguments, result, ...visible } = block;
53
+ const { arguments: args, result, ...visible } = block;
54
+ // Keep display fields without sending every tool's raw arguments to the browser.
55
+ const record = args && typeof args === "object" ? args as Json : {};
56
+ const toolName = block.name?.toLowerCase();
57
+ if (toolName === "agent") {
58
+ const resumed = lookup(text(record.resume));
59
+ if (args && typeof args === "object") {
60
+ visible.subagent = {
61
+ type: text(record.subagent_type) || resumed?.type || "",
62
+ task: text(record.description) || resumed?.task || "",
63
+ prompt: text(record.prompt),
64
+ };
65
+ visible.argumentsText = visible.subagent.task;
66
+ }
67
+ const details = result?.details && typeof result.details === "object" ? result.details as Json : {};
68
+ const receiptField = (label: string): string => result?.content.match(new RegExp(`^${label}:[ \\t]*(.*)$`, "m"))?.[1]?.trim() || "";
69
+ const id = text(details.agentId) || receiptField("Agent ID") || resumed?.id;
70
+ if (id && !result?.isError) {
71
+ const identity = {
72
+ id,
73
+ type: visible.subagent?.type || text(details.subagentType) || receiptField("Type"),
74
+ task: visible.subagent?.task || text(details.description) || receiptField("Description"),
75
+ };
76
+ agents.set(id, identity);
77
+ if (text(record.name)) names.set(text(record.name), id);
78
+ // A type is a useful fallback only when it identifies one agent unambiguously.
79
+ const typeKey = identity.type.toLowerCase();
80
+ if (typeKey) {
81
+ const previous = types.get(typeKey);
82
+ types.set(typeKey, previous === undefined || previous === id ? id : null);
83
+ }
84
+ }
85
+ } else if ((toolName === "steer_subagent" || toolName === "get_subagent_result") && args && typeof args === "object") {
86
+ const target = text(record.agent_id);
87
+ const identity = lookup(target);
88
+ const agent = { target, type: identity?.type || "", task: identity?.task || "" };
89
+ if (toolName === "steer_subagent") visible.subagentFeedback = { ...agent, message: text(record.message) };
90
+ else visible.subagentResult = agent;
91
+ visible.argumentsText = [identity?.type || target, identity?.task].filter(Boolean).join(" · ");
92
+ }
45
93
  return result
46
94
  ? { ...visible, result: { content: result.content, isError: result.isError } }
47
95
  : visible;
package/web/app.css CHANGED
@@ -312,6 +312,20 @@ body[data-workspace-dragging] .YDXeBa_projectRow[draggable=true]{cursor:grabbing
312
312
  .CY-8Ka_root[data-state=running] .CY-8Ka_leading,.CY-8Ka_root[data-state=running] .CY-8Ka_title{color:var(--dsw-alias-state-business-primary)}
313
313
  .CY-8Ka_root[data-state=error] .CY-8Ka_leading,.CY-8Ka_root[data-state=error] .CY-8Ka_title{color:var(--dsw-alias-state-error-primary)}
314
314
 
315
+ /* Agent delegation details */
316
+ .pi-subagentDetail{display:flex;flex-direction:column;gap:12px;padding:10px 14px 14px;min-width:0}
317
+ .pi-subagentField{min-width:0}
318
+ .pi-subagentLabel{margin-bottom:4px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}
319
+ .pi-subagentText{margin:0;color:var(--dsw-alias-label-primary);font:13px/21px var(--dsw-font-family);white-space:pre-wrap;overflow-wrap:anywhere}
320
+ .pi-subagentField .Y0dWHa_payload{min-height:0}
321
+ .pi-subagentNotice{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:21px;overflow-wrap:anywhere}
322
+ .pi-subagentResult{min-width:0;overflow-wrap:anywhere}
323
+ .pi-subagentResult ._markdown_1nba0_5{font-size:13px;line-height:21px}
324
+ .pi-subagentResult.Y0dWHa_payloadError{color:var(--dsw-alias-state-error-primary)}
325
+ .pi-subagentConversation{min-width:0}
326
+ .pi-subagentConversation summary{cursor:pointer;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:20px;margin-bottom:6px}
327
+ .pi-subagentConversation summary:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary);outline-offset:2px}
328
+
315
329
  /* Extension notifications */
316
330
  .pi-toast{z-index:1500;box-sizing:border-box;position:fixed;right:24px;bottom:24px;max-width:min(420px,calc(100vw - 48px));background:var(--dsw-specific-menu);border:1px solid var(--dsw-alias-border-l2);box-shadow:var(--dsw-shadow-lv3);color:var(--dsw-alias-label-primary);border-radius:12px;padding:10px 14px;font-size:13px;line-height:20px;animation:pi-toast-in .16s var(--ds-ease-in-out)}
317
331
  .pi-toast-warning{border-color:var(--dsw-alias-state-warn-secondary);color:var(--dsw-alias-state-warn-label)}
@@ -499,6 +513,10 @@ body[data-workspace-dragging] .YDXeBa_projectRow[draggable=true]{cursor:grabbing
499
513
  .pi-settingsEmpty{display:flex;flex-direction:column;align-items:center;gap:12px;padding:32px 20px;text-align:center}
500
514
  .pi-settingsEmpty strong{font-size:14px;font-weight:500}
501
515
  .pi-settingsEmptyIcon{display:grid;place-items:center;width:40px;height:40px;border-radius:12px;background:var(--dsw-alias-bg-module-platform);color:var(--dsw-alias-label-secondary)}
516
+ .pi-languageChoices{display:flex;flex-wrap:wrap;gap:8px;margin-bottom:28px}
517
+ .pi-languageChoices button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-width:104px;min-height:36px;padding:6px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary);cursor:pointer;font:inherit;font-size:12px}
518
+ .pi-languageChoices button[aria-pressed=true]{border-color:var(--dsw-alias-state-business-primary);color:var(--dsw-alias-state-business-primary)}
519
+ .pi-languageChoices button svg{width:14px;height:14px}
502
520
  .pi-settingsTheme{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
503
521
  .pi-settingsTheme button{min-width:0;padding:7px;border:1px solid var(--dsw-alias-border-l2);border-radius:11px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;font:inherit;font-size:12px;line-height:20px;transition:border-color .15s,box-shadow .15s}
504
522
  .pi-settingsTheme button:hover:not(:disabled){border-color:var(--dsw-alias-label-tertiary);color:var(--dsw-alias-label-primary)}
package/web/app.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { markdown } from "./assets/markdown.js";
4
4
  import { webSlashCommands, unsupportedSlashCommands, parseSlashCommand } from "./slash-commands.js";
5
+ import { getLanguage, setLanguage, t, translateUi, observeUi } from "./i18n.js";
5
6
 
6
7
  const SESSION_PREVIEW_LIMIT = 5;
7
8
  const MODEL_REFRESH_TTL_MS = 60_000;
@@ -113,11 +114,11 @@ function timeLabel(iso) {
113
114
  const date = new Date(iso);
114
115
  if (Number.isNaN(date.getTime())) return "";
115
116
  const diff = Date.now() - date.getTime();
116
- if (diff < 60_000) return "刚刚";
117
- if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}分钟`;
118
- if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}小时`;
119
- if (diff < 7 * 86_400_000) return `${Math.floor(diff / 86_400_000)}天`;
120
- return date.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
117
+ if (diff < 60_000) return t("刚刚");
118
+ if (diff < 3_600_000) return t(`${Math.floor(diff / 60_000)}分钟`);
119
+ if (diff < 86_400_000) return t(`${Math.floor(diff / 3_600_000)}小时`);
120
+ if (diff < 7 * 86_400_000) return t(`${Math.floor(diff / 86_400_000)}天`);
121
+ return date.toLocaleDateString(getLanguage(), { month: "short", day: "numeric" });
121
122
  }
122
123
 
123
124
  function sessionsByRecency(workspace) {
@@ -130,7 +131,7 @@ function clockLabel(timestamp) {
130
131
  if (!timestamp) return "";
131
132
  const date = new Date(timestamp);
132
133
  if (Number.isNaN(date.getTime())) return "";
133
- return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
134
+ return date.toLocaleTimeString(getLanguage(), { hour: "2-digit", minute: "2-digit", hour12: false });
134
135
  }
135
136
 
136
137
  function shortPath(path, max = 34) {
@@ -832,7 +833,7 @@ function renderCommandMenu(snap = currentSnapshot()) {
832
833
  const rows = commands.map((command, index) => `<button type="button" role="option" aria-selected="${index === S.commandSelected}" class="_3e4SsG_item ${index === S.commandSelected ? "_3e4SsG_active" : ""}" data-action="choose-command" data-command="${esc(command.name)}">
833
834
  <span class="_3e4SsG_itemIcon">/</span>
834
835
  <span class="_3e4SsG_itemName">${esc(command.name)}</span>
835
- <span class="_3e4SsG_itemDescription">${esc(command.description || commandSourceLabel(command.source))}</span>
836
+ <span class="_3e4SsG_itemDescription">${esc(t(command.description || commandSourceLabel(command.source)))}</span>
836
837
  <span class="pi-commandSource">${esc(commandSourceLabel(command.source))}</span>
837
838
  </button>`).join("");
838
839
  const status = S.commandLoading && commands.length === 0
@@ -867,7 +868,8 @@ function renderComposerBar(hero = false) {
867
868
  const level = state.thinkingLevel || null;
868
869
  const streaming = Boolean(snap.session?.streaming);
869
870
  const waitingForDialog = Boolean(snap.dialogs?.length);
870
- const disabled = (!S.currentSessionId && !hero) || Boolean(S.loadingSession && !hero) || S.pendingCreation?.generation === S.openGeneration || waitingForDialog;
871
+ const inputDisabled = (!S.currentSessionId && !hero) || Boolean(S.loadingSession && !hero) || waitingForDialog;
872
+ const disabled = inputDisabled || S.pendingCreation?.generation === S.openGeneration;
871
873
  const inputPlaceholder = waitingForDialog ? "请先回答对话中的问题" : "给智能体发消息";
872
874
  const rawContextPct = snap.stats?.contextUsage?.percent;
873
875
  const hasContextPct = typeof rawContextPct === "number" && Number.isFinite(rawContextPct);
@@ -880,7 +882,7 @@ function renderComposerBar(hero = false) {
880
882
  ${renderDraftAttachments(model)}
881
883
  <div class="uV2eYG_scroll" data-input-scroll="true"><div class="uV2eYG_grow">
882
884
  <div aria-hidden="true" class="uV2eYG_backdrop"></div>
883
- <textarea class="uV2eYG_input" id="composerInput" data-phase="plain" data-session-id="${esc(S.currentSessionId || "")}" placeholder="${inputPlaceholder}" rows="1" ${disabled ? "disabled" : ""}></textarea>
885
+ <textarea class="uV2eYG_input" id="composerInput" data-phase="plain" data-session-id="${esc(S.currentSessionId || "")}" placeholder="${inputPlaceholder}" rows="1" ${inputDisabled ? "disabled" : ""}></textarea>
884
886
  <div aria-hidden="true" class="uV2eYG_mirror"></div>
885
887
  </div></div>
886
888
  <div class="uV2eYG_row">
@@ -891,7 +893,7 @@ function renderComposerBar(hero = false) {
891
893
  <div class="uV2eYG_trailing">
892
894
  <div class="_7KE1Ra_root pi-modelControl">
893
895
  <button type="button" class="_7KE1Ra_trigger" aria-haspopup="menu" aria-expanded="false" title="选择模型" data-action="model-menu" ${disabled ? "disabled" : ""}>
894
- <span class="_7KE1Ra_triggerLabel">${esc(modelLabel(model))}</span>
896
+ <span class="_7KE1Ra_triggerLabel">${esc(model?.name || model?.id || "选择模型")}</span>
895
897
  <svg width="14" height="14" class="_7KE1Ra_chevron" viewBox="0 0 14 14" fill="none"><path d="M11.85 5.5L11.42 5.92L8.7 8.65C8.44 8.91 8.22 9.13 8.01 9.3C7.8 9.47 7.56 9.62 7.25 9.67C7.08 9.69 6.92 9.69 6.75 9.67C6.44 9.62 6.2 9.47 5.99 9.3C5.78 9.13 5.56 8.91 5.3 8.65L2.58 5.92L2.15 5.5L3 4.65L3.42 5.08L6.15 7.8C6.43 8.08 6.6 8.25 6.74 8.36C6.87 8.47 6.92 8.48 6.94 8.48C6.98 8.49 7.02 8.49 7.06 8.48C7.08 8.48 7.13 8.47 7.26 8.36C7.4 8.25 7.57 8.08 7.85 7.8L10.58 5.08L11 4.65L11.85 5.5Z" fill="currentColor"/></svg>
896
898
  </button>
897
899
  </div>
@@ -1495,7 +1497,11 @@ function renderConversation() {
1495
1497
  }
1496
1498
  if (!patchedActive) {
1497
1499
  const nextInput = $("#composerInput");
1498
- if (previousInput && nextInput && previousInput.dataset.sessionId === nextInput.dataset.sessionId) {
1500
+ const completingCreation = !previousSessionId && S.pendingCreation?.sessionId === S.currentSessionId;
1501
+ if (previousInput && nextInput && (previousInput.dataset.sessionId === nextInput.dataset.sessionId || completingCreation)) {
1502
+ previousInput.dataset.sessionId = nextInput.dataset.sessionId;
1503
+ previousInput.disabled = nextInput.disabled;
1504
+ previousInput.placeholder = nextInput.placeholder;
1499
1505
  nextInput.replaceWith(previousInput);
1500
1506
  }
1501
1507
  restoreDraft();
@@ -1567,6 +1573,93 @@ function compactionUsageSummary(usage) {
1567
1573
  };
1568
1574
  }
1569
1575
 
1576
+ function subagentPresentation(tool) {
1577
+ const toolName = (tool?.name || tool?.toolName || "").toLowerCase();
1578
+ if (!["agent", "steer_subagent", "get_subagent_result"].includes(toolName)) return null;
1579
+ const rawOutput = tool.result?.content || tool.text || "";
1580
+ const isError = Boolean(tool.result?.isError || tool.isError);
1581
+ if (toolName === "get_subagent_result") {
1582
+ const header = /^Agent: ([^\r\n]+)\r?\nType: ([^|\r\n]+)\s*\|\s*Status: ([^|\r\n]+)[^\r\n]*\r?\nDescription: ([^\r\n]*)\r?\n(?:\r?\n)?/.exec(rawOutput);
1583
+ const status = isError ? "error" : header?.[3]?.trim().toLowerCase() || (tool.running ? "fetching" : "");
1584
+ const body = header ? rawOutput.slice(header[0].length) : rawOutput;
1585
+ const trace = /\r?\n\r?\n--- Agent Conversation ---\r?\n/.exec(body);
1586
+ return {
1587
+ retrieval: true,
1588
+ target: tool.subagentResult?.target || header?.[1] || "",
1589
+ type: tool.subagentResult?.type || header?.[2]?.trim() || "",
1590
+ task: tool.subagentResult?.task || header?.[4] || "",
1591
+ prompt: "",
1592
+ status,
1593
+ output: trace ? body.slice(0, trace.index) : body,
1594
+ conversation: trace ? body.slice(trace.index + trace[0].length) : "",
1595
+ isError: isError || ["error", "failed"].includes(status),
1596
+ };
1597
+ }
1598
+ if (toolName === "steer_subagent") {
1599
+ const feedback = tool.subagentFeedback;
1600
+ const sent = !isError && /^Steering message sent to agent .+\./.test(rawOutput.trim());
1601
+ return {
1602
+ feedback: true,
1603
+ target: feedback?.target || rawOutput.match(/^Steering message sent to agent (.+?)\. (?:The agent|$)/)?.[1] || "",
1604
+ type: feedback?.type || "",
1605
+ task: feedback?.task || "",
1606
+ prompt: feedback?.message || "",
1607
+ promptUnavailable: tool.type === "toolCall" && typeof feedback?.message !== "string",
1608
+ output: sent ? "" : rawOutput,
1609
+ isError,
1610
+ };
1611
+ }
1612
+ const receipt = !isError && /^Agent started in background\.(?:\r?\n|$)/.test(rawOutput.trim());
1613
+ const receiptField = (label) => receipt ? rawOutput.match(new RegExp(`^${label}:\\s*(.*)$`, "m"))?.[1]?.trim() || "" : "";
1614
+ return {
1615
+ type: tool.subagent?.type || receiptField("Type"),
1616
+ task: tool.subagent?.task || receiptField("Description"),
1617
+ prompt: tool.subagent?.prompt || "",
1618
+ promptUnavailable: tool.type === "toolCall" && typeof tool.subagent?.prompt !== "string",
1619
+ output: receipt ? "" : rawOutput,
1620
+ isError,
1621
+ };
1622
+ }
1623
+
1624
+ function subagentTitle(agent, fallback) {
1625
+ if (agent?.feedback || agent?.retrieval) {
1626
+ const target = agent.type || (agent.target.length > 24 ? `${agent.target.slice(0, 21)}…` : agent.target) || "Agent";
1627
+ return agent.retrieval ? `${t("查看结果")} ← ${target}` : `${t("补充指令")} → ${target}`;
1628
+ }
1629
+ return agent?.type ? `Agent · ${agent.type}` : fallback;
1630
+ }
1631
+
1632
+ function subagentStatusLabel(status) {
1633
+ return { completed: "已完成", done: "已完成", running: "运行中", pending: "等待执行", queued: "等待执行", scheduled: "等待执行", error: "失败", failed: "失败", aborted: "已取消", cancelled: "已取消", canceled: "已取消", fetching: "正在获取结果" }[status] || status;
1634
+ }
1635
+
1636
+ function subagentSummary(agent) {
1637
+ if (agent.retrieval) return [agent.task, t(subagentStatusLabel(agent.status))].filter(Boolean).join(" · ");
1638
+ return agent.feedback
1639
+ ? [agent.task, agent.prompt.replace(/\s+/g, " ").slice(0, 120)].filter(Boolean).join(" · ")
1640
+ : agent.task;
1641
+ }
1642
+
1643
+ function renderSubagentDetail(agent) {
1644
+ const field = (label, value) => value ? `<div class="pi-subagentField"><div class="pi-subagentLabel">${label}</div><pre class="pi-subagentText" translate="no">${esc(value)}</pre></div>` : "";
1645
+ if (agent.retrieval) {
1646
+ const label = agent.isError ? "失败原因" : agent.status === "running" ? "当前进展" : "执行结果";
1647
+ const result = agent.output
1648
+ ? `<div class="pi-subagentField"><div class="pi-subagentLabel">${label}</div><div class="pi-subagentResult ${agent.isError ? "Y0dWHa_payloadError" : ""}"><div class="_markdown_1nba0_5">${markdown(agent.output)}</div></div></div>`
1649
+ : `<div class="pi-subagentNotice">${agent.status === "fetching" ? "正在获取结果" : agent.status === "running" ? "子 agent 仍在运行,尚无结果。" : "暂无结果内容。"}</div>`;
1650
+ const conversation = agent.conversation ? `<details class="pi-subagentConversation"><summary>执行记录</summary><pre class="pi-subagentText" translate="no">${esc(agent.conversation)}</pre></details>` : "";
1651
+ return `<div class="Sxvs8a_body pi-subagentDetail">${field("结果来源", [agent.type, agent.task].filter(Boolean).join(" · ") || agent.target)}${agent.status ? `<div class="pi-subagentField"><div class="pi-subagentLabel">状态</div><div>${esc(subagentStatusLabel(agent.status))}</div></div>` : ""}${result}${conversation}</div>`;
1652
+ }
1653
+ const label = agent.feedback ? "指令内容" : "提示词";
1654
+ const identity = agent.feedback
1655
+ ? field("目标 agent", [agent.type, agent.task].filter(Boolean).join(" · ") || agent.target)
1656
+ : field("类型", agent.type) + field("委派任务", agent.task);
1657
+ const notice = agent.feedback
1658
+ ? "当前运行时未提供完整指令内容。请等待所有会话空闲,在设置 → 维护中重新加载运行时,然后刷新页面。"
1659
+ : "当前运行时未提供完整提示词。请等待所有会话空闲,在设置 → 维护中重新加载运行时,然后刷新页面。";
1660
+ return `<div class="Sxvs8a_body pi-subagentDetail">${identity}${field(label, agent.prompt)}${agent.promptUnavailable ? `<div class="pi-subagentField"><div class="pi-subagentLabel">${label}</div><div class="pi-subagentNotice">${notice}</div></div>` : ""}${agent.output ? `<div class="pi-subagentField"><div class="pi-subagentLabel">${agent.isError ? "失败" : "结果"}</div><pre class="Y0dWHa_payload ${agent.isError ? "Y0dWHa_payloadError" : ""}" translate="no">${esc(agent.output)}</pre></div>` : ""}</div>`;
1661
+ }
1662
+
1570
1663
  function renderMessageDetail(msg, blockIndex) {
1571
1664
  if (msg.kind === "compaction") {
1572
1665
  const usage = compactionUsageSummary(msg.usage);
@@ -1583,6 +1676,8 @@ function renderMessageDetail(msg, blockIndex) {
1583
1676
  if (block?.type === "thinking") {
1584
1677
  return `<div class="Y0dWHa_thinkingQuote"><pre class="Y0dWHa_payload">${esc(block.thinking || "")}</pre></div>`;
1585
1678
  }
1679
+ const agent = subagentPresentation(block || msg);
1680
+ if (agent) return renderSubagentDetail(agent);
1586
1681
  const argsText = block?.argumentsText || "";
1587
1682
  const output = block?.result?.content || (msg.kind === "bashExecution" ? msg.output : msg.text) || "";
1588
1683
  const isError = block ? block.result?.isError : msg.kind === "toolResult" && msg.isError;
@@ -1698,15 +1793,16 @@ function renderMessage(msg, messageIndex) {
1698
1793
  return `<div class="pi-assistantMessageGroup">${content}${error}${actions}</div>`;
1699
1794
  }
1700
1795
  if (msg.kind === "toolResult") {
1701
- const output = msg.text || "";
1796
+ const agent = subagentPresentation(msg);
1797
+ const output = agent ? agent.output : msg.text || "";
1702
1798
  const lineCount = output ? output.split("\n").length : 0;
1703
- const hasDetail = output !== "";
1799
+ const hasDetail = Boolean(agent ? agent.type || agent.task || agent.target || agent.status || agent.prompt || output : output);
1704
1800
  const toolKey = `result:${msg.toolCallId || msg.timestamp || msg.toolName || "tool"}`;
1705
1801
  const expanded = hasDetail && S.expandedTools.has(toolKey);
1706
1802
  return `<div class="Md3f7G_flowItem" data-chat-flow-kind="tool-result"><div class="ztWv_q_callRow" data-tool-key="${esc(toolKey)}"><div class="CY-8Ka_card">
1707
- <div class="CY-8Ka_root" data-state="${msg.isError ? "error" : "ok"}" ${hasDetail ? `role="button" data-expandable aria-expanded="${expanded}" data-action="toggle-tool"` : ""}>
1708
- <span class="CY-8Ka_leading">${ICONS.bash}</span><span class="CY-8Ka_title">${esc(msg.toolName || "Tool")}</span><span class="CY-8Ka_sep"></span>
1709
- <span class="CY-8Ka_summary ${msg.isError ? "CY-8Ka_errorSummary" : ""}">${msg.isError ? "失败" : "完成"}${lineCount ? ` · ${lineCount} 行输出` : ""}</span>
1803
+ <div class="CY-8Ka_root" data-state="${agent?.isError || msg.isError ? "error" : agent?.status === "running" ? "running" : "ok"}" ${hasDetail ? `role="button" data-expandable aria-expanded="${expanded}" data-action="toggle-tool"` : ""}>
1804
+ <span class="CY-8Ka_leading">${ICONS.bash}</span><span class="CY-8Ka_title">${esc(subagentTitle(agent, msg.toolName || "Tool"))}</span><span class="CY-8Ka_sep"></span>
1805
+ <span class="CY-8Ka_summary ${agent?.isError || msg.isError ? "CY-8Ka_errorSummary" : ""}">${agent ? esc(subagentSummary(agent) || (msg.isError ? "失败" : "")) : `${msg.isError ? "失败" : "完成"}${lineCount ? ` · ${lineCount} 行输出` : ""}`}</span>
1710
1806
  </div>
1711
1807
  ${hasDetail ? `<div class="Sxvs8a_root" ${expanded ? "" : "hidden"}>${expanded ? renderMessageDetail(msg) : ""}</div>` : ""}
1712
1808
  </div></div></div>`;
@@ -1743,13 +1839,14 @@ function renderBlock(block, msg, idx, messageIndex) {
1743
1839
  }
1744
1840
  if (block.type === "toolCall") {
1745
1841
  const result = block.result;
1746
- const state = result ? (result.isError ? "error" : "ok") : block.running ? "running" : "ok";
1842
+ const agent = subagentPresentation(block);
1843
+ const state = agent?.isError || result?.isError ? "error" : agent?.status === "running" || block.running ? "running" : "ok";
1747
1844
  const argsText = block.argumentsText || "";
1748
- const resultText = result?.content || "";
1845
+ const resultText = agent ? agent.output : result?.content || "";
1749
1846
  const resultLines = resultText ? resultText.split("\n").length : 0;
1750
- const hasDetail = argsText !== "" || resultText !== "";
1847
+ const hasDetail = Boolean(agent ? agent.type || agent.task || agent.target || agent.status || agent.prompt || agent.promptUnavailable || resultText : argsText || resultText);
1751
1848
  const resultHint = resultLines ? `${resultLines} 行输出` : state === "running" ? "运行中" : "";
1752
- const summary = [argsText, resultHint].filter(Boolean).join(" · ");
1849
+ const summary = agent ? subagentSummary(agent) : [argsText, resultHint].filter(Boolean).join(" · ");
1753
1850
  const toolKey = `call:${block.id || `${block.name || "tool"}:${idx}`}`;
1754
1851
  const expanded = hasDetail && S.expandedTools.has(toolKey);
1755
1852
  return `<div class="Md3f7G_flowItem" data-chat-flow-kind="tool-call">
@@ -1757,8 +1854,8 @@ function renderBlock(block, msg, idx, messageIndex) {
1757
1854
  <div class="CY-8Ka_card"><div class="CY-8Ka_root" data-sample="${esc(block.name || "tool")}" data-variant="${esc(block.name || "tool")}" data-state="${state}" ${hasDetail ? `role="button" data-expandable aria-expanded="${expanded}" data-action="toggle-tool"` : ""}>
1758
1855
  <span class="CY-8Ka_leading"><span class="CY-8Ka_iconIdle">${ICONS.bash}</span></span>
1759
1856
  <span class="CY-8Ka_visuallyHidden">${state === "error" ? "失败" : state === "running" ? "运行中" : "完成"}</span>
1760
- <span class="CY-8Ka_title">${esc(block.name || "Tool")}</span><span class="CY-8Ka_sep"></span>
1761
- <span class="CY-8Ka_summary ${result?.isError ? "CY-8Ka_errorSummary" : ""}">${esc(summary)}</span>
1857
+ <span class="CY-8Ka_title">${esc(subagentTitle(agent, block.name || "Tool"))}</span><span class="CY-8Ka_sep"></span>
1858
+ <span class="CY-8Ka_summary ${state === "error" ? "CY-8Ka_errorSummary" : ""}">${esc(summary)}</span>
1762
1859
  </div>
1763
1860
  ${hasDetail ? `<div class="Sxvs8a_root" data-block-index="${idx}" ${expanded ? "" : "hidden"}>${expanded ? renderMessageDetail(msg, idx) : ""}</div>` : ""}
1764
1861
  </div>
@@ -2320,7 +2417,7 @@ async function navigateHistory(entryId, role, restoreMode) {
2320
2417
  if (!view || !sessionId || view.busyEntryId) return;
2321
2418
  if (S.draft.trim()) {
2322
2419
  const replacesDraft = role === "user" || role === "custom";
2323
- if (!confirm(replacesDraft ? "当前输入框草稿将被历史消息替换,继续回溯?" : "切换会话节点将清空当前输入框草稿,继续?")) return;
2420
+ if (!confirm(t(replacesDraft ? "当前输入框草稿将被历史消息替换,继续回溯?" : "切换会话节点将清空当前输入框草稿,继续?"))) return;
2324
2421
  }
2325
2422
  S.historyView = { ...view, busyEntryId: entryId, busyRestoreMode: restoreMode || null, error: null };
2326
2423
  renderConversation();
@@ -2680,7 +2777,7 @@ function renderSettings() {
2680
2777
  <div class="pi-settingsPageHeading"><h3>${tab.title}</h3><p>${tab.description}</p></div>`;
2681
2778
  };
2682
2779
  const status = S.maintenanceMessage
2683
- ? `<div class="pi-settingsStatus pi-settingsStatus-${esc(S.maintenanceMessage.type || "info")}" role="status">${esc(S.maintenanceMessage.text || "")}</div>`
2780
+ ? `<div class="pi-settingsStatus pi-settingsStatus-${esc(S.maintenanceMessage.type || "info")}" role="status">${esc(t(S.maintenanceMessage.text || ""))}</div>`
2684
2781
  : "";
2685
2782
  return `<div class="pi-settingsBackdrop">
2686
2783
  <div class="pi-settingsPanel" role="dialog" aria-modal="true" aria-labelledby="pi-settings-title">
@@ -2697,7 +2794,11 @@ function renderSettings() {
2697
2794
  </aside>
2698
2795
  <div class="pi-settingsContent">
2699
2796
  ${pageStart("appearance")}
2700
- <div class="pi-settingsGroupHeading"><h4>主题风格</h4><span>即时生效</span></div>
2797
+ <div class="pi-settingsGroupHeading"><h4>界面语言</h4><span>即时生效</span></div>
2798
+ <div class="pi-languageChoices" role="group" aria-label="界面语言">
2799
+ ${[["zh-CN", "中文"], ["en", "English"]].map(([value, label]) => `<button type="button" data-action="choose-language" data-language="${value}" aria-pressed="${getLanguage() === value}">${label}${getLanguage() === value ? ICONS.check : ""}</button>`).join("")}
2800
+ </div>
2801
+ <div class="pi-settingsGroupHeading pi-themeModeHeading"><h4>主题风格</h4><span>即时生效</span></div>
2701
2802
  <div class="pi-settingsTheme pi-settingsStyles" role="group" aria-label="主题风格">
2702
2803
  ${styles.map(([value, label, description]) => `<button type="button" data-action="choose-theme-style" data-theme-style="${value}" aria-pressed="${(S.themeStyle || "classic") === value}" ${busy ? "disabled" : ""}>
2703
2804
  <span class="pi-stylePreview pi-stylePreview-${value}" aria-hidden="true"><span class="pi-stylePreviewWindow"><i></i><i></i><i></i></span></span>
@@ -2806,6 +2907,7 @@ function renderOverlay() {
2806
2907
  }
2807
2908
  const nextFocus = focused?.isConnected ? focused
2808
2909
  : focused?.dataset.settingsTab ? $(`[data-settings-tab="${focused.dataset.settingsTab}"]`, overlay)
2910
+ : focused?.dataset.language ? $(`[data-language="${focused.dataset.language}"]`, overlay)
2809
2911
  : focused?.dataset.theme ? $(`[data-theme="${focused.dataset.theme}"]`, overlay)
2810
2912
  : focused?.dataset.themeStyle ? $(`[data-theme-style="${focused.dataset.themeStyle}"]`, overlay)
2811
2913
  : focused?.dataset.action ? $(`[data-action="${focused.dataset.action}"]`, overlay) : null;
@@ -3012,7 +3114,7 @@ async function openSession(id, opts = {}) {
3012
3114
  if (!opts.skipRemember) rememberCurrentDraft();
3013
3115
  const generation = ++S.openGeneration;
3014
3116
  S.currentSessionId = id;
3015
- S.loadingSession = id;
3117
+ S.loadingSession = opts.initialSnapshot ? null : id;
3016
3118
  if (!opts.silent) {
3017
3119
  const key = draftKey(id);
3018
3120
  const savedDraft = S.drafts.get(key) || "";
@@ -3071,6 +3173,7 @@ async function createAndOpenSession(workspaceId, title, options = {}) {
3071
3173
  }
3072
3174
  renderSidebar();
3073
3175
  renderConversation();
3176
+ $("#composerInput")?.focus({ preventScroll: true });
3074
3177
  try {
3075
3178
  const model = source?.state?.model;
3076
3179
  const data = await post(`/api/workspaces/${encodeURIComponent(workspaceId)}/sessions`, {
@@ -3090,10 +3193,21 @@ async function createAndOpenSession(workspaceId, title, options = {}) {
3090
3193
  return null;
3091
3194
  }
3092
3195
  S.newSessionPreferenceWorkspaceId = null;
3196
+ pending.sessionId = id;
3093
3197
  await openSession(id, { silent: true, skipRemember: true, initialSnapshot: data });
3094
3198
  return generation + 1 === S.openGeneration && S.currentSessionId === id ? id : null;
3095
3199
  } catch (error) {
3096
3200
  if (generation === S.openGeneration) {
3201
+ // Keep anything typed during startup available in the workspace's new-session draft.
3202
+ rememberCurrentDraft();
3203
+ const pendingDraft = S.drafts.get(pending.draftKey);
3204
+ if (pendingDraft) S.drafts.set(`new:${workspaceId || "default"}`, pendingDraft);
3205
+ const pendingAttachments = S.attachmentDrafts.get(pending.draftKey);
3206
+ if (pendingAttachments) S.attachmentDrafts.set(`new:${workspaceId || "default"}`, pendingAttachments);
3207
+ if (!previous.sessionId && S.currentWorkspaceId === previous.workspaceId) {
3208
+ previous.draft = S.draft;
3209
+ previous.attachments = [...S.draftAttachments];
3210
+ }
3097
3211
  S.pendingCreation = null;
3098
3212
  S.currentSessionId = previous.sessionId;
3099
3213
  S.currentWorkspaceId = previous.workspaceId;
@@ -3105,7 +3219,7 @@ async function createAndOpenSession(workspaceId, title, options = {}) {
3105
3219
  if (previous.sessionId) sessionStorage.setItem("pi-web-current-session", previous.sessionId);
3106
3220
  renderSidebar();
3107
3221
  renderConversation();
3108
- alert(`新建会话失败:${error.message}`);
3222
+ alert(t(`新建会话失败:${error.message}`));
3109
3223
  }
3110
3224
  return null;
3111
3225
  } finally {
@@ -3585,7 +3699,7 @@ async function copyText(value) {
3585
3699
  let toastTimer = 0;
3586
3700
  function showToast(message, type = "info") {
3587
3701
  $(".pi-toast")?.remove();
3588
- const toast = el(`<div class="pi-toast pi-toast-${esc(type)}" role="status">${esc(message)}</div>`);
3702
+ const toast = el(`<div class="pi-toast pi-toast-${esc(type)}" role="status">${esc(t(message))}</div>`);
3589
3703
  document.body.appendChild(toast);
3590
3704
  clearTimeout(toastTimer);
3591
3705
  toastTimer = setTimeout(() => toast.remove(), 3600);
@@ -3966,7 +4080,7 @@ async function executeLocalSlashCommand(text) {
3966
4080
  const parsed = parseSlashCommand(text);
3967
4081
  if (!parsed) return false;
3968
4082
  const { name, args } = parsed;
3969
- if (Object.hasOwn(unsupportedSlashCommands, name)) throw new Error(unsupportedSlashCommands[name]);
4083
+ if (Object.hasOwn(unsupportedSlashCommands, name)) throw new Error(t(unsupportedSlashCommands[name]));
3970
4084
  const command = WEB_SLASH_COMMANDS.find((command) => command.name === name);
3971
4085
  if (!command) return false;
3972
4086
  if (args && !command.acceptsArgs) throw new Error(`/${name} 不接受参数${name === "export" ? ",Web 会将 HTML 文件下载到浏览器下载目录" : ""}`);
@@ -4053,12 +4167,15 @@ async function executeLocalSlashCommand(text) {
4053
4167
  const stats = snap.stats || {};
4054
4168
  const tokens = stats.tokens || {};
4055
4169
  const cu = stats.contextUsage || {};
4170
+ const colon = getLanguage() === "en" ? ": " : ":";
4171
+ const openParen = getLanguage() === "en" ? "(" : "(";
4172
+ const closeParen = getLanguage() === "en" ? ")" : ")";
4056
4173
  alert([
4057
- `会话:${snap.session?.title || "新会话"}`,
4058
- `路径:${snap.session?.cwd || "-"}`,
4059
- `模型:${snap.state?.model?.provider || "-"}/${snap.state?.model?.id || "-"} · 思考 ${thinkingLabel(snap.state?.thinkingLevel || "off")}`,
4060
- `上下文:${fmtNum(cu.tokens ?? 0)} / ${fmtNum(cu.contextWindow ?? 0)} tok(${Math.round(cu.percent ?? 0)}%)`,
4061
- `输入 ${fmtNum(tokens.input ?? 0)} · 输出 ${fmtNum(tokens.output ?? 0)} tok · 成本 ${typeof stats.cost === "number" ? formatCost(stats.cost) : "-"}`,
4174
+ `${t("会话")}${colon}${snap.session?.title || t("新会话")}`,
4175
+ `${t("路径")}${colon}${snap.session?.cwd || "-"}`,
4176
+ `${t("模型")}${colon}${snap.state?.model?.provider || "-"}/${snap.state?.model?.id || "-"} · ${t("思考")} ${thinkingLabel(snap.state?.thinkingLevel || "off")}`,
4177
+ `${t("上下文")}${colon}${fmtNum(cu.tokens ?? 0)} / ${fmtNum(cu.contextWindow ?? 0)} tok${openParen}${Math.round(cu.percent ?? 0)}%${closeParen}`,
4178
+ `${t("输入")} ${fmtNum(tokens.input ?? 0)} · ${t("输出")} ${fmtNum(tokens.output ?? 0)} tok · ${t("成本")} ${typeof stats.cost === "number" ? formatCost(stats.cost) : "-"}`,
4062
4179
  ].join("\n"));
4063
4180
  }
4064
4181
  if (S.currentSessionId === sessionId && S.draft.trim() === text.trim()) clearComposerDraft();
@@ -4104,7 +4221,9 @@ async function sendDraft() {
4104
4221
  sessionId = await createAndOpenSession(S.currentWorkspaceId || currentWorkspace().id, title, { keepDraft: true });
4105
4222
  if (!sessionId) return;
4106
4223
  }
4107
- clearComposerDraft({ attachments: true });
4224
+ // Startup can take a while; edits made after Send belong to the next draft.
4225
+ if (S.draft.trim() === text) clearComposerDraft();
4226
+ setDraftAttachments(S.draftAttachments.filter((attachment) => !attachments.some((sent) => sent.id === attachment.id)));
4108
4227
  S.pendingSends.set(sessionId, (S.pendingSends.get(sessionId) || 0) + 1);
4109
4228
  queueRender(true);
4110
4229
  try {
@@ -4498,6 +4617,13 @@ async function onClick(ev) {
4498
4617
  S.maintenanceMessage = null;
4499
4618
  renderOverlay();
4500
4619
  }
4620
+ } else if (action === "choose-language") {
4621
+ setLanguage(target.dataset.language);
4622
+ closePopovers();
4623
+ queueRender(true);
4624
+ renderFilePreview();
4625
+ if (browser) renderBrowser();
4626
+ translateUi();
4501
4627
  } else if (action === "choose-theme") {
4502
4628
  setTheme(target.dataset.theme || "system");
4503
4629
  renderOverlay();
@@ -4687,7 +4813,7 @@ async function onClick(ev) {
4687
4813
  showToast(error.message || String(error), "error");
4688
4814
  }
4689
4815
  } else if (action === "rename-session") {
4690
- const name = prompt("会话名称:", snapshotFor(sessionId).session?.title || "");
4816
+ const name = prompt(t("会话名称:"), snapshotFor(sessionId).session?.title || "");
4691
4817
  if (name && sessionId) {
4692
4818
  await post(`/api/sessions/${encodeURIComponent(sessionId)}/set_name`, { name });
4693
4819
  closePopovers();
@@ -4704,7 +4830,7 @@ async function onClick(ev) {
4704
4830
  }
4705
4831
  } else if (action === "remove-session") {
4706
4832
  if (!sessionId) return;
4707
- if (!confirm("永久删除这个会话?磁盘上的 pi 会话文件也会被删除,此操作不可撤销。")) return;
4833
+ if (!confirm(t("永久删除这个会话?磁盘上的 pi 会话文件也会被删除,此操作不可撤销。"))) return;
4708
4834
  const ws = S.workspaces.find((w) => w.sessions?.some((s) => s.id === sessionId));
4709
4835
  if (ws) {
4710
4836
  try {
@@ -4787,21 +4913,23 @@ async function onClick(ev) {
4787
4913
  } else if (action === "context-info") {
4788
4914
  const stats = currentSnapshot().stats;
4789
4915
  if (!stats) {
4790
- alert("暂无统计");
4916
+ alert(t("暂无统计"));
4791
4917
  return;
4792
4918
  }
4793
4919
  const tokens = stats.tokens || {};
4794
4920
  const cu = stats.contextUsage || {};
4795
4921
  const hasContextUsage = typeof cu.tokens === "number" && Number.isFinite(cu.tokens)
4796
4922
  && typeof cu.contextWindow === "number" && Number.isFinite(cu.contextWindow) && cu.contextWindow > 0;
4923
+ const openParen = getLanguage() === "en" ? "(" : "(";
4924
+ const closeParen = getLanguage() === "en" ? ")" : ")";
4797
4925
  const contextLine = hasContextUsage
4798
- ? `上下文 ${fmtNum(cu.tokens)} / ${fmtNum(cu.contextWindow)} tok(${Math.round(cu.percent ?? 0)}%)`
4799
- : cu.contextWindow ? `上下文占用待更新 / ${fmtNum(cu.contextWindow)} tok` : "上下文占用暂无统计";
4926
+ ? `${t("上下文")} ${fmtNum(cu.tokens)} / ${fmtNum(cu.contextWindow)} tok${openParen}${Math.round(cu.percent ?? 0)}%${closeParen}`
4927
+ : cu.contextWindow ? `${t("上下文占用待更新")} / ${fmtNum(cu.contextWindow)} tok` : t("上下文占用暂无统计");
4800
4928
  const lines = [
4801
4929
  contextLine,
4802
- `输入 ${fmtNum(tokens.input ?? 0)} · 输出 ${fmtNum(tokens.output ?? 0)} · 缓存读 ${fmtNum(tokens.cacheRead ?? 0)} · 缓存写 ${fmtNum(tokens.cacheWrite ?? 0)} tok`,
4803
- `消息 ${stats.totalMessages ?? "?"}(用户 ${stats.userMessages ?? "?"} / 助手 ${stats.assistantMessages ?? "?"} / 工具 ${stats.toolCalls ?? "?"})`,
4804
- typeof stats.cost === "number" ? `成本 ${formatCost(stats.cost)}` : "",
4930
+ `${t("输入")} ${fmtNum(tokens.input ?? 0)} · ${t("输出")} ${fmtNum(tokens.output ?? 0)} · ${t("缓存读")} ${fmtNum(tokens.cacheRead ?? 0)} · ${t("缓存写")} ${fmtNum(tokens.cacheWrite ?? 0)} tok`,
4931
+ `${t("消息")} ${stats.totalMessages ?? "?"}${openParen}${t("用户")} ${stats.userMessages ?? "?"} / ${t("助手")} ${stats.assistantMessages ?? "?"} / ${t("工具")} ${stats.toolCalls ?? "?"}${closeParen}`,
4932
+ typeof stats.cost === "number" ? `${t("成本")} ${formatCost(stats.cost)}` : "",
4805
4933
  ].filter(Boolean);
4806
4934
  alert(lines.join("\n"));
4807
4935
  } else if (action === "view-options") {
@@ -4813,7 +4941,7 @@ async function onClick(ev) {
4813
4941
  <button type="button" role="menuitem" class="_7KE1Ra_cell" data-action="settings"><span class="_7KE1Ra_cellLabel">设置</span></button>
4814
4942
  </div>`);
4815
4943
  } else if (action === "pi-agent-chip") {
4816
- alert("该 Web UI 由 pi coding agent 的 RPC 会话驱动。");
4944
+ alert(t("该 Web UI 由 pi coding agent 的 RPC 会话驱动。"));
4817
4945
  } else if (action === "upload-files") {
4818
4946
  selectAttachmentFiles();
4819
4947
  } else if (action === "workspace-menu") {
@@ -4831,7 +4959,7 @@ async function onClick(ev) {
4831
4959
  closePopovers();
4832
4960
  queueRender(true);
4833
4961
  } else if (action === "remove-workspace") {
4834
- if (!confirm("从侧边栏移除该工作区?磁盘上的会话文件会保留。")) return;
4962
+ if (!confirm(t("从侧边栏移除该工作区?磁盘上的会话文件会保留。"))) return;
4835
4963
  await api(`/api/workspaces/${encodeURIComponent(target.dataset.workspace)}`, { method: "DELETE" });
4836
4964
  await refreshWorkspaces();
4837
4965
  const currentStillExists = S.workspaces.some((w) => w.sessions?.some((s) => s.id === S.currentSessionId));
@@ -5167,6 +5295,8 @@ window.addEventListener("online", () => { if (eventSource && !loginPromise) conn
5167
5295
  document.addEventListener("visibilitychange", () => {
5168
5296
  if (!document.hidden && eventSource && !loginPromise) connectEvents();
5169
5297
  });
5298
+ setLanguage(getLanguage());
5299
+ observeUi();
5170
5300
  setTheme(S.theme);
5171
5301
  setThemeStyle(S.themeStyle);
5172
5302
  matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
package/web/i18n.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export function getLanguage(): "zh-CN" | "en";
2
+ export function setLanguage(value: string): void;
3
+ export function t<T extends string>(value: T): string;
4
+ export function translateUi(root?: Node): void;
5
+ export function observeUi(): MutationObserver;
package/web/i18n.js ADDED
@@ -0,0 +1,153 @@
1
+ // Browser-only UI copy. Chinese is the source language; unknown text (including
2
+ // user messages, paths and server responses) is left untouched.
3
+ const english = {
4
+ "查看结果": "View result", "结果来源": "Result from", "执行结果": "Agent result", "当前进展": "Progress", "执行记录": "Execution transcript", "状态": "Status", "失败原因": "Failure reason", "等待执行": "Pending", "已取消": "Canceled", "正在获取结果": "Fetching result", "子 agent 仍在运行,尚无结果。": "The agent is still running; no result yet.", "暂无结果内容。": "No result content available.",
5
+ "类型": "Type", "委派任务": "Delegated task", "提示词": "Prompt", "结果": "Result", "补充指令": "Additional instructions", "目标 agent": "Target agent", "指令内容": "Instruction content",
6
+ "当前运行时未提供完整指令内容。请等待所有会话空闲,在设置 → 维护中重新加载运行时,然后刷新页面。": "The current runtime did not provide the full instructions. Wait until all sessions are idle, reload the runtime in Settings → Maintenance, then refresh the page.",
7
+ "当前运行时未提供完整提示词。请等待所有会话空闲,在设置 → 维护中重新加载运行时,然后刷新页面。": "The current runtime did not provide the full prompt. Wait until all sessions are idle, reload the runtime in Settings → Maintenance, then refresh the page.",
8
+ "刚刚": "just now", "选择模型": "Select model", "内置": "Built-in", "模板": "Prompt", "扩展": "Extension", "技能": "Skill", "命令": "Command",
9
+ "文件预览": "File preview", "文件": "File", "正在读取": "Reading", "无法预览": "Preview unavailable", "正在加载文件…": "Loading file…", "文件预览失败": "File preview failed", "预览": "Preview", "源代码": "Source code", "返回对话": "Back to chat", "查看源码": "View source", "查看预览": "View preview", "源码": "Source", "在系统文件管理器中定位": "Show in file manager", "关闭预览": "Close preview", "纯文本模式": "Plain text mode",
10
+ "关闭侧边栏": "Close sidebar", "调整侧边栏宽度": "Resize sidebar", "调整文件预览宽度": "Resize file preview", "可拖动排序": "drag to reorder", "新会话": "New session", "等待输入": "Waiting for input", "会话操作": "Session actions", "工作区操作": "Workspace actions", "新建会话": "New session", "没有匹配的会话": "No matching sessions", "暂无会话": "No sessions yet", "没有工作区。点击 + 添加一个目录。": "No workspaces. Click + to add a folder.", "收起侧边栏": "Collapse sidebar", "展开侧边栏": "Expand sidebar", "工作区": "Workspaces", "搜索会话": "Search sessions", "搜索会话…": "Search sessions…", "视图选项": "View options", "添加工作区": "Add workspace", "会话": "Sessions", "设置": "Settings", "选择工作区": "Select workspace", "和你的代码库对话": "Chat with your codebase", "Web 预览版": "Web preview", "正在创建会话…": "Creating session…", "这个会话由 pi coding agent 驱动": "This session runs on pi coding agent",
11
+ "正在加载命令…": "Loading commands…", "没有匹配的命令": "No matching commands", "斜杠命令": "Slash commands", "已解析": "Parsed", "字符": "characters", "移除附件": "Remove attachment", "当前模型不支持图片输入": "Current model does not support images", "正在读取并解析文件…": "Reading and parsing files…", "待发送附件": "Attachments to send", "请先回答对话中的问题": "Answer the question in the conversation first", "给智能体发消息": "Message the agent", "上下文占用待更新": "Context usage pending", "上传文件": "Upload files", "上传文件(文本、代码、PDF、DOCX、XLSX、PPTX、图片)": "Upload files (text, code, PDF, DOCX, XLSX, PPTX, images)", "粘贴剪贴板图片": "Paste clipboard image", "选择图片": "Choose image", "粘贴剪贴板图片(Alt+V)": "Paste clipboard image (Alt+V)", "从手机选择图片": "Choose image from phone", "选择思考强度": "Select thinking level", "停止生成": "Stop generating", "插入消息": "Steer response", "发送消息": "Send message",
12
+ "查看后台任务日志": "View background job logs", "后台任务日志": "Background job logs", "关闭日志": "Close logs", "任务": "Job", "选择后台任务": "Select background job", "正在加载…": "Loading…", "运行日志": "Run logs", "当前会话暂无后台任务": "No background jobs in this session", "自动刷新": "Auto refresh", "仅显示最近的日志": "Showing recent logs only", "暂无输出,等待任务产生日志…": "No output yet. Waiting for the job…", "打开侧边栏": "Open sidebar", "会话层级": "Session hierarchy", "对话": "Chat", "会话历史": "Session history", "待插入": "To steer", "待后续处理": "Queued", "撤回并重新编辑这条消息": "Withdraw and edit this message", "撤回并重新编辑": "Withdraw and edit", "等待你的输入…": "Waiting for your input…", "正在生成…": "Generating…", "正在加载会话…": "Loading session…", "正在启动 Pi…": "Starting Pi…", "正在启动 pi…": "Starting pi…", "(空消息)": "(empty message)", "用户对话导航": "Conversation navigation", "回到对话底部": "Scroll to bottom",
13
+ "手动触发": "Manual", "达到上下文阈值": "Context threshold", "上下文溢出恢复": "Overflow recovery", "上下文维护": "Context maintenance", "压缩前": "Before compaction", "压缩后约": "After compaction (est.)", "摘要实际消耗": "Summary usage", "会话摘要": "Session summary", "正在压缩上下文": "Compacting context", "上下文已压缩": "Context compacted", "上下文压缩已取消": "Compaction canceled", "上下文压缩失败": "Compaction failed", "正在生成会话摘要,请稍候": "Generating session summary…", "压缩后自动重试": "Retry after compaction", "未返回错误详情": "No error details returned", "原请求仍会重试": "Original request will retry", "已完成": "Completed", "模型调用失败,但 Pi 未返回错误详情": "Model call failed; Pi returned no details", "生成失败": "Generation failed", "复制": "Copy", "复制本条 AI 回复": "Copy this AI response", "失败": "Failed", "完成": "Done", "运行中": "Running", "行输出": "lines of output",
14
+ "没有匹配的模型": "No matching models", "暂无可用模型": "No models available", "正在加载模型…": "Loading models…", "模型": "Model", "模糊搜索模型": "Search models", "模糊搜索模型…": "Search models…", "↑↓ 选择 · Enter 确认 · Esc 关闭": "↑↓ Select · Enter Confirm · Esc Close", "关闭额外推理": "Disable extra reasoning", "正在加载思考强度…": "Loading thinking levels…", "当前模型没有可用思考强度": "No thinking levels available for this model", "思考强度": "Thinking level", "回溯与会话树": "Rewind and session tree", "重命名": "Rename", "导出 HTML": "Export HTML", "删除会话": "Delete session", "请确认": "Please confirm", "请输入": "Enter a value", "请选择": "Please select", "确认": "Confirm", "取消": "Cancel", "确定": "OK", "回溯点": "Rewind point", "用户": "User", "助手": "Assistant", "工具": "Tool", "压缩": "Compaction", "分支摘要": "Branch summary", "思考": "Thinking", "扩展消息": "Extension message", "扩展状态": "Extension status", "标签": "Label", "节点": "Node", "正在读取会话树…": "Loading session tree…", "最近": "Latest", "当前路径": "Current path", "历史分支": "History branch", "当前会话还没有可回溯的用户消息": "No messages to rewind to", "当前位置": "Current position", "当前会话树为空": "Session tree is empty", "选择回溯范围": "Choose rewind scope", "仅回溯对话": "Rewind conversation only", "保留工作区当前文件": "Keep current workspace files", "对话和文件一起回溯": "Rewind conversation and files", "文件恢复到该时点最近的 pi-rewind 检查点": "Restore files to the nearest pi-rewind checkpoint", "返回切换前的位置": "Return to previous position", "正在回溯对话和文件…": "Rewinding conversation and files…", "正在切换会话分支…": "Switching session branch…", "导出记录": "Export transcript", "回溯": "Rewind", "会话树": "Session tree",
15
+ "访问方式": "Access mode", "仅本机": "This computer only", "局域网": "Local network", "服务器中转": "Server relay", "仅在这台电脑上访问。": "Only accessible on this computer.", "中转服务器": "Relay server", "服务器地址": "Server address", "服务器 IP 或域名": "Server IP or domain", "frps 端口": "frps port", "认证密钥": "Authentication token", "输入认证密钥": "Enter authentication token", "HTTPS 访问地址": "HTTPS URL", "本机客户端": "Local client", "frpc 路径": "frpc path", "留空使用 PATH 中的 frpc,由 Pi 自动启动。": "Leave blank to use frpc from PATH; Pi starts it automatically.", "下载 frpc": "Download frpc", "高级设置": "Advanced settings", "隧道与端口": "Tunnel and ports", "隧道名": "Tunnel name", "使用默认名称": "Use default name", "服务器内部端口": "Internal server port", "保存后生效": "Takes effect after saving", "保存设置": "Save settings", "访问链接": "Access links", "服务器配置": "Server configuration", "下载已保存的配置,证书路径请按服务器修改。": "Download the saved configuration and adjust certificate paths on the server.", "让同一 Wi-Fi 或局域网内的设备访问。": "Allow devices on the same Wi-Fi or local network.", "通过自己的服务器,在外网访问 Pi。": "Access Pi over the internet through your own server.", "有未保存的更改": "Unsaved changes", "已设置,留空保留现有密钥": "Configured; leave blank to keep the current token", "保存中…": "Saving…", "正在应用…": "Applying…", "保存并连接": "Save and connect", "未连接": "Disconnected", "连接中": "Connecting", "已连接": "Connected", "连接失败": "Connection failed", "正在加载网络设置…": "Loading network settings…", "正在应用配置…": "Applying configuration…", "手机访问链接": "Mobile access link", "复制链接": "Copy link",
16
+ "代理方式": "Proxy mode", "跟随启动环境": "Inherit startup environment", "手动代理": "Manual proxy", "直连": "Direct", "代理地址": "Proxy address", "HTTP / HTTPS 代理": "HTTP / HTTPS proxy", "请先开启 VPN,填写其 HTTP 或混合端口。仅提供 SOCKS 的端口无法用于此设置。": "Start the VPN, then enter its HTTP or mixed port. SOCKS-only ports are not supported here.", "用于 Pi Web 启动的 Pi 会话及其终端命令。保存后对新启动的会话进程生效;已有会话请在“维护”中重新加载运行时。此设置不会修改 Windows 全局代理。": "Applies to Pi sessions started by Pi Web and their terminal commands. New sessions use the saved proxy; reload existing sessions under Maintenance. Windows proxy settings are unaffected.", "保存代理": "Save proxy", "沿用启动 Pi 时的代理环境变量。": "Use the proxy environment variables present when Pi started.", "通过指定代理访问模型服务,本机地址保持直连。": "Use the specified proxy for model services; local addresses connect directly.", "清除会话的代理环境变量,直接连接模型服务。": "Remove session proxy variables and connect directly to model services.", "新启动的会话进程生效": "Applies to new sessions", "正在加载终端代理…": "Loading terminal proxy…",
17
+ "已配置的扩展包": "Configured extension packages", "显示全局与启动项目的包配置。包中可能同时包含技能、提示词和主题。": "Shows packages from the global and startup project configuration. Packages may also include skills, prompts and themes.", "处理中…": "Working…", "刷新列表": "Refresh list", "启动项目": "Startup project", "全局": "Global", "已启用(按过滤规则)": "Enabled (filtered)", "已启用": "Enabled", "已停用": "Disabled", "停用": "Disable", "启用": "Enable", "正在读取扩展配置…": "Loading extension configuration…", "暂无扩展包配置。": "No extension packages configured.", "尚未读取扩展配置。": "Extension configuration has not been loaded.", "开关仅影响包内扩展,保留其他资源与原有过滤规则。项目配置可能覆盖全局配置。此列表不包含自动发现的本地扩展或命令行临时扩展。": "These switches affect only extensions in each package. Other resources and filters remain. Project settings may override global settings. Automatically discovered local extensions and temporary CLI extensions are not shown.", "应用配置": "Apply configuration", "已有会话进程请在空闲后重载。停用 Pi Web 所在包会关闭网页服务。": "Reload existing sessions when idle. Disabling the package containing Pi Web will stop the web service.", "重新加载": "Reload",
18
+ "界面语言": "Interface language", "中文": "Chinese", "外观": "Appearance", "外观与主题": "Appearance and themes", "选择适合你的主题,专注于眼前的对话。": "Choose a theme that suits your workspace.", "连接": "Connection", "设备连接": "Device connection", "管理这台电脑的访问方式与连接地址。": "Manage how other devices connect to this computer.", "代理": "Proxy", "终端代理": "Terminal proxy", "为 Pi 调用模型及终端命令配置网络代理。": "Configure the network proxy for Pi sessions and terminal commands.", "安全": "Security", "访问安全": "Access security", "使用固定密码,保护其他设备对 Pi 的访问。": "Set a password for access from other devices.", "Pi 扩展": "Pi extensions", "管理扩展包的加载配置,保存后重新加载运行时生效。": "Manage extension packages; reload to apply changes.", "维护": "Maintenance", "更新与维护": "Updates and maintenance", "更新已安装的扩展,或重新加载运行时。": "Update installed extensions or reload the runtime.", "关闭设置": "Close settings", "关闭": "Close", "设置分类": "Settings categories", "本机设置": "Local settings", "远程连接": "Remote connection", "主题风格": "Theme style", "即时生效": "Applies immediately", "浅色": "Light", "深色": "Dark", "跟随系统": "System", "经典": "Classic", "简洁 · 专注": "Clean · Focused", "樱花来信": "Sakura", "樱粉 · 春日": "Pink · Spring", "星夜列车": "Starlight", "星蓝 · 微光": "Blue · Starlight", "明暗模式": "Color mode", "跟随系统会随设备的深浅色模式自动切换。": "System mode follows your device appearance.", "聊天背景": "Chat background", "当前浏览器": "This browser", "使用主题默认背景": "Use theme background", "PNG、JPEG 或 WebP,最大 10 MB。图片仅保存在当前浏览器,刷新后保留。": "PNG, JPEG or WebP, up to 10 MB. Saved in this browser and kept after refresh.", "更换图片": "Change image", "上传图片": "Upload image", "恢复默认": "Restore default", "已连接远程服务": "Connected to a remote service", "访问方式与中转配置可在运行 Pi 的电脑上管理。": "Manage access and relay settings on the computer running Pi.", "在本机管理终端代理": "Manage terminal proxy locally", "请在运行 Pi 的电脑上配置代理地址。": "Configure the proxy on the computer running Pi.", "访问密码": "Access password", "已设置": "Set", "未设置": "Not set", "更换密码": "Change password", "设置密码": "Set password", "输入新密码": "Enter new password", "输入固定密码": "Enter password", "保存密码": "Save password", "修改后,其他设备需要重新登录。": "Other devices must log in again after a change.", "设置后,其他设备可用此密码登录。": "Other devices can log in with this password.", "在本机管理密码": "Manage password locally", "请在运行 Pi 的电脑上设置或更换访问密码。": "Set or change the password on the computer running Pi.", "在本机管理扩展": "Manage extensions locally", "请在运行 Pi 的电脑上修改扩展配置。": "Change extension settings on the computer running Pi.", "更新扩展": "Update extensions", "获取已安装扩展的最新版本并重载。": "Fetch the latest installed extensions and reload.", "更新中": "Updating…", "更新并重载": "Update and reload", "重新加载运行时": "Reload runtime", "应用本地的扩展与配置变更。": "Apply local extension and configuration changes.", "重载中": "Reloading…",
19
+ "主页": "Home", "编辑路径": "Edit path", "新建文件夹": "New folder", "在": "Create in", "中创建": "", "未命名文件夹": "Untitled folder", "创建": "Create", "选择工作区目录": "Select workspace folder", "加载中…": "Loading…", "显示隐藏文件": "Show hidden files", "打开": "Open", "手动复制": "Manual copy", "长按文本复制": "Press and hold to copy", "登录 Pi Web": "Sign in to Pi Web", "输入访问密码,继续使用会话。": "Enter your password to continue.", "请先在电脑网页的「设置 → 手机访问」中设置密码。": "Set a password in Settings on the computer running Pi.", "登录": "Sign in", "重新检查": "Check again", "无法连接到 pi-web": "Could not connect to pi-web",
20
+ "扫描当前工作区的 pi 会话": "Scan this workspace for pi sessions", "移除当前工作区": "Remove current workspace", "在系统文件管理器中打开": "Open in file manager", "扫描 pi 会话": "Scan pi sessions", "移除工作区": "Remove workspace",
21
+ "打开界面与运行时设置": "Open UI and runtime settings", "选择当前模型": "Select current model", "将当前会话下载为 HTML": "Download the current session as HTML", "复制最后一条助手回复": "Copy the last assistant response", "设置当前会话名称": "Rename current session", "显示会话统计信息": "Show session statistics", "在当前工作区创建新会话": "Create a session in this workspace", "打开侧栏搜索并切换会话": "Search and switch sessions", "回溯对话,可选择同步恢复文件": "Rewind conversation and optionally restore files", "打开当前会话的分支树": "Open the current session tree", "压缩当前会话,可附加压缩要求": "Compact the session with optional instructions", "重新加载当前会话的扩展与配置": "Reload extensions and settings for this session",
22
+ };
23
+
24
+ Object.assign(english, {
25
+ "返回": "Back", "路径": "Path", "上下文": "Context", "输入": "Input", "输出": "Output", "缓存读": "Cache read", "缓存写": "Cache write", "消息": "Messages", "成本": "Cost", "无": "None", "上下文占用暂无统计": "No context usage statistics", "生成未响应,已重启该会话": "Generation stalled; session restarted", "已请求停止生成": "Stop requested", "无法读取本地背景,请检查浏览器的存储权限。": "Could not load the background. Check browser storage permissions.", "请选择 PNG、JPEG 或 WebP 图片。": "Choose a PNG, JPEG or WebP image.", "图片不能超过 10 MB。": "Image must be 10 MB or smaller.", "图片处理失败,请尝试其他图片。": "Could not process the image. Try another one.", "无法保存图片,请检查浏览器的存储空间与权限。": "Could not save the image. Check browser storage and permissions.",
26
+ "· 纯文本模式": "· Plain text mode", "· 当前路径": "· Current path", "· 历史分支": "· History branch", "· 当前位置": "· Current position", "· 仅显示最近的日志": "· Showing recent logs only",
27
+ "访问密码已保存": "Access password saved", "已保存,重新加载运行时后生效。": "Saved. Reload the runtime to apply changes.", "已保存。已有会话请在“维护”中重新加载运行时后使用新代理。": "Saved. Reload existing sessions under Maintenance to use the new proxy.", "重新加载运行时以启用网络设置。": "Reload the runtime to enable network settings.", "重新加载运行时以启用终端代理设置。": "Reload the runtime to enable terminal proxy settings.", "服务器未返回此配置文件": "The server did not return this configuration file", "请先打开一个会话": "Open a session first", "当前输入框草稿将被历史消息替换,继续回溯?": "The message draft will be replaced by the selected message. Continue?", "切换会话节点将清空当前输入框草稿,继续?": "Switching session nodes will clear the current draft. Continue?", "已回溯对话和文件,可编辑后重新发送": "Conversation and files rewound. Edit and send again.", "已切换会话节点并恢复文件": "Session node switched and files restored", "已回溯对话,工作区文件保持不变": "Conversation rewound; workspace files unchanged", "已回溯到所选消息,可编辑后重新发送": "Rewound to the selected message. Edit and send again.", "已切换到所选会话节点": "Switched to the selected session node",
28
+ "当前没有可导出的会话": "No session to export", "会话导出失败": "Session export failed", "每条消息最多附加 8 个文件": "Up to 8 files per message", "文件不能超过 15 MB": "Files must be 15 MB or smaller", "会话已切换,本次上传已取消": "Session changed; upload canceled", "每条消息最多附加 4 张图片": "Up to 4 images per message", "当前模型不支持图片输入,请先切换视觉模型": "Choose a vision model to attach images", "每条消息最多附加 8 个文件,其中图片最多 4 张": "Up to 8 files per message, including 4 images", "会话已切换,本次粘贴已取消": "Session changed; paste canceled", "服务端未返回有效图片附件": "Server did not return a valid image attachment", "图片已附加到消息": "Image attached", "剪贴板内容为空": "Clipboard is empty", "已粘贴剪贴板文本": "Clipboard text pasted", "队列已经变化,请重试": "Queue changed; try again", "消息已撤回到输入框": "Message returned to the composer", "没有待恢复的队列消息": "No queued messages to restore", "请先打开工作区": "Open a workspace first", "请先打开会话": "Open a session first", "当前 Web 服务尚未加载重载接口。请在设置 → 维护中重新加载,或在启动 Pi Web 的终端执行 /reload,然后刷新网页。": "Reload the runtime under Settings → Maintenance, or run /reload in the Pi terminal, then refresh the page.", "服务端未确认重载完成,请重新加载 Pi Web 服务后再试。": "Server did not confirm the reload. Restart Pi Web and try again.", "当前会话的扩展与配置已重新加载": "Extensions and configuration reloaded for this session", "正在压缩当前会话…": "Compacting this session…", "上下文压缩完成": "Context compaction complete", "未命名": "Untitled", "会话名称已更新": "Session renamed", "暂无可复制的助手回复": "No assistant response to copy", "复制失败,请检查浏览器剪贴板权限": "Copy failed. Check browser clipboard permissions.", "已复制最后一条助手回复": "Last assistant response copied", "正在上传并解析文件,请稍候": "Uploading and parsing files…", "生成过程中暂不支持排队附件,请停止当前生成后再发送": "Stop generation before sending attachments", "当前模型不支持图片输入,请切换视觉模型": "Choose a vision model to send images", "斜杠命令不能附带附件": "Slash commands cannot include attachments", "文件会话": "File session", "消息已加入当前执行队列": "Message queued", "已复制": "Copied", "这条 AI 回复没有可复制的正文": "This AI response has no text to copy", "已复制本条 AI 回复": "AI response copied", "暂无统计": "No statistics available", "永久删除这个会话?磁盘上的 pi 会话文件也会被删除,此操作不可撤销。": "Permanently delete this session and its file on disk? This cannot be undone.", "从侧边栏移除该工作区?磁盘上的会话文件会保留。": "Remove this workspace from the sidebar? Session files on disk will be kept.", "该 Web UI 由 pi coding agent 的 RPC 会话驱动。": "This Web UI is powered by pi coding agent RPC sessions.", "重新加载超时,请刷新页面后重试": "Reload timed out. Refresh and try again.", "正在更新扩展,请勿关闭页面…": "Updating extensions. Keep this page open…", "正在重新加载 Pi 运行时…": "Reloading the Pi runtime…", "扩展更新完成,正在应用新版本…": "Extensions updated. Applying the new version…", "重载已启动,正在恢复 Web UI…": "Reload started. Restoring the Web UI…", "重新加载失败": "Reload failed", "模型调用失败": "Model call failed",
29
+ "Web 暂不支持终端模型轮换范围,请使用 /model 选择模型。": "Terminal scoped model cycling is unavailable on the web. Use /model.", "Web 暂不支持从命令导入 JSONL 文件,请在 Pi 终端执行 /import。": "Importing JSONL is unavailable on the web. Run /import in the Pi terminal.", "Web 暂不支持发布 GitHub gist,请使用 /export 下载会话,或在 Pi 终端执行 /share。": "Publishing GitHub gists is unavailable on the web. Use /export or run /share in the Pi terminal.", "Web 暂不支持创建分叉副本,请在 Pi 终端执行 /fork;同一会话内切换分支可使用 /tree。": "Forking sessions is unavailable on the web. Run /fork in the Pi terminal or use /tree to switch branches.", "Web 暂不支持复制整个会话,请在 Pi 终端执行 /clone。": "Cloning sessions is unavailable on the web. Run /clone in the Pi terminal.", "项目信任设置需要在 Pi 终端中通过 /trust 管理。": "Manage project trust with /trust in the Pi terminal.", "Web 暂不展示 Pi 更新日志,请在 Pi 终端执行 /changelog。": "The Pi changelog is unavailable on the web. Run /changelog in the Pi terminal.", "终端快捷键不适用于网页。网页输入框支持 Enter 发送、Shift+Enter 换行,命令菜单支持方向键选择和 Tab 补全。": "Terminal shortcuts do not apply in the browser. Use Enter to send, Shift+Enter for a newline, arrow keys to select commands and Tab to complete.", "此调试命令仅支持 Pi 终端。": "This debug command is available only in the Pi terminal.", "此彩蛋命令仅支持 Pi 终端。": "This command is available only in the Pi terminal.", "Web 暂不支持登录流程,请在启动 Pi 的终端执行 /login。": "Login is unavailable on the web. Run /login in the Pi terminal.", "Web 暂不支持注销流程,请在启动 Pi 的终端执行 /logout。": "Logout is unavailable on the web. Run /logout in the Pi terminal.", "请关闭网页标签页;如需退出 Pi,请在启动 Pi 的终端执行 /quit。": "Close this tab; to exit Pi, run /quit in the Pi terminal.",
30
+ });
31
+
32
+ const dynamic = [
33
+ [/^(\d+)分钟$/, (_, n) => `${n}m ago`], [/^(\d+)小时$/, (_, n) => `${n}h ago`], [/^(\d+)天$/, (_, n) => `${n}d ago`],
34
+ [/^(.+),可拖动排序$/, (_, name) => `${name}, drag to reorder`],
35
+ [/^收起至最近 (\d+) 个$/, (_, n) => `Show latest ${n}`], [/^展开其余 (\d+) 个$/, (_, n) => `Show ${n} more`],
36
+ [/^(\d+) 行$/, (_, n) => `${n} lines`], [/^(\d+) 行输出$/, (_, n) => `${n} lines of output`],
37
+ [/^(.+) · (\d+) 行 · (.+?)( · 纯文本模式)?$/, (_, language, n, size, plain) => `${language} · ${n} lines · ${size}${plain ? " · Plain text mode" : ""}`],
38
+ [/^(.+) (预览|源代码)$/, (_, name, mode) => `${name} ${t(mode)}`],
39
+ [/^(失败|完成) · (\d+) 行输出$/, (_, state, n) => `${t(state)} · ${n} lines of output`],
40
+ [/^缓存读 ([\d,]+) · 写 ([\d,]+)$/, (_, read, written) => `Cache read ${read} · write ${written}`],
41
+ [/^摘要用量 (.+) tok · \$(.+)$/, (_, n, cost) => `Summary usage ${n} tok · $${cost}`],
42
+ [/^已解析 · ([\d,]+) 字符$/, (_, n) => `Parsed · ${n} characters`],
43
+ [/^图片 (\d+)$/, (_, n) => `Image ${n}`], [/^移除 (.+)$/, (_, name) => `Remove ${name}`],
44
+ [/^上下文已用 (\d+)%$/, (_, n) => `Context ${n}% used`],
45
+ [/^上下文 (.+)$/, (_, detail) => `Context ${detail}`], [/^待处理 (\d+)$/, (_, n) => `${n} queued`],
46
+ [/^输入 (.+) · 输出 (.+) tok$/, (_, input, output) => `Input ${input} · Output ${output} tok`],
47
+ [/^缓存读 (.+)$/, (_, n) => `Cache read ${n}`], [/^写 (.+)$/, (_, n) => `write ${n}`],
48
+ [/^读取日志失败:(.+)$/, (_, detail) => `Failed to load logs: ${detail}`],
49
+ [/^第 (\d+) 次对话$/, (_, n) => `Turn ${n}`], [/^跳到第 (\d+) 次对话:(.+)$/, (_, n, detail) => `Go to turn ${n}: ${detail}`],
50
+ [/^摘要用量 (.+) tok$/, (_, n) => `Summary usage ${n} tok`],
51
+ [/^背景未更改:(.+)$/, (_, error) => `Background unchanged: ${t(error)}`],
52
+ [/^命令加载失败:(.+)$/, (_, error) => `Failed to load commands: ${error}`],
53
+ [/^模型刷新失败,继续使用缓存:(.+)$/, (_, error) => `Model refresh failed; using cache: ${error}`],
54
+ [/^模型加载失败:(.+)$/, (_, error) => `Failed to load models: ${error}`],
55
+ [/^思考强度加载失败:(.+)$/, (_, error) => `Failed to load thinking levels: ${error}`],
56
+ [/^加载网络设置失败:(.+)。重新打开设置可重试。$/, (_, error) => `Failed to load network settings: ${error}. Reopen Settings to retry.`],
57
+ [/^加载失败:(.+)。重新打开设置可重试。$/, (_, error) => `Failed to load: ${error}. Reopen Settings to retry.`],
58
+ [/^保存失败:(.+)$/, (_, error) => `Save failed: ${error}`],
59
+ [/^新建会话失败:(.+)$/, (_, error) => `Failed to create session: ${error}`],
60
+ [/^已下载到浏览器下载目录:(.+)$/, (_, name) => `Downloaded to browser downloads: ${name}`],
61
+ [/^已导出:(.+)$/, (_, path) => `Exported: ${path}`],
62
+ [/^无法读取文件:(.+)$/, (_, name) => `Could not read file: ${name}`],
63
+ [/^(\d+) 条队列消息已恢复到输入框$/, (_, n) => `${n} queued messages restored to the composer`],
64
+ [/^模型已切换为 (.+)$/, (_, name) => `Model changed to ${name}`],
65
+ [/^思考强度已切换为 (.+)$/, (_, level) => `Thinking level changed to ${level}`],
66
+ [/^当前模型不支持思考强度 (.+),可选:(.+)$/, (_, level, options) => `Thinking level ${level} is unavailable. Available: ${options}`],
67
+ [/^会话名称:(.+)$/, (_, name) => `Session name: ${t(name)}`],
68
+ [/^发送失败,消息已保留(在输入框中|到原会话草稿):(.+)$/, (_, where, error) => `Send failed; message saved ${where === "在输入框中" ? "in the composer" : "in its session draft"}: ${error}`],
69
+ [/^复制失败:(.+)$/, (_, error) => `Copy failed: ${error}`],
70
+ [/^删除会话失败:(.+)$/, (_, error) => `Failed to delete session: ${error}`],
71
+ [/^工作区顺序已保存在当前浏览器,服务端同步失败:(.+)$/, (_, error) => `Workspace order saved in this browser; server sync failed: ${error}`],
72
+ [/^已在系统文件管理器中打开 (.+)$/, (_, path) => `Opened in file manager: ${path}`],
73
+ [/^已停止生成,(\d+) 条待处理消息已恢复到输入框$/, (_, n) => `Generation stopped; ${n} queued messages restored`],
74
+ [/^(.+) 需要由 Web 命令入口执行,请刷新网页后重试。$/, (_, name) => `${name} must run through the Web command menu. Refresh and try again.`],
75
+ [/^推理强度:(.+)$/, (_, level) => `Thinking level: ${level}`],
76
+ [/^(\d+) 个分支$/, (_, n) => `${n} branches`],
77
+ [/^(\d+) 条用户消息 · (\d+) 个历史分支节点$/, (_, a, b) => `${a} user messages · ${b} branch nodes`],
78
+ [/^启动项目:(.+)$/, (_, value) => `Startup project: ${value}`],
79
+ ];
80
+
81
+ const sources = new WeakMap();
82
+ let language = null;
83
+
84
+ export function getLanguage() {
85
+ if (language) return language;
86
+ try { language = localStorage.getItem("pi-web-language") === "en" ? "en" : "zh-CN"; }
87
+ catch { language = "zh-CN"; }
88
+ return language;
89
+ }
90
+
91
+ export function setLanguage(value) {
92
+ language = value === "en" ? "en" : "zh-CN";
93
+ localStorage.setItem("pi-web-language", language);
94
+ document.documentElement.lang = language;
95
+ }
96
+
97
+ export function t(value) {
98
+ if (getLanguage() !== "en" || typeof value !== "string") return value;
99
+ if (Object.hasOwn(english, value)) return english[value];
100
+ for (const [pattern, replace] of dynamic) {
101
+ const match = pattern.exec(value);
102
+ if (match) return replace(...match);
103
+ }
104
+ return value;
105
+ }
106
+
107
+ const skipText = "script,style,textarea,code,pre,._markdown_1nba0_5,._text_1pfhk_1,.pi-filePreviewScroll,.pi-copyField,.pi-historyPromptText,.pi-historyTreeText,.pi-historyDecisionText,.db-rowName,.db-crumb,.pi-fileAttachmentName,.pi-dialogMessage,.pi-assistantErrorMessage,.CY-8Ka_title,.CY-8Ka_command,.pXSMma_workspaceLabel,.wSkVaW_crumbCurrent,.pi-backgroundCopy strong,.pi-extensionList strong";
108
+ const skipAttributes = ".db-crumb,.pi-sidebarRailButton,.pi-attachmentItem,.pi-modelOption";
109
+ const attributes = ["aria-label", "title", "placeholder"];
110
+
111
+ function translateNode(node) {
112
+ if (node.nodeType === 3) {
113
+ if (!node.parentElement || node.parentElement.closest(skipText)) return;
114
+ const current = node.nodeValue;
115
+ const saved = sources.get(node);
116
+ const original = saved?.translated === current ? saved.original : current;
117
+ const match = /^(\s*)(.*?)(\s*)$/s.exec(original);
118
+ const translated = match[1] + t(match[2]) + match[3];
119
+ sources.set(node, { original, translated });
120
+ if (translated !== current) node.nodeValue = translated;
121
+ return;
122
+ }
123
+ if (node.nodeType !== 1) return;
124
+ if (node.matches(skipText)) return;
125
+ for (const attr of attributes) {
126
+ if (node.matches(skipAttributes) || !node.hasAttribute(attr)) continue;
127
+ const current = node.getAttribute(attr);
128
+ const saved = sources.get(node) || {};
129
+ const original = saved[attr]?.translated === current ? saved[attr].original : current;
130
+ const translated = t(original);
131
+ saved[attr] = { original, translated };
132
+ sources.set(node, saved);
133
+ if (translated !== current) node.setAttribute(attr, translated);
134
+ }
135
+ for (const child of node.childNodes) translateNode(child);
136
+ }
137
+
138
+ export function translateUi(root = document.body) {
139
+ translateNode(root);
140
+ }
141
+
142
+ export function observeUi() {
143
+ const observer = new MutationObserver((records) => {
144
+ for (const record of records) {
145
+ if (record.type === "characterData") translateNode(record.target);
146
+ else if (record.type === "attributes") translateNode(record.target);
147
+ else for (const node of record.addedNodes) translateNode(node);
148
+ }
149
+ });
150
+ observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: attributes });
151
+ translateUi();
152
+ return observer;
153
+ }
package/web/index.html CHANGED
@@ -13,6 +13,7 @@
13
13
  <body>
14
14
  <script>
15
15
  (() => {
16
+ document.documentElement.lang = localStorage.getItem("pi-web-language") === "en" ? "en" : "zh-CN";
16
17
  const saved = localStorage.getItem("pi-web-theme");
17
18
  const preference = ["light", "dark", "system"].includes(saved) ? saved : "system";
18
19
  const style = localStorage.getItem("pi-web-theme-style");