aims-workstation 0.1.3 → 0.1.5

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/.env.example CHANGED
@@ -2,15 +2,19 @@
2
2
  PORT=5600
3
3
 
4
4
  # 平台 LLM(OpenAI 兼容端点;也可在「系统设置」页运行时切换)
5
- LLM_BASE_URL=https://open.bigmodel.cn/api/coding/paas/v4
6
- LLM_API_KEY=<your-llm-api-key>
7
- LLM_MODEL=glm-5.3
5
+ # 缺省公司内网 bifrost 网关(内网可达即开箱可用);以下三行留空/删除即用默认
6
+ LLM_BASE_URL=http://bifrost.infra.dev.aimstek.cn/v1
7
+ LLM_API_KEY=def
8
+ LLM_MODEL=def
8
9
 
9
- # ARM 系统(人事系统:助手档案 + 经验库)
10
- ARM_MCP_URL=https://your-arm-host/api/mcp
11
- # PAT 留空则自动读取 ~/.arm/config.json 的 token(仅经验写入需要)
10
+ # ARM 系统(助手档案 + 经验库):站点地址缺省公司内网 http://arm.agent-platform.dev.aimstek.cn,留空即用默认
11
+ # 匿名可浏览目录;下载/上岗/新建/经验回流需令牌——到 ARM 生成访问令牌(arm_pat_…)后填入,或在「系统设置 · ARM 源」粘贴
12
+ # ARM_MCP_URL=https://your-arm-host/api/mcp # 可选:私有化部署时覆盖
13
+ # ARM_API_BASE=http://localhost:3001 # 可选:本地联调 ARM 时启用
14
+ # PAT 留空则自动读取 ~/.arm/config.json 的 token
12
15
  ARM_PAT=
13
-
14
- # ARM REST API 默认值(v2:助手检索/下载 + 知识混合检索;运行时可在「系统设置」页切换源)
15
- ARM_API_BASE=http://localhost:3001
16
16
  ARM_API_TOKEN=<arm_pat_xxx-or-leave-empty>
17
+
18
+ # npm 发版令牌(npmjs.com → Access Tokens 生成;供 scripts/publish-npm.sh 使用,发布免浏览器授权)
19
+ # 选 Granular(限定本包、Read and write)或 Classic → Automation
20
+ # NPM_TOKEN=npm_xxxxxxxxxxxxxxxxxxxx
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  - **工作区**:助手 1:N 工作区(文件现场隔离),`exec` 锚定工作区目录,数据集按工作区分库
11
11
  - **分级自主**:L0 只读 → L2 确认执行(写操作挂起人工确认)→ L3 自主,危险命令永久拒绝;全程审计
12
12
  - **守护栏可维护**:危险命令清单(正则)与只读白名单在「系统设置·守护栏」增删,保存即热生效;`sudo -u <用户>` 降权形式可配置为 L2 确认执行;删减危险规则需二次确认并写审计
13
- - **并行会话**:多助手并行、后台运行进度跟踪、确认卡跨切换恢复
13
+ - **并行会话**:多助手并行、后台运行进度跟踪、确认卡跨切换恢复;对话可随时中断(停止按钮/状态行中断,执行中的命令一并终止,部分回复保留进上下文可续聊)
14
14
  - **系统设置**:ARM 源 / LLM(任意 OpenAI 兼容端点)/ 全局场景画像 / 守护栏策略,界面配置即时生效
15
15
 
16
16
  ## 快速开始
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.3",
16
+ "version": "0.1.5",
17
17
  "description": "AIMS 工作站:本地智能体工作站——ARM 助手本地化(下载/离线/升级)、工作台对话式定制、多工作区隔离、分级自主与审计",
18
18
  "bin": {
19
19
  "aims-workstation": "bin/aims-workstation.js"
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env bash
2
+ # 发布到 npm 公共仓(令牌免浏览器 2FA 授权)
3
+ #
4
+ # 令牌配置(一次性):在包根目录 .env 里加一行
5
+ # NPM_TOKEN=npm_xxxxxxxxxxxx
6
+ # 令牌到 npmjs.com → 头像 → Access Tokens 生成:
7
+ # 推荐 Granular(限定 aims-workstation 包、Read and write、可设过期时间);
8
+ # 选 Classic 时必须用 Automation 类型——Publish 类型在 2FA 开启下仍会要求验证码。
9
+ # CI/临时覆盖可用环境变量:NPM_TOKEN=npm_xxx ./scripts/publish-npm.sh
10
+ # ⚠ 令牌不要写进本文件:scripts/ 在 package.json 的 files 清单里,会随 npm 包发布出去
11
+ #
12
+ # 用法(在 aims-workstation/ 目录下):
13
+ # ./scripts/publish-npm.sh 发布当前版本
14
+ # ./scripts/publish-npm.sh patch 先升版本(patch/minor/major/1.2.3)再发布
15
+ set -euo pipefail
16
+ cd "$(dirname "$0")/.."
17
+
18
+ REGISTRY="${NPM_REGISTRY:-https://registry.npmjs.org/}"
19
+
20
+ # 令牌:环境变量 > .env 的 NPM_TOKEN(不 source 整个 .env,只取这一行)
21
+ TOKEN="${NPM_TOKEN:-}"
22
+ if [[ -z "$TOKEN" && -f .env ]]; then
23
+ TOKEN="$(sed -n 's/^NPM_TOKEN=//p' .env | tail -n1 | tr -d "[:space:]\"'")"
24
+ fi
25
+ if [[ -z "$TOKEN" ]]; then
26
+ echo "✗ 未找到 NPM_TOKEN(环境变量或 .env)。到 npmjs.com → Access Tokens 生成后执行:"
27
+ echo " echo 'NPM_TOKEN=npm_你的令牌' >> .env"
28
+ exit 1
29
+ fi
30
+ case "$TOKEN" in
31
+ npm_*) ;;
32
+ *) echo "⚠ 令牌不以 npm_ 开头(npmjs.org 令牌通常以 npm_ 开头),请确认来源" ;;
33
+ esac
34
+
35
+ if [[ $# -ge 1 ]]; then
36
+ npm version "$1" --no-git-tag-version
37
+ fi
38
+ NAME="$(node -p "require('./package.json').name")"
39
+ VERSION="$(node -p "require('./package.json').version")"
40
+ echo "→ 发布 $NAME@$VERSION → $REGISTRY"
41
+
42
+ # 令牌经临时 userconfig 传入,不写 ~/.npmrc、不落盘
43
+ CFG="$(mktemp)"
44
+ trap 'rm -f "$CFG"' EXIT
45
+ HOST_KEY="$(echo "$REGISTRY" | sed -E 's|^https?://||; s|/+$||')"
46
+ printf '//%s/:_authToken=%s\n' "$HOST_KEY" "$TOKEN" > "$CFG"
47
+
48
+ echo "→ 身份: $(NPM_CONFIG_USERCONFIG="$CFG" npm whoami --registry="$REGISTRY" 2>/dev/null || echo '未知(Granular 令牌不支持 whoami,属正常,直接尝试发布)')"
49
+ NPM_CONFIG_USERCONFIG="$CFG" npm publish --registry="$REGISTRY"
50
+ echo "✅ 已发布 $NAME@$VERSION"
51
+ echo " 安装: npx $NAME@$VERSION"
52
+ if [[ $# -ge 1 ]]; then
53
+ echo "ℹ 版本号已变更,记得提交 package.json / package-lock.json"
54
+ fi
package/server.js CHANGED
@@ -67,13 +67,13 @@ for (const envFile of [path.join(PKG_ROOT, '.env'), path.join(AIMS_HOME, '.env')
67
67
  try {
68
68
  for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
69
69
  const m = line.match(/^\s*([A-Z_]+)\s*=\s*(.*)\s*$/);
70
- if (m && env[m[1]] === undefined) env[m[1]] = m[2];
70
+ // 跳过模板占位符(<xxx-or-leave-empty> 之类):直接复制 .env.example 为 .env 时不应产生毒害值
71
+ if (m && env[m[1]] === undefined && !/^<[^>]*>$/.test(m[2])) env[m[1]] = m[2];
71
72
  }
72
73
  } catch { /* .env 可选 */ }
73
74
  }
74
75
  const PORT = +(process.env.PORT || env.PORT || 5600);
75
76
  const HOST = process.env.AIMS_HOST || '0.0.0.0'; // 默认局域网可达(团队共用);仅本机使用可设 AIMS_HOST=127.0.0.1
76
- const ARM_MCP_URL = process.env.ARM_MCP_URL || env.ARM_MCP_URL;
77
77
  // ── 运行时配置(ARM 源与 LLM 均可私有化部署:在「系统设置」页切换,存 AIMS_HOME/config/platform.json,优先级 运行时 > 环境变量 > .env)──
78
78
  const CFG_FILE = path.join(AIMS_HOME, 'config/platform.json');
79
79
  const cfg = { arm_api_base: '', arm_mcp_url: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', scene_profile: '', access_token: '' };
@@ -96,15 +96,16 @@ function loadCfg() { try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_FIL
96
96
  loadCfg();
97
97
  const guardNow = () => cfg.guard;
98
98
  function saveCfg() { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg, null, 2)); }
99
- // 站点地址:运行时配置 > ARM_API_BASE(env) > 从 MCP URL 推导;MCP 端点:显式配置 > 跟随站点地址 > env
100
- const armApiBase = () => cfg.arm_api_base || process.env.ARM_API_BASE || env.ARM_API_BASE || String(env.ARM_MCP_URL || '').replace(/\/api\/mcp$/, '');
101
- const armMcpUrl = () => cfg.arm_mcp_url || (cfg.arm_api_base ? `${cfg.arm_api_base}/api/mcp` : ARM_MCP_URL);
99
+ // ARM 站点地址:运行时配置 > ARM_API_BASE(env) > 从 MCP URL 推导 > 公司内网默认(内网可达即开箱可用);MCP 端点:显式配置 > 跟随站点地址
100
+ const ARM_DEFAULT_BASE = 'http://arm.agent-platform.dev.aimstek.cn';
101
+ const armApiBase = () => cfg.arm_api_base || process.env.ARM_API_BASE || env.ARM_API_BASE || (env.ARM_MCP_URL ? String(env.ARM_MCP_URL).replace(/\/api\/mcp$/, '') : ARM_DEFAULT_BASE);
102
+ const armMcpUrl = () => cfg.arm_mcp_url || `${armApiBase()}/api/mcp`;
102
103
  const armApiToken = () => cfg.arm_token || env.ARM_API_TOKEN || ARM_PAT;
103
- // LLM:OpenAI 兼容端点(baseUrl/模型/密钥),缺省智谱 coding plan
104
- const LLM_DEFAULT_BASE = 'https://open.bigmodel.cn/api/coding/paas/v4';
104
+ // LLM:OpenAI 兼容端点(baseUrl/模型/密钥),缺省公司内网 bifrost 网关(内网可达即可用,无需配置)
105
+ const LLM_DEFAULT_BASE = 'http://bifrost.infra.dev.aimstek.cn/v1';
105
106
  const llmBaseUrl = () => cfg.llm_base_url || env.LLM_BASE_URL || LLM_DEFAULT_BASE;
106
- const llmApiKey = () => cfg.llm_api_key || env.LLM_API_KEY || '';
107
- const llmModelName = () => cfg.llm_model || env.LLM_MODEL || 'glm-5.3';
107
+ const llmApiKey = () => cfg.llm_api_key || env.LLM_API_KEY || 'def';
108
+ const llmModelName = () => cfg.llm_model || env.LLM_MODEL || 'def';
108
109
  // 场景画像:平台级、全局一份的用户场景描述(团队环境/系统清单/规范),注入所有助手;空则不注入。
109
110
  // 与知识检索互补(常驻事实 vs 按需检索);凭据不进画像(放"凭据在哪、找谁要")。
110
111
  const sceneProfile = () => String(cfg.scene_profile || '').trim();
@@ -214,7 +215,10 @@ async function armRest(pathname, init = {}, timeout_ms = 20000) {
214
215
  try {
215
216
  const res = await fetch(armApiBase() + pathname, { ...init, headers, signal: ctrl.signal });
216
217
  const data = await res.json();
217
- if (!res.ok || data.ok === false) throw new Error(`ARM REST ${pathname.split('?')[0]}: ${data.msg || res.status}`);
218
+ if (!res.ok || data.ok === false) {
219
+ const noAuth = res.status === 401 || res.status === 403 || /401|403|unauthorized|forbidden/i.test(String(data.msg || ''));
220
+ throw new Error(`ARM REST ${pathname.split('?')[0]}: ${data.msg || res.status}${noAuth ? '——令牌未配置或无权限:到 ARM(' + armApiBase() + ')生成访问令牌(arm_pat_…)后,粘贴到「系统设置 · ARM 源 · 访问令牌」' : ''}`);
221
+ }
218
222
  return data.data;
219
223
  } catch (e) {
220
224
  if (e.message === 'fetch failed') { // undici 把网络层错误笼统包装,展开真实原因(DNS/连接/TLS)
@@ -267,7 +271,7 @@ async function armListAgents(keyword = '') {
267
271
  out.push(...(d.agents || []));
268
272
  if (!out.length || out.length >= +(d.total || 0)) break;
269
273
  }
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' }));
274
+ 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 || '' }));
271
275
  }
272
276
 
273
277
  // 知识检索(全局经验库):REST 混合检索(RAG 语义 ⊕ 关键词)优先,回落 MCP RAG
@@ -331,6 +335,19 @@ function commitDownload(manifest) {
331
335
  e.versions.sort(cmpVersion);
332
336
  saveRegistry(reg);
333
337
  }
338
+ // 本地卸载(本地 DELETE 与「从 ARM 删除并卸载」共用):上岗配置/工作台/版本目录/注册表,可选清工作区数据
339
+ function uninstallLocalAgent(agentId, purge_workspaces) {
340
+ 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`)]) {
341
+ try { fs.rmSync(p, { force: true }); } catch { }
342
+ }
343
+ try { fs.rmSync(path.join(ARM_AGENTS_DIR, agentId), { recursive: true, force: true }); } catch { }
344
+ const reg = loadRegistry(); reg.agents = reg.agents.filter(a => a.agent_id !== agentId); saveRegistry(reg);
345
+ if (purge_workspaces) {
346
+ try { fs.rmSync(wsDir(agentId), { recursive: true, force: true }); } catch { }
347
+ for (const f of fs.readdirSync(DATA_DIR)) if (f.startsWith(`ws-${agentId}-`)) { try { fs.rmSync(path.join(DATA_DIR, f), { force: true }); } catch { } }
348
+ }
349
+ return true;
350
+ }
334
351
 
335
352
  async function downloadAgentRest(agentId) {
336
353
  const d = await armRest(`/api/v1/agents/${agentId}`);
@@ -563,17 +580,18 @@ const PRIMITIVE_TOOLS = {
563
580
  desc: '通用终端命令执行。工作目录(cwd)= 当前工作区;连通性实测用 nc -vz -w 3 host port;平台日志用绝对路径 tail(见系统提示)。只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝。不要期待专用工具,优先用命令组合能力',
564
581
  schema: { command: { type: 'string', description: '要执行的 shell 命令' }, timeout_ms: { type: 'number', description: '超时毫秒,默认 15000' } },
565
582
  required: ['command'],
566
- run: async ({ command, timeout_ms = 15000 }, agentId) => {
583
+ run: async ({ command, timeout_ms = 15000 }, agentId, _wsId, signal) => {
567
584
  const env = { ...process.env };
568
585
  delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT; // 凭据不进子进程
569
586
  const ws = agentId ? currentWs(agentId) : null; // 文件领地锚定工作区(07 设计 §4.3)
570
587
  return await new Promise((resolve) => {
571
- cpExec(command, { cwd: ws ? ws.path : ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell: process.env.SHELL || '/bin/zsh' }, (err, stdout, stderr) => {
588
+ 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) => {
572
589
  const out = String(stdout || '').slice(0, 4000);
573
590
  const errOut = String(stderr || '').slice(0, 1500);
574
591
  audit({ actor: 'assistant', tool: 'exec', command: String(command).slice(0, 200), ok: !err, ws: ws?.id });
575
- resolve(`$ ${command}\n${out}${errOut ? '\n[stderr] ' + errOut : ''}${err && !out && !errOut ? '\n[错误] ' + err.message : ''}`);
592
+ resolve(`$ ${command}\n${out}${errOut ? '\n[stderr] ' + errOut : ''}${err && !out && !errOut ? '\n[错误] ' + err.message : ''}${signal?.aborted ? '\n[中断] 命令已被用户终止' : ''}`);
576
593
  });
594
+ if (signal) signal.addEventListener('abort', () => { try { child.kill('SIGTERM') } catch { /* 已退出 */ } }, { once: true }); // 用户中断 → 终止执行中的命令
577
595
  });
578
596
  },
579
597
  },
@@ -1209,13 +1227,16 @@ function devSystemPrompt() {
1209
1227
  ].join('\n');
1210
1228
  }
1211
1229
 
1212
- async function devChatLoop(session, emit) {
1230
+ async function devChatLoop(session, emit, signal = null) {
1213
1231
  const events = [];
1214
1232
  const tools = Object.entries(DEV_TOOLS).map(([n, t]) => ({ name: n, description: t.desc, parameters: { type: 'object', properties: t.schema, required: t.required || [] } }));
1215
- for (let round = 0; round < 32; round++) {
1216
- emit('status', { text: `第 ${round + 1}/32 轮` });
1217
- const stream = models.stream(model, session.context);
1233
+ const interrupted = () => !!signal?.aborted;
1234
+ for (let round = 0; ; round++) { // 不设轮次上限:自然结束或用户中断(断开连接/停止按钮)
1235
+ if (interrupted()) { emit('interrupted', { at: `第 ${round + 1} 轮` }); return { reply: '(已被用户中断)', events }; }
1236
+ emit('status', { text: `第 ${round + 1} 轮` });
1237
+ const stream = models.stream(model, session.context, signal ? { signal } : undefined);
1218
1238
  for await (const ev of stream) {
1239
+ if (interrupted()) break;
1219
1240
  if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
1220
1241
  else if (ev.type === 'toolcall_end') emit('status', { text: `调用工具 ${ev.toolCall?.name || ''}` });
1221
1242
  }
@@ -1225,6 +1246,10 @@ async function devChatLoop(session, emit) {
1225
1246
  const text = (final.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
1226
1247
  if (!calls.length) { emit('done', { reply: text, events }); return { reply: text, events }; }
1227
1248
  for (const tc of calls) {
1249
+ if (interrupted()) { // 余下工具不再执行,补占位结果保证工具调用链完整
1250
+ session.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: '(用户中断,未执行)' }], isError: false, timestamp: Date.now() });
1251
+ continue;
1252
+ }
1228
1253
  const args = tc.arguments || {};
1229
1254
  emit('tool_start', { tool: tc.name, args });
1230
1255
  let result, isErr = false;
@@ -1234,9 +1259,8 @@ async function devChatLoop(session, emit) {
1234
1259
  emit('tool_end', { tool: tc.name, args, result: String(result).slice(0, 600), ok: !isErr });
1235
1260
  session.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: String(result) }], isError: isErr, timestamp: Date.now() });
1236
1261
  }
1262
+ if (interrupted()) { emit('interrupted', { at: `第 ${round + 1} 轮` }); return { reply: text, events }; }
1237
1263
  }
1238
- emit('done', { reply: '(达到轮次上限——请把结论性需求拆小,或继续对话续跑)', events });
1239
- return { reply: '(达到轮次上限)', events };
1240
1264
  }
1241
1265
 
1242
1266
  // ── 助手运行时(pi-ai Context + 守护栏 + SSE 事件流)────
@@ -1293,7 +1317,7 @@ async function ensureSession(sessionId, agentId, autonomyLevel, wsId) {
1293
1317
  if (!deployment) throw new Error('该助手未上岗(无 deployment)');
1294
1318
  const armAgent = await getArmAgent(agentId);
1295
1319
  if (!s || s.agentId !== agentId || (wsId && s.wsId !== wsId)) { // 切工作区 = 新会话上下文(旧会话留在原工作区)
1296
- s = { agentId, wsId: wsId || 'default', autonomy: { L0: 0, L1: 1, L2: 2 }[deployment.default_autonomy] ?? 1, context: null, pending: null };
1320
+ s = { agentId, wsId: wsId || 'default', autonomy: { L0: 0, L1: 1, L2: 2, L3: 3 }[deployment.default_autonomy] ?? 1, context: null, pending: null };
1297
1321
  sessions.set(sessionId, s);
1298
1322
  }
1299
1323
  const ws = getWs(agentId, s.wsId) || currentWs(agentId);
@@ -1307,24 +1331,52 @@ async function ensureSession(sessionId, agentId, autonomyLevel, wsId) {
1307
1331
  }
1308
1332
 
1309
1333
  // 运行一轮 Agent 循环,事件通过 emit(type, data) 推给 SSE;s.running/s.statusText 供跨页面进度跟踪
1310
- async function agentLoop(s, deployment, emit, maxRounds = 32) {
1311
- for (let round = 0; round < maxRounds; round++) {
1312
- s.statusText = `第 ${round + 1}/${maxRounds} 轮 · 推理中`;
1313
- const stream = models.stream(model, s.context);
1334
+ // 不设轮次上限:跑到无更多工具调用自然结束,失控靠用户中断(signal)
1335
+ // signal:用户中断(停止按钮/断开连接/POST /api/chat/interrupt)——轮次间、流中、每个工具执行前检查;
1336
+ // 中断时保留已流出的部分文本为助手消息并补记"(已被用户中断)",未应答的 toolCall 补占位 toolResult 保证下轮上下文合法
1337
+ async function agentLoop(s, deployment, emit, signal = null) {
1338
+ const interrupted = () => !!signal?.aborted;
1339
+ for (let round = 0; ; round++) {
1340
+ if (interrupted()) return { reply: '(已被用户中断)', interrupted: true, pending: s.pending };
1341
+ s.statusText = `第 ${round + 1} 轮 · 推理中`;
1342
+ const stream = models.stream(model, s.context, signal ? { signal } : undefined);
1314
1343
  emit('status', { text: '推理中…' });
1315
- for await (const ev of stream) {
1316
- if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
1317
- else if (ev.type === 'thinking_start') emit('status', { text: '思考中…' });
1318
- else if (ev.type === 'toolcall_end') emit('status', { text: `准备调用工具 ${ev.toolCall?.name || ''}` });
1319
- else if (ev.type === 'error') throw new Error(`LLM 流错误: ${ev.error?.errorMessage || JSON.stringify(ev.error).slice(0, 200)}`);
1344
+ let final = null;
1345
+ try {
1346
+ for await (const ev of stream) {
1347
+ if (interrupted()) break;
1348
+ if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
1349
+ else if (ev.type === 'thinking_start') emit('status', { text: '思考中…' });
1350
+ else if (ev.type === 'toolcall_end') emit('status', { text: `准备调用工具 ${ev.toolCall?.name || ''}` });
1351
+ else if (ev.type === 'error') {
1352
+ if (interrupted()) break; // 中断引发的流错误按中断处理,不报错
1353
+ throw new Error(`LLM 流错误: ${ev.error?.errorMessage || JSON.stringify(ev.error).slice(0, 200)}`);
1354
+ }
1355
+ }
1356
+ final = await stream.result(); // 中止时 pi-ai 以 done(aborted) 收尾,result() 返回已积累的部分消息
1357
+ } catch (e) {
1358
+ if (!interrupted()) throw e; // 真实错误上抛;中断路径用占位消息兜底
1320
1359
  }
1321
- const final = await stream.result();
1360
+ const aborted = interrupted();
1361
+ if (!final) final = { role: 'assistant', content: [], timestamp: Date.now() };
1362
+ if (aborted) (final.content = final.content || []).push({ type: 'text', text: (final.content.some((b) => b.type === 'text' && b.text) ? '\n\n' : '') + '(已被用户中断)' });
1322
1363
  s.context.messages.push(final);
1323
1364
  const textNow = (final.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
1324
1365
  const calls = (final.content || []).filter(b => b.type === 'toolCall');
1325
- if (!calls.length) return { reply: textNow, pending: null };
1366
+ if (!calls.length || aborted) {
1367
+ if (aborted) {
1368
+ emit('interrupted', { at: `第 ${round + 1} 轮` });
1369
+ audit({ actor: 'user', agent: deployment.agent_id, action: 'interrupt-chat', round: round + 1 });
1370
+ return { reply: textNow, interrupted: true, pending: s.pending };
1371
+ }
1372
+ return { reply: textNow, pending: null };
1373
+ }
1326
1374
 
1327
1375
  for (const tc of calls) {
1376
+ if (interrupted()) { // 后续工具不再执行,补占位结果保证工具调用链完整
1377
+ s.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: '(用户中断,未执行)' }], isError: false, timestamp: Date.now() });
1378
+ continue;
1379
+ }
1328
1380
  const args = tc.arguments || {};
1329
1381
  emit('tool_start', { tool: tc.name, args });
1330
1382
  s.statusText = `调用工具 ${tc.name}`;
@@ -1344,14 +1396,18 @@ async function agentLoop(s, deployment, emit, maxRounds = 32) {
1344
1396
  if (s.pending && s.pending.tool_call_id === tc.id) s.pending = null;
1345
1397
  let result, isErr = false;
1346
1398
  s.statusText = `执行工具 ${tc.name}`;
1347
- try { result = await getTool(tc.name).run(args, s.agentId, s.wsId || 'default'); }
1399
+ try { result = await getTool(tc.name).run(args, s.agentId, s.wsId || 'default', signal); }
1348
1400
  catch (e) { result = `工具执行失败: ${e.message}`; isErr = true; }
1349
1401
  s.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: String(result) }], isError: isErr, timestamp: Date.now() });
1350
1402
  emit('tool_end', { tool: tc.name, args, result: String(result), status: 'ok' });
1351
1403
  audit({ actor: 'assistant', agent: deployment.agent_id, tool: tc.name, autonomy: s.autonomy, ok: !isErr });
1352
1404
  }
1405
+ if (interrupted()) {
1406
+ emit('interrupted', { at: `第 ${round + 1} 轮` });
1407
+ audit({ actor: 'user', agent: deployment.agent_id, action: 'interrupt-chat', round: round + 1 });
1408
+ return { reply: textNow, interrupted: true, pending: s.pending };
1409
+ }
1353
1410
  }
1354
- return { reply: '(达到工具调用轮次上限——回复「继续」可在完整上下文上续跑;也可在工作台 DSL 提高 max_rounds,上限 128)', pending: null };
1355
1411
  }
1356
1412
 
1357
1413
  // ── HTTP ────────────────────────────────────────────────
@@ -1359,7 +1415,7 @@ function json(res, code, data) { res.writeHead(code, { 'Content-Type': 'applicat
1359
1415
  async function body(req) { let s = ''; for await (const c of req) s += c; return s ? JSON.parse(s) : {}; }
1360
1416
  function sseInit(req, res) {
1361
1417
  res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
1362
- return (type, data) => res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
1418
+ return (type, data) => { if (res.writableEnded || res.destroyed) return; res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`); }; // 客户端断开后不再写
1363
1419
  }
1364
1420
 
1365
1421
  const server = http.createServer(async (req, res) => {
@@ -1397,7 +1453,7 @@ const server = http.createServer(async (req, res) => {
1397
1453
  const k = llmApiKey();
1398
1454
  return json(res, 200, {
1399
1455
  arm: {
1400
- api_base: armApiBase(), api_base_runtime: !!cfg.arm_api_base,
1456
+ api_base: armApiBase(), api_base_runtime: !!cfg.arm_api_base, default_base: ARM_DEFAULT_BASE,
1401
1457
  mcp_url: armMcpUrl(), mcp_explicit: !!cfg.arm_mcp_url,
1402
1458
  token_set: !!t, token_masked: t ? `${t.slice(0, 10)}…${t.slice(-4)}` : '',
1403
1459
  token_source: cfg.arm_token ? 'runtime' : (env.ARM_API_TOKEN ? 'env' : (ARM_PAT ? 'pat' : 'none')),
@@ -1512,15 +1568,18 @@ const server = http.createServer(async (req, res) => {
1512
1568
 
1513
1569
  // ── 助手目录(本地库 ∪ 远端目录;离线时仅本地)──
1514
1570
  if (req.method === 'GET' && pathname === '/api/agents') {
1515
- const keyword = url.searchParams.get('keyword') || '';
1571
+ const kw = (url.searchParams.get('keyword') || '').trim().toLowerCase();
1516
1572
  const reg = loadRegistry();
1517
- let online = true, remoteCards = [];
1518
- try { remoteCards = await armListAgents(keyword); } // REST 全量优先,旧版回落 MCP
1519
- catch { online = false; }
1573
+ let online = true, remoteCards = [], authError = false;
1574
+ try { remoteCards = await armListAgents(url.searchParams.get('keyword') || ''); } // REST 全量优先,旧版回落 MCP
1575
+ catch (e) { online = false; authError = /令牌未配置或无权限/.test(String(e.message)); } // 区分"未授权"与"不可达",前端引导配置令牌
1520
1576
  const byId = new Map();
1521
1577
  for (const e of reg.agents) {
1522
1578
  const lm = localManifest(e.agent_id) || {};
1579
+ // 搜索时本地库同样按关键词过滤(名称/描述),与远端搜索结果口径一致
1580
+ if (kw && !(String(e.name || '').toLowerCase().includes(kw) || String(e.description || lm.description || '').toLowerCase().includes(kw))) continue;
1523
1581
  byId.set(e.agent_id, {
1582
+ created_via_workstation: !!e.created_via_workstation, // 工作站新建的助手:大厅提供编辑档案/从 ARM 删除
1524
1583
  agent_id: e.agent_id, name: e.name, version: e.installed_version,
1525
1584
  skills: (lm.skills || []).length, knowledges: (lm.knowledges || []).length,
1526
1585
  description: e.description || lm.description || '',
@@ -1530,7 +1589,8 @@ const server = http.createServer(async (req, res) => {
1530
1589
  delisted: !!e.delisted, downloaded_at: e.downloaded_at || null,
1531
1590
  });
1532
1591
  }
1533
- if (online) { // 静默刷新远端版本与下架标记(显式事件统计走 /api/arm/sync)
1592
+ if (online) { // 静默刷新远端版本与下架标记(显式事件统计走 /api/arm/sync)——仅在完整拉取时;
1593
+ // 搜索结果是全量的子集,若据此刷新会把未命中的本地助手误标"已下架"
1534
1594
  const ids = new Set(remoteCards.map(c => c.agent_id));
1535
1595
  for (const c of remoteCards) {
1536
1596
  const cur = byId.get(c.agent_id) || { agent_id: c.agent_id, installed: false, installed_version: null, downloaded_at: null };
@@ -1541,14 +1601,100 @@ const server = http.createServer(async (req, res) => {
1541
1601
  update_available: cur.installed ? cmpVersion(c.version, cur.installed_version) > 0 : false,
1542
1602
  });
1543
1603
  }
1544
- for (const e of reg.agents) {
1545
- if (ids.has(e.agent_id)) { e.remote_version = byId.get(e.agent_id)?.remote_version || e.remote_version; e.delisted = false; }
1546
- else e.delisted = true;
1604
+ if (!kw) {
1605
+ for (const e of reg.agents) {
1606
+ if (ids.has(e.agent_id)) { e.remote_version = byId.get(e.agent_id)?.remote_version || e.remote_version; e.delisted = false; }
1607
+ else e.delisted = true;
1608
+ }
1609
+ reg.last_sync_at = new Date().toISOString();
1610
+ saveRegistry(reg);
1547
1611
  }
1548
- reg.last_sync_at = new Date().toISOString();
1549
- saveRegistry(reg);
1550
1612
  }
1551
- return json(res, 200, { online, last_sync_at: reg.last_sync_at || null, agents: [...byId.values()] });
1613
+ return json(res, 200, { online, auth_error: authError, last_sync_at: reg.last_sync_at || null, agents: [...byId.values()] });
1614
+ }
1615
+ // ── 新建助手:本地起草(AI 生成 prompt)→ ARM JSON 创建(创建即发布)→ 可选知识绑定(幂等)→ 下载物化 ──
1616
+ if (req.method === 'POST' && pathname === '/api/agents/draft-prompt') {
1617
+ const { description, name } = await body(req);
1618
+ if (!String(description || '').trim()) return json(res, 400, { error: '请先填写助手职责描述' });
1619
+ try {
1620
+ const ctx = {
1621
+ systemPrompt: '你是助手档案起草器。根据用户的职责描述输出严格 JSON(不要 markdown 代码围栏):{"name":"助手名称","prompt":"System Prompt"}。name:12 字以内的中文名称,体现职责;prompt:第一句角色定位,随后 3-6 条职责要点(每条一个短句),最后 1-2 条工作方式或能力边界约束,200 字以内。只输出 JSON。',
1622
+ messages: [{ role: 'user', content: `助手名称(用户意向,可参考):${String(name || '').trim() || '(待定)'}\n职责描述:${String(description).trim()}\n\n请输出 JSON:`, timestamp: Date.now() }],
1623
+ tools: [],
1624
+ };
1625
+ const stream = models.stream(model, ctx);
1626
+ let out = '';
1627
+ for await (const ev of stream) { if (ev.type === 'text_delta') out += ev.delta; }
1628
+ const final = await stream.result();
1629
+ const text = ((final.content || []).filter(b => b.type === 'text').map(b => b.text).join('') || out).trim();
1630
+ let draftName = '', prompt = text;
1631
+ const jm = text.match(/\{[\s\S]*\}/); // JSON 容错解析:失败则整段当 prompt,不阻断
1632
+ if (jm) { try { const j = JSON.parse(jm[0]); if (j.prompt) { prompt = String(j.prompt).trim(); if (j.name) draftName = String(j.name).trim().slice(0, 24); } } catch { } }
1633
+ return json(res, 200, { prompt, name: draftName });
1634
+ } catch (e) { return json(res, 502, { error: `起草失败: ${e.message}` }); }
1635
+ }
1636
+ if (req.method === 'GET' && pathname === '/api/agents/knowledges') { // 新建向导的知识选择器:ARM 关键词检索
1637
+ const kwd = url.searchParams.get('keyword') || '';
1638
+ try {
1639
+ const d = await armRest(`/api/v1/knowledges?search=${encodeURIComponent(kwd)}&page=1&pageSize=20`);
1640
+ 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) }));
1641
+ return json(res, 200, { items });
1642
+ } catch (e) { return json(res, 200, { items: [], error: String(e.message).slice(0, 100) }); }
1643
+ }
1644
+ if (req.method === 'POST' && pathname === '/api/agents/create') {
1645
+ const { name, description, prompt, knowledge_ids } = await body(req);
1646
+ const nm = String(name || '').trim(), pr = String(prompt || '').trim();
1647
+ if (!nm || !pr) return json(res, 400, { error: '名称与 System Prompt 必填' });
1648
+ try {
1649
+ 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' }) });
1650
+ const newId = created?.id || created?.agent?.id;
1651
+ if (!newId) throw new Error('ARM 未返回新助手 id');
1652
+ let bound = 0;
1653
+ for (const kid of (Array.isArray(knowledge_ids) ? knowledge_ids : [])) { // 绑定幂等(重复绑定返回已有版本)
1654
+ try { await armRest(`/api/v1/agents/${newId}/knowledges`, { method: 'POST', body: JSON.stringify({ knowledgeId: kid, kind: 'experience' }) }); bound++; }
1655
+ catch { /* 单条绑定失败不阻断创建 */ }
1656
+ }
1657
+ const manifest = await downloadAgent(newId); // 创建即物化本地(沿用下载链路)
1658
+ const reg = loadRegistry(); const entry = reg.agents.find((x) => x.agent_id === newId); if (entry) entry.created_via_workstation = true; saveRegistry(reg); // 大厅据此显示编辑/删除入口
1659
+ audit({ actor: 'user', action: 'create-agent', agentId: newId, name: nm, knowledges: bound });
1660
+ return json(res, 200, { agent_id: newId, manifest, knowledges_bound: bound });
1661
+ } catch (e) { return json(res, 502, { error: `创建失败: ${e.message}` }); }
1662
+ }
1663
+ // 编辑档案(工作站创建的助手):ARM PUT 后重新物化,本地 prompt 同步更新
1664
+ if (req.method === 'GET' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}\/profile$/)) {
1665
+ const id = pathname.split('/')[3];
1666
+ try {
1667
+ const d = await armRest(`/api/v1/agents/${id}`);
1668
+ return json(res, 200, { name: d.name, description: String(d.description || ''), prompt: d.prompt || '' });
1669
+ } catch (e) { return json(res, 502, { error: `档案读取失败: ${e.message}` }); }
1670
+ }
1671
+ if (req.method === 'PUT' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}$/)) {
1672
+ const id = pathname.split('/')[3];
1673
+ const { name, description, prompt } = await body(req);
1674
+ const patch = {};
1675
+ if (String(name || '').trim()) patch.name = String(name).trim();
1676
+ if (description !== undefined) patch.description = String(description || '').trim().slice(0, 512);
1677
+ if (String(prompt || '').trim()) patch.prompt = String(prompt).trim();
1678
+ if (!Object.keys(patch).length) return json(res, 400, { error: '没有可更新字段' });
1679
+ try {
1680
+ await armRest(`/api/v1/agents/${id}`, { method: 'PUT', body: JSON.stringify(patch) });
1681
+ let manifest = null;
1682
+ try { manifest = await downloadAgent(id); } catch { /* 本地未安装时无需物化 */ }
1683
+ audit({ actor: 'user', action: 'update-agent', agentId: id, fields: Object.keys(patch) });
1684
+ return json(res, 200, { ok: true, manifest });
1685
+ } catch (e) { return json(res, 502, { error: `更新失败: ${e.message}` }); }
1686
+ }
1687
+ // 从 ARM 删除(危险操作,前端二次确认):可选同时卸载本地档案
1688
+ if (req.method === 'POST' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}\/arm-delete$/)) {
1689
+ const id = pathname.split('/')[3];
1690
+ const { also_uninstall, purge_workspaces } = await body(req);
1691
+ try {
1692
+ await armRest(`/api/v1/agents/${id}`, { method: 'DELETE' });
1693
+ let uninstalled = false;
1694
+ if (also_uninstall && loadRegistry().agents.some((a) => a.agent_id === id)) uninstalled = uninstallLocalAgent(id, !!purge_workspaces);
1695
+ audit({ actor: 'user', action: 'arm-delete-agent', agentId: id, also_uninstalled: uninstalled });
1696
+ return json(res, 200, { ok: true, uninstalled });
1697
+ } catch (e) { return json(res, 502, { error: `ARM 删除失败: ${e.message}` }); }
1552
1698
  }
1553
1699
  // ── 本地助手库:下载 / 升级 / 版本历史 / 回滚 / 卸载 ──
1554
1700
  let mAgt = pathname.match(/^\/api\/agents\/([0-9a-f-]{36})\/(download|upgrade|versions|rollback)$/);
@@ -1588,15 +1734,7 @@ const server = http.createServer(async (req, res) => {
1588
1734
  if (loadDeployment(agentId) && !force) {
1589
1735
  return json(res, 409, { error: '该助手在岗:卸载将同时删除上岗配置与工作台定义', hint: '确认卸载请带 force=true(可另带 purge_workspaces=true 清除工作区数据)' });
1590
1736
  }
1591
- 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`)]) {
1592
- try { fs.rmSync(p, { force: true }); } catch { }
1593
- }
1594
- try { fs.rmSync(path.join(ARM_AGENTS_DIR, agentId), { recursive: true, force: true }); } catch { }
1595
- const reg = loadRegistry(); reg.agents = reg.agents.filter(a => a.agent_id !== agentId); saveRegistry(reg);
1596
- if (purge_workspaces) {
1597
- try { fs.rmSync(wsDir(agentId), { recursive: true, force: true }); } catch { }
1598
- for (const f of fs.readdirSync(DATA_DIR)) if (f.startsWith(`ws-${agentId}-`)) { try { fs.rmSync(path.join(DATA_DIR, f), { force: true }); } catch { } }
1599
- }
1737
+ uninstallLocalAgent(agentId, !!purge_workspaces);
1600
1738
  audit({ actor: 'arm-library', agentId, action: 'uninstall', purged_workspaces: !!purge_workspaces });
1601
1739
  return json(res, 200, { ok: true });
1602
1740
  }
@@ -1923,6 +2061,9 @@ const server = http.createServer(async (req, res) => {
1923
2061
  if (req.method === 'POST' && pathname === '/api/dev/chat') {
1924
2062
  const { sessionId, agentId, message } = await body(req);
1925
2063
  const emit = sseInit(req, res);
2064
+ const ctl = new AbortController();
2065
+ let finished = false;
2066
+ req.on('close', () => { if (!finished) ctl.abort(); }); // 断开即中断定制循环(无轮次上限后的失控出口)
1926
2067
  try {
1927
2068
  devCtx.agentId = agentId;
1928
2069
  if (!loadWorkbench(agentId)) return emit('error', { message: '该助手尚无工作台' }), res.end();
@@ -1934,9 +2075,10 @@ const server = http.createServer(async (req, res) => {
1934
2075
  s.context.systemPrompt = devSystemPrompt();
1935
2076
  }
1936
2077
  s.context.messages.push({ role: 'user', content: message, timestamp: Date.now() });
1937
- await devChatLoop(s, emit);
2078
+ await devChatLoop(s, emit, ctl.signal);
1938
2079
  emit('meta', { hasDraft: !!loadDraft(agentId) });
1939
2080
  } catch (e) { emit('error', { message: e.message }); }
2081
+ finally { finished = true; }
1940
2082
  return res.end();
1941
2083
  }
1942
2084
 
@@ -1948,10 +2090,13 @@ const server = http.createServer(async (req, res) => {
1948
2090
  if (req.method === 'POST' && pathname === '/api/chat') {
1949
2091
  const { sessionId, agentId, message, autonomyLevel, context, ws_id } = await body(req);
1950
2092
  const emit = sseInit(req, res);
2093
+ const ctl = new AbortController();
2094
+ let finished = false;
2095
+ req.on('close', () => { if (!finished) ctl.abort(); }); // 客户端断开(停止按钮/关页面)→ 中断后台循环
1951
2096
  try {
1952
2097
  const { s, deployment, armAgent } = await ensureSession(sessionId, agentId, autonomyLevel, ws_id);
1953
2098
  if (s.running) { // 会话在后台仍在处理上一条消息(跨页面进度跟踪)
1954
- emit('error', { message: `该会话正在后台处理(${s.statusText || '进行中'})——可先切换其他助手,稍后回来进度会自动恢复` });
2099
+ emit('error', { message: `该会话正在后台处理(${s.statusText || '进行中'})——可先切换其他助手,稍后回来进度会自动恢复;也可在状态行点「中断」` });
1955
2100
  return res.end();
1956
2101
  }
1957
2102
  if (s.pending) {
@@ -1959,19 +2104,28 @@ const server = http.createServer(async (req, res) => {
1959
2104
  return res.end();
1960
2105
  }
1961
2106
  s.context.messages.push({ role: 'user', content: context ? `${message}\n(界面注入上下文:当前选中设备 ${context.device})` : message, timestamp: Date.now() });
1962
- const wb = loadDraft(agentId) || loadWorkbench(agentId) || {};
1963
- s.running = true; s.sinceTs = Date.now();
1964
- const out = await agentLoop(s, deployment, emit, Math.min(128, Math.max(1, +(wb.max_rounds) || 32)));
2107
+ s.running = true; s.sinceTs = Date.now(); s.abortCtl = ctl;
2108
+ const out = await agentLoop(s, deployment, emit, ctl.signal);
1965
2109
  emit('done', { reply: out.reply, pending: out.pending });
1966
2110
  } catch (e) { emit('error', { message: e.message }); }
1967
- finally { const s = sessions.get(sessionId); if (s) { s.running = false; s.statusText = ''; } }
2111
+ finally { finished = true; const s = sessions.get(sessionId); if (s) { s.running = false; s.statusText = ''; s.abortCtl = null; } }
1968
2112
  return res.end();
1969
2113
  }
2114
+ if (req.method === 'POST' && pathname === '/api/chat/interrupt') {
2115
+ const { sessionId } = await body(req);
2116
+ const s = sessions.get(sessionId);
2117
+ if (s?.running && s.abortCtl) { s.abortCtl.abort(); return json(res, 200, { ok: true, interrupted: true }); } // 幂等:循环在下一个检查点退出
2118
+ return json(res, 200, { ok: true, interrupted: false, note: '当前没有进行中的对话' });
2119
+ }
1970
2120
  if (req.method === 'POST' && pathname === '/api/chat/confirm') {
1971
2121
  const { sessionId, approved } = await body(req);
1972
2122
  const emit = sseInit(req, res);
2123
+ const ctl = new AbortController();
2124
+ let finished = false;
2125
+ let s = null;
2126
+ req.on('close', () => { if (!finished) ctl.abort(); });
1973
2127
  try {
1974
- const s = sessions.get(sessionId);
2128
+ s = sessions.get(sessionId);
1975
2129
  if (!s || !s.pending) return emit('error', { message: '无挂起操作' }), res.end();
1976
2130
  const deployment = loadDeployment(s.agentId);
1977
2131
  const { tool_call_id, tool, args } = s.pending; s.pending = null;
@@ -1981,17 +2135,16 @@ const server = http.createServer(async (req, res) => {
1981
2135
  } else {
1982
2136
  emit('tool_start', { tool, args });
1983
2137
  let result, isErr = false;
1984
- try { result = await getTool(tool).run(args, s.agentId, s.wsId || 'default'); } catch (e) { result = `执行失败: ${e.message}`; isErr = true; }
2138
+ try { result = await getTool(tool).run(args, s.agentId, s.wsId || 'default', ctl.signal); } catch (e) { result = `执行失败: ${e.message}`; isErr = true; }
1985
2139
  s.context.messages.push({ role: 'toolResult', toolCallId: tool_call_id, toolName: tool, content: [{ type: 'text', text: String(result) }], isError: isErr, timestamp: Date.now() });
1986
2140
  emit('tool_end', { tool, args, result: String(result), status: 'executed' });
1987
2141
  audit({ actor: 'human-confirm', agent: s.agentId, tool, autonomy: s.autonomy, ok: !isErr });
1988
2142
  }
1989
- const wb = loadDraft(s.agentId) || loadWorkbench(s.agentId) || {};
1990
- s.running = true; s.sinceTs = Date.now();
1991
- const out = await agentLoop(s, deployment, emit, Math.min(128, Math.max(1, +(wb.max_rounds) || 32)));
2143
+ s.running = true; s.sinceTs = Date.now(); s.abortCtl = ctl;
2144
+ const out = await agentLoop(s, deployment, emit, ctl.signal);
1992
2145
  emit('done', { reply: out.reply, pending: out.pending });
1993
2146
  } catch (e) { emit('error', { message: e.message }); }
1994
- finally { if (s) { s.running = false; s.statusText = ''; } }
2147
+ finally { finished = true; if (s) { s.running = false; s.statusText = ''; s.abortCtl = null; } }
1995
2148
  return res.end();
1996
2149
  }
1997
2150
  // ── 会话历史与进度(切换助手后回来:上下文回放 + 后台运行状态;服务端为唯一事实源)──