u1s1-cli 1.2.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/api.d.ts CHANGED
@@ -63,6 +63,9 @@ export interface ModelsResponse {
63
63
  features: ApiFeatures;
64
64
  announcement?: ApiAnnouncement | null;
65
65
  }
66
+ /** 401:凭证失效(设备被移除/换过钥匙),调用方应引导重新登录而不是继续硬跑。 */
67
+ export declare class AuthError extends Error {
68
+ }
66
69
  export declare function fetchModels(cfg: CliConfig): Promise<ModelsResponse>;
67
70
  export interface ApiEndpoint {
68
71
  id: string;
package/dist/api.js CHANGED
@@ -1,17 +1,21 @@
1
1
  import { apiEndpointToCustom, loadEndpointsCache, saveEndpointsCache, setCustomEndpoints, VERSION, } from "./config.js";
2
2
  import { authorizedFetch } from "./device-auth.js";
3
+ /** 401:凭证失效(设备被移除/换过钥匙),调用方应引导重新登录而不是继续硬跑。 */
4
+ export class AuthError extends Error {
5
+ }
3
6
  export async function fetchModels(cfg) {
4
7
  let resp;
5
8
  try {
6
9
  resp = await authorizedFetch(cfg, `${cfg.baseUrl}/models`, {
7
10
  headers: { "x-u1s1-version": VERSION },
11
+ signal: AbortSignal.timeout(15_000),
8
12
  });
9
13
  }
10
14
  catch {
11
15
  throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
12
16
  }
13
17
  if (resp.status === 401)
14
- throw new Error("这把 Key 不对或已失效,去 https://u1s1.io/dashboard 看看");
18
+ throw new AuthError("登录已失效,请重新运行 u1s1 login");
15
19
  if (!resp.ok)
16
20
  throw new Error(`服务端返回 ${resp.status},稍后再试`);
17
21
  const body = (await resp.json());
@@ -26,6 +30,7 @@ export async function fetchUserEndpoints(cfg) {
26
30
  try {
27
31
  resp = await authorizedFetch(cfg, `${cfg.baseUrl}/endpoints`, {
28
32
  headers: { "x-u1s1-version": VERSION },
33
+ signal: AbortSignal.timeout(15_000),
29
34
  });
30
35
  }
31
36
  catch {
@@ -63,14 +68,14 @@ export async function searchWeb(cfg, query, maxResults, signal) {
63
68
  "content-type": "application/json",
64
69
  },
65
70
  body: JSON.stringify({ query, max_results: maxResults }),
66
- signal,
71
+ signal: signal ?? AbortSignal.timeout(60_000),
67
72
  });
68
73
  }
69
74
  catch {
70
75
  throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
71
76
  }
72
77
  if (resp.status === 401)
73
- throw new Error("这把 Key 不对或已失效,去 https://u1s1.io/dashboard 看看");
78
+ throw new Error("登录已失效,请重新运行 u1s1 login");
74
79
  if (!resp.ok) {
75
80
  const body = (await resp.json().catch(() => null));
76
81
  throw new Error(body?.error?.message ?? `搜索服务返回 ${resp.status},稍后再试`);
@@ -90,14 +95,14 @@ export async function renderPage(cfg, url, signal) {
90
95
  "content-type": "application/json",
91
96
  },
92
97
  body: JSON.stringify({ url }),
93
- signal,
98
+ signal: signal ?? AbortSignal.timeout(90_000),
94
99
  });
95
100
  }
96
101
  catch {
97
102
  throw new Error(`连不上 ${cfg.baseUrl}`);
98
103
  }
99
104
  if (resp.status === 401)
100
- throw new Error("这把 Key 不对或已失效,去 https://u1s1.io/dashboard 看看");
105
+ throw new Error("登录已失效,请重新运行 u1s1 login");
101
106
  if (!resp.ok) {
102
107
  const body = (await resp.json().catch(() => null));
103
108
  throw new Error(body?.error?.message ?? `渲染服务返回 ${resp.status}`);
@@ -117,7 +122,7 @@ export async function generateImage(cfg, req, signal) {
117
122
  "content-type": "application/json",
118
123
  },
119
124
  body: JSON.stringify(req),
120
- signal,
125
+ signal: signal ?? AbortSignal.timeout(180_000),
121
126
  });
122
127
  }
123
128
  catch (e) {
@@ -126,7 +131,7 @@ export async function generateImage(cfg, req, signal) {
126
131
  throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
127
132
  }
128
133
  if (resp.status === 401)
129
- throw new Error("这把 Key 不对或已失效,去 https://u1s1.io/dashboard 看看");
134
+ throw new Error("登录已失效,请重新运行 u1s1 login");
130
135
  if (!resp.ok) {
131
136
  const body = (await resp.json().catch(() => null));
132
137
  throw new Error(body?.error?.message ?? `图片生成服务返回 ${resp.status},稍后再试`);
@@ -140,13 +145,14 @@ export async function fetchMe(cfg) {
140
145
  try {
141
146
  resp = await authorizedFetch(cfg, `${cfg.baseUrl}/me`, {
142
147
  headers: { "x-u1s1-version": VERSION },
148
+ signal: AbortSignal.timeout(15_000),
143
149
  });
144
150
  }
145
151
  catch {
146
152
  throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
147
153
  }
148
154
  if (resp.status === 401)
149
- throw new Error("这把 Key 不对或已失效,去 https://u1s1.io/dashboard 看看");
155
+ throw new Error("登录已失效,请重新运行 u1s1 login");
150
156
  if (!resp.ok)
151
157
  throw new Error(`服务端返回 ${resp.status},稍后再试`);
152
158
  return (await resp.json());
package/dist/deploy.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, join, relative, resolve, sep } from "node:path";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { VERSION, u1s1Dir } from "./config.js";
@@ -21,9 +21,17 @@ function readDeploys() {
21
21
  }
22
22
  }
23
23
  function rememberSite(dir, site) {
24
- const all = readDeploys();
25
- all[dir] = site;
26
- writeFileSync(deploysFile, JSON.stringify(all, null, 2) + "\n");
24
+ // 记录失败不致命(比如纯环境变量登录时 ~/.u1s1 不存在):下次部署重新问名字而已,
25
+ // 绝不能因为这里抛错吞掉「部署成功 + 网址」的输出
26
+ try {
27
+ mkdirSync(u1s1Dir, { recursive: true });
28
+ const all = readDeploys();
29
+ all[dir] = site;
30
+ writeFileSync(deploysFile, JSON.stringify(all, null, 2) + "\n");
31
+ }
32
+ catch {
33
+ // ignore
34
+ }
27
35
  }
28
36
  /** 找要部署的目录:显式参数 > 含 index.html 的构建产物目录 > 当前目录本身。 */
29
37
  function resolveSiteDir(explicit) {
@@ -122,6 +130,7 @@ async function api(cfg, method, path, body) {
122
130
  method,
123
131
  headers: { "x-u1s1-version": VERSION, "content-type": "application/json" },
124
132
  body: body === undefined ? undefined : JSON.stringify(body),
133
+ signal: AbortSignal.timeout(30_000),
125
134
  });
126
135
  }
127
136
  catch {
@@ -145,6 +154,7 @@ async function uploadAll(cfg, start, files) {
145
154
  method: "PUT",
146
155
  headers: { "x-u1s1-version": VERSION, "content-type": "application/octet-stream" },
147
156
  body: readFileSync(f.abs),
157
+ signal: AbortSignal.timeout(120_000),
148
158
  });
149
159
  let resp = await put().catch(() => null);
150
160
  if (!resp?.ok)
@@ -160,8 +170,13 @@ async function uploadAll(cfg, start, files) {
160
170
  for (let f = queue.shift(); f; f = queue.shift())
161
171
  await uploadOne(f);
162
172
  });
163
- await Promise.all(workers);
164
- process.stdout.write("\r" + " ".repeat(70) + "\r");
173
+ try {
174
+ await Promise.all(workers);
175
+ }
176
+ finally {
177
+ // 失败时也要清掉进度行,否则错误信息糊在「上传中 12/40 …」后面
178
+ process.stdout.write("\r" + " ".repeat(70) + "\r");
179
+ }
165
180
  }
166
181
  async function promptSiteName(def) {
167
182
  if (!process.stdin.isTTY)
@@ -201,6 +216,11 @@ export async function deployCommand(cfg, args) {
201
216
  console.log(" 还没有部署过站点。在网页目录里跑 u1s1 deploy 试试。");
202
217
  return;
203
218
  }
219
+ // 服务端给的是 UTC 时间戳,转成本地时间展示
220
+ const fmtTime = (ts) => {
221
+ const d = new Date(ts.includes("T") ? ts : `${ts.replace(" ", "T")}Z`);
222
+ return Number.isNaN(d.getTime()) ? ts : d.toLocaleString("zh-CN", { hour12: false });
223
+ };
204
224
  console.log("");
205
225
  for (const s of sites) {
206
226
  const visibility = s.visibility === "private" ? "私密" : s.community_listed ? "公开(社区)" : "公开(未展示)";
@@ -154,7 +154,10 @@ export async function ensureSigningProxy(cfg, fallbackClient = "terminal") {
154
154
  if (res.headersSent)
155
155
  return void res.destroy(error);
156
156
  res.writeHead(502, { "content-type": "application/json" });
157
- res.end(JSON.stringify({ error: { message: error instanceof Error ? error.message : "signing proxy failed" } }));
157
+ // 中文说明放前面给用户看;原始英文错误保留在括号里,pi 的重试分类正则
158
+ // (fetch failed / timeout 等)和排查都还认得
159
+ const raw = error instanceof Error ? error.message : "signing proxy failed";
160
+ res.end(JSON.stringify({ error: { message: `连不上 u1s1 服务器,检查一下网络?(${raw})` } }));
158
161
  }
159
162
  });
160
163
  await new Promise((resolve, reject) => {
package/dist/index.js CHANGED
@@ -8,7 +8,7 @@ import { registerLoopCommand } from "./loop.js";
8
8
  import { ensureSearchTools } from "./search-tools.js";
9
9
  import { ensureUsableShell } from "./shell-doctor.js";
10
10
  import { applyBrandUi, setAnnouncement, setUpdateNotice } from "./style.js";
11
- import { fetchModels, loadCustomEndpoints } from "./api.js";
11
+ import { AuthError, fetchModels, loadCustomEndpoints } from "./api.js";
12
12
  import { ensureSigningProxy } from "./device-auth.js";
13
13
  const PACKAGE_NAME = "u1s1-cli";
14
14
  /** 启动时检测到的可自动安装的新版;TUI 退出后才装(见 installPendingUpdate)。 */
@@ -41,8 +41,9 @@ async function checkForUpdate() {
41
41
  }
42
42
  if (!latest)
43
43
  return;
44
- const pa = VERSION.split(".").map(Number);
45
- const pb = latest.split(".").map(Number);
44
+ // parseInt 而非 Number:预发布段("0-beta.1")取前导数字,避免 NaN 让比较全军覆没
45
+ const pa = VERSION.split(".").map((s) => parseInt(s, 10) || 0);
46
+ const pb = latest.split(".").map((s) => parseInt(s, 10) || 0);
46
47
  let newer = false;
47
48
  for (let i = 0; i < 3; i++) {
48
49
  const diff = (pb[i] ?? 0) - (pa[i] ?? 0);
@@ -89,8 +90,14 @@ function installPendingUpdate() {
89
90
  console.log(`✅ 已更新到 v${latest},下次运行 u1s1 生效。`);
90
91
  }
91
92
  catch {
92
- // 失败不挡退出,下次启动还会再试;也可手动 `u1s1 update`
93
- console.error("自动更新失败。可稍后手动运行:u1s1 update");
93
+ // 失败不挡退出,下次启动还会再试。npm 全局目录没写权限时 `u1s1 update` 也会
94
+ // 走同一条命令再失败一次,所以这里直接给出能跳出循环的路径(官网安装脚本)
95
+ console.error("自动更新失败(上面是 npm 的报错,常见原因是没有全局安装权限)。");
96
+ console.error("可以改用官网安装脚本重装,一步到位:");
97
+ console.error(process.platform === "win32"
98
+ ? " powershell -c \"irm https://u1s1.io/releases/install.ps1 | iex\""
99
+ : " curl -fsSL https://u1s1.io/releases/install.sh | bash");
100
+ console.error("不想每次退出都尝试更新的话,可在 ~/.u1s1/agent/settings.json 里设 \"autoUpdate\": false");
94
101
  }
95
102
  }
96
103
  /**
@@ -151,8 +158,28 @@ async function runAgent(cfg, args) {
151
158
  // 老网关没有 /v1/image,image_gen 缺失时按关闭处理,不注册生图工具
152
159
  let imageGenEnabled = false;
153
160
  const endpointsReady = loadCustomEndpoints(cfg);
161
+ let modelsResp;
154
162
  try {
155
- const { models, features, announcement } = await fetchModels(cfg);
163
+ modelsResp = await fetchModels(cfg);
164
+ }
165
+ catch (e) {
166
+ if (e instanceof AuthError) {
167
+ // 凭证形状还在但服务端已作废(设备被移除/换过钥匙):直接进 TUI 每条消息都会失败,
168
+ // 不如现在就引导重新登录
169
+ console.error(" 登录已失效(这台设备可能被移除,或账号更换过钥匙),需要重新登录。");
170
+ const { login } = await import("./login.js");
171
+ cfg = await login();
172
+ modelsResp = await fetchModels(cfg).catch((e2) => {
173
+ console.error(" 获取模型列表失败,使用内置列表:", e2.message);
174
+ return undefined;
175
+ });
176
+ }
177
+ else {
178
+ console.error(" 获取模型列表失败,使用内置列表:", e.message);
179
+ }
180
+ }
181
+ if (modelsResp) {
182
+ const { models, features, announcement } = modelsResp;
156
183
  setModelsFromApi(models.map(apiModelToDef));
157
184
  webSearchEnabled = features.web_search !== false;
158
185
  webFetchRenderEnabled = features.web_fetch_render === true;
@@ -161,9 +188,6 @@ async function runAgent(cfg, args) {
161
188
  if (announcement?.text)
162
189
  setAnnouncement(announcement);
163
190
  }
164
- catch (e) {
165
- console.error(" 获取模型列表失败,使用内置列表:", e.message);
166
- }
167
191
  await endpointsReady;
168
192
  // pi provider 只支持静态 header;指向本机 signing proxy,由它逐请求附 DPoP proof。
169
193
  const signing = await ensureSigningProxy(cfg);
@@ -197,20 +221,23 @@ async function runAgent(cfg, args) {
197
221
  // 网页终端(如 Taikula)把 Shift+Enter 发成 ESC+CR(\x1b\r)。pi 在没开 Kitty
198
222
  // 协议时把它当成 Alt+Enter:空闲就直接发送,忙碌才排队。先改成 CSI-u 的
199
223
  // Shift+Enter,输入框就会换行,真·Alt+Enter(\x1b[13;3u)不受影响。
224
+ // TUI 里 console.log 会被下一帧重绘吞掉,拦截提示必须走 ui.notify;
225
+ // session_start 时把 notify 句柄存出来给编辑器拦截用
226
+ let notifyUi;
200
227
  class ShiftEnterEditor extends CustomEditor {
201
228
  handleInput(data) {
202
229
  // 拦截 /login 和 /logout,不让 pi 弹出内置供应商列表
203
230
  if (data === "\r" || data === "\n") {
204
231
  const text = this.getText().trim();
232
+ const tip = (msg) => (notifyUi ? notifyUi(msg) : console.log(` ${msg}`));
205
233
  if (text.startsWith("/login")) {
206
- console.log(" 切换模型用 /model;要换 u1s1 账号的话,先 /exit 退出,再在终端运行:");
207
- console.log(" u1s1 logout && u1s1 login(浏览器里选要用的账号)");
234
+ tip("切换模型用 /model;要换 u1s1 账号:先 /exit 退出,再在终端运行 u1s1 logout && u1s1 login");
208
235
  this.setText("");
209
236
  this.addToHistory?.(text);
210
237
  return;
211
238
  }
212
239
  if (text === "/logout") {
213
- console.log(" 退出登录请先 /exit 回到终端,再运行:u1s1 logout");
240
+ tip("退出登录请先 /exit 回到终端,再运行:u1s1 logout");
214
241
  this.setText("");
215
242
  this.addToHistory?.(text);
216
243
  return;
@@ -283,6 +310,7 @@ async function runAgent(cfg, args) {
283
310
  persistPreferredModel(loadConfig(), ref.provider, ref.id);
284
311
  });
285
312
  pi.on("session_start", (_event, ctx) => {
313
+ notifyUi = (msg) => ctx.ui.notify(msg, "info");
286
314
  const previous = ctx.ui.getEditorComponent();
287
315
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
288
316
  if (previous) {
@@ -316,6 +344,18 @@ async function run() {
316
344
  printConsoleBanner(VERSION);
317
345
  console.log(" u1s1 命令:deploy(发布网页,可选 --public / --private)· login / logout · model · usage · update · import · bench");
318
346
  console.log("");
347
+ console.log(" 命令:");
348
+ console.log(" u1s1 login / logout 登录 / 退出登录");
349
+ console.log(" u1s1 model 查看或切换默认模型");
350
+ console.log(" u1s1 usage 查看免费额度和余额");
351
+ console.log(" u1s1 update 升级到最新版");
352
+ console.log(" u1s1 deploy 发布网页,可选 --public / --private");
353
+ console.log(" u1s1 import 导入历史会话");
354
+ console.log(" u1s1 --version 查看版本");
355
+ console.log("");
356
+ console.log(" 对话里输入 / 可以看会话内命令(/model /clear /exit …)");
357
+ console.log(" 更多帮助 → https://u1s1.io/guide");
358
+ return;
319
359
  }
320
360
  if (cmd === "web") {
321
361
  console.error("u1s1 web 已下线,请使用 Desktop App:https://u1s1.io/#download");
@@ -347,9 +387,15 @@ async function run() {
347
387
  if (cmd === "logout") {
348
388
  const { saveConfig } = await import("./config.js");
349
389
  const current = loadConfig();
390
+ if (!current.deviceToken && !current.apiKey) {
391
+ console.log("当前本来就没有登录。运行 u1s1 即可登录。");
392
+ return;
393
+ }
394
+ let serverRevoked = false;
350
395
  if (current.deviceToken) {
351
396
  const { authorizedFetch } = await import("./device-auth.js");
352
- await authorizedFetch(current, `${current.baseUrl}/device`, { method: "DELETE" }).catch(() => null);
397
+ const resp = await authorizedFetch(current, `${current.baseUrl}/device`, { method: "DELETE" }).catch(() => null);
398
+ serverRevoked = resp?.ok ?? false;
353
399
  }
354
400
  saveConfig({
355
401
  ...current,
@@ -359,7 +405,13 @@ async function run() {
359
405
  devicePrivateJwk: undefined,
360
406
  devicePublicJwk: undefined,
361
407
  });
362
- console.log("已退出登录。");
408
+ if (serverRevoked) {
409
+ console.log("已退出登录,这台设备在服务端的授权也已注销。");
410
+ }
411
+ else {
412
+ console.log("已退出本机登录。");
413
+ console.log("(没连上服务器注销这台设备;如有需要,可在 https://u1s1.io/dashboard 的设备列表里手动移除。)");
414
+ }
363
415
  return;
364
416
  }
365
417
  if (cmd === "update") {
package/dist/login.d.ts CHANGED
@@ -19,8 +19,10 @@ export interface DeviceLoginResult {
19
19
  devicePrivateJwk: webcrypto.JsonWebKey;
20
20
  devicePublicJwk: webcrypto.JsonWebKey;
21
21
  }
22
- /** 轮询等浏览器批准;只接受带设备凭证的新网关响应。 */
23
- export declare function pollDeviceLogin(origin: string, start: DeviceStart): Promise<DeviceLoginResult | null>;
22
+ /** 轮询等浏览器批准;只接受带设备凭证的新网关响应。outcome 可带出失败原因。 */
23
+ export declare function pollDeviceLogin(origin: string, start: DeviceStart, outcome?: {
24
+ reason?: "expired" | "timeout";
25
+ }): Promise<DeviceLoginResult | null>;
24
26
  export declare function login(keyArg?: string): Promise<CliConfig>;
25
27
  /** Returns a config with a browser-approved, sender-constrained device credential. */
26
28
  export declare function ensureAuth(): Promise<CliConfig>;
package/dist/login.js CHANGED
@@ -34,6 +34,7 @@ export async function startDeviceLogin(origin) {
34
34
  device_name: `${hostname()} (${platform()})`,
35
35
  client_version: VERSION,
36
36
  }),
37
+ signal: AbortSignal.timeout(15_000),
37
38
  });
38
39
  if (!resp.ok)
39
40
  return null;
@@ -53,8 +54,8 @@ export async function startDeviceLogin(origin) {
53
54
  return null;
54
55
  }
55
56
  }
56
- /** 轮询等浏览器批准;只接受带设备凭证的新网关响应。 */
57
- export async function pollDeviceLogin(origin, start) {
57
+ /** 轮询等浏览器批准;只接受带设备凭证的新网关响应。outcome 可带出失败原因。 */
58
+ export async function pollDeviceLogin(origin, start, outcome) {
58
59
  const deadline = Date.now() + start.expires_in * 1000;
59
60
  while (Date.now() < deadline) {
60
61
  await sleep(start.interval * 1000);
@@ -63,6 +64,7 @@ export async function pollDeviceLogin(origin, start) {
63
64
  method: "POST",
64
65
  headers: { "content-type": "application/json" },
65
66
  body: JSON.stringify({ poll_secret: start.poll_secret }),
67
+ signal: AbortSignal.timeout(10_000),
66
68
  });
67
69
  if (!resp.ok)
68
70
  continue;
@@ -76,13 +78,18 @@ export async function pollDeviceLogin(origin, start) {
76
78
  devicePublicJwk: start.public_jwk,
77
79
  };
78
80
  }
79
- if (data.status === "expired")
81
+ if (data.status === "expired") {
82
+ if (outcome)
83
+ outcome.reason = "expired";
80
84
  return null;
85
+ }
81
86
  }
82
87
  catch {
83
88
  // 网络抖一下,下一轮继续
84
89
  }
85
90
  }
91
+ if (outcome)
92
+ outcome.reason = "timeout";
86
93
  return null;
87
94
  }
88
95
  export async function login(keyArg) {
@@ -94,26 +101,51 @@ export async function login(keyArg) {
94
101
  printConsoleBanner(VERSION);
95
102
  const origin = apiOrigin(cfg);
96
103
  const start = await startDeviceLogin(origin);
97
- if (start) {
98
- console.log(" 用浏览器登录并批准这台设备:");
99
- console.log("");
100
- console.log(` ${start.verify_url}`);
101
- console.log("");
102
- console.log(" 已经帮你打开浏览器了;如果没打开,把上面这行网址复制到浏览器打开。");
103
- console.log(" 批准后,本机会用设备私钥为每次 API 请求签名。");
104
- tryOpenBrowser(start.verify_url);
105
- credential = (await pollDeviceLogin(origin, start)) ?? undefined;
104
+ if (!start) {
105
+ console.error(" 连不上 u1s1 服务器,请检查网络后重试。");
106
+ console.error(" 如果网络正常,可能是 u1s1 版本太旧,运行 u1s1 update 升级后再试。");
107
+ process.exit(1);
108
+ }
109
+ console.log(" 用浏览器登录并批准这台设备:");
110
+ console.log("");
111
+ console.log(` ${start.verify_url}`);
112
+ console.log("");
113
+ console.log(" 正在尝试打开浏览器;如果没有自动打开,把上面这行网址复制到浏览器打开。");
114
+ console.log(" 批准后,本机会用设备私钥为每次 API 请求签名。");
115
+ tryOpenBrowser(start.verify_url);
116
+ console.log("");
117
+ console.log(" 等待浏览器里完成批准……(按 Ctrl+C 取消)");
118
+ const waitStartedAt = Date.now();
119
+ const waitTimer = setInterval(() => {
120
+ const mins = Math.round((Date.now() - waitStartedAt) / 60_000);
121
+ console.log(` 仍在等待批准(已等 ${mins} 分钟),在浏览器里点「批准」后这里会自动继续。`);
122
+ }, 120_000);
123
+ const outcome = {};
124
+ try {
125
+ credential = (await pollDeviceLogin(origin, start, outcome)) ?? undefined;
126
+ }
127
+ finally {
128
+ clearInterval(waitTimer);
106
129
  }
107
130
  if (!credential) {
108
- console.error(" 没能取得设备凭证。请确认网络正常并升级到最新版 u1s1 后重试。");
131
+ if (outcome.reason === "expired" || outcome.reason === "timeout") {
132
+ console.error(" 一直没等到浏览器里的批准,这个登录链接已失效。");
133
+ console.error(" 重新运行 u1s1 会生成新的登录链接;网页打不开的话检查一下网络。");
134
+ }
135
+ else {
136
+ console.error(" 没能取得设备凭证,请确认网络正常后重试。");
137
+ }
109
138
  process.exit(1);
110
139
  }
111
140
  const next = { ...cfg, ...credential };
141
+ // 浏览器批准已经成功,先把凭证落盘;后面的账号信息确认失败也不用重走登录
142
+ saveConfig(next);
112
143
  const me = await fetchMe(next).catch((e) => {
113
- console.error(` 验证失败:${e.message}`);
114
- process.exit(1);
144
+ console.log(" ✓ 设备已批准,登录凭证已保存。");
145
+ console.error(` 只是暂时没连上服务器确认账号信息(${e.message})。`);
146
+ console.error(" 稍后直接运行 u1s1 即可,不需要重新登录。");
147
+ process.exit(0);
115
148
  });
116
- saveConfig(next);
117
149
  const tpu = me.tokens_per_usd ?? 0;
118
150
  let quotaNote;
119
151
  if (me.free_claim) {
@@ -134,7 +166,7 @@ export async function login(keyArg) {
134
166
  }
135
167
  else {
136
168
  // 老网关:没有 tokens_per_usd,退回金额显示
137
- quotaNote = `今日免费还剩 $${me.daily_free_remaining_usd},永久余额 $${me.remaining_usd}`;
169
+ quotaNote = `今日免费还剩 $${me.daily_free_remaining_usd.toFixed(2)},永久余额 $${me.remaining_usd.toFixed(2)}`;
138
170
  }
139
171
  console.log(` ✓ 登录成功${me.email ? `(${me.email})` : ""},${quotaNote}。`);
140
172
  return next;
@@ -137,14 +137,11 @@ export async function ensureSearchTools(cfg) {
137
137
  const missing = TOOLS.filter((t) => !existsSync(join(binDir, t.bin + ext)) && !inPath(t.pathNames));
138
138
  if (missing.length === 0)
139
139
  return;
140
- console.log(` 正在安装搜索组件(${missing.map((t) => t.bin).join("/")})…`);
140
+ // 这是新手首启最早看到的输出之一:说清是首次准备、不用管;fd/rg 等术语不外露
141
+ console.log(" 首次运行,正在准备文件搜索组件(只需一次)…");
141
142
  const results = await Promise.allSettled(missing.map((t) => install(t, cfg.baseUrl, t.bin + ext)));
142
- for (let i = 0; i < results.length; i++) {
143
- const r = results[i];
144
- if (r.status === "rejected") {
145
- const msg = r.reason instanceof Error ? r.reason.message : String(r.reason);
146
- console.error(` ${missing[i].bin} 安装失败(${msg}),稍后将尝试 GitHub 直连`);
147
- }
143
+ if (results.some((r) => r.status === "rejected")) {
144
+ console.log(" 部分搜索组件暂时没装上,不影响使用,之后会自动重试。");
148
145
  }
149
146
  }
150
147
  catch {
package/dist/tools.d.ts CHANGED
@@ -28,6 +28,16 @@ export declare function createSubagentTool(getParentModel: () => ParentModelRef)
28
28
  model: Type.TOptional<Type.TString>;
29
29
  timeout_minutes: Type.TOptional<Type.TNumber>;
30
30
  }>, unknown, any> & import("@earendil-works/pi-coding-agent").ToolDefinition<any, any, any>;
31
+ /**
32
+ * 下载方舟返回的同一个临时 URL。fetch() 拿到响应头后,body 仍可能在
33
+ * arrayBuffer() 阶段以 Undici `terminated` 断流,所以两步必须放在同一个
34
+ * try/retry 中。重试下载不会再次调用生图接口,也不会重复计费。
35
+ */
36
+ export declare function downloadGeneratedImage(url: string, signal: AbortSignal, options?: {
37
+ attempts?: number;
38
+ retryDelayMs?: number;
39
+ fetchImpl?: typeof fetch;
40
+ }): Promise<Uint8Array>;
31
41
  /** 生图工具:走 u1s1 网关代理火山方舟 Seedream,上游 key 不落到用户机器上。 */
32
42
  export declare function createImageTool(cfg: Pick<CliConfig, "baseUrl" | "apiKey">): import("@earendil-works/pi-coding-agent").ToolDefinition<Type.TObject<{
33
43
  prompt: Type.TString;
package/dist/tools.js CHANGED
@@ -246,6 +246,9 @@ export function createSubagentTool(getParentModel) {
246
246
  }
247
247
  /** 生图链路 = 网关排队 + 方舟生成(2K 实测 ~10s,4K 更久)+ 下载落盘。 */
248
248
  const IMAGE_TIMEOUT_MS = 150_000;
249
+ /** 生图已经计费后只重试同一个临时 URL,绝不能靠重新生图补偿下载断流。 */
250
+ const IMAGE_DOWNLOAD_ATTEMPTS = 3;
251
+ const IMAGE_DOWNLOAD_RETRY_DELAY_MS = 500;
249
252
  /** 方舟单张参考图原图上限 10MB。 */
250
253
  const MAX_REF_IMAGE_BYTES = 10 * 1024 * 1024;
251
254
  const REF_IMAGE_MIME = {
@@ -285,6 +288,57 @@ function resolveSavePath(savePath, urlExt) {
285
288
  path = `${base}-${i}${urlExt}`;
286
289
  return path;
287
290
  }
291
+ function errorDetail(error) {
292
+ const message = error instanceof Error ? error.message : String(error);
293
+ const cause = error instanceof Error ? error.cause : undefined;
294
+ const code = cause && typeof cause === "object" && "code" in cause && typeof cause.code === "string" ? cause.code : "";
295
+ return code && !message.includes(code) ? `${message} (${code})` : message;
296
+ }
297
+ async function abortableDelay(ms, signal) {
298
+ if (ms <= 0)
299
+ return;
300
+ if (signal.aborted)
301
+ throw signal.reason;
302
+ await new Promise((resolveDelay, rejectDelay) => {
303
+ const onAbort = () => {
304
+ clearTimeout(timer);
305
+ rejectDelay(signal.reason);
306
+ };
307
+ const timer = setTimeout(() => {
308
+ signal.removeEventListener("abort", onAbort);
309
+ resolveDelay();
310
+ }, ms);
311
+ signal.addEventListener("abort", onAbort, { once: true });
312
+ });
313
+ }
314
+ /**
315
+ * 下载方舟返回的同一个临时 URL。fetch() 拿到响应头后,body 仍可能在
316
+ * arrayBuffer() 阶段以 Undici `terminated` 断流,所以两步必须放在同一个
317
+ * try/retry 中。重试下载不会再次调用生图接口,也不会重复计费。
318
+ */
319
+ export async function downloadGeneratedImage(url, signal, options = {}) {
320
+ const attempts = Math.max(1, Math.floor(options.attempts ?? IMAGE_DOWNLOAD_ATTEMPTS));
321
+ const retryDelayMs = Math.max(0, options.retryDelayMs ?? IMAGE_DOWNLOAD_RETRY_DELAY_MS);
322
+ const fetchImpl = options.fetchImpl ?? fetch;
323
+ let lastError;
324
+ for (let attempt = 1; attempt <= attempts; attempt++) {
325
+ try {
326
+ const resp = await fetchImpl(url, { signal });
327
+ if (!resp.ok)
328
+ throw new Error(`HTTP ${resp.status}`);
329
+ return new Uint8Array(await resp.arrayBuffer());
330
+ }
331
+ catch (error) {
332
+ if (signal.aborted)
333
+ throw error;
334
+ lastError = error;
335
+ if (attempt < attempts)
336
+ await abortableDelay(retryDelayMs * attempt, signal);
337
+ }
338
+ }
339
+ throw new Error(`图片已经生成,但下载失败(同一地址已重试 ${attempts} 次): ${errorDetail(lastError)}。` +
340
+ `不要重新调用 generate_image,可在 24 小时内手动下载: ${url}`, { cause: lastError });
341
+ }
288
342
  /** 生图工具:走 u1s1 网关代理火山方舟 Seedream,上游 key 不落到用户机器上。 */
289
343
  export function createImageTool(cfg) {
290
344
  return defineTool({
@@ -320,18 +374,7 @@ export function createImageTool(cfg) {
320
374
  const abort = signal ? AbortSignal.any([signal, timeout]) : timeout;
321
375
  const images = (params.images ?? []).map(refImageToPayload);
322
376
  const result = await generateImage(cfg, { prompt: params.prompt, images: images.length ? images : undefined, size: params.size }, abort);
323
- let resp;
324
- try {
325
- resp = await fetch(result.url, { signal: abort });
326
- }
327
- catch (e) {
328
- if (signal?.aborted)
329
- throw e;
330
- throw new Error(`图片生成成功但下载失败: ${e.message}`);
331
- }
332
- if (!resp.ok)
333
- throw new Error(`图片生成成功但下载失败: HTTP ${resp.status}`);
334
- const bytes = new Uint8Array(await resp.arrayBuffer());
377
+ const bytes = await downloadGeneratedImage(result.url, abort);
335
378
  const urlExt = extname(new URL(result.url).pathname).toLowerCase() || ".jpeg";
336
379
  const path = resolveSavePath(params.save_path, urlExt);
337
380
  mkdirSync(dirname(path), { recursive: true });
package/dist/update.js CHANGED
@@ -44,8 +44,9 @@ export async function getLatestVersion() {
44
44
  }
45
45
  }
46
46
  export function compareVersions(a, b) {
47
- const pa = a.split(".").map(Number);
48
- const pb = b.split(".").map(Number);
47
+ // parseInt 而非 Number:预发布段("0-beta.1")取前导数字,避免 NaN 让比较恒为「相等」
48
+ const pa = a.split(".").map((s) => parseInt(s, 10) || 0);
49
+ const pb = b.split(".").map((s) => parseInt(s, 10) || 0);
49
50
  for (let i = 0; i < 3; i++) {
50
51
  const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
51
52
  if (diff !== 0)
@@ -175,8 +176,13 @@ export async function update() {
175
176
  console.log(`\n✅ 已更新到 v${latest},重启 u1s1 后生效。`);
176
177
  }
177
178
  catch {
178
- console.error("\n自动更新失败。请手动运行:");
179
- console.error(` ${pm === "npm" ? "npm install -g" : `${pm} add -g`} ${PACKAGE_NAME}@latest`);
179
+ // 再让用户手抄同一条命令多半还是失败(最常见是全局目录没写权限),
180
+ // 直接给能跳出循环的路:官网安装脚本(装进用户目录,不需要管理员权限)
181
+ console.error("\n更新失败(上面是包管理器的报错,常见原因是全局目录没有写权限)。");
182
+ console.error("推荐改用官网安装脚本重装,不需要管理员权限,一步到位:");
183
+ console.error(process.platform === "win32"
184
+ ? " irm https://u1s1.io/releases/install.ps1 | iex (在 PowerShell 里运行)"
185
+ : " curl -fsSL https://u1s1.io/releases/install.sh | bash");
180
186
  process.exit(1);
181
187
  }
182
188
  }
package/dist/usage.js CHANGED
@@ -77,10 +77,10 @@ export async function usage() {
77
77
  console.log(` ${scopeNote} · ${p.expires_at ? `${p.expires_at.slice(0, 10)} 到期` : "永不过期"}`);
78
78
  }
79
79
  if (me.bonus_balance_usd > 0) {
80
- const balText = tpu > 0 ? `${fmtTokensCn(me.bonus_balance_usd * tpu)} Token` : `$${me.bonus_balance_usd}`;
80
+ const balText = tpu > 0 ? `${fmtTokensCn(me.bonus_balance_usd * tpu)} Token` : `$${me.bonus_balance_usd.toFixed(2)}`;
81
81
  console.log(` 余额(按量) ${balText}`);
82
82
  }
83
- console.log(` 本月已用 ${tpu > 0 ? `${fmtTokensCn(me.mtd_usd * tpu)} Token` : ""}($${me.mtd_usd.toFixed(2)})`);
83
+ console.log(` 本月已用 ${tpu > 0 ? `约 ${fmtTokensCn(me.mtd_usd * tpu)} Token($${me.mtd_usd.toFixed(2)})` : `$${me.mtd_usd.toFixed(2)}`}`);
84
84
  console.log("");
85
85
  if (me.free_claim === "first") {
86
86
  console.log(" → 你有免费用量包没领:首月每天 1 亿 Token,去 https://u1s1.io/dashboard 点「领取」");
@@ -106,10 +106,10 @@ export async function usage() {
106
106
  }
107
107
  else {
108
108
  // 老网关没下发 tokens_per_usd,退回金额显示
109
- console.log(` 今日免费 $${freeRemain} / $${freeTotal} ${bar(freeRatio)}`);
109
+ console.log(` 今日免费 $${freeRemain.toFixed(2)} / $${freeTotal.toFixed(2)} ${bar(freeRatio)}`);
110
110
  console.log(" DeepSeek V4 Flash · 北京时间 0 点恢复");
111
- console.log(` 永久余额 $${me.remaining_usd}`);
112
- console.log(` 本月成本 $${me.mtd_usd}`);
111
+ console.log(` 永久余额 $${me.remaining_usd.toFixed(2)}`);
112
+ console.log(` 本月成本 $${me.mtd_usd.toFixed(2)}`);
113
113
  }
114
114
  console.log("");
115
115
  console.log(" 免费额度每天自动恢复;邀请朋友双方各得永久加量 → https://u1s1.io/dashboard");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {