u1s1-cli 1.3.0 → 1.3.2

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/tools.js CHANGED
@@ -8,6 +8,8 @@ import { generateImage, renderPage, searchWeb } from "./api.js";
8
8
  const FETCH_TIMEOUT_MS = 20_000;
9
9
  const MAX_FETCH_BYTES = 2_000_000;
10
10
  const MAX_TEXT_CHARS = 30_000;
11
+ const MAX_GENERATED_IMAGE_BYTES = 64 * 1024 * 1024;
12
+ const IMAGE_DOWNLOAD_TIMEOUT_MS = 60_000;
11
13
  /** 云端渲染是真开浏览器,比直连慢得多,给宽裕些。 */
12
14
  const RENDER_TIMEOUT_MS = 45_000;
13
15
  /** 200 但榨出的正文比这还短,多半是 JS 渲染的空壳页,值得上浏览器再试。 */
@@ -39,31 +41,37 @@ export function truncate(text) {
39
41
  return `${text.slice(0, MAX_TEXT_CHARS)}\n\n…(内容过长已截断,共 ${text.length} 字符)`;
40
42
  }
41
43
  /** 只下载前 maxBytes 就断流,超大页面不用整个拉完再丢。 */
42
- async function readBodyCapped(resp, maxBytes) {
44
+ export async function readBodyCapped(resp, maxBytes) {
43
45
  if (!resp.body)
44
46
  return new Uint8Array(await resp.arrayBuffer()).subarray(0, maxBytes);
45
47
  const reader = resp.body.getReader();
46
48
  const chunks = [];
47
49
  let total = 0;
48
- for (;;) {
49
- const { done, value } = await reader.read();
50
- if (done)
51
- break;
52
- chunks.push(value);
53
- total += value.byteLength;
54
- if (total >= maxBytes) {
55
- void reader.cancel().catch(() => { });
56
- break;
50
+ try {
51
+ for (;;) {
52
+ const { done, value } = await reader.read();
53
+ if (done)
54
+ break;
55
+ chunks.push(value);
56
+ total += value.byteLength;
57
+ if (total >= maxBytes) {
58
+ void reader.cancel("web fetch body limit reached").catch(() => { });
59
+ break;
60
+ }
57
61
  }
58
62
  }
63
+ finally {
64
+ reader.releaseLock();
65
+ }
59
66
  const out = new Uint8Array(Math.min(total, maxBytes));
60
67
  let offset = 0;
61
68
  for (const chunk of chunks) {
62
69
  const room = out.byteLength - offset;
63
70
  if (room <= 0)
64
71
  break;
65
- out.set(room < chunk.byteLength ? chunk.subarray(0, room) : chunk, offset);
66
- offset += chunk.byteLength;
72
+ const copied = Math.min(room, chunk.byteLength);
73
+ out.set(copied < chunk.byteLength ? chunk.subarray(0, copied) : chunk, offset);
74
+ offset += copied;
67
75
  }
68
76
  return out;
69
77
  }
@@ -316,6 +324,44 @@ async function abortableDelay(ms, signal) {
316
324
  signal.addEventListener("abort", onAbort, { once: true });
317
325
  });
318
326
  }
327
+ class GeneratedImageTooLargeError extends Error {
328
+ }
329
+ async function readGeneratedImageBody(resp, maxBytes) {
330
+ const declaredLength = Number(resp.headers.get("content-length"));
331
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
332
+ if (resp.body)
333
+ void resp.body.cancel("generated image body limit reached").catch(() => { });
334
+ throw new GeneratedImageTooLargeError(`图片文件超过下载上限 ${Math.floor(maxBytes / 1024 / 1024)} MiB`);
335
+ }
336
+ if (!resp.body)
337
+ return new Uint8Array();
338
+ const reader = resp.body.getReader();
339
+ const chunks = [];
340
+ let total = 0;
341
+ try {
342
+ for (;;) {
343
+ const { done, value } = await reader.read();
344
+ if (done)
345
+ break;
346
+ if (total + value.byteLength > maxBytes) {
347
+ void reader.cancel("generated image body limit reached").catch(() => { });
348
+ throw new GeneratedImageTooLargeError(`图片文件超过下载上限 ${Math.floor(maxBytes / 1024 / 1024)} MiB`);
349
+ }
350
+ chunks.push(value);
351
+ total += value.byteLength;
352
+ }
353
+ }
354
+ finally {
355
+ reader.releaseLock();
356
+ }
357
+ const bytes = new Uint8Array(total);
358
+ let offset = 0;
359
+ for (const chunk of chunks) {
360
+ bytes.set(chunk, offset);
361
+ offset += chunk.byteLength;
362
+ }
363
+ return bytes;
364
+ }
319
365
  /**
320
366
  * 下载方舟返回的同一个临时 URL。fetch() 拿到响应头后,body 仍可能在
321
367
  * arrayBuffer() 阶段以 Undici `terminated` 断流,所以两步必须放在同一个
@@ -325,17 +371,27 @@ export async function downloadGeneratedImage(url, signal, options = {}) {
325
371
  const attempts = Math.max(1, Math.floor(options.attempts ?? IMAGE_DOWNLOAD_ATTEMPTS));
326
372
  const retryDelayMs = Math.max(0, options.retryDelayMs ?? IMAGE_DOWNLOAD_RETRY_DELAY_MS);
327
373
  const fetchImpl = options.fetchImpl ?? fetch;
374
+ const maxBytes = Math.max(1, Math.floor(options.maxBytes ?? MAX_GENERATED_IMAGE_BYTES));
375
+ const attemptTimeoutMs = Math.max(1, Math.floor(options.attemptTimeoutMs ?? IMAGE_DOWNLOAD_TIMEOUT_MS));
328
376
  let lastError;
329
377
  for (let attempt = 1; attempt <= attempts; attempt++) {
330
378
  try {
331
- const resp = await fetchImpl(url, { signal });
332
- if (!resp.ok)
379
+ const resp = await fetchImpl(url, {
380
+ signal: AbortSignal.any([signal, AbortSignal.timeout(attemptTimeoutMs)]),
381
+ });
382
+ if (!resp.ok) {
383
+ if (resp.body)
384
+ void resp.body.cancel("generated image HTTP error").catch(() => { });
333
385
  throw new Error(`HTTP ${resp.status}`);
334
- return new Uint8Array(await resp.arrayBuffer());
386
+ }
387
+ return await readGeneratedImageBody(resp, maxBytes);
335
388
  }
336
389
  catch (error) {
337
390
  if (signal.aborted)
338
391
  throw error;
392
+ if (error instanceof GeneratedImageTooLargeError) {
393
+ throw new Error(`图片已经生成,但${error.message}。不要重新调用 generate_image,可在 24 小时内手动下载: ${url}`, { cause: error });
394
+ }
339
395
  lastError = error;
340
396
  if (attempt < attempts)
341
397
  await abortableDelay(retryDelayMs * attempt, signal);
@@ -350,7 +406,7 @@ export function createImageTool(cfg) {
350
406
  name: "generate_image",
351
407
  label: "生成图片",
352
408
  description: "Generate an image from a text prompt, or edit/compose existing images, using the Seedream image model. " +
353
- "Saves the result as a local image file and returns its path. " +
409
+ "Saves the result as a local image file and displays it directly in supported chat UIs. " +
354
410
  "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). " +
355
411
  "Prompts work in Chinese or English; describe content, style, composition, and any text to render.",
356
412
  promptSnippet: "AI image generation/editing (text-to-image, editing, multi-image composition)",
@@ -359,6 +415,7 @@ export function createImageTool(cfg) {
359
415
  "Each call generates one image and costs the user credits; refine the prompt first instead of regenerating repeatedly.",
360
416
  "If an error says the image may/already has been generated or says not to call generate_image again, stop immediately; never retry with a new generation.",
361
417
  "To edit an existing image, pass its path in `images` and describe only the change in `prompt`.",
418
+ "A successful result is already displayed in supported chat UIs; never open an OS image viewer or claim that you opened one when the user asks to see it.",
362
419
  ],
363
420
  parameters: Type.Object({
364
421
  prompt: Type.String({
@@ -386,9 +443,17 @@ export function createImageTool(cfg) {
386
443
  mkdirSync(dirname(path), { recursive: true });
387
444
  writeFileSync(path, bytes);
388
445
  const sizeNote = result.size ? ` (${result.size})` : "";
446
+ const mimeType = REF_IMAGE_MIME[urlExt] ?? "image/jpeg";
389
447
  return {
390
- content: [{ type: "text", text: `图片已保存: ${path}${sizeNote}` }],
391
- details: { path, size: result.size, bytes: bytes.byteLength },
448
+ content: [{ type: "text", text: `图片已保存并在对话中展示: ${path}${sizeNote}` }],
449
+ // displayImage 只给 UI 预览,不放进 content:避免每轮都把整张 2K/4K
450
+ // 图片送回模型占用 context。pi-web-ui 会从 tool details 序列化它。
451
+ details: {
452
+ path,
453
+ size: result.size,
454
+ bytes: bytes.byteLength,
455
+ displayImage: { data: Buffer.from(bytes).toString("base64"), mimeType },
456
+ },
392
457
  };
393
458
  },
394
459
  });
@@ -425,13 +490,25 @@ async function fetchDirect(url, signal) {
425
490
  throw new DirectFetchError(`打不开 ${url.href}: ${e.message}`, true);
426
491
  }
427
492
  if (!resp.ok) {
493
+ void resp.body?.cancel("web fetch rejected HTTP response").catch(() => undefined);
428
494
  throw new DirectFetchError(`${url.href} 返回 ${resp.status} ${resp.statusText}`, RENDER_WORTHY_STATUS.has(resp.status));
429
495
  }
430
496
  const type = resp.headers.get("content-type") ?? "";
431
497
  if (!/text\/|json|xml|javascript/i.test(type)) {
498
+ void resp.body?.cancel("web fetch rejected non-text response").catch(() => undefined);
432
499
  throw new DirectFetchError(`${url.href} 不是文本内容 (${type || "unknown"}),读不了`, false);
433
500
  }
434
- const raw = decodeBody(await readBodyCapped(resp, MAX_FETCH_BYTES), type);
501
+ let bytes;
502
+ try {
503
+ bytes = await readBodyCapped(resp, MAX_FETCH_BYTES);
504
+ }
505
+ catch (error) {
506
+ if (signal?.aborted)
507
+ throw error;
508
+ const detail = error instanceof Error ? error.message : String(error);
509
+ throw new DirectFetchError(`${url.href} 正文读取失败: ${detail}`, true);
510
+ }
511
+ const raw = decodeBody(bytes, type);
435
512
  const isHtml = /html|xml/i.test(type);
436
513
  return { text: isHtml ? htmlToText(raw) : raw.trim(), contentType: type, isHtml };
437
514
  }
package/dist/update.js CHANGED
@@ -1,14 +1,24 @@
1
- import { execSync, spawnSync } from "node:child_process";
1
+ import { execFileSync, spawnSync } from "node:child_process";
2
2
  import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { isPortableInstall } from "./config.js";
8
+ import { readJsonResponseCapped, readResponseTextCapped } from "./api.js";
8
9
  const require = createRequire(import.meta.url);
9
10
  const pkg = require("../package.json");
10
11
  export const VERSION = pkg.version;
11
12
  export const PACKAGE_NAME = "u1s1-cli";
13
+ const MAX_NPM_METADATA_BYTES = 64 * 1024;
14
+ const MAX_INSTALL_SCRIPT_BYTES = 1024 * 1024;
15
+ function publishedVersion(value) {
16
+ return typeof value === "string"
17
+ && value.length <= 100
18
+ && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(value)
19
+ ? value
20
+ : undefined;
21
+ }
12
22
  /** engines.node 里要求的最低版本(如 ">=22.19.0" → "22.19.0");解析不出返回 undefined。 */
13
23
  function requiredNodeVersion() {
14
24
  const m = /(\d+\.\d+\.\d+)/.exec(pkg.engines?.node ?? "");
@@ -36,8 +46,8 @@ export async function getLatestVersion() {
36
46
  });
37
47
  if (!res.ok)
38
48
  return undefined;
39
- const body = (await res.json());
40
- return body.version;
49
+ const body = await readJsonResponseCapped(res, MAX_NPM_METADATA_BYTES);
50
+ return publishedVersion(body.version);
41
51
  }
42
52
  catch {
43
53
  return undefined;
@@ -97,7 +107,7 @@ async function portableSelfUpdate(latest) {
97
107
  });
98
108
  if (!res.ok)
99
109
  throw new Error(`HTTP ${res.status}`);
100
- script = await res.text();
110
+ script = await readResponseTextCapped(res, MAX_INSTALL_SCRIPT_BYTES);
101
111
  }
102
112
  catch {
103
113
  console.error("安装脚本下载失败。请手动运行:");
@@ -107,7 +117,7 @@ async function portableSelfUpdate(latest) {
107
117
  const tmp = join(mkdtempSync(join(tmpdir(), "u1s1-update-")), "install.sh");
108
118
  writeFileSync(tmp, script, { mode: 0o755 });
109
119
  try {
110
- execSync(`bash ${JSON.stringify(tmp)}`, { stdio: "inherit" });
120
+ execFileSync("bash", [tmp], { stdio: "inherit" });
111
121
  }
112
122
  catch {
113
123
  console.error("\n自动升级失败。请手动运行:");
@@ -171,8 +181,10 @@ export async function update() {
171
181
  console.log(`正在用 ${pm} 更新 ${PACKAGE_NAME}…`);
172
182
  try {
173
183
  // npm 压掉 EBADENGINE 等警告墙,对新手只有噪音;出错时 error 仍会显示
174
- const installCmd = pm === "npm" ? `npm install -g --loglevel=error ${PACKAGE_NAME}@latest` : `${pm} add -g ${PACKAGE_NAME}@latest`;
175
- execSync(installCmd, { stdio: "inherit" });
184
+ const args = pm === "npm"
185
+ ? ["install", "-g", "--loglevel=error", `${PACKAGE_NAME}@latest`]
186
+ : ["add", "-g", `${PACKAGE_NAME}@latest`];
187
+ execFileSync(pm, args, { stdio: "inherit" });
176
188
  console.log(`\n✅ 已更新到 v${latest},重启 u1s1 后生效。`);
177
189
  }
178
190
  catch {
package/dist/web.js CHANGED
@@ -55,21 +55,33 @@ export async function prepareWebEnv(cfg) {
55
55
  // 老网关没有 /v1/image,image_gen 缺失时按关闭处理,不注册生图工具
56
56
  let imageGenEnabled = false;
57
57
  let clientAttestation;
58
+ let clientAttestationExpiresInSeconds;
58
59
  const endpointsReady = loadCustomEndpoints(cfg);
59
60
  try {
60
- const { models, features, clientAttestation: attestation } = await fetchModels(cfg);
61
+ const { models, features, clientAttestation: attestation, clientAttestationExpiresInSeconds: attestationTtl } = await fetchModels(cfg);
61
62
  setModelsFromApi(models.map(apiModelToDef));
62
63
  webSearchEnabled = features.web_search !== false;
63
64
  webFetchRenderEnabled = features.web_fetch_render === true;
64
65
  imageGenEnabled = features.image_gen === true;
65
66
  clientAttestation = attestation;
67
+ clientAttestationExpiresInSeconds = attestationTtl;
66
68
  }
67
69
  catch (e) {
68
70
  console.error(" 获取模型列表失败,使用内置列表:", e.message);
69
71
  }
70
72
  await endpointsReady;
71
73
  ensureDefaultSettings(MODELS);
72
- const signing = await ensureSigningProxy(cfg, "desktop", clientAttestation);
74
+ const signing = await ensureSigningProxy(cfg, "desktop", {
75
+ token: clientAttestation,
76
+ expiresInSeconds: clientAttestationExpiresInSeconds,
77
+ refresh: async () => {
78
+ const refreshed = await fetchModels(cfg);
79
+ return {
80
+ token: refreshed.clientAttestation,
81
+ expiresInSeconds: refreshed.clientAttestationExpiresInSeconds,
82
+ };
83
+ },
84
+ });
73
85
  const officialCfg = { ...cfg, baseUrl: signing.baseUrl, apiKey: signing.localKey };
74
86
  webOfficialCfg = officialCfg;
75
87
  const modelsPath = refreshWebModels();
@@ -3,6 +3,22 @@ import { type ParentModelRef } from "../subagent.js";
3
3
  export declare const WORKFLOW_TIMEOUT_MS: number;
4
4
  /** 默认整个 run 的 token 预算;0 或负数表示不设限。 */
5
5
  export declare const WORKFLOW_DEFAULT_BUDGET_TOKENS = 20000000;
6
+ interface ProgressEntry {
7
+ key: string;
8
+ /** 任务摘要(前 120 字),调试/排查用,key 才是身份。 */
9
+ task: string;
10
+ ok: boolean;
11
+ text: string;
12
+ ms: number;
13
+ }
14
+ /** JSONL 进度存档:一行一个已完成的子任务,断点续跑时按 key 跳过。 */
15
+ export declare class ProgressStore {
16
+ #private;
17
+ readonly path: string;
18
+ constructor(path: string);
19
+ getSuccessful(key: string): ProgressEntry | undefined;
20
+ append(entry: ProgressEntry): void;
21
+ }
6
22
  /** 静态白名单校验:语法能编译 + 不碰沙箱之外的任何能力。 */
7
23
  export declare function validateScript(code: string): string[];
8
24
  export interface WorkflowRunResult {
@@ -46,3 +62,4 @@ export declare function runWorkflow(opts: WorkflowRunOptions): Promise<WorkflowR
46
62
  export declare function workflowsDir(): string;
47
63
  /** 内联脚本落盘,返回脚本路径(进度存档按同名约定派生)。 */
48
64
  export declare function saveWorkflowScript(code: string): string;
65
+ export {};
@@ -15,7 +15,7 @@ const MAX_LOG_LINES = 60;
15
15
  /** 默认整个 run 的 token 预算;0 或负数表示不设限。 */
16
16
  export const WORKFLOW_DEFAULT_BUDGET_TOKENS = 20_000_000;
17
17
  /** JSONL 进度存档:一行一个已完成的子任务,断点续跑时按 key 跳过。 */
18
- class ProgressStore {
18
+ export class ProgressStore {
19
19
  path;
20
20
  #cache = new Map();
21
21
  constructor(path) {
@@ -36,8 +36,9 @@ class ProgressStore {
36
36
  }
37
37
  }
38
38
  }
39
- get(key) {
40
- return this.#cache.get(key);
39
+ getSuccessful(key) {
40
+ const entry = this.#cache.get(key);
41
+ return entry?.ok === true && typeof entry.text === "string" ? entry : undefined;
41
42
  }
42
43
  append(entry) {
43
44
  this.#cache.set(entry.key, entry);
@@ -154,14 +155,12 @@ export async function runWorkflow(opts) {
154
155
  }
155
156
  const key = taskKey(task, model);
156
157
  if (opts.resume) {
157
- const prev = store.get(key);
158
+ const prev = store.getSuccessful(key);
158
159
  if (prev) {
159
160
  stats.cached++;
160
161
  progress.cached = stats.cached;
161
162
  reportProgress();
162
- if (prev.ok)
163
- return prev.text;
164
- throw new Error(prev.text);
163
+ return prev.text;
165
164
  }
166
165
  }
167
166
  stats.spawns++;
@@ -127,14 +127,14 @@ return { synthesis };
127
127
  },
128
128
  refactor: {
129
129
  name: "refactor",
130
- description: "批量重构/迁移:先由一个 agent 读代表文件制定统一规则,然后每个文件一个子 agent 在独立 worktree 里套用规则,互不冲突",
130
+ description: "批量重构/迁移:先由一个 agent 读代表文件制定统一规则,然后每个文件一个子 agent 在当前工作区套用规则",
131
131
  inputHint: '{ "instruction": "把所有 var 改为 const", "files": ["src/a.ts", ...] } — files 省略时用 git diff 改动文件',
132
132
  build(input) {
133
133
  const instruction = str(input.instruction);
134
134
  if (!instruction)
135
135
  throw new Error('refactor 模板需要 instruction,例如 { "instruction": "把 var 全部改为 const" }');
136
136
  // 与 review 模板一致:构建期取 git 改动文件,不花一个 subagent 在沙箱里跑 git
137
- let files = strArray(input.files);
137
+ let files = [...new Set(strArray(input.files))];
138
138
  if (files.length === 0)
139
139
  files = gitDiffFiles();
140
140
  if (files.length === 0)
@@ -150,8 +150,8 @@ const rule = await subagent({
150
150
  });
151
151
  const results = await parallel(targetFiles.map(f => () =>
152
152
  subagent({
153
- task: "按以下重构规则处理文件 " + f + ",改完检查该文件语法正确(必要时运行 tsc 或构建验证)。\\n\\n重构规则:\\n" + rule,
154
- worktree: true
153
+ task: "按以下重构规则处理文件 " + f + "。只修改这个目标文件,不要改动其他文件,避免与并行任务冲突。" +
154
+ "改完检查该文件语法正确(必要时运行 tsc 或构建验证)。\\n\\n重构规则:\\n" + rule
155
155
  })
156
156
  ));
157
157
  const done = targetFiles.filter((_, i) => results[i] !== null);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {