u1s1-cli 1.11.7 → 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 (52) hide show
  1. package/README.md +5 -5
  2. package/dist/agent-setup.d.ts +7 -1
  3. package/dist/agent-setup.js +34 -10
  4. package/dist/api.js +42 -34
  5. package/dist/bench-report.js +12 -9
  6. package/dist/bench.js +34 -31
  7. package/dist/brand.d.ts +7 -2
  8. package/dist/brand.js +44 -13
  9. package/dist/config.d.ts +28 -2
  10. package/dist/config.js +52 -14
  11. package/dist/context-usage.js +12 -9
  12. package/dist/deploy.js +53 -47
  13. package/dist/device-auth.js +7 -5
  14. package/dist/embed.d.ts +2 -1
  15. package/dist/embed.js +2 -1
  16. package/dist/error-humanize.d.ts +3 -1
  17. package/dist/error-humanize.js +24 -9
  18. package/dist/feedback.d.ts +4 -2
  19. package/dist/feedback.js +60 -39
  20. package/dist/i18n.d.ts +21 -0
  21. package/dist/i18n.js +46 -0
  22. package/dist/import/index.js +64 -28
  23. package/dist/import/skills.js +72 -41
  24. package/dist/import/util.js +3 -2
  25. package/dist/import/write.js +2 -1
  26. package/dist/index.js +153 -54
  27. package/dist/login.d.ts +13 -2
  28. package/dist/login.js +84 -27
  29. package/dist/loop.js +18 -12
  30. package/dist/mcp/client.js +15 -14
  31. package/dist/mcp/command.js +76 -48
  32. package/dist/mcp/extension.js +5 -3
  33. package/dist/mcp/tools.js +6 -5
  34. package/dist/model.js +12 -10
  35. package/dist/nudges.d.ts +8 -2
  36. package/dist/nudges.js +22 -3
  37. package/dist/search-tools.js +7 -6
  38. package/dist/shell-doctor.js +9 -8
  39. package/dist/site.d.ts +71 -0
  40. package/dist/site.js +123 -0
  41. package/dist/subagent.js +5 -4
  42. package/dist/tools.js +53 -37
  43. package/dist/update.js +47 -39
  44. package/dist/usage.d.ts +6 -4
  45. package/dist/usage.js +111 -53
  46. package/dist/web.js +2 -1
  47. package/dist/workflow/runner.js +33 -29
  48. package/dist/workflow/templates.js +70 -45
  49. package/dist/workflow/tool.js +15 -12
  50. package/package.json +3 -3
  51. package/scripts/patch-pi.js +243 -278
  52. package/scripts/render-regression.mjs +55 -4
package/dist/site.d.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * 双站点表:中国站(cn)与国际站(intl)。见 docs/region-split-architecture-2026-09-26.md 第 3 节。
3
+ *
4
+ * 站点在分发时就定好(从哪个站下载就连哪个站),界面里不放选站入口;运行时一律按
5
+ * 当前 baseUrl 所在站点取域名/链接,CLI 里不再散落 u1s1.io 字面量。
6
+ *
7
+ * 本模块不 import 任何本地模块:config.ts 依赖它(DEFAULT_BASE_URL、老配置迁移),
8
+ * 反向依赖会成环。「当前生效站点」要读配置,放在 config.ts 的 activeSite()。
9
+ */
10
+ export type SiteId = "cn" | "intl";
11
+ export interface Site {
12
+ id: SiteId;
13
+ /** 主站 origin,站内页面链接都挂在这里 */
14
+ origin: string;
15
+ /** API 根,形如 https://api.<host>/v1(由 origin 派生) */
16
+ apiBase: string;
17
+ /** 发布件(安装脚本/SHA256SUMS/便携包)所在 origin */
18
+ releaseOrigin: string;
19
+ /** 申诉/联系邮箱 */
20
+ contactEmail: string;
21
+ /** 认作本站的主机名(baseUrl 落在这些主机上即判定为本站) */
22
+ hosts: readonly string[];
23
+ }
24
+ /**
25
+ * 中国站当前主域(P1 起为备案域 u1s1app.com,原主域 u1s1.io 的页面 308 过来、API/下载原地服务)。
26
+ * apiBase / releaseOrigin / 安装命令 / 站内链接全部随之派生,老配置里的
27
+ * api.u1s1.io 由 migrateStoredBaseUrl 在下次保存时改写到新 apiBase。
28
+ */
29
+ export declare const CN_ORIGIN = "https://u1s1app.com";
30
+ /** origin → https://api.<host>/v1 */
31
+ export declare function apiBaseForOrigin(origin: string): string;
32
+ export declare const SITES: Readonly<Record<SiteId, Site>>;
33
+ /**
34
+ * 历代中国站 API 根。配置文件里存的是其中之一、但不是当前中国站 apiBase 时,
35
+ * 读配置时换成当前值(见 migrateStoredBaseUrl),把流量逐步挪到新主域。
36
+ */
37
+ export declare const LEGACY_CN_API_BASES: readonly string[];
38
+ /** baseUrl 属于哪个站;自定义网关/localhost/解析不了的一律按中国站处理(与现状一致)。 */
39
+ export declare function siteForBaseUrl(baseUrl: string | undefined): SiteId;
40
+ /** baseUrl 是否落在该站(含历史主域)的主机上;自定义网关/localhost 不算,不能假定账号相通。 */
41
+ export declare function isSiteUrl(baseUrl: string | undefined, site: SiteId): boolean;
42
+ /** 有 cfg 在手时直接用它的 baseUrl 定站(结构类型,免得 import config.ts)。 */
43
+ export declare function siteOf(cfg: {
44
+ baseUrl?: string;
45
+ }): Site;
46
+ /**
47
+ * 老配置迁移:存的是历代中国站 API 根、且与当前中国站 apiBase 不同 → 换成当前值;
48
+ * 其余(国际站、自定义网关、当前值)原样返回。currentCnApiBase 可注入,便于测试
49
+ * P1 之后的行为(P0 两者相等,这里是 no-op)。
50
+ */
51
+ export declare function migrateStoredBaseUrl(stored: string | undefined, currentCnApiBase?: string): string | undefined;
52
+ /** `u1s1 login --site <值>`:cn/china → 中国站;global/intl → 国际站;其它返回 undefined。 */
53
+ export declare function parseSiteArg(value: string | undefined): SiteId | undefined;
54
+ export interface SiteLinks {
55
+ home: string;
56
+ dashboard: string;
57
+ /** 仪表盘充值卡片深链 */
58
+ topup: string;
59
+ /** 仪表盘工单/留言区 */
60
+ support: string;
61
+ /** 仪表盘「我的站点」 */
62
+ sites: string;
63
+ guides: string;
64
+ status: string;
65
+ community: string;
66
+ /** 官网下载区(Desktop App) */
67
+ download: string;
68
+ }
69
+ export declare function siteLinks(site: Site | SiteId): SiteLinks;
70
+ /** 本站官网安装命令:sh = curl … | bash(Linux/macOS),ps1 = irm … | iex(PowerShell)。 */
71
+ export declare function installCommand(site: Site | SiteId, shell: "sh" | "ps1"): string;
package/dist/site.js ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * 双站点表:中国站(cn)与国际站(intl)。见 docs/region-split-architecture-2026-09-26.md 第 3 节。
3
+ *
4
+ * 站点在分发时就定好(从哪个站下载就连哪个站),界面里不放选站入口;运行时一律按
5
+ * 当前 baseUrl 所在站点取域名/链接,CLI 里不再散落 u1s1.io 字面量。
6
+ *
7
+ * 本模块不 import 任何本地模块:config.ts 依赖它(DEFAULT_BASE_URL、老配置迁移),
8
+ * 反向依赖会成环。「当前生效站点」要读配置,放在 config.ts 的 activeSite()。
9
+ */
10
+ /**
11
+ * 中国站当前主域(P1 起为备案域 u1s1app.com,原主域 u1s1.io 的页面 308 过来、API/下载原地服务)。
12
+ * apiBase / releaseOrigin / 安装命令 / 站内链接全部随之派生,老配置里的
13
+ * api.u1s1.io 由 migrateStoredBaseUrl 在下次保存时改写到新 apiBase。
14
+ */
15
+ export const CN_ORIGIN = "https://u1s1app.com";
16
+ const INTL_ORIGIN = "https://u1s1.dev";
17
+ /** origin → https://api.<host>/v1 */
18
+ export function apiBaseForOrigin(origin) {
19
+ const url = new URL(origin);
20
+ return `${url.protocol}//api.${url.host}/v1`;
21
+ }
22
+ export const SITES = {
23
+ cn: {
24
+ id: "cn",
25
+ origin: CN_ORIGIN,
26
+ apiBase: apiBaseForOrigin(CN_ORIGIN),
27
+ releaseOrigin: CN_ORIGIN,
28
+ // 邮件域已切到 u1s1app.com(2026-09-30);contact@u1s1.io 仍能收信,老版本照旧可用
29
+ contactEmail: "contact@u1s1app.com",
30
+ hosts: ["u1s1.io", "www.u1s1.io", "api.u1s1.io", "u1s1app.com", "www.u1s1app.com", "api.u1s1app.com"],
31
+ },
32
+ intl: {
33
+ id: "intl",
34
+ origin: INTL_ORIGIN,
35
+ apiBase: apiBaseForOrigin(INTL_ORIGIN),
36
+ releaseOrigin: INTL_ORIGIN,
37
+ contactEmail: "contact@u1s1.dev",
38
+ hosts: ["u1s1.dev", "www.u1s1.dev", "api.u1s1.dev"],
39
+ },
40
+ };
41
+ /**
42
+ * 历代中国站 API 根。配置文件里存的是其中之一、但不是当前中国站 apiBase 时,
43
+ * 读配置时换成当前值(见 migrateStoredBaseUrl),把流量逐步挪到新主域。
44
+ */
45
+ export const LEGACY_CN_API_BASES = ["https://api.u1s1.io/v1", "https://api.u1s1app.com/v1"];
46
+ /** baseUrl 属于哪个站;自定义网关/localhost/解析不了的一律按中国站处理(与现状一致)。 */
47
+ export function siteForBaseUrl(baseUrl) {
48
+ if (!baseUrl)
49
+ return "cn";
50
+ let host;
51
+ try {
52
+ host = new URL(baseUrl).hostname.toLowerCase();
53
+ }
54
+ catch {
55
+ return "cn";
56
+ }
57
+ return SITES.intl.hosts.includes(host) ? "intl" : "cn";
58
+ }
59
+ /** baseUrl 是否落在该站(含历史主域)的主机上;自定义网关/localhost 不算,不能假定账号相通。 */
60
+ export function isSiteUrl(baseUrl, site) {
61
+ if (!baseUrl)
62
+ return false;
63
+ try {
64
+ return SITES[site].hosts.includes(new URL(baseUrl).hostname.toLowerCase());
65
+ }
66
+ catch {
67
+ return false;
68
+ }
69
+ }
70
+ /** 有 cfg 在手时直接用它的 baseUrl 定站(结构类型,免得 import config.ts)。 */
71
+ export function siteOf(cfg) {
72
+ return SITES[siteForBaseUrl(cfg.baseUrl)];
73
+ }
74
+ function resolveSite(site) {
75
+ return typeof site === "string" ? SITES[site] : site;
76
+ }
77
+ /**
78
+ * 老配置迁移:存的是历代中国站 API 根、且与当前中国站 apiBase 不同 → 换成当前值;
79
+ * 其余(国际站、自定义网关、当前值)原样返回。currentCnApiBase 可注入,便于测试
80
+ * P1 之后的行为(P0 两者相等,这里是 no-op)。
81
+ */
82
+ export function migrateStoredBaseUrl(stored, currentCnApiBase = SITES.cn.apiBase) {
83
+ if (!stored)
84
+ return stored;
85
+ const normalized = stored.replace(/\/+$/, "");
86
+ if (LEGACY_CN_API_BASES.includes(normalized) && normalized !== currentCnApiBase)
87
+ return currentCnApiBase;
88
+ return stored;
89
+ }
90
+ /** `u1s1 login --site <值>`:cn/china → 中国站;global/intl → 国际站;其它返回 undefined。 */
91
+ export function parseSiteArg(value) {
92
+ switch (value?.trim().toLowerCase()) {
93
+ case "cn":
94
+ case "china":
95
+ return "cn";
96
+ case "global":
97
+ case "intl":
98
+ return "intl";
99
+ default:
100
+ return undefined;
101
+ }
102
+ }
103
+ export function siteLinks(site) {
104
+ const { origin } = resolveSite(site);
105
+ return {
106
+ home: origin,
107
+ dashboard: `${origin}/dashboard`,
108
+ topup: `${origin}/dashboard#usage-topup-card`,
109
+ support: `${origin}/dashboard#sec-support`,
110
+ sites: `${origin}/dashboard#sec-sites`,
111
+ guides: `${origin}/guides`,
112
+ status: `${origin}/status`,
113
+ community: `${origin}/community`,
114
+ download: `${origin}/#download`,
115
+ };
116
+ }
117
+ /** 本站官网安装命令:sh = curl … | bash(Linux/macOS),ps1 = irm … | iex(PowerShell)。 */
118
+ export function installCommand(site, shell) {
119
+ const { releaseOrigin } = resolveSite(site);
120
+ return shell === "ps1"
121
+ ? `irm ${releaseOrigin}/releases/install.ps1 | iex`
122
+ : `curl -fsSL ${releaseOrigin}/releases/install.sh | bash`;
123
+ }
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,换浏览器再试;渲染失败就退回空壳