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 +13 -9
- package/README.md +1 -1
- package/package.json +1 -1
- package/scripts/publish-npm.sh +54 -0
- package/server.js +221 -68
- package/web/dist/assets/index-CYcUNXFu.js +60 -0
- package/web/dist/assets/index-CtIZsLv7.css +2 -0
- package/web/dist/index.html +2 -2
- package/scripts/publish-nexus.sh +0 -15
- package/web/dist/assets/index-5WjVRlob.js +0 -58
- package/web/dist/assets/index-D7L4IIPO.css +0 -2
package/.env.example
CHANGED
|
@@ -2,15 +2,19 @@
|
|
|
2
2
|
PORT=5600
|
|
3
3
|
|
|
4
4
|
# 平台 LLM(OpenAI 兼容端点;也可在「系统设置」页运行时切换)
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
|
100
|
-
const
|
|
101
|
-
const
|
|
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
|
|
104
|
-
const LLM_DEFAULT_BASE = '
|
|
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 || '
|
|
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)
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1963
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1990
|
-
|
|
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
|
// ── 会话历史与进度(切换助手后回来:上下文回放 + 后台运行状态;服务端为唯一事实源)──
|