u1s1-cli 0.13.7 → 0.15.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.
- package/bench/suite.json +84 -0
- package/dist/agent-setup.d.ts +1 -0
- package/dist/agent-setup.js +4 -0
- package/dist/api.d.ts +14 -0
- package/dist/api.js +29 -0
- package/dist/bench.d.ts +1 -0
- package/dist/bench.js +520 -0
- package/dist/index.js +32 -2
- package/dist/tools.d.ts +11 -0
- package/dist/tools.js +103 -1
- package/dist/web.js +8 -1
- package/package.json +3 -2
package/bench/suite.json
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"name": "quick",
|
|
4
|
+
"description": "快速烟雾测试 — 几秒钟测完,日常切模型后随手验证",
|
|
5
|
+
"questions": [
|
|
6
|
+
{
|
|
7
|
+
"id": "hello",
|
|
8
|
+
"category": "基础",
|
|
9
|
+
"prompt": "只说一句「你好,我是 u1s1」就行,不要多余的话。"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "reverse",
|
|
13
|
+
"category": "编码",
|
|
14
|
+
"prompt": "写一个 TypeScript 函数,把字符串里的单词顺序反转(不是字符反转)。示例: 'hello world' → 'world hello'。只给代码,不要解释。"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"name": "full",
|
|
20
|
+
"description": "全面评测 — 覆盖编程、推理、中文理解、指令遵循",
|
|
21
|
+
"questions": [
|
|
22
|
+
{
|
|
23
|
+
"id": "fizzbuzz",
|
|
24
|
+
"category": "编码",
|
|
25
|
+
"prompt": "用 TypeScript 写 fizzbuzz,从 1 到 100,3 的倍数打印 fizz,5 的倍数打印 buzz,同时是 3 和 5 的倍数打印 fizzbuzz。只给代码,不要解释。"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "sort-algo",
|
|
29
|
+
"category": "编码",
|
|
30
|
+
"prompt": "用 TypeScript 实现一个快速排序(原地排序,不创建新数组)。只给代码,不要解释。"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "react-component",
|
|
34
|
+
"category": "编码",
|
|
35
|
+
"prompt": "写一个 React 计数器组件(TypeScript),包含:+ 按钮、- 按钮、重置按钮,显示当前数值。用 useState。只给代码,不要解释。"
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "regex",
|
|
39
|
+
"category": "编码",
|
|
40
|
+
"prompt": "写一个 JavaScript 正则表达式,匹配中国大陆手机号(11 位,1 开头,第二位 3-9)。只给正则,不要解释。"
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"id": "reasoning-1",
|
|
44
|
+
"category": "推理",
|
|
45
|
+
"prompt": "有三个箱子:一个只装苹果,一个只装橘子,一个混装。所有标签都贴错了。你只能从一个箱子里拿一个水果看,就能推断出所有箱子的正确内容。请问该从哪个箱子拿?为什么?"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "reasoning-2",
|
|
49
|
+
"category": "推理",
|
|
50
|
+
"prompt": "一个人花 8 元买了一只鸡,9 元卖出,10 元买回,11 元卖出。他赚了多少钱?一步步算。"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"id": "zh-explain",
|
|
54
|
+
"category": "中文",
|
|
55
|
+
"prompt": "用一句话给完全不懂编程的人解释什么是「递归」。说得通俗一点。"
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
"id": "zh-poem",
|
|
59
|
+
"category": "中文",
|
|
60
|
+
"prompt": "以「AI」为主题写一首五言绝句(每句五个字,共四句)。"
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"id": "instruction-follow",
|
|
64
|
+
"category": "指令遵循",
|
|
65
|
+
"prompt": "你的回答里只能包含以下三个词(可以重复):好、的、行。其他任何词都不许出现。开始:今天天气怎么样?"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "format-json",
|
|
69
|
+
"category": "指令遵循",
|
|
70
|
+
"prompt": "输出一个 JSON 对象,包含 name(你的名字)、version(当前日期)、models(数组,随便列三个模型名)。只输出 JSON,不要 markdown 代码块标记。"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "context-length",
|
|
74
|
+
"category": "长上下文",
|
|
75
|
+
"prompt": "请重复以下句子 50 遍:「u1s1 有一说一。」然后告诉我你一共输出了多少遍。"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"id": "translation",
|
|
79
|
+
"category": "翻译",
|
|
80
|
+
"prompt": "把这句话翻译成地道的中文: 'One should always code as if the person who ends up maintaining your code is a violent psychopath who knows where you live.'"
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
]
|
package/dist/agent-setup.d.ts
CHANGED
|
@@ -24,6 +24,7 @@ export declare function cleanupBrandThemes(): void;
|
|
|
24
24
|
export declare function writeWebToolsExtension(cfg: CliConfig, features: {
|
|
25
25
|
webSearch: boolean;
|
|
26
26
|
webFetchRender: boolean;
|
|
27
|
+
imageGen: boolean;
|
|
27
28
|
}): void;
|
|
28
29
|
/**
|
|
29
30
|
* pi-web-ui 的就绪检测只认 <agentDir>/auth.json 里有没有凭据条目(models.json
|
package/dist/agent-setup.js
CHANGED
|
@@ -130,12 +130,16 @@ export function writeWebToolsExtension(cfg, features) {
|
|
|
130
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
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
|
@@ -48,6 +48,8 @@ export interface ApiFeatures {
|
|
|
48
48
|
web_search?: boolean;
|
|
49
49
|
/** 网关配了 Browser Rendering 才为 true;老网关没有 /v1/fetch,缺失按关闭处理。 */
|
|
50
50
|
web_fetch_render?: boolean;
|
|
51
|
+
/** 网关配了方舟 ARK_API_KEY 才为 true;老网关没有 /v1/image,缺失按关闭处理。 */
|
|
52
|
+
image_gen?: boolean;
|
|
51
53
|
}
|
|
52
54
|
export interface ModelsResponse {
|
|
53
55
|
models: ApiModel[];
|
|
@@ -95,4 +97,16 @@ export declare function renderPage(cfg: Pick<CliConfig, "baseUrl" | "apiKey">, u
|
|
|
95
97
|
url: string;
|
|
96
98
|
markdown: string;
|
|
97
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
|
+
}>;
|
|
98
112
|
export declare function fetchMe(cfg: CliConfig): Promise<MeResponse>;
|
package/dist/api.js
CHANGED
|
@@ -107,6 +107,35 @@ export async function renderPage(cfg, url, signal) {
|
|
|
107
107
|
}
|
|
108
108
|
return (await resp.json());
|
|
109
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
|
+
}
|
|
110
139
|
export async function fetchMe(cfg) {
|
|
111
140
|
if (!cfg.apiKey)
|
|
112
141
|
throw new Error("没有配置 API Key");
|
package/dist/bench.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function benchCommand(args: string[]): Promise<void>;
|
package/dist/bench.js
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { CUSTOM_ENDPOINTS, loadConfig, MODELS, PROVIDER_ID, } from "./config.js";
|
|
6
|
+
import { loadCustomEndpoints } from "./api.js";
|
|
7
|
+
// ─── 工具 ───
|
|
8
|
+
const BENCH_DIR = join(homedir(), ".u1s1", "bench");
|
|
9
|
+
/** 项目 bench 数据目录 */
|
|
10
|
+
function benchDataDir() {
|
|
11
|
+
const distDir = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
return join(distDir, "..", "bench");
|
|
13
|
+
}
|
|
14
|
+
/** 从 suites 数组里按 name 找一个 suite */
|
|
15
|
+
function findSuite(suites, name) {
|
|
16
|
+
return suites.find((s) => s.name === name);
|
|
17
|
+
}
|
|
18
|
+
/** 从 JSON 文件读到 suites 数组 */
|
|
19
|
+
function readSuitesFile(filePath) {
|
|
20
|
+
return JSON.parse(readFileSync(filePath, "utf8"));
|
|
21
|
+
}
|
|
22
|
+
/** 读内置或用户自定义的 suite */
|
|
23
|
+
function loadSuite(name) {
|
|
24
|
+
// 先找内置的(suite.json 含多个 suite,按 name 匹配)
|
|
25
|
+
const builtinPath = join(benchDataDir(), "suite.json");
|
|
26
|
+
if (existsSync(builtinPath)) {
|
|
27
|
+
const found = findSuite(readSuitesFile(builtinPath), name);
|
|
28
|
+
if (found)
|
|
29
|
+
return found;
|
|
30
|
+
}
|
|
31
|
+
// 再找用户的 ~/.u1s1/bench/<name>.json(单文件单 suite,沿用 suite.json 数组格式)
|
|
32
|
+
const userPath = join(BENCH_DIR, `${name}.json`);
|
|
33
|
+
if (existsSync(userPath)) {
|
|
34
|
+
const found = findSuite(readSuitesFile(userPath), name);
|
|
35
|
+
if (found)
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
throw new Error(`找不到 bench suite「${name}」`);
|
|
39
|
+
}
|
|
40
|
+
/** 列出可用的 suite */
|
|
41
|
+
function listSuites() {
|
|
42
|
+
const suites = [];
|
|
43
|
+
// 内置
|
|
44
|
+
const builtinPath = join(benchDataDir(), "suite.json");
|
|
45
|
+
if (existsSync(builtinPath)) {
|
|
46
|
+
const raw = JSON.parse(readFileSync(builtinPath, "utf8"));
|
|
47
|
+
for (const s of raw) {
|
|
48
|
+
suites.push({ name: s.name, description: s.description, builtin: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// 用户自定义
|
|
52
|
+
if (existsSync(BENCH_DIR)) {
|
|
53
|
+
try {
|
|
54
|
+
const files = readdirSync(BENCH_DIR).filter((f) => f.endsWith(".json"));
|
|
55
|
+
for (const f of files) {
|
|
56
|
+
try {
|
|
57
|
+
const raw = JSON.parse(readFileSync(join(BENCH_DIR, f), "utf8"));
|
|
58
|
+
for (const s of raw) {
|
|
59
|
+
if (!suites.find((x) => x.name === s.name)) {
|
|
60
|
+
suites.push({ name: s.name, description: s.description, builtin: false });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch { /* 格式不对跳过 */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch { /* 忽略 */ }
|
|
68
|
+
}
|
|
69
|
+
return suites;
|
|
70
|
+
}
|
|
71
|
+
function formatDuration(ms) {
|
|
72
|
+
if (ms < 1000)
|
|
73
|
+
return `${ms.toFixed(0)}ms`;
|
|
74
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
75
|
+
}
|
|
76
|
+
function costStr(usd) {
|
|
77
|
+
if (usd === 0)
|
|
78
|
+
return "—";
|
|
79
|
+
if (usd < 0.001)
|
|
80
|
+
return "<$0.001";
|
|
81
|
+
return `$${usd.toFixed(4)}`;
|
|
82
|
+
}
|
|
83
|
+
/** 排列表格:每列对齐 */
|
|
84
|
+
function renderTable(header, rows) {
|
|
85
|
+
const colW = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]?.length ?? 0)));
|
|
86
|
+
const sep = "─".repeat(colW.reduce((a, b) => a + b + 3, 1));
|
|
87
|
+
const line = (cells) => " " + cells.map((c, i) => c.padEnd(colW[i])).join(" │ ") + " ";
|
|
88
|
+
return [
|
|
89
|
+
line(header),
|
|
90
|
+
sep,
|
|
91
|
+
...rows.map((r) => line(r)),
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
// ─── 模型调用 ───
|
|
95
|
+
async function callModel(baseUrl, apiKey, modelId, prompt) {
|
|
96
|
+
const start = performance.now();
|
|
97
|
+
// 简单判断是不是推理模型 — deepseek-reasoner、grok 带 reasoning
|
|
98
|
+
const isReasoning = /reasoner|grok/i.test(modelId);
|
|
99
|
+
const body = {
|
|
100
|
+
model: modelId,
|
|
101
|
+
messages: [{ role: "user", content: prompt }],
|
|
102
|
+
max_tokens: 4096,
|
|
103
|
+
// 推理模型不需要 temperature,传了可能报错
|
|
104
|
+
...(isReasoning ? {} : { temperature: 0.3 }),
|
|
105
|
+
stream: false,
|
|
106
|
+
};
|
|
107
|
+
const headers = {
|
|
108
|
+
"content-type": "application/json",
|
|
109
|
+
};
|
|
110
|
+
if (apiKey) {
|
|
111
|
+
headers["authorization"] = `Bearer ${apiKey}`;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers,
|
|
117
|
+
body: JSON.stringify(body),
|
|
118
|
+
signal: AbortSignal.timeout(120_000), // 2 分钟超时
|
|
119
|
+
});
|
|
120
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
121
|
+
if (!res.ok) {
|
|
122
|
+
const errBody = await res.text().catch(() => "未知错误");
|
|
123
|
+
return {
|
|
124
|
+
response: "",
|
|
125
|
+
latencyMs,
|
|
126
|
+
tokensIn: 0,
|
|
127
|
+
tokensOut: 0,
|
|
128
|
+
error: `HTTP ${res.status}: ${errBody.slice(0, 200)}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const data = (await res.json());
|
|
132
|
+
const content = data.choices?.[0]?.message?.content ?? "";
|
|
133
|
+
const tokensIn = data.usage?.prompt_tokens ?? 0;
|
|
134
|
+
const tokensOut = data.usage?.completion_tokens ?? 0;
|
|
135
|
+
return { response: content, latencyMs, tokensIn, tokensOut };
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
139
|
+
return {
|
|
140
|
+
response: "",
|
|
141
|
+
latencyMs,
|
|
142
|
+
tokensIn: 0,
|
|
143
|
+
tokensOut: 0,
|
|
144
|
+
error: e instanceof Error ? e.message : String(e),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** 找一个模型的 cost 信息(用于计费估算) */
|
|
149
|
+
function findModelCost(modelId) {
|
|
150
|
+
for (const m of MODELS) {
|
|
151
|
+
if (m.id === modelId)
|
|
152
|
+
return m.cost;
|
|
153
|
+
}
|
|
154
|
+
for (const ep of CUSTOM_ENDPOINTS) {
|
|
155
|
+
for (const m of ep.models) {
|
|
156
|
+
if (m.id === modelId)
|
|
157
|
+
return m.cost;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
/** 估算花费 */
|
|
163
|
+
function estimateCost(modelId, tokensIn, tokensOut) {
|
|
164
|
+
const cost = findModelCost(modelId);
|
|
165
|
+
if (!cost)
|
|
166
|
+
return 0;
|
|
167
|
+
// cost 是每百万 token 的美元价格
|
|
168
|
+
return (tokensIn * cost.input + tokensOut * cost.output) / 1_000_000;
|
|
169
|
+
}
|
|
170
|
+
// ─── 保存 & 报告 ───
|
|
171
|
+
function saveRun(run) {
|
|
172
|
+
mkdirSync(BENCH_DIR, { recursive: true });
|
|
173
|
+
const file = join(BENCH_DIR, `${run.id}.json`);
|
|
174
|
+
writeFileSync(file, JSON.stringify(run, null, 2) + "\n");
|
|
175
|
+
console.log(` 结果已保存:${file}`);
|
|
176
|
+
}
|
|
177
|
+
function loadRun(id) {
|
|
178
|
+
const file = join(BENCH_DIR, `${id}.json`);
|
|
179
|
+
if (!existsSync(file))
|
|
180
|
+
throw new Error(`找不到运行记录「${id}」`);
|
|
181
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
182
|
+
}
|
|
183
|
+
/** 列出最近的运行记录 */
|
|
184
|
+
function listRuns() {
|
|
185
|
+
if (!existsSync(BENCH_DIR))
|
|
186
|
+
return [];
|
|
187
|
+
const files = readdirSync(BENCH_DIR).filter((f) => f.endsWith(".json") && f.startsWith("bench-"));
|
|
188
|
+
const runs = [];
|
|
189
|
+
for (const f of files.sort().reverse().slice(0, 20)) {
|
|
190
|
+
try {
|
|
191
|
+
const run = JSON.parse(readFileSync(join(BENCH_DIR, f), "utf8"));
|
|
192
|
+
runs.push({ id: run.id, suiteName: run.suiteName, timestamp: run.timestamp, models: run.models });
|
|
193
|
+
}
|
|
194
|
+
catch { /* 跳过损坏记录 */ }
|
|
195
|
+
}
|
|
196
|
+
return runs;
|
|
197
|
+
}
|
|
198
|
+
// ─── 报告生成 ───
|
|
199
|
+
function generateReport(run) {
|
|
200
|
+
const lines = [];
|
|
201
|
+
lines.push(`# Bench 报告: ${run.suiteName}`);
|
|
202
|
+
lines.push(`运行时间: ${run.timestamp}`);
|
|
203
|
+
lines.push(`模型: ${run.models.join(", ")}`);
|
|
204
|
+
lines.push("");
|
|
205
|
+
// 按 category 分组
|
|
206
|
+
const byCategory = new Map();
|
|
207
|
+
for (const r of run.results) {
|
|
208
|
+
const cat = r.category || "未分类";
|
|
209
|
+
if (!byCategory.has(cat))
|
|
210
|
+
byCategory.set(cat, []);
|
|
211
|
+
byCategory.get(cat).push(r);
|
|
212
|
+
}
|
|
213
|
+
for (const [cat, results] of byCategory) {
|
|
214
|
+
lines.push(`## ${cat}`);
|
|
215
|
+
lines.push("");
|
|
216
|
+
// 按 question 分组
|
|
217
|
+
const byQuestion = new Map();
|
|
218
|
+
for (const r of results) {
|
|
219
|
+
if (!byQuestion.has(r.questionId))
|
|
220
|
+
byQuestion.set(r.questionId, []);
|
|
221
|
+
byQuestion.get(r.questionId).push(r);
|
|
222
|
+
}
|
|
223
|
+
for (const [qId, qResults] of byQuestion) {
|
|
224
|
+
const prompt = qResults[0].prompt;
|
|
225
|
+
lines.push(`### ${qId}`);
|
|
226
|
+
lines.push(`> ${prompt}`);
|
|
227
|
+
lines.push("");
|
|
228
|
+
// 表格:模型 | 延迟 | 输入 | 输出 | 花费 | 错误
|
|
229
|
+
const header = ["模型", "延迟", "输入token", "输出token", "花费", "状态"];
|
|
230
|
+
const rows = [];
|
|
231
|
+
for (const r of qResults) {
|
|
232
|
+
const modelLabel = r.providerName === PROVIDER_ID ? r.modelId : `${r.providerName}:${r.modelId}`;
|
|
233
|
+
rows.push([
|
|
234
|
+
modelLabel,
|
|
235
|
+
formatDuration(r.latencyMs),
|
|
236
|
+
String(r.tokensIn),
|
|
237
|
+
String(r.tokensOut),
|
|
238
|
+
costStr(r.costUsd),
|
|
239
|
+
r.error ? `❌ ${r.error}` : "✅",
|
|
240
|
+
]);
|
|
241
|
+
}
|
|
242
|
+
lines.push("```");
|
|
243
|
+
lines.push(renderTable(header, rows));
|
|
244
|
+
lines.push("```");
|
|
245
|
+
lines.push("");
|
|
246
|
+
// 各模型的回答
|
|
247
|
+
for (const r of qResults) {
|
|
248
|
+
const modelLabel = r.providerName === PROVIDER_ID ? r.modelId : `${r.providerName}:${r.modelId}`;
|
|
249
|
+
lines.push(`<details><summary>${modelLabel} 的回答</summary>`);
|
|
250
|
+
lines.push("");
|
|
251
|
+
lines.push("```");
|
|
252
|
+
lines.push(r.response.slice(0, 2000)); // 截断避免报告过大
|
|
253
|
+
if (r.response.length > 2000)
|
|
254
|
+
lines.push("...(截断)");
|
|
255
|
+
lines.push("```");
|
|
256
|
+
lines.push("</details>");
|
|
257
|
+
lines.push("");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// 汇总
|
|
262
|
+
lines.push("## 汇总");
|
|
263
|
+
lines.push("");
|
|
264
|
+
const modelSummary = new Map();
|
|
265
|
+
for (const r of run.results) {
|
|
266
|
+
const key = r.providerName === PROVIDER_ID ? r.modelId : `${r.providerName}:${r.modelId}`;
|
|
267
|
+
if (!modelSummary.has(key))
|
|
268
|
+
modelSummary.set(key, { totalLatency: 0, totalCost: 0, errors: 0, total: 0 });
|
|
269
|
+
const s = modelSummary.get(key);
|
|
270
|
+
s.totalLatency += r.latencyMs;
|
|
271
|
+
s.totalCost += r.costUsd;
|
|
272
|
+
if (r.error)
|
|
273
|
+
s.errors++;
|
|
274
|
+
s.total++;
|
|
275
|
+
}
|
|
276
|
+
const header = ["模型", "总延迟", "平均延迟", "总花费", "错误率"];
|
|
277
|
+
const rows = [];
|
|
278
|
+
for (const [model, s] of modelSummary) {
|
|
279
|
+
rows.push([
|
|
280
|
+
model,
|
|
281
|
+
formatDuration(s.totalLatency),
|
|
282
|
+
formatDuration(s.totalLatency / s.total),
|
|
283
|
+
costStr(s.totalCost),
|
|
284
|
+
s.errors > 0 ? `${((s.errors / s.total) * 100).toFixed(0)}%` : "0%",
|
|
285
|
+
]);
|
|
286
|
+
}
|
|
287
|
+
lines.push("```");
|
|
288
|
+
lines.push(renderTable(header, rows));
|
|
289
|
+
lines.push("```");
|
|
290
|
+
return lines.join("\n");
|
|
291
|
+
}
|
|
292
|
+
// ─── 主命令 ───
|
|
293
|
+
export async function benchCommand(args) {
|
|
294
|
+
const sub = args[0];
|
|
295
|
+
if (!sub || sub === "help" || sub === "--help") {
|
|
296
|
+
console.log("");
|
|
297
|
+
console.log(" u1s1 bench — 模型质量基准测试");
|
|
298
|
+
console.log("");
|
|
299
|
+
console.log(" 用法:");
|
|
300
|
+
console.log(" u1s1 bench list 列出可用的测试套件");
|
|
301
|
+
console.log(" u1s1 bench run <suite> [models..] 跑基准测试");
|
|
302
|
+
console.log(" u1s1 bench report <run-id> 查看报告");
|
|
303
|
+
console.log(" u1s1 bench compare <run-id1> <run-id2> 对比两次运行");
|
|
304
|
+
console.log("");
|
|
305
|
+
console.log(" 示例:");
|
|
306
|
+
console.log(" u1s1 bench run quick 用当前默认模型跑快速测试");
|
|
307
|
+
console.log(" u1s1 bench run full deepseek grok 用两个模型跑全面测试");
|
|
308
|
+
console.log(" u1s1 bench report latest 看最近一次报告");
|
|
309
|
+
console.log("");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (sub === "list") {
|
|
313
|
+
const suites = listSuites();
|
|
314
|
+
console.log("");
|
|
315
|
+
console.log(" Bench 套件:");
|
|
316
|
+
for (const s of suites) {
|
|
317
|
+
const tag = s.builtin ? "(内置)" : "(自定义)";
|
|
318
|
+
console.log(` ${s.name.padEnd(12)} ${s.description} ${tag}`);
|
|
319
|
+
}
|
|
320
|
+
console.log("");
|
|
321
|
+
console.log(" 自定义套件放 ~/.u1s1/bench/<name>.json,格式同内置 suite.json");
|
|
322
|
+
console.log("");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (sub === "run") {
|
|
326
|
+
const suiteName = args[1] ?? "quick";
|
|
327
|
+
const modelQueries = args.slice(2);
|
|
328
|
+
const cfg = loadConfig();
|
|
329
|
+
if (!cfg.apiKey) {
|
|
330
|
+
console.error(" 还没有登录,先 u1s1 login");
|
|
331
|
+
process.exit(1);
|
|
332
|
+
}
|
|
333
|
+
// 加载端点配置
|
|
334
|
+
await loadCustomEndpoints(cfg);
|
|
335
|
+
// 加载测试套件
|
|
336
|
+
let suite;
|
|
337
|
+
try {
|
|
338
|
+
suite = loadSuite(suiteName);
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
console.error(` ${e instanceof Error ? e.message : e}`);
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
let targets;
|
|
345
|
+
function resolveModelTarget(query) {
|
|
346
|
+
const lower = query.toLowerCase();
|
|
347
|
+
// u1s1 官方模型
|
|
348
|
+
const m = MODELS.find((x) => x.id.toLowerCase() === lower || x.aliases.includes(lower));
|
|
349
|
+
if (m) {
|
|
350
|
+
return { id: m.id, label: m.id, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, source: "u1s1" };
|
|
351
|
+
}
|
|
352
|
+
// 自定义端点
|
|
353
|
+
for (const ep of CUSTOM_ENDPOINTS) {
|
|
354
|
+
const em = ep.models.find((x) => x.id.toLowerCase() === lower || x.aliases.includes(lower));
|
|
355
|
+
if (em) {
|
|
356
|
+
// 自定义端点传模型短 ID(本地 Ollama 等不认带前缀的 huihui_ai/xxx)
|
|
357
|
+
const shortId = em.id.includes("/") ? em.id.split("/")[1] : em.id;
|
|
358
|
+
return { id: shortId, label: `${ep.name}:${em.id}`, baseUrl: ep.baseUrl, apiKey: ep.apiKey ?? "", source: "custom" };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
// 直接当 ID 用
|
|
362
|
+
return { id: query, label: query, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, source: "u1s1" };
|
|
363
|
+
}
|
|
364
|
+
if (modelQueries.length > 0) {
|
|
365
|
+
targets = modelQueries.map((q) => resolveModelTarget(q)).filter((t) => t !== null);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
// 默认:用当前首选模型
|
|
369
|
+
const { resolvePreferredModel } = await import("./config.js");
|
|
370
|
+
const pref = resolvePreferredModel(cfg);
|
|
371
|
+
if (pref.provider === PROVIDER_ID) {
|
|
372
|
+
targets = [{ id: pref.id, label: pref.id, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, source: "u1s1" }];
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
const ep = CUSTOM_ENDPOINTS.find((e) => e.id === pref.provider);
|
|
376
|
+
if (ep) {
|
|
377
|
+
const shortId = pref.id.includes("/") ? pref.id.split("/")[1] : pref.id;
|
|
378
|
+
targets = [{ id: shortId, label: `${ep.name}:${pref.id}`, baseUrl: ep.baseUrl, apiKey: ep.apiKey ?? "", source: "custom" }];
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
targets = [{ id: pref.id, label: pref.id, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, source: "u1s1" }];
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// 确认
|
|
386
|
+
console.log("");
|
|
387
|
+
console.log(` 📊 Bench: ${suite.name}`);
|
|
388
|
+
console.log(` ${suite.description}`);
|
|
389
|
+
console.log(` 题目数: ${suite.questions.length}`);
|
|
390
|
+
console.log(` 模型: ${targets.map((t) => t.label).join(", ")}`);
|
|
391
|
+
console.log("");
|
|
392
|
+
// 跑
|
|
393
|
+
const results = [];
|
|
394
|
+
const total = suite.questions.length * targets.length;
|
|
395
|
+
let done = 0;
|
|
396
|
+
for (const target of targets) {
|
|
397
|
+
for (const q of suite.questions) {
|
|
398
|
+
const label = `${target.label} / ${q.id}`;
|
|
399
|
+
process.stdout.write(` [${++done}/${total}] ${label.padEnd(40)} `);
|
|
400
|
+
const result = await callModel(target.baseUrl, target.apiKey, target.id, q.prompt);
|
|
401
|
+
const costUsd = estimateCost(target.id, result.tokensIn, result.tokensOut);
|
|
402
|
+
results.push({
|
|
403
|
+
questionId: q.id,
|
|
404
|
+
category: q.category,
|
|
405
|
+
prompt: q.prompt,
|
|
406
|
+
modelId: target.label,
|
|
407
|
+
providerName: target.source === "u1s1" ? PROVIDER_ID : target.label,
|
|
408
|
+
response: result.response,
|
|
409
|
+
latencyMs: result.latencyMs,
|
|
410
|
+
tokensIn: result.tokensIn,
|
|
411
|
+
tokensOut: result.tokensOut,
|
|
412
|
+
costUsd,
|
|
413
|
+
error: result.error,
|
|
414
|
+
thinking: result.thinking,
|
|
415
|
+
});
|
|
416
|
+
if (result.error) {
|
|
417
|
+
console.log(`❌ ${result.error.slice(0, 60)}`);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
console.log(`✅ ${formatDuration(result.latencyMs)} · ${result.tokensIn}→${result.tokensOut} tok · ${costStr(costUsd)}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
// 保存
|
|
425
|
+
const runId = `bench-${suite.name}-${Date.now()}`;
|
|
426
|
+
const run = {
|
|
427
|
+
id: runId,
|
|
428
|
+
timestamp: new Date().toISOString(),
|
|
429
|
+
suiteName: suite.name,
|
|
430
|
+
models: [...new Set(results.map((r) => r.modelId))],
|
|
431
|
+
results,
|
|
432
|
+
};
|
|
433
|
+
saveRun(run);
|
|
434
|
+
// 打印简易报告
|
|
435
|
+
console.log("");
|
|
436
|
+
console.log(" 📋 简易汇总:");
|
|
437
|
+
const byModel = new Map();
|
|
438
|
+
for (const r of results) {
|
|
439
|
+
const key = r.modelId;
|
|
440
|
+
if (!byModel.has(key))
|
|
441
|
+
byModel.set(key, { ok: 0, fail: 0, totalLatency: 0, totalCost: 0 });
|
|
442
|
+
const s = byModel.get(key);
|
|
443
|
+
if (r.error)
|
|
444
|
+
s.fail++;
|
|
445
|
+
else
|
|
446
|
+
s.ok++;
|
|
447
|
+
s.totalLatency += r.latencyMs;
|
|
448
|
+
s.totalCost += r.costUsd;
|
|
449
|
+
}
|
|
450
|
+
for (const [model, s] of byModel) {
|
|
451
|
+
const passRate = s.ok / (s.ok + s.fail);
|
|
452
|
+
console.log(` ${model.padEnd(30)} ${(passRate * 100).toFixed(0)}%通过 · 平均 ${formatDuration(s.totalLatency / (s.ok + s.fail))} · 花费 ${costStr(s.totalCost)}`);
|
|
453
|
+
}
|
|
454
|
+
console.log("");
|
|
455
|
+
console.log(` 查看完整报告: u1s1 bench report ${runId}`);
|
|
456
|
+
console.log("");
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (sub === "report") {
|
|
460
|
+
const runId = args[1] ?? "latest";
|
|
461
|
+
let run;
|
|
462
|
+
try {
|
|
463
|
+
const id = runId === "latest" ? findLatestRun() : runId;
|
|
464
|
+
if (!id)
|
|
465
|
+
throw new Error("还没有运行记录");
|
|
466
|
+
run = loadRun(id);
|
|
467
|
+
}
|
|
468
|
+
catch (e) {
|
|
469
|
+
console.error(` ${e instanceof Error ? e.message : e}`);
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
const report = generateReport(run);
|
|
473
|
+
// 写入可读文件
|
|
474
|
+
const reportFile = join(BENCH_DIR, `${run.id}.md`);
|
|
475
|
+
writeFileSync(reportFile, report);
|
|
476
|
+
console.log(report);
|
|
477
|
+
console.log(` 报告已保存: ${reportFile}`);
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (sub === "compare" && args[1] && args[2]) {
|
|
481
|
+
try {
|
|
482
|
+
const run1 = loadRun(args[1]);
|
|
483
|
+
const run2 = loadRun(args[2]);
|
|
484
|
+
console.log("");
|
|
485
|
+
console.log(` 对比 ${run1.suiteName}(${run1.timestamp}) vs ${run2.suiteName}(${run2.timestamp})`);
|
|
486
|
+
console.log("");
|
|
487
|
+
// 找共同题目,并排展示
|
|
488
|
+
const qIds1 = new Set(run1.results.map((r) => r.questionId));
|
|
489
|
+
const qIds2 = new Set(run2.results.map((r) => r.questionId));
|
|
490
|
+
const common = [...qIds1].filter((q) => qIds2.has(q));
|
|
491
|
+
for (const qId of common) {
|
|
492
|
+
console.log(` ── ${qId} ──`);
|
|
493
|
+
const r1 = run1.results.find((r) => r.questionId === qId);
|
|
494
|
+
const r2 = run2.results.find((r) => r.questionId === qId);
|
|
495
|
+
if (r1 && r2) {
|
|
496
|
+
console.log(` [run1] ${r1.modelId}: ${r1.response.slice(0, 200)}`);
|
|
497
|
+
console.log(` [run2] ${r2.modelId}: ${r2.response.slice(0, 200)}`);
|
|
498
|
+
console.log("");
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
catch (e) {
|
|
503
|
+
console.error(` ${e instanceof Error ? e.message : e}`);
|
|
504
|
+
process.exit(1);
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (sub === "compare") {
|
|
509
|
+
console.log(" 用法: u1s1 bench compare <run-id1> <run-id2>");
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
console.error(` 未知子命令: ${sub}`);
|
|
513
|
+
console.log(" u1s1 bench help 查看用法");
|
|
514
|
+
process.exit(1);
|
|
515
|
+
}
|
|
516
|
+
/** 找最近一次运行的 id */
|
|
517
|
+
function findLatestRun() {
|
|
518
|
+
const runs = listRuns();
|
|
519
|
+
return runs[0]?.id ?? null;
|
|
520
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -147,12 +147,15 @@ async function runAgent(cfg, args) {
|
|
|
147
147
|
let webSearchEnabled = true;
|
|
148
148
|
// 老网关没有 /v1/fetch,web_fetch_render 缺失时按关闭处理,直连失败不去白调
|
|
149
149
|
let webFetchRenderEnabled = false;
|
|
150
|
+
// 老网关没有 /v1/image,image_gen 缺失时按关闭处理,不注册生图工具
|
|
151
|
+
let imageGenEnabled = false;
|
|
150
152
|
const endpointsReady = loadCustomEndpoints(cfg);
|
|
151
153
|
try {
|
|
152
154
|
const { models, features } = await fetchModels(cfg);
|
|
153
155
|
setModelsFromApi(models.map(apiModelToDef));
|
|
154
156
|
webSearchEnabled = features.web_search !== false;
|
|
155
157
|
webFetchRenderEnabled = features.web_fetch_render === true;
|
|
158
|
+
imageGenEnabled = features.image_gen === true;
|
|
156
159
|
}
|
|
157
160
|
catch (e) {
|
|
158
161
|
console.error(" 获取模型列表失败,使用内置列表:", e.message);
|
|
@@ -161,7 +164,11 @@ async function runAgent(cfg, args) {
|
|
|
161
164
|
ensureBrandPrompt(await shellReady);
|
|
162
165
|
ensureProviderModels(cfg);
|
|
163
166
|
// 联网工具经 agentDir/extensions 投影,TUI 和 u1s1 web 共用一份注册
|
|
164
|
-
writeWebToolsExtension(cfg, {
|
|
167
|
+
writeWebToolsExtension(cfg, {
|
|
168
|
+
webSearch: webSearchEnabled,
|
|
169
|
+
webFetchRender: webFetchRenderEnabled,
|
|
170
|
+
imageGen: imageGenEnabled,
|
|
171
|
+
});
|
|
165
172
|
ensureTmuxKeyboardProtocol();
|
|
166
173
|
// must be set before pi reads them (getAgentDir() reads at call time, env at import is fine too)
|
|
167
174
|
process.env["PI_CODING_AGENT_DIR"] = agentDir;
|
|
@@ -206,6 +213,24 @@ async function runAgent(cfg, args) {
|
|
|
206
213
|
factory: (pi) => {
|
|
207
214
|
applyBrandUi(pi, VERSION);
|
|
208
215
|
offerStarterTemplates(pi);
|
|
216
|
+
// 会话标题:拿用户第一条正经输入的前半截当名字(像 Claude Code 那样),
|
|
217
|
+
// /resume 的会话列表里就能一眼认出每个会话。/clear 后 session_start
|
|
218
|
+
// 会重置标记,新会话重新起标题。
|
|
219
|
+
let sessionTitled = false;
|
|
220
|
+
pi.on("session_start", () => {
|
|
221
|
+
sessionTitled = false;
|
|
222
|
+
});
|
|
223
|
+
pi.on("input", async (event) => {
|
|
224
|
+
if (sessionTitled || event.source === "extension")
|
|
225
|
+
return;
|
|
226
|
+
const text = event.text.trim().replace(/\s+/g, " ");
|
|
227
|
+
// 空输入(纯图片粘贴)和斜杠命令不当标题
|
|
228
|
+
if (!text || text.startsWith("/"))
|
|
229
|
+
return;
|
|
230
|
+
sessionTitled = true;
|
|
231
|
+
const title = text.length > 30 ? `${text.slice(0, 30)}…` : text;
|
|
232
|
+
pi.setSessionName(title);
|
|
233
|
+
});
|
|
209
234
|
// pi 内置只有 /quit;补一个更常见的 /exit,方便新手退出
|
|
210
235
|
pi.registerCommand("exit", {
|
|
211
236
|
description: "退出 u1s1",
|
|
@@ -276,7 +301,7 @@ async function run() {
|
|
|
276
301
|
}
|
|
277
302
|
if (cmd === "--help" || cmd === "-h") {
|
|
278
303
|
printConsoleBanner(VERSION);
|
|
279
|
-
console.log(" u1s1 命令:web(浏览器网页版)· web shortcut(桌面图标)· deploy(发布网页)· login / logout · model · usage · update · import");
|
|
304
|
+
console.log(" u1s1 命令:web(浏览器网页版)· web shortcut(桌面图标)· deploy(发布网页)· login / logout · model · usage · update · import · bench");
|
|
280
305
|
console.log("");
|
|
281
306
|
}
|
|
282
307
|
if (cmd === "web") {
|
|
@@ -330,6 +355,11 @@ async function run() {
|
|
|
330
355
|
await importCommand(args.slice(1));
|
|
331
356
|
return;
|
|
332
357
|
}
|
|
358
|
+
if (cmd === "bench") {
|
|
359
|
+
const { benchCommand } = await import("./bench.js");
|
|
360
|
+
await benchCommand(args.slice(1));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
333
363
|
const { ensureAuth } = await import("./login.js");
|
|
334
364
|
const cfg = await ensureAuth();
|
|
335
365
|
// 在后台检查更新(非阻塞,不影响启动速度)
|
package/dist/tools.d.ts
CHANGED
|
@@ -8,6 +8,17 @@ 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
|
+
/** 生图工具:走 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>;
|
|
11
22
|
export interface FetchToolConfig {
|
|
12
23
|
baseUrl: string;
|
|
13
24
|
apiKey?: string;
|
package/dist/tools.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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 { renderPage, 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;
|
|
@@ -108,6 +110,106 @@ export function createSearchTool(cfg) {
|
|
|
108
110
|
},
|
|
109
111
|
});
|
|
110
112
|
}
|
|
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
|
+
"The model requires ≥3.7M total pixels; smaller explicit sizes are auto-scaled up keeping the aspect ratio, so prefer WIDTHxHEIGHT only to control the ratio.",
|
|
179
|
+
})),
|
|
180
|
+
save_path: Type.Optional(Type.String({
|
|
181
|
+
description: "Where to save the image (relative to cwd). Defaults to image-<timestamp>.<ext> in the current directory.",
|
|
182
|
+
})),
|
|
183
|
+
}),
|
|
184
|
+
async execute(_toolCallId, params, signal) {
|
|
185
|
+
const timeout = AbortSignal.timeout(IMAGE_TIMEOUT_MS);
|
|
186
|
+
const abort = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
187
|
+
const images = (params.images ?? []).map(refImageToPayload);
|
|
188
|
+
const result = await generateImage(cfg, { prompt: params.prompt, images: images.length ? images : undefined, size: params.size }, abort);
|
|
189
|
+
let resp;
|
|
190
|
+
try {
|
|
191
|
+
resp = await fetch(result.url, { signal: abort });
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
if (signal?.aborted)
|
|
195
|
+
throw e;
|
|
196
|
+
throw new Error(`图片生成成功但下载失败: ${e.message}`);
|
|
197
|
+
}
|
|
198
|
+
if (!resp.ok)
|
|
199
|
+
throw new Error(`图片生成成功但下载失败: HTTP ${resp.status}`);
|
|
200
|
+
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
201
|
+
const urlExt = extname(new URL(result.url).pathname).toLowerCase() || ".jpeg";
|
|
202
|
+
const path = resolveSavePath(params.save_path, urlExt);
|
|
203
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
204
|
+
writeFileSync(path, bytes);
|
|
205
|
+
const sizeNote = result.size ? ` (${result.size})` : "";
|
|
206
|
+
return {
|
|
207
|
+
content: [{ type: "text", text: `图片已保存: ${path}${sizeNote}` }],
|
|
208
|
+
details: { path, size: result.size, bytes: bytes.byteLength },
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
}
|
|
111
213
|
/** 直连失败里值得换浏览器再试的一类:网络不通/超时,或典型的反爬状态码。 */
|
|
112
214
|
class DirectFetchError extends Error {
|
|
113
215
|
renderWorthy;
|
package/dist/web.js
CHANGED
|
@@ -32,12 +32,15 @@ export async function prepareWebEnv(cfg) {
|
|
|
32
32
|
let webSearchEnabled = true;
|
|
33
33
|
// 老网关没有 /v1/fetch,web_fetch_render 缺失时按关闭处理,直连失败不去白调
|
|
34
34
|
let webFetchRenderEnabled = false;
|
|
35
|
+
// 老网关没有 /v1/image,image_gen 缺失时按关闭处理,不注册生图工具
|
|
36
|
+
let imageGenEnabled = false;
|
|
35
37
|
const endpointsReady = loadCustomEndpoints(cfg);
|
|
36
38
|
try {
|
|
37
39
|
const { models, features } = await fetchModels(cfg);
|
|
38
40
|
setModelsFromApi(models.map(apiModelToDef));
|
|
39
41
|
webSearchEnabled = features.web_search !== false;
|
|
40
42
|
webFetchRenderEnabled = features.web_fetch_render === true;
|
|
43
|
+
imageGenEnabled = features.image_gen === true;
|
|
41
44
|
}
|
|
42
45
|
catch (e) {
|
|
43
46
|
console.error(" 获取模型列表失败,使用内置列表:", e.message);
|
|
@@ -48,7 +51,11 @@ export async function prepareWebEnv(cfg) {
|
|
|
48
51
|
// pi-web-ui 靠 auth.json 判断「已配置」,否则网页会弹 pi 安装引导
|
|
49
52
|
ensureAuthCredential();
|
|
50
53
|
// 联网工具经 agentDir/extensions 投影,和 TUI 共用一份注册
|
|
51
|
-
writeWebToolsExtension(cfg, {
|
|
54
|
+
writeWebToolsExtension(cfg, {
|
|
55
|
+
webSearch: webSearchEnabled,
|
|
56
|
+
webFetchRender: webFetchRenderEnabled,
|
|
57
|
+
imageGen: imageGenEnabled,
|
|
58
|
+
});
|
|
52
59
|
// 网页版新会话从 settings.json 的 defaultModel 取模型(TUI 是每次传 --model),
|
|
53
60
|
// 确保它有值;resolvePreferredModel 优先尊重已有的 in-session 选择,不会回退覆盖。
|
|
54
61
|
const pref = resolvePreferredModel(cfg);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "u1s1-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
},
|
|
16
16
|
"files": [
|
|
17
17
|
"dist",
|
|
18
|
-
"webui-dist"
|
|
18
|
+
"webui-dist",
|
|
19
|
+
"bench"
|
|
19
20
|
],
|
|
20
21
|
"engines": {
|
|
21
22
|
"node": ">=22.19.0"
|