pi-web-ui 0.62.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +504 -497
- package/README.zh-CN.md +427 -422
- package/bin/pi-web-ui.mjs +1809 -1881
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +54 -54
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +318 -88
- package/dist/server/attachments.js +37 -28
- package/dist/server/bg-servers.js +6 -1
- package/dist/server/client-state.js +15 -7
- package/dist/server/control-socket.js +1 -5
- package/dist/server/dsh/dsh-agent-service.js +454 -118
- package/dist/server/dsh/dsh-client.js +1 -3
- package/dist/server/dsh/dsh-sessions.js +4 -14
- package/dist/server/dsh/runtime/cordis.yml +1 -1
- package/dist/server/dsh/runtime/goal-rpc.mjs +645 -662
- package/dist/server/dsh/runtime/launcher.mjs +8 -18
- package/dist/server/dsh/runtime/override.patch.yml +9 -9
- package/dist/server/dsh/runtime/runtime-root.mjs +7 -11
- package/dist/server/ensure-bash.js +2 -5
- package/dist/server/files-service.js +42 -36
- package/dist/server/goal-service.js +59 -30
- package/dist/server/index.js +17 -10
- package/dist/server/mcp-bridge.js +11 -5
- package/dist/server/model-admin.js +78 -72
- package/dist/server/plugin-facilities.js +2 -4
- package/dist/server/plugin-updater.js +1 -1
- package/dist/server/plugins.js +26 -8
- package/dist/server/process-utils.js +4 -4
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/scm.js +27 -21
- package/dist/server/serialize.js +1 -3
- package/dist/server/settings-service.js +63 -21
- package/dist/server/slash-commands.js +29 -3
- package/dist/server/subagent-templates.js +249 -0
- package/dist/server/subagents.js +150 -0
- package/dist/server/terminals.js +101 -40
- package/dist/server/text-sniff.js +4 -24
- package/dist/server/update-check.js +2 -4
- package/dist/server/uploads.js +1 -3
- package/dist/server/vision-bridge.js +2 -7
- package/dist/server/wait-subscription-scan.js +102 -21
- package/dist/server/webui-context.js +1 -1
- package/extensions/webui.ts +190 -192
- package/package.json +109 -103
- package/web/dist/assets/TerminalPanel-B9vcI7-_.js +2 -0
- package/web/dist/assets/index-B8Wv29p5.css +10 -0
- package/web/dist/assets/index-CWUWD0rq.js +323 -0
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +15 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/TerminalPanel-BkCWDsIG.js +0 -2
- package/web/dist/assets/index-BRVKed7W.js +0 -321
- package/web/dist/assets/index-ORTpR6ZQ.css +0 -10
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// subagents.ts — 第一方轻量子代理:工具定义与运行态模型
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// 架构(相对参考项目的大幅简化):
|
|
5
|
+
//
|
|
6
|
+
// 参考项目(tintinweb / nicobailon 的 pi-subagents)把子代理做成「完整子会话」
|
|
7
|
+
// —— 独立 SessionManager / SettingsManager / 模型 / 工具隔离 / resume /
|
|
8
|
+
// steer / 并发池 / 工作区隔离,复杂度来自「要独立支撑一个完整会话」。
|
|
9
|
+
//
|
|
10
|
+
// pi-web-ui 的 ClientSession 天生就是多会话并发的:一个 conversation 就有
|
|
11
|
+
// 一个独立 AgentSessionRuntime + TerminalManager,所有 conversation 共享
|
|
12
|
+
// 同一个 modelRuntime,创建走 createAgentSessionServices + FromServices(已
|
|
13
|
+
// 封装好)。因此本模块把子代理定义为:
|
|
14
|
+
//
|
|
15
|
+
// 子代理 = 一个标记了 isSubagent 的普通 Conversation(inMemory session,
|
|
16
|
+
// 不落盘、不进历史/resume 列表)
|
|
17
|
+
// - 出现在左栏「运行的对话」列表,带「子代理」徽标
|
|
18
|
+
// - 用户可以像普通对话一样:点开查看实时消息流、输入补充(= steer)、
|
|
19
|
+
// 中止(= abort)、完成后移出(= dismiss)
|
|
20
|
+
// - 运行态经现有快照/消息管线推送,不需要单独的可视化桥
|
|
21
|
+
//
|
|
22
|
+
// 本文件只定义:运行态快照类型、host 接口(由 ClientSession 实现,操作的是
|
|
23
|
+
// 它的 conversation 体系)、以及注册给每个会话的 subagent_* 工具。真正创建
|
|
24
|
+
// conversation / 跑 prompt 全部在 agent-service.ts 的 spawnSubagent 里完成。
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { Type } from "typebox";
|
|
28
|
+
/** 从 prompt 取首行作为标题(截断 40 字符)。 */
|
|
29
|
+
export function subagentTitle(prompt) {
|
|
30
|
+
const line = prompt.split("\n")[0]?.trim() ?? "";
|
|
31
|
+
return line.length > 40 ? `${line.slice(0, 40)}…` : line;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 子代理工具集(注册进每个会话的 customTools,供主 agent 驱动子代理)。
|
|
35
|
+
* 用 `subagent_*` 前缀命名,避免与第三方 pi-subagents 的
|
|
36
|
+
* `Agent`/`get_subagent_result`/`steer_subagent` 冲突。
|
|
37
|
+
*/
|
|
38
|
+
export function makeSubagentTools(host) {
|
|
39
|
+
const text = (t, details = {}) => ({
|
|
40
|
+
content: [{ type: "text", text: t }],
|
|
41
|
+
details,
|
|
42
|
+
});
|
|
43
|
+
return [
|
|
44
|
+
defineTool({
|
|
45
|
+
name: "subagent_spawn",
|
|
46
|
+
label: "Spawn subagent",
|
|
47
|
+
description: "在后台启动一个独立的子代理对话,用一个明确的指令去完成一项可独立交付的工作(调研/实现/审查等)。" +
|
|
48
|
+
"子代理会出现在左栏「运行的对话」列表(带子代理标识),用户可点开查看、补充、中止。主 agent 可并行派发多个:" +
|
|
49
|
+
"用 subagent_list 查看运行态、subagent_get_result 取结果、subagent_steer 中途改向、subagent_stop 停止。" +
|
|
50
|
+
"适合:长耗时探索、并行调研、独立子任务委派。可选 template 参数:使用设置面板配置的子代理模板" +
|
|
51
|
+
"(角色系统提示词 + 技能/扩展白名单);用 subagent_templates 查看可用清单,不传 = 不用模板。",
|
|
52
|
+
promptSnippet: "spawn an independent background subagent for a deliverable task (parallel work)",
|
|
53
|
+
parameters: Type.Object({
|
|
54
|
+
prompt: Type.String({ description: "交给子代理的完整指令(要达成的目标 + 约束 + 期望产出)。" }),
|
|
55
|
+
type: Type.Optional(Type.String({ description: "子代理类型/角色名(如 explore/implement/review),用于展示。默认 general。" })),
|
|
56
|
+
template: Type.Optional(Type.String({
|
|
57
|
+
description: "可选:子代理模板名(设置面板「子代理模板」配置的预设,见 subagent_templates 工具)。" +
|
|
58
|
+
"模板 = 角色系统提示词 + 技能/扩展白名单;不传 = 不使用模板,按主会话默认配置运行。",
|
|
59
|
+
})),
|
|
60
|
+
cwd: Type.Optional(Type.String({ description: "子代理工作目录(相对/绝对)。默认继承主会话工作目录。" })),
|
|
61
|
+
}),
|
|
62
|
+
execute: async (_id, p, _signal, _onUpdate, ctx) => {
|
|
63
|
+
if (p.template && !host.isTemplateUsable(p.template)) {
|
|
64
|
+
return text(`子代理模板不可用:${p.template}(不存在或已停用)。用 subagent_templates 查看当前可用模板清单;不传 template 则按默认配置运行。`);
|
|
65
|
+
}
|
|
66
|
+
const convId = await host.spawnSubagent(p.prompt, p.type ?? "general", p.cwd ?? ctx.cwd, p.template);
|
|
67
|
+
return text(`子代理已启动(运行列表可见):${convId}\n类型:${p.type ?? "general"} · 标题:${subagentTitle(p.prompt)}` +
|
|
68
|
+
(p.template ? `\n模板:${p.template}` : "") +
|
|
69
|
+
`\n用 subagent_get_result 取结果,subagent_list 看运行态,subagent_steer 改向,subagent_stop 停止。`, { convId, template: p.template });
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
defineTool({
|
|
73
|
+
name: "subagent_get_result",
|
|
74
|
+
label: "Get subagent result",
|
|
75
|
+
description: "取一个子代理的结果或当前运行态。若尚未完成,返回当前状态与已产出的文本。",
|
|
76
|
+
promptSnippet: "fetch a subagent's result / current progress",
|
|
77
|
+
parameters: Type.Object({
|
|
78
|
+
runId: Type.String({ description: "subagent_spawn 返回的 convId。左栏点击同名对话可直接查看。" }),
|
|
79
|
+
}),
|
|
80
|
+
execute: async (_id, p) => {
|
|
81
|
+
const r = host.getSubagent(p.runId);
|
|
82
|
+
if (!r)
|
|
83
|
+
return text(`未找到子代理 ${shortId(p.runId)}(可能已移出)。`, undefined);
|
|
84
|
+
if (r.streaming || r.state === "running") {
|
|
85
|
+
return text(`子代理 ${shortId(r.convId)}(${r.type})仍在运行(状态 ${r.state})。\n当前输出:\n${r.output || "(暂无输出)"}`, r);
|
|
86
|
+
}
|
|
87
|
+
return text(`子代理 ${shortId(r.convId)}(${r.type})状态:${r.state}\n${r.output || "(无结果)"}`, r);
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
defineTool({
|
|
91
|
+
name: "subagent_steer",
|
|
92
|
+
label: "Steer subagent",
|
|
93
|
+
description: "向一个子代理注入一条消息,重定向/补充它的工作方向(等同用户在它的对话里发消息)。",
|
|
94
|
+
promptSnippet: "inject a message into a running subagent to redirect its work",
|
|
95
|
+
parameters: Type.Object({
|
|
96
|
+
runId: Type.String({ description: "目标子代理 convId。" }),
|
|
97
|
+
message: Type.String({ description: "要注入的方向调整/补充信息。" }),
|
|
98
|
+
}),
|
|
99
|
+
execute: async (_id, p) => {
|
|
100
|
+
await host.steerSubagent(p.runId, p.message);
|
|
101
|
+
return text(`已向子代理 ${shortId(p.runId)} 注入消息。`);
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
defineTool({
|
|
105
|
+
name: "subagent_list",
|
|
106
|
+
label: "List subagents",
|
|
107
|
+
description: "列出全部子代理的运行态:convId、类型、状态、标题、消息数。",
|
|
108
|
+
promptSnippet: "list all subagents and their live status",
|
|
109
|
+
parameters: Type.Object({}),
|
|
110
|
+
execute: async () => {
|
|
111
|
+
const list = host.listSubagents();
|
|
112
|
+
if (list.length === 0)
|
|
113
|
+
return text("当前没有子代理。");
|
|
114
|
+
const lines = list.map((r) => `- ${r.convId} · ${r.type} · ${r.state} · ${r.title}(msg: ${r.messageCount})`);
|
|
115
|
+
return text(lines.join("\n"));
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
defineTool({
|
|
119
|
+
name: "subagent_stop",
|
|
120
|
+
label: "Stop subagent",
|
|
121
|
+
description: "停止一个运行中的子代理(等同用户在它的对话里点中止)。已完成的不受影响。",
|
|
122
|
+
promptSnippet: "stop a running subagent",
|
|
123
|
+
parameters: Type.Object({ runId: Type.String({ description: "目标子代理 convId。" }) }),
|
|
124
|
+
execute: async (_id, p) => {
|
|
125
|
+
await host.stopSubagent(p.runId);
|
|
126
|
+
return text(`已请求停止子代理 ${shortId(p.runId)}。`);
|
|
127
|
+
},
|
|
128
|
+
}),
|
|
129
|
+
defineTool({
|
|
130
|
+
name: "subagent_templates",
|
|
131
|
+
label: "List subagent templates",
|
|
132
|
+
description: "列出设置面板「子代理模板」配置的可用模板(角色系统提示词 + 技能/扩展白名单的组合预设)," +
|
|
133
|
+
"供 subagent_spawn 的 template 参数选用。已停用的模板不会出现在这里。list 为空 = 未配置模板,子代理按默认配置运行。",
|
|
134
|
+
promptSnippet: "list configurable subagent templates (role prompt + skills/extensions whitelist presets)",
|
|
135
|
+
parameters: Type.Object({}),
|
|
136
|
+
execute: async () => {
|
|
137
|
+
const list = host.listTemplates();
|
|
138
|
+
if (list.length === 0) {
|
|
139
|
+
return text("当前没有可用的子代理模板(设置面板 → 子代理模板 添加后可用)。子代理默认按主会话配置运行。");
|
|
140
|
+
}
|
|
141
|
+
const lines = list.map((t) => `- ${t.name}${t.description ? `:${t.description}` : ""}`);
|
|
142
|
+
return text(`可用的子代理模板(subagent_spawn 的 template 参数传名字,如 subagent_spawn(template="${list[0]?.name}")):\n${lines.join("\n")}`);
|
|
143
|
+
},
|
|
144
|
+
}),
|
|
145
|
+
];
|
|
146
|
+
}
|
|
147
|
+
/** 短 id 前缀(前端展示/日志用)。 */
|
|
148
|
+
function shortId(id) {
|
|
149
|
+
return id.slice(0, 8);
|
|
150
|
+
}
|
package/dist/server/terminals.js
CHANGED
|
@@ -22,7 +22,7 @@ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
|
22
22
|
// module itself for details).
|
|
23
23
|
import "./patch-node-pty.js";
|
|
24
24
|
import { spawn } from "node-pty";
|
|
25
|
-
import { defineTool
|
|
25
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
26
26
|
import { Type } from "typebox";
|
|
27
27
|
/** Location of the command list for a project: <workspaceRoot>/.pi/commands.json */
|
|
28
28
|
export function commandsFilePath(workspaceRoot) {
|
|
@@ -162,6 +162,7 @@ export function buildTerminalBashLine(command, tailFile) {
|
|
|
162
162
|
// 留下的 141 不是真失败,而是“按要求截断=成功”。只有恰好 141 才转 0,真失败照报。
|
|
163
163
|
// tailFile:`cmd > log 2>&1 | tail -N`——拆掉 tail 后 stdout 进文件、终端为空;
|
|
164
164
|
// 在哨兵前补一个 `tail -N log` 让模型看到日志尾部,退出码仍是底层命令的。
|
|
165
|
+
// eslint-disable-next-line no-useless-escape -- \$? must reach the shell literally
|
|
165
166
|
const rcGuard = `__pi_rc=\${PIPESTATUS:-\$?}; [ "$__pi_rc" -eq 141 ] && __pi_rc=0`;
|
|
166
167
|
const tailPart = tailFile
|
|
167
168
|
? `; tail -n ${Math.max(1, Math.floor(tailFile.lines))} -- '${tailFile.file.replace(/'/g, `'\\''`)}'`
|
|
@@ -234,10 +235,26 @@ export function detectTrailingLimiter(command) {
|
|
|
234
235
|
if (/(?:^|\s)(?:-f|--follow(?:=|\b))/.test(rest))
|
|
235
236
|
return null;
|
|
236
237
|
const lines = parseTailLines(rest) ?? 10; // 裸 `| tail` 默认 10 行
|
|
237
|
-
return {
|
|
238
|
+
return {
|
|
239
|
+
base: parts
|
|
240
|
+
.slice(0, -1)
|
|
241
|
+
.map((s) => s.trim())
|
|
242
|
+
.join(" | "),
|
|
243
|
+
kind,
|
|
244
|
+
lines,
|
|
245
|
+
segment: last,
|
|
246
|
+
};
|
|
238
247
|
}
|
|
239
248
|
// less / more / cat:纯透传,拆掉只为修正退出码 + 避免交互分页器在 PTY 里挂起。
|
|
240
|
-
return {
|
|
249
|
+
return {
|
|
250
|
+
base: parts
|
|
251
|
+
.slice(0, -1)
|
|
252
|
+
.map((s) => s.trim())
|
|
253
|
+
.join(" | "),
|
|
254
|
+
kind,
|
|
255
|
+
lines: null,
|
|
256
|
+
segment: last,
|
|
257
|
+
};
|
|
241
258
|
}
|
|
242
259
|
/** 识别命令里把 stdout 重定向到文件的 `> file` / `>> file`(忽略 2>N / >&N / /dev/null)。
|
|
243
260
|
* 用于 `cmd > log 2>&1 | tail -N`:拆掉 tail 后输出进文件,终端为空——补一个 tail 文件
|
|
@@ -260,7 +277,10 @@ export function queryTerminalOutput(output, q, running = false, exitCode = null)
|
|
|
260
277
|
.split("\n")
|
|
261
278
|
.filter((l) => {
|
|
262
279
|
const t = l.trim();
|
|
263
|
-
return !/^\[pi-exit:\d+\]$/.test(t) &&
|
|
280
|
+
return (!/^\[pi-exit:\d+\]$/.test(t) &&
|
|
281
|
+
!t.startsWith("[进程已退出") &&
|
|
282
|
+
!t.startsWith("[Process exited") &&
|
|
283
|
+
!/^\[.*(已退出|exited)/.test(t));
|
|
264
284
|
});
|
|
265
285
|
while (lines.length > 0 && !lines[lines.length - 1].trim())
|
|
266
286
|
lines.pop();
|
|
@@ -529,11 +549,29 @@ function launchdSpawnedOnMac() {
|
|
|
529
549
|
*/
|
|
530
550
|
export function encodeTerminalKey(key, modifiers = {}) {
|
|
531
551
|
const named = {
|
|
532
|
-
Enter: "\r",
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
552
|
+
Enter: "\r",
|
|
553
|
+
Return: "\r",
|
|
554
|
+
Tab: "\t",
|
|
555
|
+
Backspace: "\x7f",
|
|
556
|
+
Escape: "\x1b",
|
|
557
|
+
Up: "\x1b[A",
|
|
558
|
+
ArrowUp: "\x1b[A",
|
|
559
|
+
Down: "\x1b[B",
|
|
560
|
+
ArrowDown: "\x1b[B",
|
|
561
|
+
Left: "\x1b[D",
|
|
562
|
+
ArrowLeft: "\x1b[D",
|
|
563
|
+
Right: "\x1b[C",
|
|
564
|
+
ArrowRight: "\x1b[C",
|
|
565
|
+
Home: "\x1b[H",
|
|
566
|
+
End: "\x1b[F",
|
|
567
|
+
Delete: "\x1b[3~",
|
|
568
|
+
Insert: "\x1b[2~",
|
|
569
|
+
PageUp: "\x1b[5~",
|
|
570
|
+
PageDown: "\x1b[6~",
|
|
571
|
+
F1: "\x1bOP",
|
|
572
|
+
F2: "\x1bOQ",
|
|
573
|
+
F3: "\x1bOR",
|
|
574
|
+
F4: "\x1bOS",
|
|
537
575
|
};
|
|
538
576
|
let data = named[key] ?? (key.length === 1 ? key : "");
|
|
539
577
|
if (!data)
|
|
@@ -544,8 +582,17 @@ export function encodeTerminalKey(key, modifiers = {}) {
|
|
|
544
582
|
const arrow = /^\x1b\[([A-DHF])$/.exec(data);
|
|
545
583
|
const functionKey = /^\x1bO([P-S])$/.exec(data);
|
|
546
584
|
const namedCode = {
|
|
547
|
-
Enter: 13,
|
|
548
|
-
|
|
585
|
+
Enter: 13,
|
|
586
|
+
Return: 13,
|
|
587
|
+
Tab: 9,
|
|
588
|
+
Backspace: 127,
|
|
589
|
+
Escape: 27,
|
|
590
|
+
Insert: 2,
|
|
591
|
+
Delete: 3,
|
|
592
|
+
Home: 1,
|
|
593
|
+
End: 4,
|
|
594
|
+
PageUp: 5,
|
|
595
|
+
PageDown: 6,
|
|
549
596
|
};
|
|
550
597
|
if (arrow && modifier !== 1) {
|
|
551
598
|
data = `\x1b[1;${modifier}${arrow[1]}`;
|
|
@@ -602,7 +649,7 @@ export class TerminalManager {
|
|
|
602
649
|
create(id, cwd, cols, rows, fallbackCwd, title, opts) {
|
|
603
650
|
const valid = this.validateId(id);
|
|
604
651
|
if (valid) {
|
|
605
|
-
this.fail(id, valid);
|
|
652
|
+
this.fail(id, valid.text, valid.textEn);
|
|
606
653
|
return null;
|
|
607
654
|
}
|
|
608
655
|
if (this.terms.has(id))
|
|
@@ -616,10 +663,10 @@ export class TerminalManager {
|
|
|
616
663
|
this.history.delete(id);
|
|
617
664
|
const safeCwd = this.safeCwd(cwd || fallbackCwd);
|
|
618
665
|
if (!safeCwd) {
|
|
619
|
-
this.fail(id, "终端工作目录必须位于当前工作区内");
|
|
666
|
+
this.fail(id, "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace");
|
|
620
667
|
return null;
|
|
621
668
|
}
|
|
622
|
-
if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.agentBash)) {
|
|
669
|
+
if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.agentBash, opts?.locale)) {
|
|
623
670
|
this.maybeEmitTccHint(id);
|
|
624
671
|
this.emitList();
|
|
625
672
|
return this.info(this.terms.get(id));
|
|
@@ -640,10 +687,10 @@ export class TerminalManager {
|
|
|
640
687
|
* running process is killed and a fresh shell runs the command again in the
|
|
641
688
|
* same terminal (used when re-running a command by clicking its entry).
|
|
642
689
|
*/
|
|
643
|
-
runCommand(id, def, cols, rows, pwd) {
|
|
690
|
+
runCommand(id, def, cols, rows, pwd, locale) {
|
|
644
691
|
const invalidId = this.validateId(id);
|
|
645
692
|
if (invalidId) {
|
|
646
|
-
this.fail(id, invalidId);
|
|
693
|
+
this.fail(id, invalidId.text, invalidId.textEn);
|
|
647
694
|
return;
|
|
648
695
|
}
|
|
649
696
|
const existing = this.terms.get(id);
|
|
@@ -653,13 +700,12 @@ export class TerminalManager {
|
|
|
653
700
|
// the cap could push the live count past MAX_TERMINALS.
|
|
654
701
|
if (!existing && !this.ensureSpawnAllowed(id))
|
|
655
702
|
return;
|
|
656
|
-
const hasHistory = this.history.has(id);
|
|
657
703
|
const rawDir = resolveCommandCwd(def.cwd, pwd);
|
|
658
704
|
const dir = this.safeCwd(rawDir);
|
|
659
705
|
const command = expandPwd(def.command.trim(), pwd);
|
|
660
706
|
const title = def.name || command || `终端 ${++this.seq}`;
|
|
661
707
|
if (!dir) {
|
|
662
|
-
this.fail(id, "终端工作目录必须位于当前工作区内");
|
|
708
|
+
this.fail(id, "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace");
|
|
663
709
|
return;
|
|
664
710
|
}
|
|
665
711
|
if (existing) {
|
|
@@ -681,14 +727,14 @@ export class TerminalManager {
|
|
|
681
727
|
this.terms.delete(id);
|
|
682
728
|
}
|
|
683
729
|
this.history.delete(id);
|
|
684
|
-
const
|
|
730
|
+
const keepLocale = locale ?? existing?.locale;
|
|
731
|
+
const ok = this.spawnShell(id, dir, cols, rows, title, def, undefined, false, keepLocale);
|
|
685
732
|
if (!ok)
|
|
686
733
|
return;
|
|
687
734
|
this.emitList();
|
|
688
735
|
// Clear the previous run's output, then show a banner and run the command
|
|
689
736
|
// (the PTY input buffer holds it until the shell is ready).
|
|
690
|
-
const banner = "\x1b[2J\x1b[3J\x1b[H" +
|
|
691
|
-
`\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`;
|
|
737
|
+
const banner = "\x1b[2J\x1b[3J\x1b[H" + `\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`;
|
|
692
738
|
const fresh = this.terms.get(id);
|
|
693
739
|
if (fresh)
|
|
694
740
|
this.appendOutput(fresh, banner);
|
|
@@ -698,7 +744,7 @@ export class TerminalManager {
|
|
|
698
744
|
this.input(id, command + "\r");
|
|
699
745
|
}
|
|
700
746
|
/** Spawn the user's shell as a PTY. Returns false when the spawn failed. */
|
|
701
|
-
spawnShell(id, cwd, cols, rows, title, command, forceBash, agentBash = false) {
|
|
747
|
+
spawnShell(id, cwd, cols, rows, title, command, forceBash, agentBash = false, locale) {
|
|
702
748
|
let abs = cwd;
|
|
703
749
|
if (!abs)
|
|
704
750
|
abs = homedir();
|
|
@@ -706,12 +752,12 @@ export class TerminalManager {
|
|
|
706
752
|
abs = resolve(abs);
|
|
707
753
|
try {
|
|
708
754
|
if (!existsSync(abs) || !statSync(abs).isDirectory()) {
|
|
709
|
-
this.fail(id, `目录不存在或不是目录:${abs}`);
|
|
755
|
+
this.fail(id, `目录不存在或不是目录:${abs}`, `Directory does not exist or is not a directory: ${abs}`);
|
|
710
756
|
return false;
|
|
711
757
|
}
|
|
712
758
|
}
|
|
713
759
|
catch {
|
|
714
|
-
this.fail(id, `无法访问终端目录:${abs}`);
|
|
760
|
+
this.fail(id, `无法访问终端目录:${abs}`, `Cannot access terminal directory: ${abs}`);
|
|
715
761
|
return false;
|
|
716
762
|
}
|
|
717
763
|
// node-pty's spawn-helper may have lost its +x bit since the last repair
|
|
@@ -732,7 +778,9 @@ export class TerminalManager {
|
|
|
732
778
|
const helper = brokenSpawnHelper();
|
|
733
779
|
this.fail(id, helper
|
|
734
780
|
? `启动终端失败:${err.message}(node-pty 的 spawn-helper 缺少执行权限,请运行:chmod +x "${helper}")`
|
|
735
|
-
: `启动终端失败:${err.message}
|
|
781
|
+
: `启动终端失败:${err.message}`, helper
|
|
782
|
+
? `Failed to start terminal: ${err.message} (node-pty spawn-helper is not executable, run: chmod +x "${helper}")`
|
|
783
|
+
: `Failed to start terminal: ${err.message}`);
|
|
736
784
|
return false;
|
|
737
785
|
}
|
|
738
786
|
const entry = {
|
|
@@ -755,6 +803,7 @@ export class TerminalManager {
|
|
|
755
803
|
idleTimer: null,
|
|
756
804
|
watches: [],
|
|
757
805
|
agentBash,
|
|
806
|
+
locale,
|
|
758
807
|
};
|
|
759
808
|
this.terms.set(id, entry);
|
|
760
809
|
// The closures capture `entry`: after a restart the map points at the
|
|
@@ -881,7 +930,10 @@ export class TerminalManager {
|
|
|
881
930
|
}
|
|
882
931
|
validateId(id) {
|
|
883
932
|
if (!id || id.length > MAX_ID || !/^[A-Za-z0-9._:-]+$/.test(id)) {
|
|
884
|
-
return
|
|
933
|
+
return {
|
|
934
|
+
text: "终端名称无效:只能使用字母、数字、.-、_ 或 :(最长 80 字符)",
|
|
935
|
+
textEn: "Invalid terminal name: use letters, digits, .-_ or : (max 80 chars)",
|
|
936
|
+
};
|
|
885
937
|
}
|
|
886
938
|
return null;
|
|
887
939
|
}
|
|
@@ -901,7 +953,7 @@ export class TerminalManager {
|
|
|
901
953
|
return true;
|
|
902
954
|
const liveUser = [...this.terms.values()].filter((t) => !t.agentBash).length;
|
|
903
955
|
if (liveUser >= MAX_TERMINALS) {
|
|
904
|
-
this.fail(id, `终端数量已达上限(${MAX_TERMINALS}
|
|
956
|
+
this.fail(id, `终端数量已达上限(${MAX_TERMINALS})`, `Terminal limit reached (${MAX_TERMINALS})`);
|
|
905
957
|
return false;
|
|
906
958
|
}
|
|
907
959
|
return true;
|
|
@@ -958,7 +1010,12 @@ export class TerminalManager {
|
|
|
958
1010
|
return null;
|
|
959
1011
|
const start = Math.max(entry.outputOffset, Math.min(cursor, entry.outputOffset + entry.output.length));
|
|
960
1012
|
const end = Math.min(start + Math.max(1, Math.floor(maxBytes) || 20_000), entry.outputOffset + entry.output.length);
|
|
961
|
-
return {
|
|
1013
|
+
return {
|
|
1014
|
+
data: entry.output.slice(start - entry.outputOffset, end - entry.outputOffset),
|
|
1015
|
+
cursor: end,
|
|
1016
|
+
running: !entry.exited,
|
|
1017
|
+
exitCode: entry.exitCode,
|
|
1018
|
+
};
|
|
962
1019
|
}
|
|
963
1020
|
async waitForOutput(id, cursor, timeoutMs, signal) {
|
|
964
1021
|
const current = this.read(id, cursor, 1);
|
|
@@ -983,7 +1040,7 @@ export class TerminalManager {
|
|
|
983
1040
|
}
|
|
984
1041
|
inputChecked(id, data) {
|
|
985
1042
|
if (data.length > MAX_INPUT)
|
|
986
|
-
return `输入过长(上限 ${MAX_INPUT}
|
|
1043
|
+
return `输入过长(上限 ${MAX_INPUT} 字符) Input too long (max ${MAX_INPUT} chars)`;
|
|
987
1044
|
const entry = this.terms.get(id);
|
|
988
1045
|
if (!entry || entry.exited)
|
|
989
1046
|
return "终端不存在或进程已退出";
|
|
@@ -1102,8 +1159,8 @@ export class TerminalManager {
|
|
|
1102
1159
|
};
|
|
1103
1160
|
}
|
|
1104
1161
|
/** Emit a terminal failure (bad cwd, spawn error) and mark the terminal dead. */
|
|
1105
|
-
fail(id, text) {
|
|
1106
|
-
this.emit({ type: "notice", level: "error", text });
|
|
1162
|
+
fail(id, text, textEn) {
|
|
1163
|
+
this.emit({ type: "notice", level: "error", text, textEn });
|
|
1107
1164
|
this.emit({
|
|
1108
1165
|
type: "terminal_output",
|
|
1109
1166
|
terminalId: id,
|
|
@@ -1118,7 +1175,9 @@ export class TerminalManager {
|
|
|
1118
1175
|
this.disarmIdleWatch(entry);
|
|
1119
1176
|
// Flush queued output BEFORE the exit banner so ordering is preserved.
|
|
1120
1177
|
this.flushPending(entry);
|
|
1121
|
-
const banner =
|
|
1178
|
+
const banner = entry.locale === "en"
|
|
1179
|
+
? `\r\n\x1b[90m[Process exited with code ${exitCode}]\x1b[0m\r\n`
|
|
1180
|
+
: `\r\n\x1b[90m[进程已退出,退出码 ${exitCode}]\x1b[0m\r\n`;
|
|
1122
1181
|
this.appendOutput(entry, banner);
|
|
1123
1182
|
this.emit({ type: "terminal_output", terminalId: id, data: banner });
|
|
1124
1183
|
entry.exited = true;
|
|
@@ -1358,11 +1417,13 @@ export function makeTerminalBashTool(terminals, opts) {
|
|
|
1358
1417
|
// `cmd > log 2>&1 | tail -N`:拆掉 tail 后 stdout 进文件、终端为空 → 补一个
|
|
1359
1418
|
// tail 文件让模型看到日志尾部与真实退出码(否则输出为空)。
|
|
1360
1419
|
const redirect = stripped && limiter.kind === "tail" ? detectStdoutRedirect(runCommand) : null;
|
|
1361
|
-
const tailFile = redirect
|
|
1362
|
-
? { file: redirect.file, lines: limiter.lines ?? 10 }
|
|
1363
|
-
: undefined;
|
|
1420
|
+
const tailFile = redirect ? { file: redirect.file, lines: limiter.lines ?? 10 } : undefined;
|
|
1364
1421
|
const limiterNote = stripped
|
|
1365
|
-
? "\n[注:检测到你带了「" +
|
|
1422
|
+
? "\n[注:检测到你带了「" +
|
|
1423
|
+
limiter.segment +
|
|
1424
|
+
"」这类限输出/过滤管道——已在终端里直跑底层命令(实时可见 + 真实退出码),只按参数返回片段。" +
|
|
1425
|
+
(limiter.kind === "tail" ? "本次返回末尾 " + (limiter.lines ?? 10) + " 行。" : "本次返回全部输出。") +
|
|
1426
|
+
" 后续直接用 bash(command, tail=N) 参数限输出。]"
|
|
1366
1427
|
: "";
|
|
1367
1428
|
try {
|
|
1368
1429
|
let collected = "";
|
|
@@ -1460,10 +1521,10 @@ export const TERMINAL_TOOL_NAMES = [
|
|
|
1460
1521
|
/** System-prompt guidance teaching the model WHEN to prefer the terminal tools
|
|
1461
1522
|
* over one-shot bash. Without it models almost never pick them — bash returns
|
|
1462
1523
|
* complete output in a single call, so it always wins on convenience. */
|
|
1463
|
-
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The bash tool stays the DEFAULT for ordinary commands - it runs in a visible terminal and returns the full output (persist=false, one-shot terminal that exits when the command finishes). Switch to the bash tool's persist=true, or to the terminal tools, when:
|
|
1464
|
-
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin). For these, prefer bash({ persist: true }) which runs it in the persistent 'ai-bash' terminal and returns immediately; then drive it with terminal_input / terminal_key (and terminal_read) on terminalId='ai-bash'.
|
|
1465
|
-
- You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
|
|
1466
|
-
- The user explicitly asks you to work in the visible terminal panel.
|
|
1524
|
+
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read / terminal_wait). The bash tool stays the DEFAULT for ordinary commands - it runs in a visible terminal and returns the full output (persist=false, one-shot terminal that exits when the command finishes). Switch to the bash tool's persist=true, or to the terminal tools, when:
|
|
1525
|
+
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin). For these, prefer bash({ persist: true }) which runs it in the persistent 'ai-bash' terminal and returns immediately; then drive it with terminal_input / terminal_key (and terminal_read) on terminalId='ai-bash'.
|
|
1526
|
+
- You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs), send keys to it later (e.g. interrupt via terminal_key with Ctrl+c), or block until a backgrounded command finishes without polling (terminal_wait).
|
|
1527
|
+
- The user explicitly asks you to work in the visible terminal panel.
|
|
1467
1528
|
Use head/tail on bash to trim verbose output instead of piping through head/tail. Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.`;
|
|
1468
1529
|
/** Build the agent-facing persistent terminal tools for one conversation. */
|
|
1469
1530
|
export function makePersistentTerminalTools(terminals, cwd) {
|
|
@@ -19,19 +19,7 @@ const PREVIEW_IMAGE_EXTS = new Set([
|
|
|
19
19
|
"tif",
|
|
20
20
|
"tiff",
|
|
21
21
|
]);
|
|
22
|
-
const PREVIEW_VIDEO_EXTS = new Set([
|
|
23
|
-
"mp4",
|
|
24
|
-
"webm",
|
|
25
|
-
"mov",
|
|
26
|
-
"mkv",
|
|
27
|
-
"avi",
|
|
28
|
-
"m4v",
|
|
29
|
-
"ogv",
|
|
30
|
-
"mpg",
|
|
31
|
-
"mpeg",
|
|
32
|
-
"wmv",
|
|
33
|
-
"flv",
|
|
34
|
-
]);
|
|
22
|
+
const PREVIEW_VIDEO_EXTS = new Set(["mp4", "webm", "mov", "mkv", "avi", "m4v", "ogv", "mpg", "mpeg", "wmv", "flv"]);
|
|
35
23
|
const PREVIEW_TEXT_EXTS = new Set([
|
|
36
24
|
// code
|
|
37
25
|
"ts",
|
|
@@ -204,11 +192,7 @@ export function decodeText(buf) {
|
|
|
204
192
|
* Returns null when the bytes don't look like a known raster format —
|
|
205
193
|
* callers keep such files as plain path references. */
|
|
206
194
|
export function sniffImageMime(buf, ext) {
|
|
207
|
-
if (buf.length >= 8 &&
|
|
208
|
-
buf[0] === 0x89 &&
|
|
209
|
-
buf[1] === 0x50 &&
|
|
210
|
-
buf[2] === 0x4e &&
|
|
211
|
-
buf[3] === 0x47) {
|
|
195
|
+
if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
|
212
196
|
return "image/png";
|
|
213
197
|
}
|
|
214
198
|
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
|
@@ -242,12 +226,8 @@ export function hexDump(buf, maxBytes = 4096) {
|
|
|
242
226
|
const rows = [];
|
|
243
227
|
for (let off = 0; off < data.length; off += 16) {
|
|
244
228
|
const chunk = data.subarray(off, off + 16);
|
|
245
|
-
const hex = [...chunk]
|
|
246
|
-
|
|
247
|
-
.join(" ");
|
|
248
|
-
const ascii = [...chunk]
|
|
249
|
-
.map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : "."))
|
|
250
|
-
.join("");
|
|
229
|
+
const hex = [...chunk].map((b) => b.toString(16).padStart(2, "0")).join(" ");
|
|
230
|
+
const ascii = [...chunk].map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : ".")).join("");
|
|
251
231
|
rows.push(`${off.toString(16).padStart(8, "0")} ${hex.padEnd(47, " ")} ${ascii}`);
|
|
252
232
|
}
|
|
253
233
|
return rows.join("\n");
|
|
@@ -203,9 +203,7 @@ function readVendoredPiCore(agentDir) {
|
|
|
203
203
|
* two rows for the same package.
|
|
204
204
|
*/
|
|
205
205
|
export function collectTargets(agentDir, webuiVersion, probePiCore = defaultProbePiCore) {
|
|
206
|
-
const targets = [
|
|
207
|
-
{ name: "pi-web-ui", version: webuiVersion, kind: "webui" },
|
|
208
|
-
];
|
|
206
|
+
const targets = [{ name: "pi-web-ui", version: webuiVersion, kind: "webui" }];
|
|
209
207
|
const coreVersion = probePiCore() ?? readVendoredPiCore(agentDir);
|
|
210
208
|
if (coreVersion) {
|
|
211
209
|
targets.push({
|
|
@@ -239,7 +237,7 @@ export async function fetchLatest(fetcher, name) {
|
|
|
239
237
|
* input order. Bounded concurrency (CONCURRENCY) keeps registry load polite.
|
|
240
238
|
*/
|
|
241
239
|
export async function checkAll(targets, fetcher = defaultFetcher) {
|
|
242
|
-
const results =
|
|
240
|
+
const results = Array.from({ length: targets.length });
|
|
243
241
|
let cursor = 0;
|
|
244
242
|
async function worker() {
|
|
245
243
|
while (cursor < targets.length) {
|
package/dist/server/uploads.js
CHANGED
|
@@ -27,9 +27,7 @@ export function uploadRetentionDays() {
|
|
|
27
27
|
export function saveUpload(clientId, name, buf, dataDir = resolveDataDir()) {
|
|
28
28
|
const dir = join(uploadsRoot(dataDir), clientId);
|
|
29
29
|
mkdirSync(dir, { recursive: true });
|
|
30
|
-
const displayName = name
|
|
31
|
-
.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_")
|
|
32
|
-
.slice(0, 80) || "file";
|
|
30
|
+
const displayName = name.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_").slice(0, 80) || "file";
|
|
33
31
|
const abs = join(dir, `${Date.now()}-${displayName}`);
|
|
34
32
|
writeFileSync(abs, buf);
|
|
35
33
|
return { abs, displayName };
|
|
@@ -90,9 +90,7 @@ export async function transcribeImages(runtime, images, options = {}) {
|
|
|
90
90
|
const imageBlocks = images.map((img) => ({
|
|
91
91
|
type: "image",
|
|
92
92
|
data: img.data.replace(/^data:[^;]*;base64,/, ""),
|
|
93
|
-
mimeType: img.mimeType?.startsWith("image/")
|
|
94
|
-
? img.mimeType
|
|
95
|
-
: "image/png",
|
|
93
|
+
mimeType: img.mimeType?.startsWith("image/") ? img.mimeType : "image/png",
|
|
96
94
|
}));
|
|
97
95
|
const context = {
|
|
98
96
|
systemPrompt: options.systemPrompt ?? SYSTEM_PROMPT,
|
|
@@ -100,10 +98,7 @@ export async function transcribeImages(runtime, images, options = {}) {
|
|
|
100
98
|
{
|
|
101
99
|
role: "user",
|
|
102
100
|
timestamp: Date.now(),
|
|
103
|
-
content: [
|
|
104
|
-
...imageBlocks,
|
|
105
|
-
{ type: "text", text: buildUserPrompt(images.length) },
|
|
106
|
-
],
|
|
101
|
+
content: [...imageBlocks, { type: "text", text: buildUserPrompt(images.length) }],
|
|
107
102
|
},
|
|
108
103
|
],
|
|
109
104
|
};
|