aims-workstation 0.1.15 → 0.1.17
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/package.json +1 -1
- package/server.js +490 -69
- package/skills/workbench-dev/SKILL.md +1 -1
- package/web/dist/assets/index-BPQQAeWw.js +105 -0
- package/web/dist/assets/index-BybiSo44.css +2 -0
- package/web/dist/favicon.svg +15 -1
- package/web/dist/index.html +4 -4
- package/web/dist/assets/index-DVXOh_4n.js +0 -105
- package/web/dist/assets/index-DawDLoJp.css +0 -2
package/server.js
CHANGED
|
@@ -76,31 +76,81 @@ const PORT = +(process.env.PORT || env.PORT || 5600);
|
|
|
76
76
|
const HOST = process.env.AIMS_HOST || '0.0.0.0'; // 默认局域网可达(团队共用);仅本机使用可设 AIMS_HOST=127.0.0.1
|
|
77
77
|
// ── 运行时配置(ARM 源与 LLM 均可私有化部署:在「系统设置」页切换,存 AIMS_HOME/config/platform.json,优先级 运行时 > 环境变量 > .env)──
|
|
78
78
|
const CFG_FILE = path.join(AIMS_HOME, 'config/platform.json');
|
|
79
|
-
const cfg = {
|
|
79
|
+
const cfg = { arm_sources: [], arm_current: 'default', llm_base_url: '', llm_api_key: '', llm_model: '', llm_context_tokens: 0, llm_vision: '', scene_profile: '', access_token: '' };
|
|
80
80
|
// 守护栏默认策略(可在「系统设置·守护栏」维护,存 platform.json,保存即热生效):deny=正则源串(命中即永久拒绝,任何级别不豁免);
|
|
81
81
|
// ro=只读白名单命令名(免确认直行);sudo_u_l2=true 时 `sudo -u <用户>` 降权形式不再永久拒绝、按写命令走 L2 人工确认(裸 sudo 仍拒绝)
|
|
82
|
-
|
|
82
|
+
// 默认清单按平台合成:POSIX 为主;Windows(cmd/PowerShell 命令与毁灭性命令)仅在 win32 追加——非 Windows 平台不引入 "format" 等词的误杀
|
|
83
|
+
const WIN = process.platform === 'win32';
|
|
84
|
+
const GUARD_POSIX = {
|
|
83
85
|
deny: ['\\bsudo\\b', '\\b(shutdown|reboot|halt|poweroff|mkfs)\\b', '\\bdd\\s+if=', 'rm\\s+-[a-z]*[rf][a-z]*\\s+/(\\s|$)', ':\\(\\)\\s*\\{.*\\};:', '\\bkill\\s+-9\\s+1\\b'],
|
|
84
86
|
ro: ['ls', 'cat', 'head', 'tail', 'wc', 'grep', 'find', 'file', 'stat', 'whoami', 'date', 'uname', 'uptime', 'df', 'du', 'ps', 'curl', 'ping', 'nc', 'netstat', 'dig', 'nslookup', 'sqlite3', 'echo', 'which', 'env'],
|
|
85
|
-
sudo_u_l2: false,
|
|
86
87
|
};
|
|
87
|
-
|
|
88
|
+
// Windows 补充:ro 含 cmd 只读命令与 PowerShell 只读 cmdlet/别名(多行为动词 net/route/reg/sc/wmic 不入白名单——首个词无法区分读写);
|
|
89
|
+
// deny 的删除类只拦"直指盘符根",对齐 POSIX `rm -rf /` 只保护根的粒度(`rm -rf /tmp/x` 走 L2 确认);
|
|
90
|
+
// format 只认裸盘符(`format c:` / `format /q d:`),`dotnet format C:\x.sln`、`git log --format=… C:\repo` 这类路径参数不命中
|
|
91
|
+
const GUARD_WIN = {
|
|
92
|
+
deny: [
|
|
93
|
+
'\\bdiskpart\\b',
|
|
94
|
+
'\\bformat(\\.com)?\\b[^|;&]*\\s[c-z]:(\\s|$|/)',
|
|
95
|
+
'\\b(format-volume|clear-disk)\\b',
|
|
96
|
+
'\\b(rd|rmdir|del|erase|remove-item|ri|rm)\\b[^|;&]*([c-z]:\\\\\\s*$|\\s[c-z]:\\\\\\s)',
|
|
97
|
+
'\\b(stop-computer|restart-computer)\\b',
|
|
98
|
+
'%0\\s*\\|\\s*%0',
|
|
99
|
+
],
|
|
100
|
+
ro: ['dir', 'type', 'more', 'find', 'findstr', 'where', 'cd', 'chdir', 'tree', 'fc', 'sort', 'ver', 'vol', 'hostname', 'ipconfig', 'tracert', 'getmac', 'systeminfo', 'tasklist', 'time', 'set', 'help',
|
|
101
|
+
'Get-Content', 'gc', 'Select-String', 'sls', 'Get-ChildItem', 'gci', 'Get-Item', 'gi', 'Get-Process', 'gps', 'Get-Service', 'gsv', 'Get-Command', 'gcm', 'Get-Member', 'gm', 'Get-Help', 'man', 'Get-Location', 'pwd', 'Get-Date', 'Get-NetIPAddress', 'Get-NetTCPConnection', 'Test-Connection', 'Test-NetConnection', 'Get-ComputerInfo', 'Get-Acl'],
|
|
102
|
+
};
|
|
103
|
+
const guardDefaults = (win = WIN) => ({
|
|
104
|
+
deny: [...new Set([...GUARD_POSIX.deny, ...(win ? GUARD_WIN.deny : [])])],
|
|
105
|
+
ro: [...new Set([...(win ? GUARD_WIN.ro : []), ...GUARD_POSIX.ro])], // win32 时 Windows 命令排前:L0 拦截提示的样例对当前平台有意义;去重(find 两边都有)
|
|
106
|
+
sudo_u_l2: false,
|
|
107
|
+
});
|
|
108
|
+
function normalizeGuard(g, win = WIN) {
|
|
88
109
|
g = g && typeof g === 'object' ? g : {};
|
|
110
|
+
const d = guardDefaults(win);
|
|
89
111
|
return {
|
|
90
|
-
deny: Array.isArray(g.deny) ? [...new Set(g.deny.map((s) => String(s).trim()).filter(Boolean))] :
|
|
91
|
-
ro: Array.isArray(g.ro) ? [...new Set(g.ro.map((s) => String(s).trim()).filter(Boolean))] :
|
|
112
|
+
deny: Array.isArray(g.deny) ? [...new Set(g.deny.map((s) => String(s).trim()).filter(Boolean))] : d.deny.slice(),
|
|
113
|
+
ro: Array.isArray(g.ro) ? [...new Set(g.ro.map((s) => String(s).trim()).filter(Boolean))] : d.ro.slice(),
|
|
92
114
|
sudo_u_l2: !!g.sudo_u_l2,
|
|
93
115
|
};
|
|
94
116
|
}
|
|
95
|
-
|
|
117
|
+
// ── 多 ARM 源(公库/私库并存,随时切换):arm_sources 列表 + arm_current 指针。default 源常驻不可删,承载 env / ~/.arm PAT /
|
|
118
|
+
// 内置默认的回落;其他源只用自己的地址与令牌——不把一个站点的令牌发给另一个站点。旧单源字段 arm_api_base/arm_token 迁移为 default 源 ──
|
|
119
|
+
const SRC_DEFAULT_ID = 'default';
|
|
120
|
+
function normalizeSources(c) {
|
|
121
|
+
const clean = (s, i) => ({ id: String(s.id || '').trim(), name: String(s.name || '').trim() || (String(s.id) === SRC_DEFAULT_ID ? '默认 ARM' : `ARM 源 ${i + 1}`), base: String(s.base || '').trim().replace(/\/+$/, ''), token: String(s.token || '').trim() });
|
|
122
|
+
let list = (Array.isArray(c.arm_sources) ? c.arm_sources : []).filter((s) => s && typeof s === 'object').map(clean).filter((s) => /^[\w-]{1,32}$/.test(s.id));
|
|
123
|
+
const seen = new Set(); list = list.filter((s) => !seen.has(s.id) && seen.add(s.id));
|
|
124
|
+
if (!list.some((s) => s.id === SRC_DEFAULT_ID)) list.unshift({ id: SRC_DEFAULT_ID, name: '默认 ARM', base: String(c.arm_api_base || '').trim().replace(/\/+$/, ''), token: String(c.arm_token || '').trim() });
|
|
125
|
+
return list;
|
|
126
|
+
}
|
|
127
|
+
function loadCfg() {
|
|
128
|
+
try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'))); } catch { }
|
|
129
|
+
delete cfg.arm_mcp_url; /* MCP 通道已退役:剥离旧配置残留 */
|
|
130
|
+
cfg.arm_sources = normalizeSources(cfg); delete cfg.arm_api_base; delete cfg.arm_token; // 单源字段已并入 default 源(下次保存即清理)
|
|
131
|
+
if (!cfg.arm_sources.some((s) => s.id === cfg.arm_current)) cfg.arm_current = SRC_DEFAULT_ID;
|
|
132
|
+
cfg.guard = normalizeGuard(cfg.guard);
|
|
133
|
+
}
|
|
96
134
|
loadCfg();
|
|
97
135
|
const guardNow = () => cfg.guard;
|
|
98
136
|
function saveCfg() { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg, null, 2)); }
|
|
99
|
-
// ARM
|
|
137
|
+
// ARM 站点地址:当前源配置 > (仅 default 源)ARM_API_BASE(env) > 公司内网默认(内网可达即开箱可用)
|
|
100
138
|
//(2026-10-21 全面切换新版 ARM:仅 REST 通道,MCP 通道及旧版兼容回落已退役)
|
|
101
139
|
const ARM_DEFAULT_BASE = 'http://arm.agent-platform.dev.aimstek.cn';
|
|
102
|
-
const
|
|
103
|
-
const
|
|
140
|
+
const armSources = () => cfg.arm_sources;
|
|
141
|
+
const armSource = (id) => cfg.arm_sources.find((s) => s.id === id) || null;
|
|
142
|
+
const armCurrent = () => armSource(cfg.arm_current) || cfg.arm_sources[0];
|
|
143
|
+
const srcBase = (s) => s.base || (s.id === SRC_DEFAULT_ID ? (process.env.ARM_API_BASE || env.ARM_API_BASE || ARM_DEFAULT_BASE) : '');
|
|
144
|
+
const srcToken = (s) => s.token || (s.id === SRC_DEFAULT_ID ? (env.ARM_API_TOKEN || ARM_PAT) : '');
|
|
145
|
+
const armApiBase = () => srcBase(armCurrent());
|
|
146
|
+
const armApiToken = () => srcToken(armCurrent());
|
|
147
|
+
// 本地库条目归属:未发布档案不属于任何源(可发布到任一当前源);旧条目无 source_id 视为 default 源
|
|
148
|
+
const entryBelongs = (e, src) => !!e.local_only || (e.source_id || SRC_DEFAULT_ID) === src.id;
|
|
149
|
+
// 源的对外视图:令牌只给掩码;token_source 说明令牌来自运行时配置还是(仅 default 源)env/PAT 回落
|
|
150
|
+
const srcView = (s) => {
|
|
151
|
+
const t = srcToken(s);
|
|
152
|
+
return { id: s.id, name: s.name, base: srcBase(s), base_runtime: !!s.base, token_set: !!t, token_masked: t ? `${t.slice(0, 10)}…${t.slice(-4)}` : '', token_source: s.token ? 'runtime' : (s.id === SRC_DEFAULT_ID ? (env.ARM_API_TOKEN ? 'env' : (ARM_PAT ? 'pat' : 'none')) : 'none'), builtin: s.id === SRC_DEFAULT_ID, current: s.id === armCurrent().id };
|
|
153
|
+
};
|
|
104
154
|
// LLM:OpenAI 兼容端点(baseUrl/模型/密钥),缺省公司内网 bifrost 网关(内网可达即可用,无需配置)
|
|
105
155
|
const LLM_DEFAULT_BASE = 'http://bifrost.infra.dev.aimstek.cn/v1';
|
|
106
156
|
const llmBaseUrl = () => cfg.llm_base_url || env.LLM_BASE_URL || LLM_DEFAULT_BASE;
|
|
@@ -141,6 +191,9 @@ const METRICS_LOG = path.join(DATA_DIR, 'metrics.log');
|
|
|
141
191
|
|
|
142
192
|
// ── pi-ai(Agent 层)─────────────────────────────────────
|
|
143
193
|
let models, model, llmCatalog = [];
|
|
194
|
+
// 视觉能力(对话附图是否随消息送给模型):auto 按 pi-ai 模型条目 input 判定(glm-4.6v / glm-5.3-flash 支持,glm-5.3 不支持);
|
|
195
|
+
// 自定义模型名按纯文本模板克隆、能力未知 → 设置页可强制 on/off
|
|
196
|
+
const llmVision = () => cfg.llm_vision === 'on' ? true : cfg.llm_vision === 'off' ? false : !!(model && Array.isArray(model.input) && model.input.includes('image'));
|
|
144
197
|
async function applyLLMConfig() { // 可重复执行:设置页保存后热切换(在途请求沿用旧 model 引用,安全)
|
|
145
198
|
const { createModels, createProvider } = await import('@earendil-works/pi-ai/models');
|
|
146
199
|
const { zaiCodingCnProvider } = await import('@earendil-works/pi-ai/providers/zai-coding-cn');
|
|
@@ -197,7 +250,7 @@ function agentBrief(agentId) {
|
|
|
197
250
|
const inspCache = new Map(); // agent 自描述指纹 → 灵感列表(LLM 一次生成,档案不变即复用)
|
|
198
251
|
|
|
199
252
|
// ── 现场数据容器:真实数据源(AIMS-MOS)未接入前保持为空——面板按空态渲染,不造假数据
|
|
200
|
-
//(2026-10-15 MVP
|
|
253
|
+
//(2026-10-15 MVP 打磨:移除模拟立库场景与场景画像注入,见 README)
|
|
201
254
|
const world = { devices: [], alarms: [], kpi: {}, work_orders: [], inspections: {} };
|
|
202
255
|
|
|
203
256
|
// ── 审计与指标(自进化飞轮的数据原料)──────────────────
|
|
@@ -205,18 +258,20 @@ function audit(entry) { entry.ts = new Date().toISOString(); fs.appendFileSync(A
|
|
|
205
258
|
function metric(entry) { entry.ts = new Date().toISOString(); fs.appendFileSync(METRICS_LOG, JSON.stringify(entry) + '\n'); }
|
|
206
259
|
|
|
207
260
|
// ARM REST 客户端(v2 开发版结构化通道;{ ok, data, msg } 包络,ok=false 抛错)——目录/检索/下载/回流全链路唯一通道
|
|
208
|
-
|
|
261
|
+
// 缺省打当前源;src 显式指定用于"探测某个源"而不切换
|
|
262
|
+
async function armRest(pathname, init = {}, timeout_ms = 20000, src = null) {
|
|
263
|
+
const s = src || armCurrent();
|
|
209
264
|
const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) };
|
|
210
|
-
const token =
|
|
265
|
+
const token = srcToken(s);
|
|
211
266
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
212
267
|
const ctrl = new AbortController();
|
|
213
268
|
const timer = setTimeout(() => ctrl.abort(), timeout_ms);
|
|
214
269
|
try {
|
|
215
|
-
const res = await fetch(
|
|
270
|
+
const res = await fetch(srcBase(s) + pathname, { ...init, headers, signal: ctrl.signal });
|
|
216
271
|
const data = await res.json();
|
|
217
272
|
if (!res.ok || data.ok === false) {
|
|
218
273
|
const noAuth = res.status === 401 || res.status === 403 || /401|403|unauthorized|forbidden/i.test(String(data.msg || ''));
|
|
219
|
-
throw new Error(`ARM REST ${pathname.split('?')[0]}: ${data.msg || res.status}${noAuth ? '——令牌未配置或无权限:到 ARM(' +
|
|
274
|
+
throw new Error(`ARM REST ${pathname.split('?')[0]}: ${data.msg || res.status}${noAuth ? '——令牌未配置或无权限:到 ARM(' + srcBase(s) + ')生成访问令牌(arm_pat_…)后,粘贴到「系统设置 · ARM 源 · ' + s.name + '」' : ''}`);
|
|
220
275
|
}
|
|
221
276
|
return data.data;
|
|
222
277
|
} catch (e) {
|
|
@@ -301,7 +356,12 @@ async function searchKnowledgeItems(query, topK = 5) {
|
|
|
301
356
|
// ── 本地助手库(07-助手本地化:下载物化 → 离线可用;按版本目录存档,升级原子切指针)──
|
|
302
357
|
const regFile = () => path.join(ARM_AGENTS_DIR, 'registry.json');
|
|
303
358
|
function loadRegistry() {
|
|
304
|
-
try {
|
|
359
|
+
let reg; try { reg = JSON.parse(fs.readFileSync(regFile(), 'utf8')); } catch { return { agents: [], last_sync_at: null }; }
|
|
360
|
+
// 多源迁移(读即回填一次):多源之前下载的条目都来自当时唯一的那个源 = default;未发布档案不属于任何源
|
|
361
|
+
let dirty = false;
|
|
362
|
+
for (const e of reg.agents || []) if (!e.source_id && !e.local_only) { e.source_id = SRC_DEFAULT_ID; e.source_name = armSource(SRC_DEFAULT_ID)?.name || '默认 ARM'; dirty = true; }
|
|
363
|
+
if (dirty) saveRegistry(reg);
|
|
364
|
+
return reg;
|
|
305
365
|
}
|
|
306
366
|
function saveRegistry(reg) { fs.writeFileSync(regFile(), JSON.stringify(reg, null, 2)); }
|
|
307
367
|
function localAgent(agentId) { return loadRegistry().agents.find(a => a.agent_id === agentId) || null; }
|
|
@@ -330,6 +390,7 @@ function commitDownload(manifest) {
|
|
|
330
390
|
let e = reg.agents.find(a => a.agent_id === manifest.agent_id);
|
|
331
391
|
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); }
|
|
332
392
|
e.name = manifest.name; e.description = manifest.description || ''; e.installed_version = manifest.version; e.last_sync_at = manifest.downloaded_at; e.delisted = false;
|
|
393
|
+
if (manifest.source_id) { e.source_id = manifest.source_id; e.source_name = manifest.source_name; } // 来源源 = 最近一次同步的源(跨源发布后转为目标源)
|
|
333
394
|
if (!e.versions.includes(manifest.version)) e.versions.push(manifest.version);
|
|
334
395
|
e.versions.sort(cmpVersion);
|
|
335
396
|
saveRegistry(reg);
|
|
@@ -385,6 +446,7 @@ async function downloadAgent(agentId) {
|
|
|
385
446
|
const manifest = {
|
|
386
447
|
agent_id: agentId, name: d.name, version, description: d.description || '',
|
|
387
448
|
downloaded_at: new Date().toISOString(), source: `arm-rest://${(armApiBase() || '').replace(/^https?:\/\//, '').split('/')[0]}`,
|
|
449
|
+
source_id: armCurrent().id, source_name: armCurrent().name,
|
|
388
450
|
prompt: d.prompt || '',
|
|
389
451
|
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) })),
|
|
390
452
|
knowledges, ui_bindings,
|
|
@@ -419,6 +481,7 @@ function writeLocalAgent(agentId, { name, description, prompt }, { bump = false
|
|
|
419
481
|
|
|
420
482
|
// 同步:比对远端目录与本地库,产出 new/update/delisted 事件;顺带冲账离线经验 outbox
|
|
421
483
|
async function armSync() {
|
|
484
|
+
const src = armCurrent();
|
|
422
485
|
const remote = await armListAgents();
|
|
423
486
|
const reg = loadRegistry();
|
|
424
487
|
const remoteIds = new Set(remote.map(r => r.agent_id));
|
|
@@ -430,13 +493,14 @@ async function armSync() {
|
|
|
430
493
|
if (e) { e.remote_version = r.version; e.delisted = false; }
|
|
431
494
|
}
|
|
432
495
|
for (const e of reg.agents) {
|
|
496
|
+
if (!entryBelongs(e, src)) continue; // 其他源的助手:当前源目录里没有是正常的,不参与下架判定
|
|
433
497
|
if (!remoteIds.has(e.agent_id)) { if (!e.delisted) events.delisted.push({ agent_id: e.agent_id, name: e.name }); e.delisted = true; }
|
|
434
498
|
}
|
|
435
499
|
reg.last_sync_at = new Date().toISOString();
|
|
436
500
|
saveRegistry(reg);
|
|
437
501
|
const outbox_flushed = await flushExperienceOutbox();
|
|
438
|
-
audit({ actor: 'arm-library', action: 'sync', new: events.new.length, update: events.update.length, delisted: events.delisted.length, outbox_flushed });
|
|
439
|
-
return { at: reg.last_sync_at, remote_count: remote.length, events, outbox_flushed };
|
|
502
|
+
audit({ actor: 'arm-library', action: 'sync', source: src.id, new: events.new.length, update: events.update.length, delisted: events.delisted.length, outbox_flushed });
|
|
503
|
+
return { at: reg.last_sync_at, source: { id: src.id, name: src.name }, remote_count: remote.length, events, outbox_flushed };
|
|
440
504
|
}
|
|
441
505
|
|
|
442
506
|
// 离线经验 outbox:ARM 不可达时缓写,恢复连接后冲账(07 设计 §2.4)
|
|
@@ -445,9 +509,12 @@ function outboxCount() { try { return fs.readFileSync(OUTBOX_FILE, 'utf8').split
|
|
|
445
509
|
async function flushExperienceOutbox() {
|
|
446
510
|
if (!fs.existsSync(OUTBOX_FILE)) return 0;
|
|
447
511
|
const lines = fs.readFileSync(OUTBOX_FILE, 'utf8').split('\n').filter(Boolean);
|
|
512
|
+
const cur = armCurrent().id;
|
|
448
513
|
const remain = []; let ok = 0;
|
|
449
514
|
for (const line of lines) {
|
|
450
|
-
try {
|
|
515
|
+
let it; try { it = JSON.parse(line); } catch { remain.push(line); continue; }
|
|
516
|
+
if ((it.source_id || SRC_DEFAULT_ID) !== cur) { remain.push(line); continue; } // 记在别的源上的经验:切回那个源再冲账,不串源
|
|
517
|
+
try { const r = await armCreateKnowledge(it.args); if (it.agentId && r?.id) await bindExperienceToAgent(it.agentId, r.id); ok++; }
|
|
451
518
|
catch { remain.push(line); }
|
|
452
519
|
}
|
|
453
520
|
fs.writeFileSync(OUTBOX_FILE, remain.length ? remain.join('\n') + '\n' : '');
|
|
@@ -545,29 +612,146 @@ function deleteWorkspace(agentId, wsId, purge) {
|
|
|
545
612
|
return reg;
|
|
546
613
|
}
|
|
547
614
|
|
|
615
|
+
// ── 对话附件:落 <工作区>/attachments/YYYY-MM-DD/,助手 × 工作区天然隔离;exec 的 cwd 就是工作区,助手按相对路径即可读 ──
|
|
616
|
+
const ATTACH_DIR = 'attachments';
|
|
617
|
+
const ATTACH_MAX_BYTES = 25 * 1024 * 1024;
|
|
618
|
+
const ATTACH_INLINE_MAX = 16 * 1024, ATTACH_HEAD = 2 * 1024; // 文本类附件:≤16KB 全文内联进消息,更大只给前 2KB 预览 + exec 提示
|
|
619
|
+
const ATTACH_IMAGES_PER_MSG = 6;
|
|
620
|
+
const ATTACH_TEXT_EXT = new Set(['txt', 'log', 'md', 'markdown', 'json', 'yaml', 'yml', 'csv', 'tsv', 'xml', 'html', 'htm', 'svg', 'ini', 'conf', 'cfg', 'properties', 'env', 'toml', 'sh', 'bash', 'zsh', 'ps1', 'bat', 'cmd', 'py', 'js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx', 'java', 'go', 'rs', 'c', 'h', 'cpp', 'hpp', 'cs', 'sql', 'diff', 'patch', 'dockerfile', 'gitignore', 'lock', 'pem', 'crt']);
|
|
621
|
+
const ATTACH_MIME_BY_EXT = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', pdf: 'application/pdf', txt: 'text/plain', log: 'text/plain', md: 'text/markdown', json: 'application/json', csv: 'text/csv', xml: 'application/xml', yaml: 'text/yaml', yml: 'text/yaml', html: 'text/html', zip: 'application/zip' };
|
|
622
|
+
const fmtBytes = (n) => n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`;
|
|
623
|
+
// 客户端声明的 MIME 仅在格式合法且不是兜底值时采信,否则按扩展名推断
|
|
624
|
+
const attachMime = (mime, name) => (/^[\w.+-]+\/[\w.+-]+$/.test(String(mime || '')) && mime !== 'application/octet-stream') ? String(mime).toLowerCase() : (ATTACH_MIME_BY_EXT[path.extname(name).slice(1).toLowerCase()] || 'application/octet-stream');
|
|
625
|
+
function attachKind(mime, name, buf) {
|
|
626
|
+
if (/^image\/(png|jpe?g|gif|webp|bmp)$/.test(mime)) return 'image'; // svg 走文本(可内联;也不该作为位图送视觉模型)
|
|
627
|
+
const ext = path.extname(name).slice(1).toLowerCase();
|
|
628
|
+
const textual = /^text\//.test(mime) || /(json|xml|yaml|javascript|x-sh|x-python|svg)/.test(mime) || ATTACH_TEXT_EXT.has(ext);
|
|
629
|
+
return textual && buf && !buf.subarray(0, 8192).includes(0) ? 'text' : 'binary'; // 头部含 NUL 的"文本"按二进制处理
|
|
630
|
+
}
|
|
631
|
+
const safeFileName = (name) => {
|
|
632
|
+
let n = String(name || '').normalize('NFC').replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\.{2,}/g, '.').replace(/^\.+/, '').trim() || 'file';
|
|
633
|
+
if (n.length > 80) { const ext = path.extname(n); n = n.slice(0, 80 - ext.length) + ext; }
|
|
634
|
+
return n;
|
|
635
|
+
};
|
|
636
|
+
// rel 为工作区相对 posix 路径,且必须落在 attachments/ 内:防穿越,也不借此暴露工作区其他文件
|
|
637
|
+
function attachAbs(ws, rel) {
|
|
638
|
+
const r = String(rel || '');
|
|
639
|
+
if (!r || path.posix.isAbsolute(r) || /^[a-zA-Z]:/.test(r) || r.split('/').some((seg) => seg === '..' || seg === '')) return null;
|
|
640
|
+
const abs = path.resolve(ws.path, ...r.split('/'));
|
|
641
|
+
return abs.startsWith(path.resolve(ws.path, ATTACH_DIR) + path.sep) ? abs : null;
|
|
642
|
+
}
|
|
643
|
+
function saveAttachment(ws, name, mime, buf) {
|
|
644
|
+
const d = new Date(), p2 = (n) => String(n).padStart(2, '0');
|
|
645
|
+
const day = `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`;
|
|
646
|
+
const safe = safeFileName(name);
|
|
647
|
+
const rel = path.posix.join(ATTACH_DIR, day, `${p2(d.getHours())}${p2(d.getMinutes())}${p2(d.getSeconds())}-${crypto.randomUUID().slice(0, 4)}-${safe}`);
|
|
648
|
+
const abs = path.join(ws.path, ...rel.split('/'));
|
|
649
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
650
|
+
fs.writeFileSync(abs, buf);
|
|
651
|
+
return { name: safe, rel, size: buf.length, mime, kind: attachKind(mime, safe, buf) };
|
|
652
|
+
}
|
|
653
|
+
// 原始二进制请求体(上传用):声明长度超限直接拒;流式超限立即断开,不等整体读完
|
|
654
|
+
function rawBody(req, max = ATTACH_MAX_BYTES) {
|
|
655
|
+
return new Promise((resolve, reject) => {
|
|
656
|
+
const tooBig = () => Object.assign(new Error(`附件超过上限 ${Math.round(max / 1048576)} MB`), { status: 413 });
|
|
657
|
+
const declared = +req.headers['content-length'];
|
|
658
|
+
if (Number.isFinite(declared) && declared > max) return reject(tooBig());
|
|
659
|
+
const chunks = []; let size = 0, done = false;
|
|
660
|
+
req.on('data', (c) => { if (done) return; size += c.length; if (size > max) { done = true; req.destroy(); return reject(tooBig()); } chunks.push(c); });
|
|
661
|
+
req.on('end', () => { if (!done) { done = true; resolve(Buffer.concat(chunks)); } });
|
|
662
|
+
req.on('error', (e) => { if (!done) { done = true; reject(e); } });
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
// 把附件编进用户消息:图片在视觉可用时作为 ImageContent 随消息提供;文本类内联/预览;其余只给路径。
|
|
666
|
+
// 按 rel 从工作区重读(不信任客户端带回的内容)。无附件时 content 仍是纯字符串——文本对话路径与之前完全一致
|
|
667
|
+
function composeUserMessage(ws, message, rels) {
|
|
668
|
+
const text = String(message || '');
|
|
669
|
+
const list = [];
|
|
670
|
+
for (const r of (Array.isArray(rels) ? rels : []).slice(0, 10)) {
|
|
671
|
+
const rel = String((r && typeof r === 'object' ? r.rel : r) || '');
|
|
672
|
+
const abs = attachAbs(ws, rel);
|
|
673
|
+
if (!abs || !fs.existsSync(abs)) continue;
|
|
674
|
+
const buf = fs.readFileSync(abs), name = path.basename(abs).replace(/^\d{6}-[0-9a-f]{4}-/, ''), mime = attachMime('', abs);
|
|
675
|
+
list.push({ rel, name, size: buf.length, mime, kind: attachKind(mime, name, buf), buf });
|
|
676
|
+
}
|
|
677
|
+
if (!list.length) return { content: text, attachments: [], userText: text };
|
|
678
|
+
const vision = llmVision();
|
|
679
|
+
const images = [];
|
|
680
|
+
const lines = [`【附件 ${list.length} 个 · 已保存到当前工作区(exec 的 cwd),相对路径如下】`];
|
|
681
|
+
list.forEach((a, i) => {
|
|
682
|
+
const head = `${i + 1}. ${a.name}(${a.kind === 'image' ? '图片' : a.kind === 'text' ? '文本' : a.mime},${fmtBytes(a.size)})→ ${a.rel}`;
|
|
683
|
+
if (a.kind === 'image') {
|
|
684
|
+
if (vision && images.length < ATTACH_IMAGES_PER_MSG) { images.push({ type: 'image', data: a.buf.toString('base64'), mimeType: a.mime }); lines.push(`${head}(图片已随本消息提供给你查看)`); }
|
|
685
|
+
else lines.push(`${head}(${vision ? '超出单条消息图片上限,' : '当前模型不支持看图,'}仅作为文件保存,请结合用户描述与文件名处理)`);
|
|
686
|
+
} else if (a.kind === 'text') {
|
|
687
|
+
const s = a.buf.toString('utf8');
|
|
688
|
+
if (a.size <= ATTACH_INLINE_MAX) lines.push(`${head}\n\`\`\`\n${s}\n\`\`\``);
|
|
689
|
+
else lines.push(`${head}\n前 ${fmtBytes(ATTACH_HEAD)} 预览:\n\`\`\`\n${s.slice(0, ATTACH_HEAD)}\n\`\`\`\n(文件较大,完整内容请用 exec 读取,如 tail -n 200 "${a.rel}" / grep -n "ERROR" "${a.rel}")`);
|
|
690
|
+
} else lines.push(head);
|
|
691
|
+
});
|
|
692
|
+
const full = [text, lines.join('\n')].filter(Boolean).join('\n\n');
|
|
693
|
+
const attachments = list.map(({ rel, name, size, mime, kind }) => ({ rel, name, size, mime, kind }));
|
|
694
|
+
return { content: images.length ? [{ type: 'text', text: full }, ...images] : full, attachments, userText: text };
|
|
695
|
+
}
|
|
696
|
+
// 图片 base64 随每轮请求整体重发:只保留最近 keep 条带图用户消息的图片块,更早的替换为文字占位(文件仍在工作区,助手可按路径再看)
|
|
697
|
+
function pruneOldImages(ctx, keep = 3) {
|
|
698
|
+
let seen = 0;
|
|
699
|
+
for (let i = ctx.messages.length - 1; i >= 0; i--) {
|
|
700
|
+
const m = ctx.messages[i];
|
|
701
|
+
if (m.role !== 'user' || !Array.isArray(m.content) || !m.content.some((b) => b.type === 'image')) continue;
|
|
702
|
+
if (++seen <= keep) continue;
|
|
703
|
+
const imgs = (m.attachments || []).filter((a) => a.kind === 'image');
|
|
704
|
+
let k = 0;
|
|
705
|
+
m.content = m.content.map((b) => b.type !== 'image' ? b : { type: 'text', text: `(图片 ${imgs[k]?.name || ''} 已在前文提供,此处省略;文件:${imgs[k++]?.rel || ATTACH_DIR + '/'})` });
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
548
709
|
// ── 通用计算原语与平台数据服务(pi-core 哲学:少量原语 + 组合,而非功能堆叠)──
|
|
549
710
|
const { exec: cpExec } = require('node:child_process');
|
|
711
|
+
// ── exec 用 shell 跨平台解析:/bin/zsh 在 Windows 与无 zsh 的 Linux 上不存在,硬编码会让所有命令 spawn ENOENT ──
|
|
712
|
+
function resolveShell() {
|
|
713
|
+
const exists = (p) => !!p && fs.existsSync(p);
|
|
714
|
+
if (exists(process.env.AIMS_SHELL)) return process.env.AIMS_SHELL; // 显式指定优先(如 Windows 想用 pwsh / 自定义 bash 路径)
|
|
715
|
+
if (process.platform === 'win32') {
|
|
716
|
+
if (exists(process.env.SHELL)) return process.env.SHELL; // Git Bash 内启动时 SHELL=/usr/bin/bash 是 MSYS 伪路径,存在性校验会把它过滤掉
|
|
717
|
+
// 只认 Git for Windows 安装位置,不探测 PATH:System32 的 bash.exe 是 WSL,cwd 为 Windows 路径时行为错乱
|
|
718
|
+
const gitBash = [process.env.ProgramFiles, process.env['ProgramFiles(x86)'], path.join(os.homedir(), 'AppData', 'Local', 'Programs')]
|
|
719
|
+
.filter(Boolean).map((base) => path.join(base, 'Git', 'bin', 'bash.exe')).find(exists);
|
|
720
|
+
return gitBash || process.env.ComSpec || 'cmd.exe'; // cmd.exe 由 Node 识别后以 /d /s /c 调用
|
|
721
|
+
}
|
|
722
|
+
return [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].find(exists) || '/bin/sh';
|
|
723
|
+
}
|
|
724
|
+
const SHELL = resolveShell();
|
|
725
|
+
const SHELL_HINT = process.platform === 'win32'
|
|
726
|
+
? (/bash\.exe$/i.test(SHELL) ? 'Windows · Git Bash(POSIX 语法可用)' : `Windows · ${path.basename(SHELL)}(用 Windows 命令与语法:dir/type/findstr,路径用反斜杠)`)
|
|
727
|
+
: `${process.platform} · ${path.basename(SHELL)}`;
|
|
728
|
+
let EXEC_WIN = WIN; // 命令语法判定走 Windows(cmd/PowerShell)分支;模块级可变仅为 --test-guard 在任意平台覆盖 Windows 用例
|
|
550
729
|
function classifyExec(command, autonomy) {
|
|
551
730
|
const cmd = String(command || '').trim();
|
|
552
731
|
const g = guardNow();
|
|
732
|
+
const win = EXEC_WIN;
|
|
553
733
|
// sudo 降权特例(可配置):开启后剥掉 `sudo -u <用户>` 片段再匹配危险清单(嵌套的裸 sudo/毁灭性命令仍会命中),剩余部分按普通命令分级
|
|
554
734
|
const denyCmd = g.sudo_u_l2 ? cmd.replace(/\bsudo\s+(?:-\w+\s+)*-u\s+\S+/g, '') : cmd;
|
|
555
735
|
const denyRes = [];
|
|
556
|
-
for (const src of g.deny) { try { denyRes.push(new RegExp(src)); } catch { /* 坏正则跳过(PUT 时已校验,历史数据兜底) */ } }
|
|
736
|
+
for (const src of g.deny) { try { denyRes.push(new RegExp(src, win ? 'i' : '')); } catch { /* 坏正则跳过(PUT 时已校验,历史数据兜底) */ } } // win32 忽略大小写:cmd/PowerShell 命令名不区分大小写(Stop-Computer / FORMAT C:);POSIX 保持原样
|
|
557
737
|
if (denyRes.some((re) => re.test(denyCmd))) return { status: 'blocked', reason: '【守护栏】命令命中危险清单(清单可在 系统设置·守护栏 维护)' };
|
|
558
738
|
const EXEC_RO = g.ro;
|
|
559
739
|
const noRedirect = cmd.replace(/2>&1/g, ''); // 重定向/tee 使白名单命令具备写能力,不再视为只读
|
|
560
740
|
const SQL_WRITE = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|REPLACE)\b/i;
|
|
561
|
-
// 复合命令逐段判定:每个由 ; && || |
|
|
741
|
+
// 复合命令逐段判定:每个由 ; && || | 换行(win32 再加 cmd 的无条件分隔符 &)分隔的段都须只读(控制关键字与 for 头部放行)
|
|
562
742
|
const segOk = (seg) => {
|
|
563
|
-
let s = seg.trim().replace(/^(do|then|else|done|fi|esac|\{|\})\s+/, '');
|
|
743
|
+
let s = seg.trim().replace(win ? /^((do|then|else|elseif|done|fi|esac|try|catch|finally|foreach|switch|function|\{|\})\s+)/ : /^(do|then|else|done|fi|esac|\{|\})\s+/, '');
|
|
564
744
|
if (/^[A-Za-z_]\w*\s+in\b/.test(s)) return true; // for 循环头部(变量 in 值列表)
|
|
565
|
-
if (/^(for|while|until|if|case)\b
|
|
566
|
-
|
|
567
|
-
|
|
745
|
+
if ((win ? /^(for|foreach|while|until|if|case|switch|try|function)\b/ : /^(for|while|until|if|case)\b/).test(s)) return true; // 控制关键字本身
|
|
746
|
+
let first = s.split(/\s+/)[0].split(win ? /[\/\\]/ : '/').pop();
|
|
747
|
+
if (win) first = first.replace(/\.(exe|com|bat|cmd|ps1)$/i, ''); // C:\Windows\System32\ipconfig.exe → ipconfig
|
|
748
|
+
const name = win ? first.toLowerCase() : first; // cmd/PowerShell 命令名不区分大小写(Get-Process / get-process)
|
|
749
|
+
const hit = win ? EXEC_RO.some((x) => x.toLowerCase() === name) : EXEC_RO.includes(first);
|
|
750
|
+
return hit && !(name === 'sqlite3' && SQL_WRITE.test(s));
|
|
568
751
|
};
|
|
569
|
-
const segments = cmd.
|
|
570
|
-
|
|
752
|
+
const segments = (win ? cmd.replace(/\d\s*>&\s*\d/g, '') : cmd) // win32:先剥 2>&1 类 fd 复制,& 才能安全作分隔符
|
|
753
|
+
.split(win ? /;|&&|\|\||\|\n?|\n|&/ : /;|&&|\|\||\|\n?|\n/).map((s) => s.trim()).filter(Boolean);
|
|
754
|
+
const ro = segments.length > 0 && segments.every(segOk) && !/(>>|>)/.test(noRedirect) && !(win ? /\b(tee|tee-object)\b/i : /\btee\b/).test(noRedirect);
|
|
571
755
|
if (ro) return { status: 'ok' }; // 只读白名单:任何级别免确认(取证不应有摩擦)
|
|
572
756
|
if (autonomy >= 2) return { status: 'pending' }; // 写命令:L2 可发起,执行前需人工确认
|
|
573
757
|
return { status: 'blocked', reason: `【守护栏】L${autonomy} 仅放行只读命令(${EXEC_RO.slice(0, 8).join('/')}等,完整清单见 系统设置·守护栏)。写操作需 L2 并人工确认` };
|
|
@@ -575,7 +759,7 @@ function classifyExec(command, autonomy) {
|
|
|
575
759
|
const PRIMITIVE_TOOLS = {
|
|
576
760
|
exec: {
|
|
577
761
|
min_level: 0, confirm_required: false, // 确认由 classifyExec 按命令动态判定
|
|
578
|
-
desc:
|
|
762
|
+
desc: `通用终端命令执行。工作目录(cwd)= 当前工作区;连通性实测用 nc -vz -w 3 host port;平台日志用绝对路径 tail(见系统提示)。只读命令直接执行;写命令需 L2+人工确认;危险命令永久拒绝。不要期待专用工具,优先用命令组合能力。当前 shell:${SHELL_HINT}`,
|
|
579
763
|
schema: { command: { type: 'string', description: '要执行的 shell 命令' }, timeout_ms: { type: 'number', description: '超时毫秒,默认 15000' } },
|
|
580
764
|
required: ['command'],
|
|
581
765
|
run: async ({ command, timeout_ms = 15000 }, agentId, _wsId, signal) => {
|
|
@@ -583,7 +767,7 @@ const PRIMITIVE_TOOLS = {
|
|
|
583
767
|
delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT; // 凭据不进子进程
|
|
584
768
|
const ws = agentId ? currentWs(agentId) : null; // 文件领地锚定工作区(07 设计 §4.3)
|
|
585
769
|
return await new Promise((resolve) => {
|
|
586
|
-
const child = cpExec(command, { cwd: ws ? ws.path : ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell:
|
|
770
|
+
const child = cpExec(command, { cwd: ws ? ws.path : ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell: SHELL }, (err, stdout, stderr) => {
|
|
587
771
|
const out = String(stdout || '').slice(0, 4000);
|
|
588
772
|
const errOut = String(stderr || '').slice(0, 1500);
|
|
589
773
|
audit({ actor: 'assistant', tool: 'exec', command: String(command).slice(0, 200), ok: !err, ws: ws?.id });
|
|
@@ -671,7 +855,7 @@ const TOOLS = {
|
|
|
671
855
|
let r;
|
|
672
856
|
try { r = await armCreateKnowledge(args); }
|
|
673
857
|
catch (e) { // 离线:入 outbox 缓写,恢复连接后经「检查更新」自动回流
|
|
674
|
-
fs.appendFileSync(OUTBOX_FILE, JSON.stringify({ at: new Date().toISOString(), agentId, args }) + '\n');
|
|
858
|
+
fs.appendFileSync(OUTBOX_FILE, JSON.stringify({ at: new Date().toISOString(), agentId, args, source_id: armCurrent().id }) + '\n'); // 记账到当时的源,冲账只回这个源
|
|
675
859
|
audit({ actor: 'experience-reflow', target: 'outbox', title });
|
|
676
860
|
return `ARM 不可达(${String(e.message).slice(0, 60)}),经验已存本地待回流队列;恢复连接后在助手大厅点「检查更新」将自动回流。`;
|
|
677
861
|
}
|
|
@@ -1341,7 +1525,7 @@ const DEV_TOOLS = {
|
|
|
1341
1525
|
},
|
|
1342
1526
|
},
|
|
1343
1527
|
exec: {
|
|
1344
|
-
desc:
|
|
1528
|
+
desc: `终端命令执行(开发档):读白名单直接放行;写文件/跑脚本/构建调试等开发动作放行(这是你的本职);仅毁灭性命令(sudo/rm -rf //mkfs 等)被永久拒绝。用于探索 AGENT.md、写临时代码、运行测试、验证接口。全部调用入审计。当前 shell:${SHELL_HINT}`,
|
|
1345
1529
|
schema: { command: { type: 'string', description: '只读命令,如 grep -rl "Nexus" <目录>/*/AGENT.md' }, timeout_ms: { type: 'number' } },
|
|
1346
1530
|
required: ['command'],
|
|
1347
1531
|
run: async ({ command, timeout_ms = 30000 }) => {
|
|
@@ -1351,7 +1535,7 @@ const DEV_TOOLS = {
|
|
|
1351
1535
|
const env = { ...process.env };
|
|
1352
1536
|
delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT;
|
|
1353
1537
|
return await new Promise((resolve) => {
|
|
1354
|
-
cpExec(command, { cwd: ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell:
|
|
1538
|
+
cpExec(command, { cwd: ROOT, timeout: +timeout_ms, maxBuffer: 1024 * 512, env, shell: SHELL }, (err, stdout, stderr) => {
|
|
1355
1539
|
audit({ actor: 'dev-agent', tool: 'exec', command: String(command).slice(0, 200), ok: !err });
|
|
1356
1540
|
resolve(`$ ${command}\n${String(stdout || '').slice(0, 5000)}${stderr ? '\n[stderr] ' + String(stderr).slice(0, 1000) : ''}${err && !stdout ? '\n[错误] ' + err.message : ''}`);
|
|
1357
1541
|
});
|
|
@@ -1397,7 +1581,9 @@ const ctxCharBudget = () => Math.max(4_000, Math.floor((llmContextTokens() - 16_
|
|
|
1397
1581
|
// 超预算从最旧轮次整轮丢弃;下刀点只选 user 消息边界,剩余前缀不会出现孤儿 toolResult(转换层要求其紧跟 assistant 的 toolCall);当前轮永不裁。
|
|
1398
1582
|
function trimContext(ctx, budget = ctxCharBudget()) {
|
|
1399
1583
|
const msgs = ctx.messages;
|
|
1400
|
-
|
|
1584
|
+
// 图片块按固定 4000 字符估算(约 2.5K token):按 base64 长度算会让一张图顶掉整段历史
|
|
1585
|
+
const sizeOf = (c) => Array.isArray(c) ? c.reduce((n, b) => n + (b.type === 'image' ? 4000 : JSON.stringify(b).length), 0) : JSON.stringify(c ?? '').length;
|
|
1586
|
+
const size = () => msgs.reduce((n, m) => n + sizeOf(m.content), 0);
|
|
1401
1587
|
if (size() <= budget) return 0;
|
|
1402
1588
|
const lastUser = msgs.length - 1 - [...msgs].reverse().findIndex((m) => m.role === 'user');
|
|
1403
1589
|
let dropped = 0;
|
|
@@ -1421,7 +1607,7 @@ function devSystemPrompt() {
|
|
|
1421
1607
|
localDir ? `助手本地档案(平台下载物化): ${localDir} —— manifest.json 含技能/知识绑定清单,knowledges/ 为知识全文` : '',
|
|
1422
1608
|
`ARM 工作站助手目录(若配置): ${process.env.ARM_WS_ROOT || '未配置'} —— 每个从 ARM 下载的助手有独立文件夹,AGENT.md 描述其定位与真实能力(含可用脚本/服务器/接口线索)。按名定位:grep -rl "助手名" <目录>/*/AGENT.md`,
|
|
1423
1609
|
'',
|
|
1424
|
-
'你是 AIMS
|
|
1610
|
+
'你是 AIMS 智能体平台的「工作台工程师」,通过编写面板插件帮用户对话式定制工作台。',
|
|
1425
1611
|
'',
|
|
1426
1612
|
'【面板插件契约(扩展点)】',
|
|
1427
1613
|
'插件是一段 JS 代码,必须定义:function Panel(props) { return html`...` }',
|
|
@@ -1540,10 +1726,10 @@ async function devChatLoop(session, emit, signal = null) {
|
|
|
1540
1726
|
const sessions = new Map(); // sid -> { agentId, autonomy, context, pending }
|
|
1541
1727
|
|
|
1542
1728
|
function systemPrompt(armAgent, deployment, autonomy, ws) {
|
|
1543
|
-
//
|
|
1729
|
+
// 场景画像按 knowledge_slices 条件注入(02 设计:切片授权=上下文工程+权限治理);
|
|
1544
1730
|
// 未声明画像切片的助手(现阶段的绝大多数)只有自己的档案 prompt——不注入硬编码场景(2026-10-15 MVP 打磨)
|
|
1545
1731
|
const slices = (deployment.knowledge_slices || []).map((s) => String(s));
|
|
1546
|
-
const hasProfile = slices.some((s) => s.includes('
|
|
1732
|
+
const hasProfile = slices.some((s) => s.includes('场景画像'));
|
|
1547
1733
|
const scene = sceneProfile();
|
|
1548
1734
|
return [
|
|
1549
1735
|
armAgent.prompt || '你是运维助手。',
|
|
@@ -1555,7 +1741,7 @@ function systemPrompt(armAgent, deployment, autonomy, ws) {
|
|
|
1555
1741
|
'【自主级别策略】',
|
|
1556
1742
|
`当前级别 ${LEVEL_NAME[autonomy]}。L0=只读问答;L1=诊断建议+可沉淀经验;L2=可发起需人工确认的执行操作。`,
|
|
1557
1743
|
'执行类工具发起后会挂起等待用户在界面确认,确认后才真正执行。',
|
|
1558
|
-
...(hasProfile && world.devices.length ? ['', '
|
|
1744
|
+
...(hasProfile && world.devices.length ? ['', '【场景画像(知识切片授权)】', `设备: ${world.devices.map(d => `${d.id}(${d.name},${d.type})`).join(';')}`, '高危操作必须人工确认'] : []), // 真实画像数据接入前(world 为空)诚实跳过
|
|
1559
1745
|
'',
|
|
1560
1746
|
'【能力哲学:少量原语 + 组合,而非功能堆叠】',
|
|
1561
1747
|
'你拥有通用终端 exec 和平台数据服务(data_describe/data_query/data_write)。不要期待每个功能都有专用工具——先用原语组合:',
|
|
@@ -1727,11 +1913,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1727
1913
|
if (req.method === 'GET' && pathname === '/api/settings') {
|
|
1728
1914
|
const t = armApiToken();
|
|
1729
1915
|
const k = llmApiKey();
|
|
1916
|
+
const cur = armCurrent();
|
|
1730
1917
|
return json(res, 200, {
|
|
1731
|
-
arm: {
|
|
1732
|
-
api_base: armApiBase(), api_base_runtime: !!
|
|
1918
|
+
arm: { // 兼容字段描述的是当前源;多源列表与管理走 /api/arm/sources
|
|
1919
|
+
api_base: armApiBase(), api_base_runtime: !!cur.base, default_base: ARM_DEFAULT_BASE,
|
|
1733
1920
|
token_set: !!t, token_masked: t ? `${t.slice(0, 10)}…${t.slice(-4)}` : '',
|
|
1734
|
-
token_source:
|
|
1921
|
+
token_source: cur.token ? 'runtime' : (cur.id === SRC_DEFAULT_ID ? (env.ARM_API_TOKEN ? 'env' : (ARM_PAT ? 'pat' : 'none')) : 'none'),
|
|
1922
|
+
current: cur.id, current_name: cur.name, sources: armSources().map(srcView),
|
|
1735
1923
|
},
|
|
1736
1924
|
llm: {
|
|
1737
1925
|
base_url: llmBaseUrl(), base_url_runtime: !!cfg.llm_base_url, default_base: LLM_DEFAULT_BASE,
|
|
@@ -1739,14 +1927,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
1739
1927
|
key_set: !!k, key_masked: k ? `${k.slice(0, 6)}…${k.slice(-4)}` : '',
|
|
1740
1928
|
key_source: cfg.llm_api_key ? 'runtime' : (env.LLM_API_KEY ? 'env' : 'none'),
|
|
1741
1929
|
context_tokens: llmContextTokens(), context_tokens_runtime: !!cfg.llm_context_tokens, default_context_tokens: LLM_DEFAULT_CONTEXT_TOKENS,
|
|
1930
|
+
vision: llmVision(), vision_mode: cfg.llm_vision || 'auto', vision_auto: !!(model && Array.isArray(model.input) && model.input.includes('image')),
|
|
1742
1931
|
},
|
|
1743
1932
|
scene: { profile: sceneProfile(), length: sceneProfile().length },
|
|
1744
1933
|
auth: { enabled: !!accessToken(), token_masked: accessToken() ? `${accessToken().slice(0, 4)}…${accessToken().slice(-2)}` : '' },
|
|
1745
|
-
guard: { ...guardNow(), default_deny:
|
|
1934
|
+
guard: { ...guardNow(), default_deny: guardDefaults().deny, default_ro: guardDefaults().ro },
|
|
1746
1935
|
});
|
|
1747
1936
|
}
|
|
1748
1937
|
if (req.method === 'PUT' && pathname === '/api/settings') {
|
|
1749
|
-
const { arm_api_base, arm_token, llm_base_url, llm_api_key, llm_model, llm_context_tokens, scene_profile, access_token, guard } = await body(req);
|
|
1938
|
+
const { arm_api_base, arm_token, llm_base_url, llm_api_key, llm_model, llm_context_tokens, llm_vision, scene_profile, access_token, guard } = await body(req);
|
|
1750
1939
|
// 守护栏维护:deny 正则可编译 / ro 须为单命令名;保存即热生效(classifyExec 每次调用现读配置);删减危险规则记入审计
|
|
1751
1940
|
let guardAudit = null;
|
|
1752
1941
|
if (guard !== undefined) {
|
|
@@ -1772,12 +1961,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1772
1961
|
sceneChanged = sp !== sceneProfile();
|
|
1773
1962
|
cfg.scene_profile = sp;
|
|
1774
1963
|
}
|
|
1775
|
-
if (arm_api_base !== undefined) {
|
|
1964
|
+
if (arm_api_base !== undefined) { // 兼容旧字段:改的是当前源(多源增删改走 /api/arm/sources)
|
|
1776
1965
|
const u = normUrl(arm_api_base);
|
|
1777
1966
|
if (u && !/^https?:\/\/[^\s/]+/.test(u)) return json(res, 400, { error: `站点地址须为 http(s) URL:${u}` });
|
|
1778
|
-
|
|
1967
|
+
if (!u && armCurrent().id !== SRC_DEFAULT_ID) return json(res, 400, { error: '非默认源地址不能为空(默认源留空=回落 env/内置默认)' });
|
|
1968
|
+
armCurrent().base = u;
|
|
1779
1969
|
}
|
|
1780
|
-
if (arm_token !== undefined)
|
|
1970
|
+
if (arm_token !== undefined) armCurrent().token = String(arm_token || '').trim(); // 置空:默认源回落 env/PAT,其他源转匿名
|
|
1781
1971
|
if (llm_base_url !== undefined) {
|
|
1782
1972
|
const u = normUrl(llm_base_url);
|
|
1783
1973
|
if (u && !/^https?:\/\/[^\s/]+/.test(u)) return json(res, 400, { error: `LLM 端点须为 http(s) URL:${u}` });
|
|
@@ -1798,6 +1988,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1798
1988
|
}
|
|
1799
1989
|
}
|
|
1800
1990
|
if (llm_api_key !== undefined) cfg.llm_api_key = String(llm_api_key || '').trim();
|
|
1991
|
+
if (llm_vision !== undefined) { // 视觉能力:auto=按模型条目 input 判定;on/off 强制(自定义模型名能力未知时按实际选)。不影响 provider,无需热切换
|
|
1992
|
+
const v = String(llm_vision || '').trim();
|
|
1993
|
+
if (v && !['auto', 'on', 'off'].includes(v)) return json(res, 400, { error: `视觉能力须为 auto / on / off:${v}` });
|
|
1994
|
+
cfg.llm_vision = v === 'auto' ? '' : v;
|
|
1995
|
+
}
|
|
1801
1996
|
// LLM 热切换:先应用验证,失败整体回滚(应用只是构建对象,无网络请求)
|
|
1802
1997
|
let llm_probe = null;
|
|
1803
1998
|
if ([llm_base_url, llm_model, llm_api_key].some((v) => v !== undefined)) {
|
|
@@ -1811,7 +2006,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1811
2006
|
try { llm_probe = await llmProbe(); } catch (e) { llm_probe = { online: false, error: String(e.message).slice(0, 150) }; }
|
|
1812
2007
|
}
|
|
1813
2008
|
saveCfg();
|
|
1814
|
-
audit({ actor: 'settings', action: 'save',
|
|
2009
|
+
audit({ actor: 'settings', action: 'save', arm_source: armCurrent().id, arm_api_base: armApiBase(), llm_model: cfg.llm_model, llm_context_tokens: cfg.llm_context_tokens, llm_vision: cfg.llm_vision || 'auto', llm_base_changed: llm_base_url !== undefined, token_changed: arm_token !== undefined || llm_api_key !== undefined, scene_profile_changed: sceneChanged, scene_profile_len: sceneProfile().length, auth_changed: authChanged, guard_changed: guardAudit });
|
|
1815
2010
|
// ARM 保存后即时探测(失败不回滚:允许先配置后启动 ARM)
|
|
1816
2011
|
let probe = { online: false, error: '' };
|
|
1817
2012
|
if ([arm_api_base, arm_token].some((v) => v !== undefined)) {
|
|
@@ -1826,6 +2021,59 @@ const server = http.createServer(async (req, res) => {
|
|
|
1826
2021
|
if (llm_probe && !llm_probe.online) notes.push('LLM 已保存但探针未通过(密钥/端点/模型可用性待确认,对话可能失败,可再试或改回)');
|
|
1827
2022
|
return json(res, 200, { saved: true, probe, llm_probe, note: notes.join(';') });
|
|
1828
2023
|
}
|
|
2024
|
+
// ── 多 ARM 源:列表 / 增删改 / 切换 / 探测(公库与私库并存;切换后目录/下载/发布/回流全链路即时跟随当前源)──
|
|
2025
|
+
const probeSource = async (s) => { const t0 = Date.now(); try { await armRest('/api/v1/agents?page=1&pageSize=1', {}, 6000, s); return { online: true, latency_ms: Date.now() - t0 }; } catch (e) { return { online: false, error: String(e.message).slice(0, 160) }; } };
|
|
2026
|
+
if (pathname === '/api/arm/sources' && req.method === 'GET') return json(res, 200, { current: armCurrent().id, sources: armSources().map(srcView) });
|
|
2027
|
+
if (pathname === '/api/arm/sources' && req.method === 'POST') {
|
|
2028
|
+
const { name, base, token } = await body(req);
|
|
2029
|
+
const u = String(base || '').trim().replace(/\/+$/, '');
|
|
2030
|
+
if (!/^https?:\/\/[^\s/]+/.test(u)) return json(res, 400, { error: 'ARM 源地址须为 http(s) URL,如 https://arm.customer.com' });
|
|
2031
|
+
const nm = String(name || '').trim().slice(0, 40) || u.replace(/^https?:\/\//, '');
|
|
2032
|
+
if (armSources().some((s) => s.name === nm)) return json(res, 400, { error: `源名称已存在:${nm}` });
|
|
2033
|
+
const s = { id: `src_${crypto.randomUUID().slice(0, 4)}`, name: nm, base: u, token: String(token || '').trim() };
|
|
2034
|
+
cfg.arm_sources.push(s); saveCfg();
|
|
2035
|
+
audit({ actor: 'settings', action: 'arm-source-add', source: s.id, name: nm, base: u, token_set: !!s.token });
|
|
2036
|
+
return json(res, 200, { source: srcView(s), sources: armSources().map(srcView), current: armCurrent().id, probe: await probeSource(s) });
|
|
2037
|
+
}
|
|
2038
|
+
const mSrc = pathname.match(/^\/api\/arm\/sources\/([\w-]{1,32})(?:\/(select|probe))?$/);
|
|
2039
|
+
if (mSrc) {
|
|
2040
|
+
const s = armSource(mSrc[1]);
|
|
2041
|
+
if (!s) return json(res, 404, { error: `ARM 源不存在:${mSrc[1]}` });
|
|
2042
|
+
if (req.method === 'PUT' && !mSrc[2]) {
|
|
2043
|
+
const { name, base, token } = await body(req);
|
|
2044
|
+
if (name !== undefined) {
|
|
2045
|
+
const nm = String(name || '').trim().slice(0, 40);
|
|
2046
|
+
if (!nm) return json(res, 400, { error: '源名称不能为空' });
|
|
2047
|
+
if (armSources().some((x) => x.id !== s.id && x.name === nm)) return json(res, 400, { error: `源名称已存在:${nm}` });
|
|
2048
|
+
s.name = nm;
|
|
2049
|
+
}
|
|
2050
|
+
if (base !== undefined) {
|
|
2051
|
+
const u = String(base || '').trim().replace(/\/+$/, '');
|
|
2052
|
+
if (u && !/^https?:\/\/[^\s/]+/.test(u)) return json(res, 400, { error: `ARM 源地址须为 http(s) URL:${u}` });
|
|
2053
|
+
if (!u && s.id !== SRC_DEFAULT_ID) return json(res, 400, { error: '非默认源地址不能为空(默认源留空=回落 env/内置默认)' });
|
|
2054
|
+
s.base = u;
|
|
2055
|
+
}
|
|
2056
|
+
if (token !== undefined) s.token = String(token || '').trim(); // 置空:默认源回落 env/PAT,其他源转匿名
|
|
2057
|
+
if (s.id === armCurrent().id) agentCache.clear();
|
|
2058
|
+
saveCfg(); audit({ actor: 'settings', action: 'arm-source-update', source: s.id, fields: ['name', 'base', 'token'].filter((k) => ({ name, base, token })[k] !== undefined) });
|
|
2059
|
+
return json(res, 200, { source: srcView(s), sources: armSources().map(srcView), current: armCurrent().id });
|
|
2060
|
+
}
|
|
2061
|
+
if (req.method === 'DELETE' && !mSrc[2]) {
|
|
2062
|
+
if (s.id === SRC_DEFAULT_ID) return json(res, 400, { error: '默认 ARM 源不可删除(承载 env/PAT 回落);可改名或改地址' });
|
|
2063
|
+
cfg.arm_sources = cfg.arm_sources.filter((x) => x.id !== s.id);
|
|
2064
|
+
if (cfg.arm_current === s.id) { cfg.arm_current = SRC_DEFAULT_ID; agentCache.clear(); } // 删的是当前源 → 回到默认源
|
|
2065
|
+
saveCfg(); audit({ actor: 'settings', action: 'arm-source-delete', source: s.id, name: s.name });
|
|
2066
|
+
return json(res, 200, { sources: armSources().map(srcView), current: armCurrent().id });
|
|
2067
|
+
}
|
|
2068
|
+
if (req.method === 'POST' && mSrc[2] === 'select') {
|
|
2069
|
+
const prev = armCurrent().id;
|
|
2070
|
+
cfg.arm_current = s.id; agentCache.clear(); saveCfg(); // 远端详情缓存按 agentId 键控,跨源同 id 内容可能不同
|
|
2071
|
+
audit({ actor: 'settings', action: 'arm-source-select', from: prev, to: s.id, name: s.name });
|
|
2072
|
+
return json(res, 200, { current: s.id, source: srcView(s), probe: await probeSource(s) });
|
|
2073
|
+
}
|
|
2074
|
+
if (req.method === 'POST' && mSrc[2] === 'probe') return json(res, 200, await probeSource(s)); // 探测指定源,不切换
|
|
2075
|
+
return json(res, 405, { error: 'method not allowed' });
|
|
2076
|
+
}
|
|
1829
2077
|
if (req.method === 'GET' && pathname === '/api/arm/status') {
|
|
1830
2078
|
let online = false, latency_ms = null;
|
|
1831
2079
|
const t0 = Date.now();
|
|
@@ -1834,7 +2082,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1834
2082
|
online = true; latency_ms = Date.now() - t0;
|
|
1835
2083
|
} catch { }
|
|
1836
2084
|
const reg = loadRegistry();
|
|
1837
|
-
|
|
2085
|
+
const cur = armCurrent();
|
|
2086
|
+
return json(res, 200, { online, latency_ms, api: armApiBase(), pat: !!ARM_PAT, source: { id: cur.id, name: cur.name }, sources: armSources().map((s) => ({ id: s.id, name: s.name })), last_sync_at: reg.last_sync_at || null, local_count: reg.agents.length, outbox_pending: outboxCount() });
|
|
1838
2087
|
}
|
|
1839
2088
|
if (req.method === 'POST' && pathname === '/api/arm/sync') {
|
|
1840
2089
|
try { return json(res, 200, await armSync()); }
|
|
@@ -1845,6 +2094,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1845
2094
|
if (req.method === 'GET' && pathname === '/api/agents') {
|
|
1846
2095
|
const kw = (url.searchParams.get('keyword') || '').trim().toLowerCase();
|
|
1847
2096
|
const reg = loadRegistry();
|
|
2097
|
+
const src = armCurrent();
|
|
1848
2098
|
let online = true, remoteCards = [], authError = false;
|
|
1849
2099
|
try { remoteCards = await armListAgents(url.searchParams.get('keyword') || ''); } // REST 全量
|
|
1850
2100
|
catch (e) { online = false; authError = /令牌未配置或无权限/.test(String(e.message)); } // 区分"未授权"与"不可达",前端引导配置令牌
|
|
@@ -1853,17 +2103,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
1853
2103
|
const lm = localManifest(e.agent_id) || {};
|
|
1854
2104
|
// 搜索时本地库同样按关键词过滤(名称/描述),与远端搜索结果口径一致
|
|
1855
2105
|
if (kw && !(String(e.name || '').toLowerCase().includes(kw) || String(e.description || lm.description || '').toLowerCase().includes(kw))) continue;
|
|
2106
|
+
// 来自其他源的助手:当前源目录里没有它是正常的——不拿当前源的远端版本/下架状态去评判它(foreign 卡片只留「发布到当前源」)
|
|
2107
|
+
const belongs = entryBelongs(e, src);
|
|
2108
|
+
const sid = e.local_only ? null : (e.source_id || SRC_DEFAULT_ID);
|
|
1856
2109
|
byId.set(e.agent_id, {
|
|
1857
2110
|
created_via_workstation: !!e.created_via_workstation, // 工作站新建的助手:大厅提供编辑档案/从 ARM 删除
|
|
1858
2111
|
agent_id: e.agent_id, name: e.name, version: e.installed_version,
|
|
1859
2112
|
skills: (lm.skills || []).length, knowledges: (lm.knowledges || []).length,
|
|
1860
2113
|
description: e.description || lm.description || '',
|
|
1861
2114
|
deployed: !!loadDeployment(e.agent_id), installed: true,
|
|
1862
|
-
installed_version: e.installed_version, remote_version: e.remote_version || null,
|
|
1863
|
-
update_available: !!(e.remote_version && cmpVersion(e.remote_version, e.installed_version) > 0),
|
|
1864
|
-
delisted: !!e.delisted, downloaded_at: e.downloaded_at || null,
|
|
2115
|
+
installed_version: e.installed_version, remote_version: belongs ? (e.remote_version || null) : null,
|
|
2116
|
+
update_available: belongs && !!(e.remote_version && cmpVersion(e.remote_version, e.installed_version) > 0),
|
|
2117
|
+
delisted: belongs && !!e.delisted, downloaded_at: e.downloaded_at || null,
|
|
1865
2118
|
created_by_id: null, created_by: null, // 属主身份以远端目录为准(离线/已下架不可编辑,无需属主)
|
|
1866
2119
|
local_only: !!e.local_only, // 本地未发布档案(离线创建):大厅显示「未发布」徽章 + 发布入口
|
|
2120
|
+
source_id: sid, source_name: sid ? (e.source_name || armSource(sid)?.name || sid) : null,
|
|
2121
|
+
remote_present: false, foreign: !belongs, // foreign:来源 ≠ 当前源且当前源目录里没有(远端合并时若命中则翻回 false)
|
|
1867
2122
|
});
|
|
1868
2123
|
}
|
|
1869
2124
|
if (online) { // 静默刷新远端版本与下架标记(显式事件统计走 /api/arm/sync)——仅在完整拉取时;
|
|
@@ -1877,10 +2132,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
1877
2132
|
deployed: !!loadDeployment(c.agent_id),
|
|
1878
2133
|
update_available: cur.installed ? cmpVersion(c.version, cur.installed_version) > 0 : false,
|
|
1879
2134
|
created_by_id: c.created_by_id || null, created_by: c.created_by || null,
|
|
2135
|
+
remote_present: true, foreign: false, delisted: false, // 当前源目录里有它(含跨源发布后两边都有的情况)
|
|
1880
2136
|
});
|
|
1881
2137
|
}
|
|
1882
2138
|
if (!kw) {
|
|
1883
2139
|
for (const e of reg.agents) {
|
|
2140
|
+
if (!entryBelongs(e, src)) continue; // 其他源的助手不参与当前源的下架/版本判定
|
|
1884
2141
|
if (ids.has(e.agent_id)) { e.remote_version = byId.get(e.agent_id)?.remote_version || e.remote_version; e.delisted = false; }
|
|
1885
2142
|
else e.delisted = true;
|
|
1886
2143
|
}
|
|
@@ -1891,7 +2148,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1891
2148
|
// PAT 身份(编辑/删除门控依据:me.id vs 卡片 created_by_id,或 ADMIN):armMe 有缓存——
|
|
1892
2149
|
// 身份只随 (ARM 源, 令牌) 变化,搜索防抖高频刷新目录时不重复打 /auth/me;失败降级 null 不阻断目录
|
|
1893
2150
|
const me = online ? await armMe() : null;
|
|
1894
|
-
return json(res, 200, { online, auth_error: authError, me, last_sync_at: reg.last_sync_at || null, agents: [...byId.values()] });
|
|
2151
|
+
return json(res, 200, { online, auth_error: authError, me, last_sync_at: reg.last_sync_at || null, agents: [...byId.values()], source: { id: src.id, name: src.name, base: srcBase(src) }, sources: armSources().map((s) => ({ id: s.id, name: s.name })) });
|
|
1895
2152
|
}
|
|
1896
2153
|
// ── 新建助手:本地起草(AI 生成 prompt)→ ARM JSON 创建(创建即发布)→ 可选知识绑定(幂等)→ 下载物化 ──
|
|
1897
2154
|
if (req.method === 'POST' && pathname === '/api/agents/draft-prompt') {
|
|
@@ -2066,13 +2323,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
2066
2323
|
return json(res, 200, { ok: true, uninstalled });
|
|
2067
2324
|
} catch (e) { return json(res, 502, { error: `ARM 删除失败: ${e.message}` }); }
|
|
2068
2325
|
}
|
|
2069
|
-
//
|
|
2070
|
-
//
|
|
2326
|
+
// 发布到当前 ARM 源:本地未发布档案转正,或把来自其他源的已安装助手推送到当前源(公库 → 私库 / 反向)。同 id 上行(新版 ARM),
|
|
2327
|
+
// 身份不变、本地 deployment/工作台/数据零迁移;知识全文随档案复制并绑定(best-effort),技能/UI 资产是 ARM 侧资产不复制、明示跳过。
|
|
2328
|
+
// 幂等:同 id 已在目标源(上次发布部分成功)→ 校验同名即续走;旧版 ARM 忽略 id 返回新 id → 明确报错提示升级。
|
|
2071
2329
|
if (req.method === 'POST' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}\/publish$/)) {
|
|
2072
2330
|
const id = pathname.match(/^\/api\/agents\/([0-9a-f-]{36})\/publish$/)[1];
|
|
2073
2331
|
const reg = loadRegistry();
|
|
2074
2332
|
const le = reg.agents.find((x) => x.agent_id === id);
|
|
2075
|
-
|
|
2333
|
+
const src = armCurrent();
|
|
2334
|
+
if (!le) return json(res, 400, { error: '本地未安装该助手,无可发布的档案' });
|
|
2335
|
+
if (!le.local_only && entryBelongs(le, src)) return json(res, 400, { error: `该助手来源就是当前源「${src.name}」,无需发布;要推送到另一个源,先在大厅切换到目标源` });
|
|
2076
2336
|
const m = localManifest(id);
|
|
2077
2337
|
if (!m) return json(res, 400, { error: '本地档案缺失,请先重新创建' });
|
|
2078
2338
|
try {
|
|
@@ -2088,12 +2348,22 @@ const server = http.createServer(async (req, res) => {
|
|
|
2088
2348
|
}
|
|
2089
2349
|
const finalId = created?.id || created?.agent?.id;
|
|
2090
2350
|
if (finalId && finalId !== id) return json(res, 502, { error: '当前 ARM 版本不支持保持助手身份(忽略客户端 id)——请升级 ARM 后再发布' });
|
|
2091
|
-
const
|
|
2351
|
+
const copied = { knowledges: 0, failed: 0 }, skipped = { skills: (m.skills || []).length, ui_bindings: (m.ui_bindings || []).length };
|
|
2352
|
+
for (const k of (m.knowledges || [])) { // 本地已物化的知识全文 → 目标源新建并绑定;单条失败只计数
|
|
2353
|
+
if (!k.file) { copied.failed++; continue; }
|
|
2354
|
+
try {
|
|
2355
|
+
const content = fs.readFileSync(path.join(ARM_AGENTS_DIR, id, 'versions', m.version, k.file), 'utf8');
|
|
2356
|
+
const kr = await armCreateKnowledge({ name: k.ref, description: `随助手「${m.name}」跨源发布`, content, tags: ['跨源发布'] });
|
|
2357
|
+
if (kr?.id) await armRest(`/api/v1/agents/${id}/knowledges`, { method: 'POST', body: JSON.stringify({ knowledgeId: kr.id, kind: k.kind || 'experience' }) });
|
|
2358
|
+
copied.knowledges++;
|
|
2359
|
+
} catch { copied.failed++; }
|
|
2360
|
+
}
|
|
2361
|
+
const manifest = await downloadAgent(id); // 重新物化:source 转为当前源、版本对齐目标 ARM
|
|
2092
2362
|
const reg2 = loadRegistry(); const en = reg2.agents.find((x) => x.agent_id === id);
|
|
2093
|
-
if (en) { en.local_only = false; en.remote_version = manifest.version; } saveRegistry(reg2);
|
|
2094
|
-
audit({ actor: 'user', action: 'publish-agent', agentId: id, name: m.name, version: manifest.version });
|
|
2095
|
-
return json(res, 200, { ok: true, manifest });
|
|
2096
|
-
} catch (e) { return json(res, 502, { error:
|
|
2363
|
+
if (en) { en.local_only = false; en.remote_version = manifest.version; en.delisted = false; } saveRegistry(reg2);
|
|
2364
|
+
audit({ actor: 'user', action: 'publish-agent', agentId: id, name: m.name, version: manifest.version, target: src.id, cross_source: !le.local_only, ...copied, skipped });
|
|
2365
|
+
return json(res, 200, { ok: true, manifest, target: { id: src.id, name: src.name }, copied, skipped });
|
|
2366
|
+
} catch (e) { return json(res, 502, { error: `发布到「${src.name}」失败: ${e.message}` }); }
|
|
2097
2367
|
}
|
|
2098
2368
|
// ── 本地助手库:下载 / 升级 / 版本历史 / 回滚 / 卸载 ──
|
|
2099
2369
|
let mAgt = pathname.match(/^\/api\/agents\/([0-9a-f-]{36})\/(download|upgrade|versions|rollback)$/);
|
|
@@ -2225,7 +2495,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2225
2495
|
if (!brief || !model) return json(res, 200, { items: GENERIC_INSPIRATIONS, source: 'generic' });
|
|
2226
2496
|
try {
|
|
2227
2497
|
const raw = await llmComplete(
|
|
2228
|
-
'
|
|
2498
|
+
'你是智能体平台的「工作台定制灵感策划」。根据助手的自描述,为它的定制工作台生成 6 个面板灵感。要求:0) 多样性:从监控/操作/统计/检索/趋势/健康等维度组合挑选,同助手多次生成要有明显差异;1) 每个灵感贴合该助手的真实职责与数据(自描述里提到的系统/脚本/接口),不要套用与它无关的模板;2) prompt 是给二开助手的完整需求描述,必须引用可用的 ui kit 组件(ui.Card/Badge/Button/Stat/Spark/Bars/Matrix/StatusItem/StatusDot/Empty/Loading/Error)并遵守契约:面板标题由平台渲染、插件首屏不写标题行、列表用 ui.StatusItem、KPI 用 ui.Stat(可带 trend);3) 输出严格为 JSON 数组,每项 {"emoji":"单字符","title":"≤6字","desc":"≤20字一句人话","prompt":"完整需求"},不要输出其他任何文字。',
|
|
2229
2499
|
`助手自描述:\n${brief}`,
|
|
2230
2500
|
);
|
|
2231
2501
|
const arr = JSON.parse((raw.match(/\[[\s\S]*\]/) || [''])[0]);
|
|
@@ -2276,7 +2546,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2276
2546
|
if (!dep) return json(res, 400, { error: '助手未上岗' });
|
|
2277
2547
|
const arm = await getArmAgent(agent_id);
|
|
2278
2548
|
const toolsAvail = dep.toolset.map(t => t.tool).join(', ');
|
|
2279
|
-
const sys = '
|
|
2549
|
+
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"}...]}。action_group 的 panels 含 "actions":[{"id","label","orchestration","autonomy","confirm":{"text","double_check"}}],orchestration 只能引用这些: logic://设备复位流程@mock / logic://告警派工@mock / logic://服务滚动重启@mock / logic://环境自检@mock。只输出 JSON,不要 markdown 代码围栏。';
|
|
2280
2550
|
const user = `助手: ${arm.name} ${arm.version}\n职责: ${arm.prompt}\n可用工具: ${toolsAvail}\n默认自主级别: ${dep.default_autonomy}\n请生成 4-6 个面板,含一个 agent_dialog 和一个 action_group。`;
|
|
2281
2551
|
let out;
|
|
2282
2552
|
try { out = await llmComplete(sys, user); }
|
|
@@ -2377,7 +2647,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2377
2647
|
delete env.ZAI_CODING_CN_API_KEY; delete env.LLM_API_KEY; delete env.ARM_PAT;
|
|
2378
2648
|
audit({ actor: 'panel-action', agentId, panelId, actionId, command: cmd.slice(0, 200) });
|
|
2379
2649
|
return await new Promise((resolve) => {
|
|
2380
|
-
cpExec(cmd, { cwd: ROOT, timeout: +(action.timeout_ms || 120000), maxBuffer: 1024 * 1024, env, shell:
|
|
2650
|
+
cpExec(cmd, { cwd: ROOT, timeout: +(action.timeout_ms || 120000), maxBuffer: 1024 * 1024, env, shell: SHELL }, (err, stdout, stderr) => {
|
|
2381
2651
|
const out = String(stdout || '');
|
|
2382
2652
|
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 }); // 结果入审计:可查证
|
|
2383
2653
|
resolve(json(res, 200, { ok: !err, output: out.slice(0, 8000), error: err ? String(err.message).slice(0, 500) : String(stderr || '').slice(0, 1000) }));
|
|
@@ -2632,9 +2902,42 @@ const server = http.createServer(async (req, res) => {
|
|
|
2632
2902
|
if (req.method === 'GET' && pathname === '/api/state') {
|
|
2633
2903
|
return json(res, 200, { devices: world.devices, alarms: world.alarms, kpi: world.kpi, work_orders: world.work_orders, inspections: world.inspections });
|
|
2634
2904
|
}
|
|
2905
|
+
// ── 对话附件:上传 / 回传 / 删除——只在 <工作区>/attachments/ 内读写(助手 × 工作区隔离;路径困限见 attachAbs)──
|
|
2906
|
+
if (pathname === '/api/attachments' || pathname === '/api/attachments/file') {
|
|
2907
|
+
const agentId = String(url.searchParams.get('agentId') || '');
|
|
2908
|
+
if (!/^[\w.-]{1,80}$/.test(agentId)) return json(res, 400, { error: 'agentId 不合法' });
|
|
2909
|
+
const ws = getWs(agentId, url.searchParams.get('ws_id') || undefined) || currentWs(agentId); // 与 ensureSession 同口径:指定工作区不存在退回当前工作区
|
|
2910
|
+
if (pathname === '/api/attachments/file' && req.method === 'GET') {
|
|
2911
|
+
const rel = url.searchParams.get('rel');
|
|
2912
|
+
const abs = attachAbs(ws, rel);
|
|
2913
|
+
if (!abs || !fs.existsSync(abs)) return json(res, 404, { error: '附件不存在' });
|
|
2914
|
+
const name = path.basename(abs).replace(/^\d{6}-[0-9a-f]{4}-/, '');
|
|
2915
|
+
res.writeHead(200, { 'Content-Type': attachMime('', abs), 'Content-Length': fs.statSync(abs).size, 'Content-Disposition': `inline; filename*=UTF-8''${encodeURIComponent(name)}`, 'X-Content-Type-Options': 'nosniff', 'Cache-Control': 'private, max-age=3600' });
|
|
2916
|
+
return fs.createReadStream(abs).pipe(res);
|
|
2917
|
+
}
|
|
2918
|
+
if (pathname === '/api/attachments' && req.method === 'DELETE') {
|
|
2919
|
+
const rel = url.searchParams.get('rel');
|
|
2920
|
+
const abs = attachAbs(ws, rel);
|
|
2921
|
+
if (!abs) return json(res, 400, { error: '附件路径不合法(仅允许工作区 attachments/ 内)' });
|
|
2922
|
+
if (fs.existsSync(abs)) fs.unlinkSync(abs);
|
|
2923
|
+
audit({ actor: 'attachment', agentId, ws: ws.id, action: 'delete', rel });
|
|
2924
|
+
return json(res, 200, { ok: true });
|
|
2925
|
+
}
|
|
2926
|
+
if (pathname === '/api/attachments' && req.method === 'POST') {
|
|
2927
|
+
let buf;
|
|
2928
|
+
try { buf = await rawBody(req, ATTACH_MAX_BYTES); } catch (e) { return json(res, e.status || 400, { error: e.message }); }
|
|
2929
|
+
if (!buf.length) return json(res, 400, { error: '空文件' });
|
|
2930
|
+
let name = ''; try { name = decodeURIComponent(String(req.headers['x-file-name'] || '')); } catch { /* 非法编码按无名处理 */ }
|
|
2931
|
+
name = name || `file-${Date.now()}`;
|
|
2932
|
+
const att = saveAttachment(ws, name, attachMime(req.headers['content-type'], name), buf);
|
|
2933
|
+
audit({ actor: 'attachment', agentId, ws: ws.id, action: 'upload', rel: att.rel, size: att.size, mime: att.mime, kind: att.kind });
|
|
2934
|
+
return json(res, 200, { attachment: att, vision: llmVision() }); // vision 供前端提示"当前模型不支持看图"
|
|
2935
|
+
}
|
|
2936
|
+
return json(res, 405, { error: 'method not allowed' });
|
|
2937
|
+
}
|
|
2635
2938
|
// ── 对话(SSE 流式;会话绑定工作区:exec cwd / 数据集库 / 上下文随工作区走)──
|
|
2636
2939
|
if (req.method === 'POST' && pathname === '/api/chat') {
|
|
2637
|
-
const { sessionId, agentId, message, autonomyLevel, context, ws_id } = await body(req);
|
|
2940
|
+
const { sessionId, agentId, message, autonomyLevel, context, ws_id, attachments } = await body(req);
|
|
2638
2941
|
const emit = sseInit(req, res);
|
|
2639
2942
|
const ctl = new AbortController();
|
|
2640
2943
|
let finished = false;
|
|
@@ -2649,7 +2952,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2649
2952
|
emit('error', { message: '有一项写操作等待人工确认,请先处理对话中的确认卡(或点取消)' });
|
|
2650
2953
|
return res.end();
|
|
2651
2954
|
}
|
|
2652
|
-
|
|
2955
|
+
// 附件按 rel 从会话所绑定的工作区重读编进消息(图片进多模态块 / 文本内联 / 其余给路径);userText+attachments 挂在消息上供历史回放
|
|
2956
|
+
const ws = getWs(agentId, s.wsId) || currentWs(agentId);
|
|
2957
|
+
const um = composeUserMessage(ws, context ? `${message}\n(界面注入上下文:当前选中设备 ${context.device})` : message, attachments);
|
|
2958
|
+
s.context.messages.push({ role: 'user', content: um.content, timestamp: Date.now(), ...(um.attachments.length ? { userText: String(message || ''), attachments: um.attachments } : {}) });
|
|
2959
|
+
pruneOldImages(s.context);
|
|
2653
2960
|
const dropped = trimContext(s.context); // 主对话 sessionId 同样固定(chat-<agentId>-<wsId>),不裁剪会同样撑死
|
|
2654
2961
|
if (dropped > 0) emit('status', { text: `会话历史过长,已自动裁剪最早 ${dropped} 条消息` });
|
|
2655
2962
|
s.running = true; s.sinceTs = Date.now(); s.abortCtl = ctl;
|
|
@@ -2718,7 +3025,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
2718
3025
|
}
|
|
2719
3026
|
const msgs = [];
|
|
2720
3027
|
for (const m of s.context.messages) {
|
|
2721
|
-
if (m.role === 'user') {
|
|
3028
|
+
if (m.role === 'user') { // 带附件的消息 content 含附件说明/图片块,展示用 userText 原文 + attachments 元数据
|
|
3029
|
+
const text = m.userText ?? (typeof m.content === 'string' ? m.content : (m.content || []).filter((b) => b.type === 'text').map((b) => b.text).join(''));
|
|
3030
|
+
msgs.push({ kind: 'user', text, ...(m.attachments?.length ? { attachments: m.attachments } : {}) }); continue;
|
|
3031
|
+
}
|
|
2722
3032
|
if (!Array.isArray(m.content)) continue;
|
|
2723
3033
|
const text = m.content.filter(b => b.type === 'text').map(b => b.text).join('');
|
|
2724
3034
|
if (text) msgs.push({ kind: 'bot', text });
|
|
@@ -2837,20 +3147,131 @@ if (process.argv.includes('--test-guard')) {
|
|
|
2837
3147
|
['exec', 0, { command: 'sudo -u aims npm install -g x' }, 'blocked'],
|
|
2838
3148
|
['exec', 2, { command: "sudo -u aims bash -c 'sudo reboot'" }, 'blocked'],
|
|
2839
3149
|
['exec', 2, { command: 'sudo apt install -y x' }, 'blocked'],
|
|
3150
|
+
['exec', 0, { command: 'grep -i Reboot /var/log/syslog' }, 'ok'], // POSIX deny 仍区分大小写(win32 才忽略):非 Windows 行为零变化
|
|
2840
3151
|
);
|
|
2841
3152
|
let pass = true;
|
|
2842
|
-
|
|
2843
|
-
const
|
|
2844
|
-
|
|
2845
|
-
|
|
3153
|
+
const runCases = (list) => {
|
|
3154
|
+
for (const [tool, lv, args, want] of list) {
|
|
3155
|
+
const g = guardCheck(tool, lv, null, args); const ok = g.status === want; if (!ok) pass = false;
|
|
3156
|
+
console.log(`${ok ? '✅' : '❌'} guard(${tool}, L${lv}, ${String(args.command || '').slice(0, 30)}) -> ${g.status}(期望 ${want})`);
|
|
3157
|
+
}
|
|
3158
|
+
};
|
|
3159
|
+
runCases(cases); // POSIX 组:按当前平台语法判定 + POSIX 默认清单(classifyExec 每次现读配置,须在切换 Windows 前跑完)
|
|
3160
|
+
// Windows 组:切到 win32 判定 + win32 默认清单(与真实平台无关,任何平台都跑这组)
|
|
3161
|
+
EXEC_WIN = true; cfg.guard = normalizeGuard({ sudo_u_l2: true }, true);
|
|
3162
|
+
console.log('—— Windows(cmd/PowerShell)语法与清单 ——');
|
|
3163
|
+
runCases([
|
|
3164
|
+
['exec', 0, { command: 'dir /b C:\\work' }, 'ok'],
|
|
3165
|
+
['exec', 0, { command: 'tasklist /v | findstr aims' }, 'ok'],
|
|
3166
|
+
['exec', 0, { command: 'ipconfig /all & netstat -ano' }, 'ok'], // cmd 的 & 无条件分隔:两段都须只读
|
|
3167
|
+
['exec', 0, { command: 'ping -n 1 192.0.2.1 2>&1' }, 'ok'], // fd 复制重定向不被当成 & 分隔
|
|
3168
|
+
['exec', 0, { command: 'C:\\Windows\\System32\\ipconfig.exe /all' }, 'ok'], // 全路径 + .exe 归一为命令名
|
|
3169
|
+
['exec', 0, { command: 'Get-Process | Select-String aims' }, 'ok'], // PowerShell cmdlet,大小写不敏感
|
|
3170
|
+
['exec', 0, { command: 'ls -la && cat package.json' }, 'ok'], // Git Bash 场景:POSIX 清单在 win32 仍有效
|
|
3171
|
+
['exec', 2, { command: 'echo hi > C:\\tmp\\a.txt' }, 'pending'], // 重定向=写
|
|
3172
|
+
['exec', 2, { command: 'dir | Tee-Object out.txt' }, 'pending'], // Tee-Object 视同 tee
|
|
3173
|
+
['exec', 2, { command: 'del /f /s /q C:\\Windows\\Temp\\x' }, 'pending'], // 递归删除但非盘符根:L2 确认(对齐 rm -rf /tmp/x)
|
|
3174
|
+
['exec', 2, { command: 'net user guest /delete' }, 'pending'], // 多行为动词不在白名单 → 写
|
|
3175
|
+
['exec', 0, { command: 'wmic process list brief' }, 'blocked'], // L0 非只读白名单 → 拦截
|
|
3176
|
+
// deny 误杀面:路径参数里的 X:\ 与 format 子命令/参数名不得命中(写命令按 L2 确认即可,不能永拒)
|
|
3177
|
+
['exec', 2, { command: 'dotnet format C:\\proj\\x.sln' }, 'pending'],
|
|
3178
|
+
['exec', 2, { command: 'git log --format=%H C:\\repo' }, 'pending'],
|
|
3179
|
+
['exec', 2, { command: 'Get-Process | Format-Table Name, CPU' }, 'pending'],
|
|
3180
|
+
['exec', 2, { command: 'Remove-Item .\\dist -Recurse -Force' }, 'pending'],
|
|
3181
|
+
['exec', 2, { command: 'rd /s /q "C:\\work\\build"' }, 'pending'],
|
|
3182
|
+
['exec', 0, { command: 'format c: /q' }, 'blocked'],
|
|
3183
|
+
['exec', 0, { command: 'format /q d:' }, 'blocked'], // 裸盘符在 flag 之后
|
|
3184
|
+
['exec', 0, { command: 'FORMAT D:' }, 'blocked'], // deny 忽略大小写
|
|
3185
|
+
['exec', 3, { command: 'Format-Volume -DriveLetter D' }, 'blocked'],
|
|
3186
|
+
['exec', 0, { command: 'rd /s /q C:\\' }, 'blocked'],
|
|
3187
|
+
['exec', 0, { command: 'Remove-Item C:\\ -Recurse -Force' }, 'blocked'],
|
|
3188
|
+
['exec', 0, { command: 'diskpart' }, 'blocked'],
|
|
3189
|
+
['exec', 3, { command: 'Stop-Computer' }, 'blocked'], // deny 任何级别不豁免
|
|
3190
|
+
['exec', 3, { command: 'del x.txt' }, 'ok'], // L3 完全自主:写命令免确认
|
|
3191
|
+
]);
|
|
2846
3192
|
console.log(pass ? '守护栏单元测试全部通过' : '守护栏单元测试存在失败');
|
|
2847
3193
|
process.exit(pass ? 0 : 1);
|
|
2848
3194
|
}
|
|
2849
3195
|
|
|
3196
|
+
// ── 单测: node server.js --test-attachments(附件落盘/路径困限/消息组装/图片裁剪,临时工作区,不碰 LLM)──
|
|
3197
|
+
if (process.argv.includes('--test-attachments')) {
|
|
3198
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'aims-att-'));
|
|
3199
|
+
const ws = { id: 'default', path: tmp };
|
|
3200
|
+
let pass = true;
|
|
3201
|
+
const ok = (name, cond) => { if (!cond) pass = false; console.log(`${cond ? '✅' : '❌'} ${name}`); };
|
|
3202
|
+
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
|
|
3203
|
+
const a1 = saveAttachment(ws, '报错截图.png', 'image/png', png);
|
|
3204
|
+
const a2 = saveAttachment(ws, 'app.log', 'text/plain', Buffer.from('line1\nERROR boom\n'));
|
|
3205
|
+
const a3 = saveAttachment(ws, 'big.log', 'text/plain', Buffer.alloc(20 * 1024, 0x61));
|
|
3206
|
+
const a4 = saveAttachment(ws, 'weird.txt', 'text/plain', Buffer.from([0x61, 0x00, 0x62]));
|
|
3207
|
+
const a5 = saveAttachment(ws, '../../evil.sh', 'application/octet-stream', Buffer.from('#!/bin/sh'));
|
|
3208
|
+
ok('图片 kind=image', a1.kind === 'image');
|
|
3209
|
+
ok('文本 kind=text', a2.kind === 'text');
|
|
3210
|
+
ok('头部含 NUL 的 .txt 判为 binary', a4.kind === 'binary');
|
|
3211
|
+
ok('扩展名推断 MIME(客户端给兜底值时)', a5.mime === 'application/octet-stream' && attachMime('application/octet-stream', 'x.png') === 'image/png');
|
|
3212
|
+
ok('文件名 ../ 被清洗、仍落在 attachments/ 内', !a5.rel.includes('..') && !!attachAbs(ws, a5.rel) && fs.existsSync(path.join(tmp, ...a5.rel.split('/'))));
|
|
3213
|
+
ok('落盘位置 = <工作区>/attachments/<日期>/', /^attachments\/\d{4}-\d{2}-\d{2}\/\d{6}-[0-9a-f]{4}-/.test(a1.rel) && fs.existsSync(path.join(tmp, ...a1.rel.split('/'))));
|
|
3214
|
+
ok('rel 穿越/越界一律拒绝', [`../registry.json`, `attachments/../registry.json`, `/etc/passwd`, `C:/x`, `registry.json`, `attachments`, ``, `attachments//x`].every((r) => attachAbs(ws, r) === null));
|
|
3215
|
+
cfg.llm_vision = 'on';
|
|
3216
|
+
const m1 = composeUserMessage(ws, '看下这个报错', [{ rel: a1.rel }, { rel: a2.rel }, { rel: a3.rel }, { rel: '../registry.json' }]);
|
|
3217
|
+
ok('视觉开:content=数组,首块文本 + 1 个 image 块;非法 rel 被忽略', Array.isArray(m1.content) && m1.content[0].type === 'text' && m1.content.filter((b) => b.type === 'image').length === 1 && m1.attachments.length === 3);
|
|
3218
|
+
ok('小文本全文内联', m1.content[0].text.includes('ERROR boom') && m1.content[0].text.includes(a2.rel));
|
|
3219
|
+
ok('大文本只给前 2KB + exec 提示', m1.content[0].text.includes('前 2.0 KB 预览') && m1.content[0].text.includes('tail -n 200') && !m1.content[0].text.includes('a'.repeat(3000)));
|
|
3220
|
+
ok('attachments 元数据不含文件内容,userText 为原文', m1.attachments.every((a) => !('buf' in a) && a.rel && a.name) && m1.userText === '看下这个报错');
|
|
3221
|
+
cfg.llm_vision = 'off';
|
|
3222
|
+
const m2 = composeUserMessage(ws, '', [{ rel: a1.rel }]);
|
|
3223
|
+
ok('视觉关:content=纯字符串且说明不支持看图(空正文直接以附件说明开头)', typeof m2.content === 'string' && m2.content.includes('不支持看图') && m2.content.startsWith('【附件'));
|
|
3224
|
+
const m3 = composeUserMessage(ws, 'hi', []);
|
|
3225
|
+
ok('无附件:content 与原文一致(文本对话路径行为不变)', m3.content === 'hi' && m3.attachments.length === 0);
|
|
3226
|
+
cfg.llm_vision = 'on';
|
|
3227
|
+
const ctx = { messages: [] };
|
|
3228
|
+
for (let i = 0; i < 5; i++) { const u = composeUserMessage(ws, `q${i}`, [{ rel: a1.rel }]); ctx.messages.push({ role: 'user', content: u.content, attachments: u.attachments, userText: u.userText }, { role: 'assistant', content: [{ type: 'text', text: 'ok' }] }); }
|
|
3229
|
+
pruneOldImages(ctx, 3);
|
|
3230
|
+
const withImg = ctx.messages.filter((m) => m.role === 'user' && m.content.some((b) => b.type === 'image')).length;
|
|
3231
|
+
ok('pruneOldImages 只保留最近 3 条带图消息,更早的替换为占位文本', withImg === 3 && ctx.messages[0].content.some((b) => b.type === 'text' && b.text.includes('已在前文提供') && b.text.includes(a1.rel)));
|
|
3232
|
+
ok('trimContext 图片按固定估算(500KB base64 不触发裁剪)', trimContext({ messages: [{ role: 'user', content: [{ type: 'text', text: 'x' }, { type: 'image', data: 'A'.repeat(500000), mimeType: 'image/png' }] }] }, 10_000) === 0);
|
|
3233
|
+
cfg.llm_vision = '';
|
|
3234
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
3235
|
+
console.log(pass ? '附件单元测试全部通过' : '附件单元测试存在失败');
|
|
3236
|
+
process.exit(pass ? 0 : 1);
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
// ── 单测: node server.js --test-arm-sources(多源迁移 / 回落规则 / 归属判定 / 切换生效;不碰网络)──
|
|
3240
|
+
if (process.argv.includes('--test-arm-sources')) {
|
|
3241
|
+
let pass = true;
|
|
3242
|
+
const ok = (name, cond) => { if (!cond) pass = false; console.log(`${cond ? '✅' : '❌'} ${name}`); };
|
|
3243
|
+
const saved = { sources: cfg.arm_sources, current: cfg.arm_current };
|
|
3244
|
+
const m1 = normalizeSources({ arm_api_base: 'http://a.example/', arm_token: 'tok-a' });
|
|
3245
|
+
ok('旧单源字段迁移为 default 源(去尾斜杠)', m1.length === 1 && m1[0].id === 'default' && m1[0].base === 'http://a.example' && m1[0].token === 'tok-a' && m1[0].name === '默认 ARM');
|
|
3246
|
+
const m2 = normalizeSources({ arm_sources: [{ id: 'src_b', name: 'B 私库', base: 'http://b.example/' }, { id: 'bad id!', base: 'x' }, { id: 'src_b', name: '重复' }] });
|
|
3247
|
+
ok('缺 default 自动补在首位;非法 id 过滤;重复 id 去重', m2.length === 2 && m2[0].id === 'default' && m2[1].id === 'src_b' && m2[1].name === 'B 私库' && m2[1].base === 'http://b.example' && m2[1].token === '');
|
|
3248
|
+
ok('无名源自动命名', normalizeSources({ arm_sources: [{ id: 'src_c', base: 'http://c' }] })[1].name === 'ARM 源 1');
|
|
3249
|
+
const B = { id: 'src_b', name: 'B', base: 'http://b.example', token: '' };
|
|
3250
|
+
ok('非默认源无令牌 → 空(不回落 PAT)', srcToken(B) === '');
|
|
3251
|
+
ok('非默认源地址为空 → 空(不回落内置默认)', srcBase({ id: 'src_x', base: '', token: '' }) === '');
|
|
3252
|
+
ok('default 源空地址 → env/内置默认', srcBase({ id: 'default', base: '', token: '' }) === (process.env.ARM_API_BASE || env.ARM_API_BASE || ARM_DEFAULT_BASE));
|
|
3253
|
+
ok('default 源空令牌 → env/PAT 回落', srcToken({ id: 'default', base: '', token: '' }) === (env.ARM_API_TOKEN || ARM_PAT));
|
|
3254
|
+
ok('显式令牌优先于回落', srcToken({ id: 'default', base: '', token: 'explicit' }) === 'explicit');
|
|
3255
|
+
cfg.arm_sources = [{ id: 'default', name: '默认 ARM', base: 'http://a.example', token: 'tok-a' }, B];
|
|
3256
|
+
cfg.arm_current = 'src_b';
|
|
3257
|
+
ok('切到 B 后 armApiBase/armApiToken 指向 B', armApiBase() === 'http://b.example' && armApiToken() === '' && armCurrent().name === 'B');
|
|
3258
|
+
cfg.arm_current = 'nope';
|
|
3259
|
+
ok('当前源指针失效 → 退回首个(default)', armCurrent().id === 'default' && armApiBase() === 'http://a.example' && armApiToken() === 'tok-a');
|
|
3260
|
+
ok('未发布档案属于任何当前源', entryBelongs({ local_only: true }, B) && entryBelongs({ local_only: true }, armSource('default')));
|
|
3261
|
+
ok('旧条目(无 source_id)属于 default', entryBelongs({}, armSource('default')) && !entryBelongs({}, B));
|
|
3262
|
+
ok('来自 B 的条目在 default 下为 foreign', !entryBelongs({ source_id: 'src_b' }, armSource('default')) && entryBelongs({ source_id: 'src_b' }, B));
|
|
3263
|
+
cfg.arm_current = 'src_b'; // 上一条把指针置为无效值验证退回,这里恢复到 B 再验视图
|
|
3264
|
+
ok('srcView 只给令牌掩码', !JSON.stringify(srcView({ id: 'src_b', name: 'B', base: 'http://b', token: 'arm_pat_1234567890abcdef' })).includes('arm_pat_1234567890abcdef') && srcView(B).current === true && srcView(armSource('default')).builtin === true && srcView(armSource('default')).current === false);
|
|
3265
|
+
cfg.arm_sources = saved.sources; cfg.arm_current = saved.current;
|
|
3266
|
+
console.log(pass ? '多 ARM 源单元测试全部通过' : '多 ARM 源单元测试存在失败');
|
|
3267
|
+
process.exit(pass ? 0 : 1);
|
|
3268
|
+
}
|
|
3269
|
+
|
|
2850
3270
|
applyLLMConfig().then(() => {
|
|
2851
3271
|
server.listen(PORT, HOST, () => {
|
|
2852
3272
|
console.log(`[aims-workstation] http://localhost:${PORT}(绑定 ${HOST}) | 模型: ${model.id} @ ${model.baseUrl}`);
|
|
2853
3273
|
console.log(`[aims-workstation] 数据目录 AIMS_HOME: ${AIMS_HOME}`);
|
|
3274
|
+
console.log(`[aims-workstation] exec shell: ${SHELL}(${SHELL_HINT})`);
|
|
2854
3275
|
console.log(`[aims-workstation] ARM(REST): ${armApiBase()} | PAT: ${ARM_PAT ? '已加载' : '未配置'} | 已上岗助手: ${fs.readdirSync(DEPLOY_DIR).length} | 本地助手库: ${loadRegistry().agents.length}`);
|
|
2855
3276
|
});
|
|
2856
3277
|
// 已上岗助手后台物化为本地档案(best-effort;ARM 不可达时静默跳过,待手动同步)
|