aims-workstation 0.1.2 → 0.1.4
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/README.md +3 -2
- package/package.json +2 -2
- package/server.js +266 -62
- package/web/dist/assets/index-B8MrcnV-.css +2 -0
- package/web/dist/assets/index-XtJADtwi.js +60 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-71y-1jAY.js +0 -58
- package/web/dist/assets/index-B1JTYuR8.css +0 -2
package/README.md
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
- **工作台**:每个助手一个专属操作面;区块自由布局,**对话式开发面板插件**(描述需求 → 二开助手生成 → 草稿审阅 → 发布)
|
|
10
10
|
- **工作区**:助手 1:N 工作区(文件现场隔离),`exec` 锚定工作区目录,数据集按工作区分库
|
|
11
11
|
- **分级自主**:L0 只读 → L2 确认执行(写操作挂起人工确认)→ L3 自主,危险命令永久拒绝;全程审计
|
|
12
|
-
-
|
|
13
|
-
-
|
|
12
|
+
- **守护栏可维护**:危险命令清单(正则)与只读白名单在「系统设置·守护栏」增删,保存即热生效;`sudo -u <用户>` 降权形式可配置为 L2 确认执行;删减危险规则需二次确认并写审计
|
|
13
|
+
- **并行会话**:多助手并行、后台运行进度跟踪、确认卡跨切换恢复;对话可随时中断(停止按钮/状态行中断,执行中的命令一并终止,部分回复保留进上下文可续聊)
|
|
14
|
+
- **系统设置**:ARM 源 / LLM(任意 OpenAI 兼容端点)/ 全局场景画像 / 守护栏策略,界面配置即时生效
|
|
14
15
|
|
|
15
16
|
## 快速开始
|
|
16
17
|
|
package/package.json
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"@earendil-works/pi-ai": "^1.2.0"
|
|
14
14
|
},
|
|
15
15
|
"packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1",
|
|
16
|
-
"version": "0.1.
|
|
16
|
+
"version": "0.1.4",
|
|
17
17
|
"description": "AIMS 工作站:本地智能体工作站——ARM 助手本地化(下载/离线/升级)、工作台对话式定制、多工作区隔离、分级自主与审计",
|
|
18
18
|
"bin": {
|
|
19
19
|
"aims-workstation": "bin/aims-workstation.js"
|
|
@@ -31,4 +31,4 @@
|
|
|
31
31
|
".env.example",
|
|
32
32
|
"README.md"
|
|
33
33
|
]
|
|
34
|
-
}
|
|
34
|
+
}
|
package/server.js
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// 链路: ARM员工库 → 下载物化本地 → 平台实例化(pi-ai + 工具注入 + 分级自主) → 工作台(DSL) → 经验回流ARM
|
|
3
3
|
// Agent 层基于 @earendil-works/pi-ai(统一 LLM API),前端为 web/ 预构建的 shadcn/ui 应用(web/dist)
|
|
4
4
|
'use strict';
|
|
5
|
+
// TLS 证书校验关闭(用户决策:内网私有部署自签证书常见,能访问优先)
|
|
6
|
+
// 覆盖所有出站 fetch(ARM REST/MCP、LLM、面板代理)。本工具面向内网/本机使用。
|
|
7
|
+
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
|
5
8
|
const http = require('http');
|
|
6
9
|
const fs = require('fs');
|
|
7
10
|
const path = require('path');
|
|
@@ -74,8 +77,24 @@ const ARM_MCP_URL = process.env.ARM_MCP_URL || env.ARM_MCP_URL;
|
|
|
74
77
|
// ── 运行时配置(ARM 源与 LLM 均可私有化部署:在「系统设置」页切换,存 AIMS_HOME/config/platform.json,优先级 运行时 > 环境变量 > .env)──
|
|
75
78
|
const CFG_FILE = path.join(AIMS_HOME, 'config/platform.json');
|
|
76
79
|
const cfg = { arm_api_base: '', arm_mcp_url: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', scene_profile: '', access_token: '' };
|
|
77
|
-
|
|
80
|
+
// 守护栏默认策略(可在「系统设置·守护栏」维护,存 platform.json,保存即热生效):deny=正则源串(命中即永久拒绝,任何级别不豁免);
|
|
81
|
+
// ro=只读白名单命令名(免确认直行);sudo_u_l2=true 时 `sudo -u <用户>` 降权形式不再永久拒绝、按写命令走 L2 人工确认(裸 sudo 仍拒绝)
|
|
82
|
+
const GUARD_DEFAULT = {
|
|
83
|
+
deny: ['\\bsudo\\b', '\\b(shutdown|reboot|halt|poweroff|mkfs)\\b', '\\bdd\\s+if=', 'rm\\s+-[a-z]*[rf][a-z]*\\s+/(\\s|$)', ':\\(\\)\\s*\\{.*\\};:', '\\bkill\\s+-9\\s+1\\b'],
|
|
84
|
+
ro: ['ls', 'cat', 'head', 'tail', 'wc', 'grep', 'find', 'file', 'stat', 'whoami', 'date', 'uname', 'uptime', 'df', 'du', 'ps', 'curl', 'ping', 'nc', 'netstat', 'dig', 'nslookup', 'sqlite3', 'echo', 'which', 'env'],
|
|
85
|
+
sudo_u_l2: false,
|
|
86
|
+
};
|
|
87
|
+
function normalizeGuard(g) {
|
|
88
|
+
g = g && typeof g === 'object' ? g : {};
|
|
89
|
+
return {
|
|
90
|
+
deny: Array.isArray(g.deny) ? [...new Set(g.deny.map((s) => String(s).trim()).filter(Boolean))] : GUARD_DEFAULT.deny.slice(),
|
|
91
|
+
ro: Array.isArray(g.ro) ? [...new Set(g.ro.map((s) => String(s).trim()).filter(Boolean))] : GUARD_DEFAULT.ro.slice(),
|
|
92
|
+
sudo_u_l2: !!g.sudo_u_l2,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function loadCfg() { try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'))); } catch { } cfg.guard = normalizeGuard(cfg.guard); }
|
|
78
96
|
loadCfg();
|
|
97
|
+
const guardNow = () => cfg.guard;
|
|
79
98
|
function saveCfg() { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg, null, 2)); }
|
|
80
99
|
// 站点地址:运行时配置 > ARM_API_BASE(env) > 从 MCP URL 推导;MCP 端点:显式配置 > 跟随站点地址 > env
|
|
81
100
|
const armApiBase = () => cfg.arm_api_base || process.env.ARM_API_BASE || env.ARM_API_BASE || String(env.ARM_MCP_URL || '').replace(/\/api\/mcp$/, '');
|
|
@@ -197,6 +216,16 @@ async function armRest(pathname, init = {}, timeout_ms = 20000) {
|
|
|
197
216
|
const data = await res.json();
|
|
198
217
|
if (!res.ok || data.ok === false) throw new Error(`ARM REST ${pathname.split('?')[0]}: ${data.msg || res.status}`);
|
|
199
218
|
return data.data;
|
|
219
|
+
} catch (e) {
|
|
220
|
+
if (e.message === 'fetch failed') { // undici 把网络层错误笼统包装,展开真实原因(DNS/连接/TLS)
|
|
221
|
+
const c = e.cause || {};
|
|
222
|
+
const reason = c.code || c.message || '未知网络错误';
|
|
223
|
+
const hint = /LEAF|CERT|SSL|TLS/i.test(String(reason)) ? '——HTTPS 证书校验失败(自签证书?内网解析应直连 HTTP)'
|
|
224
|
+
: /ENOTFOUND|EAI_AGAIN/.test(String(reason)) ? '——域名解析失败(当前 DNS 无此内网记录)'
|
|
225
|
+
: /ECONNREFUSED|ETIMEDOUT|EHOSTUNREACH/.test(String(reason)) ? '——目标不可达(连接被拒/超时)' : '';
|
|
226
|
+
throw new Error(`ARM 请求失败: ${reason}${hint}`);
|
|
227
|
+
}
|
|
228
|
+
throw e;
|
|
200
229
|
} finally { clearTimeout(timer); }
|
|
201
230
|
}
|
|
202
231
|
const vVer = (v) => (String(v || '').startsWith('v') ? String(v) : `v${v || '0.0.0'}`); // REST 无 v 前缀,统一内部表示
|
|
@@ -238,7 +267,7 @@ async function armListAgents(keyword = '') {
|
|
|
238
267
|
out.push(...(d.agents || []));
|
|
239
268
|
if (!out.length || out.length >= +(d.total || 0)) break;
|
|
240
269
|
}
|
|
241
|
-
return out.map(a => ({ agent_id: a.id, name: a.name, version: vVer(a.version), skills: a.skillsCount || 0, knowledges: a.knowledgesCount || 0, description: String(a.description || '').slice(0, 120), status: a.status || 'active' }));
|
|
270
|
+
return out.map(a => ({ agent_id: a.id, name: a.name, version: vVer(a.version), skills: a.skillsCount || 0, knowledges: a.knowledgesCount || 0, description: String(a.description || '').slice(0, 120), status: a.status || 'active', created_by: a.creatorName || a.createdBy || '' }));
|
|
242
271
|
}
|
|
243
272
|
|
|
244
273
|
// 知识检索(全局经验库):REST 混合检索(RAG 语义 ⊕ 关键词)优先,回落 MCP RAG
|
|
@@ -302,6 +331,19 @@ function commitDownload(manifest) {
|
|
|
302
331
|
e.versions.sort(cmpVersion);
|
|
303
332
|
saveRegistry(reg);
|
|
304
333
|
}
|
|
334
|
+
// 本地卸载(本地 DELETE 与「从 ARM 删除并卸载」共用):上岗配置/工作台/版本目录/注册表,可选清工作区数据
|
|
335
|
+
function uninstallLocalAgent(agentId, purge_workspaces) {
|
|
336
|
+
for (const p of [path.join(DEPLOY_DIR, `${agentId}.json`), path.join(WB_DIR, `${agentId}.json`), draftPath(agentId), path.join(WB_VER_DIR, `${agentId}.json`)]) {
|
|
337
|
+
try { fs.rmSync(p, { force: true }); } catch { }
|
|
338
|
+
}
|
|
339
|
+
try { fs.rmSync(path.join(ARM_AGENTS_DIR, agentId), { recursive: true, force: true }); } catch { }
|
|
340
|
+
const reg = loadRegistry(); reg.agents = reg.agents.filter(a => a.agent_id !== agentId); saveRegistry(reg);
|
|
341
|
+
if (purge_workspaces) {
|
|
342
|
+
try { fs.rmSync(wsDir(agentId), { recursive: true, force: true }); } catch { }
|
|
343
|
+
for (const f of fs.readdirSync(DATA_DIR)) if (f.startsWith(`ws-${agentId}-`)) { try { fs.rmSync(path.join(DATA_DIR, f), { force: true }); } catch { } }
|
|
344
|
+
}
|
|
345
|
+
return true;
|
|
346
|
+
}
|
|
305
347
|
|
|
306
348
|
async function downloadAgentRest(agentId) {
|
|
307
349
|
const d = await armRest(`/api/v1/agents/${agentId}`);
|
|
@@ -503,11 +545,15 @@ function deleteWorkspace(agentId, wsId, purge) {
|
|
|
503
545
|
|
|
504
546
|
// ── 通用计算原语与平台数据服务(pi-core 哲学:少量原语 + 组合,而非功能堆叠)──
|
|
505
547
|
const { exec: cpExec } = require('node:child_process');
|
|
506
|
-
const EXEC_DENY = [/\bsudo\b/, /\b(shutdown|reboot|halt|poweroff|mkfs)\b/, /\bdd\s+if=/, /rm\s+-[a-z]*[rf][a-z]*\s+\/(\s|$)/, /:\(\)\s*\{.*\};:/, /\bkill\s+-9\s+1\b/];
|
|
507
|
-
const EXEC_RO = ['ls', 'cat', 'head', 'tail', 'wc', 'grep', 'find', 'file', 'stat', 'whoami', 'date', 'uname', 'uptime', 'df', 'du', 'ps', 'curl', 'ping', 'nc', 'netstat', 'dig', 'nslookup', 'sqlite3', 'echo', 'which', 'env'];
|
|
508
548
|
function classifyExec(command, autonomy) {
|
|
509
549
|
const cmd = String(command || '').trim();
|
|
510
|
-
|
|
550
|
+
const g = guardNow();
|
|
551
|
+
// sudo 降权特例(可配置):开启后剥掉 `sudo -u <用户>` 片段再匹配危险清单(嵌套的裸 sudo/毁灭性命令仍会命中),剩余部分按普通命令分级
|
|
552
|
+
const denyCmd = g.sudo_u_l2 ? cmd.replace(/\bsudo\s+(?:-\w+\s+)*-u\s+\S+/g, '') : cmd;
|
|
553
|
+
const denyRes = [];
|
|
554
|
+
for (const src of g.deny) { try { denyRes.push(new RegExp(src)); } catch { /* 坏正则跳过(PUT 时已校验,历史数据兜底) */ } }
|
|
555
|
+
if (denyRes.some((re) => re.test(denyCmd))) return { status: 'blocked', reason: '【守护栏】命令命中危险清单(清单可在 系统设置·守护栏 维护)' };
|
|
556
|
+
const EXEC_RO = g.ro;
|
|
511
557
|
const noRedirect = cmd.replace(/2>&1/g, ''); // 重定向/tee 使白名单命令具备写能力,不再视为只读
|
|
512
558
|
const SQL_WRITE = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE)\b/i;
|
|
513
559
|
// 复合命令逐段判定:每个由 ; && || | 换行 分隔的段都须只读(控制关键字与 for 头部放行)
|
|
@@ -522,7 +568,7 @@ function classifyExec(command, autonomy) {
|
|
|
522
568
|
const ro = segments.length > 0 && segments.every(segOk) && !/(>>|>)|\btee\b/.test(noRedirect);
|
|
523
569
|
if (ro) return { status: 'ok' }; // 只读白名单:任何级别免确认(取证不应有摩擦)
|
|
524
570
|
if (autonomy >= 2) return { status: 'pending' }; // 写命令:L2 可发起,执行前需人工确认
|
|
525
|
-
return { status: 'blocked', reason: `【守护栏】L${autonomy}
|
|
571
|
+
return { status: 'blocked', reason: `【守护栏】L${autonomy} 仅放行只读命令(${EXEC_RO.slice(0, 8).join('/')}等,完整清单见 系统设置·守护栏)。写操作需 L2 并人工确认` };
|
|
526
572
|
}
|
|
527
573
|
const PRIMITIVE_TOOLS = {
|
|
528
574
|
exec: {
|
|
@@ -530,17 +576,18 @@ const PRIMITIVE_TOOLS = {
|
|
|
530
576
|
desc: '通用终端命令执行。工作目录(cwd)= 当前工作区;连通性实测用 nc -vz -w 3 host port;平台日志用绝对路径 tail(见系统提示)。只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝。不要期待专用工具,优先用命令组合能力',
|
|
531
577
|
schema: { command: { type: 'string', description: '要执行的 shell 命令' }, timeout_ms: { type: 'number', description: '超时毫秒,默认 15000' } },
|
|
532
578
|
required: ['command'],
|
|
533
|
-
run: async ({ command, timeout_ms = 15000 }, agentId) => {
|
|
579
|
+
run: async ({ command, timeout_ms = 15000 }, agentId, _wsId, signal) => {
|
|
534
580
|
const env = { ...process.env };
|
|
535
581
|
delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT; // 凭据不进子进程
|
|
536
582
|
const ws = agentId ? currentWs(agentId) : null; // 文件领地锚定工作区(07 设计 §4.3)
|
|
537
583
|
return await new Promise((resolve) => {
|
|
538
|
-
cpExec(command, { cwd: ws ? ws.path : ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell: process.env.SHELL || '/bin/zsh' }, (err, stdout, stderr) => {
|
|
584
|
+
const child = cpExec(command, { cwd: ws ? ws.path : ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell: process.env.SHELL || '/bin/zsh' }, (err, stdout, stderr) => {
|
|
539
585
|
const out = String(stdout || '').slice(0, 4000);
|
|
540
586
|
const errOut = String(stderr || '').slice(0, 1500);
|
|
541
587
|
audit({ actor: 'assistant', tool: 'exec', command: String(command).slice(0, 200), ok: !err, ws: ws?.id });
|
|
542
|
-
resolve(`$ ${command}\n${out}${errOut ? '\n[stderr] ' + errOut : ''}${err && !out && !errOut ? '\n[错误] ' + err.message : ''}`);
|
|
588
|
+
resolve(`$ ${command}\n${out}${errOut ? '\n[stderr] ' + errOut : ''}${err && !out && !errOut ? '\n[错误] ' + err.message : ''}${signal?.aborted ? '\n[中断] 命令已被用户终止' : ''}`);
|
|
543
589
|
});
|
|
590
|
+
if (signal) signal.addEventListener('abort', () => { try { child.kill('SIGTERM') } catch { /* 已退出 */ } }, { once: true }); // 用户中断 → 终止执行中的命令
|
|
544
591
|
});
|
|
545
592
|
},
|
|
546
593
|
},
|
|
@@ -1176,13 +1223,16 @@ function devSystemPrompt() {
|
|
|
1176
1223
|
].join('\n');
|
|
1177
1224
|
}
|
|
1178
1225
|
|
|
1179
|
-
async function devChatLoop(session, emit) {
|
|
1226
|
+
async function devChatLoop(session, emit, signal = null) {
|
|
1180
1227
|
const events = [];
|
|
1181
1228
|
const tools = Object.entries(DEV_TOOLS).map(([n, t]) => ({ name: n, description: t.desc, parameters: { type: 'object', properties: t.schema, required: t.required || [] } }));
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1229
|
+
const interrupted = () => !!signal?.aborted;
|
|
1230
|
+
for (let round = 0; ; round++) { // 不设轮次上限:自然结束或用户中断(断开连接/停止按钮)
|
|
1231
|
+
if (interrupted()) { emit('interrupted', { at: `第 ${round + 1} 轮` }); return { reply: '(已被用户中断)', events }; }
|
|
1232
|
+
emit('status', { text: `第 ${round + 1} 轮` });
|
|
1233
|
+
const stream = models.stream(model, session.context, signal ? { signal } : undefined);
|
|
1185
1234
|
for await (const ev of stream) {
|
|
1235
|
+
if (interrupted()) break;
|
|
1186
1236
|
if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
|
|
1187
1237
|
else if (ev.type === 'toolcall_end') emit('status', { text: `调用工具 ${ev.toolCall?.name || ''}` });
|
|
1188
1238
|
}
|
|
@@ -1192,6 +1242,10 @@ async function devChatLoop(session, emit) {
|
|
|
1192
1242
|
const text = (final.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
|
|
1193
1243
|
if (!calls.length) { emit('done', { reply: text, events }); return { reply: text, events }; }
|
|
1194
1244
|
for (const tc of calls) {
|
|
1245
|
+
if (interrupted()) { // 余下工具不再执行,补占位结果保证工具调用链完整
|
|
1246
|
+
session.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: '(用户中断,未执行)' }], isError: false, timestamp: Date.now() });
|
|
1247
|
+
continue;
|
|
1248
|
+
}
|
|
1195
1249
|
const args = tc.arguments || {};
|
|
1196
1250
|
emit('tool_start', { tool: tc.name, args });
|
|
1197
1251
|
let result, isErr = false;
|
|
@@ -1201,9 +1255,8 @@ async function devChatLoop(session, emit) {
|
|
|
1201
1255
|
emit('tool_end', { tool: tc.name, args, result: String(result).slice(0, 600), ok: !isErr });
|
|
1202
1256
|
session.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: String(result) }], isError: isErr, timestamp: Date.now() });
|
|
1203
1257
|
}
|
|
1258
|
+
if (interrupted()) { emit('interrupted', { at: `第 ${round + 1} 轮` }); return { reply: text, events }; }
|
|
1204
1259
|
}
|
|
1205
|
-
emit('done', { reply: '(达到轮次上限——请把结论性需求拆小,或继续对话续跑)', events });
|
|
1206
|
-
return { reply: '(达到轮次上限)', events };
|
|
1207
1260
|
}
|
|
1208
1261
|
|
|
1209
1262
|
// ── 助手运行时(pi-ai Context + 守护栏 + SSE 事件流)────
|
|
@@ -1232,7 +1285,7 @@ function systemPrompt(armAgent, deployment, autonomy, ws) {
|
|
|
1232
1285
|
'- 连通性实测:exec `nc -vz -w 3 <host> <port>`("能否连接"必须实测)',
|
|
1233
1286
|
'- 平台数据:先 data_describe 看字典,再 data_query 查询(服务端按助手隔离)',
|
|
1234
1287
|
'- 现场若有已接入的数据面板,以工作台展示为准;没有就说没有,不猜测',
|
|
1235
|
-
|
|
1288
|
+
`exec 只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝${guardNow().sudo_u_l2 ? '(例外:sudo 降权形式 sudo -u <用户> 按写命令处理,L2 确认后可执行;裸 sudo 仍拒绝)' : ''}。`,
|
|
1236
1289
|
'',
|
|
1237
1290
|
'【事实与推断分离(硬规则)】',
|
|
1238
1291
|
'- 连通性结论只能来自实测(nc/curl);记录存在≠可达,设备状态在线≠可达',
|
|
@@ -1260,7 +1313,7 @@ async function ensureSession(sessionId, agentId, autonomyLevel, wsId) {
|
|
|
1260
1313
|
if (!deployment) throw new Error('该助手未上岗(无 deployment)');
|
|
1261
1314
|
const armAgent = await getArmAgent(agentId);
|
|
1262
1315
|
if (!s || s.agentId !== agentId || (wsId && s.wsId !== wsId)) { // 切工作区 = 新会话上下文(旧会话留在原工作区)
|
|
1263
|
-
s = { agentId, wsId: wsId || 'default', autonomy: { L0: 0, L1: 1, L2: 2 }[deployment.default_autonomy] ?? 1, context: null, pending: null };
|
|
1316
|
+
s = { agentId, wsId: wsId || 'default', autonomy: { L0: 0, L1: 1, L2: 2, L3: 3 }[deployment.default_autonomy] ?? 1, context: null, pending: null };
|
|
1264
1317
|
sessions.set(sessionId, s);
|
|
1265
1318
|
}
|
|
1266
1319
|
const ws = getWs(agentId, s.wsId) || currentWs(agentId);
|
|
@@ -1274,24 +1327,52 @@ async function ensureSession(sessionId, agentId, autonomyLevel, wsId) {
|
|
|
1274
1327
|
}
|
|
1275
1328
|
|
|
1276
1329
|
// 运行一轮 Agent 循环,事件通过 emit(type, data) 推给 SSE;s.running/s.statusText 供跨页面进度跟踪
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1330
|
+
// 不设轮次上限:跑到无更多工具调用自然结束,失控靠用户中断(signal)
|
|
1331
|
+
// signal:用户中断(停止按钮/断开连接/POST /api/chat/interrupt)——轮次间、流中、每个工具执行前检查;
|
|
1332
|
+
// 中断时保留已流出的部分文本为助手消息并补记"(已被用户中断)",未应答的 toolCall 补占位 toolResult 保证下轮上下文合法
|
|
1333
|
+
async function agentLoop(s, deployment, emit, signal = null) {
|
|
1334
|
+
const interrupted = () => !!signal?.aborted;
|
|
1335
|
+
for (let round = 0; ; round++) {
|
|
1336
|
+
if (interrupted()) return { reply: '(已被用户中断)', interrupted: true, pending: s.pending };
|
|
1337
|
+
s.statusText = `第 ${round + 1} 轮 · 推理中`;
|
|
1338
|
+
const stream = models.stream(model, s.context, signal ? { signal } : undefined);
|
|
1281
1339
|
emit('status', { text: '推理中…' });
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1340
|
+
let final = null;
|
|
1341
|
+
try {
|
|
1342
|
+
for await (const ev of stream) {
|
|
1343
|
+
if (interrupted()) break;
|
|
1344
|
+
if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
|
|
1345
|
+
else if (ev.type === 'thinking_start') emit('status', { text: '思考中…' });
|
|
1346
|
+
else if (ev.type === 'toolcall_end') emit('status', { text: `准备调用工具 ${ev.toolCall?.name || ''}` });
|
|
1347
|
+
else if (ev.type === 'error') {
|
|
1348
|
+
if (interrupted()) break; // 中断引发的流错误按中断处理,不报错
|
|
1349
|
+
throw new Error(`LLM 流错误: ${ev.error?.errorMessage || JSON.stringify(ev.error).slice(0, 200)}`);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
final = await stream.result(); // 中止时 pi-ai 以 done(aborted) 收尾,result() 返回已积累的部分消息
|
|
1353
|
+
} catch (e) {
|
|
1354
|
+
if (!interrupted()) throw e; // 真实错误上抛;中断路径用占位消息兜底
|
|
1287
1355
|
}
|
|
1288
|
-
const
|
|
1356
|
+
const aborted = interrupted();
|
|
1357
|
+
if (!final) final = { role: 'assistant', content: [], timestamp: Date.now() };
|
|
1358
|
+
if (aborted) (final.content = final.content || []).push({ type: 'text', text: (final.content.some((b) => b.type === 'text' && b.text) ? '\n\n' : '') + '(已被用户中断)' });
|
|
1289
1359
|
s.context.messages.push(final);
|
|
1290
1360
|
const textNow = (final.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
|
|
1291
1361
|
const calls = (final.content || []).filter(b => b.type === 'toolCall');
|
|
1292
|
-
if (!calls.length)
|
|
1362
|
+
if (!calls.length || aborted) {
|
|
1363
|
+
if (aborted) {
|
|
1364
|
+
emit('interrupted', { at: `第 ${round + 1} 轮` });
|
|
1365
|
+
audit({ actor: 'user', agent: deployment.agent_id, action: 'interrupt-chat', round: round + 1 });
|
|
1366
|
+
return { reply: textNow, interrupted: true, pending: s.pending };
|
|
1367
|
+
}
|
|
1368
|
+
return { reply: textNow, pending: null };
|
|
1369
|
+
}
|
|
1293
1370
|
|
|
1294
1371
|
for (const tc of calls) {
|
|
1372
|
+
if (interrupted()) { // 后续工具不再执行,补占位结果保证工具调用链完整
|
|
1373
|
+
s.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: '(用户中断,未执行)' }], isError: false, timestamp: Date.now() });
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1295
1376
|
const args = tc.arguments || {};
|
|
1296
1377
|
emit('tool_start', { tool: tc.name, args });
|
|
1297
1378
|
s.statusText = `调用工具 ${tc.name}`;
|
|
@@ -1311,14 +1392,18 @@ async function agentLoop(s, deployment, emit, maxRounds = 32) {
|
|
|
1311
1392
|
if (s.pending && s.pending.tool_call_id === tc.id) s.pending = null;
|
|
1312
1393
|
let result, isErr = false;
|
|
1313
1394
|
s.statusText = `执行工具 ${tc.name}`;
|
|
1314
|
-
try { result = await getTool(tc.name).run(args, s.agentId, s.wsId || 'default'); }
|
|
1395
|
+
try { result = await getTool(tc.name).run(args, s.agentId, s.wsId || 'default', signal); }
|
|
1315
1396
|
catch (e) { result = `工具执行失败: ${e.message}`; isErr = true; }
|
|
1316
1397
|
s.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: String(result) }], isError: isErr, timestamp: Date.now() });
|
|
1317
1398
|
emit('tool_end', { tool: tc.name, args, result: String(result), status: 'ok' });
|
|
1318
1399
|
audit({ actor: 'assistant', agent: deployment.agent_id, tool: tc.name, autonomy: s.autonomy, ok: !isErr });
|
|
1319
1400
|
}
|
|
1401
|
+
if (interrupted()) {
|
|
1402
|
+
emit('interrupted', { at: `第 ${round + 1} 轮` });
|
|
1403
|
+
audit({ actor: 'user', agent: deployment.agent_id, action: 'interrupt-chat', round: round + 1 });
|
|
1404
|
+
return { reply: textNow, interrupted: true, pending: s.pending };
|
|
1405
|
+
}
|
|
1320
1406
|
}
|
|
1321
|
-
return { reply: '(达到工具调用轮次上限——回复「继续」可在完整上下文上续跑;也可在工作台 DSL 提高 max_rounds,上限 128)', pending: null };
|
|
1322
1407
|
}
|
|
1323
1408
|
|
|
1324
1409
|
// ── HTTP ────────────────────────────────────────────────
|
|
@@ -1326,7 +1411,7 @@ function json(res, code, data) { res.writeHead(code, { 'Content-Type': 'applicat
|
|
|
1326
1411
|
async function body(req) { let s = ''; for await (const c of req) s += c; return s ? JSON.parse(s) : {}; }
|
|
1327
1412
|
function sseInit(req, res) {
|
|
1328
1413
|
res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
|
|
1329
|
-
return (type, data) => res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
|
|
1414
|
+
return (type, data) => { if (res.writableEnded || res.destroyed) return; res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`); }; // 客户端断开后不再写
|
|
1330
1415
|
}
|
|
1331
1416
|
|
|
1332
1417
|
const server = http.createServer(async (req, res) => {
|
|
@@ -1377,10 +1462,27 @@ const server = http.createServer(async (req, res) => {
|
|
|
1377
1462
|
},
|
|
1378
1463
|
scene: { profile: sceneProfile(), length: sceneProfile().length },
|
|
1379
1464
|
auth: { enabled: !!accessToken(), token_masked: accessToken() ? `${accessToken().slice(0, 4)}…${accessToken().slice(-2)}` : '' },
|
|
1465
|
+
guard: { ...guardNow(), default_deny: GUARD_DEFAULT.deny, default_ro: GUARD_DEFAULT.ro },
|
|
1380
1466
|
});
|
|
1381
1467
|
}
|
|
1382
1468
|
if (req.method === 'PUT' && pathname === '/api/settings') {
|
|
1383
|
-
const { arm_api_base, arm_mcp_url, arm_token, llm_base_url, llm_api_key, llm_model, scene_profile, access_token } = await body(req);
|
|
1469
|
+
const { arm_api_base, arm_mcp_url, arm_token, llm_base_url, llm_api_key, llm_model, scene_profile, access_token, guard } = await body(req);
|
|
1470
|
+
// 守护栏维护:deny 正则可编译 / ro 须为单命令名;保存即热生效(classifyExec 每次调用现读配置);删减危险规则记入审计
|
|
1471
|
+
let guardAudit = null;
|
|
1472
|
+
if (guard !== undefined) {
|
|
1473
|
+
if (!guard || typeof guard !== 'object') return json(res, 400, { error: 'guard 须为对象 { deny, ro, sudo_u_l2 }' });
|
|
1474
|
+
const next = normalizeGuard(guard);
|
|
1475
|
+
for (const src of next.deny) { try { new RegExp(src); } catch (e) { return json(res, 400, { error: `危险清单正则不合法:${src}(${e.message})` }); } }
|
|
1476
|
+
for (const c of next.ro) { if (!/^[A-Za-z0-9_.-]{1,32}$/.test(c)) return json(res, 400, { error: `只读白名单项须为单个命令名(字母数字._-):${c}` }); }
|
|
1477
|
+
const cur = guardNow();
|
|
1478
|
+
guardAudit = {
|
|
1479
|
+
removed_deny: cur.deny.filter((d) => !next.deny.includes(d)),
|
|
1480
|
+
added_deny: next.deny.filter((d) => !cur.deny.includes(d)),
|
|
1481
|
+
removed_ro: cur.ro.filter((c) => !next.ro.includes(c)),
|
|
1482
|
+
sudo_u_l2: next.sudo_u_l2,
|
|
1483
|
+
};
|
|
1484
|
+
cfg.guard = next;
|
|
1485
|
+
}
|
|
1384
1486
|
let authChanged = false;
|
|
1385
1487
|
if (access_token !== undefined) { const t = String(access_token || '').trim(); authChanged = t !== accessToken(); cfg.access_token = t; } // 置空=关闭认证
|
|
1386
1488
|
const normUrl = (u) => { const s = String(u || '').trim().replace(/\/+$/, ''); return s; };
|
|
@@ -1425,7 +1527,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1425
1527
|
try { llm_probe = await llmProbe(); } catch (e) { llm_probe = { online: false, error: String(e.message).slice(0, 150) }; }
|
|
1426
1528
|
}
|
|
1427
1529
|
saveCfg();
|
|
1428
|
-
audit({ actor: 'settings', action: 'save', arm_api_base: cfg.arm_api_base, mcp_explicit: !!cfg.arm_mcp_url, llm_model: cfg.llm_model, llm_base_changed: llm_base_url !== undefined, token_changed: arm_token !== undefined || llm_api_key !== undefined, scene_profile_changed: sceneChanged, scene_profile_len: sceneProfile().length, auth_changed: authChanged });
|
|
1530
|
+
audit({ actor: 'settings', action: 'save', arm_api_base: cfg.arm_api_base, mcp_explicit: !!cfg.arm_mcp_url, llm_model: cfg.llm_model, llm_base_changed: llm_base_url !== undefined, token_changed: arm_token !== undefined || llm_api_key !== undefined, scene_profile_changed: sceneChanged, scene_profile_len: sceneProfile().length, auth_changed: authChanged, guard_changed: guardAudit });
|
|
1429
1531
|
// ARM 保存后即时探测(失败不回滚:允许先配置后启动 ARM)
|
|
1430
1532
|
let probe = { online: false, error: '' };
|
|
1431
1533
|
if ([arm_api_base, arm_mcp_url, arm_token].some((v) => v !== undefined)) {
|
|
@@ -1462,15 +1564,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
1462
1564
|
|
|
1463
1565
|
// ── 助手目录(本地库 ∪ 远端目录;离线时仅本地)──
|
|
1464
1566
|
if (req.method === 'GET' && pathname === '/api/agents') {
|
|
1465
|
-
const
|
|
1567
|
+
const kw = (url.searchParams.get('keyword') || '').trim().toLowerCase();
|
|
1466
1568
|
const reg = loadRegistry();
|
|
1467
1569
|
let online = true, remoteCards = [];
|
|
1468
|
-
try { remoteCards = await armListAgents(keyword); } // REST 全量优先,旧版回落 MCP
|
|
1570
|
+
try { remoteCards = await armListAgents(url.searchParams.get('keyword') || ''); } // REST 全量优先,旧版回落 MCP
|
|
1469
1571
|
catch { online = false; }
|
|
1470
1572
|
const byId = new Map();
|
|
1471
1573
|
for (const e of reg.agents) {
|
|
1472
1574
|
const lm = localManifest(e.agent_id) || {};
|
|
1575
|
+
// 搜索时本地库同样按关键词过滤(名称/描述),与远端搜索结果口径一致
|
|
1576
|
+
if (kw && !(String(e.name || '').toLowerCase().includes(kw) || String(e.description || lm.description || '').toLowerCase().includes(kw))) continue;
|
|
1473
1577
|
byId.set(e.agent_id, {
|
|
1578
|
+
created_via_workstation: !!e.created_via_workstation, // 工作站新建的助手:大厅提供编辑档案/从 ARM 删除
|
|
1474
1579
|
agent_id: e.agent_id, name: e.name, version: e.installed_version,
|
|
1475
1580
|
skills: (lm.skills || []).length, knowledges: (lm.knowledges || []).length,
|
|
1476
1581
|
description: e.description || lm.description || '',
|
|
@@ -1480,7 +1585,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1480
1585
|
delisted: !!e.delisted, downloaded_at: e.downloaded_at || null,
|
|
1481
1586
|
});
|
|
1482
1587
|
}
|
|
1483
|
-
if (online) { // 静默刷新远端版本与下架标记(显式事件统计走 /api/arm/sync
|
|
1588
|
+
if (online) { // 静默刷新远端版本与下架标记(显式事件统计走 /api/arm/sync)——仅在完整拉取时;
|
|
1589
|
+
// 搜索结果是全量的子集,若据此刷新会把未命中的本地助手误标"已下架"
|
|
1484
1590
|
const ids = new Set(remoteCards.map(c => c.agent_id));
|
|
1485
1591
|
for (const c of remoteCards) {
|
|
1486
1592
|
const cur = byId.get(c.agent_id) || { agent_id: c.agent_id, installed: false, installed_version: null, downloaded_at: null };
|
|
@@ -1491,15 +1597,98 @@ const server = http.createServer(async (req, res) => {
|
|
|
1491
1597
|
update_available: cur.installed ? cmpVersion(c.version, cur.installed_version) > 0 : false,
|
|
1492
1598
|
});
|
|
1493
1599
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1600
|
+
if (!kw) {
|
|
1601
|
+
for (const e of reg.agents) {
|
|
1602
|
+
if (ids.has(e.agent_id)) { e.remote_version = byId.get(e.agent_id)?.remote_version || e.remote_version; e.delisted = false; }
|
|
1603
|
+
else e.delisted = true;
|
|
1604
|
+
}
|
|
1605
|
+
reg.last_sync_at = new Date().toISOString();
|
|
1606
|
+
saveRegistry(reg);
|
|
1497
1607
|
}
|
|
1498
|
-
reg.last_sync_at = new Date().toISOString();
|
|
1499
|
-
saveRegistry(reg);
|
|
1500
1608
|
}
|
|
1501
1609
|
return json(res, 200, { online, last_sync_at: reg.last_sync_at || null, agents: [...byId.values()] });
|
|
1502
1610
|
}
|
|
1611
|
+
// ── 新建助手:本地起草(AI 生成 prompt)→ ARM JSON 创建(创建即发布)→ 可选知识绑定(幂等)→ 下载物化 ──
|
|
1612
|
+
if (req.method === 'POST' && pathname === '/api/agents/draft-prompt') {
|
|
1613
|
+
const { description, name } = await body(req);
|
|
1614
|
+
if (!String(description || '').trim()) return json(res, 400, { error: '请先填写助手职责描述' });
|
|
1615
|
+
try {
|
|
1616
|
+
const ctx = {
|
|
1617
|
+
systemPrompt: '你是助手档案起草器。根据用户对助手职责的描述写出可直接使用的中文 System Prompt:第一句角色定位;随后 3-6 条职责要点(每条一个短句);最后 1-2 条工作方式或能力边界约束。只输出 Prompt 正文,不要标题、不要解释,200 字以内。',
|
|
1618
|
+
messages: [{ role: 'user', content: `助手名称:${String(name || '').trim() || '(待定)'}\n职责描述:${String(description).trim()}\n\n请生成 System Prompt:`, timestamp: Date.now() }],
|
|
1619
|
+
tools: [],
|
|
1620
|
+
};
|
|
1621
|
+
const stream = models.stream(model, ctx);
|
|
1622
|
+
let out = '';
|
|
1623
|
+
for await (const ev of stream) { if (ev.type === 'text_delta') out += ev.delta; }
|
|
1624
|
+
const final = await stream.result();
|
|
1625
|
+
const text = ((final.content || []).filter(b => b.type === 'text').map(b => b.text).join('') || out).trim();
|
|
1626
|
+
return json(res, 200, { prompt: text });
|
|
1627
|
+
} catch (e) { return json(res, 502, { error: `起草失败: ${e.message}` }); }
|
|
1628
|
+
}
|
|
1629
|
+
if (req.method === 'GET' && pathname === '/api/agents/knowledges') { // 新建向导的知识选择器:ARM 关键词检索
|
|
1630
|
+
const kwd = url.searchParams.get('keyword') || '';
|
|
1631
|
+
try {
|
|
1632
|
+
const d = await armRest(`/api/v1/knowledges?search=${encodeURIComponent(kwd)}&page=1&pageSize=20`);
|
|
1633
|
+
const items = (Array.isArray(d) ? d : (d.items || d.knowledges || [])).map((k) => ({ id: k.id, name: k.name, description: String(k.description || '').slice(0, 80) }));
|
|
1634
|
+
return json(res, 200, { items });
|
|
1635
|
+
} catch (e) { return json(res, 200, { items: [], error: String(e.message).slice(0, 100) }); }
|
|
1636
|
+
}
|
|
1637
|
+
if (req.method === 'POST' && pathname === '/api/agents/create') {
|
|
1638
|
+
const { name, description, prompt, knowledge_ids } = await body(req);
|
|
1639
|
+
const nm = String(name || '').trim(), pr = String(prompt || '').trim();
|
|
1640
|
+
if (!nm || !pr) return json(res, 400, { error: '名称与 System Prompt 必填' });
|
|
1641
|
+
try {
|
|
1642
|
+
const created = await armRest('/api/v1/agents', { method: 'POST', body: JSON.stringify({ name: nm, description: String(description || '').trim().slice(0, 512), prompt: pr, status: 'active' }) });
|
|
1643
|
+
const newId = created?.id || created?.agent?.id;
|
|
1644
|
+
if (!newId) throw new Error('ARM 未返回新助手 id');
|
|
1645
|
+
let bound = 0;
|
|
1646
|
+
for (const kid of (Array.isArray(knowledge_ids) ? knowledge_ids : [])) { // 绑定幂等(重复绑定返回已有版本)
|
|
1647
|
+
try { await armRest(`/api/v1/agents/${newId}/knowledges`, { method: 'POST', body: JSON.stringify({ knowledgeId: kid, kind: 'experience' }) }); bound++; }
|
|
1648
|
+
catch { /* 单条绑定失败不阻断创建 */ }
|
|
1649
|
+
}
|
|
1650
|
+
const manifest = await downloadAgent(newId); // 创建即物化本地(沿用下载链路)
|
|
1651
|
+
const reg = loadRegistry(); const entry = reg.agents.find((x) => x.agent_id === newId); if (entry) entry.created_via_workstation = true; saveRegistry(reg); // 大厅据此显示编辑/删除入口
|
|
1652
|
+
audit({ actor: 'user', action: 'create-agent', agentId: newId, name: nm, knowledges: bound });
|
|
1653
|
+
return json(res, 200, { agent_id: newId, manifest, knowledges_bound: bound });
|
|
1654
|
+
} catch (e) { return json(res, 502, { error: `创建失败: ${e.message}` }); }
|
|
1655
|
+
}
|
|
1656
|
+
// 编辑档案(工作站创建的助手):ARM PUT 后重新物化,本地 prompt 同步更新
|
|
1657
|
+
if (req.method === 'GET' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}\/profile$/)) {
|
|
1658
|
+
const id = pathname.split('/')[3];
|
|
1659
|
+
try {
|
|
1660
|
+
const d = await armRest(`/api/v1/agents/${id}`);
|
|
1661
|
+
return json(res, 200, { name: d.name, description: String(d.description || ''), prompt: d.prompt || '' });
|
|
1662
|
+
} catch (e) { return json(res, 502, { error: `档案读取失败: ${e.message}` }); }
|
|
1663
|
+
}
|
|
1664
|
+
if (req.method === 'PUT' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}$/)) {
|
|
1665
|
+
const id = pathname.split('/')[3];
|
|
1666
|
+
const { name, description, prompt } = await body(req);
|
|
1667
|
+
const patch = {};
|
|
1668
|
+
if (String(name || '').trim()) patch.name = String(name).trim();
|
|
1669
|
+
if (description !== undefined) patch.description = String(description || '').trim().slice(0, 512);
|
|
1670
|
+
if (String(prompt || '').trim()) patch.prompt = String(prompt).trim();
|
|
1671
|
+
if (!Object.keys(patch).length) return json(res, 400, { error: '没有可更新字段' });
|
|
1672
|
+
try {
|
|
1673
|
+
await armRest(`/api/v1/agents/${id}`, { method: 'PUT', body: JSON.stringify(patch) });
|
|
1674
|
+
let manifest = null;
|
|
1675
|
+
try { manifest = await downloadAgent(id); } catch { /* 本地未安装时无需物化 */ }
|
|
1676
|
+
audit({ actor: 'user', action: 'update-agent', agentId: id, fields: Object.keys(patch) });
|
|
1677
|
+
return json(res, 200, { ok: true, manifest });
|
|
1678
|
+
} catch (e) { return json(res, 502, { error: `更新失败: ${e.message}` }); }
|
|
1679
|
+
}
|
|
1680
|
+
// 从 ARM 删除(危险操作,前端二次确认):可选同时卸载本地档案
|
|
1681
|
+
if (req.method === 'POST' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}\/arm-delete$/)) {
|
|
1682
|
+
const id = pathname.split('/')[3];
|
|
1683
|
+
const { also_uninstall, purge_workspaces } = await body(req);
|
|
1684
|
+
try {
|
|
1685
|
+
await armRest(`/api/v1/agents/${id}`, { method: 'DELETE' });
|
|
1686
|
+
let uninstalled = false;
|
|
1687
|
+
if (also_uninstall && loadRegistry().agents.some((a) => a.agent_id === id)) uninstalled = uninstallLocalAgent(id, !!purge_workspaces);
|
|
1688
|
+
audit({ actor: 'user', action: 'arm-delete-agent', agentId: id, also_uninstalled: uninstalled });
|
|
1689
|
+
return json(res, 200, { ok: true, uninstalled });
|
|
1690
|
+
} catch (e) { return json(res, 502, { error: `ARM 删除失败: ${e.message}` }); }
|
|
1691
|
+
}
|
|
1503
1692
|
// ── 本地助手库:下载 / 升级 / 版本历史 / 回滚 / 卸载 ──
|
|
1504
1693
|
let mAgt = pathname.match(/^\/api\/agents\/([0-9a-f-]{36})\/(download|upgrade|versions|rollback)$/);
|
|
1505
1694
|
if (mAgt && req.method === 'POST' && (mAgt[2] === 'download' || mAgt[2] === 'upgrade')) {
|
|
@@ -1538,15 +1727,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1538
1727
|
if (loadDeployment(agentId) && !force) {
|
|
1539
1728
|
return json(res, 409, { error: '该助手在岗:卸载将同时删除上岗配置与工作台定义', hint: '确认卸载请带 force=true(可另带 purge_workspaces=true 清除工作区数据)' });
|
|
1540
1729
|
}
|
|
1541
|
-
|
|
1542
|
-
try { fs.rmSync(p, { force: true }); } catch { }
|
|
1543
|
-
}
|
|
1544
|
-
try { fs.rmSync(path.join(ARM_AGENTS_DIR, agentId), { recursive: true, force: true }); } catch { }
|
|
1545
|
-
const reg = loadRegistry(); reg.agents = reg.agents.filter(a => a.agent_id !== agentId); saveRegistry(reg);
|
|
1546
|
-
if (purge_workspaces) {
|
|
1547
|
-
try { fs.rmSync(wsDir(agentId), { recursive: true, force: true }); } catch { }
|
|
1548
|
-
for (const f of fs.readdirSync(DATA_DIR)) if (f.startsWith(`ws-${agentId}-`)) { try { fs.rmSync(path.join(DATA_DIR, f), { force: true }); } catch { } }
|
|
1549
|
-
}
|
|
1730
|
+
uninstallLocalAgent(agentId, !!purge_workspaces);
|
|
1550
1731
|
audit({ actor: 'arm-library', agentId, action: 'uninstall', purged_workspaces: !!purge_workspaces });
|
|
1551
1732
|
return json(res, 200, { ok: true });
|
|
1552
1733
|
}
|
|
@@ -1873,6 +2054,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
1873
2054
|
if (req.method === 'POST' && pathname === '/api/dev/chat') {
|
|
1874
2055
|
const { sessionId, agentId, message } = await body(req);
|
|
1875
2056
|
const emit = sseInit(req, res);
|
|
2057
|
+
const ctl = new AbortController();
|
|
2058
|
+
let finished = false;
|
|
2059
|
+
req.on('close', () => { if (!finished) ctl.abort(); }); // 断开即中断定制循环(无轮次上限后的失控出口)
|
|
1876
2060
|
try {
|
|
1877
2061
|
devCtx.agentId = agentId;
|
|
1878
2062
|
if (!loadWorkbench(agentId)) return emit('error', { message: '该助手尚无工作台' }), res.end();
|
|
@@ -1884,9 +2068,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
1884
2068
|
s.context.systemPrompt = devSystemPrompt();
|
|
1885
2069
|
}
|
|
1886
2070
|
s.context.messages.push({ role: 'user', content: message, timestamp: Date.now() });
|
|
1887
|
-
await devChatLoop(s, emit);
|
|
2071
|
+
await devChatLoop(s, emit, ctl.signal);
|
|
1888
2072
|
emit('meta', { hasDraft: !!loadDraft(agentId) });
|
|
1889
2073
|
} catch (e) { emit('error', { message: e.message }); }
|
|
2074
|
+
finally { finished = true; }
|
|
1890
2075
|
return res.end();
|
|
1891
2076
|
}
|
|
1892
2077
|
|
|
@@ -1898,10 +2083,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1898
2083
|
if (req.method === 'POST' && pathname === '/api/chat') {
|
|
1899
2084
|
const { sessionId, agentId, message, autonomyLevel, context, ws_id } = await body(req);
|
|
1900
2085
|
const emit = sseInit(req, res);
|
|
2086
|
+
const ctl = new AbortController();
|
|
2087
|
+
let finished = false;
|
|
2088
|
+
req.on('close', () => { if (!finished) ctl.abort(); }); // 客户端断开(停止按钮/关页面)→ 中断后台循环
|
|
1901
2089
|
try {
|
|
1902
2090
|
const { s, deployment, armAgent } = await ensureSession(sessionId, agentId, autonomyLevel, ws_id);
|
|
1903
2091
|
if (s.running) { // 会话在后台仍在处理上一条消息(跨页面进度跟踪)
|
|
1904
|
-
emit('error', { message: `该会话正在后台处理(${s.statusText || '进行中'}
|
|
2092
|
+
emit('error', { message: `该会话正在后台处理(${s.statusText || '进行中'})——可先切换其他助手,稍后回来进度会自动恢复;也可在状态行点「中断」` });
|
|
1905
2093
|
return res.end();
|
|
1906
2094
|
}
|
|
1907
2095
|
if (s.pending) {
|
|
@@ -1909,19 +2097,28 @@ const server = http.createServer(async (req, res) => {
|
|
|
1909
2097
|
return res.end();
|
|
1910
2098
|
}
|
|
1911
2099
|
s.context.messages.push({ role: 'user', content: context ? `${message}\n(界面注入上下文:当前选中设备 ${context.device})` : message, timestamp: Date.now() });
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
const out = await agentLoop(s, deployment, emit, Math.min(128, Math.max(1, +(wb.max_rounds) || 32)));
|
|
2100
|
+
s.running = true; s.sinceTs = Date.now(); s.abortCtl = ctl;
|
|
2101
|
+
const out = await agentLoop(s, deployment, emit, ctl.signal);
|
|
1915
2102
|
emit('done', { reply: out.reply, pending: out.pending });
|
|
1916
2103
|
} catch (e) { emit('error', { message: e.message }); }
|
|
1917
|
-
finally { const s = sessions.get(sessionId); if (s) { s.running = false; s.statusText = ''; } }
|
|
2104
|
+
finally { finished = true; const s = sessions.get(sessionId); if (s) { s.running = false; s.statusText = ''; s.abortCtl = null; } }
|
|
1918
2105
|
return res.end();
|
|
1919
2106
|
}
|
|
2107
|
+
if (req.method === 'POST' && pathname === '/api/chat/interrupt') {
|
|
2108
|
+
const { sessionId } = await body(req);
|
|
2109
|
+
const s = sessions.get(sessionId);
|
|
2110
|
+
if (s?.running && s.abortCtl) { s.abortCtl.abort(); return json(res, 200, { ok: true, interrupted: true }); } // 幂等:循环在下一个检查点退出
|
|
2111
|
+
return json(res, 200, { ok: true, interrupted: false, note: '当前没有进行中的对话' });
|
|
2112
|
+
}
|
|
1920
2113
|
if (req.method === 'POST' && pathname === '/api/chat/confirm') {
|
|
1921
2114
|
const { sessionId, approved } = await body(req);
|
|
1922
2115
|
const emit = sseInit(req, res);
|
|
2116
|
+
const ctl = new AbortController();
|
|
2117
|
+
let finished = false;
|
|
2118
|
+
let s = null;
|
|
2119
|
+
req.on('close', () => { if (!finished) ctl.abort(); });
|
|
1923
2120
|
try {
|
|
1924
|
-
|
|
2121
|
+
s = sessions.get(sessionId);
|
|
1925
2122
|
if (!s || !s.pending) return emit('error', { message: '无挂起操作' }), res.end();
|
|
1926
2123
|
const deployment = loadDeployment(s.agentId);
|
|
1927
2124
|
const { tool_call_id, tool, args } = s.pending; s.pending = null;
|
|
@@ -1931,17 +2128,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
1931
2128
|
} else {
|
|
1932
2129
|
emit('tool_start', { tool, args });
|
|
1933
2130
|
let result, isErr = false;
|
|
1934
|
-
try { result = await getTool(tool).run(args, s.agentId, s.wsId || 'default'); } catch (e) { result = `执行失败: ${e.message}`; isErr = true; }
|
|
2131
|
+
try { result = await getTool(tool).run(args, s.agentId, s.wsId || 'default', ctl.signal); } catch (e) { result = `执行失败: ${e.message}`; isErr = true; }
|
|
1935
2132
|
s.context.messages.push({ role: 'toolResult', toolCallId: tool_call_id, toolName: tool, content: [{ type: 'text', text: String(result) }], isError: isErr, timestamp: Date.now() });
|
|
1936
2133
|
emit('tool_end', { tool, args, result: String(result), status: 'executed' });
|
|
1937
2134
|
audit({ actor: 'human-confirm', agent: s.agentId, tool, autonomy: s.autonomy, ok: !isErr });
|
|
1938
2135
|
}
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
const out = await agentLoop(s, deployment, emit, Math.min(128, Math.max(1, +(wb.max_rounds) || 32)));
|
|
2136
|
+
s.running = true; s.sinceTs = Date.now(); s.abortCtl = ctl;
|
|
2137
|
+
const out = await agentLoop(s, deployment, emit, ctl.signal);
|
|
1942
2138
|
emit('done', { reply: out.reply, pending: out.pending });
|
|
1943
2139
|
} catch (e) { emit('error', { message: e.message }); }
|
|
1944
|
-
finally { if (s) { s.running = false; s.statusText = ''; } }
|
|
2140
|
+
finally { finished = true; if (s) { s.running = false; s.statusText = ''; s.abortCtl = null; } }
|
|
1945
2141
|
return res.end();
|
|
1946
2142
|
}
|
|
1947
2143
|
// ── 会话历史与进度(切换助手后回来:上下文回放 + 后台运行状态;服务端为唯一事实源)──
|
|
@@ -2065,6 +2261,14 @@ if (process.argv.includes('--test-guard')) {
|
|
|
2065
2261
|
['exec', 3, { command: 'echo hi > /tmp/a.txt' }, 'ok'],
|
|
2066
2262
|
['exec', 3, { command: 'sudo reboot' }, 'blocked'],
|
|
2067
2263
|
];
|
|
2264
|
+
// 守护栏可配置(系统设置·守护栏):sudo_u_l2 开启后,`sudo -u <用户>` 降权按写命令走 L2 确认;裸 sudo 与嵌套危险命令仍拒绝
|
|
2265
|
+
cfg.guard = normalizeGuard({ ...guardNow(), sudo_u_l2: true });
|
|
2266
|
+
cases.push(
|
|
2267
|
+
['exec', 2, { command: "sshpass -p x ssh -o StrictHostKeyChecking=no root@192.0.2.1 'sudo -u aims bash -c \"npm install -g aims-workstation\"' 2>&1 | tail -40" }, 'pending'],
|
|
2268
|
+
['exec', 0, { command: 'sudo -u aims npm install -g x' }, 'blocked'],
|
|
2269
|
+
['exec', 2, { command: "sudo -u aims bash -c 'sudo reboot'" }, 'blocked'],
|
|
2270
|
+
['exec', 2, { command: 'sudo apt install -y x' }, 'blocked'],
|
|
2271
|
+
);
|
|
2068
2272
|
let pass = true;
|
|
2069
2273
|
for (const [tool, lv, args, want] of cases) {
|
|
2070
2274
|
const g = guardCheck(tool, lv, null, args); const ok = g.status === want; if (!ok) pass = false;
|