aims-workstation 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js ADDED
@@ -0,0 +1,2066 @@
1
+ // AIMS 工作站(aims-workstation)
2
+ // 链路: ARM员工库 → 下载物化本地 → 平台实例化(pi-ai + 工具注入 + 分级自主) → 工作台(DSL) → 经验回流ARM
3
+ // Agent 层基于 @earendil-works/pi-ai(统一 LLM API),前端为 web/ 预构建的 shadcn/ui 应用(web/dist)
4
+ 'use strict';
5
+ const http = require('http');
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const os = require('os');
10
+
11
+ // ── Node 版本门槛(node:sqlite 为实验特性,需 ≥22.5)──
12
+ (function checkNode() {
13
+ const [maj, min] = process.versions.node.split('.').map(Number);
14
+ if (maj < 22 || (maj === 22 && min < 5)) {
15
+ console.error(`[aims-workstation] 需要 Node ≥ 22.5(当前 ${process.versions.node},node:sqlite 实验特性依赖)。请升级后重试。`);
16
+ process.exit(1);
17
+ }
18
+ })();
19
+
20
+ // ── 代码与数据分离:包目录只放代码与只读资产;全部运行时状态在 AIMS_HOME(缺省 ~/.aims-workstation)──
21
+ const PKG_ROOT = __dirname;
22
+ const AIMS_HOME = process.env.AIMS_HOME ? path.resolve(process.env.AIMS_HOME) : path.join(os.homedir(), '.aims-workstation');
23
+ const moveIn = (from, to) => { // 首次升级迁移:包目录旁的旧运行时目录整体挪进 AIMS_HOME(跨卷降级为复制)
24
+ if (fs.existsSync(from) && !fs.existsSync(to)) {
25
+ try { fs.renameSync(from, to); }
26
+ catch { fs.cpSync(from, to, { recursive: true }); try { fs.rmSync(from, { recursive: true, force: true }); } catch { } }
27
+ }
28
+ };
29
+ (function ensureHome() {
30
+ const rels = ['data', 'arm-agents', 'workspace', 'config/deployments', 'config/workbenches', 'config/workbench-versions', 'config/panels', 'config/collections'];
31
+ for (const rel of rels) {
32
+ const to = path.join(AIMS_HOME, rel);
33
+ fs.mkdirSync(path.dirname(to), { recursive: true });
34
+ moveIn(path.join(PKG_ROOT, rel), to);
35
+ fs.mkdirSync(to, { recursive: true });
36
+ }
37
+ for (const f of ['config/platform.json', '.env']) moveIn(path.join(PKG_ROOT, f), path.join(AIMS_HOME, f));
38
+ // 工作区登记表存绝对路径:随迁移改写(旧包内路径 → AIMS_HOME;外部自定义目录不动)
39
+ const wsRegDir = path.join(AIMS_HOME, 'workspace');
40
+ if (fs.existsSync(wsRegDir)) {
41
+ const oldP = PKG_ROOT + path.sep + 'workspace' + path.sep, newP = AIMS_HOME + path.sep + 'workspace' + path.sep;
42
+ for (const agent of fs.readdirSync(wsRegDir)) {
43
+ const reg = path.join(wsRegDir, agent, 'registry.json');
44
+ try {
45
+ const s = fs.readFileSync(reg, 'utf8');
46
+ if (s.includes(oldP)) fs.writeFileSync(reg, s.split(oldP).join(newP));
47
+ } catch { }
48
+ }
49
+ }
50
+ // 种子资产:系统范本插件(存在即跳过——用户改动优先于包内版本)
51
+ const seedDir = path.join(PKG_ROOT, 'seed/panels');
52
+ if (fs.existsSync(seedDir)) {
53
+ for (const f of fs.readdirSync(seedDir).filter((x) => x.endsWith('.json'))) {
54
+ const to = path.join(AIMS_HOME, 'config/panels', f);
55
+ if (!fs.existsSync(to)) { try { fs.copyFileSync(path.join(seedDir, f), to); } catch { } }
56
+ }
57
+ }
58
+ })();
59
+
60
+ const ROOT = PKG_ROOT; // 兼容只读资产引用(web/dist、skills、scripts);运行时状态一律用 AIMS_HOME 子目录
61
+ // ── 配置(.env 可选:先包目录(开发仓),再 AIMS_HOME;npm 安装后仅靠系统设置页即可)──
62
+ const env = {};
63
+ for (const envFile of [path.join(PKG_ROOT, '.env'), path.join(AIMS_HOME, '.env')]) {
64
+ try {
65
+ for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) {
66
+ const m = line.match(/^\s*([A-Z_]+)\s*=\s*(.*)\s*$/);
67
+ if (m && env[m[1]] === undefined) env[m[1]] = m[2];
68
+ }
69
+ } catch { /* .env 可选 */ }
70
+ }
71
+ const PORT = +(process.env.PORT || env.PORT || 5600);
72
+ const HOST = process.env.AIMS_HOST || '127.0.0.1'; // 本机工具默认只绑回环(exec/目录浏览/面板动作不宜暴露局域网)
73
+ const ARM_MCP_URL = process.env.ARM_MCP_URL || env.ARM_MCP_URL;
74
+ // ── 运行时配置(ARM 源与 LLM 均可私有化部署:在「系统设置」页切换,存 AIMS_HOME/config/platform.json,优先级 运行时 > 环境变量 > .env)──
75
+ const CFG_FILE = path.join(AIMS_HOME, 'config/platform.json');
76
+ const cfg = { arm_api_base: '', arm_mcp_url: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', scene_profile: '' };
77
+ function loadCfg() { try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'))); } catch { } }
78
+ loadCfg();
79
+ function saveCfg() { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg, null, 2)); }
80
+ // 站点地址:运行时配置 > ARM_API_BASE(env) > 从 MCP URL 推导;MCP 端点:显式配置 > 跟随站点地址 > env
81
+ const armApiBase = () => cfg.arm_api_base || process.env.ARM_API_BASE || env.ARM_API_BASE || String(env.ARM_MCP_URL || '').replace(/\/api\/mcp$/, '');
82
+ const armMcpUrl = () => cfg.arm_mcp_url || (cfg.arm_api_base ? `${cfg.arm_api_base}/api/mcp` : ARM_MCP_URL);
83
+ const armApiToken = () => cfg.arm_token || env.ARM_API_TOKEN || ARM_PAT;
84
+ // LLM:OpenAI 兼容端点(baseUrl/模型/密钥),缺省智谱 coding plan
85
+ const LLM_DEFAULT_BASE = 'https://open.bigmodel.cn/api/coding/paas/v4';
86
+ const llmBaseUrl = () => cfg.llm_base_url || env.LLM_BASE_URL || LLM_DEFAULT_BASE;
87
+ const llmApiKey = () => cfg.llm_api_key || env.LLM_API_KEY || '';
88
+ const llmModelName = () => cfg.llm_model || env.LLM_MODEL || 'glm-5.3';
89
+ // 场景画像:平台级、全局一份的用户场景描述(团队环境/系统清单/规范),注入所有助手;空则不注入。
90
+ // 与知识检索互补(常驻事实 vs 按需检索);凭据不进画像(放"凭据在哪、找谁要")。
91
+ const sceneProfile = () => String(cfg.scene_profile || '').trim();
92
+ const SCENE_PROFILE_MAX = 4000;
93
+ let ARM_PAT = env.ARM_PAT || '';
94
+ if (!ARM_PAT) {
95
+ try { ARM_PAT = JSON.parse(fs.readFileSync(path.join(process.env.HOME, '.arm/config.json'), 'utf8')).token || ''; }
96
+ catch { /* 经验写入不可用 */ }
97
+ }
98
+ const DATA_DIR = path.join(AIMS_HOME, 'data');
99
+ const DEPLOY_DIR = path.join(AIMS_HOME, 'config/deployments');
100
+ const WB_DIR = path.join(AIMS_HOME, 'config/workbenches');
101
+ const WB_VER_DIR = path.join(AIMS_HOME, 'config/workbench-versions');
102
+ const ARM_AGENTS_DIR = path.join(AIMS_HOME, 'arm-agents'); // 本地助手库(07 设计:下载物化档案快照,离线可用)
103
+ const WS_ROOT = path.join(AIMS_HOME, 'workspace'); // 工作区根目录(默认工作区在此动态新增)
104
+ for (const d of [DATA_DIR, DEPLOY_DIR, WB_DIR, WB_VER_DIR, ARM_AGENTS_DIR, WS_ROOT]) fs.mkdirSync(d, { recursive: true });
105
+ const AUDIT_LOG = path.join(DATA_DIR, 'audit.log');
106
+ const METRICS_LOG = path.join(DATA_DIR, 'metrics.log');
107
+
108
+ // ── pi-ai(Agent 层)─────────────────────────────────────
109
+ let models, model, llmCatalog = [];
110
+ async function applyLLMConfig() { // 可重复执行:设置页保存后热切换(在途请求沿用旧 model 引用,安全)
111
+ const { createModels, createProvider } = await import('@earendil-works/pi-ai/models');
112
+ const { zaiCodingCnProvider } = await import('@earendil-works/pi-ai/providers/zai-coding-cn');
113
+ const { openAICompletionsApi } = await import('@earendil-works/pi-ai/api/openai-completions.lazy'); // exports 通配符自动补 .js
114
+ const base = llmBaseUrl(), key = llmApiKey(), name = llmModelName();
115
+ // 官方模型条目为模板(compat/thinkingLevelMap 等字段不自行猜测);任意模型名按 glm-5.3 克隆;条目 baseUrl 一并改写
116
+ const entries = zaiCodingCnProvider().getModels().map((m) => ({ ...m, provider: 'platform-llm', baseUrl: base }));
117
+ if (!entries.some((m) => m.id === name)) {
118
+ const tpl = entries.find((m) => m.id === 'glm-5.3') || entries[0];
119
+ entries.push({ ...tpl, id: name, name });
120
+ }
121
+ llmCatalog = [...new Set([...zaiCodingCnProvider().getModels().map((m) => m.id), name])];
122
+ const provider = createProvider({
123
+ id: 'platform-llm', name: 'Platform LLM(OpenAI 兼容)', baseUrl: base,
124
+ // 注意 pi-ai 的 auth 输入是包装结构 { apiKey: <ApiKeyAuth> };凭证级 baseUrl 覆盖模型条目默认值
125
+ auth: { apiKey: { name: '平台设置 API Key', resolve: async () => (key ? { auth: { apiKey: key, baseUrl: base }, source: 'platform-config' } : undefined) } },
126
+ models: entries, api: openAICompletionsApi(),
127
+ });
128
+ models = createModels();
129
+ models.setProvider(provider);
130
+ model = models.getModel('platform-llm', name);
131
+ if (!model) throw new Error(`模型不可用: ${name}`);
132
+ }
133
+ async function llmProbe() { // 设置页「保存并测试」:一次极小补全验证连通性(pi-ai 把认证/请求失败表现为 stopReason=error 而非异常,须显式判定)
134
+ const t0 = Date.now();
135
+ const r = await Promise.race([
136
+ models.complete(model, { systemPrompt: '你是连通性探针,只回复 ok', messages: [{ role: 'user', content: 'ping', timestamp: Date.now() }], tools: [] }),
137
+ new Promise((_, rej) => setTimeout(() => rej(new Error('探针超时(20s)——模型或端点无响应')), 20000)),
138
+ ]);
139
+ const text = (r.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
140
+ if (r.stopReason === 'error' || (!text && r.stopReason !== 'stop')) {
141
+ return { online: false, error: `模型服务拒绝或无响应(stop=${r.stopReason}${r.error ? ',' + JSON.stringify(r.error).slice(0, 100) : ''})——请检查密钥/端点/模型名` };
142
+ }
143
+ return { online: true, latency_ms: Date.now() - t0, reply: (text || `stop=${r.stopReason}`).slice(0, 40) };
144
+ }
145
+ async function llmComplete(systemPrompt, userText) { // 无工具的一次性调用(起草/摘要)
146
+ const r = await models.complete(model, { systemPrompt, messages: [{ role: 'user', content: userText, timestamp: Date.now() }], tools: [] });
147
+ return (r.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
148
+ }
149
+
150
+ // ── 现场数据容器:真实数据源(AIMS-MOS)未接入前保持为空——面板按空态渲染,不造假数据
151
+ //(2026-10-15 MVP 打磨:移除模拟立库场景与工厂画像注入,见 README)
152
+ const world = { devices: [], alarms: [], kpi: {}, work_orders: [], inspections: {} };
153
+
154
+ // ── 审计与指标(自进化飞轮的数据原料)──────────────────
155
+ function audit(entry) { entry.ts = new Date().toISOString(); fs.appendFileSync(AUDIT_LOG, JSON.stringify(entry) + '\n'); }
156
+ function metric(entry) { entry.ts = new Date().toISOString(); fs.appendFileSync(METRICS_LOG, JSON.stringify(entry) + '\n'); }
157
+
158
+ // ── ARM MCP 客户端(调用链 + 回流链)─────────────────────
159
+ async function mcpCall(name, args, { pat = false, timeout_ms = 20000 } = {}) {
160
+ const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json, text/event-stream' };
161
+ if (pat && ARM_PAT) headers['Authorization'] = `Bearer ${ARM_PAT}`;
162
+ const ctrl = new AbortController();
163
+ const timer = setTimeout(() => ctrl.abort(), timeout_ms); // 离线快速失败(降级本地库)
164
+ let data;
165
+ try {
166
+ const res = await fetch(armMcpUrl(), {
167
+ method: 'POST', headers, signal: ctrl.signal,
168
+ body: JSON.stringify({ jsonrpc: '2.0', id: crypto.randomUUID(), method: 'tools/call', params: { name, arguments: args } }),
169
+ });
170
+ data = await res.json();
171
+ } finally { clearTimeout(timer); }
172
+ if (data.error) throw new Error(`ARM MCP ${name}: ${data.error.message}`);
173
+ if ((data.result || {}).isError) throw new Error(`ARM MCP ${name} 拒绝: ${((data.result).content || []).map(c => c.text).join('').slice(0, 150)}`);
174
+ return ((data.result || {}).content || []).map(c => c.text || '').join('\n');
175
+ }
176
+
177
+ // ARM REST 客户端(v2 开发版结构化通道;{ ok, data, msg } 包络,ok=false 抛错)
178
+ async function armRest(pathname, init = {}, timeout_ms = 20000) {
179
+ const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) };
180
+ const token = armApiToken();
181
+ if (token) headers.Authorization = `Bearer ${token}`;
182
+ const ctrl = new AbortController();
183
+ const timer = setTimeout(() => ctrl.abort(), timeout_ms);
184
+ try {
185
+ const res = await fetch(armApiBase() + pathname, { ...init, headers, signal: ctrl.signal });
186
+ const data = await res.json();
187
+ if (!res.ok || data.ok === false) throw new Error(`ARM REST ${pathname.split('?')[0]}: ${data.msg || res.status}`);
188
+ return data.data;
189
+ } finally { clearTimeout(timer); }
190
+ }
191
+ const vVer = (v) => (String(v || '').startsWith('v') ? String(v) : `v${v || '0.0.0'}`); // REST 无 v 前缀,统一内部表示
192
+
193
+ const agentCache = new Map();
194
+ async function getArmAgent(agentId) {
195
+ const lm = localManifest(agentId); // 本地档案优先(07 设计:实例化不依赖 ARM 在线)
196
+ if (lm) return { name: lm.name, version: lm.version, prompt: lm.prompt, knowledges: (lm.knowledges || []).map(k => k.ref) };
197
+ const hit = agentCache.get(agentId);
198
+ if (hit && Date.now() - hit.at < 5 * 60_000) return hit.data;
199
+ const text = await mcpCall('get_agent', { id: agentId });
200
+ const name = (text.match(/^#\s+(.+?)\s+\(/m) || [])[1] || agentId;
201
+ const version = (text.match(/^#\s+.+?\s+\((v[\d.]+)\)/m) || [])[1] || '';
202
+ const prompt = (text.match(/## Prompt\n([\s\S]*?)(\n## |\n*$)/) || [])[1]?.trim() || '';
203
+ const knowledges = [...text.matchAll(/^- (.+?) v[\d.]+((\w+))/gm)].map(m => m[1]);
204
+ const data = { name, version, prompt, knowledges };
205
+ agentCache.set(agentId, { data, at: Date.now() });
206
+ return data;
207
+ }
208
+
209
+ // search_agents 目录解析(大厅/同步共用)
210
+ function parseAgentCards(raw) {
211
+ const cards = [];
212
+ for (const block of raw.split(/\n(?=## )/)) {
213
+ const m = block.match(/^## (.+?) \((v[\d.]+)\)\nID: ([0-9a-f-]{36})/);
214
+ if (!m) continue;
215
+ const bind = block.match(/绑定:(\d+) Skill \/ (\d+) Knowledge/);
216
+ const desc = (block.split('\n').filter(l => l && !l.startsWith('#') && !l.startsWith('ID:') && !l.startsWith('绑定'))[0] || '').trim();
217
+ cards.push({ name: m[1], version: m[2], agent_id: m[3], skills: +(bind?.[1] || 0), knowledges: +(bind?.[2] || 0), description: desc.slice(0, 120) });
218
+ }
219
+ return cards;
220
+ }
221
+
222
+ // 助手目录:REST 分页结构化优先(可全量),旧版 ARM 回落 MCP markdown 解析(limit 上限 20)
223
+ async function armListAgents(keyword = '') {
224
+ const out = [];
225
+ for (let page = 1; page <= 5; page++) {
226
+ const d = await armRest(`/api/v1/agents?keyword=${encodeURIComponent(keyword)}&page=${page}&pageSize=50`);
227
+ out.push(...(d.agents || []));
228
+ if (!out.length || out.length >= +(d.total || 0)) break;
229
+ }
230
+ 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' }));
231
+ }
232
+
233
+ // 知识检索(全局经验库):REST 混合检索(RAG 语义 ⊕ 关键词)优先,回落 MCP RAG
234
+ async function searchKnowledgeItems(query, topK = 5) {
235
+ try {
236
+ const d = await armRest('/api/v1/knowledges/search', { method: 'POST', body: JSON.stringify({ query, topK }) });
237
+ return {
238
+ via: `rest:${d.mode || 'hybrid'}${d.degraded ? ':degraded' : ''}`,
239
+ items: (d.items || []).map(it => ({ title: it.name, similarity: it.score != null ? Math.round(it.score * 100) : null, snippet: String(it.snippet || '').slice(0, 320), id: it.knowledgeId })),
240
+ };
241
+ } catch {
242
+ const text = await mcpCall('search_knowledges', { query, limit: topK });
243
+ const items = text.split(/\n(?=## )/).filter(Boolean).map(b => {
244
+ const title = (b.match(/^## (.+?)(?:(相似度 (\d+)%))?/) || [])[1] || b.slice(0, 40);
245
+ return { title: title.trim(), similarity: +((b.match(/相似度 (\d+)%/) || [])[1] || 0) || null, snippet: b.replace(/^## .*\n?/, '').split('[完整内容')[0].slice(0, 320) };
246
+ });
247
+ return { via: 'mcp', items };
248
+ }
249
+ }
250
+
251
+ // ── 本地助手库(07-助手本地化:下载物化 → 离线可用;按版本目录存档,升级原子切指针)──
252
+ const armSourceTag = (base) => { try { return `arm://${new URL(base).host}`; } catch { return 'arm://unknown'; } };
253
+ const regFile = () => path.join(ARM_AGENTS_DIR, 'registry.json');
254
+ function loadRegistry() {
255
+ try { return JSON.parse(fs.readFileSync(regFile(), 'utf8')); } catch { return { agents: [], last_sync_at: null }; }
256
+ }
257
+ function saveRegistry(reg) { fs.writeFileSync(regFile(), JSON.stringify(reg, null, 2)); }
258
+ function localAgent(agentId) { return loadRegistry().agents.find(a => a.agent_id === agentId) || null; }
259
+ function localManifest(agentId, version) { // 缺省取当前安装版本
260
+ const e = localAgent(agentId);
261
+ const ver = version || e?.installed_version;
262
+ if (!ver) return null;
263
+ try { return JSON.parse(fs.readFileSync(path.join(ARM_AGENTS_DIR, agentId, 'versions', ver, 'manifest.json'), 'utf8')); } catch { return null; }
264
+ }
265
+ function agentProfileNow(agentId) { // 同步取档案:本地优先,退回内存缓存(工程师上下文/经验回流用)
266
+ const m = localManifest(agentId);
267
+ if (m) return { name: m.name, version: m.version, prompt: m.prompt, knowledges: (m.knowledges || []).map(k => k.ref) };
268
+ return agentCache.get(agentId)?.data || {};
269
+ }
270
+ function cmpVersion(a, b) { // 'v1.10.0' vs 'v1.9.3' 语义化比较
271
+ const pa = String(a || '').replace(/^v/, '').split('.').map(n => +n || 0);
272
+ const pb = String(b || '').replace(/^v/, '').split('.').map(n => +n || 0);
273
+ for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
274
+ return 0;
275
+ }
276
+ const slug = (s) => String(s).trim().replace(/[\\/:*?"<>|\s]+/g, '-').slice(0, 60) || 'knowledge';
277
+
278
+ // 下载物化:REST 主路径(结构化详情 + knowledgeId 直取全文),旧版 ARM 回落 MCP 解析
279
+ async function downloadAgent(agentId) {
280
+ try { return await downloadAgentRest(agentId); }
281
+ catch { return await downloadAgentMcp(agentId); }
282
+ }
283
+
284
+ // 注册表提交(两路下载共用):写版本目录后原子切指针
285
+ function commitDownload(manifest) {
286
+ const reg = loadRegistry();
287
+ let e = reg.agents.find(a => a.agent_id === manifest.agent_id);
288
+ if (!e) { e = { agent_id: manifest.agent_id, name: manifest.name, installed_version: manifest.version, versions: [], origin: manifest.source, status: 'active', downloaded_at: manifest.downloaded_at }; reg.agents.push(e); }
289
+ e.name = manifest.name; e.description = manifest.description || ''; e.installed_version = manifest.version; e.last_sync_at = manifest.downloaded_at; e.delisted = false;
290
+ if (!e.versions.includes(manifest.version)) e.versions.push(manifest.version);
291
+ e.versions.sort(cmpVersion);
292
+ saveRegistry(reg);
293
+ }
294
+
295
+ async function downloadAgentRest(agentId) {
296
+ const d = await armRest(`/api/v1/agents/${agentId}`);
297
+ const version = vVer(d.version);
298
+ const verDir = path.join(ARM_AGENTS_DIR, agentId, 'versions', version);
299
+ fs.mkdirSync(path.join(verDir, 'knowledges'), { recursive: true });
300
+ const knowledges = [];
301
+ for (const kb of d.knowledges || []) { // 详情直接携带 knowledgeId——无需按名检索定位
302
+ let file = null, note = '';
303
+ const kid = kb.knowledgeId || (kb.knowledge && kb.knowledge.id);
304
+ const name = (kb.knowledge && kb.knowledge.name) || kid;
305
+ try {
306
+ const k = await armRest(`/api/v1/knowledges/${kid}`);
307
+ file = `knowledges/${slug(k.name || name)}.md`;
308
+ fs.writeFileSync(path.join(verDir, file), k.content || '');
309
+ } catch (e) { note = `知识拉取失败: ${String(e.message).slice(0, 80)}`; }
310
+ knowledges.push({ ref: name, version: vVer(kb.version), kind: kb.kind || '', file, note });
311
+ }
312
+ const manifest = {
313
+ agent_id: agentId, name: d.name, version, description: d.description || '',
314
+ downloaded_at: new Date().toISOString(), source: `arm-rest://${(armApiBase() || '').replace(/^https?:\/\//, '').split('/')[0]}`,
315
+ prompt: d.prompt || '',
316
+ skills: (d.skills || []).map(s => ({ ref: (s.skill && s.skill.name) || s.skillId || s.id, version: vVer(s.version), kind: 'skill', description: String((s.skill && s.skill.description) || '').slice(0, 200) })),
317
+ knowledges,
318
+ };
319
+ fs.writeFileSync(path.join(verDir, 'prompt.md'), manifest.prompt);
320
+ fs.writeFileSync(path.join(verDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
321
+ commitDownload(manifest);
322
+ audit({ actor: 'arm-library', agentId, action: 'download', via: 'rest', version, knowledges_local: knowledges.filter(k => k.file).length });
323
+ return manifest;
324
+ }
325
+
326
+ // 旧版 ARM 兼容:get_agent markdown 解析 + 知识 ID 经检索定位
327
+ async function downloadAgentMcp(agentId) {
328
+ const text = await mcpCall('get_agent', { id: agentId }, { timeout_ms: 25000 });
329
+ const section = (h) => (text.match(new RegExp(`## ${h}\\n([\\s\\S]*?)(\\n## |\\n*$)`)) || [])[1] || '';
330
+ const parseList = (s) => [...s.matchAll(/^- (.+?) (v[\d.]+)((\w+))/gm)].map(m => ({ ref: m[1].trim(), version: m[2], kind: m[3] }));
331
+ const name = (text.match(/^#\s+(.+?)\s+\(/m) || [])[1] || agentId;
332
+ const version = (text.match(/^#\s+.+?\s+\((v[\d.]+)\)/m) || [])[1] || 'v0.0.0';
333
+ const prompt = (text.match(/## Prompt\n([\s\S]*?)(\n## |\n*$)/) || [])[1]?.trim() || '';
334
+ const description = section('描述').trim();
335
+ const skills = parseList(section('Skills'));
336
+ const verDir = path.join(ARM_AGENTS_DIR, agentId, 'versions', version);
337
+ fs.mkdirSync(path.join(verDir, 'knowledges'), { recursive: true });
338
+ const knowledges = [];
339
+ for (const k of parseList(section('Knowledges'))) {
340
+ let file = null, note = '';
341
+ try { // 知识 ID 经检索定位(ARM 无按名直取接口),再拉全文。注意:知识正文自身含 "## " 内部标题,不可按标题切块——须按「标题→首个 [完整内容] 标记」成对匹配
342
+ const sres = await mcpCall('search_knowledges', { query: k.ref, limit: 5 }, { timeout_ms: 20000 });
343
+ const pairs = [...sres.matchAll(/^## (.+?)(?:(相似度 \d+%))?\n[\s\S]*?\[完整内容: get_knowledge id=([0-9a-f-]{36})\]/gm)];
344
+ const kid = pairs.find(m => m[1].trim() === k.ref)?.[2];
345
+ if (kid) {
346
+ const full = await mcpCall('get_knowledge', { id: kid }, { timeout_ms: 20000 });
347
+ file = `knowledges/${slug(k.ref)}.md`;
348
+ fs.writeFileSync(path.join(verDir, file), full);
349
+ } else note = 'ARM 检索未命中该知识,离线不可用';
350
+ } catch (e) { note = `知识拉取失败: ${String(e.message).slice(0, 80)}`; }
351
+ knowledges.push({ ...k, file, note });
352
+ }
353
+ const manifest = {
354
+ agent_id: agentId, name, version, description,
355
+ downloaded_at: new Date().toISOString(), source: armSourceTag(armMcpUrl()), prompt,
356
+ skills, knowledges,
357
+ };
358
+ fs.writeFileSync(path.join(verDir, 'prompt.md'), prompt);
359
+ fs.writeFileSync(path.join(verDir, 'manifest.json'), JSON.stringify(manifest, null, 2));
360
+ commitDownload(manifest);
361
+ audit({ actor: 'arm-library', agentId, action: 'download', via: 'mcp', version, knowledges_local: knowledges.filter(k => k.file).length });
362
+ return manifest;
363
+ }
364
+
365
+ // 同步:比对远端目录与本地库,产出 new/update/delisted 事件;顺带冲账离线经验 outbox
366
+ async function armSync() {
367
+ const remote = await armListAgents(); // REST 全量优先,旧版 ARM 内部回落 MCP(≤20)
368
+ const reg = loadRegistry();
369
+ const remoteIds = new Set(remote.map(r => r.agent_id));
370
+ const events = { new: [], update: [], delisted: [] };
371
+ for (const r of remote) {
372
+ const e = reg.agents.find(a => a.agent_id === r.agent_id);
373
+ if (!e) events.new.push({ agent_id: r.agent_id, name: r.name, version: r.version });
374
+ else if (cmpVersion(r.version, e.installed_version) > 0) events.update.push({ agent_id: r.agent_id, name: r.name, from: e.installed_version, to: r.version });
375
+ if (e) { e.remote_version = r.version; e.delisted = false; }
376
+ }
377
+ for (const e of reg.agents) {
378
+ if (!remoteIds.has(e.agent_id)) { if (!e.delisted) events.delisted.push({ agent_id: e.agent_id, name: e.name }); e.delisted = true; }
379
+ }
380
+ reg.last_sync_at = new Date().toISOString();
381
+ saveRegistry(reg);
382
+ const outbox_flushed = await flushExperienceOutbox();
383
+ audit({ actor: 'arm-library', action: 'sync', new: events.new.length, update: events.update.length, delisted: events.delisted.length, outbox_flushed });
384
+ return { at: reg.last_sync_at, remote_count: remote.length, events, outbox_flushed };
385
+ }
386
+
387
+ // 离线经验 outbox:ARM 不可达时缓写,恢复连接后冲账(07 设计 §2.4)
388
+ const OUTBOX_FILE = path.join(DATA_DIR, 'outbox-experience.jsonl');
389
+ function outboxCount() { try { return fs.readFileSync(OUTBOX_FILE, 'utf8').split('\n').filter(Boolean).length; } catch { return 0; } }
390
+ async function flushExperienceOutbox() {
391
+ if (!fs.existsSync(OUTBOX_FILE)) return 0;
392
+ const lines = fs.readFileSync(OUTBOX_FILE, 'utf8').split('\n').filter(Boolean);
393
+ const remain = []; let ok = 0;
394
+ for (const line of lines) {
395
+ try { const it = JSON.parse(line); await mcpCall('create_knowledge', it.args, { pat: true }); ok++; }
396
+ catch { remain.push(line); }
397
+ }
398
+ fs.writeFileSync(OUTBOX_FILE, remain.length ? remain.join('\n') + '\n' : '');
399
+ if (ok) audit({ actor: 'experience-reflow', target: 'ARM', action: 'outbox-flush', count: ok });
400
+ return ok;
401
+ }
402
+
403
+ // 离线知识检索:对当前版本已物化的 knowledges/*.md 做关键词命中(降级路径,非 RAG)
404
+ function cjkTokens(q) { // 中英混合分词:拉丁词整词,CJK 连续段切二元(覆盖无空格中文查询)
405
+ const toks = [];
406
+ for (const part of String(q).split(/[\s,,、;;。??!!::]+/).filter(Boolean)) {
407
+ for (const w of part.replace(/[\u4e00-\u9fff]+/g, ' ').split(/\s+/)) if (w.length >= 2) toks.push(w);
408
+ for (const seg of part.match(/[\u4e00-\u9fff]+/g) || []) for (let i = 0; i + 2 <= seg.length; i++) toks.push(seg.slice(i, i + 2));
409
+ }
410
+ return [...new Set(toks)];
411
+ }
412
+ function localKnowledgeSearch(agentId, query) {
413
+ const m = localManifest(agentId);
414
+ if (!m) return [];
415
+ const tokens = cjkTokens(query);
416
+ if (!tokens.length) return [];
417
+ const base = path.join(ARM_AGENTS_DIR, agentId, 'versions', m.version);
418
+ const hits = [];
419
+ for (const k of m.knowledges || []) {
420
+ if (!k.file) continue;
421
+ let content; try { content = fs.readFileSync(path.join(base, k.file), 'utf8'); } catch { continue; }
422
+ const score = tokens.reduce((s, t) => s + (content.split(t).length - 1) + (k.ref.includes(t) ? 3 : 0), 0);
423
+ if (score > 2) {
424
+ const idx = Math.max(0, content.search(new RegExp(tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'i')));
425
+ hits.push({ title: k.ref, score, snippet: content.slice(idx, idx + 220).replace(/\n+/g, ' ') });
426
+ }
427
+ }
428
+ return hits.sort((a, b) => b.score - a.score).slice(0, 3);
429
+ }
430
+
431
+ // ── 工作区(07 设计:助手 1:N 工作区 = exec cwd + 私有数据集库 + 会话现场;工作台是 1:1 操作面,与此正交)──
432
+ const wsDir = (agentId) => path.join(WS_ROOT, agentId);
433
+ function saveWsRegistry(agentId, reg) {
434
+ fs.mkdirSync(wsDir(agentId), { recursive: true });
435
+ fs.writeFileSync(path.join(wsDir(agentId), 'registry.json'), JSON.stringify(reg, null, 2));
436
+ }
437
+ function loadWsRegistry(agentId) { // 读即保障:默认工作区固定在安装路径 workspace 目录下动态新增
438
+ let reg = null;
439
+ try { reg = JSON.parse(fs.readFileSync(path.join(wsDir(agentId), 'registry.json'), 'utf8')); } catch { }
440
+ if (!reg || !Array.isArray(reg.workspaces)) reg = { current: 'default', workspaces: [] };
441
+ if (!reg.workspaces.some(w => w.id === 'default')) {
442
+ fs.mkdirSync(path.join(wsDir(agentId), 'default'), { recursive: true });
443
+ reg.workspaces.unshift({ id: 'default', name: '默认工作区', path: path.join(wsDir(agentId), 'default'), external: false, created_at: new Date().toISOString(), last_opened_at: null });
444
+ saveWsRegistry(agentId, reg);
445
+ }
446
+ if (!reg.workspaces.some(w => w.id === reg.current)) reg.current = 'default';
447
+ return reg;
448
+ }
449
+ function getWs(agentId, wsId) { return loadWsRegistry(agentId).workspaces.find(w => w.id === wsId) || null; }
450
+ function currentWs(agentId) {
451
+ const reg = loadWsRegistry(agentId);
452
+ return reg.workspaces.find(w => w.id === reg.current) || reg.workspaces[0];
453
+ }
454
+ function createWorkspace(agentId, { name, path: customPath }) {
455
+ const reg = loadWsRegistry(agentId);
456
+ const id = `ws_${crypto.randomUUID().slice(0, 4)}`;
457
+ let wsPath = customPath ? path.resolve(customPath) : null;
458
+ if (wsPath) { // 自定义路径 = 登记制:目录须已存在,平台不在用户目录建库(SQLite 留平台 data/)
459
+ const st = fs.existsSync(wsPath) ? fs.statSync(wsPath) : null;
460
+ if (!st || !st.isDirectory()) throw new Error(`目录不存在或不是文件夹: ${wsPath}`);
461
+ } else {
462
+ wsPath = path.join(wsDir(agentId), id);
463
+ fs.mkdirSync(wsPath, { recursive: true });
464
+ }
465
+ const ws = { id, name: name || `工作区 ${reg.workspaces.length}`, path: wsPath, external: !!customPath, created_at: new Date().toISOString(), last_opened_at: null };
466
+ reg.workspaces.push(ws);
467
+ saveWsRegistry(agentId, reg);
468
+ audit({ actor: 'workspace', agentId, action: 'create', ws: id, external: !!customPath, path: wsPath });
469
+ return { ws, reg };
470
+ }
471
+ function selectWorkspace(agentId, wsId) {
472
+ const reg = loadWsRegistry(agentId);
473
+ const ws = reg.workspaces.find(w => w.id === wsId);
474
+ if (!ws) throw new Error(`工作区 ${wsId} 不存在`);
475
+ reg.current = wsId; ws.last_opened_at = new Date().toISOString();
476
+ saveWsRegistry(agentId, reg);
477
+ audit({ actor: 'workspace', agentId, action: 'select', ws: wsId });
478
+ return reg;
479
+ }
480
+ function deleteWorkspace(agentId, wsId, purge) {
481
+ const reg = loadWsRegistry(agentId);
482
+ const ws = reg.workspaces.find(w => w.id === wsId);
483
+ if (!ws) throw new Error(`工作区 ${wsId} 不存在`);
484
+ if (wsId === 'default') throw new Error('默认工作区不可删除');
485
+ reg.workspaces = reg.workspaces.filter(w => w.id !== wsId);
486
+ if (reg.current === wsId) reg.current = 'default';
487
+ saveWsRegistry(agentId, reg);
488
+ if (purge && !ws.external && fs.existsSync(ws.path)) fs.rmSync(ws.path, { recursive: true, force: true }); // 自定义路径只删登记,不动用户文件
489
+ audit({ actor: 'workspace', agentId, action: 'delete', ws: wsId, purged: !!purge });
490
+ return reg;
491
+ }
492
+
493
+ // ── 通用计算原语与平台数据服务(pi-core 哲学:少量原语 + 组合,而非功能堆叠)──
494
+ const { exec: cpExec } = require('node:child_process');
495
+ 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/];
496
+ 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'];
497
+ function classifyExec(command, autonomy) {
498
+ const cmd = String(command || '').trim();
499
+ if (EXEC_DENY.some((re) => re.test(cmd))) return { status: 'blocked', reason: '【守护栏】命令命中危险清单(sudo/毁灭性操作等)' };
500
+ const noRedirect = cmd.replace(/2>&1/g, ''); // 重定向/tee 使白名单命令具备写能力,不再视为只读
501
+ const SQL_WRITE = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE)\b/i;
502
+ // 复合命令逐段判定:每个由 ; && || | 换行 分隔的段都须只读(控制关键字与 for 头部放行)
503
+ const segOk = (seg) => {
504
+ let s = seg.trim().replace(/^(do|then|else|done|fi|esac|\{|\})\s+/, '');
505
+ if (/^[A-Za-z_]\w*\s+in\b/.test(s)) return true; // for 循环头部(变量 in 值列表)
506
+ if (/^(for|while|until|if|case)\b/.test(s)) return true; // 控制关键字本身
507
+ const first = s.split(/\s+/)[0].split('/').pop();
508
+ return EXEC_RO.includes(first) && !(first === 'sqlite3' && SQL_WRITE.test(s));
509
+ };
510
+ const segments = cmd.split(/;|&&|\|\||\|\n?|\n/).map((s) => s.trim()).filter(Boolean);
511
+ const ro = segments.length > 0 && segments.every(segOk) && !/(>>|>)|\btee\b/.test(noRedirect);
512
+ if (ro) return { status: 'ok' }; // 只读白名单:任何级别免确认(取证不应有摩擦)
513
+ if (autonomy >= 2) return { status: 'pending' }; // 写命令:L2 可发起,执行前需人工确认
514
+ return { status: 'blocked', reason: `【守护栏】L${autonomy} 仅放行只读命令(ls/cat/grep/curl/ping/nc/sqlite3只读等)。写操作需 L2 并人工确认` };
515
+ }
516
+ const PRIMITIVE_TOOLS = {
517
+ exec: {
518
+ min_level: 0, confirm_required: false, // 确认由 classifyExec 按命令动态判定
519
+ desc: '通用终端命令执行。工作目录(cwd)= 当前工作区;连通性实测用 nc -vz -w 3 host port;平台日志用绝对路径 tail(见系统提示)。只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝。不要期待专用工具,优先用命令组合能力',
520
+ schema: { command: { type: 'string', description: '要执行的 shell 命令' }, timeout_ms: { type: 'number', description: '超时毫秒,默认 15000' } },
521
+ required: ['command'],
522
+ run: async ({ command, timeout_ms = 15000 }, agentId) => {
523
+ const env = { ...process.env };
524
+ delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT; // 凭据不进子进程
525
+ const ws = agentId ? currentWs(agentId) : null; // 文件领地锚定工作区(07 设计 §4.3)
526
+ return await new Promise((resolve) => {
527
+ cpExec(command, { cwd: ws ? ws.path : ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell: process.env.SHELL || '/bin/zsh' }, (err, stdout, stderr) => {
528
+ const out = String(stdout || '').slice(0, 4000);
529
+ const errOut = String(stderr || '').slice(0, 1500);
530
+ audit({ actor: 'assistant', tool: 'exec', command: String(command).slice(0, 200), ok: !err, ws: ws?.id });
531
+ resolve(`$ ${command}\n${out}${errOut ? '\n[stderr] ' + errOut : ''}${err && !out && !errOut ? '\n[错误] ' + err.message : ''}`);
532
+ });
533
+ });
534
+ },
535
+ },
536
+ data_describe: {
537
+ min_level: 0, confirm_required: false,
538
+ desc: '查看平台数据集字典(可见=本助手私有+共享;含字段 schema/归属/作用域)。不知道有什么数据时先调它',
539
+ schema: { name: { type: 'string', description: '数据集名;缺省列出全部可见数据集' } }, required: [],
540
+ run: async (args, agentId, wsId = 'default') => {
541
+ const list = loadCollections().filter((c) => scopeOf(c) === 'shared' || c.owner_agent === agentId);
542
+ if (args.name) {
543
+ const m = list.find((c) => c.name === args.name);
544
+ if (!m) throw new Error(`数据集 ${args.name} 不存在或不在你的可见范围(助手隔离)`);
545
+ return JSON.stringify({ ...m, workspace: wsId }, null, 1);
546
+ }
547
+ return JSON.stringify(list.map(({ name, title, description, scope, owner_agent, schema, required }) => ({ name, title, description, scope, owner: owner_agent?.slice(0, 8), fields: Object.keys(schema || {}), required })), null, 1);
548
+ },
549
+ },
550
+ data_query: {
551
+ min_level: 0, confirm_required: false,
552
+ desc: '查询平台数据集记录(服务端按助手隔离;字段见 data_describe)',
553
+ schema: { name: { type: 'string', description: '数据集名' }, filter: { type: 'object', description: '字段精确匹配对象' }, sort: { type: 'string', description: '排序字段,-前缀倒序' }, limit: { type: 'number', description: '默认 20' } },
554
+ required: ['name'],
555
+ run: async (args, agentId, wsId = 'default') => {
556
+ const m = loadCollection(args.name);
557
+ if (!m || (scopeOf(m) !== 'shared' && m.owner_agent !== agentId)) throw new Error(`数据集 ${args.name} 不存在或不在你的可见范围`);
558
+ const rows = collRecords(m, { filter: args.filter, sort: args.sort, limit: args.limit || 20 }, wsId);
559
+ metric({ kind: 'data_query', collection: m.name, hits: rows.length });
560
+ return rows.length ? JSON.stringify(rows, null, 1) : `(数据集 ${args.name} 无匹配记录)`;
561
+ },
562
+ },
563
+ data_write: {
564
+ min_level: 2, confirm_required: true,
565
+ desc: '向平台数据集写入/更新一条记录(写操作,需人工确认;doc 含 _id 则更新)',
566
+ schema: { name: { type: 'string', description: '数据集名' }, doc: { type: 'object', description: '记录内容(字段见 data_describe;含 _id 则为更新)' } },
567
+ required: ['name', 'doc'],
568
+ run: async ({ name, doc }, agentId, wsId = 'default') => {
569
+ const m = loadCollection(name);
570
+ if (!m || (scopeOf(m) !== 'shared' && m.owner_agent !== agentId)) throw new Error(`数据集 ${name} 不存在或不在你的可见范围`);
571
+ const saved = doc._id ? updateDoc(m, doc._id, doc, wsId) : insertDoc(m, doc, wsId);
572
+ audit({ actor: 'data-write', collection: name, id: saved._id, ws: wsId });
573
+ return `已写入(id=${saved._id}):${JSON.stringify(saved)}`;
574
+ },
575
+ },
576
+ };
577
+
578
+
579
+ // ── 工具注册中心(L2 工具层)────────────────────────────
580
+ const TOOLS = {
581
+ search_knowledge: {
582
+ min_level: 0, confirm_required: false,
583
+ schema: { query: { type: 'string', description: '检索词,建议用报错信息或问题主题' } }, required: ['query'],
584
+ desc: '检索企业知识库(ARM 混合检索:RAG 语义⊕关键词;离线时自动降级为本地已下载知识的关键词检索)——历史故障处置经验、SOP',
585
+ run: async ({ query }, agentId) => {
586
+ try {
587
+ const { via, items } = await searchKnowledgeItems(query, 3);
588
+ metric({ kind: 'kb_search', agentId, query, via, top_similarity: items.length ? items[0].similarity : null });
589
+ if (!items.length) return '(ARM 知识库无命中。可换更具体的现象/报错关键词再试。)';
590
+ return items.map(i => `## ${i.title}${i.similarity != null ? `(相关度 ${i.similarity}%)` : ''}\n${i.snippet}`).join('\n\n') + `\n\n(via ${via})`;
591
+ } catch (e) { // 离线降级:本地已物化知识的关键词命中(07 设计 §2.4)
592
+ const hits = localKnowledgeSearch(agentId, query);
593
+ metric({ kind: 'kb_search', agentId, query, offline: true, hits: hits.length });
594
+ if (!hits.length) return `(离线模式:ARM 不可达且本地知识无命中——${String(e.message).slice(0, 60)}。可在助手大厅下载/升级助手以补充本地知识。)`;
595
+ return ['(离线模式:ARM 不可达,以下来自本助手已下载的本地知识)', ...hits.map(h => `## ${h.title}(本地知识 · 命中 ${h.score} 处)\n${h.snippet}…`)].join('\n\n');
596
+ }
597
+ },
598
+ },
599
+ // ──(已退役 2026-10-15)execute_device_reset / create_work_order / restart_service / run_environment_check:
600
+ // 四个 mock 编排工具(logic://…@mock,改内存状态)是"一个功能一个工具"反模式的幸存者,且 ARM 真实助手群体
601
+ // (WMS/Jenkins/镜像/代码review)无一需要复位堆垛机。MVP 打磨移除;L2 确认流由 exec 写命令与 data_write 承担,
602
+ // 工作台按钮统一走 params.actions + runAction(随草稿人审)。logic-ide 接入后以通用编排工具(run_orchestration)回归。──
603
+ save_experience: {
604
+ min_level: 1, confirm_required: false,
605
+ schema: { title: { type: 'string', description: '经验标题,含设备与现象关键词' }, markdown: { type: 'string', description: '正文 markdown:现象/根因/处置步骤/验证' } }, required: ['title', 'markdown'],
606
+ desc: '把本次处置经验沉淀进企业知识库(ARM 经验库;离线时存本地待回流队列)',
607
+ run: async ({ title, markdown }, agentId) => {
608
+ const arm = agentProfileNow(agentId);
609
+ const content = ['---', 'kind: experience', 'source: aims-platform-mvp', `agent: ${arm.name || agentId}@${arm.version || ''}`, 'scenario: 立库A区智能运维@mvp', '---', '', markdown].join('\n');
610
+ const args = { name: title, description: `MVP回流:${title}`, content, tags: ['MVP回流'] };
611
+ let r;
612
+ try { r = await mcpCall('create_knowledge', args, { pat: true }); }
613
+ catch (e) { // 离线:入 outbox 缓写,恢复连接后经「检查更新」自动回流
614
+ fs.appendFileSync(OUTBOX_FILE, JSON.stringify({ at: new Date().toISOString(), agentId, args }) + '\n');
615
+ audit({ actor: 'experience-reflow', target: 'outbox', title });
616
+ return `ARM 不可达(${String(e.message).slice(0, 60)}),经验已存本地待回流队列;恢复连接后在助手大厅点「检查更新」将自动回流。`;
617
+ }
618
+ const kid = (r.match(/id:\s*([0-9a-f-]{36})/) || [])[1] || '';
619
+ metric({ kind: 'experience', agentId, title, knowledge_id: kid });
620
+ audit({ actor: 'experience-reflow', target: 'ARM', title });
621
+ return `经验已沉淀到 ARM 知识库:\n${r}`;
622
+ },
623
+ },
624
+ };
625
+ const LEVEL_NAME = { 0: 'L0 只读问答', 1: 'L1 诊断建议', 2: 'L2 确认执行', 3: 'L3 完全自主' };
626
+ Object.assign(TOOLS, PRIMITIVE_TOOLS); // 原语与数据服务并入工具注册中心
627
+ const getTool = (name) => TOOLS[name];
628
+
629
+ // JSON Schema → pi-ai TypeBox(普通 object schema 直接透传即可)
630
+ function piTools(deployment) {
631
+ const allow = new Set(deployment.toolset.map(t => t.tool));
632
+ return Object.entries(TOOLS).filter(([n]) => allow.has(n))
633
+ .map(([n, t]) => ({ name: n, description: t.desc, parameters: { type: 'object', properties: t.schema, required: t.required || [] } }));
634
+ }
635
+
636
+ // ── 守护栏(运行时强制,03-§2)──────────────────────────
637
+ function guardCheck(toolName, autonomy, agentId, args = {}) {
638
+ const tool = getTool(toolName);
639
+ if (!tool) return { allowed: false, status: 'rejected', reason: `未知工具 ${toolName}` };
640
+ if (toolName === 'exec') { // 通用原语:按命令内容分级,而非枚举功能
641
+ const cls = classifyExec(args.command, autonomy);
642
+ if (cls.status === 'blocked') return { allowed: false, status: 'blocked', reason: cls.reason };
643
+ if (autonomy >= 3) return { allowed: true, status: 'ok', confirm_required: false }; // L3 完全自主:免确认
644
+ return { allowed: true, status: cls.status, confirm_required: cls.status === 'pending' };
645
+ }
646
+ if (tool.min_level > autonomy) {
647
+ return { allowed: false, status: 'blocked', reason: `【守护栏拦截】当前助手自主级别为 ${LEVEL_NAME[autonomy]},该操作需要 ${LEVEL_NAME[tool.min_level]}。请在工作台操作区人工执行,或经评测升权后再试。` };
648
+ }
649
+ if (autonomy >= 3) return { allowed: true, status: 'ok', confirm_required: false }; // L3 完全自主:写操作不再挂起
650
+ return { allowed: true, status: tool.confirm_required ? 'pending' : 'ok', confirm_required: tool.confirm_required };
651
+ }
652
+
653
+ // ── 上岗配置 / 工作台 DSL 存储(版本化)────────────────
654
+ function loadDeployment(agentId) {
655
+ const p = path.join(DEPLOY_DIR, `${agentId}.json`);
656
+ return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null;
657
+ }
658
+ function saveDeployment(agentId, dep) {
659
+ fs.writeFileSync(path.join(DEPLOY_DIR, `${agentId}.json`), JSON.stringify(dep, null, 2));
660
+ audit({ actor: 'deployment', agentId, action: 'save' });
661
+ }
662
+ function loadWorkbench(agentId) {
663
+ const p = path.join(WB_DIR, `${agentId}.json`);
664
+ return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null;
665
+ }
666
+ function saveWorkbench(agentId, dsl) {
667
+ fs.writeFileSync(path.join(WB_DIR, `${agentId}.json`), JSON.stringify(dsl, null, 2));
668
+ const vp = path.join(WB_VER_DIR, `${agentId}.json`);
669
+ const vers = fs.existsSync(vp) ? JSON.parse(fs.readFileSync(vp, 'utf8')) : [];
670
+ vers.push({ version: (dsl.version || '').replace(/^v/, '') || String(vers.length + 1), at: new Date().toISOString(), by: 'platform-user', dsl });
671
+ fs.writeFileSync(vp, JSON.stringify(vers, null, 2));
672
+ audit({ actor: 'workbench', agentId, action: 'save', version: dsl.version });
673
+ }
674
+ function workbenchVersions(agentId) {
675
+ const vp = path.join(WB_VER_DIR, `${agentId}.json`);
676
+ if (!fs.existsSync(vp)) return [];
677
+ return JSON.parse(fs.readFileSync(vp, 'utf8')).map(({ version, at, by }) => ({ version, at, by }));
678
+ }
679
+
680
+ // ── 数据集存储(按助手隔离:每个助手一个库文件 + 可选共享库;建表即建工具)──
681
+ const { DatabaseSync } = require('node:sqlite');
682
+ const _dbs = new Map();
683
+ function openDb(file) {
684
+ if (!_dbs.has(file)) {
685
+ const db = new DatabaseSync(path.join(DATA_DIR, file));
686
+ db.exec('CREATE TABLE IF NOT EXISTS docs (collection TEXT NOT NULL, id TEXT PRIMARY KEY, data TEXT NOT NULL, created TEXT, updated TEXT)');
687
+ db.exec('CREATE INDEX IF NOT EXISTS idx_docs_collection ON docs(collection)');
688
+ _dbs.set(file, db);
689
+ }
690
+ return _dbs.get(file);
691
+ }
692
+ const DB = openDb('store.db'); // 兼容旧引用(迁移后即空)
693
+ // 作用域语义:agent=助手私有(默认,存归属助手×工作区的独立库);shared=跨助手共享(存共享库)
694
+ const scopeOf = (m) => (m.scope === 'shared' || m.scope === 'global') ? 'shared' : 'agent'; // legacy global→shared, workbench→agent
695
+ const dbFor = (m, wsId = 'default') => openDb(scopeOf(m) === 'shared' ? 'store-shared.db' : `ws-${m.owner_agent || 'orphan'}-${wsId}.db`);
696
+ const COLL_DIR = path.join(AIMS_HOME, 'config/collections');
697
+ fs.mkdirSync(COLL_DIR, { recursive: true });
698
+
699
+ function loadCollections() {
700
+ return fs.readdirSync(COLL_DIR).filter(f => f.endsWith('.json'))
701
+ .map(f => JSON.parse(fs.readFileSync(path.join(COLL_DIR, f), 'utf8')));
702
+ }
703
+ function loadCollection(name) {
704
+ const p = path.join(COLL_DIR, `${name}.json`);
705
+ return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null;
706
+ }
707
+ function saveCollection({ name, title, description, schema, required = [], scope = 'agent', owner_agent, owner_plugin }) {
708
+ if (!/^[a-z][a-z0-9_-]{1,40}$/.test(name)) throw new Error('数据集 name 须为小写字母开头的字母/数字/-_');
709
+ if (!schema || typeof schema !== 'object') throw new Error('schema 必须为 {字段:{type,description}} 对象');
710
+ if (scope !== 'shared' && !owner_agent) throw new Error('agent 作用域数据集必须声明 owner_agent(归属助手)');
711
+ const manifest = { name, title: title || name, description: description || '', schema, required, scope: scopeOf({ scope }), owner_agent: owner_agent || null, owner_plugin: owner_plugin || null, updated: new Date().toISOString() };
712
+ fs.writeFileSync(path.join(COLL_DIR, `${name}.json`), JSON.stringify(manifest, null, 2));
713
+ audit({ actor: 'collection', name, action: loadCollection(name) ? 'update' : 'create', scope });
714
+ return manifest;
715
+ }
716
+ const collKey = (m) => m.name; // 物理隔离已由分库保证,collection 名即键
717
+ function validateDoc(m, doc) {
718
+ for (const f of m.required || []) if (doc[f] === undefined || doc[f] === null || doc[f] === '') throw new Error(`缺少必填字段: ${f}`);
719
+ for (const [f, def] of Object.entries(m.schema || {})) {
720
+ const v = doc[f]; if (v === undefined) continue;
721
+ const tp = { string: 'string', number: 'number', boolean: 'boolean' }[def.type];
722
+ if (tp && typeof v !== tp) throw new Error(`字段 ${f} 类型应为 ${def.type}`);
723
+ }
724
+ }
725
+ function collRecords(m, { filter, sort, limit } = {}, wsId = 'default') {
726
+ let rows = dbFor(m, wsId).prepare('SELECT id, data, created, updated FROM docs WHERE collection = ?').all(collKey(m))
727
+ .map(r => ({ ...JSON.parse(r.data), _id: r.id, _created: r.created, _updated: r.updated }));
728
+ if (filter && typeof filter === 'object') rows = rows.filter(d => Object.entries(filter).every(([k, v]) => String(d[k]) === String(v)));
729
+ if (sort) {
730
+ const desc = sort.startsWith('-'); const f = desc ? sort.slice(1) : sort;
731
+ rows.sort((a, b) => (a[f] > b[f] ? 1 : a[f] < b[f] ? -1 : 0) * (desc ? -1 : 1));
732
+ }
733
+ return rows.slice(0, Math.min(limit || 100, 500));
734
+ }
735
+ function insertDoc(m, doc, wsId = 'default') {
736
+ validateDoc(m, doc);
737
+ const id = doc._id || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
738
+ const now = new Date().toISOString();
739
+ dbFor(m, wsId).prepare('INSERT OR REPLACE INTO docs (collection, id, data, created, updated) VALUES (?,?,?,?,?)')
740
+ .run(collKey(m), id, JSON.stringify(doc), now, now);
741
+ return { ...doc, _id: id, _created: now, _updated: now };
742
+ }
743
+ function updateDoc(m, id, patch, wsId = 'default') {
744
+ const row = dbFor(m, wsId).prepare('SELECT data FROM docs WHERE collection = ? AND id = ?').get(collKey(m), id);
745
+ if (!row) throw new Error(`记录 ${id} 不存在`);
746
+ const merged = { ...JSON.parse(row.data), ...patch };
747
+ validateDoc(m, merged);
748
+ dbFor(m, wsId).prepare('UPDATE docs SET data = ?, updated = ? WHERE collection = ? AND id = ?').run(JSON.stringify(merged), new Date().toISOString(), collKey(m), id);
749
+ return merged;
750
+ }
751
+ function deleteDoc(m, id, wsId = 'default') {
752
+ const r = dbFor(m, wsId).prepare('DELETE FROM docs WHERE collection = ? AND id = ?').run(collKey(m), id);
753
+ if (!r.changes) throw new Error(`记录 ${id} 不存在`);
754
+ }
755
+
756
+ // ── 一次性迁移:旧单库 → 按助手分库(按归属切分记录)──
757
+ (function migrateLegacyStore() {
758
+ const legacy = path.join(DATA_DIR, 'store.db');
759
+ if (!fs.existsSync(legacy)) return;
760
+ const OWNERS = { server_accounts: '4d8f94ab-cfee-490f-8873-43c58ceefc0c', maintenance_records: 'de319700-4412-4d55-b3dd-7a14b7cb4566' };
761
+ try {
762
+ const old = new DatabaseSync(legacy);
763
+ const rows = old.prepare('SELECT collection, id, data, created, updated FROM docs').all();
764
+ for (const r of rows) {
765
+ const m = loadCollection(r.collection);
766
+ if (!m) continue;
767
+ if (!m.owner_agent && OWNERS[m.name]) { // 补归属并私有化
768
+ m.owner_agent = OWNERS[m.name]; m.scope = 'agent';
769
+ fs.writeFileSync(path.join(COLL_DIR, `${m.name}.json`), JSON.stringify(m, null, 2));
770
+ }
771
+ dbFor(m, 'default').prepare('INSERT OR REPLACE INTO docs (collection, id, data, created, updated) VALUES (?,?,?,?,?)')
772
+ .run(r.collection, r.id, r.data, r.created, r.updated);
773
+ }
774
+ fs.renameSync(legacy, legacy + '.migrated');
775
+ audit({ actor: 'migration', action: 'per-agent-split', moved: rows.length });
776
+ console.log(`[mvp] 数据已按助手分库迁移(${rows.length} 条)`);
777
+ } catch (e) { console.error('[mvp] 迁移失败:', e.message); }
778
+ })();
779
+
780
+ // ── 一次性迁移:按助手分库 → 按工作区分库(store-{agent}.db → ws-{agent}-default.db,07 设计 §7)──
781
+ (function migrateStoreToWorkspace() {
782
+ let moved = 0;
783
+ for (const f of fs.readdirSync(DATA_DIR)) {
784
+ const m = f.match(/^store-([0-9a-f-]{36})\.db(-wal|-shm|-journal)?$/);
785
+ if (!m) continue;
786
+ const from = path.join(DATA_DIR, f);
787
+ const to = path.join(DATA_DIR, `ws-${m[1]}-default.db${m[2] || ''}`);
788
+ if (!fs.existsSync(to)) { try { fs.renameSync(from, to); moved++; } catch { } }
789
+ else try { fs.rmSync(from); } catch { }
790
+ }
791
+ if (moved) { audit({ actor: 'migration', action: 'per-workspace-split', moved }); console.log(`[mvp] 助手数据已迁移到默认工作区库(${moved} 个文件)`); }
792
+ })();
793
+
794
+
795
+
796
+ // ── 布局迁移:slot 三栏 → grid{x,y,w,h} 自由网格(一次性,幂等)──
797
+ (function migrateSlotToGrid() {
798
+ const COL = { left: { x: 1, w: 3 }, center: { x: 4, w: 6 }, right: { x: 10, w: 3 } };
799
+ for (const f of fs.readdirSync(WB_DIR).filter((f) => f.endsWith('.json'))) {
800
+ const fp = path.join(WB_DIR, f);
801
+ let dsl; try { dsl = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { continue; }
802
+ if ((dsl.panels || []).every((p) => p.grid)) continue;
803
+ const y = { left: 1, center: 1, right: 1 };
804
+ let changed = false;
805
+ for (const p of dsl.panels || []) {
806
+ if (p.grid) continue;
807
+ const key = p.slot || (p.type === 'agent_dialog' ? 'center' : 'left');
808
+ const track = COL[key] || COL.left;
809
+ const h = p.type === 'agent_dialog' ? 3 : 1;
810
+ p.grid = { x: track.x, w: track.w, y: y[key] || 1, h };
811
+ y[key] = (y[key] || 1) + h;
812
+ delete p.slot;
813
+ changed = true;
814
+ }
815
+ if (changed) fs.writeFileSync(fp, JSON.stringify(dsl, null, 2));
816
+ }
817
+ })();
818
+ // 占位清理:旧类型残留保护
819
+ (function migrateBuiltinPanels() {
820
+ const MAP = {
821
+ realtime_list: { plugin: 'system-alarm-stream', slot: 'left', keep: ['refresh', 'device_type'] },
822
+ status_grid: { plugin: 'system-device-grid', slot: 'center', keep: ['refresh', 'device_type', 'context_inject'] },
823
+ kpi_strip: { plugin: 'system-kpi-strip', slot: 'center', keep: [] },
824
+ inspection_report: { plugin: 'system-inspection-report', slot: 'left', keep: [] },
825
+ action_group: { plugin: 'system-action-group', slot: 'right', keep: ['actions'] },
826
+ };
827
+ let migrated = 0;
828
+ for (const f of fs.readdirSync(WB_DIR).filter((f) => f.endsWith('.json'))) {
829
+ const fp = path.join(WB_DIR, f);
830
+ let dsl; try { dsl = JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { continue; }
831
+ let changed = false;
832
+ for (const p of dsl.panels || []) {
833
+ const m = MAP[p.type];
834
+ if (!m) continue;
835
+ const params = {};
836
+ for (const k of m.keep) if (p[k] !== undefined) params[k] = p[k];
837
+ const np = { id: p.id, type: 'custom_panel', panel: m.plugin, title: p.title, slot: p.slot || m.slot, params };
838
+ Object.assign(p, np); delete p.type; p.type = 'custom_panel'; // 原地替换保序
839
+ changed = true; migrated++;
840
+ }
841
+ if (changed) fs.writeFileSync(fp, JSON.stringify(dsl, null, 2));
842
+ }
843
+ if (migrated) { audit({ actor: 'migration', action: 'builtin-to-plugin', count: migrated }); console.log(`[mvp] 内置面板已迁移为系统范本插件(${migrated} 处)`); }
844
+ })();
845
+
846
+ // ── 面板插件库(PenguinHarness 模式:插件 = 数据 + 代码)──
847
+ const PANELS_DIR = path.join(AIMS_HOME, 'config/panels');
848
+ fs.mkdirSync(PANELS_DIR, { recursive: true });
849
+ function loadPanelPlugin(id) {
850
+ const p = path.join(PANELS_DIR, `${id}.json`);
851
+ return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null;
852
+ }
853
+ function savePanelPlugin({ id, name, description, code, author = 'ai' }) {
854
+ if (!/^[a-z0-9-]{2,40}$/.test(id)) throw new Error('插件 id 只允许小写字母/数字/连字符');
855
+ if (!code.includes('function Panel')) throw new Error('插件代码必须定义 function Panel(props)');
856
+ // 校验器(check-panel.mjs):编译检查 + 契约 + 沙箱 + 模板完整性,拒绝错误代码入库
857
+ try {
858
+ const out = require('node:child_process').execFileSync('node', [path.join(ROOT, 'scripts/check-panel.mjs')], { input: code, encoding: 'utf8', timeout: 15000 });
859
+ if (out.includes('⚠️')) audit({ actor: 'check-panel', id, note: out.split('\n').filter(l => l.includes('⚠️')).join(' | ').slice(0, 200) });
860
+ } catch (e) {
861
+ throw new Error('插件代码未通过校验器:\n' + String(e.stdout || e.message).slice(0, 400));
862
+ }
863
+ const old = loadPanelPlugin(id);
864
+ const plugin = {
865
+ id, name: name || id, description: description || '', author,
866
+ version: old ? `v${(+(old.version || 'v0').replace('v', '') || 0) + 1}` : 'v1',
867
+ updated: new Date().toISOString(), code,
868
+ history: [...(old?.history || []), ...(old ? [{ version: old.version, code: old.code, at: old.updated }] : [])].slice(-5),
869
+ };
870
+ fs.writeFileSync(path.join(PANELS_DIR, `${id}.json`), JSON.stringify(plugin, null, 2));
871
+ audit({ actor: 'panel-plugin', id, action: old ? 'update' : 'create', version: plugin.version });
872
+ return plugin;
873
+ }
874
+ function listPanelPlugins() {
875
+ return fs.readdirSync(PANELS_DIR).filter(f => f.endsWith('.json'))
876
+ .map(f => JSON.parse(fs.readFileSync(path.join(PANELS_DIR, f), 'utf8')))
877
+ .map(({ id, name, description, version, author, updated }) => ({ id, name, description, version, author, updated }));
878
+ }
879
+ function panelCodeOf(p) { // 工作台面板引用解析为可执行代码
880
+ if (p.type === 'custom' && p.code) return p.code;
881
+ if (p.type === 'custom_panel' && p.panel) {
882
+ const plugin = loadPanelPlugin(p.panel);
883
+ if (!plugin) throw new Error(`插件 ${p.panel} 不存在`);
884
+ return plugin.code;
885
+ }
886
+ return null;
887
+ }
888
+ // 工作台草稿(Agent 的产出先进草稿,人审发布后生效——生成→审查→发布)
889
+ const draftPath = (agentId) => path.join(WB_DIR, `${agentId}.draft.json`);
890
+ function loadDraft(agentId) {
891
+ const p = draftPath(agentId);
892
+ return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null;
893
+ }
894
+ function saveDraft(agentId, dsl) {
895
+ if (!Array.isArray(dsl.panels)) throw new Error('dsl.panels 必须为数组');
896
+ const prev = loadDraft(agentId) || loadWorkbench(agentId) || { panels: [] };
897
+ // 防护性合并:同 id 同类型的面板,Agent 遗漏的字段(如 action_group.actions)从上一版补全,防止全量重写丢配置
898
+ dsl.panels = dsl.panels.map(p => {
899
+ const old = prev.panels.find(x => x.id === p.id && x.type === p.type);
900
+ return old ? { ...old, ...p } : p;
901
+ });
902
+ for (const p of dsl.panels) {
903
+ if ((p.type === 'custom' || p.type === 'custom_panel') && !panelCodeOf(p)) throw new Error(`面板 ${p.id || p.title} 引用的代码/插件缺失`);
904
+ }
905
+ dsl.owner_agent = agentId;
906
+ fs.writeFileSync(draftPath(agentId), JSON.stringify(dsl, null, 2));
907
+ audit({ actor: 'dev-agent', agentId, action: 'draft', panels: dsl.panels.length });
908
+ }
909
+
910
+ // ── Skill 装载(Agent Skills 规范:SKILL.md frontmatter 注入描述,正文按需读取)──
911
+ const SKILLS_DIR = path.join(ROOT, 'skills');
912
+ function listSkills() {
913
+ if (!fs.existsSync(SKILLS_DIR)) return [];
914
+ return fs.readdirSync(SKILLS_DIR).filter((d) => fs.existsSync(path.join(SKILLS_DIR, d, 'SKILL.md')))
915
+ .map((d) => {
916
+ const raw = fs.readFileSync(path.join(SKILLS_DIR, d, 'SKILL.md'), 'utf8');
917
+ const fm = raw.match(/^---\n([\s\S]*?)\n---/);
918
+ const meta = {};
919
+ for (const line of (fm?.[1] || '').split('\n')) {
920
+ const m = line.match(/^(name|description|version|author):\s*(.+)$/);
921
+ if (m) meta[m[1]] = m[2].trim();
922
+ }
923
+ return { dir: d, name: meta.name || d, version: meta.version || '', description: (meta.description || '').slice(0, 200) };
924
+ });
925
+ }
926
+ function readSkill(name) {
927
+ const s = listSkills().find((x) => x.name === name || x.dir === name);
928
+ if (!s) return null;
929
+ const dir = path.join(SKILLS_DIR, s.dir);
930
+ const files = {};
931
+ const walk = (d, prefix = '') => {
932
+ for (const f of fs.readdirSync(d)) {
933
+ const fp = path.join(d, f);
934
+ if (fs.statSync(fp).isDirectory()) walk(fp, `${prefix}${f}/`);
935
+ else files[`${prefix}${f}`] = fs.readFileSync(fp, 'utf8').slice(0, 12000);
936
+ }
937
+ };
938
+ walk(dir);
939
+ return { ...s, files };
940
+ }
941
+
942
+ // ── 统一插件中心:系统面板与契约插件同一注册表,同一生命周期 ──
943
+ const SYSTEM_PLUGINS = [
944
+ { id: 'agent_dialog', name: '诊断对话', author: 'system', status: 'active', note: '唯一编译内核:与助手对话。其余面板均为插件库范本(KISS)' },
945
+ ];
946
+ const SYS_STATUS_FILE = path.join(DATA_DIR, 'system-plugin-status.json');
947
+ function systemPluginStatus() {
948
+ let over = {};
949
+ try { over = JSON.parse(fs.readFileSync(SYS_STATUS_FILE, 'utf8')); } catch { }
950
+ return SYSTEM_PLUGINS.map((s) => ({ ...s, status: over[s.id] || s.status }));
951
+ }
952
+ function setSystemPluginStatus(id, status) {
953
+ let over = {};
954
+ try { over = JSON.parse(fs.readFileSync(SYS_STATUS_FILE, 'utf8')); } catch { }
955
+ over[id] = status;
956
+ fs.writeFileSync(SYS_STATUS_FILE, JSON.stringify(over, null, 2));
957
+ audit({ actor: 'plugin-center', kind: 'system', id, action: status === 'deprecated' ? 'deprecate' : 'restore' });
958
+ }
959
+ function pluginUsage() { // 引用统计:扫描所有工作台(线上+草稿)
960
+ const usage = {};
961
+ for (const f of fs.readdirSync(WB_DIR).filter((f) => f.endsWith('.json'))) {
962
+ const agentId = f.replace(/\.(draft\.)?json$/, '');
963
+ let dsl; try { dsl = JSON.parse(fs.readFileSync(path.join(WB_DIR, f), 'utf8')); } catch { continue; }
964
+ for (const p of dsl.panels || []) {
965
+ const key = p.type === 'custom_panel' ? `plugin:${p.panel}` : `system:${p.type}`;
966
+ (usage[key] = usage[key] || []).push({ agent: agentId.slice(0, 8), draft: f.includes('.draft.') });
967
+ }
968
+ }
969
+ return usage;
970
+ }
971
+ function pluginCenterData() {
972
+ const usage = pluginUsage();
973
+ return {
974
+ system: systemPluginStatus().map((s) => ({ ...s, usage: usage[`system:${s.id}`] || [] })),
975
+ library: listPanelPlugins().map((pl) => {
976
+ const full = loadPanelPlugin(pl.id);
977
+ return { ...pl, status: full?.status || 'active', usage: usage[`plugin:${pl.id}`] || [], lastCheck: full?.lastCheck || null };
978
+ }),
979
+ };
980
+ }
981
+ // 代理域名白名单:从在库插件代码中提取其声明访问的主机(含 deprecated,保守起见全量)
982
+ function proxyAllowedHosts() {
983
+ const hosts = new Set(['localhost', '127.0.0.1']);
984
+ for (const f of fs.readdirSync(PANELS_DIR).filter((f) => f.endsWith('.json'))) {
985
+ try {
986
+ const code = JSON.parse(fs.readFileSync(path.join(PANELS_DIR, f), 'utf8')).code || '';
987
+ for (const m of code.matchAll(/https?:\/\/([a-z0-9.-]+(?::\d+)?)/gi)) hosts.add(m[1].toLowerCase());
988
+ } catch { }
989
+ }
990
+ return hosts;
991
+ }
992
+
993
+ // ── 工作台工程师 Agent(对话式开发工作台的 skill:工具即扩展点)──
994
+ const DEV_TOOLS = {
995
+ list_workbench: {
996
+ desc: '查看当前工作台 DSL(live 与未发布草稿)',
997
+ schema: {}, required: [],
998
+ run: async () => JSON.stringify({ live: loadWorkbench(devCtx.agentId), draft: loadDraft(devCtx.agentId) }, null, 1),
999
+ },
1000
+ list_panel_plugins: {
1001
+ desc: '列出面析插件库(可复用的自定义面板插件)',
1002
+ schema: {}, required: [],
1003
+ run: async () => JSON.stringify(listPanelPlugins(), null, 1),
1004
+ },
1005
+ get_state_schema: {
1006
+ desc: '获取现场实时数据样例(面板 props 的数据结构)',
1007
+ schema: {}, required: [],
1008
+ run: async () => JSON.stringify({ devices: [world.devices[0]], alarms: [world.alarms[0]], kpi: world.kpi, work_orders: world.work_orders.slice(-1), note: 'devices: [{id,name,type,status(normal|warning|alarm),detail}] alarms: [{id,device,level(高|中|低),text,ts,state(active|resolved)}]' }, null, 1),
1009
+ },
1010
+ write_panel_plugin: {
1011
+ desc: '创建/更新面板插件(写入插件库)。代码必须定义 function Panel(props)',
1012
+ schema: {
1013
+ id: { type: 'string', description: '插件 id,小写字母数字连字符,如 alarm-by-type' },
1014
+ name: { type: 'string', description: '插件显示名' },
1015
+ description: { type: 'string', description: '一句话说明' },
1016
+ code: { type: 'string', description: '面板代码,契约见系统提示' },
1017
+ }, required: ['id', 'name', 'code'],
1018
+ run: async ({ id, name, description, code }) => {
1019
+ const plugin = savePanelPlugin({ id, name, description, code });
1020
+ metric({ kind: 'plugin_write', agentId: devCtx.agentId, id });
1021
+ return `插件已入库:${plugin.id}@${plugin.version}(${plugin.name})。用 update_workbench 把它加入工作台草稿。`;
1022
+ },
1023
+ },
1024
+ list_collections: {
1025
+ desc: '列出平台数据集(含 schema),供面板绑定与助手查询',
1026
+ schema: {}, required: [],
1027
+ run: async () => JSON.stringify(loadCollections(), null, 1),
1028
+ },
1029
+ create_collection: {
1030
+ desc: '创建数据集(建表即建工具:助手侧自动生成 query_/create_ 工具;面板侧通过 props.store 读写)。需要 CRUD 的面板应先建数据集',
1031
+ schema: {
1032
+ name: { type: 'string', description: '数据集名,小写字母开头,如 maintenance_records' },
1033
+ title: { type: 'string', description: '显示名,如 保养记录' },
1034
+ description: { type: 'string', description: '一句话用途说明(会成为助手工具描述)' },
1035
+ schema: { type: 'object', description: '字段定义 {字段名:{type:string|number|boolean, description:...}}' } ,
1036
+ required: { type: 'array', description: '必填字段名数组' },
1037
+ scope: { type: 'string', description: 'agent(默认,仅归属助手及其面板可用)或 shared(跨助手共享)' },
1038
+ }, required: ['name', 'title', 'schema'],
1039
+ run: async (args) => {
1040
+ const m = saveCollection({ ...args, owner_agent: devCtx.agentId });
1041
+ metric({ kind: 'collection_create', name: m.name });
1042
+ return `数据集「${m.title}」(${m.name}) 已创建,scope=${m.scope}${m.scope === 'agent' ? '(私有,仅本助手)' : '(跨助手共享)'}。本助手已自动获得 query_${m.name}/create_${m.name} 工具;面板用 props.store.list('${m.name}')/create('${m.name}', doc) 读写。`;
1043
+ },
1044
+ },
1045
+ exec: {
1046
+ desc: '终端命令执行(开发档):读白名单直接放行;写文件/跑脚本/构建调试等开发动作放行(这是你的本职);仅毁灭性命令(sudo/rm -rf //mkfs 等)被永久拒绝。用于探索 AGENT.md、写临时代码、运行测试、验证接口。全部调用入审计',
1047
+ schema: { command: { type: 'string', description: '只读命令,如 grep -rl "Nexus" <目录>/*/AGENT.md' }, timeout_ms: { type: 'number' } },
1048
+ required: ['command'],
1049
+ run: async ({ command, timeout_ms = 30000 }) => {
1050
+ // 工程师权限模型:写代码/调试是本职——仅毁灭性黑名单拦截(产出另受草稿审阅门治理),全程审计
1051
+ const cls = classifyExec(command, 3);
1052
+ if (cls.status === 'blocked') return `【拒绝】${cls.reason}`; // 开发档:只拦毁灭性黑名单;pending(写类)对工程师免确认
1053
+ const env = { ...process.env };
1054
+ delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT;
1055
+ return await new Promise((resolve) => {
1056
+ cpExec(command, { cwd: ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell: process.env.SHELL || '/bin/zsh' }, (err, stdout, stderr) => {
1057
+ audit({ actor: 'dev-agent', tool: 'exec', command: String(command).slice(0, 200), ok: !err });
1058
+ resolve(`$ ${command}\n${String(stdout || '').slice(0, 5000)}${stderr ? '\n[stderr] ' + String(stderr).slice(0, 1000) : ''}${err && !stdout ? '\n[错误] ' + err.message : ''}`);
1059
+ });
1060
+ });
1061
+ },
1062
+ },
1063
+ load_skill: {
1064
+ desc: '装载技能全文(SKILL.md + references + scripts)。系统提示里只注入了技能描述;使用某技能前先装载它获得完整规范',
1065
+ schema: { name: { type: 'string', description: '技能名,如 workbench-dev' } }, required: ['name'],
1066
+ run: async ({ name }) => {
1067
+ const s = readSkill(name);
1068
+ if (!s) return `技能 ${name} 不存在。已装载:${listSkills().map((x) => x.name).join(', ')}`;
1069
+ audit({ actor: 'dev-agent', skill: name, action: 'load' });
1070
+ return JSON.stringify({ name: s.name, version: s.version, files: Object.keys(s.files), ...(s.files['SKILL.md'] ? { body: s.files['SKILL.md'] } : {}) }, null, 1).slice(0, 12000);
1071
+ },
1072
+ },
1073
+ read_panel_plugin: {
1074
+ desc: '读取插件库中某个插件的当前代码(修改/升级已有插件前必须先读)',
1075
+ schema: { id: { type: 'string', description: '插件 id' } }, required: ['id'],
1076
+ run: async ({ id }) => {
1077
+ const plugin = loadPanelPlugin(id);
1078
+ if (!plugin) return `插件 ${id} 不存在。现有:${listPanelPlugins().map(p => p.id).join(', ')}`;
1079
+ return `插件 ${plugin.id}@${plugin.version}(${plugin.name}):\n${plugin.code}`;
1080
+ },
1081
+ },
1082
+ update_workbench: {
1083
+ desc: '把完整的工作台 DSL 写入草稿(不会直接生效,需用户在编辑器审阅后发布)。基于当前 DSL 增量修改后整体提交',
1084
+ schema: { dsl: { type: 'object', description: "完整工作台 DSL {workbench,version,panels:[...]}。panels 元素含 id/type/title;自定义面板用 type=custom_panel 且 panel=插件id,或 type=custom 且 code=面板代码" } },
1085
+ required: ['dsl'],
1086
+ run: async ({ dsl }) => {
1087
+ saveDraft(devCtx.agentId, dsl);
1088
+ return `草稿已保存(${dsl.panels.length} 个面板)。提醒用户在「定制工作台」审阅差异后点发布。`;
1089
+ },
1090
+ },
1091
+ };
1092
+ const devCtx = { agentId: null };
1093
+ const devSessions = new Map();
1094
+
1095
+ function devSystemPrompt() {
1096
+ const live = loadWorkbench(devCtx.agentId);
1097
+ const plugins = listPanelPlugins();
1098
+ const owner = agentProfileNow(devCtx.agentId); // 工作台所属助手档案:本地库优先(离线可用)
1099
+ const localDir = localAgent(devCtx.agentId) ? path.join(ARM_AGENTS_DIR, devCtx.agentId, 'versions', localAgent(devCtx.agentId).installed_version) : null;
1100
+ return [
1101
+ '【本工作台所属助手(你的探索起点)】',
1102
+ owner && owner.name ? `名称: ${owner.name} ${owner.version || ''}\n档案 prompt:\n${(owner.prompt || '').slice(0, 600)}\n绑定知识: ${(owner.knowledges || []).join('、') || '无'}` : '(ARM 档案未缓存,先让平台调用过该助手)',
1103
+ localDir ? `助手本地档案(平台下载物化): ${localDir} —— manifest.json 含技能/知识绑定清单,knowledges/ 为知识全文` : '',
1104
+ `ARM 工作站助手目录(若配置): ${process.env.ARM_WS_ROOT || '未配置'} —— 每个从 ARM 下载的助手有独立文件夹,AGENT.md 描述其定位与真实能力(含可用脚本/服务器/接口线索)。按名定位:grep -rl "助手名" <目录>/*/AGENT.md`,
1105
+ '',
1106
+ '你是 AIMS 工业智能体平台的「工作台工程师」,通过编写面板插件帮用户对话式定制工作台。',
1107
+ '',
1108
+ '【面板插件契约(扩展点)】',
1109
+ '插件是一段 JS 代码,必须定义:function Panel(props) { return html`...` }',
1110
+ '· html 是 htm 模板标签(绑定 React.createElement),写法:html`<div className="text-xs">${x}</div>`',
1111
+ '· 可用 hooks:useState / useEffect / useRef / useMemo(已注入,直接用)',
1112
+ '· props(实时现场数据 + 交互):',
1113
+ ' devices:[{id,name,type,status:normal|warning|alarm,detail}] alarms:[{id,device,level:高|中|低,text,ts,state}]',
1114
+ ' kpi:{出入库吞吐:...} work_orders:[{id,device,priority,state}] selected(当前选中设备id) onSelect(id)(选中设备)',
1115
+ '· 样式用 Tailwind className(暗色主题;卡片 bg-card border rounded-lg p-3;主色 text-primary;状态色 emerald/amber/red)',
1116
+ '· 禁止 import/require/window 操作;需要更多数据可 fetch("/api/state")',
1117
+ '· 面板渲染于工作台左栏(宽约 290px)或中栏(dsl 里 slot:"center"),布局要紧凑',
1118
+ '',
1119
+ '【参考实现(严格仿照此风格)】',
1120
+ 'function Panel({ devices, selected, onSelect }) {',
1121
+ ' const bad = devices.filter(d => d.status !== "normal")',
1122
+ ' return html`<div className="p-2 text-xs">',
1123
+ ' <div className="mb-2 font-semibold">异常设备 ${bad.length} 台</div>',
1124
+ ' ${devices.filter(d=>d.status!=="normal").map(d => html`',
1125
+ ' <div key=${d.id} onClick=${() => onSelect(d.id)}',
1126
+ ' className=${"mb-1 cursor-pointer rounded border p-1.5 " + (d.status==="alarm" ? "border-red-500/60 bg-red-500/10" : "border-amber-500/50 bg-amber-500/5")}>',
1127
+ ' <span className="font-mono">${d.id}</span> <span className="text-muted-foreground">${d.name}</span>',
1128
+ ' <div className="text-[10px] text-muted-foreground">${d.detail}</div>',
1129
+ ' </div>`)}',
1130
+ ' </div>`',
1131
+ '}',
1132
+ '',
1133
+ '【当前工作台】', JSON.stringify(live ? { workbench: live.workbench, panels: live.panels.map(p => ({ id: p.id, type: p.type, title: p.title, panel: p.panel })) } : null),
1134
+ '【插件库】', JSON.stringify(plugins.map(p => ({ id: p.id, name: p.name, version: p.version }))),
1135
+ '',
1136
+ '【修改已有插件】先 read_panel_plugin 看当前代码 → 需要持久数据就 create_collection → write_panel_plugin 同 id 覆盖(版本自增)→ 需要时 update_workbench 调整布局。同 id 写入即升级,工作台引用不受影响。',
1137
+ '【自探索工作流(必须遵守)】理解需求 → 探索 → 规划 → 实现 → 提交草稿:',
1138
+ '1. 探索:新需求涉及助手既有能力时,必须先 exec 读它的 AGENT.md(按名 grep 定位),必要时读其 skills/脚本与验证相关接口(curl 实测返回结构)。能推送通常就能搜索——从脚本/配置里找到真实的服务地址与 API。',
1139
+ '2. 规划:把探索结论映射为面板方案(数据从哪来、经什么能力取、展示成什么),方案要说清"复用了助手的哪个既有能力"。',
1140
+ '3. 实现:面板运行在浏览器,内网接口跨域受限——一律经平台代理取数:fetch("/api/proxy?url=" + encodeURIComponent(接口地址)),返回 {status, body}。',
1141
+ '4. 提交:write_panel_plugin + update_workbench 草稿,明确告知用户审阅发布。',
1142
+ '【工作方式】改已有面板时基于当前 DSL 增量修改;单一职责,先小后大。',
1143
+ '【已装载技能(用前先 load_skill 读全文)】',
1144
+ ...listSkills().map((s) => `- ${s.name}${s.version ? '@' + s.version : ''}: ${s.description}`),
1145
+ '',
1146
+ '【数据读写(props.store 契约)】需要 CRUD 的面板先 create_collection 建数据集,再用 store 读写:',
1147
+ '· props.store.list(name, {filter, sort, limit}) → Promise<records[]> (filter 为字段精确匹配对象)',
1148
+ '· props.store.create(name, doc) / props.store.update(name, id, patch) / props.store.remove(name, id)',
1149
+ '参考:const [rows, setRows] = useState([]); useEffect(() => { props.store.list("maintenance_records", { limit: 20 }).then(setRows) }, []);',
1150
+ '添加记录:onClick 里 await props.store.create("maintenance_records", { device, date, item, worker }); 然后重新 list 刷新。',
1151
+ '注意:数据集字段要贴合业务(设备/日期/项目/执行人)。数据默认私有(仅创建它的助手可查可用,物理上独立库文件隔离);确需跨助手共享才用 scope=shared。',
1152
+ '【区块几何感知】面板渲染在用户规划的自由网格区块中,params.grid={x,y,w,h}(12列):',
1153
+ '- 宽占比 = w/12:w<=3(窄条)用紧凑单列列表/迷你卡,禁表格与横向多列;w 4-8 用两列卡或紧凑表;w>=9 可用表格/仪表盘多列',
1154
+ '- h>=2 的高区块可加滚动列表或上下分区;h=1 保持一眼可见',
1155
+ '- 新增插件默认 params.grid 由工作台分配,不要自行假设宽度',
1156
+ '【完成度准则(web-design 精要)】面板必须"交付即完整”:',
1157
+ '- 异步数据要有 加载/空/错误 三态(空数据不空白,出错显示错误文案而不是静默);',
1158
+ '- 视觉:1px 边框分层,不用阴影/渐变;层级靠字号字重与留白;主色 text-primary 克制使用,状态色 emerald/amber/red 语义化;',
1159
+ '- 交互:可点目标≥28px;输入法回车安全(onKeyDown 判断 e.isComposing 不触发);键盘 Esc/Enter 可用。',
1160
+ '【面板动作契约(按钮触发服务端命令)】需要"输入→执行→出结果"的面板(如推送镜像):',
1161
+ '1. 在面板 params.actions 声明:[{id:"push", label:"推送", exec:"命令模板 ${image}", confirm:"确认推送 ${image}?"}](exec 中 ${key} 会被用户输入替换)',
1162
+ '2. 面板代码:const r = await props.runAction("push", { image }); → r = {ok, output, error}',
1163
+ '3. 服务端执行有黑名单与审计;命令绑定随工作台草稿走人工审阅。涉及凭据的命令把账号写进 exec 模板(人审时可见)。',
1164
+ '【硬性约束】update_workbench 必须携带全部未修改面板的完整字段——尤其 action_group 的 actions 数组与 confirm 文案、status_grid 的 device_type——遗漏会导致配置丢失(平台会尽力从上一版补全,但不要依赖)。',
1165
+ ].join('\n');
1166
+ }
1167
+
1168
+ async function devChatLoop(session, emit) {
1169
+ const events = [];
1170
+ const tools = Object.entries(DEV_TOOLS).map(([n, t]) => ({ name: n, description: t.desc, parameters: { type: 'object', properties: t.schema, required: t.required || [] } }));
1171
+ for (let round = 0; round < 24; round++) {
1172
+ emit('status', { text: `第 ${round + 1}/24 轮` });
1173
+ const stream = models.stream(model, session.context);
1174
+ for await (const ev of stream) {
1175
+ if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
1176
+ else if (ev.type === 'toolcall_end') emit('status', { text: `调用工具 ${ev.toolCall?.name || ''}` });
1177
+ }
1178
+ const final = await stream.result();
1179
+ session.context.messages.push(final);
1180
+ const calls = (final.content || []).filter(b => b.type === 'toolCall');
1181
+ const text = (final.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
1182
+ if (!calls.length) { emit('done', { reply: text, events }); return { reply: text, events }; }
1183
+ for (const tc of calls) {
1184
+ const args = tc.arguments || {};
1185
+ emit('tool_start', { tool: tc.name, args });
1186
+ let result, isErr = false;
1187
+ try { result = await DEV_TOOLS[tc.name].run(args); }
1188
+ catch (e) { result = `失败: ${e.message}`; isErr = true; }
1189
+ events.push({ tool: tc.name, args, result: String(result).slice(0, 400), ok: !isErr });
1190
+ emit('tool_end', { tool: tc.name, args, result: String(result).slice(0, 600), ok: !isErr });
1191
+ session.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: String(result) }], isError: isErr, timestamp: Date.now() });
1192
+ }
1193
+ }
1194
+ emit('done', { reply: '(达到轮次上限——请把结论性需求拆小,或继续对话续跑)', events });
1195
+ return { reply: '(达到轮次上限)', events };
1196
+ }
1197
+
1198
+ // ── 助手运行时(pi-ai Context + 守护栏 + SSE 事件流)────
1199
+ const sessions = new Map(); // sid -> { agentId, autonomy, context, pending }
1200
+
1201
+ function systemPrompt(armAgent, deployment, autonomy, ws) {
1202
+ // 工厂画像按 knowledge_slices 条件注入(02 设计:切片授权=上下文工程+权限治理);
1203
+ // 未声明画像切片的助手(现阶段的绝大多数)只有自己的档案 prompt——不注入硬编码场景(2026-10-15 MVP 打磨)
1204
+ const slices = (deployment.knowledge_slices || []).map((s) => String(s));
1205
+ const hasProfile = slices.some((s) => s.includes('工厂画像'));
1206
+ const scene = sceneProfile();
1207
+ return [
1208
+ armAgent.prompt || '你是运维助手。',
1209
+ '',
1210
+ ...(scene ? ['【平台全局场景画像】(使用方环境事实,对所有助手共享)', scene, ''] : []),
1211
+ '【当前工作区】',
1212
+ ws ? `${ws.name}(${ws.path})——exec 的工作目录(cwd),文件读写以此为根;平台文件用绝对路径(如 tail -50 ${path.join(ROOT, 'data/audit.log')})` : '(默认)',
1213
+ '',
1214
+ '【自主级别策略】',
1215
+ `当前级别 ${LEVEL_NAME[autonomy]}。L0=只读问答;L1=诊断建议+可沉淀经验;L2=可发起需人工确认的执行操作。`,
1216
+ '执行类工具发起后会挂起等待用户在界面确认,确认后才真正执行。',
1217
+ ...(hasProfile && world.devices.length ? ['', '【工厂画像(知识切片授权)】', `设备: ${world.devices.map(d => `${d.id}(${d.name},${d.type})`).join(';')}`, '高危操作必须人工确认'] : []), // 真实画像数据接入前(world 为空)诚实跳过
1218
+ '',
1219
+ '【能力哲学:少量原语 + 组合,而非功能堆叠】',
1220
+ '你拥有通用终端 exec 和平台数据服务(data_describe/data_query/data_write)。不要期待每个功能都有专用工具——先用原语组合:',
1221
+ '- 连通性实测:exec `nc -vz -w 3 <host> <port>`("能否连接"必须实测)',
1222
+ '- 平台数据:先 data_describe 看字典,再 data_query 查询(服务端按助手隔离)',
1223
+ '- 现场若有已接入的数据面板,以工作台展示为准;没有就说没有,不猜测',
1224
+ 'exec 只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝。',
1225
+ '',
1226
+ '【事实与推断分离(硬规则)】',
1227
+ '- 连通性结论只能来自实测(nc/curl);记录存在≠可达,设备状态在线≠可达',
1228
+ '- 不同数据源的关联必须有显式字段,禁止按名称相似猜测对应;无依据时明说"未关联"',
1229
+ '- 没有能力做到的事如实说"无法验证",不允许编造结论',
1230
+ '',
1231
+ '【工作方式】',
1232
+ 'exec/data 取实时证据 → search_knowledge 检索历史经验对照 → 给结论。',
1233
+ '回答精炼:结论先行 + 依据 + 建议动作。中文。处置成功且有价值时主动 save_experience 沉淀经验。',
1234
+ '',
1235
+ '【富回复块(a2ui)】当回复更适合组件而非长文本时,输出 ```a2ui 围栏块(内为严格 JSON,应用原生渲染):',
1236
+ '- choice:需要用户做一个决定(2-5 个选项,可标 recommended:true;块必须是回复结尾;用户点击后以纯文本回传)',
1237
+ '- form:一次收集多个输入(1-6 字段 {id,label,kind,placeholder,required};提交按"label: 答案"逐行回传)',
1238
+ '- steps:手工操作步骤(每步一句祈使句;warning/caution 放在它保护的那一步上)',
1239
+ '- callout:必须被注意的单一警示/提示(tone: note|tip|caution|warning)',
1240
+ '- metrics:少量读数一览(items:[{label,value,unit,warn,danger}],注明 asOf)',
1241
+ '规则:块前要有一句引导语;choice/form 之后不再有文字;数据必须来自工具实测并注明 asOf,不得编造;一个回复最多 1-2 个块。用户说"给选项/怎么选/选哪个"时优先用 choice。',
1242
+ '强化:凡回复包含 2 个及以上并列的可选动作时,一律用 choice 块呈现(不要用编号/短横线列表罗列选项)。需要采集多个参数(设备/日期/原因等)时用 form 块。示例:\n服务器 CPU 持续 90%,如何处置?\n```a2ui\n{"type":"choice","question":"服务器 CPU 90% 的处置方式","options":[{"label":"先持续观察 30 分钟","description":"短期可控"},{"label":"定位高 CPU 进程","description":"top 找出元凶再决定","recommended":true},{"label":"立即重启服务","description":"影响可用性,需值班确认"}]}\n```',
1243
+ ].join('\n');
1244
+ }
1245
+
1246
+ async function ensureSession(sessionId, agentId, autonomyLevel, wsId) {
1247
+ let s = sessions.get(sessionId);
1248
+ const deployment = loadDeployment(agentId);
1249
+ if (!deployment) throw new Error('该助手未上岗(无 deployment)');
1250
+ const armAgent = await getArmAgent(agentId);
1251
+ if (!s || s.agentId !== agentId || (wsId && s.wsId !== wsId)) { // 切工作区 = 新会话上下文(旧会话留在原工作区)
1252
+ s = { agentId, wsId: wsId || 'default', autonomy: { L0: 0, L1: 1, L2: 2 }[deployment.default_autonomy] ?? 1, context: null, pending: null };
1253
+ sessions.set(sessionId, s);
1254
+ }
1255
+ const ws = getWs(agentId, s.wsId) || currentWs(agentId);
1256
+ if (typeof autonomyLevel === 'number') s.autonomy = autonomyLevel;
1257
+ if (!s.context) {
1258
+ s.context = { systemPrompt: systemPrompt(armAgent, deployment, s.autonomy, ws), messages: [], tools: piTools(deployment) };
1259
+ } else if (typeof autonomyLevel === 'number') {
1260
+ s.context.systemPrompt = systemPrompt(armAgent, deployment, s.autonomy, ws);
1261
+ }
1262
+ return { s, deployment, armAgent };
1263
+ }
1264
+
1265
+ // 运行一轮 Agent 循环,事件通过 emit(type, data) 推给 SSE;s.running/s.statusText 供跨页面进度跟踪
1266
+ async function agentLoop(s, deployment, emit, maxRounds = 16) {
1267
+ for (let round = 0; round < maxRounds; round++) {
1268
+ s.statusText = `第 ${round + 1}/${maxRounds} 轮 · 推理中`;
1269
+ const stream = models.stream(model, s.context);
1270
+ emit('status', { text: '推理中…' });
1271
+ for await (const ev of stream) {
1272
+ if (ev.type === 'text_delta') emit('text_delta', { delta: ev.delta });
1273
+ else if (ev.type === 'thinking_start') emit('status', { text: '思考中…' });
1274
+ else if (ev.type === 'toolcall_end') emit('status', { text: `准备调用工具 ${ev.toolCall?.name || ''}` });
1275
+ else if (ev.type === 'error') throw new Error(`LLM 流错误: ${ev.error?.errorMessage || JSON.stringify(ev.error).slice(0, 200)}`);
1276
+ }
1277
+ const final = await stream.result();
1278
+ s.context.messages.push(final);
1279
+ const textNow = (final.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
1280
+ const calls = (final.content || []).filter(b => b.type === 'toolCall');
1281
+ if (!calls.length) return { reply: textNow, pending: null };
1282
+
1283
+ for (const tc of calls) {
1284
+ const args = tc.arguments || {};
1285
+ emit('tool_start', { tool: tc.name, args });
1286
+ s.statusText = `调用工具 ${tc.name}`;
1287
+ const g = guardCheck(tc.name, s.autonomy, s.agentId, tc.arguments || {});
1288
+ if (!g.allowed) {
1289
+ s.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: g.reason }], isError: false, timestamp: Date.now() });
1290
+ emit('tool_end', { tool: tc.name, args, result: g.reason, status: g.status });
1291
+ audit({ actor: 'guardrail', agent: deployment.agent_id, tool: tc.name, autonomy: s.autonomy, verdict: g.status });
1292
+ continue;
1293
+ }
1294
+ if (g.confirm_required && !s.pending) { // 挂起等待人工确认(L2)
1295
+ s.pending = { tool_call_id: tc.id, tool: tc.name, args };
1296
+ s.statusText = `等待人工确认 ${tc.name}`;
1297
+ emit('pending', { pending: s.pending, intro: textNow });
1298
+ return { reply: textNow || `已准备好执行 ${tc.name},请确认。`, pending: s.pending };
1299
+ }
1300
+ if (s.pending && s.pending.tool_call_id === tc.id) s.pending = null;
1301
+ let result, isErr = false;
1302
+ s.statusText = `执行工具 ${tc.name}`;
1303
+ try { result = await getTool(tc.name).run(args, s.agentId, s.wsId || 'default'); }
1304
+ catch (e) { result = `工具执行失败: ${e.message}`; isErr = true; }
1305
+ s.context.messages.push({ role: 'toolResult', toolCallId: tc.id, toolName: tc.name, content: [{ type: 'text', text: String(result) }], isError: isErr, timestamp: Date.now() });
1306
+ emit('tool_end', { tool: tc.name, args, result: String(result), status: 'ok' });
1307
+ audit({ actor: 'assistant', agent: deployment.agent_id, tool: tc.name, autonomy: s.autonomy, ok: !isErr });
1308
+ }
1309
+ }
1310
+ return { reply: '(达到工具调用轮次上限)', pending: null };
1311
+ }
1312
+
1313
+ // ── HTTP ────────────────────────────────────────────────
1314
+ function json(res, code, data) { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify(data)); }
1315
+ async function body(req) { let s = ''; for await (const c of req) s += c; return s ? JSON.parse(s) : {}; }
1316
+ function sseInit(req, res) {
1317
+ res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
1318
+ return (type, data) => res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
1319
+ }
1320
+
1321
+ const server = http.createServer(async (req, res) => {
1322
+ const url = new URL(req.url, `http://localhost:${PORT}`);
1323
+ const pathname = url.pathname;
1324
+ try {
1325
+ // ── 静态前端(web/dist 生产构建)──
1326
+ if (req.method === 'GET' && !pathname.startsWith('/api')) {
1327
+ let p = path.join(ROOT, 'web/dist', pathname === '/' ? 'index.html' : pathname);
1328
+ if (!p.startsWith(path.join(ROOT, 'web/dist')) || !fs.existsSync(p)) p = path.join(ROOT, 'web/dist/index.html');
1329
+ if (fs.existsSync(p)) {
1330
+ const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon' }[path.extname(p)] || 'application/octet-stream';
1331
+ res.writeHead(200, { 'Content-Type': mime });
1332
+ return res.end(fs.readFileSync(p));
1333
+ }
1334
+ return json(res, 404, { error: '前端未构建:cd web && pnpm build' });
1335
+ }
1336
+ if (req.method === 'GET' && pathname === '/api/health') return json(res, 200, { ok: true, model: model?.id, arm: armMcpUrl(), pat: !!ARM_PAT, local_agents: loadRegistry().agents.length });
1337
+
1338
+ // ── ARM 连接与同步(07:大厅双视图 = 本地助手库 ∪ 远端目录)──
1339
+ if (req.method === 'GET' && pathname === '/api/settings') {
1340
+ const t = armApiToken();
1341
+ const k = llmApiKey();
1342
+ return json(res, 200, {
1343
+ arm: {
1344
+ api_base: armApiBase(), api_base_runtime: !!cfg.arm_api_base,
1345
+ mcp_url: armMcpUrl(), mcp_explicit: !!cfg.arm_mcp_url,
1346
+ token_set: !!t, token_masked: t ? `${t.slice(0, 10)}…${t.slice(-4)}` : '',
1347
+ token_source: cfg.arm_token ? 'runtime' : (env.ARM_API_TOKEN ? 'env' : (ARM_PAT ? 'pat' : 'none')),
1348
+ },
1349
+ llm: {
1350
+ base_url: llmBaseUrl(), base_url_runtime: !!cfg.llm_base_url, default_base: LLM_DEFAULT_BASE,
1351
+ model: llmModelName(), model_runtime: !!cfg.llm_model, catalog: llmCatalog,
1352
+ key_set: !!k, key_masked: k ? `${k.slice(0, 6)}…${k.slice(-4)}` : '',
1353
+ key_source: cfg.llm_api_key ? 'runtime' : (env.LLM_API_KEY ? 'env' : 'none'),
1354
+ },
1355
+ scene: { profile: sceneProfile(), length: sceneProfile().length },
1356
+ });
1357
+ }
1358
+ if (req.method === 'PUT' && pathname === '/api/settings') {
1359
+ const { arm_api_base, arm_mcp_url, arm_token, llm_base_url, llm_api_key, llm_model, scene_profile } = await body(req);
1360
+ const normUrl = (u) => { const s = String(u || '').trim().replace(/\/+$/, ''); return s; };
1361
+ let sceneChanged = false;
1362
+ if (scene_profile !== undefined) {
1363
+ const sp = String(scene_profile || '').trim().slice(0, SCENE_PROFILE_MAX); // 置空 = 清除(不注入)
1364
+ sceneChanged = sp !== sceneProfile();
1365
+ cfg.scene_profile = sp;
1366
+ }
1367
+ if (arm_api_base !== undefined) {
1368
+ const u = normUrl(arm_api_base);
1369
+ if (u && !/^https?:\/\/[^\s/]+/.test(u)) return json(res, 400, { error: `站点地址须为 http(s) URL:${u}` });
1370
+ cfg.arm_api_base = u;
1371
+ }
1372
+ if (arm_mcp_url !== undefined) {
1373
+ const u = normUrl(arm_mcp_url);
1374
+ if (u && !/^https?:\/\/[^\s/]+/.test(u)) return json(res, 400, { error: `MCP 端点须为 http(s) URL:${u}` });
1375
+ cfg.arm_mcp_url = u;
1376
+ }
1377
+ if (arm_token !== undefined) cfg.arm_token = String(arm_token || '').trim(); // 置空 = 回落 env/PAT
1378
+ if (llm_base_url !== undefined) {
1379
+ const u = normUrl(llm_base_url);
1380
+ if (u && !/^https?:\/\/[^\s/]+/.test(u)) return json(res, 400, { error: `LLM 端点须为 http(s) URL:${u}` });
1381
+ cfg.llm_base_url = u; // 置空 = 回落默认(智谱 coding plan)
1382
+ }
1383
+ if (llm_model !== undefined) {
1384
+ const m = String(llm_model || '').trim();
1385
+ if (m && !/^[\w.:@\/-]{1,80}$/.test(m)) return json(res, 400, { error: `模型名不合法:${m}` });
1386
+ cfg.llm_model = m;
1387
+ }
1388
+ if (llm_api_key !== undefined) cfg.llm_api_key = String(llm_api_key || '').trim();
1389
+ // LLM 热切换:先应用验证,失败整体回滚(应用只是构建对象,无网络请求)
1390
+ let llm_probe = null;
1391
+ if ([llm_base_url, llm_model, llm_api_key].some((v) => v !== undefined)) {
1392
+ const backup = { ...cfg };
1393
+ try { await applyLLMConfig(); }
1394
+ catch (e) {
1395
+ Object.assign(cfg, backup);
1396
+ try { await applyLLMConfig(); } catch { }
1397
+ return json(res, 400, { error: `LLM 配置应用失败,已回滚保持原配置:${e.message}` });
1398
+ }
1399
+ try { llm_probe = await llmProbe(); } catch (e) { llm_probe = { online: false, error: String(e.message).slice(0, 150) }; }
1400
+ }
1401
+ saveCfg();
1402
+ 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 });
1403
+ // ARM 保存后即时探测(失败不回滚:允许先配置后启动 ARM)
1404
+ let probe = { online: false, error: '' };
1405
+ if ([arm_api_base, arm_mcp_url, arm_token].some((v) => v !== undefined)) {
1406
+ try {
1407
+ const t0 = Date.now();
1408
+ await armRest('/api/v1/agents?page=1&pageSize=1', {}, 6000);
1409
+ probe = { online: true, latency_ms: Date.now() - t0 };
1410
+ } catch (e) { probe = { online: false, error: String(e.message).slice(0, 120) }; }
1411
+ }
1412
+ const notes = [];
1413
+ if (probe.error) notes.push('ARM 当前源不可达(目录/检索将回落 MCP 或本地库,源恢复后自动可用)');
1414
+ if (llm_probe && !llm_probe.online) notes.push('LLM 已保存但探针未通过(密钥/端点/模型可用性待确认,对话可能失败,可再试或改回)');
1415
+ return json(res, 200, { saved: true, probe, llm_probe, note: notes.join(';') });
1416
+ }
1417
+ if (req.method === 'GET' && pathname === '/api/arm/status') {
1418
+ let online = false, latency_ms = null;
1419
+ const t0 = Date.now();
1420
+ try {
1421
+ await armRest('/api/v1/agents?page=1&pageSize=1', {}, 6000);
1422
+ online = true; latency_ms = Date.now() - t0;
1423
+ } catch {
1424
+ try { // 旧版 ARM 无 REST:回落 MCP 探测
1425
+ await mcpCall('search_agents', { keyword: '', limit: 1 }, { timeout_ms: 6000 });
1426
+ online = true; latency_ms = Date.now() - t0;
1427
+ } catch { }
1428
+ }
1429
+ const reg = loadRegistry();
1430
+ return json(res, 200, { online, latency_ms, url: armMcpUrl(), api: armApiBase(), pat: !!ARM_PAT, last_sync_at: reg.last_sync_at || null, local_count: reg.agents.length, outbox_pending: outboxCount() });
1431
+ }
1432
+ if (req.method === 'POST' && pathname === '/api/arm/sync') {
1433
+ try { return json(res, 200, await armSync()); }
1434
+ catch (e) { return json(res, 502, { error: `ARM 同步失败(离线模式可继续使用本地助手): ${e.message}` }); }
1435
+ }
1436
+
1437
+ // ── 助手目录(本地库 ∪ 远端目录;离线时仅本地)──
1438
+ if (req.method === 'GET' && pathname === '/api/agents') {
1439
+ const keyword = url.searchParams.get('keyword') || '';
1440
+ const reg = loadRegistry();
1441
+ let online = true, remoteCards = [];
1442
+ try { remoteCards = await armListAgents(keyword); } // REST 全量优先,旧版回落 MCP
1443
+ catch { online = false; }
1444
+ const byId = new Map();
1445
+ for (const e of reg.agents) {
1446
+ const lm = localManifest(e.agent_id) || {};
1447
+ byId.set(e.agent_id, {
1448
+ agent_id: e.agent_id, name: e.name, version: e.installed_version,
1449
+ skills: (lm.skills || []).length, knowledges: (lm.knowledges || []).length,
1450
+ description: e.description || lm.description || '',
1451
+ deployed: !!loadDeployment(e.agent_id), installed: true,
1452
+ installed_version: e.installed_version, remote_version: e.remote_version || null,
1453
+ update_available: !!(e.remote_version && cmpVersion(e.remote_version, e.installed_version) > 0),
1454
+ delisted: !!e.delisted, downloaded_at: e.downloaded_at || null,
1455
+ });
1456
+ }
1457
+ if (online) { // 静默刷新远端版本与下架标记(显式事件统计走 /api/arm/sync)
1458
+ const ids = new Set(remoteCards.map(c => c.agent_id));
1459
+ for (const c of remoteCards) {
1460
+ const cur = byId.get(c.agent_id) || { agent_id: c.agent_id, installed: false, installed_version: null, downloaded_at: null };
1461
+ byId.set(c.agent_id, {
1462
+ ...cur, name: c.name, version: c.version, skills: c.skills, knowledges: c.knowledges,
1463
+ description: c.description || cur.description, remote_version: c.version,
1464
+ deployed: !!loadDeployment(c.agent_id),
1465
+ update_available: cur.installed ? cmpVersion(c.version, cur.installed_version) > 0 : false,
1466
+ });
1467
+ }
1468
+ for (const e of reg.agents) {
1469
+ if (ids.has(e.agent_id)) { e.remote_version = byId.get(e.agent_id)?.remote_version || e.remote_version; e.delisted = false; }
1470
+ else e.delisted = true;
1471
+ }
1472
+ reg.last_sync_at = new Date().toISOString();
1473
+ saveRegistry(reg);
1474
+ }
1475
+ return json(res, 200, { online, last_sync_at: reg.last_sync_at || null, agents: [...byId.values()] });
1476
+ }
1477
+ // ── 本地助手库:下载 / 升级 / 版本历史 / 回滚 / 卸载 ──
1478
+ let mAgt = pathname.match(/^\/api\/agents\/([0-9a-f-]{36})\/(download|upgrade|versions|rollback)$/);
1479
+ if (mAgt && req.method === 'POST' && (mAgt[2] === 'download' || mAgt[2] === 'upgrade')) {
1480
+ try {
1481
+ const m = await downloadAgent(mAgt[1]); // 升级 = 下载新版 + 原子切指针(档案/配置分离:deployment/工作台/数据不动)
1482
+ return json(res, 200, { manifest: { agent_id: m.agent_id, name: m.name, version: m.version, knowledges_local: m.knowledges.filter(k => k.file).length, skills: m.skills.length } });
1483
+ } catch (e) { return json(res, 502, { error: `下载失败(需 ARM 在线): ${e.message}` }); }
1484
+ }
1485
+ if (mAgt && req.method === 'GET' && mAgt[2] === 'versions') {
1486
+ const reg = loadRegistry();
1487
+ const e = reg.agents.find(a => a.agent_id === mAgt[1]);
1488
+ if (!e) return json(res, 404, { error: '本地未安装该助手' });
1489
+ return json(res, 200, {
1490
+ installed_version: e.installed_version, remote_version: e.remote_version || null,
1491
+ versions: e.versions.slice().reverse().map(v => {
1492
+ const m = localManifest(mAgt[1], v);
1493
+ return { version: v, current: v === e.installed_version, downloaded_at: m?.downloaded_at || null, knowledges: (m?.knowledges || []).length };
1494
+ }),
1495
+ });
1496
+ }
1497
+ if (mAgt && req.method === 'POST' && mAgt[2] === 'rollback') {
1498
+ const { version } = await body(req);
1499
+ const reg = loadRegistry();
1500
+ const e = reg.agents.find(a => a.agent_id === mAgt[1]);
1501
+ if (!e) return json(res, 404, { error: '本地未安装该助手' });
1502
+ if (!e.versions.includes(version) || !localManifest(mAgt[1], version)) return json(res, 404, { error: `本地版本 ${version} 不存在。可用:${e.versions.join(', ')}` });
1503
+ e.installed_version = version;
1504
+ saveRegistry(reg);
1505
+ audit({ actor: 'arm-library', agentId: mAgt[1], action: 'rollback', to: version });
1506
+ return json(res, 200, { installed_version: version });
1507
+ }
1508
+ let mAgtDel = pathname.match(/^\/api\/agents\/([0-9a-f-]{36})$/);
1509
+ if (mAgtDel && req.method === 'DELETE') {
1510
+ const agentId = mAgtDel[1];
1511
+ const { force, purge_workspaces } = await body(req);
1512
+ if (loadDeployment(agentId) && !force) {
1513
+ return json(res, 409, { error: '该助手在岗:卸载将同时删除上岗配置与工作台定义', hint: '确认卸载请带 force=true(可另带 purge_workspaces=true 清除工作区数据)' });
1514
+ }
1515
+ 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`)]) {
1516
+ try { fs.rmSync(p, { force: true }); } catch { }
1517
+ }
1518
+ try { fs.rmSync(path.join(ARM_AGENTS_DIR, agentId), { recursive: true, force: true }); } catch { }
1519
+ const reg = loadRegistry(); reg.agents = reg.agents.filter(a => a.agent_id !== agentId); saveRegistry(reg);
1520
+ if (purge_workspaces) {
1521
+ try { fs.rmSync(wsDir(agentId), { recursive: true, force: true }); } catch { }
1522
+ for (const f of fs.readdirSync(DATA_DIR)) if (f.startsWith(`ws-${agentId}-`)) { try { fs.rmSync(path.join(DATA_DIR, f), { force: true }); } catch { } }
1523
+ }
1524
+ audit({ actor: 'arm-library', agentId, action: 'uninstall', purged_workspaces: !!purge_workspaces });
1525
+ return json(res, 200, { ok: true });
1526
+ }
1527
+ // ── 本地目录浏览(新建工作区的目录选择器;只读、仅目录名,与 exec 只读白名单同一信任域)──
1528
+ if (req.method === 'GET' && pathname === '/api/fs/dirs') {
1529
+ const os = require('node:os');
1530
+ const raw = url.searchParams.get('path');
1531
+ let p = path.resolve(raw || os.homedir());
1532
+ try {
1533
+ if (!fs.statSync(p).isDirectory()) throw new Error('不是目录');
1534
+ } catch (e) { return json(res, 400, { error: `无法读取 ${p}: ${e.message}` }); }
1535
+ let names = [];
1536
+ try {
1537
+ names = fs.readdirSync(p, { withFileTypes: true })
1538
+ .filter((d) => d.isDirectory() && !d.isSymbolicLink())
1539
+ .map((d) => d.name)
1540
+ .sort((a, b) => a.localeCompare(b, 'zh'))
1541
+ .slice(0, 500);
1542
+ } catch (e) { return json(res, 400, { error: `无权列出 ${p}: ${e.message}` }); }
1543
+ const parent = path.dirname(p) !== p ? path.dirname(p) : null;
1544
+ return json(res, 200, { path: p, parent, dirs: names });
1545
+ }
1546
+ // ── 工作区(07:助手 1:N 运行现场)──
1547
+ if (req.method === 'GET' && pathname === '/api/workspaces') {
1548
+ const agentId = url.searchParams.get('agent_id');
1549
+ if (!agentId) return json(res, 400, { error: 'agent_id 必填' });
1550
+ const reg = loadWsRegistry(agentId);
1551
+ return json(res, 200, { current: reg.current, workspaces: reg.workspaces });
1552
+ }
1553
+ if (req.method === 'POST' && pathname === '/api/workspaces') {
1554
+ const { agent_id, name, path: customPath } = await body(req);
1555
+ if (!agent_id) return json(res, 400, { error: 'agent_id 必填' });
1556
+ try {
1557
+ const { ws, reg } = createWorkspace(agent_id, { name, path: customPath });
1558
+ return json(res, 200, { workspace: ws, current: reg.current, workspaces: reg.workspaces });
1559
+ } catch (e) { return json(res, 400, { error: e.message }); }
1560
+ }
1561
+ if (req.method === 'POST' && pathname === '/api/workspaces/select') {
1562
+ const { agent_id, ws_id } = await body(req);
1563
+ if (!agent_id || !ws_id) return json(res, 400, { error: 'agent_id 与 ws_id 必填' });
1564
+ try {
1565
+ const reg = selectWorkspace(agent_id, ws_id);
1566
+ return json(res, 200, { current: reg.current, workspaces: reg.workspaces });
1567
+ } catch (e) { return json(res, 400, { error: e.message }); }
1568
+ }
1569
+ let mWsDel = pathname.match(/^\/api\/workspaces\/(default|ws_[a-z0-9]+)$/);
1570
+ if (mWsDel && req.method === 'DELETE') {
1571
+ const agentId = url.searchParams.get('agent_id');
1572
+ const { purge } = await body(req);
1573
+ if (!agentId) return json(res, 400, { error: 'agent_id 必填' });
1574
+ try {
1575
+ const reg = deleteWorkspace(agentId, mWsDel[1], purge);
1576
+ return json(res, 200, { current: reg.current, workspaces: reg.workspaces });
1577
+ } catch (e) { return json(res, 400, { error: e.message }); }
1578
+ }
1579
+ if (req.method === 'GET' && pathname === '/api/deployment' && url.searchParams.get('agent_id')) {
1580
+ const dep = loadDeployment(url.searchParams.get('agent_id'));
1581
+ return json(res, 200, { deployment: dep });
1582
+ }
1583
+ if (req.method === 'PUT' && pathname === '/api/deployment') {
1584
+ const { agent_id, name, default_autonomy, toolset } = await body(req);
1585
+ if (!agent_id) return json(res, 400, { error: 'agent_id 必填' });
1586
+ if (!localAgent(agent_id)) { // 上岗前物化本地档案(07:之后实例化/离线不依赖 ARM)
1587
+ try { await downloadAgent(agent_id); }
1588
+ catch (e) { return json(res, 502, { error: `上岗失败:ARM 不可达且本地无该助手档案(${String(e.message).slice(0, 100)})。可先在网络恢复后重试。` }); }
1589
+ }
1590
+ const dep = loadDeployment(agent_id) || { agent_id, toolset: [], knowledge_slices: ['ARM经验库(检索式注入)'], guardrail_hooks: [{ on: 'pre_tool_use', policy: '写操作需 L2 + 人工确认' }] };
1591
+ Object.assign(dep, { name: name || dep.name, default_autonomy: default_autonomy || dep.default_autonomy || 'L2', toolset: toolset || dep.toolset });
1592
+ saveDeployment(agent_id, dep);
1593
+ if (!loadWorkbench(agent_id)) saveWorkbench(agent_id, defaultWorkbench(dep)); // 新上岗自动生成初版工作台
1594
+ return json(res, 200, { deployment: dep });
1595
+ }
1596
+ if (req.method === 'GET' && pathname === '/api/tools') {
1597
+ return json(res, 200, { tools: Object.entries(TOOLS).map(([n, t]) => ({ name: n, description: t.desc, min_level: t.min_level, confirm_required: t.confirm_required })) });
1598
+ }
1599
+
1600
+ // ── 工作台 DSL ──
1601
+ if (req.method === 'GET' && pathname === '/api/workbench') {
1602
+ const id = url.searchParams.get('agent_id');
1603
+ if (url.searchParams.get('draft') === '1') {
1604
+ const draft = loadDraft(id);
1605
+ return json(res, 200, { dsl: draft || loadWorkbench(id), isDraft: !!draft, versions: workbenchVersions(id) });
1606
+ }
1607
+ return json(res, 200, { dsl: loadWorkbench(id), versions: workbenchVersions(id) });
1608
+ }
1609
+ if (req.method === 'PUT' && pathname === '/api/workbench') {
1610
+ const { agent_id, dsl } = await body(req);
1611
+ if (!agent_id || !dsl?.panels) return json(res, 400, { error: 'agent_id 与 dsl.panels 必填' });
1612
+ dsl.version = nextVersion(dsl.version, agent_id);
1613
+ saveWorkbench(agent_id, dsl);
1614
+ try { fs.rmSync(draftPath(agent_id), { force: true }); } catch { }
1615
+ return json(res, 200, { dsl, versions: workbenchVersions(agent_id) });
1616
+ }
1617
+ if (req.method === 'POST' && pathname === '/api/workbench/rollback') {
1618
+ const { agent_id, version } = await body(req);
1619
+ const vp = path.join(WB_VER_DIR, `${agent_id}.json`);
1620
+ const vers = fs.existsSync(vp) ? JSON.parse(fs.readFileSync(vp, 'utf8')) : [];
1621
+ const hit = vers.find(v => v.version === String(version));
1622
+ if (!hit) return json(res, 404, { error: `版本 ${version} 不存在` });
1623
+ hit.dsl.version = nextVersion(hit.dsl.version, agent_id); // 回滚也前进一个版本号,保持单调
1624
+ saveWorkbench(agent_id, hit.dsl);
1625
+ audit({ actor: 'workbench', agentId: agent_id, action: 'rollback', to: version });
1626
+ return json(res, 200, { dsl: loadWorkbench(agent_id), versions: workbenchVersions(agent_id) });
1627
+ }
1628
+ if (req.method === 'POST' && pathname === '/api/workbench/ai-draft') {
1629
+ const { agent_id } = await body(req);
1630
+ const dep = loadDeployment(agent_id);
1631
+ if (!dep) return json(res, 400, { error: '助手未上岗' });
1632
+ const arm = await getArmAgent(agent_id);
1633
+ const toolsAvail = dep.toolset.map(t => t.tool).join(', ');
1634
+ const sys = '你是工业智能体平台的工作台设计师。根据助手档案生成工作台 DSL(JSON)。面板类型只允许: realtime_list(实时告警流)/status_grid(设备状态矩阵)/agent_dialog(诊断对话)/action_group(操作区)/kpi_strip(指标条)/inspection_report(自检报告)。结构: {"workbench":"名称","owner_agent":"agentId","version":"v0.1-draft","layout":[...],"panels":[{"id","type","title","refresh"}...]}。action_group 的 panels 含 "actions":[{"id","label","orchestration","autonomy","confirm":{"text","double_check"}}],orchestration 只能引用这些: logic://设备复位流程@mock / logic://告警派工@mock / logic://服务滚动重启@mock / logic://环境自检@mock。只输出 JSON,不要 markdown 代码围栏。';
1635
+ const user = `助手: ${arm.name} ${arm.version}\n职责: ${arm.prompt}\n可用工具: ${toolsAvail}\n默认自主级别: ${dep.default_autonomy}\n请生成 4-6 个面板,含一个 agent_dialog 和一个 action_group。`;
1636
+ const out = await llmComplete(sys, user);
1637
+ const cleaned = out.replace(/```json|```/g, '').trim();
1638
+ let draft;
1639
+ try { draft = JSON.parse(cleaned); } catch (e) { return json(res, 502, { error: 'AI 草稿解析失败', raw: cleaned.slice(0, 500) }); }
1640
+ draft.owner_agent = agent_id; draft.version = 'v0.1-draft';
1641
+ metric({ kind: 'ai_draft', agentId: agent_id });
1642
+ audit({ actor: 'workbench', agentId: agent_id, action: 'ai-draft' });
1643
+ return json(res, 200, { dsl: draft });
1644
+ }
1645
+ function nextVersion(v, agentId) {
1646
+ const nums = [v, ...workbenchVersions(agentId).map(x => x.version)]
1647
+ .map(x => +String(x || '').replace(/^v/, '') || 0);
1648
+ return `v${Math.max(...nums, 0) + 1}`;
1649
+ }
1650
+ function defaultWorkbench(dep) {
1651
+ // KISS:默认只有诊断对话(唯一内核);其余面板从插件库范本二开或让工程师定制
1652
+ return { workbench: `${dep.name || '助手'}工作台`, owner_agent: dep.agent_id, version: 'v1', layout: ['agent_dialog'], panels: [
1653
+ { id: 'agent_dialog', type: 'agent_dialog', title: '诊断对话' },
1654
+ ] };
1655
+ }
1656
+
1657
+ // ── 面板数据代理(面板运行于浏览器,内网接口跨域受限;平台代取只读 GET)──
1658
+ if (req.method === 'GET' && pathname === '/api/proxy') {
1659
+ const u = url.searchParams.get('url') || '';
1660
+ if (!/^https?:\/\//.test(u)) return json(res, 400, { error: '仅支持 http/https' });
1661
+ const host = (() => { try { return new URL(u).hostname.toLowerCase(); } catch { return ''; } })();
1662
+ if (!proxyAllowedHosts().has(host)) {
1663
+ return json(res, 403, { error: `主机 ${host} 未在任何插件中声明(代理仅允许插件代码中出现过的主机,防面板成为任意内网跳板)` });
1664
+ }
1665
+ try {
1666
+ const ctrl = new AbortController();
1667
+ const timer = setTimeout(() => ctrl.abort(), 8000);
1668
+ const r = await fetch(u, { signal: ctrl.signal });
1669
+ clearTimeout(timer);
1670
+ const body = (await r.text()).slice(0, 128 * 1024);
1671
+ audit({ actor: 'panel-proxy', url: u.slice(0, 120), status: r.status });
1672
+ return json(res, 200, { status: r.status, body });
1673
+ } catch (e) { return json(res, 502, { error: `代理请求失败: ${e.message}` }); }
1674
+ }
1675
+
1676
+ // ── 面板运行错误(debug 的眼睛:错误边界/编译失败上报,供 AI 调试取证)──
1677
+ if (req.method === 'POST' && pathname === '/api/panel-error') {
1678
+ const { agentId, panelId, pluginId, kind, message } = await body(req);
1679
+ const f = path.join(DATA_DIR, 'panel-errors.json');
1680
+ let list = []; try { list = JSON.parse(fs.readFileSync(f, 'utf8')); } catch { }
1681
+ list.push({ ts: new Date().toISOString(), agentId, panelId, pluginId: pluginId || null, kind: kind || 'runtime', message: String(message || '').slice(0, 400) });
1682
+ fs.writeFileSync(f, JSON.stringify(list.slice(-50), null, 1));
1683
+ audit({ actor: 'panel-error', panelId, pluginId, kind, message: String(message).slice(0, 120) });
1684
+ return json(res, 200, { ok: true });
1685
+ }
1686
+ if (req.method === 'GET' && pathname === '/api/panel-errors') {
1687
+ const key = url.searchParams.get('key') || '';
1688
+ let list = []; try { list = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'panel-errors.json'), 'utf8')); } catch { }
1689
+ const hit = list.filter((e) => !key || e.panelId === key || e.pluginId === key).slice(-5);
1690
+ return json(res, 200, { errors: hit });
1691
+ }
1692
+
1693
+ // ── D) 插件版本回退 ──
1694
+ if (req.method === 'POST' && pathname === '/api/panels/rollback') {
1695
+ const { id, version } = await body(req);
1696
+ const full = loadPanelPlugin(id);
1697
+ if (!full) return json(res, 404, { error: `插件 ${id} 不存在` });
1698
+ const hit = (full.history || []).find((h) => h.version === String(version));
1699
+ if (!hit) return json(res, 404, { error: `版本 ${version} 不存在。历史:${(full.history || []).map((h) => h.version).join(', ') || '无'}` });
1700
+ const cur = +String(full.version || 'v0').replace('v', '') || 0;
1701
+ full.history = [...(full.history || []), { version: full.version, code: full.code, at: full.updated }].slice(-5);
1702
+ full.code = hit.code; full.version = `v${cur + 1}`; full.updated = new Date().toISOString();
1703
+ fs.writeFileSync(path.join(PANELS_DIR, `${id}.json`), JSON.stringify(full, null, 2));
1704
+ audit({ actor: 'panel-plugin', id, action: 'rollback', to: String(version), newVersion: full.version });
1705
+ return json(res, 200, { plugin: full });
1706
+ }
1707
+
1708
+ // ── 面板动作:插件经 props.runAction 触发服务端命令(命令声明在 params.actions,随草稿人审)──
1709
+ if (req.method === 'POST' && pathname === '/api/panel-action') {
1710
+ const { agentId, panelId, actionId, args = {} } = await body(req);
1711
+ const dsl = loadDraft(agentId) || loadWorkbench(agentId);
1712
+ const panel = (dsl?.panels || []).find((p) => p.id === panelId);
1713
+ const action = panel?.params?.actions?.find?.((a) => a.id === actionId);
1714
+ if (!action || !action.exec) return json(res, 404, { error: `面板动作未声明: ${actionId}(须在工作台 params.actions 中配置 exec)` });
1715
+ let cmd = String(action.exec);
1716
+ for (const [k, v] of Object.entries(args)) cmd = cmd.split(`\${${k}}`).join(String(v));
1717
+ const cls = classifyExec(cmd, 3);
1718
+ if (cls.status === 'blocked') return json(res, 403, { error: `命令被黑名单拒绝:${cls.reason}` }); // 只拦毁灭性黑名单;写类命令(pending)由"人审声明+用户主动点击"授权
1719
+ const env = { ...process.env };
1720
+ delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT;
1721
+ audit({ actor: 'panel-action', agentId, panelId, actionId, command: cmd.slice(0, 200) });
1722
+ return await new Promise((resolve) => {
1723
+ cpExec(cmd, { cwd: ROOT, timeout: +(action.timeout_ms || 120000), maxBuffer: 1024 * 1024, env, shell: process.env.SHELL || '/bin/zsh' }, (err, stdout, stderr) => {
1724
+ const out = String(stdout || '');
1725
+ audit({ actor: 'panel-action', agentId, panelId, actionId, command: cmd.slice(0, 200), ok: !err, output_tail: out.slice(-300), stderr_tail: String(stderr || '').slice(-200), err: err ? err.message.slice(0, 150) : undefined }); // 结果入审计:可查证
1726
+ resolve(json(res, 200, { ok: !err, output: out.slice(0, 8000), error: err ? String(err.message).slice(0, 500) : String(stderr || '').slice(0, 1000) }));
1727
+ });
1728
+ });
1729
+ }
1730
+
1731
+ // ── 数据集存储(Collection CRUD + 记录 CRUD)──
1732
+ if (req.method === 'GET' && pathname === '/api/store') {
1733
+ return json(res, 200, { collections: loadCollections() });
1734
+ }
1735
+ if (req.method === 'PUT' && pathname === '/api/store') {
1736
+ try { const m = saveCollection(await body(req)); return json(res, 200, { collection: m }); }
1737
+ catch (e) { return json(res, 400, { error: e.message }); }
1738
+ }
1739
+ let mStore = pathname.match(/^\/api\/store\/([a-z0-9_-]+)\/records$/);
1740
+ if (mStore) {
1741
+ const manifest = loadCollection(mStore[1]);
1742
+ if (!manifest) return json(res, 404, { error: `数据集 ${mStore[1]} 不存在` });
1743
+ const wb = url.searchParams.get('wb') || undefined;
1744
+ const ws = url.searchParams.get('ws') || 'default';
1745
+ if (scopeOf(manifest) === 'agent' && wb && wb !== manifest.owner_agent) {
1746
+ return json(res, 403, { error: `数据集「${manifest.title}」为助手私有(归属 ${manifest.owner_agent?.slice(0, 8)})` });
1747
+ }
1748
+ if (req.method === 'GET') {
1749
+ let filter = {}; try { filter = JSON.parse(url.searchParams.get('filter') || '{}'); } catch { }
1750
+ return json(res, 200, { records: collRecords(manifest, { filter, sort: url.searchParams.get('sort') || undefined, limit: +url.searchParams.get('limit') || 100 }, ws) });
1751
+ }
1752
+ if (req.method === 'POST') {
1753
+ try { return json(res, 200, { record: insertDoc(manifest, await body(req), ws) }); }
1754
+ catch (e) { return json(res, 400, { error: e.message }); }
1755
+ }
1756
+ }
1757
+ let mRec = pathname.match(/^\/api\/store\/([a-z0-9_-]+)\/records\/([^/]+)$/);
1758
+ if (mRec) {
1759
+ const manifest = loadCollection(mRec[1]);
1760
+ if (!manifest) return json(res, 404, { error: `数据集 ${mRec[1]} 不存在` });
1761
+ const wb = url.searchParams.get('wb') || undefined;
1762
+ const ws = url.searchParams.get('ws') || 'default';
1763
+ if (scopeOf(manifest) === 'agent' && wb && wb !== manifest.owner_agent) {
1764
+ return json(res, 403, { error: `数据集「${manifest.title}」为助手私有` });
1765
+ }
1766
+ if (req.method === 'PUT') {
1767
+ try { return json(res, 200, { record: updateDoc(manifest, mRec[2], await body(req), ws) }); }
1768
+ catch (e) { return json(res, 400, { error: e.message }); }
1769
+ }
1770
+ if (req.method === 'DELETE') {
1771
+ try { deleteDoc(manifest, mRec[2], ws); return json(res, 200, { ok: true }); }
1772
+ catch (e) { return json(res, 400, { error: e.message }); }
1773
+ }
1774
+ }
1775
+
1776
+ // ── 面板插件库 ──
1777
+ if (req.method === 'GET' && pathname === '/api/panels') {
1778
+ const detail = url.searchParams.get('id');
1779
+ if (detail) {
1780
+ const plugin = loadPanelPlugin(detail);
1781
+ return plugin ? json(res, 200, { plugin }) : json(res, 404, { error: '插件不存在' });
1782
+ }
1783
+ if (url.searchParams.get('with_code') === '1') {
1784
+ const list = fs.readdirSync(PANELS_DIR).filter(f => f.endsWith('.json'))
1785
+ .map(f => JSON.parse(fs.readFileSync(path.join(PANELS_DIR, f), 'utf8')))
1786
+ .map(({ id, name, description, version, author, code }) => ({ id, name, description, version, author, code }));
1787
+ return json(res, 200, { plugins: list });
1788
+ }
1789
+ return json(res, 200, { plugins: listPanelPlugins() });
1790
+ }
1791
+ if (req.method === 'PUT' && pathname === '/api/panels') {
1792
+ const { id, name, description, code } = await body(req);
1793
+ try { return json(res, 200, { plugin: savePanelPlugin({ id, name, description, code, author: 'human' }) }); }
1794
+ catch (e) { return json(res, 400, { error: e.message }); }
1795
+ }
1796
+ if (req.method === 'DELETE' && pathname === '/api/panels') {
1797
+ const { id, force } = await body(req);
1798
+ if (!loadPanelPlugin(id)) return json(res, 404, { error: `插件 ${id} 不存在` });
1799
+ // 引用检查:哪些工作台(线上/草稿)在用它
1800
+ const refs = [];
1801
+ for (const f of fs.readdirSync(WB_DIR).filter(f => f.endsWith('.json'))) {
1802
+ const agentId = f.replace(/\.(draft\.)?json$/, '');
1803
+ const dsl = JSON.parse(fs.readFileSync(path.join(WB_DIR, f), 'utf8'));
1804
+ if ((dsl.panels || []).some(p => p.panel === id)) refs.push({ agentId, draft: f.includes('.draft.') });
1805
+ }
1806
+ if (refs.length && !force) {
1807
+ return json(res, 409, { error: `插件被 ${refs.length} 个工作台引用`, refs, hint: '先在编辑器移除后发布,或 force=true 自动摘除' });
1808
+ }
1809
+ for (const r of refs) { // force:从引用方摘除该面板(线上版走版本化保存,草稿直接改)
1810
+ const target = r.draft ? draftPath(r.agentId) : path.join(WB_DIR, `${r.agentId}.json`);
1811
+ const dsl = JSON.parse(fs.readFileSync(target, 'utf8'));
1812
+ dsl.panels = dsl.panels.filter(p => p.panel !== id);
1813
+ fs.writeFileSync(target, JSON.stringify(dsl, null, 2));
1814
+ audit({ actor: 'panel-plugin', id, action: 'detach', from: r.agentId, draft: r.draft });
1815
+ }
1816
+ fs.rmSync(path.join(PANELS_DIR, `${id}.json`));
1817
+ audit({ actor: 'panel-plugin', id, action: 'delete', detached: refs.length });
1818
+ return json(res, 200, { ok: true, detached: refs.length });
1819
+ }
1820
+
1821
+ // ── 插件中心 ──
1822
+ if (req.method === 'GET' && pathname === '/api/plugins/center') {
1823
+ return json(res, 200, pluginCenterData());
1824
+ }
1825
+ if (req.method === 'POST' && pathname === '/api/plugins/lifecycle') {
1826
+ const { kind, id, status } = await body(req); // kind: system|plugin; status: active|deprecated
1827
+ if (!['active', 'deprecated'].includes(status)) return json(res, 400, { error: 'status 须为 active|deprecated' });
1828
+ if (kind === 'system') { setSystemPluginStatus(id, status); return json(res, 200, { ok: true }); }
1829
+ if (kind === 'plugin') {
1830
+ const full = loadPanelPlugin(id);
1831
+ if (!full) return json(res, 404, { error: '插件不存在' });
1832
+ full.status = status; full.lastCheck = full.lastCheck || null;
1833
+ fs.writeFileSync(path.join(PANELS_DIR, `${id}.json`), JSON.stringify(full, null, 2));
1834
+ audit({ actor: 'plugin-center', kind: 'plugin', id, action: status });
1835
+ return json(res, 200, { ok: true });
1836
+ }
1837
+ return json(res, 400, { error: 'kind 须为 system|plugin' });
1838
+ }
1839
+
1840
+ // ── 草稿管理 ──
1841
+ if (req.method === 'DELETE' && pathname === '/api/workbench/draft') {
1842
+ const { agent_id } = await body(req);
1843
+ fs.rmSync(draftPath(agent_id), { force: true });
1844
+ return json(res, 200, { ok: true });
1845
+ }
1846
+ // ── 工作台工程师:对话式开发(工具即扩展点)──
1847
+ if (req.method === 'POST' && pathname === '/api/dev/chat') {
1848
+ const { sessionId, agentId, message } = await body(req);
1849
+ const emit = sseInit(req, res);
1850
+ try {
1851
+ devCtx.agentId = agentId;
1852
+ if (!loadWorkbench(agentId)) return emit('error', { message: '该助手尚无工作台' }), res.end();
1853
+ let s = devSessions.get(sessionId);
1854
+ if (!s) {
1855
+ s = { context: { systemPrompt: devSystemPrompt(), messages: [], tools: Object.entries(DEV_TOOLS).map(([n, t]) => ({ name: n, description: t.desc, parameters: { type: 'object', properties: t.schema, required: t.required || [] } })) } };
1856
+ devSessions.set(sessionId, s);
1857
+ } else {
1858
+ s.context.systemPrompt = devSystemPrompt();
1859
+ }
1860
+ s.context.messages.push({ role: 'user', content: message, timestamp: Date.now() });
1861
+ await devChatLoop(s, emit);
1862
+ emit('meta', { hasDraft: !!loadDraft(agentId) });
1863
+ } catch (e) { emit('error', { message: e.message }); }
1864
+ return res.end();
1865
+ }
1866
+
1867
+ // ── 现场状态 ──
1868
+ if (req.method === 'GET' && pathname === '/api/state') {
1869
+ return json(res, 200, { devices: world.devices, alarms: world.alarms, kpi: world.kpi, work_orders: world.work_orders, inspections: world.inspections });
1870
+ }
1871
+ // ── 对话(SSE 流式;会话绑定工作区:exec cwd / 数据集库 / 上下文随工作区走)──
1872
+ if (req.method === 'POST' && pathname === '/api/chat') {
1873
+ const { sessionId, agentId, message, autonomyLevel, context, ws_id } = await body(req);
1874
+ const emit = sseInit(req, res);
1875
+ try {
1876
+ const { s, deployment, armAgent } = await ensureSession(sessionId, agentId, autonomyLevel, ws_id);
1877
+ if (s.running) { // 会话在后台仍在处理上一条消息(跨页面进度跟踪)
1878
+ emit('error', { message: `该会话正在后台处理(${s.statusText || '进行中'})——可先切换其他助手,稍后回来进度会自动恢复` });
1879
+ return res.end();
1880
+ }
1881
+ if (s.pending) {
1882
+ emit('error', { message: '有一项写操作等待人工确认,请先处理对话中的确认卡(或点取消)' });
1883
+ return res.end();
1884
+ }
1885
+ s.context.messages.push({ role: 'user', content: context ? `${message}\n(界面注入上下文:当前选中设备 ${context.device})` : message, timestamp: Date.now() });
1886
+ const wb = loadDraft(agentId) || loadWorkbench(agentId) || {};
1887
+ s.running = true; s.sinceTs = Date.now();
1888
+ const out = await agentLoop(s, deployment, emit, Math.max(1, +(wb.max_rounds) || 16));
1889
+ emit('done', { reply: out.reply, pending: out.pending });
1890
+ } catch (e) { emit('error', { message: e.message }); }
1891
+ finally { const s = sessions.get(sessionId); if (s) { s.running = false; s.statusText = ''; } }
1892
+ return res.end();
1893
+ }
1894
+ if (req.method === 'POST' && pathname === '/api/chat/confirm') {
1895
+ const { sessionId, approved } = await body(req);
1896
+ const emit = sseInit(req, res);
1897
+ try {
1898
+ const s = sessions.get(sessionId);
1899
+ if (!s || !s.pending) return emit('error', { message: '无挂起操作' }), res.end();
1900
+ const deployment = loadDeployment(s.agentId);
1901
+ const { tool_call_id, tool, args } = s.pending; s.pending = null;
1902
+ if (!approved) {
1903
+ s.context.messages.push({ role: 'toolResult', toolCallId: tool_call_id, toolName: tool, content: [{ type: 'text', text: '用户取消了该操作。' }], isError: false, timestamp: Date.now() });
1904
+ emit('tool_end', { tool, args, result: '用户取消了该操作。', status: 'cancelled' });
1905
+ } else {
1906
+ emit('tool_start', { tool, args });
1907
+ let result, isErr = false;
1908
+ try { result = await getTool(tool).run(args, s.agentId, s.wsId || 'default'); } catch (e) { result = `执行失败: ${e.message}`; isErr = true; }
1909
+ s.context.messages.push({ role: 'toolResult', toolCallId: tool_call_id, toolName: tool, content: [{ type: 'text', text: String(result) }], isError: isErr, timestamp: Date.now() });
1910
+ emit('tool_end', { tool, args, result: String(result), status: 'executed' });
1911
+ audit({ actor: 'human-confirm', agent: s.agentId, tool, autonomy: s.autonomy, ok: !isErr });
1912
+ }
1913
+ const wb = loadDraft(s.agentId) || loadWorkbench(s.agentId) || {};
1914
+ s.running = true; s.sinceTs = Date.now();
1915
+ const out = await agentLoop(s, deployment, emit, Math.max(1, +(wb.max_rounds) || 16));
1916
+ emit('done', { reply: out.reply, pending: out.pending });
1917
+ } catch (e) { emit('error', { message: e.message }); }
1918
+ finally { if (s) { s.running = false; s.statusText = ''; } }
1919
+ return res.end();
1920
+ }
1921
+ // ── 会话历史与进度(切换助手后回来:上下文回放 + 后台运行状态;服务端为唯一事实源)──
1922
+ if (req.method === 'GET' && pathname === '/api/chat/history') {
1923
+ const sid = url.searchParams.get('sessionId');
1924
+ const s = sid && sessions.get(sid);
1925
+ if (!s) return json(res, 200, { exists: false });
1926
+ const toolResults = {}; // toolCallId → 结果(回放时把调用与结果拼回事件卡)
1927
+ for (const m of s.context.messages) {
1928
+ if (m.role === 'toolResult' && m.toolCallId) toolResults[m.toolCallId] = { text: String((m.content || []).map(c => c.text || '').join('')), isError: !!m.isError };
1929
+ }
1930
+ const msgs = [];
1931
+ for (const m of s.context.messages) {
1932
+ if (m.role === 'user') { msgs.push({ kind: 'user', text: String(m.content) }); continue; }
1933
+ if (!Array.isArray(m.content)) continue;
1934
+ const text = m.content.filter(b => b.type === 'text').map(b => b.text).join('');
1935
+ if (text) msgs.push({ kind: 'bot', text });
1936
+ for (const tc of m.content.filter(b => b.type === 'toolCall')) {
1937
+ const r = toolResults[tc.id];
1938
+ msgs.push({ kind: 'evt', evt: { tool: tc.name, args: tc.arguments || {}, result: (r?.text || '…').slice(0, 700), status: r ? (r.isError ? 'rejected' : 'ok') : 'start' } });
1939
+ }
1940
+ }
1941
+ return json(res, 200, {
1942
+ exists: true, agentId: s.agentId, wsId: s.wsId || 'default',
1943
+ running: !!s.running, statusText: s.statusText || '', since: s.sinceTs || null,
1944
+ pending: s.pending || null, msgs,
1945
+ });
1946
+ }
1947
+ // ── 顶栏快速切换数据源:在岗助手 + 各自会话运行状态(每 5s 轮询,纯内存)──
1948
+ if (req.method === 'GET' && pathname === '/api/quick-switch') {
1949
+ const live = {}; // agentId -> {running, pending, statusText}
1950
+ for (const [, s] of sessions) {
1951
+ if (!s.running && !s.pending) continue;
1952
+ const cur = live[s.agentId];
1953
+ if (!cur || s.running) live[s.agentId] = { running: s.running, pending: !!s.pending, statusText: s.pending ? `等待人工确认 ${s.pending.tool}` : (s.statusText || '进行中') };
1954
+ }
1955
+ const agents = fs.readdirSync(DEPLOY_DIR).filter(f => f.endsWith('.json')).map(f => {
1956
+ const id = f.replace(/\.json$/, '');
1957
+ const dep = loadDeployment(id);
1958
+ return { agent_id: id, name: dep?.name || localAgent(id)?.name || id.slice(0, 8), ...(live[id] || { running: false, pending: false, statusText: '' }) };
1959
+ });
1960
+ return json(res, 200, { agents });
1961
+ }
1962
+ // ── 工作台操作区按钮(已退役:mock 编排四件套移除;按钮动作统一走 /api/panel-action 的 params.actions,随草稿人审)──
1963
+ if (req.method === 'POST' && pathname === '/api/action') {
1964
+ return json(res, 410, { error: 'mock 编排动作已退役(logic-ide 接入后将以通用编排工具回归)。面板按钮请使用 params.actions + props.runAction' });
1965
+ }
1966
+ // ── 经验回流(AI起草→人审→写入ARM)──
1967
+ if (req.method === 'POST' && pathname === '/api/experience/draft') {
1968
+ const { sessionId } = await body(req);
1969
+ const s = sessions.get(sessionId);
1970
+ if (!s) return json(res, 400, { error: '会话不存在' });
1971
+ const convo = s.context.messages.filter(m => m.role === 'user' || m.role === 'assistant')
1972
+ .slice(-12).map(m => `${m.role === 'user' ? '用户' : '助手'}: ${(m.content || []).filter?.(b => b.type === 'text').map(b => b.text).join('') ?? m.content}`).join('\n');
1973
+ const md = await llmComplete('你是经验沉淀器。把运维对话提炼为一条结构化经验 markdown,章节固定:## 现象 / ## 根因 / ## 处置步骤 / ## 验证 / ## 预防。事实不确定就写"待确认"。只输出 markdown 正文。', convo || '(空会话)');
1974
+ return json(res, 200, { markdown: md });
1975
+ }
1976
+ if (req.method === 'POST' && pathname === '/api/experience/commit') {
1977
+ const { agentId, name, description, content } = await body(req);
1978
+ const arm = agentProfileNow(agentId);
1979
+ const full = ['---', 'kind: experience', 'source: aims-platform-mvp', `agent: ${arm.name || agentId}@${arm.version || ''}`, 'scenario: 立库A区智能运维@mvp', '---', '', content].join('\n');
1980
+ const r = await mcpCall('create_knowledge', { name, description: description || `工作台人工审阅沉淀`, content: full, tags: ['MVP回流'] }, { pat: true });
1981
+ const kid = (r.match(/id:\s*([0-9a-f-]{36})/) || [])[1] || '';
1982
+ metric({ kind: 'experience', agentId, title: name, knowledge_id: kid });
1983
+ audit({ actor: 'experience-reflow', target: 'ARM', name });
1984
+ return json(res, 200, { result: r });
1985
+ }
1986
+ // ── 自进化看板 ──
1987
+ if (req.method === 'GET' && pathname === '/api/evolution') {
1988
+ const audits = fs.existsSync(AUDIT_LOG) ? fs.readFileSync(AUDIT_LOG, 'utf8').trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean) : [];
1989
+ const metrics = fs.existsSync(METRICS_LOG) ? fs.readFileSync(METRICS_LOG, 'utf8').trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean) : [];
1990
+ const toolCalls = audits.filter(a => a.actor === 'assistant');
1991
+ const blocks = audits.filter(a => a.actor === 'guardrail' && a.verdict === 'blocked');
1992
+ const confirms = audits.filter(a => a.actor === 'human-confirm');
1993
+ const orchestrations = audits.filter(a => a.actor === 'orchestration');
1994
+ const reflows = metrics.filter(m => m.kind === 'experience');
1995
+ const searches = metrics.filter(m => m.kind === 'kb_search');
1996
+ const aiDrafts = metrics.filter(m => m.kind === 'ai_draft');
1997
+ const byTool = {}; toolCalls.forEach(a => { byTool[a.tool] = (byTool[a.tool] || 0) + 1; });
1998
+ const byAgent = {}; audits.forEach(a => { if (a.agent || a.agentId) byAgent[a.agent || a.agentId] = (byAgent[a.agent || a.agentId] || 0) + 1; });
1999
+ return json(res, 200, {
2000
+ totals: { tool_calls: toolCalls.length, guardrail_blocks: blocks.length, human_confirms: confirms.length, orchestrations: orchestrations.length, experiences: reflows.length, kb_searches: searches.length, ai_drafts: aiDrafts.length },
2001
+ by_tool: byTool, by_agent: byAgent,
2002
+ similarity_trend: searches.filter(s => s.top_similarity != null).map(s => ({ ts: s.ts, query: s.query.slice(0, 40), similarity: s.top_similarity })),
2003
+ experiences: reflows.map(r => ({ ts: r.ts, title: r.title, knowledge_id: r.knowledge_id, agent: r.agentId })),
2004
+ recent_audit: audits.slice(-25).reverse(),
2005
+ flywheel_note: '处置→沉淀→检索命中:对比 similarity_trend 中回流前后的相似度变化',
2006
+ });
2007
+ }
2008
+ if (req.method === 'GET' && pathname === '/api/kb/search') {
2009
+ const q = url.searchParams.get('q') || '';
2010
+ if (!q) return json(res, 200, { items: [] });
2011
+ try {
2012
+ const { via, items } = await searchKnowledgeItems(q, 5);
2013
+ metric({ kind: 'kb_search', agentId: 'evolution-view', query: q, via, top_similarity: items.length ? items[0].similarity : null });
2014
+ return json(res, 200, { via, items });
2015
+ } catch (e) { return json(res, 502, { error: `知识检索不可达: ${e.message}` }); }
2016
+ }
2017
+ json(res, 404, { error: 'not found' });
2018
+ } catch (e) {
2019
+ audit({ actor: 'server', error: e.message });
2020
+ if (!res.headersSent) json(res, 500, { error: e.message }); else try { res.end(); } catch {}
2021
+ }
2022
+ });
2023
+
2024
+ // ── 单测: node server.js --test-guard ──
2025
+ if (process.argv.includes('--test-guard')) {
2026
+ const cases = [
2027
+ ['data_write', 0, {}, 'blocked'], ['data_write', 1, {}, 'blocked'], ['data_write', 2, {}, 'pending'],
2028
+ ['save_experience', 0, {}, 'blocked'], ['save_experience', 1, {}, 'ok'],
2029
+ ['search_knowledge', 0, {}, 'ok'],
2030
+ ['exec', 0, { command: 'curl -s http://localhost:5600/api/state' }, 'ok'],
2031
+ ['exec', 0, { command: 'nc -vz -w 3 192.0.2.1 22' }, 'ok'],
2032
+ ['exec', 0, { command: 'sqlite3 data/store.db "SELECT 1"' }, 'ok'],
2033
+ ['exec', 0, { command: 'rm -rf /tmp/x' }, 'blocked'],
2034
+ ['exec', 0, { command: 'sudo reboot' }, 'blocked'],
2035
+ ['exec', 0, { command: 'sqlite3 x.db "INSERT INTO t VALUES(1)"' }, 'blocked'],
2036
+ ['exec', 2, { command: 'echo hi > /tmp/a.txt' }, 'pending'],
2037
+ ['data_query', 0, {}, 'ok'], ['data_write', 0, {}, 'blocked'], ['data_write', 2, {}, 'pending'],
2038
+ ['data_write', 3, {}, 'ok'],
2039
+ ['exec', 3, { command: 'echo hi > /tmp/a.txt' }, 'ok'],
2040
+ ['exec', 3, { command: 'sudo reboot' }, 'blocked'],
2041
+ ];
2042
+ let pass = true;
2043
+ for (const [tool, lv, args, want] of cases) {
2044
+ const g = guardCheck(tool, lv, null, args); const ok = g.status === want; if (!ok) pass = false;
2045
+ console.log(`${ok ? '✅' : '❌'} guard(${tool}, L${lv}, ${String(args.command || '').slice(0, 30)}) -> ${g.status}(期望 ${want})`);
2046
+ }
2047
+ console.log(pass ? '守护栏单元测试全部通过' : '守护栏单元测试存在失败');
2048
+ process.exit(pass ? 0 : 1);
2049
+ }
2050
+
2051
+ applyLLMConfig().then(() => {
2052
+ server.listen(PORT, HOST, () => {
2053
+ console.log(`[aims-workstation] http://localhost:${PORT}(绑定 ${HOST}) | 模型: ${model.id} @ ${model.baseUrl}`);
2054
+ console.log(`[aims-workstation] 数据目录 AIMS_HOME: ${AIMS_HOME}`);
2055
+ console.log(`[aims-workstation] ARM: ${armMcpUrl()} | REST: ${armApiBase()} | PAT: ${ARM_PAT ? '已加载' : '未配置'} | 已上岗助手: ${fs.readdirSync(DEPLOY_DIR).length} | 本地助手库: ${loadRegistry().agents.length}`);
2056
+ });
2057
+ // 已上岗助手后台物化为本地档案(best-effort;ARM 不可达时静默跳过,待手动同步)
2058
+ (async () => {
2059
+ for (const f of fs.readdirSync(DEPLOY_DIR).filter(f => f.endsWith('.json'))) {
2060
+ const agentId = f.replace(/\.json$/, '');
2061
+ if (localAgent(agentId)) continue;
2062
+ try { const m = await downloadAgent(agentId); console.log(`[mvp] 已上岗助手已物化本地档案: ${m.name} ${m.version}`); }
2063
+ catch { /* 离线,待恢复后同步 */ }
2064
+ }
2065
+ })();
2066
+ }).catch(e => { console.error('LLM 初始化失败:', e.message); process.exit(1); });