u1s1-cli 1.11.8 → 1.12.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.
Files changed (45) hide show
  1. package/dist/agent-setup.d.ts +5 -0
  2. package/dist/agent-setup.js +31 -7
  3. package/dist/api.js +42 -34
  4. package/dist/bench-report.js +12 -9
  5. package/dist/bench.js +34 -31
  6. package/dist/brand.d.ts +2 -1
  7. package/dist/brand.js +38 -12
  8. package/dist/config.d.ts +11 -1
  9. package/dist/config.js +25 -8
  10. package/dist/context-usage.js +12 -9
  11. package/dist/deploy.js +49 -45
  12. package/dist/device-auth.js +7 -5
  13. package/dist/error-humanize.d.ts +1 -0
  14. package/dist/error-humanize.js +18 -6
  15. package/dist/feedback.js +48 -31
  16. package/dist/i18n.d.ts +21 -0
  17. package/dist/i18n.js +46 -0
  18. package/dist/import/index.js +64 -28
  19. package/dist/import/skills.js +72 -41
  20. package/dist/import/util.js +3 -2
  21. package/dist/import/write.js +2 -1
  22. package/dist/index.js +139 -48
  23. package/dist/login.js +39 -28
  24. package/dist/loop.js +18 -12
  25. package/dist/mcp/client.js +15 -14
  26. package/dist/mcp/command.js +76 -48
  27. package/dist/mcp/extension.js +5 -3
  28. package/dist/mcp/tools.js +6 -5
  29. package/dist/model.js +11 -10
  30. package/dist/nudges.d.ts +8 -2
  31. package/dist/nudges.js +22 -3
  32. package/dist/search-tools.js +7 -6
  33. package/dist/shell-doctor.js +9 -8
  34. package/dist/subagent.js +5 -4
  35. package/dist/tools.js +53 -37
  36. package/dist/update.js +29 -28
  37. package/dist/usage.d.ts +1 -1
  38. package/dist/usage.js +96 -43
  39. package/dist/web.js +2 -1
  40. package/dist/workflow/runner.js +33 -29
  41. package/dist/workflow/templates.js +70 -45
  42. package/dist/workflow/tool.js +15 -12
  43. package/package.json +2 -2
  44. package/scripts/patch-pi.js +243 -278
  45. package/scripts/render-regression.mjs +55 -4
package/dist/model.js CHANGED
@@ -1,12 +1,13 @@
1
1
  import { fetchModels, loadCustomEndpoints } from "./api.js";
2
2
  import { apiModelToDef, CUSTOM_ENDPOINTS, findModel, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, resolvePreferredModel, setModelsFromApi, } from "./config.js";
3
3
  import { siteLinks, siteOf } from "./site.js";
4
+ import { tr } from "./i18n.js";
4
5
  /** Refresh official models while preserving the built-in list as an explicit offline fallback. */
5
6
  export async function refreshOfficialModels(cfg, fetcher = fetchModels) {
6
7
  try {
7
8
  const { models } = await fetcher(cfg);
8
9
  if (models.length === 0)
9
- return "线上模型列表为空";
10
+ return tr("线上模型列表为空", "the online model list is empty");
10
11
  setModelsFromApi(models.map(apiModelToDef));
11
12
  return null;
12
13
  }
@@ -25,7 +26,7 @@ export async function modelCommand(nameOrAlias) {
25
26
  loadCustomEndpoints(cfg),
26
27
  ]);
27
28
  if (officialModelsError) {
28
- console.error(` 获取线上模型列表失败,使用内置列表:${officialModelsError}`);
29
+ console.error(tr(` 获取线上模型列表失败,使用内置列表:${officialModelsError}`, ` Couldn't fetch the online model list; using the built-in list: ${officialModelsError}`));
29
30
  }
30
31
  const current = resolvePreferredModel(cfg);
31
32
  if (!nameOrAlias) {
@@ -34,26 +35,26 @@ export async function modelCommand(nameOrAlias) {
34
35
  const mark = current.provider === PROVIDER_ID && m.id === current.id ? "●" : " ";
35
36
  console.log(` ${mark} ${m.aliases[0].padEnd(10)} ${m.name}`);
36
37
  // 价格来自网关目录;离线兜底目录不带价格,只列名字与提示
37
- const price = m.cost ? `$${m.cost.input}/$${m.cost.output} 每百万 token` : "";
38
+ const price = m.cost ? tr(`$${m.cost.input}/$${m.cost.output} 每百万 token`, `$${m.cost.input}/$${m.cost.output} per 1M tokens`) : "";
38
39
  const detail = [m.note, price].filter(Boolean).join(" · ");
39
40
  if (detail)
40
41
  console.log(` ${detail}`);
41
42
  }
42
43
  for (const ep of CUSTOM_ENDPOINTS) {
43
44
  console.log("");
44
- console.log(` ─ 自定义端点 ${ep.name}(${ep.baseUrl},走本地直连不计费)`);
45
+ console.log(tr(` ─ 自定义端点 ${ep.name}(${ep.baseUrl},走本地直连不计费)`, ` ─ Custom endpoint ${ep.name} (${ep.baseUrl}, direct connection, not billed)`));
45
46
  for (const m of ep.models) {
46
47
  const mark = current.provider === ep.id && m.id === current.id ? "●" : " ";
47
48
  console.log(` ${mark} ${m.id.padEnd(10)} ${m.name === m.id ? "" : m.name}`);
48
49
  }
49
50
  }
50
51
  console.log("");
51
- console.log(" 切换:u1s1 model <model-id>(对话里 /model 同样会记住)");
52
+ console.log(tr(" 切换:u1s1 model <model-id>(对话里 /model 同样会记住)", " Switch: u1s1 model <model-id> (/model in a session is remembered too)"));
52
53
  if (CUSTOM_ENDPOINTS.length) {
53
- console.log(" 端点模型重名时可加限定:u1s1 model 端点名:模型id");
54
+ console.log(tr(" 端点模型重名时可加限定:u1s1 model 端点名:模型id", " If endpoint model names clash, qualify them: u1s1 model <endpoint>:<model-id>"));
54
55
  }
55
56
  else {
56
- console.log(` 想接自己的 API(含本机 Ollama)?去 ${siteLinks(siteOf(cfg)).dashboard} 配「自定义模型端点」`);
57
+ console.log(tr(` 想接自己的 API(含本机 Ollama)?去 ${siteLinks(siteOf(cfg)).dashboard} 配「自定义模型端点」`, ` Want to use your own API (including local Ollama)? Add a custom model endpoint at ${siteLinks(siteOf(cfg)).dashboard}`));
57
58
  }
58
59
  console.log("");
59
60
  return;
@@ -62,14 +63,14 @@ export async function modelCommand(nameOrAlias) {
62
63
  if (!m) {
63
64
  const custom = CUSTOM_ENDPOINTS.flatMap((e) => e.models.map((x) => x.id));
64
65
  const all = [...MODELS.map((x) => x.aliases[0]), ...custom];
65
- console.error(` 没有叫「${nameOrAlias}」的模型,可选:${all.join(" / ")}`);
66
+ console.error(tr(` 没有叫「${nameOrAlias}」的模型,可选:${all.join(" / ")}`, ` No model named "${nameOrAlias}". Available: ${all.join(" / ")}`));
66
67
  process.exit(1);
67
68
  }
68
69
  persistPreferredModel(cfg, m.provider, m.id);
69
70
  if (m.provider === PROVIDER_ID) {
70
- console.log(` ✓ 默认模型已切到 ${m.model.name}${m.model.note ? `(${m.model.note})` : ""}`);
71
+ console.log(tr(` ✓ 默认模型已切到 ${m.model.name}${m.model.note ? `(${m.model.note})` : ""}`, ` ✓ Default model set to ${m.model.name}${m.model.note ? ` (${m.model.note})` : ""}`));
71
72
  }
72
73
  else {
73
- console.log(` ✓ 默认模型已切到 ${m.model.name}(自定义端点 ${m.providerName},本地直连不计费)`);
74
+ console.log(tr(` ✓ 默认模型已切到 ${m.model.name}(自定义端点 ${m.providerName},本地直连不计费)`, ` ✓ Default model set to ${m.model.name} (custom endpoint ${m.providerName}, direct connection, not billed)`));
74
75
  }
75
76
  }
package/dist/nudges.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { SiteId } from "./site.js";
1
2
  /**
2
3
  * 「只提示一次」的机器级标记,落在 ~/.u1s1/nudges.json,CLI 与桌面版共用
3
4
  * (桌面版经 u1s1-cli/embed 拿到同一份 helper)。值是 ISO 时间戳,便于事后
@@ -16,6 +17,11 @@ export declare const nudgesFile: string;
16
17
  export declare function readNudges(file?: string): NudgeState;
17
18
  export declare function hasNudge(key: NudgeKey, file?: string): boolean;
18
19
  export declare function markNudge(key: NudgeKey, file?: string): void;
19
- /** 会话里首次写/改文件后要不要提示 deploy:没提示过、也没部署过才提示。 */
20
- export declare function shouldShowDeployHint(file?: string): boolean;
20
+ /**
21
+ * 会话里首次写/改文件后要不要提示 deploy:没提示过、也没部署过才提示。
22
+ * 国际站没有站点托管(网关拒绝 deploy),一律不提示。
23
+ */
24
+ export declare function shouldShowDeployHint(file?: string, site?: SiteId): boolean;
25
+ /** 老导出,固定中文;新代码用 deployHintText()(按当前语言现取)。 */
21
26
  export declare const DEPLOY_HINT_TEXT = "\u505A\u597D\u4E86\u60F3\u7ED9\u670B\u53CB\u770B\uFF1F\u8F93\u5165 /exit \u56DE\u5230\u7EC8\u7AEF,\u8FD0\u884C u1s1 deploy \u4E00\u952E\u4E0A\u7EBF";
27
+ export declare function deployHintText(): string;
package/dist/nudges.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { u1s1Dir } from "./config.js";
3
+ import { activeSite, u1s1Dir } from "./config.js";
4
+ import { tr } from "./i18n.js";
4
5
  export const nudgesFile = join(u1s1Dir, "nudges.json");
5
6
  export function readNudges(file = nudgesFile) {
6
7
  try {
@@ -32,9 +33,27 @@ export function markNudge(key, file = nudgesFile) {
32
33
  // ignore
33
34
  }
34
35
  }
35
- /** 会话里首次写/改文件后要不要提示 deploy:没提示过、也没部署过才提示。 */
36
- export function shouldShowDeployHint(file = nudgesFile) {
36
+ /**
37
+ * 会话里首次写/改文件后要不要提示 deploy:没提示过、也没部署过才提示。
38
+ * 国际站没有站点托管(网关拒绝 deploy),一律不提示。
39
+ */
40
+ export function shouldShowDeployHint(file = nudgesFile, site) {
41
+ let siteId = site;
42
+ if (!siteId) {
43
+ try {
44
+ siteId = activeSite().id;
45
+ }
46
+ catch {
47
+ siteId = "cn";
48
+ }
49
+ }
50
+ if (siteId === "intl")
51
+ return false;
37
52
  const state = readNudges(file);
38
53
  return state.deploy_hint_shown === undefined && state.deploy_done === undefined;
39
54
  }
55
+ /** 老导出,固定中文;新代码用 deployHintText()(按当前语言现取)。 */
40
56
  export const DEPLOY_HINT_TEXT = "做好了想给朋友看?输入 /exit 回到终端,运行 u1s1 deploy 一键上线";
57
+ export function deployHintText() {
58
+ return tr(DEPLOY_HINT_TEXT, "Want to show it off? Type /exit to return to the terminal, then run u1s1 deploy to publish it.");
59
+ }
@@ -5,6 +5,7 @@ import { join } from "node:path";
5
5
  import { Readable, Transform } from "node:stream";
6
6
  import { pipeline } from "node:stream/promises";
7
7
  import { agentDir } from "./config.js";
8
+ import { tr } from "./i18n.js";
8
9
  /**
9
10
  * 预 seed pi 的搜索组件(fd / ripgrep)到 ~/.u1s1/agent/bin。
10
11
  *
@@ -104,7 +105,7 @@ function findBinary(root, name) {
104
105
  async function install(tool, baseUrl, binName) {
105
106
  const asset = tool.asset(tool.version);
106
107
  if (!asset)
107
- throw new Error(`不支持的平台 ${platform()}/${arch()}`);
108
+ throw new Error(tr(`不支持的平台 ${platform()}/${arch()}`, `Unsupported platform ${platform()}/${arch()}`));
108
109
  mkdirSync(binDir, { recursive: true });
109
110
  const res = await fetch(new URL(`/tools/${asset}`, baseUrl), {
110
111
  signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
@@ -114,7 +115,7 @@ async function install(tool, baseUrl, binName) {
114
115
  const declaredLength = Number(res.headers.get("content-length"));
115
116
  if (Number.isFinite(declaredLength) && declaredLength > MAX_TOOL_ARCHIVE_BYTES) {
116
117
  void res.body.cancel("tool archive exceeded limit").catch(() => { });
117
- throw new Error(`下载文件超过 ${MAX_TOOL_ARCHIVE_BYTES} bytes`);
118
+ throw new Error(tr(`下载文件超过 ${MAX_TOOL_ARCHIVE_BYTES} bytes`, `Download exceeds ${MAX_TOOL_ARCHIVE_BYTES} bytes`));
118
119
  }
119
120
  const archive = join(binDir, asset);
120
121
  // fd/rg 并行安装,解压目录按工具名+pid 隔离,避免互踩
@@ -130,10 +131,10 @@ async function install(tool, baseUrl, binName) {
130
131
  await pipeline(Readable.fromWeb(res.body), limiter, createWriteStream(archive));
131
132
  mkdirSync(extractDir, { recursive: true });
132
133
  if (!extract(archive, extractDir))
133
- throw new Error(`解压失败 ${asset}`);
134
+ throw new Error(tr(`解压失败 ${asset}`, `Failed to extract ${asset}`));
134
135
  const found = findBinary(extractDir, binName);
135
136
  if (!found)
136
- throw new Error(`包内没有 ${binName}`);
137
+ throw new Error(tr(`包内没有 ${binName}`, `${binName} not found in the archive`));
137
138
  renameSync(found, join(binDir, binName));
138
139
  if (platform() !== "win32")
139
140
  chmodSync(join(binDir, binName), 0o755);
@@ -151,10 +152,10 @@ export async function ensureSearchTools(cfg) {
151
152
  if (missing.length === 0)
152
153
  return;
153
154
  // 这是新手首启最早看到的输出之一:说清是首次准备、不用管;fd/rg 等术语不外露
154
- console.log(" 首次运行,正在准备文件搜索组件(只需一次)…");
155
+ console.log(tr(" 首次运行,正在准备文件搜索组件(只需一次)…", " First run: setting up file search components (one-time)…"));
155
156
  const results = await Promise.allSettled(missing.map((t) => install(t, cfg.baseUrl, t.bin + ext)));
156
157
  if (results.some((r) => r.status === "rejected")) {
157
- console.log(" 部分搜索组件暂时没装上,不影响使用,之后会自动重试。");
158
+ console.log(tr(" 部分搜索组件暂时没装上,不影响使用,之后会自动重试。", " Some search components couldn't be installed yet. You can keep working; we'll retry automatically later."));
158
159
  }
159
160
  }
160
161
  catch {
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { join, win32 } from "node:path";
4
4
  import { agentDir, agentSettingsFile } from "./config.js";
5
+ import { tr } from "./i18n.js";
5
6
  /** System32/Sysnative 的 bash.exe 是 WSL 转发器,不是真 bash(与 pi 同款判断)。 */
6
7
  function isWslRelayBash(p) {
7
8
  const normalized = p.replace(/\//g, "\\").toLowerCase();
@@ -139,8 +140,8 @@ function persistShellPath(p) {
139
140
  return true;
140
141
  }
141
142
  function adviseInstallGitBash() {
142
- console.error(" ⚠ 未找到 Git Bash,将使用 WSL 的 bash:命令会在 WSL 的 Linux 里执行,不是 Windows 本机。");
143
- console.error(" 建议安装 Git for Windows(PowerShell 运行: winget install --id Git.Git)后重启 u1s1。");
143
+ console.error(tr(" ⚠ 未找到 Git Bash,将使用 WSL 的 bash:命令会在 WSL 的 Linux 里执行,不是 Windows 本机。", " ⚠ Git Bash not found; using WSL's bash. Commands will run inside WSL's Linux, not natively on Windows."));
144
+ console.error(tr(" 建议安装 Git for Windows(PowerShell 运行: winget install --id Git.Git)后重启 u1s1。", " We recommend installing Git for Windows (in PowerShell: winget install --id Git.Git), then restarting u1s1."));
144
145
  }
145
146
  export async function ensureUsableShell() {
146
147
  if (process.platform !== "win32")
@@ -164,7 +165,7 @@ export async function ensureUsableShell() {
164
165
  const candidate = await findUsableGitBash();
165
166
  if (candidate) {
166
167
  persistShellPath(candidate);
167
- console.error(` 检测到 Git Bash,命令改回 Windows 本机执行: ${candidate}`);
168
+ console.error(tr(` 检测到 Git Bash,命令改回 Windows 本机执行: ${candidate}`, ` Found Git Bash; commands will run natively on Windows again: ${candidate}`));
168
169
  return { status: "bash", shellPath: candidate };
169
170
  }
170
171
  adviseInstallGitBash();
@@ -175,7 +176,7 @@ export async function ensureUsableShell() {
175
176
  const adopt = (candidate) => {
176
177
  persistShellPath(candidate);
177
178
  if (typeof configured === "string") {
178
- console.error(` 原 shellPath 已失效,自动改用: ${candidate}`);
179
+ console.error(tr(` 原 shellPath 已失效,自动改用: ${candidate}`, ` The configured shellPath no longer works; switched to: ${candidate}`));
179
180
  }
180
181
  return { status: "bash", shellPath: candidate };
181
182
  };
@@ -195,14 +196,14 @@ export async function ensureUsableShell() {
195
196
  return { status: "bash", shellPath: fallback };
196
197
  }
197
198
  if (fallback) {
198
- console.error(` ⚠ 检测到的 bash(${fallback})无法执行命令,u1s1 的命令工具会全部失败。`);
199
+ console.error(tr(` ⚠ 检测到的 bash(${fallback})无法执行命令,u1s1 的命令工具会全部失败。`, ` ⚠ The bash found (${fallback}) can't run commands, so all of u1s1's command tools will fail.`));
199
200
  if (isWslRelayBash(fallback)) {
200
- console.error(" 它是 WSL 转发器,当前默认发行版里没有 /bin/bash(docker-desktop 被设为默认时就会这样)。");
201
+ console.error(tr(" 它是 WSL 转发器,当前默认发行版里没有 /bin/bash(docker-desktop 被设为默认时就会这样)。", " It's the WSL relay, and the default distro has no /bin/bash (this happens when docker-desktop is the default)."));
201
202
  }
202
203
  }
203
204
  else {
204
- console.error(" ⚠ 没找到 bash,u1s1 无法执行命令。");
205
+ console.error(tr(" ⚠ 没找到 bash,u1s1 无法执行命令。", " ⚠ No bash found; u1s1 can't run commands."));
205
206
  }
206
- console.error(" 修复:PowerShell 里运行 winget install --id Git.Git 安装 Git for Windows,装完重启 u1s1。");
207
+ console.error(tr(" 修复:PowerShell 里运行 winget install --id Git.Git 安装 Git for Windows,装完重启 u1s1。", " Fix: in PowerShell, run winget install --id Git.Git to install Git for Windows, then restart u1s1."));
207
208
  return { status: "none" };
208
209
  }
package/dist/subagent.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createAgentSession, DefaultResourceLoader, getAgentDir, hasTrustRequiringProjectResources, ModelRuntime, ProjectTrustStore, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
2
+ import { tr } from "./i18n.js";
2
3
  // ---- 子 agent spawn 基础设施:spawn_subagent 工具与 workflow runner 共用 ----
3
4
  /** 同时在跑的子 agent 上限;超额排队,防止打爆网关。 */
4
5
  export const SUBAGENT_CONCURRENCY = 4;
@@ -20,7 +21,7 @@ export function resolveStoredProjectTrust(cwd, agentDir = getAgentDir()) {
20
21
  return new ProjectTrustStore(agentDir).get(cwd) === true;
21
22
  }
22
23
  function cancelledError(signal) {
23
- return new Error("已取消", { cause: signal.reason });
24
+ return new Error(tr("已取消", "Cancelled"), { cause: signal.reason });
24
25
  }
25
26
  /**
26
27
  * Run one prompt while preserving the reason that stopped it. AgentSession.abort()
@@ -54,7 +55,7 @@ export async function promptSubagentSession(session, task, options) {
54
55
  if (options.signal?.aborted)
55
56
  throw cancelledError(options.signal);
56
57
  if (timedOut)
57
- throw new Error(`子任务超时(${options.timeoutMs}ms)`, { cause: promptError });
58
+ throw new Error(tr(`子任务超时(${options.timeoutMs}ms)`, `Subtask timed out (${options.timeoutMs}ms)`), { cause: promptError });
58
59
  if (promptError)
59
60
  throw promptError;
60
61
  }
@@ -72,7 +73,7 @@ function extractFinalText(messages) {
72
73
  if (text)
73
74
  return text;
74
75
  }
75
- return "(子 agent 结束但没有文本输出)";
76
+ return tr("(子 agent 结束但没有文本输出)", "(sub-agent finished without any text output)");
76
77
  }
77
78
  /**
78
79
  * 汇总会话内全部 assistant 消息的 token 与费用。
@@ -123,7 +124,7 @@ export async function runSubagent(opts) {
123
124
  sharedModelRuntime.getModel(providerId, modelId) ??
124
125
  (slash === -1 ? sharedModelRuntime.getModel("u1s1", opts.model) : undefined);
125
126
  if (!model)
126
- throw new Error(`找不到模型 "${opts.model}"(可用 /model 查看已配置的模型)`);
127
+ throw new Error(tr(`找不到模型 "${opts.model}"(可用 /model 查看已配置的模型)`, `Model "${opts.model}" not found (use /model to see configured models)`));
127
128
  }
128
129
  else if (opts.parentModel) {
129
130
  // 继承主会话模型;解析不到(端点被删等)就落回默认,不硬失败
package/dist/tools.js CHANGED
@@ -5,6 +5,7 @@ import { contextProjectTrust, runPool, runSubagent, SUBAGENT_CONCURRENCY, SUBAGE
5
5
  import { Text } from "@earendil-works/pi-tui";
6
6
  import { Type } from "typebox";
7
7
  import { generateImage, renderPage, searchWeb } from "./api.js";
8
+ import { tr } from "./i18n.js";
8
9
  const FETCH_TIMEOUT_MS = 20_000;
9
10
  const MAX_FETCH_BYTES = 2_000_000;
10
11
  const MAX_TEXT_CHARS = 30_000;
@@ -38,7 +39,7 @@ function htmlToText(html) {
38
39
  export function truncate(text) {
39
40
  if (text.length <= MAX_TEXT_CHARS)
40
41
  return text;
41
- return `${text.slice(0, MAX_TEXT_CHARS)}\n\n…(内容过长已截断,共 ${text.length} 字符)`;
42
+ return `${text.slice(0, MAX_TEXT_CHARS)}\n\n${tr(`…(内容过长已截断,共 ${text.length} 字符)`, `… (truncated; ${text.length} characters total)`)}`;
42
43
  }
43
44
  /**
44
45
  * 长网页按 30k 字符开窗返回:光截断而不给「怎么读下一段」,模型会拿前 30k 硬答
@@ -50,10 +51,10 @@ export function windowText(text, offset) {
50
51
  const body = text.slice(start, end);
51
52
  if (start === 0 && end === text.length)
52
53
  return { text: body, truncated: false, nextOffset: null };
53
- const head = start > 0 ? `(从第 ${start} 字符起继续)\n\n` : "";
54
+ const head = start > 0 ? tr(`(从第 ${start} 字符起继续)\n\n`, `(continuing from character ${start})\n\n`) : "";
54
55
  const tail = end < text.length
55
- ? `\n\n…(内容过长已截断:本次为第 ${start}–${end} 字符,共 ${text.length} 字符;要继续读取,用同一 url 再调一次 web_fetch 并传 offset=${end})`
56
- : `\n\n(已到末尾:第 ${start}–${end} 字符,共 ${text.length} 字符)`;
56
+ ? tr(`\n\n…(内容过长已截断:本次为第 ${start}–${end} 字符,共 ${text.length} 字符;要继续读取,用同一 url 再调一次 web_fetch 并传 offset=${end})`, `\n\n… (truncated: this is characters ${start}–${end} of ${text.length}; to keep reading, call web_fetch again with the same url and offset=${end})`)
57
+ : tr(`\n\n(已到末尾:第 ${start}–${end} 字符,共 ${text.length} 字符)`, `\n\n(end of page: characters ${start}–${end} of ${text.length})`);
57
58
  return { text: `${head}${body}${tail}`, truncated: end < text.length, nextOffset: end < text.length ? end : null };
58
59
  }
59
60
  /** 同一页面带 offset 续读时直接命中,不再重新抓取(云端渲染是按次计费的)。 */
@@ -159,7 +160,7 @@ const SEARCH_QUERY_MAX_CHARS = 100;
159
160
  export function createSearchTool(cfg) {
160
161
  return defineTool({
161
162
  name: "web_search",
162
- label: "联网搜索",
163
+ label: tr("联网搜索", "Web search"),
163
164
  description: "Search the web for current information. Use it for anything time-sensitive or outside the codebase: library docs, error messages, latest versions, recent events. Returns a short answer plus ranked results with URLs and snippets. Follow up with web_fetch to read a promising result in full.",
164
165
  promptSnippet: "Search the internet for up-to-date information (docs, errors, versions, current events)",
165
166
  promptGuidelines: [
@@ -181,8 +182,8 @@ export function createSearchTool(cfg) {
181
182
  const [result, options, theme, context] = args;
182
183
  const d = result.details;
183
184
  const q = d?.query ? `"${previewLine(d.query, 80)}"` : "…";
184
- const n = typeof d?.count === "number" ? ` · ${d.count} 条结果` : "";
185
- return compactResultRender({ result, options, theme, context, summaryLine: `🔍 搜索 ${q}${n}` });
185
+ const n = typeof d?.count === "number" ? tr(` · ${d.count} 条结果`, ` · ${d.count} result${d.count === 1 ? "" : "s"}`) : "";
186
+ return compactResultRender({ result, options, theme, context, summaryLine: tr(`🔍 搜索 ${q}${n}`, `🔍 Search ${q}${n}`) });
186
187
  },
187
188
  async execute(_toolCallId, params, signal) {
188
189
  const data = await searchWeb(cfg, { query: params.query, maxResults: params.maxResults, signal });
@@ -215,7 +216,7 @@ const SUBAGENT_MAX_TASKS = 10;
215
216
  export function createSubagentTool(getParentModel) {
216
217
  return defineTool({
217
218
  name: "spawn_subagent",
218
- label: "子任务",
219
+ label: tr("子任务", "Subtask"),
219
220
  description: "Spawn an independent sub-agent with its own context window to complete a self-contained task, then return its final answer. " +
220
221
  "Pass `tasks` (array) to run N sub-agents concurrently — ideal for embarrassingly parallel work like translating many locale files, " +
221
222
  "reviewing several files, or researching multiple directions at once. Each sub-agent can read/write files and run commands. " +
@@ -250,10 +251,10 @@ export function createSubagentTool(getParentModel) {
250
251
  const [result, options, theme, context] = args;
251
252
  const d = result.details;
252
253
  const summary = d && typeof d.count === "number"
253
- ? `🤖 子任务 ×${d.count}` +
254
+ ? tr(`🤖 子任务 ×${d.count}`, `🤖 Subtasks ×${d.count}`) +
254
255
  (typeof d.ok === "number" && d.failed ? ` · ✓${d.ok} ✗${d.failed}` : "") +
255
256
  (typeof d.seconds === "number" ? ` · ${d.seconds}s` : "")
256
- : "🤖 子任务";
257
+ : tr("🤖 子任务", "🤖 Subtask");
257
258
  return compactResultRender({ result, options, theme, context, summaryLine: summary });
258
259
  },
259
260
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
@@ -264,7 +265,7 @@ export function createSubagentTool(getParentModel) {
264
265
  .filter(Boolean)
265
266
  .slice(0, SUBAGENT_MAX_TASKS);
266
267
  if (list.length === 0) {
267
- throw new Error("task 和 tasks 至少填一个");
268
+ throw new Error(tr("task 和 tasks 至少填一个", "Provide at least one of task or tasks"));
268
269
  }
269
270
  const parentModel = getParentModel();
270
271
  const timeoutMs = Math.min(60, params.timeout_minutes ?? SUBAGENT_TIMEOUT_MS / 60_000) * 60_000;
@@ -299,10 +300,13 @@ export function createSubagentTool(getParentModel) {
299
300
  details: { ...details, seconds: undefined },
300
301
  };
301
302
  }
302
- const lines = [`共 ${list.length} 个子任务,✓${details.ok} ✗${details.failed},用时 ${seconds}s`, ""];
303
+ const lines = [
304
+ tr(`共 ${list.length} 个子任务,✓${details.ok} ✗${details.failed},用时 ${seconds}s`, `${list.length} subtasks, ✓${details.ok} ✗${details.failed}, took ${seconds}s`),
305
+ "",
306
+ ];
303
307
  outcomes.forEach((o, i) => {
304
- lines.push(`## 任务 ${i + 1}/${list.length} ${o?.ok ? "✅" : "❌"}`, "");
305
- lines.push(o?.ok ? o.text : `失败:${o?.text ?? "未知错误"}`, "", "---", "");
308
+ lines.push(tr(`## 任务 ${i + 1}/${list.length} ${o?.ok ? "✅" : "❌"}`, `## Task ${i + 1}/${list.length} ${o?.ok ? "✅" : "❌"}`), "");
309
+ lines.push(o?.ok ? o.text : tr(`失败:${o?.text ?? "未知错误"}`, `Failed: ${o?.text ?? "unknown error"}`), "", "---", "");
306
310
  });
307
311
  return {
308
312
  content: [{ type: "text", text: truncate(lines.join("\n").trim()) }],
@@ -333,12 +337,13 @@ function refImageToPayload(ref) {
333
337
  return s;
334
338
  const path = resolve(s);
335
339
  if (!existsSync(path))
336
- throw new Error(`参考图不存在: ${s}`);
340
+ throw new Error(tr(`参考图不存在: ${s}`, `Reference image not found: ${s}`));
337
341
  const mime = REF_IMAGE_MIME[extname(path).toLowerCase()];
338
- if (!mime)
339
- throw new Error(`参考图格式不支持: ${s}(支持 png/jpg/jpeg/webp/bmp/gif)`);
342
+ if (!mime) {
343
+ throw new Error(tr(`参考图格式不支持: ${s}(支持 png/jpg/jpeg/webp/bmp/gif)`, `Unsupported reference image format: ${s} (supported: png/jpg/jpeg/webp/bmp/gif)`));
344
+ }
340
345
  if (statSync(path).size > MAX_REF_IMAGE_BYTES)
341
- throw new Error(`参考图超过 10MB: ${s}`);
346
+ throw new Error(tr(`参考图超过 10MB: ${s}`, `Reference image exceeds 10MB: ${s}`));
342
347
  return `data:${mime};base64,${readFileSync(path).toString("base64")}`;
343
348
  }
344
349
  /** 结果落盘路径:模型没指定时按时间戳起名,已存在就加序号,不覆盖旧图。 */
@@ -380,12 +385,16 @@ async function abortableDelay(ms, signal) {
380
385
  }
381
386
  class GeneratedImageTooLargeError extends Error {
382
387
  }
388
+ function imageTooLargeMessage(maxBytes) {
389
+ const mib = Math.floor(maxBytes / 1024 / 1024);
390
+ return tr(`图片文件超过下载上限 ${mib} MiB`, `the image file exceeds the ${mib} MiB download limit`);
391
+ }
383
392
  async function readGeneratedImageBody(resp, maxBytes) {
384
393
  const declaredLength = Number(resp.headers.get("content-length"));
385
394
  if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
386
395
  if (resp.body)
387
396
  void resp.body.cancel("generated image body limit reached").catch(() => { });
388
- throw new GeneratedImageTooLargeError(`图片文件超过下载上限 ${Math.floor(maxBytes / 1024 / 1024)} MiB`);
397
+ throw new GeneratedImageTooLargeError(imageTooLargeMessage(maxBytes));
389
398
  }
390
399
  if (!resp.body)
391
400
  return new Uint8Array();
@@ -399,7 +408,7 @@ async function readGeneratedImageBody(resp, maxBytes) {
399
408
  break;
400
409
  if (total + value.byteLength > maxBytes) {
401
410
  void reader.cancel("generated image body limit reached").catch(() => { });
402
- throw new GeneratedImageTooLargeError(`图片文件超过下载上限 ${Math.floor(maxBytes / 1024 / 1024)} MiB`);
411
+ throw new GeneratedImageTooLargeError(imageTooLargeMessage(maxBytes));
403
412
  }
404
413
  chunks.push(value);
405
414
  total += value.byteLength;
@@ -444,21 +453,22 @@ export async function downloadGeneratedImage(url, signal, options = {}) {
444
453
  if (signal.aborted)
445
454
  throw error;
446
455
  if (error instanceof GeneratedImageTooLargeError) {
447
- throw new Error(`图片已经生成,但${error.message}。不要重新调用 generate_image,可在 24 小时内手动下载: ${url}`, { cause: error });
456
+ throw new Error(tr(`图片已经生成,但${error.message}。不要重新调用 generate_image,可在 24 小时内手动下载: ${url}`, `The image was generated, but ${error.message}. Do not call generate_image again; it can be downloaded manually within 24 hours: ${url}`), { cause: error });
448
457
  }
449
458
  lastError = error;
450
459
  if (attempt < attempts)
451
460
  await abortableDelay(retryDelayMs * attempt, signal);
452
461
  }
453
462
  }
454
- throw new Error(`图片已经生成,但下载失败(同一地址已重试 ${attempts} 次): ${errorDetail(lastError)}。` +
455
- `不要重新调用 generate_image,可在 24 小时内手动下载: ${url}`, { cause: lastError });
463
+ throw new Error(tr(`图片已经生成,但下载失败(同一地址已重试 ${attempts} 次): ${errorDetail(lastError)}。` +
464
+ `不要重新调用 generate_image,可在 24 小时内手动下载: ${url}`, `The image was generated, but downloading it failed (retried the same URL ${attempts} times): ${errorDetail(lastError)}. ` +
465
+ `Do not call generate_image again; it can be downloaded manually within 24 hours: ${url}`), { cause: lastError });
456
466
  }
457
467
  /** 生图工具:走 u1s1 网关代理火山方舟 Seedream,上游 key 不落到用户机器上。 */
458
468
  export function createImageTool(cfg) {
459
469
  return defineTool({
460
470
  name: "generate_image",
461
- label: "生成图片",
471
+ label: tr("生成图片", "Generate image"),
462
472
  description: "Generate an image from a text prompt, or edit/compose existing images, using the Seedream image model. " +
463
473
  "Saves the result as a local image file and displays it directly in supported chat UIs. " +
464
474
  "Pass local file paths or http(s) URLs in `images` to edit an image or use references (style transfer, adding elements, combining up to 10 images). " +
@@ -499,7 +509,9 @@ export function createImageTool(cfg) {
499
509
  const sizeNote = result.size ? ` (${result.size})` : "";
500
510
  const mimeType = REF_IMAGE_MIME[urlExt] ?? "image/jpeg";
501
511
  return {
502
- content: [{ type: "text", text: `图片已保存并在对话中展示: ${path}${sizeNote}` }],
512
+ content: [
513
+ { type: "text", text: tr(`图片已保存并在对话中展示: ${path}${sizeNote}`, `Image saved and shown in the chat: ${path}${sizeNote}`) },
514
+ ],
503
515
  // displayImage 只给 UI 预览,不放进 content:避免每轮都把整张 2K/4K
504
516
  // 图片送回模型占用 context。pi-web-ui 会从 tool details 序列化它。
505
517
  details: {
@@ -541,16 +553,16 @@ async function fetchDirect(url, signal) {
541
553
  // 用户主动打断不算失败,直接往外抛,不去碰云端渲染
542
554
  if (signal?.aborted)
543
555
  throw e;
544
- throw new DirectFetchError(`打不开 ${url.href}: ${e.message}`, true);
556
+ throw new DirectFetchError(tr(`打不开 ${url.href}: ${e.message}`, `Couldn't open ${url.href}: ${e.message}`), true);
545
557
  }
546
558
  if (!resp.ok) {
547
559
  void resp.body?.cancel("web fetch rejected HTTP response").catch(() => undefined);
548
- throw new DirectFetchError(`${url.href} 返回 ${resp.status} ${resp.statusText}`, RENDER_WORTHY_STATUS.has(resp.status));
560
+ throw new DirectFetchError(tr(`${url.href} 返回 ${resp.status} ${resp.statusText}`, `${url.href} returned ${resp.status} ${resp.statusText}`), RENDER_WORTHY_STATUS.has(resp.status));
549
561
  }
550
562
  const type = resp.headers.get("content-type") ?? "";
551
563
  if (!/text\/|json|xml|javascript/i.test(type)) {
552
564
  void resp.body?.cancel("web fetch rejected non-text response").catch(() => undefined);
553
- throw new DirectFetchError(`${url.href} 不是文本内容 (${type || "unknown"}),读不了`, false);
565
+ throw new DirectFetchError(tr(`${url.href} 不是文本内容 (${type || "unknown"}),读不了`, `${url.href} is not text content (${type || "unknown"}); can't read it`), false);
554
566
  }
555
567
  let bytes;
556
568
  try {
@@ -560,7 +572,7 @@ async function fetchDirect(url, signal) {
560
572
  if (signal?.aborted)
561
573
  throw error;
562
574
  const detail = error instanceof Error ? error.message : String(error);
563
- throw new DirectFetchError(`${url.href} 正文读取失败: ${detail}`, true);
575
+ throw new DirectFetchError(tr(`${url.href} 正文读取失败: ${detail}`, `Failed to read the body of ${url.href}: ${detail}`), true);
564
576
  }
565
577
  const raw = decodeBody(bytes, type);
566
578
  const isHtml = /html|xml/i.test(type);
@@ -580,7 +592,7 @@ export function createFetchTool(cfg) {
580
592
  }
581
593
  return defineTool({
582
594
  name: "web_fetch",
583
- label: "读取网页",
595
+ label: tr("读取网页", "Fetch page"),
584
596
  description: "Fetch a URL and return its readable text content (HTML is stripped to text; JSON and plain text are returned as-is). Use it to read a page found via web_search, or any URL the user pasted. If the direct fetch fails or the page needs JavaScript, it automatically retries through a cloud browser that can also clear bot-check interstitials. Long pages are returned in 30,000-character windows: when the result says it was truncated, call again with the same url and the offset it gives to read the next part.",
585
597
  promptSnippet: "Fetch a URL and convert it to readable text",
586
598
  promptGuidelines: [
@@ -602,9 +614,13 @@ export function createFetchTool(cfg) {
602
614
  const [result, options, theme, context] = args;
603
615
  const d = result.details;
604
616
  const u = previewLine(d?.url ?? "…", 90);
605
- const n = typeof d?.chars === "number" ? ` · ${d.chars} 字符` : "";
606
- const win = d?.offset ? ` · 从第 ${d.offset} 字符续读` : d?.truncated ? " · 只读了开头" : "";
607
- const cloud = d?.via === "render" ? " · ☁ 浏览器渲染" : "";
617
+ const n = typeof d?.chars === "number" ? tr(` · ${d.chars} 字符`, ` · ${d.chars} chars`) : "";
618
+ const win = d?.offset
619
+ ? tr(` · 从第 ${d.offset} 字符续读`, ` · continued from char ${d.offset}`)
620
+ : d?.truncated
621
+ ? tr(" · 只读了开头", " · first part only")
622
+ : "";
623
+ const cloud = d?.via === "render" ? tr(" · ☁ 浏览器渲染", " · ☁ browser render") : "";
608
624
  return compactResultRender({ result, options, theme, context, summaryLine: `🌐 ${u}${n}${win}${cloud}` });
609
625
  },
610
626
  async execute(_toolCallId, params, signal) {
@@ -614,15 +630,15 @@ export function createFetchTool(cfg) {
614
630
  url = new URL(params.url.trim().replace(/^@/, ""));
615
631
  }
616
632
  catch {
617
- throw new Error(`不是合法的网址: ${params.url}`);
633
+ throw new Error(tr(`不是合法的网址: ${params.url}`, `Not a valid URL: ${params.url}`));
618
634
  }
619
635
  if (url.protocol !== "http:" && url.protocol !== "https:") {
620
- throw new Error(`只支持 http/https,收到 ${url.protocol}`);
636
+ throw new Error(tr(`只支持 http/https,收到 ${url.protocol}`, `Only http/https is supported; got ${url.protocol}`));
621
637
  }
622
638
  const offset = Math.max(0, Math.floor(params.offset ?? 0));
623
639
  const ok = (text, via, contentType) => {
624
640
  rememberPage(url.href, { text, via, contentType });
625
- const win = windowText(text || "(空白页面)", offset);
641
+ const win = windowText(text || tr("(空白页面)", "(empty page)"), offset);
626
642
  return {
627
643
  content: [{ type: "text", text: `# ${url.href}\n\n${win.text}` }],
628
644
  details: { url: url.href, contentType, via, chars: text.length, offset, truncated: win.truncated, nextOffset: win.nextOffset },
@@ -643,7 +659,7 @@ export function createFetchTool(cfg) {
643
659
  return ok(await renderViaGateway(url, signal), "render", "text/markdown");
644
660
  }
645
661
  catch (re) {
646
- throw new Error(`${e.message};云端浏览器渲染也失败: ${re.message}`, { cause: re });
662
+ throw new Error(tr(`${e.message};云端浏览器渲染也失败: ${re.message}`, `${e.message}; cloud browser rendering also failed: ${re.message}`), { cause: re });
647
663
  }
648
664
  }
649
665
  // 直连 200 但正文近乎空:大概率是 JS 渲染的 SPA,换浏览器再试;渲染失败就退回空壳