u1s1-cli 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bench/suite.json CHANGED
@@ -6,12 +6,26 @@
6
6
  {
7
7
  "id": "hello",
8
8
  "category": "基础",
9
- "prompt": "只说一句「你好,我是 u1s1」就行,不要多余的话。"
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
+ ]
10
16
  },
11
17
  {
12
18
  "id": "reverse",
13
19
  "category": "编码",
14
- "prompt": "写一个 TypeScript 函数,把字符串里的单词顺序反转(不是字符反转)。示例: 'hello world' → 'world hello'。只给代码,不要解释。"
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
+ ]
15
29
  }
16
30
  ]
17
31
  },
@@ -22,62 +36,157 @@
22
36
  {
23
37
  "id": "fizzbuzz",
24
38
  "category": "编码",
25
- "prompt": "用 TypeScript 写 fizzbuzz,从 1 到 100,3 的倍数打印 fizz,5 的倍数打印 buzz,同时是 3 和 5 的倍数打印 fizzbuzz。只给代码,不要解释。"
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
+ ]
26
49
  },
27
50
  {
28
51
  "id": "sort-algo",
29
52
  "category": "编码",
30
- "prompt": "用 TypeScript 实现一个快速排序(原地排序,不创建新数组)。只给代码,不要解释。"
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
+ ]
31
63
  },
32
64
  {
33
65
  "id": "react-component",
34
66
  "category": "编码",
35
- "prompt": "写一个 React 计数器组件(TypeScript),包含:+ 按钮、- 按钮、重置按钮,显示当前数值。用 useState。只给代码,不要解释。"
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
+ ]
36
79
  },
37
80
  {
38
81
  "id": "regex",
39
82
  "category": "编码",
40
- "prompt": "写一个 JavaScript 正则表达式,匹配中国大陆手机号(11 位,1 开头,第二位 3-9)。只给正则,不要解释。"
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
+ ]
41
92
  },
42
93
  {
43
94
  "id": "reasoning-1",
44
95
  "category": "推理",
45
- "prompt": "有三个箱子:一个只装苹果,一个只装橘子,一个混装。所有标签都贴错了。你只能从一个箱子里拿一个水果看,就能推断出所有箱子的正确内容。请问该从哪个箱子拿?为什么?"
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
+ ]
46
104
  },
47
105
  {
48
106
  "id": "reasoning-2",
49
107
  "category": "推理",
50
- "prompt": "一个人花 8 元买了一只鸡,9 元卖出,10 元买回,11 元卖出。他赚了多少钱?一步步算。"
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
+ ]
51
117
  },
52
118
  {
53
119
  "id": "zh-explain",
54
120
  "category": "中文",
55
- "prompt": "用一句话给完全不懂编程的人解释什么是「递归」。说得通俗一点。"
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
+ ]
56
130
  },
57
131
  {
58
132
  "id": "zh-poem",
59
133
  "category": "中文",
60
- "prompt": "以「AI」为主题写一首五言绝句(每句五个字,共四句)"
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
+ ]
61
142
  },
62
143
  {
63
144
  "id": "instruction-follow",
64
145
  "category": "指令遵循",
65
- "prompt": "你的回答里只能包含以下三个词(可以重复):好、的、行。其他任何词都不许出现。开始:今天天气怎么样?"
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
+ ]
66
152
  },
67
153
  {
68
154
  "id": "format-json",
69
155
  "category": "指令遵循",
70
- "prompt": "输出一个 JSON 对象,包含 name(你的名字)、version(当前日期)、models(数组,随便列三个模型名)。只输出 JSON,不要 markdown 代码块标记。"
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
+ ]
71
165
  },
72
166
  {
73
167
  "id": "context-length",
74
168
  "category": "长上下文",
75
- "prompt": "请重复以下句子 50 遍:「u1s1 有一说一。」然后告诉我你一共输出了多少遍。"
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
+ ]
76
176
  },
77
177
  {
78
178
  "id": "translation",
79
179
  "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.'"
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
+ ]
81
190
  }
82
191
  ]
83
192
  }
@@ -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。
package/dist/bench.js CHANGED
@@ -21,14 +21,12 @@ function readSuitesFile(filePath) {
21
21
  }
22
22
  /** 读内置或用户自定义的 suite */
23
23
  function loadSuite(name) {
24
- // 先找内置的(suite.json 含多个 suite,按 name 匹配)
25
24
  const builtinPath = join(benchDataDir(), "suite.json");
26
25
  if (existsSync(builtinPath)) {
27
26
  const found = findSuite(readSuitesFile(builtinPath), name);
28
27
  if (found)
29
28
  return found;
30
29
  }
31
- // 再找用户的 ~/.u1s1/bench/<name>.json(单文件单 suite,沿用 suite.json 数组格式)
32
30
  const userPath = join(BENCH_DIR, `${name}.json`);
33
31
  if (existsSync(userPath)) {
34
32
  const found = findSuite(readSuitesFile(userPath), name);
@@ -40,7 +38,6 @@ function loadSuite(name) {
40
38
  /** 列出可用的 suite */
41
39
  function listSuites() {
42
40
  const suites = [];
43
- // 内置
44
41
  const builtinPath = join(benchDataDir(), "suite.json");
45
42
  if (existsSync(builtinPath)) {
46
43
  const raw = JSON.parse(readFileSync(builtinPath, "utf8"));
@@ -48,7 +45,6 @@ function listSuites() {
48
45
  suites.push({ name: s.name, description: s.description, builtin: true });
49
46
  }
50
47
  }
51
- // 用户自定义
52
48
  if (existsSync(BENCH_DIR)) {
53
49
  try {
54
50
  const files = readdirSync(BENCH_DIR).filter((f) => f.endsWith(".json"));
@@ -80,91 +76,204 @@ function costStr(usd) {
80
76
  return "<$0.001";
81
77
  return `$${usd.toFixed(4)}`;
82
78
  }
83
- /** 排列表格:每列对齐 */
79
+ /** 排列表格 */
84
80
  function renderTable(header, rows) {
85
81
  const colW = header.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i]?.length ?? 0)));
86
82
  const sep = "─".repeat(colW.reduce((a, b) => a + b + 3, 1));
87
83
  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");
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
+ }
93
220
  }
94
221
  // ─── 模型调用 ───
95
222
  async function callModel(baseUrl, apiKey, modelId, prompt) {
96
223
  const start = performance.now();
97
- // 简单判断是不是推理模型 — deepseek-reasoner、grok 带 reasoning
98
224
  const isReasoning = /reasoner|grok/i.test(modelId);
99
225
  const body = {
100
226
  model: modelId,
101
227
  messages: [{ role: "user", content: prompt }],
102
228
  max_tokens: 4096,
103
- // 推理模型不需要 temperature,传了可能报错
104
229
  ...(isReasoning ? {} : { temperature: 0.3 }),
105
230
  stream: false,
106
231
  };
107
- const headers = {
108
- "content-type": "application/json",
109
- };
110
- if (apiKey) {
232
+ const headers = { "content-type": "application/json" };
233
+ if (apiKey)
111
234
  headers["authorization"] = `Bearer ${apiKey}`;
112
- }
113
235
  try {
114
236
  const res = await fetch(`${baseUrl}/chat/completions`, {
115
237
  method: "POST",
116
238
  headers,
117
239
  body: JSON.stringify(body),
118
- signal: AbortSignal.timeout(120_000), // 2 分钟超时
240
+ signal: AbortSignal.timeout(120_000),
119
241
  });
120
242
  const latencyMs = Math.round(performance.now() - start);
121
243
  if (!res.ok) {
122
244
  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
- };
245
+ return { response: "", latencyMs, tokensIn: 0, tokensOut: 0, error: `HTTP ${res.status}: ${errBody.slice(0, 200)}` };
130
246
  }
131
247
  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 };
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
+ };
136
254
  }
137
255
  catch (e) {
138
256
  const latencyMs = Math.round(performance.now() - start);
139
257
  return {
140
- response: "",
141
- latencyMs,
142
- tokensIn: 0,
143
- tokensOut: 0,
258
+ response: "", latencyMs, tokensIn: 0, tokensOut: 0,
144
259
  error: e instanceof Error ? e.message : String(e),
145
260
  };
146
261
  }
147
262
  }
148
- /** 找一个模型的 cost 信息(用于计费估算) */
149
263
  function findModelCost(modelId) {
150
- for (const m of MODELS) {
264
+ for (const m of MODELS)
151
265
  if (m.id === modelId)
152
266
  return m.cost;
153
- }
154
- for (const ep of CUSTOM_ENDPOINTS) {
155
- for (const m of ep.models) {
267
+ for (const ep of CUSTOM_ENDPOINTS)
268
+ for (const m of ep.models)
156
269
  if (m.id === modelId)
157
270
  return m.cost;
158
- }
159
- }
160
271
  return null;
161
272
  }
162
- /** 估算花费 */
163
273
  function estimateCost(modelId, tokensIn, tokensOut) {
164
274
  const cost = findModelCost(modelId);
165
275
  if (!cost)
166
276
  return 0;
167
- // cost 是每百万 token 的美元价格
168
277
  return (tokensIn * cost.input + tokensOut * cost.output) / 1_000_000;
169
278
  }
170
279
  // ─── 保存 & 报告 ───
@@ -180,7 +289,6 @@ function loadRun(id) {
180
289
  throw new Error(`找不到运行记录「${id}」`);
181
290
  return JSON.parse(readFileSync(file, "utf8"));
182
291
  }
183
- /** 列出最近的运行记录 */
184
292
  function listRuns() {
185
293
  if (!existsSync(BENCH_DIR))
186
294
  return [];
@@ -191,7 +299,7 @@ function listRuns() {
191
299
  const run = JSON.parse(readFileSync(join(BENCH_DIR, f), "utf8"));
192
300
  runs.push({ id: run.id, suiteName: run.suiteName, timestamp: run.timestamp, models: run.models });
193
301
  }
194
- catch { /* 跳过损坏记录 */ }
302
+ catch { /* 跳过损坏 */ }
195
303
  }
196
304
  return runs;
197
305
  }
@@ -202,7 +310,6 @@ function generateReport(run) {
202
310
  lines.push(`运行时间: ${run.timestamp}`);
203
311
  lines.push(`模型: ${run.models.join(", ")}`);
204
312
  lines.push("");
205
- // 按 category 分组
206
313
  const byCategory = new Map();
207
314
  for (const r of run.results) {
208
315
  const cat = r.category || "未分类";
@@ -213,7 +320,6 @@ function generateReport(run) {
213
320
  for (const [cat, results] of byCategory) {
214
321
  lines.push(`## ${cat}`);
215
322
  lines.push("");
216
- // 按 question 分组
217
323
  const byQuestion = new Map();
218
324
  for (const r of results) {
219
325
  if (!byQuestion.has(r.questionId))
@@ -222,34 +328,52 @@ function generateReport(run) {
222
328
  }
223
329
  for (const [qId, qResults] of byQuestion) {
224
330
  const prompt = qResults[0].prompt;
331
+ const maxScore = qResults[0].maxScore ?? 10;
225
332
  lines.push(`### ${qId}`);
226
333
  lines.push(`> ${prompt}`);
227
334
  lines.push("");
228
- // 表格:模型 | 延迟 | 输入 | 输出 | 花费 | 错误
229
- const header = ["模型", "延迟", "输入token", "输出token", "花费", "状态"];
335
+ // 表格: 模型 | 分数 | 延迟 | 输出token | 状态
336
+ const header = ["模型", `得分/${maxScore}`, "延迟", "输出tok", "状态"];
230
337
  const rows = [];
231
338
  for (const r of qResults) {
232
339
  const modelLabel = r.providerName === PROVIDER_ID ? r.modelId : `${r.providerName}:${r.modelId}`;
340
+ const scoreStr = r.score !== undefined ? `${r.score}/${maxScore}` : "—";
233
341
  rows.push([
234
342
  modelLabel,
343
+ scoreStr,
235
344
  formatDuration(r.latencyMs),
236
- String(r.tokensIn),
237
345
  String(r.tokensOut),
238
- costStr(r.costUsd),
239
- r.error ? `❌ ${r.error}` : "✅",
346
+ r.error ? `❌ ${r.error.slice(0, 40)}` : "✅",
240
347
  ]);
241
348
  }
242
349
  lines.push("```");
243
350
  lines.push(renderTable(header, rows));
244
351
  lines.push("```");
245
- lines.push("");
246
- // 各模型的回答
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
+ // 各模型回答
247
371
  for (const r of qResults) {
248
372
  const modelLabel = r.providerName === PROVIDER_ID ? r.modelId : `${r.providerName}:${r.modelId}`;
249
373
  lines.push(`<details><summary>${modelLabel} 的回答</summary>`);
250
374
  lines.push("");
251
375
  lines.push("```");
252
- lines.push(r.response.slice(0, 2000)); // 截断避免报告过大
376
+ lines.push(r.response.slice(0, 2000));
253
377
  if (r.response.length > 2000)
254
378
  lines.push("...(截断)");
255
379
  lines.push("```");
@@ -258,27 +382,33 @@ function generateReport(run) {
258
382
  }
259
383
  }
260
384
  }
261
- // 汇总
385
+ // 汇总 — 带分数
262
386
  lines.push("## 汇总");
263
387
  lines.push("");
264
388
  const modelSummary = new Map();
265
389
  for (const r of run.results) {
266
390
  const key = r.providerName === PROVIDER_ID ? r.modelId : `${r.providerName}:${r.modelId}`;
267
391
  if (!modelSummary.has(key))
268
- modelSummary.set(key, { totalLatency: 0, totalCost: 0, errors: 0, total: 0 });
392
+ modelSummary.set(key, { totalScore: 0, totalMax: 0, totalLatency: 0, totalCost: 0, errors: 0, total: 0 });
269
393
  const s = modelSummary.get(key);
394
+ s.totalScore += r.score ?? 0;
395
+ s.totalMax += r.maxScore ?? 10;
270
396
  s.totalLatency += r.latencyMs;
271
397
  s.totalCost += r.costUsd;
272
398
  if (r.error)
273
399
  s.errors++;
274
400
  s.total++;
275
401
  }
276
- const header = ["模型", "总延迟", "平均延迟", "总花费", "错误率"];
402
+ const header = ["模型", "总分", "得分率", "平均延迟", "总花费", "错误率"];
277
403
  const rows = [];
278
- for (const [model, s] of modelSummary) {
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";
279
408
  rows.push([
280
409
  model,
281
- formatDuration(s.totalLatency),
410
+ `${s.totalScore}/${s.totalMax}`,
411
+ `${pct}%`,
282
412
  formatDuration(s.totalLatency / s.total),
283
413
  costStr(s.totalCost),
284
414
  s.errors > 0 ? `${((s.errors / s.total) * 100).toFixed(0)}%` : "0%",
@@ -294,7 +424,7 @@ export async function benchCommand(args) {
294
424
  const sub = args[0];
295
425
  if (!sub || sub === "help" || sub === "--help") {
296
426
  console.log("");
297
- console.log(" u1s1 bench — 模型质量基准测试");
427
+ console.log(" u1s1 bench — 模型质量基准测试(带自动评分)");
298
428
  console.log("");
299
429
  console.log(" 用法:");
300
430
  console.log(" u1s1 bench list 列出可用的测试套件");
@@ -304,7 +434,7 @@ export async function benchCommand(args) {
304
434
  console.log("");
305
435
  console.log(" 示例:");
306
436
  console.log(" u1s1 bench run quick 用当前默认模型跑快速测试");
307
- console.log(" u1s1 bench run full deepseek grok 用两个模型跑全面测试");
437
+ console.log(" u1s1 bench run full deepseek pro 用多个模型跑全面测试");
308
438
  console.log(" u1s1 bench report latest 看最近一次报告");
309
439
  console.log("");
310
440
  return;
@@ -330,9 +460,7 @@ export async function benchCommand(args) {
330
460
  console.error(" 还没有登录,先 u1s1 login");
331
461
  process.exit(1);
332
462
  }
333
- // 加载端点配置
334
463
  await loadCustomEndpoints(cfg);
335
- // 加载测试套件
336
464
  let suite;
337
465
  try {
338
466
  suite = loadSuite(suiteName);
@@ -344,28 +472,22 @@ export async function benchCommand(args) {
344
472
  let targets;
345
473
  function resolveModelTarget(query) {
346
474
  const lower = query.toLowerCase();
347
- // u1s1 官方模型
348
475
  const m = MODELS.find((x) => x.id.toLowerCase() === lower || x.aliases.includes(lower));
349
- if (m) {
476
+ if (m)
350
477
  return { id: m.id, label: m.id, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, source: "u1s1" };
351
- }
352
- // 自定义端点
353
478
  for (const ep of CUSTOM_ENDPOINTS) {
354
479
  const em = ep.models.find((x) => x.id.toLowerCase() === lower || x.aliases.includes(lower));
355
480
  if (em) {
356
- // 自定义端点传模型短 ID(本地 Ollama 等不认带前缀的 huihui_ai/xxx)
357
481
  const shortId = em.id.includes("/") ? em.id.split("/")[1] : em.id;
358
482
  return { id: shortId, label: `${ep.name}:${em.id}`, baseUrl: ep.baseUrl, apiKey: ep.apiKey ?? "", source: "custom" };
359
483
  }
360
484
  }
361
- // 直接当 ID 用
362
485
  return { id: query, label: query, baseUrl: cfg.baseUrl, apiKey: cfg.apiKey, source: "u1s1" };
363
486
  }
364
487
  if (modelQueries.length > 0) {
365
488
  targets = modelQueries.map((q) => resolveModelTarget(q)).filter((t) => t !== null);
366
489
  }
367
490
  else {
368
- // 默认:用当前首选模型
369
491
  const { resolvePreferredModel } = await import("./config.js");
370
492
  const pref = resolvePreferredModel(cfg);
371
493
  if (pref.provider === PROVIDER_ID) {
@@ -382,14 +504,12 @@ export async function benchCommand(args) {
382
504
  }
383
505
  }
384
506
  }
385
- // 确认
386
507
  console.log("");
387
508
  console.log(` 📊 Bench: ${suite.name}`);
388
509
  console.log(` ${suite.description}`);
389
510
  console.log(` 题目数: ${suite.questions.length}`);
390
511
  console.log(` 模型: ${targets.map((t) => t.label).join(", ")}`);
391
512
  console.log("");
392
- // 跑
393
513
  const results = [];
394
514
  const total = suite.questions.length * targets.length;
395
515
  let done = 0;
@@ -397,31 +517,47 @@ export async function benchCommand(args) {
397
517
  for (const q of suite.questions) {
398
518
  const label = `${target.label} / ${q.id}`;
399
519
  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);
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
+ }
402
533
  results.push({
403
534
  questionId: q.id,
404
535
  category: q.category,
405
536
  prompt: q.prompt,
406
537
  modelId: target.label,
407
538
  providerName: target.source === "u1s1" ? PROVIDER_ID : target.label,
408
- response: result.response,
409
- latencyMs: result.latencyMs,
410
- tokensIn: result.tokensIn,
411
- tokensOut: result.tokensOut,
539
+ response: resp.response,
540
+ latencyMs: resp.latencyMs,
541
+ tokensIn: resp.tokensIn,
542
+ tokensOut: resp.tokensOut,
412
543
  costUsd,
413
- error: result.error,
414
- thinking: result.thinking,
544
+ error: resp.error,
545
+ score,
546
+ maxScore,
547
+ scoreDetails,
415
548
  });
416
- if (result.error) {
417
- console.log(`❌ ${result.error.slice(0, 60)}`);
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)}`);
418
555
  }
419
556
  else {
420
- console.log(`✅ ${formatDuration(result.latencyMs)} · ${result.tokensIn}→${result.tokensOut} tok · ${costStr(costUsd)}`);
557
+ console.log(`✅ ${formatDuration(resp.latencyMs)} · ${resp.tokensIn}→${resp.tokensOut} tok`);
421
558
  }
422
559
  }
423
560
  }
424
- // 保存
425
561
  const runId = `bench-${suite.name}-${Date.now()}`;
426
562
  const run = {
427
563
  id: runId,
@@ -431,26 +567,39 @@ export async function benchCommand(args) {
431
567
  results,
432
568
  };
433
569
  saveRun(run);
434
- // 打印简易报告
570
+ // 简易汇总(带分数)
435
571
  console.log("");
436
- console.log(" 📋 简易汇总:");
572
+ console.log(" 📋 评分汇总:");
437
573
  const byModel = new Map();
438
574
  for (const r of results) {
439
575
  const key = r.modelId;
440
576
  if (!byModel.has(key))
441
- byModel.set(key, { ok: 0, fail: 0, totalLatency: 0, totalCost: 0 });
577
+ byModel.set(key, { totalScore: 0, totalMax: 0, totalLatency: 0, totalCost: 0, ok: 0, fail: 0 });
442
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;
443
583
  if (r.error)
444
584
  s.fail++;
445
585
  else
446
586
  s.ok++;
447
- s.totalLatency += r.latencyMs;
448
- s.totalCost += r.costUsd;
449
587
  }
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)}`);
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
+ ]);
453
601
  }
602
+ console.log(" " + renderTable(sumHeader, sumRows));
454
603
  console.log("");
455
604
  console.log(` 查看完整报告: u1s1 bench report ${runId}`);
456
605
  console.log("");
@@ -470,7 +619,6 @@ export async function benchCommand(args) {
470
619
  process.exit(1);
471
620
  }
472
621
  const report = generateReport(run);
473
- // 写入可读文件
474
622
  const reportFile = join(BENCH_DIR, `${run.id}.md`);
475
623
  writeFileSync(reportFile, report);
476
624
  console.log(report);
@@ -484,7 +632,6 @@ export async function benchCommand(args) {
484
632
  console.log("");
485
633
  console.log(` 对比 ${run1.suiteName}(${run1.timestamp}) vs ${run2.suiteName}(${run2.timestamp})`);
486
634
  console.log("");
487
- // 找共同题目,并排展示
488
635
  const qIds1 = new Set(run1.results.map((r) => r.questionId));
489
636
  const qIds2 = new Set(run2.results.map((r) => r.questionId));
490
637
  const common = [...qIds1].filter((q) => qIds2.has(q));
@@ -493,8 +640,10 @@ export async function benchCommand(args) {
493
640
  const r1 = run1.results.find((r) => r.questionId === qId);
494
641
  const r2 = run2.results.find((r) => r.questionId === qId);
495
642
  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)}`);
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)}`);
498
647
  console.log("");
499
648
  }
500
649
  }
@@ -513,7 +662,6 @@ export async function benchCommand(args) {
513
662
  console.log(" u1s1 bench help 查看用法");
514
663
  process.exit(1);
515
664
  }
516
- /** 找最近一次运行的 id */
517
665
  function findLatestRun() {
518
666
  const runs = listRuns();
519
667
  return runs[0]?.id ?? null;
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
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
+ import { registerLoopCommand } from "./loop.js";
7
8
  import { ensureSearchTools } from "./search-tools.js";
8
9
  import { ensureUsableShell } from "./shell-doctor.js";
9
10
  import { applyBrandUi, setUpdateNotice } from "./style.js";
@@ -169,6 +170,8 @@ async function runAgent(cfg, args) {
169
170
  webFetchRender: webFetchRenderEnabled,
170
171
  imageGen: imageGenEnabled,
171
172
  });
173
+ // 精简 UI:隐藏工具调用 + 汇总行 + 隐藏思考标签(同样投影到 agentDir/extensions)
174
+ writeCompactUiExtension();
172
175
  ensureTmuxKeyboardProtocol();
173
176
  // must be set before pi reads them (getAgentDir() reads at call time, env at import is fine too)
174
177
  process.env["PI_CODING_AGENT_DIR"] = agentDir;
@@ -213,6 +216,8 @@ async function runAgent(cfg, args) {
213
216
  factory: (pi) => {
214
217
  applyBrandUi(pi, VERSION);
215
218
  offerStarterTemplates(pi);
219
+ // /loop: 会话级定时循环执行提示词(对标 Claude Code),实现见 loop.ts
220
+ registerLoopCommand(pi);
216
221
  // 会话标题:拿用户第一条正经输入的前半截当名字(像 Claude Code 那样),
217
222
  // /resume 的会话列表里就能一眼认出每个会话。/clear 后 session_start
218
223
  // 会重置标记,新会话重新起标题。
package/dist/loop.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export declare function registerLoopCommand(pi: ExtensionAPI): void;
package/dist/loop.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * /loop —— 会话级定时循环执行提示词(对标 Claude Code 的 /loop)。
3
+ *
4
+ * 用法:
5
+ * /loop 5m 检查部署状态 每 5 分钟跑一次
6
+ * /loop 检查构建 every 2h 间隔放后面也行
7
+ * /loop 盯一下 PR 不写间隔,默认 10 分钟
8
+ * /loop 20m /review-pr 1234 循环里也能跑其他斜杠命令
9
+ * /loop stop 停止当前循环
10
+ *
11
+ * 语义与 Claude Code 对齐:任务只活在当前会话,/clear 或退出即消失。
12
+ * 实现核心是 pi.sendUserMessage():定时器到点就"替用户打字"发一轮,
13
+ * deliverAs: "followUp" 保证上一轮没跑完时排队等它结束,不硬插打断;
14
+ * expandPromptTemplates: true 让 prompt 里的斜杠命令也能被展开执行。
15
+ */
16
+ const DEFAULT_INTERVAL_MS = 10 * 60_000;
17
+ const STATUS_KEY = "loop";
18
+ /** 状态栏/通知里提示词最多显示这么长,防止长 prompt 把 UI 撑爆 */
19
+ const PROMPT_PREVIEW_MAX = 40;
20
+ let timer;
21
+ let activePrompt = "";
22
+ let activeIntervalMs = DEFAULT_INTERVAL_MS;
23
+ /** "30s"/"5m"/"2h"/"1d" → 毫秒;不合法返回 undefined */
24
+ function parseIntervalMs(token) {
25
+ const m = /^(\d+)([smhd])$/i.exec(token);
26
+ if (!m)
27
+ return undefined;
28
+ const n = Number(m[1]);
29
+ if (!Number.isFinite(n) || n <= 0)
30
+ return undefined;
31
+ const unitMs = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2].toLowerCase()];
32
+ return n * unitMs;
33
+ }
34
+ function formatInterval(ms) {
35
+ if (ms >= 3_600_000 && ms % 3_600_000 === 0) {
36
+ return `${ms / 3_600_000}小时`;
37
+ }
38
+ if (ms % 60_000 === 0) {
39
+ return `${ms / 60_000}分钟`;
40
+ }
41
+ return `${Math.round(ms / 1_000)}秒`;
42
+ }
43
+ function preview(text) {
44
+ return text.length > PROMPT_PREVIEW_MAX ? `${text.slice(0, PROMPT_PREVIEW_MAX)}…` : text;
45
+ }
46
+ function stopLoop(ui) {
47
+ if (timer)
48
+ clearInterval(timer);
49
+ timer = undefined;
50
+ activePrompt = "";
51
+ ui?.setStatus(STATUS_KEY, undefined);
52
+ }
53
+ /**
54
+ * 解析出 [间隔?, 提示词]。间隔可放在最前或最后(Claude Code 同款规则),
55
+ * 没写就用默认 10 分钟。解析失败返回 null 并已通过 notify 报错。
56
+ */
57
+ function parseArgs(ui, raw) {
58
+ const tokens = raw.trim().split(/\s+/).filter(Boolean);
59
+ let intervalMs;
60
+ let intervalIdx = -1;
61
+ // 末尾的 "every 30m" 形式占两个 token,先于前置匹配检查
62
+ if (tokens.length >= 2 && tokens[tokens.length - 2].toLowerCase() === "every") {
63
+ const parsed = parseIntervalMs(tokens[tokens.length - 1]);
64
+ if (parsed !== undefined) {
65
+ intervalMs = parsed;
66
+ intervalIdx = tokens.length - 2;
67
+ }
68
+ }
69
+ if (intervalMs === undefined) {
70
+ for (let i = 0; i < tokens.length; i++) {
71
+ const parsed = parseIntervalMs(tokens[i]);
72
+ if (parsed !== undefined) {
73
+ intervalMs = parsed;
74
+ intervalIdx = i;
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ const rest = tokens.slice();
80
+ if (intervalMs !== undefined) {
81
+ rest.splice(intervalIdx, intervalIdx === tokens.length - 2 ? 2 : 1);
82
+ }
83
+ const prompt = rest.join(" ").trim();
84
+ if (!prompt) {
85
+ ui.notify("用法: /loop [间隔] <提示词>,如 /loop 5m 检查部署状态;停止用 /loop stop", "error");
86
+ return null;
87
+ }
88
+ return { intervalMs: intervalMs ?? DEFAULT_INTERVAL_MS, prompt };
89
+ }
90
+ export function registerLoopCommand(pi) {
91
+ // 会话重置(/clear、新会话)时循环随之消亡——和 Claude Code 的会话级语义一致
92
+ pi.on("session_start", (_event, ctx) => stopLoop(ctx.ui));
93
+ pi.registerCommand("loop", {
94
+ description: "定时重复执行提示词,如 /loop 5m 检查部署状态;/loop stop 停止",
95
+ handler: async (args, ctx) => {
96
+ const input = args.trim();
97
+ if (!input) {
98
+ ctx.ui.notify(timer
99
+ ? `🔁 当前循环:每${formatInterval(activeIntervalMs)} · ${preview(activePrompt)}(/loop stop 停止)`
100
+ : "用法: /loop [间隔] <提示词>,如 /loop 5m 检查部署状态", "info");
101
+ return;
102
+ }
103
+ if (/^(stop|off|取消|停止)$/i.test(input)) {
104
+ if (!timer) {
105
+ ctx.ui.notify("没有正在运行的循环", "info");
106
+ return;
107
+ }
108
+ stopLoop(ctx.ui);
109
+ ctx.ui.notify("⏹ 循环已停止", "info");
110
+ return;
111
+ }
112
+ const parsed = parseArgs(ctx.ui, input);
113
+ if (!parsed)
114
+ return;
115
+ // 已有循环在跑就直接替换成新的(先清旧定时器)
116
+ stopLoop(ctx.ui);
117
+ activePrompt = parsed.prompt;
118
+ activeIntervalMs = parsed.intervalMs;
119
+ timer = setInterval(() => {
120
+ try {
121
+ pi.sendUserMessage(activePrompt, {
122
+ deliverAs: "followUp",
123
+ expandPromptTemplates: true,
124
+ });
125
+ }
126
+ catch (e) {
127
+ ctx.ui.notify(`loop 执行失败: ${e instanceof Error ? e.message : String(e)}`, "error");
128
+ }
129
+ }, activeIntervalMs);
130
+ ctx.ui.setStatus(STATUS_KEY, `🔁 每${formatInterval(activeIntervalMs)} · ${preview(activePrompt)}`);
131
+ ctx.ui.notify(`🔁 已开始:每${formatInterval(activeIntervalMs)}执行一次「${preview(activePrompt)}」`, "info");
132
+ },
133
+ });
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,8 @@
16
16
  "files": [
17
17
  "dist",
18
18
  "webui-dist",
19
- "bench"
19
+ "bench",
20
+ "scripts"
20
21
  ],
21
22
  "engines": {
22
23
  "node": ">=22.19.0"
@@ -26,6 +27,7 @@
26
27
  "dev": "tsx src/index.ts",
27
28
  "typecheck": "tsc --noEmit",
28
29
  "prepublishOnly": "npm run build",
30
+ "postinstall": "node scripts/patch-pi.js",
29
31
  "package": "bash ../../scripts/package.sh",
30
32
  "upload-release": "bash ../../scripts/upload-release.sh",
31
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);