u1s1-cli 0.13.6 → 0.14.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.
@@ -21,7 +21,11 @@ export declare function cleanupBrandThemes(): void;
21
21
  * 环境变量,文件里不落密钥。U1S1_TOOLS_VIA_EXTENSION 守卫:旧版 CLI 仍在
22
22
  * 进程内注册工具且不设该变量,残留的本文件在旧版下自动空转,避免双重注册。
23
23
  */
24
- export declare function writeWebToolsExtension(cfg: CliConfig, webSearchEnabled: boolean): void;
24
+ export declare function writeWebToolsExtension(cfg: CliConfig, features: {
25
+ webSearch: boolean;
26
+ webFetchRender: boolean;
27
+ imageGen: boolean;
28
+ }): void;
25
29
  /**
26
30
  * pi-web-ui 的就绪检测只认 <agentDir>/auth.json 里有没有凭据条目(models.json
27
31
  * 的 apiKey 它不看),空 {} 会在网页里弹「未检测到 pi agent 配置」引导装 pi。
@@ -123,19 +123,23 @@ export function cleanupBrandThemes() {
123
123
  * 环境变量,文件里不落密钥。U1S1_TOOLS_VIA_EXTENSION 守卫:旧版 CLI 仍在
124
124
  * 进程内注册工具且不设该变量,残留的本文件在旧版下自动空转,避免双重注册。
125
125
  */
126
- export function writeWebToolsExtension(cfg, webSearchEnabled) {
126
+ export function writeWebToolsExtension(cfg, features) {
127
127
  const dir = join(agentDir, "extensions");
128
128
  mkdirSync(dir, { recursive: true });
129
129
  const toolsUrl = new URL("./tools.js", import.meta.url).href;
130
- const searchLine = webSearchEnabled
130
+ const searchLine = features.webSearch
131
131
  ? ` pi.registerTool(tools.createSearchTool({ baseUrl: ${JSON.stringify(cfg.baseUrl)}, apiKey: process.env.U1S1_API_KEY }));\n`
132
132
  : "";
133
+ const imageLine = features.imageGen
134
+ ? ` pi.registerTool(tools.createImageTool({ baseUrl: ${JSON.stringify(cfg.baseUrl)}, apiKey: process.env.U1S1_API_KEY }));\n`
135
+ : "";
133
136
  writeFileSync(join(dir, "u1s1-tools.js"), `// 由 u1s1 每次启动自动生成,请勿手改\n` +
134
137
  `export default async function (pi) {\n` +
135
138
  ` if (process.env.U1S1_TOOLS_VIA_EXTENSION !== "1") return;\n` +
136
139
  ` const tools = await import(${JSON.stringify(toolsUrl)});\n` +
137
140
  searchLine +
138
- ` pi.registerTool(tools.webFetchTool);\n` +
141
+ ` pi.registerTool(tools.createFetchTool({ baseUrl: ${JSON.stringify(cfg.baseUrl)}, apiKey: process.env.U1S1_API_KEY, renderFallback: ${features.webFetchRender} }));\n` +
142
+ imageLine +
139
143
  `}\n`);
140
144
  }
141
145
  /**
package/dist/api.d.ts CHANGED
@@ -46,6 +46,10 @@ export interface ApiModel {
46
46
  /** 服务端能力开关;字段缺失(老网关)时按开启处理,保持现状。 */
47
47
  export interface ApiFeatures {
48
48
  web_search?: boolean;
49
+ /** 网关配了 Browser Rendering 才为 true;老网关没有 /v1/fetch,缺失按关闭处理。 */
50
+ web_fetch_render?: boolean;
51
+ /** 网关配了方舟 ARK_API_KEY 才为 true;老网关没有 /v1/image,缺失按关闭处理。 */
52
+ image_gen?: boolean;
49
53
  }
50
54
  export interface ModelsResponse {
51
55
  models: ApiModel[];
@@ -88,4 +92,21 @@ export interface SearchResponse {
88
92
  }
89
93
  /** 联网搜索走网关代理(上游 key 只在服务端)。maxResults 不传时由服务端决定默认值。 */
90
94
  export declare function searchWeb(cfg: Pick<CliConfig, "baseUrl" | "apiKey">, query: string, maxResults?: number, signal?: AbortSignal): Promise<SearchResponse>;
95
+ /** web_fetch 直连失败时的回退:网关用 Cloudflare Browser Rendering 渲染后转 markdown。 */
96
+ export declare function renderPage(cfg: Pick<CliConfig, "baseUrl" | "apiKey">, url: string, signal?: AbortSignal): Promise<{
97
+ url: string;
98
+ markdown: string;
99
+ }>;
100
+ export interface ImageGenRequest {
101
+ prompt: string;
102
+ /** 参考图:http(s) URL 或 data:image/... base64,最多 10 张。 */
103
+ images?: string[];
104
+ /** 1K/2K/4K 或 宽x高(如 2048x2048);缺省由服务端定(2K)。 */
105
+ size?: string;
106
+ }
107
+ /** 生图走网关代理(方舟 key 只在服务端);返回 TOS 图片 URL(24h 有效),调用方应立即下载。 */
108
+ export declare function generateImage(cfg: Pick<CliConfig, "baseUrl" | "apiKey">, req: ImageGenRequest, signal?: AbortSignal): Promise<{
109
+ url: string;
110
+ size: string | null;
111
+ }>;
91
112
  export declare function fetchMe(cfg: CliConfig): Promise<MeResponse>;
package/dist/api.js CHANGED
@@ -80,6 +80,62 @@ export async function searchWeb(cfg, query, maxResults, signal) {
80
80
  }
81
81
  return (await resp.json());
82
82
  }
83
+ /** web_fetch 直连失败时的回退:网关用 Cloudflare Browser Rendering 渲染后转 markdown。 */
84
+ export async function renderPage(cfg, url, signal) {
85
+ if (!cfg.apiKey)
86
+ throw new Error("没有配置 API Key");
87
+ let resp;
88
+ try {
89
+ resp = await fetch(`${cfg.baseUrl}/fetch`, {
90
+ method: "POST",
91
+ headers: {
92
+ ...authHeaders(cfg.apiKey),
93
+ "content-type": "application/json",
94
+ },
95
+ body: JSON.stringify({ url }),
96
+ signal,
97
+ });
98
+ }
99
+ catch {
100
+ throw new Error(`连不上 ${cfg.baseUrl}`);
101
+ }
102
+ if (resp.status === 401)
103
+ throw new Error("这把 Key 不对或已失效,去 https://u1s1.io/dashboard 看看");
104
+ if (!resp.ok) {
105
+ const body = (await resp.json().catch(() => null));
106
+ throw new Error(body?.error?.message ?? `渲染服务返回 ${resp.status}`);
107
+ }
108
+ return (await resp.json());
109
+ }
110
+ /** 生图走网关代理(方舟 key 只在服务端);返回 TOS 图片 URL(24h 有效),调用方应立即下载。 */
111
+ export async function generateImage(cfg, req, signal) {
112
+ if (!cfg.apiKey)
113
+ throw new Error("没有配置 API Key");
114
+ let resp;
115
+ try {
116
+ resp = await fetch(`${cfg.baseUrl}/image`, {
117
+ method: "POST",
118
+ headers: {
119
+ ...authHeaders(cfg.apiKey),
120
+ "content-type": "application/json",
121
+ },
122
+ body: JSON.stringify(req),
123
+ signal,
124
+ });
125
+ }
126
+ catch (e) {
127
+ if (signal?.aborted)
128
+ throw e;
129
+ throw new Error(`连不上 ${cfg.baseUrl},检查一下网络?`);
130
+ }
131
+ if (resp.status === 401)
132
+ throw new Error("这把 Key 不对或已失效,去 https://u1s1.io/dashboard 看看");
133
+ if (!resp.ok) {
134
+ const body = (await resp.json().catch(() => null));
135
+ throw new Error(body?.error?.message ?? `图片生成服务返回 ${resp.status},稍后再试`);
136
+ }
137
+ return (await resp.json());
138
+ }
83
139
  export async function fetchMe(cfg) {
84
140
  if (!cfg.apiKey)
85
141
  throw new Error("没有配置 API Key");
package/dist/index.js CHANGED
@@ -145,11 +145,17 @@ async function runAgent(cfg, args) {
145
145
  // 服务端没开搜索(或老网关没有 features 字段)时不注册 web_search,模型就不会白调
146
146
  // 自定义端点与模型列表并行拉取,失败各自兜底(内置列表 / 本地缓存)
147
147
  let webSearchEnabled = true;
148
+ // 老网关没有 /v1/fetch,web_fetch_render 缺失时按关闭处理,直连失败不去白调
149
+ let webFetchRenderEnabled = false;
150
+ // 老网关没有 /v1/image,image_gen 缺失时按关闭处理,不注册生图工具
151
+ let imageGenEnabled = false;
148
152
  const endpointsReady = loadCustomEndpoints(cfg);
149
153
  try {
150
154
  const { models, features } = await fetchModels(cfg);
151
155
  setModelsFromApi(models.map(apiModelToDef));
152
156
  webSearchEnabled = features.web_search !== false;
157
+ webFetchRenderEnabled = features.web_fetch_render === true;
158
+ imageGenEnabled = features.image_gen === true;
153
159
  }
154
160
  catch (e) {
155
161
  console.error(" 获取模型列表失败,使用内置列表:", e.message);
@@ -158,7 +164,11 @@ async function runAgent(cfg, args) {
158
164
  ensureBrandPrompt(await shellReady);
159
165
  ensureProviderModels(cfg);
160
166
  // 联网工具经 agentDir/extensions 投影,TUI 和 u1s1 web 共用一份注册
161
- writeWebToolsExtension(cfg, webSearchEnabled);
167
+ writeWebToolsExtension(cfg, {
168
+ webSearch: webSearchEnabled,
169
+ webFetchRender: webFetchRenderEnabled,
170
+ imageGen: imageGenEnabled,
171
+ });
162
172
  ensureTmuxKeyboardProtocol();
163
173
  // must be set before pi reads them (getAgentDir() reads at call time, env at import is fine too)
164
174
  process.env["PI_CODING_AGENT_DIR"] = agentDir;
package/dist/tools.d.ts CHANGED
@@ -8,11 +8,32 @@ export declare function createSearchTool(cfg: Pick<CliConfig, "baseUrl" | "apiKe
8
8
  query: string;
9
9
  count: number;
10
10
  }, any> & import("@earendil-works/pi-coding-agent").ToolDefinition<any, any, any>;
11
- /** 抓网页:纯客户端出网,不经过我们的服务器,也不额外计费。 */
12
- export declare const webFetchTool: import("@earendil-works/pi-coding-agent").ToolDefinition<Type.TObject<{
11
+ /** 生图工具:走 u1s1 网关代理火山方舟 Seedream,上游 key 不落到用户机器上。 */
12
+ export declare function createImageTool(cfg: Pick<CliConfig, "baseUrl" | "apiKey">): import("@earendil-works/pi-coding-agent").ToolDefinition<Type.TObject<{
13
+ prompt: Type.TString;
14
+ images: Type.TOptional<Type.TArray<Type.TString>>;
15
+ size: Type.TOptional<Type.TString>;
16
+ save_path: Type.TOptional<Type.TString>;
17
+ }>, {
18
+ path: string;
19
+ size: string | null;
20
+ bytes: number;
21
+ }, any> & import("@earendil-works/pi-coding-agent").ToolDefinition<any, any, any>;
22
+ export interface FetchToolConfig {
23
+ baseUrl: string;
24
+ apiKey?: string;
25
+ /** 网关配了 Browser Rendering(features.web_fetch_render)才开;老网关没有 /v1/fetch。 */
26
+ renderFallback?: boolean;
27
+ }
28
+ /**
29
+ * 抓网页工具:先直连(免费、快),打不开或疑似被反爬/JS 空壳时,回退到
30
+ * 网关的 Cloudflare Browser Rendering 渲染(真无头浏览器,按次计费)。
31
+ */
32
+ export declare function createFetchTool(cfg: FetchToolConfig): import("@earendil-works/pi-coding-agent").ToolDefinition<Type.TObject<{
13
33
  url: Type.TString;
14
34
  }>, {
15
35
  url: string;
16
36
  contentType: string;
37
+ via: "direct" | "render";
17
38
  chars: number;
18
39
  }, any> & import("@earendil-works/pi-coding-agent").ToolDefinition<any, any, any>;
package/dist/tools.js CHANGED
@@ -1,9 +1,15 @@
1
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { dirname, extname, resolve } from "node:path";
1
3
  import { defineTool } from "@earendil-works/pi-coding-agent";
2
4
  import { Type } from "typebox";
3
- import { searchWeb } from "./api.js";
5
+ import { generateImage, renderPage, searchWeb } from "./api.js";
4
6
  const FETCH_TIMEOUT_MS = 20_000;
5
7
  const MAX_FETCH_BYTES = 2_000_000;
6
8
  const MAX_TEXT_CHARS = 30_000;
9
+ /** 云端渲染是真开浏览器,比直连慢得多,给宽裕些。 */
10
+ const RENDER_TIMEOUT_MS = 45_000;
11
+ /** 200 但榨出的正文比这还短,多半是 JS 渲染的空壳页,值得上浏览器再试。 */
12
+ const MIN_HTML_TEXT_CHARS = 200;
7
13
  const HTML_ENTITIES = {
8
14
  "&nbsp;": " ",
9
15
  "&amp;": "&",
@@ -104,59 +110,212 @@ export function createSearchTool(cfg) {
104
110
  },
105
111
  });
106
112
  }
107
- /** 抓网页:纯客户端出网,不经过我们的服务器,也不额外计费。 */
108
- export const webFetchTool = defineTool({
109
- name: "web_fetch",
110
- label: "读取网页",
111
- 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.",
112
- promptSnippet: "抓取指定网址并转成可读文本",
113
- promptGuidelines: [
114
- "Use web_fetch to read a specific URL, and web_search when you still need to find the URL.",
115
- ],
116
- parameters: Type.Object({
117
- url: Type.String({ description: "Absolute http(s) URL to fetch." }),
118
- }),
119
- async execute(_toolCallId, params, signal) {
120
- let url;
121
- try {
122
- // 用户从聊天工具粘来的链接常带 "@https://…" 前缀,顺手剥掉
123
- url = new URL(params.url.trim().replace(/^@/, ""));
124
- }
125
- catch {
126
- throw new Error(`不是合法的网址: ${params.url}`);
127
- }
128
- if (url.protocol !== "http:" && url.protocol !== "https:") {
129
- throw new Error(`只支持 http/https,收到 ${url.protocol}`);
130
- }
131
- const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
113
+ /** 生图链路 = 网关排队 + 方舟生成(2K 实测 ~10s,4K 更久)+ 下载落盘。 */
114
+ const IMAGE_TIMEOUT_MS = 150_000;
115
+ /** 方舟单张参考图原图上限 10MB。 */
116
+ const MAX_REF_IMAGE_BYTES = 10 * 1024 * 1024;
117
+ const REF_IMAGE_MIME = {
118
+ ".png": "image/png",
119
+ ".jpg": "image/jpeg",
120
+ ".jpeg": "image/jpeg",
121
+ ".webp": "image/webp",
122
+ ".bmp": "image/bmp",
123
+ ".gif": "image/gif",
124
+ };
125
+ /** 本地图片路径 data URL;http(s) URL 原样透传给网关。 */
126
+ function refImageToPayload(ref) {
127
+ const s = ref.trim();
128
+ if (/^https?:\/\//.test(s))
129
+ return s;
130
+ const path = resolve(s);
131
+ if (!existsSync(path))
132
+ throw new Error(`参考图不存在: ${s}`);
133
+ const mime = REF_IMAGE_MIME[extname(path).toLowerCase()];
134
+ if (!mime)
135
+ throw new Error(`参考图格式不支持: ${s}(支持 png/jpg/jpeg/webp/bmp/gif)`);
136
+ if (statSync(path).size > MAX_REF_IMAGE_BYTES)
137
+ throw new Error(`参考图超过 10MB: ${s}`);
138
+ return `data:${mime};base64,${readFileSync(path).toString("base64")}`;
139
+ }
140
+ /** 结果落盘路径:模型没指定时按时间戳起名,已存在就加序号,不覆盖旧图。 */
141
+ function resolveSavePath(savePath, urlExt) {
142
+ if (savePath?.trim())
143
+ return resolve(savePath.trim());
144
+ const stamp = new Date()
145
+ .toISOString()
146
+ .replace(/[-:]/g, "")
147
+ .replace(/T(\d{6}).*/, "-$1");
148
+ const base = resolve(`image-${stamp}`);
149
+ let path = `${base}${urlExt}`;
150
+ for (let i = 1; existsSync(path); i++)
151
+ path = `${base}-${i}${urlExt}`;
152
+ return path;
153
+ }
154
+ /** 生图工具:走 u1s1 网关代理火山方舟 Seedream,上游 key 不落到用户机器上。 */
155
+ export function createImageTool(cfg) {
156
+ return defineTool({
157
+ name: "generate_image",
158
+ label: "生成图片",
159
+ description: "Generate an image from a text prompt, or edit/compose existing images, using the Seedream image model. " +
160
+ "Saves the result as a local image file and returns its path. " +
161
+ "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). " +
162
+ "Prompts work in Chinese or English; describe content, style, composition, and any text to render.",
163
+ promptSnippet: "AI 生成/修改图片(文生图、改图、多图合成)",
164
+ promptGuidelines: [
165
+ "Use generate_image when the user wants a picture created or an existing image modified (logo, illustration, poster, placeholder art, 改图/换风格).",
166
+ "Each call generates one image and costs the user credits; refine the prompt first instead of regenerating repeatedly.",
167
+ "To edit an existing image, pass its path in `images` and describe only the change in `prompt`.",
168
+ ],
169
+ parameters: Type.Object({
170
+ prompt: Type.String({
171
+ description: "What to generate or how to edit the reference images. Be specific about subject, style, composition, colors, and any text to appear in the image.",
172
+ }),
173
+ images: Type.Optional(Type.Array(Type.String(), {
174
+ description: "Reference images to edit or draw from: local file paths or http(s) URLs, up to 10. Omit for pure text-to-image.",
175
+ })),
176
+ size: Type.Optional(Type.String({
177
+ description: 'Output resolution: "2K" (default, aspect ratio auto-adapts to the prompt), "4K", or explicit "WIDTHxHEIGHT" like "2048x2048" / "1664x2496".',
178
+ })),
179
+ save_path: Type.Optional(Type.String({
180
+ description: "Where to save the image (relative to cwd). Defaults to image-<timestamp>.<ext> in the current directory.",
181
+ })),
182
+ }),
183
+ async execute(_toolCallId, params, signal) {
184
+ const timeout = AbortSignal.timeout(IMAGE_TIMEOUT_MS);
185
+ const abort = signal ? AbortSignal.any([signal, timeout]) : timeout;
186
+ const images = (params.images ?? []).map(refImageToPayload);
187
+ const result = await generateImage(cfg, { prompt: params.prompt, images: images.length ? images : undefined, size: params.size }, abort);
188
+ let resp;
189
+ try {
190
+ resp = await fetch(result.url, { signal: abort });
191
+ }
192
+ catch (e) {
193
+ if (signal?.aborted)
194
+ throw e;
195
+ throw new Error(`图片生成成功但下载失败: ${e.message}`);
196
+ }
197
+ if (!resp.ok)
198
+ throw new Error(`图片生成成功但下载失败: HTTP ${resp.status}`);
199
+ const bytes = new Uint8Array(await resp.arrayBuffer());
200
+ const urlExt = extname(new URL(result.url).pathname).toLowerCase() || ".jpeg";
201
+ const path = resolveSavePath(params.save_path, urlExt);
202
+ mkdirSync(dirname(path), { recursive: true });
203
+ writeFileSync(path, bytes);
204
+ const sizeNote = result.size ? ` (${result.size})` : "";
205
+ return {
206
+ content: [{ type: "text", text: `图片已保存: ${path}${sizeNote}` }],
207
+ details: { path, size: result.size, bytes: bytes.byteLength },
208
+ };
209
+ },
210
+ });
211
+ }
212
+ /** 直连失败里值得换浏览器再试的一类:网络不通/超时,或典型的反爬状态码。 */
213
+ class DirectFetchError extends Error {
214
+ renderWorthy;
215
+ constructor(message, renderWorthy) {
216
+ super(message);
217
+ this.renderWorthy = renderWorthy;
218
+ }
219
+ }
220
+ /** 反爬/风控常用的状态码;404/500 这类真错误换浏览器也救不回来,不白花一次渲染。 */
221
+ const RENDER_WORTHY_STATUS = new Set([403, 405, 406, 429, 503]);
222
+ /** 直连抓取:纯客户端出网,不经过我们的服务器,也不额外计费。 */
223
+ async function fetchDirect(url, signal) {
224
+ const timeout = AbortSignal.timeout(FETCH_TIMEOUT_MS);
225
+ const abort = signal ? AbortSignal.any([signal, timeout]) : timeout;
226
+ let resp;
227
+ try {
228
+ resp = await fetch(url, {
229
+ redirect: "follow",
230
+ headers: {
231
+ accept: "text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.8",
232
+ "user-agent": "u1s1-cli",
233
+ },
234
+ signal: abort,
235
+ });
236
+ }
237
+ catch (e) {
238
+ // 用户主动打断不算失败,直接往外抛,不去碰云端渲染
239
+ if (signal?.aborted)
240
+ throw e;
241
+ throw new DirectFetchError(`打不开 ${url.href}: ${e.message}`, true);
242
+ }
243
+ if (!resp.ok) {
244
+ throw new DirectFetchError(`${url.href} 返回 ${resp.status} ${resp.statusText}`, RENDER_WORTHY_STATUS.has(resp.status));
245
+ }
246
+ const type = resp.headers.get("content-type") ?? "";
247
+ if (!/text\/|json|xml|javascript/i.test(type)) {
248
+ throw new DirectFetchError(`${url.href} 不是文本内容 (${type || "unknown"}),读不了`, false);
249
+ }
250
+ const raw = decodeBody(await readBodyCapped(resp, MAX_FETCH_BYTES), type);
251
+ const isHtml = /html|xml/i.test(type);
252
+ return { text: isHtml ? htmlToText(raw) : raw.trim(), contentType: type, isHtml };
253
+ }
254
+ /**
255
+ * 抓网页工具:先直连(免费、快),打不开或疑似被反爬/JS 空壳时,回退到
256
+ * 网关的 Cloudflare Browser Rendering 渲染(真无头浏览器,按次计费)。
257
+ */
258
+ export function createFetchTool(cfg) {
259
+ const canRender = () => !!(cfg.renderFallback && cfg.apiKey);
260
+ async function renderViaGateway(url, signal) {
261
+ const timeout = AbortSignal.timeout(RENDER_TIMEOUT_MS);
132
262
  const abort = signal ? AbortSignal.any([signal, timeout]) : timeout;
133
- let resp;
134
- try {
135
- resp = await fetch(url, {
136
- redirect: "follow",
137
- headers: {
138
- accept: "text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.8",
139
- "user-agent": "u1s1-cli",
140
- },
141
- signal: abort,
263
+ const { markdown } = await renderPage(cfg, url.href, abort);
264
+ return markdown.trim();
265
+ }
266
+ return defineTool({
267
+ name: "web_fetch",
268
+ label: "读取网页",
269
+ 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 headless browser.",
270
+ promptSnippet: "抓取指定网址并转成可读文本",
271
+ promptGuidelines: [
272
+ "Use web_fetch to read a specific URL, and web_search when you still need to find the URL.",
273
+ ],
274
+ parameters: Type.Object({
275
+ url: Type.String({ description: "Absolute http(s) URL to fetch." }),
276
+ }),
277
+ async execute(_toolCallId, params, signal) {
278
+ let url;
279
+ try {
280
+ // 用户从聊天工具粘来的链接常带 "@https://…" 前缀,顺手剥掉
281
+ url = new URL(params.url.trim().replace(/^@/, ""));
282
+ }
283
+ catch {
284
+ throw new Error(`不是合法的网址: ${params.url}`);
285
+ }
286
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
287
+ throw new Error(`只支持 http/https,收到 ${url.protocol}`);
288
+ }
289
+ const ok = (text, via, contentType) => ({
290
+ content: [{ type: "text", text: truncate(`# ${url.href}\n\n${text || "(空白页面)"}`) }],
291
+ details: { url: url.href, contentType, via, chars: text.length },
142
292
  });
143
- }
144
- catch (e) {
145
- throw new Error(`打不开 ${url.href}: ${e.message}`);
146
- }
147
- if (!resp.ok)
148
- throw new Error(`${url.href} 返回 ${resp.status} ${resp.statusText}`);
149
- const type = resp.headers.get("content-type") ?? "";
150
- if (!/text\/|json|xml|javascript/i.test(type)) {
151
- throw new Error(`${url.href} 不是文本内容 (${type || "unknown"}),读不了`);
152
- }
153
- const raw = decodeBody(await readBodyCapped(resp, MAX_FETCH_BYTES), type);
154
- const text = /html|xml/i.test(type) ? htmlToText(raw) : raw.trim();
155
- return {
156
- content: [
157
- { type: "text", text: truncate(`# ${url.href}\n\n${text || "(空白页面)"}`) },
158
- ],
159
- details: { url: url.href, contentType: type, chars: text.length },
160
- };
161
- },
162
- });
293
+ let direct = null;
294
+ try {
295
+ direct = await fetchDirect(url, signal);
296
+ }
297
+ catch (e) {
298
+ if (!(e instanceof DirectFetchError) || !e.renderWorthy || !canRender())
299
+ throw e;
300
+ try {
301
+ return ok(await renderViaGateway(url, signal), "render", "text/markdown");
302
+ }
303
+ catch (re) {
304
+ throw new Error(`${e.message};云端浏览器渲染也失败: ${re.message}`);
305
+ }
306
+ }
307
+ // 直连 200 但正文近乎空:大概率是 JS 渲染的 SPA,换浏览器再试;渲染失败就退回空壳
308
+ if (direct.isHtml && direct.text.length < MIN_HTML_TEXT_CHARS && canRender()) {
309
+ try {
310
+ const rendered = await renderViaGateway(url, signal);
311
+ if (rendered.length > direct.text.length)
312
+ return ok(rendered, "render", "text/markdown");
313
+ }
314
+ catch {
315
+ // 直连结果虽薄但还在,渲染挂了不至于让整次调用失败
316
+ }
317
+ }
318
+ return ok(direct.text, "direct", direct.contentType);
319
+ },
320
+ });
321
+ }
package/dist/web.js CHANGED
@@ -30,11 +30,17 @@ export async function prepareWebEnv(cfg) {
30
30
  // 服务端没开搜索(或老网关没有 features 字段)时不注册 web_search
31
31
  // 自定义端点与模型列表并行拉取,失败各自兜底(内置列表 / 本地缓存)
32
32
  let webSearchEnabled = true;
33
+ // 老网关没有 /v1/fetch,web_fetch_render 缺失时按关闭处理,直连失败不去白调
34
+ let webFetchRenderEnabled = false;
35
+ // 老网关没有 /v1/image,image_gen 缺失时按关闭处理,不注册生图工具
36
+ let imageGenEnabled = false;
33
37
  const endpointsReady = loadCustomEndpoints(cfg);
34
38
  try {
35
39
  const { models, features } = await fetchModels(cfg);
36
40
  setModelsFromApi(models.map(apiModelToDef));
37
41
  webSearchEnabled = features.web_search !== false;
42
+ webFetchRenderEnabled = features.web_fetch_render === true;
43
+ imageGenEnabled = features.image_gen === true;
38
44
  }
39
45
  catch (e) {
40
46
  console.error(" 获取模型列表失败,使用内置列表:", e.message);
@@ -45,7 +51,11 @@ export async function prepareWebEnv(cfg) {
45
51
  // pi-web-ui 靠 auth.json 判断「已配置」,否则网页会弹 pi 安装引导
46
52
  ensureAuthCredential();
47
53
  // 联网工具经 agentDir/extensions 投影,和 TUI 共用一份注册
48
- writeWebToolsExtension(cfg, webSearchEnabled);
54
+ writeWebToolsExtension(cfg, {
55
+ webSearch: webSearchEnabled,
56
+ webFetchRender: webFetchRenderEnabled,
57
+ imageGen: imageGenEnabled,
58
+ });
49
59
  // 网页版新会话从 settings.json 的 defaultModel 取模型(TUI 是每次传 --model),
50
60
  // 确保它有值;resolvePreferredModel 优先尊重已有的 in-session 选择,不会回退覆盖。
51
61
  const pref = resolvePreferredModel(cfg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "0.13.6",
3
+ "version": "0.14.0",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {