u1s1-cli 1.11.8 → 1.13.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 (49) hide show
  1. package/dist/agent-setup.d.ts +19 -3
  2. package/dist/agent-setup.js +85 -10
  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 +140 -49
  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.d.ts +2 -2
  27. package/dist/mcp/command.js +109 -69
  28. package/dist/mcp/config.d.ts +22 -6
  29. package/dist/mcp/config.js +0 -0
  30. package/dist/mcp/extension.js +5 -3
  31. package/dist/mcp/tools.d.ts +2 -0
  32. package/dist/mcp/tools.js +10 -5
  33. package/dist/model.js +11 -10
  34. package/dist/nudges.d.ts +8 -2
  35. package/dist/nudges.js +22 -3
  36. package/dist/search-tools.js +7 -6
  37. package/dist/shell-doctor.js +9 -8
  38. package/dist/subagent.js +5 -4
  39. package/dist/tools.js +53 -37
  40. package/dist/update.js +29 -28
  41. package/dist/usage.d.ts +1 -1
  42. package/dist/usage.js +96 -43
  43. package/dist/web.js +2 -1
  44. package/dist/workflow/runner.js +33 -29
  45. package/dist/workflow/templates.js +70 -45
  46. package/dist/workflow/tool.js +15 -12
  47. package/package.json +4 -4
  48. package/scripts/patch-pi.js +347 -278
  49. package/scripts/render-regression.mjs +55 -4
package/dist/deploy.js CHANGED
@@ -4,6 +4,7 @@ import { VERSION, u1s1Dir } from "./config.js";
4
4
  import { authorizedFetch } from "./device-auth.js";
5
5
  import { readJsonResponseCapped } from "./api.js";
6
6
  import { markNudge } from "./nudges.js";
7
+ import { tr } from "./i18n.js";
7
8
  import { askLine } from "./prompt.js";
8
9
  import { sendTelemetryEvent } from "./telemetry.js";
9
10
  import { siteLinks, siteOf } from "./site.js";
@@ -68,10 +69,10 @@ function resolveSiteDir(explicit) {
68
69
  if (explicit) {
69
70
  const dir = resolve(explicit);
70
71
  if (!existsSync(dir) || !statSync(dir).isDirectory()) {
71
- throw new Error(`目录不存在:${dir}`);
72
+ throw new Error(tr(`目录不存在:${dir}`, `Directory not found: ${dir}`));
72
73
  }
73
74
  if (!existsSync(join(dir, "index.html"))) {
74
- throw new Error(`${dir} 里没有 index.html,网站需要一个首页`);
75
+ throw new Error(tr(`${dir} 里没有 index.html,网站需要一个首页`, `${dir} has no index.html; a site needs a home page`));
75
76
  }
76
77
  return dir;
77
78
  }
@@ -82,9 +83,11 @@ function resolveSiteDir(explicit) {
82
83
  }
83
84
  if (existsSync(join(cwd, "index.html")))
84
85
  return cwd;
85
- throw new Error("这里找不到能发布的网页(index.html)。\n" +
86
+ throw new Error(tr("这里找不到能发布的网页(index.html)。\n" +
86
87
  " 在网站目录里运行 u1s1 deploy,或指定目录:u1s1 deploy <目录>\n" +
87
- " 如果项目需要构建(如 Vite/Next),先跑构建再部署 dist/ 等产物目录");
88
+ " 如果项目需要构建(如 Vite/Next),先跑构建再部署 dist/ 等产物目录", "No publishable page (index.html) found here.\n" +
89
+ " Run u1s1 deploy in your site directory, or pass one: u1s1 deploy <dir>\n" +
90
+ " If the project needs a build step (Vite/Next etc.), build first and deploy dist/ or similar"));
88
91
  }
89
92
  export function collectFiles(root) {
90
93
  const files = [];
@@ -133,23 +136,23 @@ export function parseDeployArgs(args) {
133
136
  if (arg === "--name" || arg === "-n") {
134
137
  const value = args[++i];
135
138
  if (!value || value.startsWith("-"))
136
- throw new Error(`${arg} 后面需要站点名`);
139
+ throw new Error(tr(`${arg} 后面需要站点名`, `${arg} needs a site name`));
137
140
  parsed.name = value.toLowerCase();
138
141
  }
139
142
  else if (arg.startsWith("--name=")) {
140
143
  parsed.name = arg.slice(7).toLowerCase();
141
144
  if (!parsed.name)
142
- throw new Error("--name 后面需要站点名");
145
+ throw new Error(tr("--name 后面需要站点名", "--name needs a site name"));
143
146
  }
144
147
  else if (arg === "--public" || arg === "--private") {
145
148
  const visibility = arg.slice(2);
146
149
  if (parsed.visibility && parsed.visibility !== visibility) {
147
- throw new Error("--public 和 --private 不能同时使用");
150
+ throw new Error(tr("--public 和 --private 不能同时使用", "--public and --private can't be used together"));
148
151
  }
149
152
  parsed.visibility = visibility;
150
153
  }
151
154
  else if (arg.startsWith("-")) {
152
- throw new Error(`不认识的 deploy 参数:${arg}`);
155
+ throw new Error(tr(`不认识的 deploy 参数:${arg}`, `Unknown deploy option: ${arg}`));
153
156
  }
154
157
  else {
155
158
  parsed.dirArg = arg;
@@ -168,7 +171,7 @@ async function api(cfg, request) {
168
171
  });
169
172
  }
170
173
  catch {
171
- throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
174
+ throw new Error(tr(`连不上 ${cfg.baseUrl},检查一下网络?`, `Can't reach ${cfg.baseUrl}. Check your network?`));
172
175
  }
173
176
  const value = await readJsonResponseCapped(resp, MAX_DEPLOY_API_RESPONSE_BYTES).catch(() => null);
174
177
  const data = value && typeof value === "object" && !Array.isArray(value)
@@ -180,7 +183,7 @@ async function api(cfg, request) {
180
183
  : undefined;
181
184
  const message = typeof detail?.message === "string"
182
185
  ? detail.message.slice(0, 2_000)
183
- : `服务端返回 ${resp.status},稍后再试`;
186
+ : tr(`服务端返回 ${resp.status},稍后再试`, `Server returned ${resp.status}. Please try again later.`);
184
187
  const e = new Error(message);
185
188
  if (typeof detail?.code === "string" && detail.code.length <= 100) {
186
189
  e.code = detail.code;
@@ -188,7 +191,7 @@ async function api(cfg, request) {
188
191
  throw e;
189
192
  }
190
193
  if (!data)
191
- throw new Error("服务端部署响应格式不正确");
194
+ throw new Error(tr("服务端部署响应格式不正确", "Unexpected deploy response from the server"));
192
195
  return data;
193
196
  }
194
197
  /** 逐个上传,失败重试一次;并发数保守取 6。 */
@@ -218,12 +221,12 @@ async function uploadAll(cfg, start, files) {
218
221
  const body = value && typeof value === "object" && !Array.isArray(value) ? value : null;
219
222
  const message = body?.error && typeof body.error === "object" && typeof body.error.message === "string"
220
223
  ? body.error.message.slice(0, 2_000)
221
- : "网络错误";
222
- throw new Error(`上传 ${f.path} 失败:${message}`);
224
+ : tr("网络错误", "network error");
225
+ throw new Error(tr(`上传 ${f.path} 失败:${message}`, `Failed to upload ${f.path}: ${message}`));
223
226
  }
224
227
  void resp.body?.cancel("deploy upload completed").catch(() => { });
225
228
  done++;
226
- process.stdout.write(`\r 上传中 ${done}/${files.length} ${f.path.slice(0, 48).padEnd(48)}`);
229
+ process.stdout.write(tr(`\r 上传中 ${done}/${files.length} ${f.path.slice(0, 48).padEnd(48)}`, `\r Uploading ${done}/${files.length} ${f.path.slice(0, 48).padEnd(48)}`));
227
230
  };
228
231
  const workers = Array.from({ length: Math.min(6, queue.length) }, async () => {
229
232
  for (let f = queue.shift(); f; f = queue.shift())
@@ -238,60 +241,60 @@ async function uploadAll(cfg, start, files) {
238
241
  }
239
242
  }
240
243
  async function promptSiteName(def) {
241
- return (await askLine(` 项目名(回车用 ${def},只需在你的账号内唯一):`, def)).toLowerCase();
244
+ return (await askLine(tr(` 项目名(回车用 ${def},只需在你的账号内唯一):`, ` Project name (Enter for ${def}; only needs to be unique in your account): `), def)).toLowerCase();
242
245
  }
243
246
  async function promptVisibility() {
244
247
  if (!process.stdin.isTTY)
245
248
  return "private";
246
249
  while (true) {
247
- const answer = (await askLine(" 发布方式 [1] 公开(进入作品社区) [2] 私密(仅自己登录后可看,默认):")).toLowerCase();
250
+ const answer = (await askLine(tr(" 发布方式 [1] 公开(进入作品社区) [2] 私密(仅自己登录后可看,默认):", " Visibility [1] Public (listed in the community) [2] Private (only you, when signed in; default): "))).toLowerCase();
248
251
  if (!answer || answer === "2" || answer === "private" || answer === "私密")
249
252
  return "private";
250
253
  if (answer === "1" || answer === "public" || answer === "公开")
251
254
  return "public";
252
- console.log(" 请输入 1 或 2");
255
+ console.log(tr(" 请输入 1 或 2", " Please enter 1 or 2"));
253
256
  }
254
257
  }
255
258
  async function listDeployments(cfg) {
256
259
  const { sites } = await api(cfg, { method: "GET", path: "/deploy/sites" });
257
260
  if (!sites.length) {
258
- console.log(" 还没有部署过站点。在网页目录里跑 u1s1 deploy 试试。");
261
+ console.log(tr(" 还没有部署过站点。在网页目录里跑 u1s1 deploy 试试。", " No sites deployed yet. Try u1s1 deploy in your site directory."));
259
262
  return;
260
263
  }
261
264
  console.log("");
262
265
  for (const site of sites) {
263
266
  const visibility = site.visibility === "private"
264
- ? "私密"
265
- : site.community_listed ? "公开(社区)" : "公开(未展示)";
267
+ ? tr("私密", "private")
268
+ : site.community_listed ? tr("公开(社区)", "public (community)") : tr("公开(未展示)", "public (unlisted)");
266
269
  console.log(` ${site.deployed ? "●" : "○"} ${site.url} ${visibility} · ${fmtBytes(site.total_bytes)} · ${site.updated_at} UTC`);
267
270
  }
268
271
  console.log("");
269
272
  }
270
273
  async function confirmRemoval(url) {
271
- const answer = (await askLine(` 确定删除 ${url} 吗?网址会立即失效,站点文件会被清除,无法恢复。输入 y 确认:`)).toLowerCase();
274
+ const answer = (await askLine(tr(` 确定删除 ${url} 吗?网址会立即失效,站点文件会被清除,无法恢复。输入 y 确认:`, ` Delete ${url}? The URL stops working immediately and the files are erased for good. Type y to confirm: `))).toLowerCase();
272
275
  return answer === "y" || answer === "yes";
273
276
  }
274
277
  async function removeDeployment(cfg, args) {
275
278
  const yes = args.some((arg) => arg === "--yes" || arg === "-y");
276
279
  const positional = args.filter((arg) => !arg.startsWith("-"));
277
280
  if (positional.length !== 1) {
278
- throw new Error("用法:u1s1 deploy remove <站点名或网址> [--yes]");
281
+ throw new Error(tr("用法:u1s1 deploy remove <站点名或网址> [--yes]", "Usage: u1s1 deploy remove <site name or URL> [--yes]"));
279
282
  }
280
283
  const reference = siteReferenceFromInput(positional[0]);
281
284
  if (!reference)
282
- throw new Error("站点名不能为空");
285
+ throw new Error(tr("站点名不能为空", "Site name can't be empty"));
283
286
  // 先按列表核对,拿到完整网址给确认提示;名字打错在这里就能提前发现
284
287
  const { sites } = await api(cfg, { method: "GET", path: "/deploy/sites" });
285
288
  const site = sites.find((s) => (s.slug || s.name) === reference || s.name === reference);
286
289
  if (!site) {
287
- throw new Error(`没有找到站点「${reference}」。运行 u1s1 deploy list 查看已有站点`);
290
+ throw new Error(tr(`没有找到站点「${reference}」。运行 u1s1 deploy list 查看已有站点`, `Site "${reference}" not found. Run u1s1 deploy list to see your sites`));
288
291
  }
289
292
  if (!yes) {
290
293
  if (!process.stdin.isTTY) {
291
- throw new Error("非交互环境下删除站点需要加 --yes 明确确认");
294
+ throw new Error(tr("非交互环境下删除站点需要加 --yes 明确确认", "Deleting a site non-interactively requires --yes"));
292
295
  }
293
296
  if (!(await confirmRemoval(site.url))) {
294
- console.log(" 已取消,站点保留。");
297
+ console.log(tr(" 已取消,站点保留。", " Cancelled; the site was kept."));
295
298
  return;
296
299
  }
297
300
  }
@@ -301,8 +304,8 @@ async function removeDeployment(cfg, args) {
301
304
  });
302
305
  forgetSite(site.slug || site.name);
303
306
  console.log("");
304
- console.log(` ✓ 已删除 ${site.url}`);
305
- console.log(" 同一个名字可以再次 u1s1 deploy 重新发布。");
307
+ console.log(tr(` ✓ 已删除 ${site.url}`, ` ✓ Deleted ${site.url}`));
308
+ console.log(tr(" 同一个名字可以再次 u1s1 deploy 重新发布。", " You can publish under the same name again with u1s1 deploy."));
306
309
  console.log("");
307
310
  }
308
311
  async function selectDeployment(args) {
@@ -311,12 +314,12 @@ async function selectDeployment(args) {
311
314
  const dir = resolveSiteDir(dirArg);
312
315
  const files = collectFiles(dir);
313
316
  if (!files.some((file) => file.path === "index.html")) {
314
- throw new Error(`${dir} 里没有 index.html,网站需要一个首页`);
317
+ throw new Error(tr(`${dir} 里没有 index.html,网站需要一个首页`, `${dir} has no index.html; a site needs a home page`));
315
318
  }
316
319
  const totalBytes = files.reduce((sum, file) => sum + file.bytes, 0);
317
320
  console.log("");
318
- console.log(` 部署目录 ${dir}`);
319
- console.log(` 文件 ${files.length} 个,共 ${fmtBytes(totalBytes)}`);
321
+ console.log(tr(` 部署目录 ${dir}`, ` Directory ${dir}`));
322
+ console.log(tr(` 文件 ${files.length} 个,共 ${fmtBytes(totalBytes)}`, ` Files ${files.length}, ${fmtBytes(totalBytes)} total`));
320
323
  // 站点名:--name > 上次用过的 > 交互询问(默认目录名;dist 等产物目录用项目名)
321
324
  const remembered = readDeploys()[dir];
322
325
  if (!name && remembered)
@@ -364,44 +367,45 @@ async function startDeployment(cfg, initialName) {
364
367
  function validateDeploymentLimits(start, files, totalBytes) {
365
368
  const tooBig = files.filter((file) => file.bytes > start.limits.max_file_bytes);
366
369
  if (tooBig.length) {
367
- throw new Error(`这些文件超过单文件上限 ${fmtBytes(start.limits.max_file_bytes)}:\n` +
370
+ throw new Error(tr(`这些文件超过单文件上限 ${fmtBytes(start.limits.max_file_bytes)}:\n`, `These files exceed the per-file limit of ${fmtBytes(start.limits.max_file_bytes)}:\n`) +
368
371
  tooBig.map((file) => ` ${file.path}(${fmtBytes(file.bytes)})`).join("\n"));
369
372
  }
370
373
  if (files.length > start.limits.max_files || totalBytes > start.limits.max_total_bytes) {
371
- throw new Error(`超出配额:最多 ${start.limits.max_files} 个文件 / ${fmtBytes(start.limits.max_total_bytes)}。` +
372
- `当前 ${files.length} 个 / ${fmtBytes(totalBytes)}`);
374
+ throw new Error(tr(`超出配额:最多 ${start.limits.max_files} 个文件 / ${fmtBytes(start.limits.max_total_bytes)}。` +
375
+ `当前 ${files.length} 个 / ${fmtBytes(totalBytes)}`, `Over quota: at most ${start.limits.max_files} files / ${fmtBytes(start.limits.max_total_bytes)}. ` +
376
+ `This deploy has ${files.length} files / ${fmtBytes(totalBytes)}`));
373
377
  }
374
378
  }
375
379
  function printDeploymentResult(result, start, site) {
376
380
  const links = siteLinks(site);
377
- console.log(` ✅ 部署完成,${result.file_count} 个文件已上线`);
381
+ console.log(tr(` ✅ 部署完成,${result.file_count} 个文件已上线`, ` ✅ Deployed, ${result.file_count} files live`));
378
382
  console.log("");
379
383
  console.log(` ${result.visibility === "private" ? "🔒" : "🌐"} ${result.url}`);
380
384
  console.log("");
381
385
  if ((result.hostname || start.hostname || "").split(".").length > 3) {
382
- console.log(" 新 hostname 的 HTTPS 证书会由 Total TLS 自动签发,首次访问可能需要等待几分钟。");
386
+ console.log(tr(" 新 hostname 的 HTTPS 证书会由 Total TLS 自动签发,首次访问可能需要等待几分钟。", " The HTTPS certificate for a new hostname is issued automatically; the first visit may take a few minutes."));
383
387
  }
384
388
  if (result.visibility === "private") {
385
- console.log(` 私密站点仅你可见,请从 ${links.sites} 打开。`);
389
+ console.log(tr(` 私密站点仅你可见,请从 ${links.sites} 打开。`, ` Private sites are only visible to you; open them from ${links.sites}`));
386
390
  }
387
391
  else if (result.community_listed) {
388
- console.log(` 已进入 ${links.community} ,把网址发给朋友就能看。`);
392
+ console.log(tr(` 已进入 ${links.community} ,把网址发给朋友就能看。`, ` Listed on ${links.community} — share the URL with friends.`));
389
393
  }
390
394
  else {
391
- console.log(" 公开链接可直接访问;运行 u1s1 deploy --public 可明确加入作品社区。");
395
+ console.log(tr(" 公开链接可直接访问;运行 u1s1 deploy --public 可明确加入作品社区。", " The public link works for anyone; run u1s1 deploy --public to list it in the community."));
392
396
  }
393
- console.log(" 改完代码再跑一次 u1s1 deploy 即可更新。");
397
+ console.log(tr(" 改完代码再跑一次 u1s1 deploy 即可更新。", " Run u1s1 deploy again after changes to update."));
394
398
  console.log("");
395
399
  }
396
400
  export function printDeployHelp() {
397
401
  console.log("");
398
- console.log(" u1s1 deploy [目录] [--name 站点名] [--public|--private]");
402
+ console.log(tr(" u1s1 deploy [目录] [--name 站点名] [--public|--private]", " u1s1 deploy [dir] [--name site-name] [--public|--private]"));
399
403
  console.log("");
400
- console.log(" 不带参数时自动找构建产物目录(dist/build/out 等),否则用当前目录。");
401
- console.log(" 首次发布会问项目名和公开/私密,之后记住;再跑一次即为更新。");
404
+ console.log(tr(" 不带参数时自动找构建产物目录(dist/build/out 等),否则用当前目录。", " With no directory, uses a build output dir (dist/build/out etc.) if found, else the current dir."));
405
+ console.log(tr(" 首次发布会问项目名和公开/私密,之后记住;再跑一次即为更新。", " The first deploy asks for a name and visibility and remembers them; run it again to update."));
402
406
  console.log("");
403
- console.log(" u1s1 deploy list 查看已发布的站点");
404
- console.log(" u1s1 deploy remove <站点名或网址> 删除站点(会二次确认,--yes 跳过)");
407
+ console.log(tr(" u1s1 deploy list 查看已发布的站点", " u1s1 deploy list List your sites"));
408
+ console.log(tr(" u1s1 deploy remove <站点名或网址> 删除站点(会二次确认,--yes 跳过)", " u1s1 deploy remove <name-or-url> Delete a site (asks to confirm; --yes skips)"));
405
409
  console.log("");
406
410
  }
407
411
  export async function deployCommand(cfg, args) {
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes, randomUUID, webcrypto } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import { VERSION } from "./config.js";
4
+ import { tr } from "./i18n.js";
4
5
  const enc = new TextEncoder();
5
6
  const SIGNING_PROXY_REQUEST_LIMIT_BYTES = 32 * 1024 * 1024;
6
7
  let cachedPrivate;
@@ -42,7 +43,7 @@ async function privateKey(jwk) {
42
43
  }
43
44
  export async function dpopHeaders(cfg, method, url) {
44
45
  if (!hasDeviceCredential(cfg))
45
- throw new Error("当前安装还没有设备凭证,请重新运行 u1s1 login");
46
+ throw new Error(tr("当前安装还没有设备凭证,请重新运行 u1s1 login", "This install has no device credential yet. Run u1s1 login again."));
46
47
  const token = cfg.deviceToken;
47
48
  const header = b64url(JSON.stringify({
48
49
  typ: "dpop+jwt",
@@ -76,7 +77,7 @@ export async function authorizedFetch(cfg, input, init = {}) {
76
77
  headers.set("authorization", `Bearer ${cfg.apiKey}`);
77
78
  }
78
79
  else {
79
- throw new Error("还没有登录,请运行 u1s1 login");
80
+ throw new Error(tr("还没有登录,请运行 u1s1 login", "Not signed in. Run u1s1 login."));
80
81
  }
81
82
  return fetch(url, { ...init, headers });
82
83
  }
@@ -214,7 +215,7 @@ function clientSurface(fallback) {
214
215
  */
215
216
  export async function ensureSigningProxy(cfg, fallbackClient = "terminal", attestationSource) {
216
217
  if (!hasDeviceCredential(cfg))
217
- throw new Error("当前安装需要重新登录,以创建设备凭证");
218
+ throw new Error(tr("当前安装需要重新登录,以创建设备凭证", "Please sign in again to create a device credential for this install."));
218
219
  const client = clientSurface(fallbackClient);
219
220
  const current = signingProxy;
220
221
  if (current && current.token === cfg.deviceToken && current.client === client) {
@@ -286,10 +287,11 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal", attes
286
287
  if (res.headersSent)
287
288
  return void res.destroy(error);
288
289
  res.writeHead(502, { "content-type": "application/json" });
289
- // 中文说明放前面给用户看;原始英文错误保留在括号里,pi 的重试分类正则
290
+ // 说明放前面给用户看;原始英文错误保留在括号里,pi 的重试分类正则
290
291
  // (fetch failed / timeout 等)和排查都还认得
291
292
  const raw = error instanceof Error ? error.message : "signing proxy failed";
292
- res.end(JSON.stringify({ error: { message: `连不上 u1s1 服务器,检查一下网络?(${raw})` } }));
293
+ const message = tr(`连不上 u1s1 服务器,检查一下网络?(${raw})`, `Can't reach the u1s1 server. Check your network? (${raw})`);
294
+ res.end(JSON.stringify({ error: { message } }));
293
295
  }
294
296
  });
295
297
  await new Promise((resolve, reject) => {
@@ -16,6 +16,7 @@ import { type Site } from "./site.js";
16
16
  * 额度用尽(reason=exhausted)且今天已打卡时网关还带 error.resets_at(下次可打卡时刻,
17
17
  * 即北京时间 0 点,ISO),这里换算成「距下次可打卡还有 N 小时 M 分」接在正文后:比
18
18
  * 「北京时间 0 点」更省用户脑子。今天没打卡时网关不带 resets_at(现在就能领)。
19
+ * 国际站没有打卡/免费包(网关也不带 resets_at),英文界面一律不给这句。
19
20
  */
20
21
  export declare function quotaResetHint(resetsAt: unknown, now?: number): string;
21
22
  /** 只取错误里的 request_id(纯函数,给 request-trace 记「最近一次请求编号」用)。 */
@@ -1,4 +1,5 @@
1
1
  import { activeSite } from "./config.js";
2
+ import { isEnglish, tr } from "./i18n.js";
2
3
  import { siteLinks } from "./site.js";
3
4
  /**
4
5
  * 模型调用失败时,网关的中文说明会被 OpenAI SDK + pi-ai 包成
@@ -17,8 +18,11 @@ import { siteLinks } from "./site.js";
17
18
  * 额度用尽(reason=exhausted)且今天已打卡时网关还带 error.resets_at(下次可打卡时刻,
18
19
  * 即北京时间 0 点,ISO),这里换算成「距下次可打卡还有 N 小时 M 分」接在正文后:比
19
20
  * 「北京时间 0 点」更省用户脑子。今天没打卡时网关不带 resets_at(现在就能领)。
21
+ * 国际站没有打卡/免费包(网关也不带 resets_at),英文界面一律不给这句。
20
22
  */
21
23
  export function quotaResetHint(resetsAt, now = Date.now()) {
24
+ if (isEnglish())
25
+ return "";
22
26
  if (typeof resetsAt !== "string")
23
27
  return "";
24
28
  const at = Date.parse(resetsAt);
@@ -72,10 +76,10 @@ function humanizeOpaqueError(raw, site) {
72
76
  return undefined;
73
77
  const n = Number(status);
74
78
  const body = n >= 500
75
- ? `服务暂时不可用,稍等再试;持续出现可看状态页 ${siteLinks(site).status}`
79
+ ? tr(`服务暂时不可用,稍等再试;持续出现可看状态页 ${siteLinks(site).status}`, `The service is temporarily unavailable. Please try again shortly; if it keeps happening, check the status page: ${siteLinks(site).status}`)
76
80
  : n === 429
77
- ? "请求太频繁,稍等再试"
78
- : "请求被拒绝,重试仍失败可运行 u1s1 feedback 反馈";
81
+ ? tr("请求太频繁,稍等再试", "Too many requests. Please wait a moment and try again.")
82
+ : tr("请求被拒绝,重试仍失败可运行 u1s1 feedback 反馈", "The request was rejected. If retrying doesn't help, run u1s1 feedback to report it.");
79
83
  return `${body} (HTTP ${status})`;
80
84
  }
81
85
  export function humanizeModelError(raw, now = Date.now(), site) {
@@ -91,13 +95,21 @@ export function humanizeModelError(raw, now = Date.now(), site) {
91
95
  const type = typeof err["type"] === "string" ? err["type"] : "";
92
96
  const status = /(?:^|\s|\()(\d{3})\b/.exec(raw.slice(0, jsonStart))?.[1];
93
97
  const requestId = typeof err["request_id"] === "string" ? err["request_id"] : "";
98
+ const insufficient = type === "insufficient_quota" || code === "quota_exceeded";
94
99
  const tags = [
95
100
  status ? `HTTP ${status}` : "",
96
- type === "insufficient_quota" || code === "quota_exceeded" ? "insufficient_quota" : code,
97
- requestId ? `请求编号 ${requestId}` : "",
101
+ insufficient ? "insufficient_quota" : code,
102
+ requestId ? tr(`请求编号 ${requestId}`, `request ID ${requestId}`) : "",
98
103
  ].filter(Boolean);
99
104
  const resetHint = err["reason"] === "exhausted" ? quotaResetHint(err["resets_at"], now) : "";
100
- const body = resetHint ? `${message}〔${resetHint}〕` : message;
105
+ let body = resetHint ? `${message}〔${resetHint}〕` : message;
106
+ // 国际站(英文界面)余额不足:网关正文本该带充值链接;老网关/个别路由没带时补一句,指向本站充值卡片。
107
+ // 中文界面不动(中国站网关文案自带仪表盘链接,另有免费包等分流说明)
108
+ if (insufficient && isEnglish()) {
109
+ const links = siteLinks(site ?? activeSite());
110
+ if (!message.includes(links.dashboard))
111
+ body = `${body} Top up to continue → ${links.topup}`;
112
+ }
101
113
  const friendly = tags.length ? `${body} (${tags.join(" · ")})` : body;
102
114
  return friendly === raw ? undefined : friendly;
103
115
  }
package/dist/feedback.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readJsonResponseCapped } from "./api.js";
2
2
  import { activeSite, loadConfig, PROVIDER_ID, resolvePreferredModel, VERSION } from "./config.js";
3
3
  import { authorizedFetch, hasDeviceCredential } from "./device-auth.js";
4
+ import { tr } from "./i18n.js";
4
5
  import { askLine } from "./prompt.js";
5
6
  import { lastRequestId } from "./request-trace.js";
6
7
  import { siteLinks, siteOf } from "./site.js";
@@ -10,13 +11,14 @@ import { siteLinks, siteOf } from "./site.js";
10
11
  * 走 POST /v1/support/tickets(设备签名),201 返回 {id, url}。
11
12
  */
12
13
  export const FEEDBACK_CATEGORIES = ["question", "bug", "billing", "suggestion", "other"];
13
- const CATEGORY_LABELS = {
14
- question: "提问",
15
- bug: "故障",
16
- billing: "计费",
17
- suggestion: "建议",
18
- other: "其他",
19
- };
14
+ // 用时求值:模块加载时还不知道站点/语言
15
+ const categoryLabels = () => ({
16
+ question: tr("提问", "question"),
17
+ bug: tr("故障", "bug"),
18
+ billing: tr("计费", "billing"),
19
+ suggestion: tr("建议", "suggestion"),
20
+ other: tr("其他", "other"),
21
+ });
20
22
  export const FEEDBACK_SUBJECT_MAX = 50;
21
23
  export const FEEDBACK_MESSAGE_MAX = 4_000;
22
24
  /** 当前站点仪表盘的工单/留言区,提交失败或服务端没回链接时的兜底入口。 */
@@ -36,7 +38,7 @@ export function parseFeedbackArgs(args) {
36
38
  continue;
37
39
  }
38
40
  if (arg.startsWith("--"))
39
- throw new Error(`不认识的 feedback 参数:${arg}(可用 --bug --question --suggestion --billing)`);
41
+ throw new Error(tr(`不认识的 feedback 参数:${arg}(可用 --bug --question --suggestion --billing)`, `Unknown feedback option: ${arg} (use --bug --question --suggestion --billing)`));
40
42
  words.push(arg);
41
43
  }
42
44
  return { category, text: words.join(" ").trim() };
@@ -45,7 +47,7 @@ export function parseFeedbackArgs(args) {
45
47
  export function buildFeedbackRequest(input) {
46
48
  const message = input.text.replace(/\r\n?/g, "\n").trim();
47
49
  if (!message)
48
- throw new Error("反馈内容不能为空");
50
+ throw new Error(tr("反馈内容不能为空", "Feedback can't be empty"));
49
51
  const firstLine = message.split("\n").find((l) => l.trim())?.replace(/\s+/g, " ").trim() ?? message;
50
52
  const subject = firstLine.length > FEEDBACK_SUBJECT_MAX
51
53
  ? `${firstLine.slice(0, FEEDBACK_SUBJECT_MAX - 1)}…`
@@ -90,7 +92,7 @@ export async function submitFeedback(cfg, body) {
90
92
  });
91
93
  }
92
94
  catch {
93
- throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
95
+ throw new Error(tr(`连不上 ${cfg.baseUrl},检查一下网络?`, `Can't reach ${cfg.baseUrl}. Check your network?`));
94
96
  }
95
97
  const value = await readJsonResponseCapped(resp, MAX_TICKET_RESPONSE_BYTES).catch(() => null);
96
98
  const data = value && typeof value === "object" && !Array.isArray(value) ? value : null;
@@ -106,7 +108,7 @@ export async function submitFeedback(cfg, body) {
106
108
  function createdOutcome(status, data, site) {
107
109
  const id = typeof data?.id === "string" || typeof data?.id === "number" ? String(data.id) : "";
108
110
  if (!id)
109
- return { ok: false, status, message: "服务端工单响应格式不正确" };
111
+ return { ok: false, status, message: tr("服务端工单响应格式不正确", "Unexpected support ticket response from the server") };
110
112
  const url = typeof data?.url === "string" && data.url.startsWith("https://") ? data.url : supportUrl(site);
111
113
  return { ok: true, id, url };
112
114
  }
@@ -121,32 +123,40 @@ function retryAfterSeconds(resp, data) {
121
123
  function failureMessage(status, data) {
122
124
  if (typeof data?.error?.message === "string")
123
125
  return data.error.message.slice(0, 500);
124
- return status === 401 ? "登录已失效,请重新运行 u1s1 login" : `服务端返回 ${status},稍后再试`;
126
+ return status === 401
127
+ ? tr("登录已失效,请重新运行 u1s1 login", "Your session has expired. Run u1s1 login to sign in again.")
128
+ : tr(`服务端返回 ${status},稍后再试`, `Server returned ${status}. Please try again later.`);
125
129
  }
126
130
  /** 结果措辞,终端与会话内共用(每项一行)。 */
127
131
  export function feedbackOutcomeLines(outcome, site = activeSite()) {
128
132
  if (outcome.ok) {
129
133
  return [
130
- `✅ 反馈已提交,工单编号 ${outcome.id}。`,
131
- `进度与回复在这里看:${outcome.url}`,
132
- "我们通常会在 24 小时内回复到你的注册邮箱。",
134
+ tr(`✅ 反馈已提交,工单编号 ${outcome.id}。`, `✅ Feedback sent. Ticket #${outcome.id}.`),
135
+ tr(`进度与回复在这里看:${outcome.url}`, `Track progress and replies here: ${outcome.url}`),
136
+ tr("我们通常会在 24 小时内回复到你的注册邮箱。", "We usually reply to your account email within 24 hours."),
133
137
  ];
134
138
  }
135
139
  if (outcome.status === 429) {
136
140
  const wait = outcome.retryAfterSeconds
137
- ? `请等 ${Math.ceil(outcome.retryAfterSeconds / 60) || 1} 分钟后再试`
138
- : "请稍等几分钟再试";
139
- return [`提交太频繁了,${wait}。`, `等不及的话直接到后台留言:${supportUrl(site)}`];
141
+ ? tr(`请等 ${Math.ceil(outcome.retryAfterSeconds / 60) || 1} 分钟后再试`, `please wait ${Math.ceil(outcome.retryAfterSeconds / 60) || 1} min and try again`)
142
+ : tr("请稍等几分钟再试", "please wait a few minutes and try again");
143
+ return [
144
+ tr(`提交太频繁了,${wait}。`, `Too many submissions; ${wait}.`),
145
+ tr(`等不及的话直接到后台留言:${supportUrl(site)}`, `Or leave a message on the dashboard: ${supportUrl(site)}`),
146
+ ];
140
147
  }
141
- return [`提交失败:${outcome.message}`, `可以直接到后台留言:${supportUrl(site)}`];
148
+ return [
149
+ tr(`提交失败:${outcome.message}`, `Submission failed: ${outcome.message}`),
150
+ tr(`可以直接到后台留言:${supportUrl(site)}`, `You can leave a message on the dashboard instead: ${supportUrl(site)}`),
151
+ ];
142
152
  }
143
153
  export function printFeedbackHelp() {
144
154
  console.log("");
145
- console.log(" u1s1 feedback [--bug|--question|--suggestion|--billing] [一句话描述]");
155
+ console.log(tr(" u1s1 feedback [--bug|--question|--suggestion|--billing] [一句话描述]", " u1s1 feedback [--bug|--question|--suggestion|--billing] [short description]"));
146
156
  console.log("");
147
- console.log(" 不带描述时会问你一行;分类不写默认「其他」。");
148
- console.log(" 会自动附上版本、平台、最近一次报错的请求编号,方便我们定位。");
149
- console.log(` 也可以在后台留言:${supportUrl()}`);
157
+ console.log(tr(" 不带描述时会问你一行;分类不写默认「其他」。", " Without a description you'll be asked for one line; the category defaults to \"other\"."));
158
+ console.log(tr(" 会自动附上版本、平台、最近一次报错的请求编号,方便我们定位。", " Your version, platform and the request ID of the last error are attached automatically to help us debug."));
159
+ console.log(tr(` 也可以在后台留言:${supportUrl()}`, ` You can also leave a message on the dashboard: ${supportUrl()}`));
150
160
  console.log("");
151
161
  }
152
162
  /** 终端命令入口。 */
@@ -158,16 +168,16 @@ export async function feedbackCommand(args) {
158
168
  }
159
169
  const cfg = loadConfig();
160
170
  if (!hasDeviceCredential(cfg)) {
161
- console.error("还没登录,先跑 u1s1 login");
171
+ console.error(tr("还没登录,先跑 u1s1 login", "Not signed in. Run u1s1 login first."));
162
172
  process.exitCode = 1;
163
173
  return;
164
174
  }
165
175
  let text = parsed.text;
166
176
  if (!text) {
167
- text = await askLine(` 想反馈什么?(${CATEGORY_LABELS[parsed.category]},一行说清即可):`);
177
+ text = await askLine(tr(` 想反馈什么?(${categoryLabels()[parsed.category]},一行说清即可):`, ` What's your feedback? (${categoryLabels()[parsed.category]}, one line is enough): `));
168
178
  }
169
179
  if (!text) {
170
- console.error("反馈内容不能为空。用法:u1s1 feedback \"一句话描述\"");
180
+ console.error(tr("反馈内容不能为空。用法:u1s1 feedback \"一句话描述\"", "Feedback can't be empty. Usage: u1s1 feedback \"short description\""));
171
181
  process.exitCode = 1;
172
182
  return;
173
183
  }
@@ -191,13 +201,17 @@ export async function feedbackCommand(args) {
191
201
  export async function feedbackInSession(rawArgs, model) {
192
202
  const parsed = parseFeedbackArgs(rawArgs.split(/\s+/).filter(Boolean));
193
203
  if (parsed.help) {
194
- return ["/feedback [--bug|--question|--suggestion|--billing] 一句话描述", `或到后台留言:${supportUrl()}`];
204
+ return [
205
+ tr("/feedback [--bug|--question|--suggestion|--billing] 一句话描述", "/feedback [--bug|--question|--suggestion|--billing] short description"),
206
+ tr(`或到后台留言:${supportUrl()}`, `Or leave a message on the dashboard: ${supportUrl()}`),
207
+ ];
195
208
  }
196
209
  const cfg = loadConfig();
197
210
  if (!hasDeviceCredential(cfg))
198
- return ["还没登录,先在终端运行 u1s1 login"];
199
- if (!parsed.text)
200
- return ["反馈内容不能为空,例如:/feedback --bug 生成的网页打不开"];
211
+ return [tr("还没登录,先在终端运行 u1s1 login", "Not signed in. Run u1s1 login in your terminal first.")];
212
+ if (!parsed.text) {
213
+ return [tr("反馈内容不能为空,例如:/feedback --bug 生成的网页打不开", "Feedback can't be empty, e.g. /feedback --bug the generated page won't open")];
214
+ }
201
215
  const body = buildFeedbackRequest({
202
216
  text: parsed.text,
203
217
  category: parsed.category,
@@ -207,6 +221,9 @@ export async function feedbackInSession(rawArgs, model) {
207
221
  return feedbackOutcomeLines(await submitFeedback(cfg, body), siteOf(cfg));
208
222
  }
209
223
  catch (e) {
210
- return [e instanceof Error ? e.message : String(e), `可以直接到后台留言:${supportUrl(siteOf(cfg))}`];
224
+ return [
225
+ e instanceof Error ? e.message : String(e),
226
+ tr(`可以直接到后台留言:${supportUrl(siteOf(cfg))}`, `You can leave a message on the dashboard instead: ${supportUrl(siteOf(cfg))}`),
227
+ ];
211
228
  }
212
229
  }
package/dist/i18n.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 界面语言跟站点走:国际站(u1s1.dev)英文,中国站中文;不提供语言切换
3
+ * (运营者 2026-10-01 定「国际版只要英文」)。站点按下载渠道定,见 site.ts。
4
+ *
5
+ * 文案就地写成 tr("中文", "English"),不做 key 表:两种语言挨着,改一处顺手改另一处。
6
+ * 模块顶层常量不能直接调 tr —— 那时配置可能还没读,要写成函数在用时求值。
7
+ *
8
+ * 本模块不 import 任何本地模块:config.ts 在加载时用 setLangResolver 注册「按当前站点
9
+ * 判定」,saveConfig 后 resetLang(),`u1s1 login --site` 切站后同进程立即换语言。
10
+ */
11
+ export type Lang = "zh" | "en";
12
+ export declare function setLangResolver(fn: () => Lang): void;
13
+ /** 配置变了(换站)后调用,下次 lang() 重新判定。 */
14
+ export declare function resetLang(): void;
15
+ /**
16
+ * 当前界面语言。U1S1_LANG=en|zh 可强制(测试用;CLI 拉起 pi 前也写它,
17
+ * 让 pi 补丁里的文案跟着走,见 patches/ 与 scripts/patch-pi.js)。
18
+ */
19
+ export declare function lang(): Lang;
20
+ export declare function isEnglish(): boolean;
21
+ export declare function tr(zh: string, en: string): string;
package/dist/i18n.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * 界面语言跟站点走:国际站(u1s1.dev)英文,中国站中文;不提供语言切换
3
+ * (运营者 2026-10-01 定「国际版只要英文」)。站点按下载渠道定,见 site.ts。
4
+ *
5
+ * 文案就地写成 tr("中文", "English"),不做 key 表:两种语言挨着,改一处顺手改另一处。
6
+ * 模块顶层常量不能直接调 tr —— 那时配置可能还没读,要写成函数在用时求值。
7
+ *
8
+ * 本模块不 import 任何本地模块:config.ts 在加载时用 setLangResolver 注册「按当前站点
9
+ * 判定」,saveConfig 后 resetLang(),`u1s1 login --site` 切站后同进程立即换语言。
10
+ */
11
+ let resolver;
12
+ let cached;
13
+ export function setLangResolver(fn) {
14
+ resolver = fn;
15
+ cached = undefined;
16
+ }
17
+ /** 配置变了(换站)后调用,下次 lang() 重新判定。 */
18
+ export function resetLang() {
19
+ cached = undefined;
20
+ }
21
+ /**
22
+ * 当前界面语言。U1S1_LANG=en|zh 可强制(测试用;CLI 拉起 pi 前也写它,
23
+ * 让 pi 补丁里的文案跟着走,见 patches/ 与 scripts/patch-pi.js)。
24
+ */
25
+ export function lang() {
26
+ const forced = process.env["U1S1_LANG"];
27
+ if (forced === "en" || forced === "zh")
28
+ return forced;
29
+ if (cached)
30
+ return cached;
31
+ if (!resolver)
32
+ return "zh"; // config.ts 还没加载:不缓存,免得把默认值钉死
33
+ try {
34
+ cached = resolver();
35
+ }
36
+ catch {
37
+ cached = "zh";
38
+ }
39
+ return cached;
40
+ }
41
+ export function isEnglish() {
42
+ return lang() === "en";
43
+ }
44
+ export function tr(zh, en) {
45
+ return lang() === "en" ? en : zh;
46
+ }