u1s1-cli 0.14.0 → 0.16.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.
@@ -0,0 +1,193 @@
1
+ [
2
+ {
3
+ "name": "quick",
4
+ "description": "快速烟雾测试 — 几秒钟测完,日常切模型后随手验证",
5
+ "questions": [
6
+ {
7
+ "id": "hello",
8
+ "category": "基础",
9
+ "prompt": "只说一句「你好,我是 u1s1」就行,不要多余的话。",
10
+ "maxScore": 5,
11
+ "checks": [
12
+ { "type": "contains", "value": "你好", "ignoreCase": false, "points": 2, "description": "包含「你好」" },
13
+ { "type": "contains", "value": "u1s1", "ignoreCase": true, "points": 2, "description": "包含「u1s1」" },
14
+ { "type": "max_length", "value": 50, "points": 1, "description": "不超过 50 字(啰嗦扣分)" }
15
+ ]
16
+ },
17
+ {
18
+ "id": "reverse",
19
+ "category": "编码",
20
+ "prompt": "写一个 TypeScript 函数,把字符串里的单词顺序反转(不是字符反转)。示例: 'hello world' → 'world hello'。只给代码,不要解释。",
21
+ "maxScore": 10,
22
+ "checks": [
23
+ { "type": "contains_code_block", "value": true, "points": 2, "description": "包含代码块" },
24
+ { "type": "contains", "value": "reverse", "ignoreCase": false, "points": 2, "description": "函数名包含 reverse" },
25
+ { "type": "contains", "value": "split", "ignoreCase": false, "points": 2, "description": "用了 split" },
26
+ { "type": "contains", "value": "reverse(", "ignoreCase": false, "points": 2, "description": "调用了数组 reverse" },
27
+ { "type": "contains", "value": "join", "ignoreCase": false, "points": 2, "description": "用了 join" }
28
+ ]
29
+ }
30
+ ]
31
+ },
32
+ {
33
+ "name": "full",
34
+ "description": "全面评测 — 覆盖编程、推理、中文理解、指令遵循",
35
+ "questions": [
36
+ {
37
+ "id": "fizzbuzz",
38
+ "category": "编码",
39
+ "prompt": "用 TypeScript 写 fizzbuzz,从 1 到 100,3 的倍数打印 fizz,5 的倍数打印 buzz,同时是 3 和 5 的倍数打印 fizzbuzz。只给代码,不要解释。",
40
+ "maxScore": 10,
41
+ "checks": [
42
+ { "type": "contains_code_block", "value": true, "points": 1, "description": "包含代码" },
43
+ { "type": "contains", "value": "% 3", "ignoreCase": false, "points": 2, "description": "判断 3 的倍数" },
44
+ { "type": "contains", "value": "% 5", "ignoreCase": false, "points": 2, "description": "判断 5 的倍数" },
45
+ { "type": "contains", "value": "fizzbuzz", "ignoreCase": false, "points": 2, "description": "输出了 fizzbuzz" },
46
+ { "type": "contains", "value": "100", "ignoreCase": false, "points": 1.5, "description": "循环到 100" },
47
+ { "type": "not_contains", "value": "解释", "ignoreCase": true, "points": 1.5, "description": "没有多余解释" }
48
+ ]
49
+ },
50
+ {
51
+ "id": "sort-algo",
52
+ "category": "编码",
53
+ "prompt": "用 TypeScript 实现一个快速排序(原地排序,不创建新数组)。只给代码,不要解释。",
54
+ "maxScore": 10,
55
+ "checks": [
56
+ { "type": "contains_code_block", "value": true, "points": 1, "description": "包含代码" },
57
+ { "type": "contains", "value": "partition", "ignoreCase": false, "points": 2, "description": "实现了 partition 函数" },
58
+ { "type": "contains", "value": "pivot", "ignoreCase": false, "points": 2, "description": "选定了 pivot" },
59
+ { "type": "contains", "value": "quickSort", "ignoreCase": false, "points": 2, "description": "递归调用 quickSort" },
60
+ { "type": "not_contains", "value": "concat", "ignoreCase": false, "points": 2, "description": "不是非原地排序(没用 concat)" },
61
+ { "type": "not_contains", "value": "解释", "ignoreCase": true, "points": 1, "description": "没有多余解释" }
62
+ ]
63
+ },
64
+ {
65
+ "id": "react-component",
66
+ "category": "编码",
67
+ "prompt": "写一个 React 计数器组件(TypeScript),包含:+ 按钮、- 按钮、重置按钮,显示当前数值。用 useState。只给代码,不要解释。",
68
+ "maxScore": 10,
69
+ "checks": [
70
+ { "type": "contains_code_block", "value": true, "points": 1, "description": "包含代码" },
71
+ { "type": "contains", "value": "useState", "ignoreCase": false, "points": 2, "description": "用了 useState" },
72
+ { "type": "contains", "value": "button", "ignoreCase": true, "points": 1.5, "description": "包含按钮元素" },
73
+ { "type": "contains", "value": "+", "ignoreCase": false, "points": 1, "description": "有加号按钮" },
74
+ { "type": "contains", "value": "-", "ignoreCase": false, "points": 1, "description": "有减号按钮" },
75
+ { "type": "contains", "value": "重置", "ignoreCase": false, "points": 1.5, "description": "有重置按钮" },
76
+ { "type": "contains", "value": "React", "ignoreCase": true, "points": 1, "description": "导入 React" },
77
+ { "type": "contains", "value": "FC", "ignoreCase": false, "points": 1, "description": "用了 React.FC 类型" }
78
+ ]
79
+ },
80
+ {
81
+ "id": "regex",
82
+ "category": "编码",
83
+ "prompt": "写一个 JavaScript 正则表达式,匹配中国大陆手机号(11 位,1 开头,第二位 3-9)。只给正则,不要解释。",
84
+ "maxScore": 10,
85
+ "checks": [
86
+ { "type": "valid_regex", "value": true, "points": 3, "description": "正则语法有效" },
87
+ { "type": "contains", "value": "1[3-9]", "ignoreCase": false, "points": 3, "description": "匹配 1 开头+第二位 3-9" },
88
+ { "type": "contains", "value": "\\d{9}", "ignoreCase": false, "points": 2, "description": "匹配后 9 位数字" },
89
+ { "type": "contains", "value": "^", "ignoreCase": false, "points": 1, "description": "有开头锚点" },
90
+ { "type": "contains", "value": "$", "ignoreCase": false, "points": 1, "description": "有结尾锚点" }
91
+ ]
92
+ },
93
+ {
94
+ "id": "reasoning-1",
95
+ "category": "推理",
96
+ "prompt": "有三个箱子:一个只装苹果,一个只装橘子,一个混装。所有标签都贴错了。你只能从一个箱子里拿一个水果看,就能推断出所有箱子的正确内容。请问该从哪个箱子拿?为什么?",
97
+ "maxScore": 10,
98
+ "checks": [
99
+ { "type": "contains", "value": "混装", "ignoreCase": false, "points": 3, "description": "回答从混装箱拿" },
100
+ { "type": "contains", "value": "标签", "ignoreCase": false, "points": 2, "description": "提到标签贴错" },
101
+ { "type": "contains", "value": "推理", "ignoreCase": true, "points": 2, "description": "有推理过程" },
102
+ { "type": "min_length", "value": 100, "points": 3, "description": "解释够详细(>=100 字)" }
103
+ ]
104
+ },
105
+ {
106
+ "id": "reasoning-2",
107
+ "category": "推理",
108
+ "prompt": "一个人花 8 元买了一只鸡,9 元卖出,10 元买回,11 元卖出。他赚了多少钱?一步步算。",
109
+ "maxScore": 10,
110
+ "checks": [
111
+ { "type": "contains", "value": "2", "ignoreCase": false, "points": 4, "description": "答案正确(赚 2 元)" },
112
+ { "type": "contains", "value": "8", "ignoreCase": false, "points": 1.5, "description": "提到了 8 元买入" },
113
+ { "type": "contains", "value": "11", "ignoreCase": false, "points": 1.5, "description": "提到了 11 元卖出" },
114
+ { "type": "contains", "value": "利润", "ignoreCase": true, "points": 1.5, "description": "有利润计算过程" },
115
+ { "type": "min_length", "value": 80, "points": 1.5, "description": "有分步计算过程" }
116
+ ]
117
+ },
118
+ {
119
+ "id": "zh-explain",
120
+ "category": "中文",
121
+ "prompt": "用一句话给完全不懂编程的人解释什么是「递归」。说得通俗一点。",
122
+ "maxScore": 10,
123
+ "checks": [
124
+ { "type": "contains", "value": "套娃", "ignoreCase": false, "points": 3, "description": "用了套娃类比(通俗)" },
125
+ { "type": "min_length", "value": 20, "points": 2, "description": "不是敷衍(>=20 字)" },
126
+ { "type": "max_length", "value": 200, "points": 2, "description": "真的是一句话(<=200 字)" },
127
+ { "type": "not_contains", "value": "函数调用", "ignoreCase": true, "points": 1.5, "description": "没用编程术语" },
128
+ { "type": "not_contains", "value": "递归调用", "ignoreCase": true, "points": 1.5, "description": "没递归解释递归(循环定义)" }
129
+ ]
130
+ },
131
+ {
132
+ "id": "zh-poem",
133
+ "category": "中文",
134
+ "prompt": "以「AI」为主题写一首五言绝句(每句五个字,共四句)。只给诗句,不要解释。",
135
+ "maxScore": 10,
136
+ "checks": [
137
+ { "type": "has_chinese", "value": true, "points": 2, "description": "包含中文" },
138
+ { "type": "line_count_5chars", "value": 4, "points": 4, "description": "四句每句五字" },
139
+ { "type": "contains", "value": "AI", "ignoreCase": true, "points": 2, "description": "主题相关" },
140
+ { "type": "max_length", "value": 100, "points": 2, "description": "没有多余解释" }
141
+ ]
142
+ },
143
+ {
144
+ "id": "instruction-follow",
145
+ "category": "指令遵循",
146
+ "prompt": "你的回答里只能包含以下三个词(可以重复):好、的、行。其他任何词都不许出现。开始:今天天气怎么样?",
147
+ "maxScore": 10,
148
+ "checks": [
149
+ { "type": "allowed_words_only", "value": ["好", "的", "行"], "points": 8, "description": "只用了允许的词" },
150
+ { "type": "min_length", "value": 1, "points": 2, "description": "有实际回答(没沉默)" }
151
+ ]
152
+ },
153
+ {
154
+ "id": "format-json",
155
+ "category": "指令遵循",
156
+ "prompt": "输出一个 JSON 对象,包含 name(你的名字)、version(当前日期)、models(数组,随便列三个模型名)。只输出 JSON,不要 markdown 代码块标记。",
157
+ "maxScore": 10,
158
+ "checks": [
159
+ { "type": "parse_json", "value": true, "points": 4, "description": "能解析为合法 JSON" },
160
+ { "type": "has_json_key", "value": "name", "points": 1.5, "description": "包含 name 字段" },
161
+ { "type": "has_json_key", "value": "version", "points": 1.5, "description": "包含 version 字段" },
162
+ { "type": "has_json_key", "value": "models", "points": 1.5, "description": "包含 models 字段" },
163
+ { "type": "not_contains", "value": "```", "ignoreCase": false, "points": 1.5, "description": "没包在 markdown 代码块里" }
164
+ ]
165
+ },
166
+ {
167
+ "id": "context-length",
168
+ "category": "长上下文",
169
+ "prompt": "请重复以下句子 50 遍:「u1s1 有一说一。」然后告诉我你一共输出了多少遍。",
170
+ "maxScore": 10,
171
+ "checks": [
172
+ { "type": "contains", "value": "50", "ignoreCase": false, "points": 4, "description": "输出了 50 遍(或声明了 50)" },
173
+ { "type": "contains", "value": "u1s1 有一说一", "ignoreCase": true, "points": 3, "description": "内容包含 u1s1 有一说一" },
174
+ { "type": "min_length", "value": 200, "points": 3, "description": "长度足够(>=200 字符)" }
175
+ ]
176
+ },
177
+ {
178
+ "id": "translation",
179
+ "category": "翻译",
180
+ "prompt": "把这句话翻译成地道的中文: 'One should always code as if the person who ends up maintaining your code is a violent psychopath who knows where you live.'",
181
+ "maxScore": 10,
182
+ "checks": [
183
+ { "type": "has_chinese", "value": true, "points": 2, "description": "输出是中文" },
184
+ { "type": "contains", "value": "暴力", "ignoreCase": false, "points": 2, "description": "翻出了 violent" },
185
+ { "type": "contains", "value": "代码", "ignoreCase": false, "points": 2, "description": "翻出了 code" },
186
+ { "type": "contains", "value": "维护", "ignoreCase": false, "points": 2, "description": "翻出了 maintaining" },
187
+ { "type": "contains", "value": "你", "ignoreCase": false, "points": 1, "description": "翻出了 you" },
188
+ { "type": "contains", "value": "住", "ignoreCase": false, "points": 1, "description": "翻出了 where you live" }
189
+ ]
190
+ }
191
+ ]
192
+ }
193
+ ]
@@ -26,6 +26,13 @@ export declare function writeWebToolsExtension(cfg: CliConfig, features: {
26
26
  webFetchRender: boolean;
27
27
  imageGen: boolean;
28
28
  }): void;
29
+ /**
30
+ * 生成「精简 UI」扩展到 <agentDir>/extensions/u1s1-compact-ui.js:
31
+ * - 隐藏内置工具调用(read/bash/edit/write/grep/find/ls),出错才显示一行;ctrl+o 可展开
32
+ * - 每轮回复结束追加一条汇总行,如 ⚙ 9 tools · bash×4 · read×3 · ✗ 1
33
+ * - 思考块折叠标签设为空,配合 pi 补丁(见 patches/)消除多余空行
34
+ */
35
+ export declare function writeCompactUiExtension(): void;
29
36
  /**
30
37
  * pi-web-ui 的就绪检测只认 <agentDir>/auth.json 里有没有凭据条目(models.json
31
38
  * 的 apiKey 它不看),空 {} 会在网页里弹「未检测到 pi agent 配置」引导装 pi。
@@ -142,6 +142,99 @@ export function writeWebToolsExtension(cfg, features) {
142
142
  imageLine +
143
143
  `}\n`);
144
144
  }
145
+ /**
146
+ * 生成「精简 UI」扩展到 <agentDir>/extensions/u1s1-compact-ui.js:
147
+ * - 隐藏内置工具调用(read/bash/edit/write/grep/find/ls),出错才显示一行;ctrl+o 可展开
148
+ * - 每轮回复结束追加一条汇总行,如 ⚙ 9 tools · bash×4 · read×3 · ✗ 1
149
+ * - 思考块折叠标签设为空,配合 pi 补丁(见 patches/)消除多余空行
150
+ */
151
+ export function writeCompactUiExtension() {
152
+ const dir = join(agentDir, "extensions");
153
+ mkdirSync(dir, { recursive: true });
154
+ writeFileSync(join(dir, "u1s1-compact-ui.js"), `// 由 u1s1 每次启动自动生成,请勿手改
155
+ function oneLine(text, max = 160) {
156
+ const flat = String(text).replace(/\\s*\\n\\s*/g, " ; ").trim();
157
+ return flat.length > max ? flat.slice(0, max - 1) + "…" : flat;
158
+ }
159
+
160
+ export default async function (pi) {
161
+ // ---- 隐藏思考块折叠标签 ----
162
+ pi.on("session_start", async (_event, ctx) => {
163
+ if (ctx.hasUI) ctx.ui.setHiddenThinkingLabel("");
164
+ });
165
+
166
+ // ---- 汇总统计 ----
167
+ let counts = {};
168
+ let errors = 0;
169
+ pi.on("agent_start", async () => {
170
+ counts = {};
171
+ errors = 0;
172
+ });
173
+ pi.on("tool_call", async (event) => {
174
+ counts[event.toolName] = (counts[event.toolName] ?? 0) + 1;
175
+ });
176
+ pi.on("tool_result", async (event) => {
177
+ if (event.isError) errors++;
178
+ });
179
+ pi.on("agent_end", async () => {
180
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
181
+ if (total === 0) return;
182
+ const parts = Object.entries(counts)
183
+ .sort((a, b) => b[1] - a[1])
184
+ .map(([name, n]) => name + "×" + n);
185
+ if (errors > 0) parts.push("✗ " + errors);
186
+ pi.appendEntry("tool-summary", { total, summary: parts.join(" · ") });
187
+ });
188
+ pi.registerEntryRenderer("tool-summary", (entry, _opts, theme) => {
189
+ const d = entry.data;
190
+ let text = theme.fg("muted", "⚙ ");
191
+ text += theme.fg("toolTitle", theme.bold(d.total + " tool" + (d.total > 1 ? "s" : "")));
192
+ text += theme.fg("dim", " · " + d.summary);
193
+ return new Text(text, 0, 0);
194
+ });
195
+
196
+ // ---- 隐藏各内置工具 ----
197
+ const { createReadTool, createBashTool, createEditTool, createWriteTool, createGrepTool, createFindTool, createLsTool } = await import("@earendil-works/pi-coding-agent");
198
+ const { Text } = await import("@earendil-works/pi-tui");
199
+
200
+ function hideTool(name, orig) {
201
+ pi.registerTool({
202
+ name,
203
+ label: name,
204
+ description: orig.description,
205
+ parameters: orig.parameters,
206
+ // 自己管外壳:不用默认的 Box 包装,空内容才不会产生空行
207
+ renderShell: "self",
208
+ async execute(toolCallId, params, signal, onUpdate) {
209
+ return orig.execute(toolCallId, params, signal, onUpdate);
210
+ },
211
+ renderCall() {
212
+ return new Text("", 0, 0);
213
+ },
214
+ renderResult(result, { expanded }, theme, context) {
215
+ if (context.isError) {
216
+ const c = result.content.find((x) => x.type === "text");
217
+ return new Text(theme.fg("error", "✗ " + oneLine(c && c.type === "text" ? c.text : "error")), 0, 0);
218
+ }
219
+ if (!expanded) return new Text("", 0, 0);
220
+ const c = result.content.find((x) => x.type === "text");
221
+ if (!c || c.type !== "text") return new Text("", 0, 0);
222
+ return new Text(c.text, 0, 0);
223
+ },
224
+ });
225
+ }
226
+
227
+ const cwd = process.cwd();
228
+ hideTool("read", createReadTool(cwd));
229
+ hideTool("bash", createBashTool(cwd));
230
+ hideTool("edit", createEditTool(cwd));
231
+ hideTool("write", createWriteTool(cwd));
232
+ hideTool("grep", createGrepTool(cwd));
233
+ hideTool("find", createFindTool(cwd));
234
+ hideTool("ls", createLsTool(cwd));
235
+ }
236
+ `);
237
+ }
145
238
  /**
146
239
  * pi-web-ui 的就绪检测只认 <agentDir>/auth.json 里有没有凭据条目(models.json
147
240
  * 的 apiKey 它不看),空 {} 会在网页里弹「未检测到 pi agent 配置」引导装 pi。
@@ -0,0 +1 @@
1
+ export declare function benchCommand(args: string[]): Promise<void>;
package/dist/bench.js ADDED
@@ -0,0 +1,668 @@
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
+ const builtinPath = join(benchDataDir(), "suite.json");
25
+ if (existsSync(builtinPath)) {
26
+ const found = findSuite(readSuitesFile(builtinPath), name);
27
+ if (found)
28
+ return found;
29
+ }
30
+ const userPath = join(BENCH_DIR, `${name}.json`);
31
+ if (existsSync(userPath)) {
32
+ const found = findSuite(readSuitesFile(userPath), name);
33
+ if (found)
34
+ return found;
35
+ }
36
+ throw new Error(`找不到 bench suite「${name}」`);
37
+ }
38
+ /** 列出可用的 suite */
39
+ function listSuites() {
40
+ const suites = [];
41
+ const builtinPath = join(benchDataDir(), "suite.json");
42
+ if (existsSync(builtinPath)) {
43
+ const raw = JSON.parse(readFileSync(builtinPath, "utf8"));
44
+ for (const s of raw) {
45
+ suites.push({ name: s.name, description: s.description, builtin: true });
46
+ }
47
+ }
48
+ if (existsSync(BENCH_DIR)) {
49
+ try {
50
+ const files = readdirSync(BENCH_DIR).filter((f) => f.endsWith(".json"));
51
+ for (const f of files) {
52
+ try {
53
+ const raw = JSON.parse(readFileSync(join(BENCH_DIR, f), "utf8"));
54
+ for (const s of raw) {
55
+ if (!suites.find((x) => x.name === s.name)) {
56
+ suites.push({ name: s.name, description: s.description, builtin: false });
57
+ }
58
+ }
59
+ }
60
+ catch { /* 格式不对跳过 */ }
61
+ }
62
+ }
63
+ catch { /* 忽略 */ }
64
+ }
65
+ return suites;
66
+ }
67
+ function formatDuration(ms) {
68
+ if (ms < 1000)
69
+ return `${ms.toFixed(0)}ms`;
70
+ return `${(ms / 1000).toFixed(1)}s`;
71
+ }
72
+ function costStr(usd) {
73
+ if (usd === 0)
74
+ return "—";
75
+ if (usd < 0.001)
76
+ return "<$0.001";
77
+ return `$${usd.toFixed(4)}`;
78
+ }
79
+ /** 排列表格 */
80
+ function renderTable(header, rows) {
81
+ const colW = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]?.length ?? 0)));
82
+ const sep = "─".repeat(colW.reduce((a, b) => a + b + 3, 1));
83
+ const line = (cells) => " " + cells.map((c, i) => c.padEnd(colW[i])).join(" │ ") + " ";
84
+ return [line(header), sep, ...rows.map((r) => line(r))].join("\n");
85
+ }
86
+ /** 热度条:分数按比例转成视觉条 */
87
+ function scoreBar(score, maxScore, width = 8) {
88
+ const ratio = maxScore > 0 ? Math.min(score / maxScore, 1) : 0;
89
+ const filled = Math.round(ratio * width);
90
+ const empty = width - filled;
91
+ const bar = "█".repeat(filled) + "░".repeat(empty);
92
+ // 颜色标记
93
+ if (ratio >= 0.8)
94
+ return `\x1b[32m${bar}\x1b[0m`; // 绿
95
+ if (ratio >= 0.5)
96
+ return `\x1b[33m${bar}\x1b[0m`; // 黄
97
+ return `\x1b[31m${bar}\x1b[0m`; // 红
98
+ }
99
+ // ─── 评分引擎 ───
100
+ /** 对一条回答按 checks 逐项打分 */
101
+ function scoreResponse(response, checks, maxScore) {
102
+ const details = [];
103
+ let total = 0;
104
+ for (const check of checks) {
105
+ const result = runCheck(response, check);
106
+ details.push({
107
+ check: check.description,
108
+ passed: result,
109
+ points: check.points,
110
+ earned: result ? check.points : 0,
111
+ });
112
+ if (result)
113
+ total += check.points;
114
+ }
115
+ return { score: Math.min(total, maxScore), details };
116
+ }
117
+ /** 执行单项检查 */
118
+ function runCheck(response, check) {
119
+ const val = check.value;
120
+ switch (check.type) {
121
+ case "contains": {
122
+ const needle = String(val);
123
+ const text = check.ignoreCase ? response.toLowerCase() : response;
124
+ const search = check.ignoreCase ? needle.toLowerCase() : needle;
125
+ return text.includes(search);
126
+ }
127
+ case "not_contains": {
128
+ const needle = String(val);
129
+ const text = check.ignoreCase ? response.toLowerCase() : response;
130
+ const search = check.ignoreCase ? needle.toLowerCase() : needle;
131
+ return !text.includes(search);
132
+ }
133
+ case "contains_code_block":
134
+ return /```[\s\S]*?```/.test(response) || /`[^`]+`/.test(response);
135
+ case "parse_json": {
136
+ try {
137
+ const stripped = response.replace(/```json\s*\n?/gi, "").replace(/\n?```/g, "").trim();
138
+ JSON.parse(stripped);
139
+ return true;
140
+ }
141
+ catch {
142
+ return false;
143
+ }
144
+ }
145
+ case "valid_regex": {
146
+ try {
147
+ new RegExp(String(val));
148
+ return true;
149
+ }
150
+ catch {
151
+ // 从回答里提取 regex 来验证
152
+ const match = response.match(/\/(.+)\/[gimsuy]*/);
153
+ if (match) {
154
+ new RegExp(match[1]);
155
+ return true;
156
+ }
157
+ // 也可能是文本形式
158
+ const cleaned = response.replace(/```[\s\S]*?```/g, "").trim();
159
+ if (cleaned.startsWith("/")) {
160
+ const parts = cleaned.match(/^\/(.+)\/([gimsuy]*)$/);
161
+ if (parts) {
162
+ new RegExp(parts[1]);
163
+ return true;
164
+ }
165
+ }
166
+ return false;
167
+ }
168
+ }
169
+ case "has_json_key": {
170
+ try {
171
+ const stripped = response.replace(/```json\s*\n?/gi, "").replace(/\n?```/g, "").trim();
172
+ const obj = JSON.parse(stripped);
173
+ return obj[String(val)] !== undefined;
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ }
179
+ case "max_length": {
180
+ const limit = Number(val);
181
+ return response.length <= limit;
182
+ }
183
+ case "min_length": {
184
+ const limit = Number(val);
185
+ return response.length >= limit;
186
+ }
187
+ case "allowed_words_only": {
188
+ const allowed = val;
189
+ // 提取回答中的所有中文/英文字词
190
+ const tokens = response.split(/[\s,。!?、;:""''()\[【】\]「」]+/).filter(Boolean);
191
+ for (const token of tokens) {
192
+ if (token.length === 0)
193
+ continue;
194
+ // 标点/空格不检查
195
+ if (/^[,。!?、;:""''()《》\s.。,,、!?;:…\-—]+$/.test(token))
196
+ continue;
197
+ if (!allowed.includes(token))
198
+ return false;
199
+ }
200
+ return true;
201
+ }
202
+ case "has_chinese": {
203
+ return /[\u4e00-\u9fff\u3400-\u4dbf]/.test(response);
204
+ }
205
+ case "line_count_5chars": {
206
+ // 检查有几行是恰好 5 个汉字(去除标点空格)
207
+ const expected = Number(val);
208
+ const lines = response.split("\n");
209
+ let count5 = 0;
210
+ for (const line of lines) {
211
+ const chars = line.replace(/[,。!?、;:""''()《》\s,\.\-,;:!?\dA-Za-z]/g, "").trim();
212
+ if (/^[\u4e00-\u9fff]{5}$/.test(chars))
213
+ count5++;
214
+ }
215
+ return count5 >= expected;
216
+ }
217
+ default:
218
+ return false;
219
+ }
220
+ }
221
+ // ─── 模型调用 ───
222
+ async function callModel(baseUrl, apiKey, modelId, prompt) {
223
+ const start = performance.now();
224
+ const isReasoning = /reasoner|grok/i.test(modelId);
225
+ const body = {
226
+ model: modelId,
227
+ messages: [{ role: "user", content: prompt }],
228
+ max_tokens: 4096,
229
+ ...(isReasoning ? {} : { temperature: 0.3 }),
230
+ stream: false,
231
+ };
232
+ const headers = { "content-type": "application/json" };
233
+ if (apiKey)
234
+ headers["authorization"] = `Bearer ${apiKey}`;
235
+ try {
236
+ const res = await fetch(`${baseUrl}/chat/completions`, {
237
+ method: "POST",
238
+ headers,
239
+ body: JSON.stringify(body),
240
+ signal: AbortSignal.timeout(120_000),
241
+ });
242
+ const latencyMs = Math.round(performance.now() - start);
243
+ if (!res.ok) {
244
+ const errBody = await res.text().catch(() => "未知错误");
245
+ return { response: "", latencyMs, tokensIn: 0, tokensOut: 0, error: `HTTP ${res.status}: ${errBody.slice(0, 200)}` };
246
+ }
247
+ const data = (await res.json());
248
+ return {
249
+ response: data.choices?.[0]?.message?.content ?? "",
250
+ latencyMs,
251
+ tokensIn: data.usage?.prompt_tokens ?? 0,
252
+ tokensOut: data.usage?.completion_tokens ?? 0,
253
+ };
254
+ }
255
+ catch (e) {
256
+ const latencyMs = Math.round(performance.now() - start);
257
+ return {
258
+ response: "", latencyMs, tokensIn: 0, tokensOut: 0,
259
+ error: e instanceof Error ? e.message : String(e),
260
+ };
261
+ }
262
+ }
263
+ function findModelCost(modelId) {
264
+ for (const m of MODELS)
265
+ if (m.id === modelId)
266
+ return m.cost;
267
+ for (const ep of CUSTOM_ENDPOINTS)
268
+ for (const m of ep.models)
269
+ if (m.id === modelId)
270
+ return m.cost;
271
+ return null;
272
+ }
273
+ function estimateCost(modelId, tokensIn, tokensOut) {
274
+ const cost = findModelCost(modelId);
275
+ if (!cost)
276
+ return 0;
277
+ return (tokensIn * cost.input + tokensOut * cost.output) / 1_000_000;
278
+ }
279
+ // ─── 保存 & 报告 ───
280
+ function saveRun(run) {
281
+ mkdirSync(BENCH_DIR, { recursive: true });
282
+ const file = join(BENCH_DIR, `${run.id}.json`);
283
+ writeFileSync(file, JSON.stringify(run, null, 2) + "\n");
284
+ console.log(` 结果已保存:${file}`);
285
+ }
286
+ function loadRun(id) {
287
+ const file = join(BENCH_DIR, `${id}.json`);
288
+ if (!existsSync(file))
289
+ throw new Error(`找不到运行记录「${id}」`);
290
+ return JSON.parse(readFileSync(file, "utf8"));
291
+ }
292
+ function listRuns() {
293
+ if (!existsSync(BENCH_DIR))
294
+ return [];
295
+ const files = readdirSync(BENCH_DIR).filter((f) => f.endsWith(".json") && f.startsWith("bench-"));
296
+ const runs = [];
297
+ for (const f of files.sort().reverse().slice(0, 20)) {
298
+ try {
299
+ const run = JSON.parse(readFileSync(join(BENCH_DIR, f), "utf8"));
300
+ runs.push({ id: run.id, suiteName: run.suiteName, timestamp: run.timestamp, models: run.models });
301
+ }
302
+ catch { /* 跳过损坏 */ }
303
+ }
304
+ return runs;
305
+ }
306
+ // ─── 报告生成 ───
307
+ function generateReport(run) {
308
+ const lines = [];
309
+ lines.push(`# Bench 报告: ${run.suiteName}`);
310
+ lines.push(`运行时间: ${run.timestamp}`);
311
+ lines.push(`模型: ${run.models.join(", ")}`);
312
+ lines.push("");
313
+ const byCategory = new Map();
314
+ for (const r of run.results) {
315
+ const cat = r.category || "未分类";
316
+ if (!byCategory.has(cat))
317
+ byCategory.set(cat, []);
318
+ byCategory.get(cat).push(r);
319
+ }
320
+ for (const [cat, results] of byCategory) {
321
+ lines.push(`## ${cat}`);
322
+ lines.push("");
323
+ const byQuestion = new Map();
324
+ for (const r of results) {
325
+ if (!byQuestion.has(r.questionId))
326
+ byQuestion.set(r.questionId, []);
327
+ byQuestion.get(r.questionId).push(r);
328
+ }
329
+ for (const [qId, qResults] of byQuestion) {
330
+ const prompt = qResults[0].prompt;
331
+ const maxScore = qResults[0].maxScore ?? 10;
332
+ lines.push(`### ${qId}`);
333
+ lines.push(`> ${prompt}`);
334
+ lines.push("");
335
+ // 表格: 模型 | 分数 | 延迟 | 输出token | 状态
336
+ const header = ["模型", `得分/${maxScore}`, "延迟", "输出tok", "状态"];
337
+ const rows = [];
338
+ for (const r of qResults) {
339
+ const modelLabel = r.providerName === PROVIDER_ID ? r.modelId : `${r.providerName}:${r.modelId}`;
340
+ const scoreStr = r.score !== undefined ? `${r.score}/${maxScore}` : "—";
341
+ rows.push([
342
+ modelLabel,
343
+ scoreStr,
344
+ formatDuration(r.latencyMs),
345
+ String(r.tokensOut),
346
+ r.error ? `❌ ${r.error.slice(0, 40)}` : "✅",
347
+ ]);
348
+ }
349
+ lines.push("```");
350
+ lines.push(renderTable(header, rows));
351
+ lines.push("```");
352
+ // 评分明细
353
+ const scored = qResults.filter((r) => r.scoreDetails && r.scoreDetails.length > 0);
354
+ if (scored.length > 0) {
355
+ lines.push("");
356
+ lines.push("<details><summary>评分明细</summary>");
357
+ lines.push("");
358
+ for (const r of scored) {
359
+ const modelLabel = r.providerName === PROVIDER_ID ? r.modelId : `${r.providerName}:${r.modelId}`;
360
+ lines.push(`**${modelLabel}:** ${r.score}/${maxScore}`);
361
+ for (const d of r.scoreDetails) {
362
+ const icon = d.passed ? "✅" : "❌";
363
+ lines.push(`- ${icon} ${d.check} (+${d.earned}/${d.points})`);
364
+ }
365
+ lines.push("");
366
+ }
367
+ lines.push("</details>");
368
+ lines.push("");
369
+ }
370
+ // 各模型回答
371
+ for (const r of qResults) {
372
+ const modelLabel = r.providerName === PROVIDER_ID ? r.modelId : `${r.providerName}:${r.modelId}`;
373
+ lines.push(`<details><summary>${modelLabel} 的回答</summary>`);
374
+ lines.push("");
375
+ lines.push("```");
376
+ lines.push(r.response.slice(0, 2000));
377
+ if (r.response.length > 2000)
378
+ lines.push("...(截断)");
379
+ lines.push("```");
380
+ lines.push("</details>");
381
+ lines.push("");
382
+ }
383
+ }
384
+ }
385
+ // 汇总 — 带分数
386
+ lines.push("## 汇总");
387
+ lines.push("");
388
+ const modelSummary = new Map();
389
+ for (const r of run.results) {
390
+ const key = r.providerName === PROVIDER_ID ? r.modelId : `${r.providerName}:${r.modelId}`;
391
+ if (!modelSummary.has(key))
392
+ modelSummary.set(key, { totalScore: 0, totalMax: 0, totalLatency: 0, totalCost: 0, errors: 0, total: 0 });
393
+ const s = modelSummary.get(key);
394
+ s.totalScore += r.score ?? 0;
395
+ s.totalMax += r.maxScore ?? 10;
396
+ s.totalLatency += r.latencyMs;
397
+ s.totalCost += r.costUsd;
398
+ if (r.error)
399
+ s.errors++;
400
+ s.total++;
401
+ }
402
+ const header = ["模型", "总分", "得分率", "平均延迟", "总花费", "错误率"];
403
+ const rows = [];
404
+ // 按总分排序
405
+ const sorted = [...modelSummary.entries()].sort((a, b) => b[1].totalScore - a[1].totalScore);
406
+ for (const [model, s] of sorted) {
407
+ const pct = s.totalMax > 0 ? ((s.totalScore / s.totalMax) * 100).toFixed(1) : "0.0";
408
+ rows.push([
409
+ model,
410
+ `${s.totalScore}/${s.totalMax}`,
411
+ `${pct}%`,
412
+ formatDuration(s.totalLatency / s.total),
413
+ costStr(s.totalCost),
414
+ s.errors > 0 ? `${((s.errors / s.total) * 100).toFixed(0)}%` : "0%",
415
+ ]);
416
+ }
417
+ lines.push("```");
418
+ lines.push(renderTable(header, rows));
419
+ lines.push("```");
420
+ return lines.join("\n");
421
+ }
422
+ // ─── 主命令 ───
423
+ export async function benchCommand(args) {
424
+ const sub = args[0];
425
+ if (!sub || sub === "help" || sub === "--help") {
426
+ console.log("");
427
+ console.log(" u1s1 bench — 模型质量基准测试(带自动评分)");
428
+ console.log("");
429
+ console.log(" 用法:");
430
+ console.log(" u1s1 bench list 列出可用的测试套件");
431
+ console.log(" u1s1 bench run <suite> [models..] 跑基准测试");
432
+ console.log(" u1s1 bench report <run-id> 查看报告");
433
+ console.log(" u1s1 bench compare <run-id1> <run-id2> 对比两次运行");
434
+ console.log("");
435
+ console.log(" 示例:");
436
+ console.log(" u1s1 bench run quick 用当前默认模型跑快速测试");
437
+ console.log(" u1s1 bench run full deepseek pro 用多个模型跑全面测试");
438
+ console.log(" u1s1 bench report latest 看最近一次报告");
439
+ console.log("");
440
+ return;
441
+ }
442
+ if (sub === "list") {
443
+ const suites = listSuites();
444
+ console.log("");
445
+ console.log(" Bench 套件:");
446
+ for (const s of suites) {
447
+ const tag = s.builtin ? "(内置)" : "(自定义)";
448
+ console.log(` ${s.name.padEnd(12)} ${s.description} ${tag}`);
449
+ }
450
+ console.log("");
451
+ console.log(" 自定义套件放 ~/.u1s1/bench/<name>.json,格式同内置 suite.json");
452
+ console.log("");
453
+ return;
454
+ }
455
+ if (sub === "run") {
456
+ const suiteName = args[1] ?? "quick";
457
+ const modelQueries = args.slice(2);
458
+ const cfg = loadConfig();
459
+ if (!cfg.apiKey) {
460
+ console.error(" 还没有登录,先 u1s1 login");
461
+ process.exit(1);
462
+ }
463
+ await loadCustomEndpoints(cfg);
464
+ let suite;
465
+ try {
466
+ suite = loadSuite(suiteName);
467
+ }
468
+ catch (e) {
469
+ console.error(` ${e instanceof Error ? e.message : e}`);
470
+ process.exit(1);
471
+ }
472
+ let targets;
473
+ function resolveModelTarget(query) {
474
+ const lower = query.toLowerCase();
475
+ const m = MODELS.find((x) => x.id.toLowerCase() === lower || x.aliases.includes(lower));
476
+ if (m)
477
+ return { id: m.id, label: m.id, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, source: "u1s1" };
478
+ for (const ep of CUSTOM_ENDPOINTS) {
479
+ const em = ep.models.find((x) => x.id.toLowerCase() === lower || x.aliases.includes(lower));
480
+ if (em) {
481
+ const shortId = em.id.includes("/") ? em.id.split("/")[1] : em.id;
482
+ return { id: shortId, label: `${ep.name}:${em.id}`, baseUrl: ep.baseUrl, apiKey: ep.apiKey ?? "", source: "custom" };
483
+ }
484
+ }
485
+ return { id: query, label: query, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, source: "u1s1" };
486
+ }
487
+ if (modelQueries.length > 0) {
488
+ targets = modelQueries.map((q) => resolveModelTarget(q)).filter((t) => t !== null);
489
+ }
490
+ else {
491
+ const { resolvePreferredModel } = await import("./config.js");
492
+ const pref = resolvePreferredModel(cfg);
493
+ if (pref.provider === PROVIDER_ID) {
494
+ targets = [{ id: pref.id, label: pref.id, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, source: "u1s1" }];
495
+ }
496
+ else {
497
+ const ep = CUSTOM_ENDPOINTS.find((e) => e.id === pref.provider);
498
+ if (ep) {
499
+ const shortId = pref.id.includes("/") ? pref.id.split("/")[1] : pref.id;
500
+ targets = [{ id: shortId, label: `${ep.name}:${pref.id}`, baseUrl: ep.baseUrl, apiKey: ep.apiKey ?? "", source: "custom" }];
501
+ }
502
+ else {
503
+ targets = [{ id: pref.id, label: pref.id, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, source: "u1s1" }];
504
+ }
505
+ }
506
+ }
507
+ console.log("");
508
+ console.log(` 📊 Bench: ${suite.name}`);
509
+ console.log(` ${suite.description}`);
510
+ console.log(` 题目数: ${suite.questions.length}`);
511
+ console.log(` 模型: ${targets.map((t) => t.label).join(", ")}`);
512
+ console.log("");
513
+ const results = [];
514
+ const total = suite.questions.length * targets.length;
515
+ let done = 0;
516
+ for (const target of targets) {
517
+ for (const q of suite.questions) {
518
+ const label = `${target.label} / ${q.id}`;
519
+ process.stdout.write(` [${++done}/${total}] ${label.padEnd(40)} `);
520
+ const resp = await callModel(target.baseUrl, target.apiKey, target.id, q.prompt);
521
+ const costUsd = estimateCost(target.id, resp.tokensIn, resp.tokensOut);
522
+ // 评分
523
+ let score;
524
+ let maxScore;
525
+ let scoreDetails;
526
+ if (!resp.error && q.checks && q.checks.length > 0) {
527
+ const ms = q.maxScore ?? 10;
528
+ const result = scoreResponse(resp.response, q.checks, ms);
529
+ score = result.score;
530
+ maxScore = ms;
531
+ scoreDetails = result.details;
532
+ }
533
+ results.push({
534
+ questionId: q.id,
535
+ category: q.category,
536
+ prompt: q.prompt,
537
+ modelId: target.label,
538
+ providerName: target.source === "u1s1" ? PROVIDER_ID : target.label,
539
+ response: resp.response,
540
+ latencyMs: resp.latencyMs,
541
+ tokensIn: resp.tokensIn,
542
+ tokensOut: resp.tokensOut,
543
+ costUsd,
544
+ error: resp.error,
545
+ score,
546
+ maxScore,
547
+ scoreDetails,
548
+ });
549
+ if (resp.error) {
550
+ console.log(`❌ ${resp.error.slice(0, 60)}`);
551
+ }
552
+ else if (score !== undefined) {
553
+ const bar = scoreBar(score, maxScore);
554
+ console.log(`✅ ${bar} ${score}/${maxScore} · ${formatDuration(resp.latencyMs)}`);
555
+ }
556
+ else {
557
+ console.log(`✅ ${formatDuration(resp.latencyMs)} · ${resp.tokensIn}→${resp.tokensOut} tok`);
558
+ }
559
+ }
560
+ }
561
+ const runId = `bench-${suite.name}-${Date.now()}`;
562
+ const run = {
563
+ id: runId,
564
+ timestamp: new Date().toISOString(),
565
+ suiteName: suite.name,
566
+ models: [...new Set(results.map((r) => r.modelId))],
567
+ results,
568
+ };
569
+ saveRun(run);
570
+ // 简易汇总(带分数)
571
+ console.log("");
572
+ console.log(" 📋 评分汇总:");
573
+ const byModel = new Map();
574
+ for (const r of results) {
575
+ const key = r.modelId;
576
+ if (!byModel.has(key))
577
+ byModel.set(key, { totalScore: 0, totalMax: 0, totalLatency: 0, totalCost: 0, ok: 0, fail: 0 });
578
+ const s = byModel.get(key);
579
+ s.totalScore += r.score ?? 0;
580
+ s.totalMax += r.maxScore ?? 10;
581
+ s.totalLatency += r.latencyMs;
582
+ s.totalCost += r.costUsd;
583
+ if (r.error)
584
+ s.fail++;
585
+ else
586
+ s.ok++;
587
+ }
588
+ const sumHeader = ["模型", "总分", "得分率", "平均延迟", "花费", "状态"];
589
+ const sumRows = [];
590
+ const sorted = [...byModel.entries()].sort((a, b) => b[1].totalScore - a[1].totalScore);
591
+ for (const [model, s] of sorted) {
592
+ const pct = s.totalMax > 0 ? ((s.totalScore / s.totalMax) * 100).toFixed(1) : "0.0";
593
+ sumRows.push([
594
+ model,
595
+ `${s.totalScore}/${s.totalMax}`,
596
+ `${pct}%`,
597
+ formatDuration(s.totalLatency / (s.ok + s.fail)),
598
+ costStr(s.totalCost),
599
+ s.fail > 0 ? `${((s.fail / (s.ok + s.fail)) * 100).toFixed(0)}%失败` : "✅全部通过",
600
+ ]);
601
+ }
602
+ console.log(" " + renderTable(sumHeader, sumRows));
603
+ console.log("");
604
+ console.log(` 查看完整报告: u1s1 bench report ${runId}`);
605
+ console.log("");
606
+ return;
607
+ }
608
+ if (sub === "report") {
609
+ const runId = args[1] ?? "latest";
610
+ let run;
611
+ try {
612
+ const id = runId === "latest" ? findLatestRun() : runId;
613
+ if (!id)
614
+ throw new Error("还没有运行记录");
615
+ run = loadRun(id);
616
+ }
617
+ catch (e) {
618
+ console.error(` ${e instanceof Error ? e.message : e}`);
619
+ process.exit(1);
620
+ }
621
+ const report = generateReport(run);
622
+ const reportFile = join(BENCH_DIR, `${run.id}.md`);
623
+ writeFileSync(reportFile, report);
624
+ console.log(report);
625
+ console.log(` 报告已保存: ${reportFile}`);
626
+ return;
627
+ }
628
+ if (sub === "compare" && args[1] && args[2]) {
629
+ try {
630
+ const run1 = loadRun(args[1]);
631
+ const run2 = loadRun(args[2]);
632
+ console.log("");
633
+ console.log(` 对比 ${run1.suiteName}(${run1.timestamp}) vs ${run2.suiteName}(${run2.timestamp})`);
634
+ console.log("");
635
+ const qIds1 = new Set(run1.results.map((r) => r.questionId));
636
+ const qIds2 = new Set(run2.results.map((r) => r.questionId));
637
+ const common = [...qIds1].filter((q) => qIds2.has(q));
638
+ for (const qId of common) {
639
+ console.log(` ── ${qId} ──`);
640
+ const r1 = run1.results.find((r) => r.questionId === qId);
641
+ const r2 = run2.results.find((r) => r.questionId === qId);
642
+ if (r1 && r2) {
643
+ const s1 = r1.score !== undefined ? `[${r1.score}/${r1.maxScore}]` : "";
644
+ const s2 = r2.score !== undefined ? `[${r2.score}/${r2.maxScore}]` : "";
645
+ console.log(` ${s1} ${r1.modelId}: ${r1.response.slice(0, 200)}`);
646
+ console.log(` ${s2} ${r2.modelId}: ${r2.response.slice(0, 200)}`);
647
+ console.log("");
648
+ }
649
+ }
650
+ }
651
+ catch (e) {
652
+ console.error(` ${e instanceof Error ? e.message : e}`);
653
+ process.exit(1);
654
+ }
655
+ return;
656
+ }
657
+ if (sub === "compare") {
658
+ console.log(" 用法: u1s1 bench compare <run-id1> <run-id2>");
659
+ return;
660
+ }
661
+ console.error(` 未知子命令: ${sub}`);
662
+ console.log(" u1s1 bench help 查看用法");
663
+ process.exit(1);
664
+ }
665
+ function findLatestRun() {
666
+ const runs = listRuns();
667
+ return runs[0]?.id ?? null;
668
+ }
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { execSync, spawnSync } from "node:child_process";
3
3
  import { writeFileSync } from "node:fs";
4
- import { cleanupBrandThemes, endpointKeyEnv, endpointProviderEntry, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, scrubForeignProviderEnv, toProviderModels, writeWebToolsExtension, } from "./agent-setup.js";
4
+ import { cleanupBrandThemes, endpointKeyEnv, endpointProviderEntry, ensureBrandPrompt, ensureDefaultSettings, ensureProviderModels, scrubForeignProviderEnv, toProviderModels, writeCompactUiExtension, writeWebToolsExtension, } from "./agent-setup.js";
5
5
  import { printConsoleBanner } from "./brand.js";
6
6
  import { agentDir, apiModelToDef, CUSTOM_ENDPOINTS, isPortableInstall, loadConfig, MODELS, persistPreferredModel, PROVIDER_ID, readSettings, refValid, resolvePreferredModel, setModelsFromApi, VERSION, } from "./config.js";
7
7
  import { ensureSearchTools } from "./search-tools.js";
@@ -169,6 +169,8 @@ async function runAgent(cfg, args) {
169
169
  webFetchRender: webFetchRenderEnabled,
170
170
  imageGen: imageGenEnabled,
171
171
  });
172
+ // 精简 UI:隐藏工具调用 + 汇总行 + 隐藏思考标签(同样投影到 agentDir/extensions)
173
+ writeCompactUiExtension();
172
174
  ensureTmuxKeyboardProtocol();
173
175
  // must be set before pi reads them (getAgentDir() reads at call time, env at import is fine too)
174
176
  process.env["PI_CODING_AGENT_DIR"] = agentDir;
@@ -213,6 +215,24 @@ async function runAgent(cfg, args) {
213
215
  factory: (pi) => {
214
216
  applyBrandUi(pi, VERSION);
215
217
  offerStarterTemplates(pi);
218
+ // 会话标题:拿用户第一条正经输入的前半截当名字(像 Claude Code 那样),
219
+ // /resume 的会话列表里就能一眼认出每个会话。/clear 后 session_start
220
+ // 会重置标记,新会话重新起标题。
221
+ let sessionTitled = false;
222
+ pi.on("session_start", () => {
223
+ sessionTitled = false;
224
+ });
225
+ pi.on("input", async (event) => {
226
+ if (sessionTitled || event.source === "extension")
227
+ return;
228
+ const text = event.text.trim().replace(/\s+/g, " ");
229
+ // 空输入(纯图片粘贴)和斜杠命令不当标题
230
+ if (!text || text.startsWith("/"))
231
+ return;
232
+ sessionTitled = true;
233
+ const title = text.length > 30 ? `${text.slice(0, 30)}…` : text;
234
+ pi.setSessionName(title);
235
+ });
216
236
  // pi 内置只有 /quit;补一个更常见的 /exit,方便新手退出
217
237
  pi.registerCommand("exit", {
218
238
  description: "退出 u1s1",
@@ -283,7 +303,7 @@ async function run() {
283
303
  }
284
304
  if (cmd === "--help" || cmd === "-h") {
285
305
  printConsoleBanner(VERSION);
286
- console.log(" u1s1 命令:web(浏览器网页版)· web shortcut(桌面图标)· deploy(发布网页)· login / logout · model · usage · update · import");
306
+ console.log(" u1s1 命令:web(浏览器网页版)· web shortcut(桌面图标)· deploy(发布网页)· login / logout · model · usage · update · import · bench");
287
307
  console.log("");
288
308
  }
289
309
  if (cmd === "web") {
@@ -337,6 +357,11 @@ async function run() {
337
357
  await importCommand(args.slice(1));
338
358
  return;
339
359
  }
360
+ if (cmd === "bench") {
361
+ const { benchCommand } = await import("./bench.js");
362
+ await benchCommand(args.slice(1));
363
+ return;
364
+ }
340
365
  const { ensureAuth } = await import("./login.js");
341
366
  const cfg = await ensureAuth();
342
367
  // 在后台检查更新(非阻塞,不影响启动速度)
package/dist/tools.js CHANGED
@@ -174,7 +174,8 @@ export function createImageTool(cfg) {
174
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
175
  })),
176
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".',
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.",
178
179
  })),
179
180
  save_path: Type.Optional(Type.String({
180
181
  description: "Where to save the image (relative to cwd). Defaults to image-<timestamp>.<ext> in the current directory.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,9 @@
15
15
  },
16
16
  "files": [
17
17
  "dist",
18
- "webui-dist"
18
+ "webui-dist",
19
+ "bench",
20
+ "scripts"
19
21
  ],
20
22
  "engines": {
21
23
  "node": ">=22.19.0"
@@ -25,6 +27,7 @@
25
27
  "dev": "tsx src/index.ts",
26
28
  "typecheck": "tsc --noEmit",
27
29
  "prepublishOnly": "npm run build",
30
+ "postinstall": "node scripts/patch-pi.js",
28
31
  "package": "bash ../../scripts/package.sh",
29
32
  "upload-release": "bash ../../scripts/upload-release.sh",
30
33
  "release": "npm run package && npm run upload-release"
@@ -0,0 +1,85 @@
1
+ /**
2
+ * postinstall 补丁:给 @earendil-works/pi-coding-agent 打「精简 UI」补丁。
3
+ *
4
+ * 为什么存在:仓库里用 pnpm patch(patches/*.patch)对开发态生效,但终端用户
5
+ * 是 `npm i -g u1s1-cli` 装的,npm 不认 pnpm 的补丁机制。这个脚本在用户安装后
6
+ * 对 node_modules 里的 pi 做同样的字符串替换,保证空行修复对所有安装方式生效。
7
+ *
8
+ * 特性:
9
+ * - 纯 Node 实现,不依赖 patch(1),Windows 可跑
10
+ * - 幂等:已打过(含 pnpm patch 打过)直接跳过
11
+ * - 永不让安装失败:任何异常只提示,exit 0
12
+ */
13
+
14
+ import { readFileSync, writeFileSync } from "node:fs";
15
+ import { join, dirname } from "node:path";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
19
+ const target = join(
20
+ pkgRoot,
21
+ "node_modules",
22
+ "@earendil-works",
23
+ "pi-coding-agent",
24
+ "dist",
25
+ "modes",
26
+ "interactive",
27
+ "components",
28
+ "assistant-message.js",
29
+ );
30
+
31
+ // 每条 [原文, 替换后];与 patches/@earendil-works__pi-coding-agent@0.84.2.patch 等效
32
+ const REPLACEMENTS = [
33
+ // 隐藏的思考块不算「可见内容」(消息开头不再加空行)
34
+ [
35
+ '|| (c.type === "thinking" && c.thinking.trim())',
36
+ '|| (c.type === "thinking" && !this.hideThinkingBlock && c.thinking.trim())',
37
+ ],
38
+ // 折叠标签为空字符串时完全跳过(ANSI 包着的空串仍会画出一整行空白)
39
+ [
40
+ 'this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), this.outputPad, 0));',
41
+ 'if (this.hiddenThinkingLabel !== "") { this.contentContainer.addChild(new Text(theme.italic(theme.fg("thinkingText", this.hiddenThinkingLabel)), this.outputPad, 0)); }',
42
+ ],
43
+ // 思考块完全隐形时,不再加它后面的间隔空行
44
+ [
45
+ "if (hasVisibleContentAfter) {",
46
+ 'if (hasVisibleContentAfter && !(this.hideThinkingBlock && this.hiddenThinkingLabel === "")) {',
47
+ ],
48
+ ];
49
+
50
+ try {
51
+ let text;
52
+ try {
53
+ text = readFileSync(target, "utf8");
54
+ } catch {
55
+ // 找不到 pi 就静默退出(不该发生:postinstall 在依赖装完后跑)
56
+ process.exit(0);
57
+ }
58
+
59
+ if (text.includes("!this.hideThinkingBlock && c.thinking.trim()")) {
60
+ // 已打过(pnpm patch 或上次运行),幂等跳过
61
+ process.exit(0);
62
+ }
63
+
64
+ let applied = 0;
65
+ for (const [from, to] of REPLACEMENTS) {
66
+ if (text.includes(to)) {
67
+ applied++;
68
+ continue;
69
+ }
70
+ if (!text.includes(from)) continue;
71
+ text = text.split(from).join(to);
72
+ applied++;
73
+ }
74
+
75
+ if (applied < REPLACEMENTS.length) {
76
+ console.log(
77
+ `[u1s1] 提示: pi 版本可能已更新,精简 UI 空行补丁只应用了 ${applied}/${REPLACEMENTS.length} 处(不影响使用)`,
78
+ );
79
+ }
80
+
81
+ writeFileSync(target, text);
82
+ } catch (err) {
83
+ console.log(`[u1s1] 提示: 精简 UI 空行补丁未生效(${err?.message ?? err}),不影响使用`);
84
+ }
85
+ process.exit(0);