aims-workstation 0.1.16 → 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 CHANGED
@@ -13,7 +13,7 @@
13
13
  "@earendil-works/pi-ai": "^1.2.0"
14
14
  },
15
15
  "packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1",
16
- "version": "0.1.16",
16
+ "version": "0.1.17",
17
17
  "description": "AIMS 工作站:本地智能体工作站——ARM 助手本地化(下载/离线/升级)、工作台对话式定制、多工作区隔离、分级自主与审计",
18
18
  "bin": {
19
19
  "aims-workstation": "bin/aims-workstation.js"
package/server.js CHANGED
@@ -76,7 +76,7 @@ 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 = { arm_api_base: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', llm_context_tokens: 0, llm_vision: '', scene_profile: '', access_token: '' };
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" 等词的误杀
@@ -114,15 +114,43 @@ function normalizeGuard(g, win = WIN) {
114
114
  sudo_u_l2: !!g.sudo_u_l2,
115
115
  };
116
116
  }
117
- function loadCfg() { try { Object.assign(cfg, JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'))); } catch { } delete cfg.arm_mcp_url; /* MCP 通道已退役:剥离旧配置残留 */ cfg.guard = normalizeGuard(cfg.guard); }
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
+ }
118
134
  loadCfg();
119
135
  const guardNow = () => cfg.guard;
120
136
  function saveCfg() { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg, null, 2)); }
121
- // ARM 站点地址:运行时配置 > ARM_API_BASE(env) > 公司内网默认(内网可达即开箱可用)
137
+ // ARM 站点地址:当前源配置 > (仅 default 源)ARM_API_BASE(env) > 公司内网默认(内网可达即开箱可用)
122
138
  //(2026-10-21 全面切换新版 ARM:仅 REST 通道,MCP 通道及旧版兼容回落已退役)
123
139
  const ARM_DEFAULT_BASE = 'http://arm.agent-platform.dev.aimstek.cn';
124
- const armApiBase = () => cfg.arm_api_base || process.env.ARM_API_BASE || env.ARM_API_BASE || ARM_DEFAULT_BASE;
125
- const armApiToken = () => cfg.arm_token || env.ARM_API_TOKEN || ARM_PAT;
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
+ };
126
154
  // LLM:OpenAI 兼容端点(baseUrl/模型/密钥),缺省公司内网 bifrost 网关(内网可达即可用,无需配置)
127
155
  const LLM_DEFAULT_BASE = 'http://bifrost.infra.dev.aimstek.cn/v1';
128
156
  const llmBaseUrl = () => cfg.llm_base_url || env.LLM_BASE_URL || LLM_DEFAULT_BASE;
@@ -222,7 +250,7 @@ function agentBrief(agentId) {
222
250
  const inspCache = new Map(); // agent 自描述指纹 → 灵感列表(LLM 一次生成,档案不变即复用)
223
251
 
224
252
  // ── 现场数据容器:真实数据源(AIMS-MOS)未接入前保持为空——面板按空态渲染,不造假数据
225
- //(2026-10-15 MVP 打磨:移除模拟立库场景与工厂画像注入,见 README)
253
+ //(2026-10-15 MVP 打磨:移除模拟立库场景与场景画像注入,见 README)
226
254
  const world = { devices: [], alarms: [], kpi: {}, work_orders: [], inspections: {} };
227
255
 
228
256
  // ── 审计与指标(自进化飞轮的数据原料)──────────────────
@@ -230,18 +258,20 @@ function audit(entry) { entry.ts = new Date().toISOString(); fs.appendFileSync(A
230
258
  function metric(entry) { entry.ts = new Date().toISOString(); fs.appendFileSync(METRICS_LOG, JSON.stringify(entry) + '\n'); }
231
259
 
232
260
  // ARM REST 客户端(v2 开发版结构化通道;{ ok, data, msg } 包络,ok=false 抛错)——目录/检索/下载/回流全链路唯一通道
233
- async function armRest(pathname, init = {}, timeout_ms = 20000) {
261
+ // 缺省打当前源;src 显式指定用于"探测某个源"而不切换
262
+ async function armRest(pathname, init = {}, timeout_ms = 20000, src = null) {
263
+ const s = src || armCurrent();
234
264
  const headers = { 'Content-Type': 'application/json', ...(init.headers || {}) };
235
- const token = armApiToken();
265
+ const token = srcToken(s);
236
266
  if (token) headers.Authorization = `Bearer ${token}`;
237
267
  const ctrl = new AbortController();
238
268
  const timer = setTimeout(() => ctrl.abort(), timeout_ms);
239
269
  try {
240
- const res = await fetch(armApiBase() + pathname, { ...init, headers, signal: ctrl.signal });
270
+ const res = await fetch(srcBase(s) + pathname, { ...init, headers, signal: ctrl.signal });
241
271
  const data = await res.json();
242
272
  if (!res.ok || data.ok === false) {
243
273
  const noAuth = res.status === 401 || res.status === 403 || /401|403|unauthorized|forbidden/i.test(String(data.msg || ''));
244
- throw new Error(`ARM REST ${pathname.split('?')[0]}: ${data.msg || res.status}${noAuth ? '——令牌未配置或无权限:到 ARM(' + armApiBase() + ')生成访问令牌(arm_pat_…)后,粘贴到「系统设置 · ARM 源 · 访问令牌」' : ''}`);
274
+ throw new Error(`ARM REST ${pathname.split('?')[0]}: ${data.msg || res.status}${noAuth ? '——令牌未配置或无权限:到 ARM(' + srcBase(s) + ')生成访问令牌(arm_pat_…)后,粘贴到「系统设置 · ARM 源 · ' + s.name + '」' : ''}`);
245
275
  }
246
276
  return data.data;
247
277
  } catch (e) {
@@ -326,7 +356,12 @@ async function searchKnowledgeItems(query, topK = 5) {
326
356
  // ── 本地助手库(07-助手本地化:下载物化 → 离线可用;按版本目录存档,升级原子切指针)──
327
357
  const regFile = () => path.join(ARM_AGENTS_DIR, 'registry.json');
328
358
  function loadRegistry() {
329
- try { return JSON.parse(fs.readFileSync(regFile(), 'utf8')); } catch { return { agents: [], last_sync_at: null }; }
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;
330
365
  }
331
366
  function saveRegistry(reg) { fs.writeFileSync(regFile(), JSON.stringify(reg, null, 2)); }
332
367
  function localAgent(agentId) { return loadRegistry().agents.find(a => a.agent_id === agentId) || null; }
@@ -355,6 +390,7 @@ function commitDownload(manifest) {
355
390
  let e = reg.agents.find(a => a.agent_id === manifest.agent_id);
356
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); }
357
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; } // 来源源 = 最近一次同步的源(跨源发布后转为目标源)
358
394
  if (!e.versions.includes(manifest.version)) e.versions.push(manifest.version);
359
395
  e.versions.sort(cmpVersion);
360
396
  saveRegistry(reg);
@@ -410,6 +446,7 @@ async function downloadAgent(agentId) {
410
446
  const manifest = {
411
447
  agent_id: agentId, name: d.name, version, description: d.description || '',
412
448
  downloaded_at: new Date().toISOString(), source: `arm-rest://${(armApiBase() || '').replace(/^https?:\/\//, '').split('/')[0]}`,
449
+ source_id: armCurrent().id, source_name: armCurrent().name,
413
450
  prompt: d.prompt || '',
414
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) })),
415
452
  knowledges, ui_bindings,
@@ -444,6 +481,7 @@ function writeLocalAgent(agentId, { name, description, prompt }, { bump = false
444
481
 
445
482
  // 同步:比对远端目录与本地库,产出 new/update/delisted 事件;顺带冲账离线经验 outbox
446
483
  async function armSync() {
484
+ const src = armCurrent();
447
485
  const remote = await armListAgents();
448
486
  const reg = loadRegistry();
449
487
  const remoteIds = new Set(remote.map(r => r.agent_id));
@@ -455,13 +493,14 @@ async function armSync() {
455
493
  if (e) { e.remote_version = r.version; e.delisted = false; }
456
494
  }
457
495
  for (const e of reg.agents) {
496
+ if (!entryBelongs(e, src)) continue; // 其他源的助手:当前源目录里没有是正常的,不参与下架判定
458
497
  if (!remoteIds.has(e.agent_id)) { if (!e.delisted) events.delisted.push({ agent_id: e.agent_id, name: e.name }); e.delisted = true; }
459
498
  }
460
499
  reg.last_sync_at = new Date().toISOString();
461
500
  saveRegistry(reg);
462
501
  const outbox_flushed = await flushExperienceOutbox();
463
- audit({ actor: 'arm-library', action: 'sync', new: events.new.length, update: events.update.length, delisted: events.delisted.length, outbox_flushed });
464
- 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 };
465
504
  }
466
505
 
467
506
  // 离线经验 outbox:ARM 不可达时缓写,恢复连接后冲账(07 设计 §2.4)
@@ -470,9 +509,12 @@ function outboxCount() { try { return fs.readFileSync(OUTBOX_FILE, 'utf8').split
470
509
  async function flushExperienceOutbox() {
471
510
  if (!fs.existsSync(OUTBOX_FILE)) return 0;
472
511
  const lines = fs.readFileSync(OUTBOX_FILE, 'utf8').split('\n').filter(Boolean);
512
+ const cur = armCurrent().id;
473
513
  const remain = []; let ok = 0;
474
514
  for (const line of lines) {
475
- try { const it = JSON.parse(line); const r = await armCreateKnowledge(it.args); if (it.agentId && r?.id) await bindExperienceToAgent(it.agentId, r.id); ok++; }
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++; }
476
518
  catch { remain.push(line); }
477
519
  }
478
520
  fs.writeFileSync(OUTBOX_FILE, remain.length ? remain.join('\n') + '\n' : '');
@@ -813,7 +855,7 @@ const TOOLS = {
813
855
  let r;
814
856
  try { r = await armCreateKnowledge(args); }
815
857
  catch (e) { // 离线:入 outbox 缓写,恢复连接后经「检查更新」自动回流
816
- 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'); // 记账到当时的源,冲账只回这个源
817
859
  audit({ actor: 'experience-reflow', target: 'outbox', title });
818
860
  return `ARM 不可达(${String(e.message).slice(0, 60)}),经验已存本地待回流队列;恢复连接后在助手大厅点「检查更新」将自动回流。`;
819
861
  }
@@ -1565,7 +1607,7 @@ function devSystemPrompt() {
1565
1607
  localDir ? `助手本地档案(平台下载物化): ${localDir} —— manifest.json 含技能/知识绑定清单,knowledges/ 为知识全文` : '',
1566
1608
  `ARM 工作站助手目录(若配置): ${process.env.ARM_WS_ROOT || '未配置'} —— 每个从 ARM 下载的助手有独立文件夹,AGENT.md 描述其定位与真实能力(含可用脚本/服务器/接口线索)。按名定位:grep -rl "助手名" <目录>/*/AGENT.md`,
1567
1609
  '',
1568
- '你是 AIMS 工业智能体平台的「工作台工程师」,通过编写面板插件帮用户对话式定制工作台。',
1610
+ '你是 AIMS 智能体平台的「工作台工程师」,通过编写面板插件帮用户对话式定制工作台。',
1569
1611
  '',
1570
1612
  '【面板插件契约(扩展点)】',
1571
1613
  '插件是一段 JS 代码,必须定义:function Panel(props) { return html`...` }',
@@ -1684,10 +1726,10 @@ async function devChatLoop(session, emit, signal = null) {
1684
1726
  const sessions = new Map(); // sid -> { agentId, autonomy, context, pending }
1685
1727
 
1686
1728
  function systemPrompt(armAgent, deployment, autonomy, ws) {
1687
- // 工厂画像按 knowledge_slices 条件注入(02 设计:切片授权=上下文工程+权限治理);
1729
+ // 场景画像按 knowledge_slices 条件注入(02 设计:切片授权=上下文工程+权限治理);
1688
1730
  // 未声明画像切片的助手(现阶段的绝大多数)只有自己的档案 prompt——不注入硬编码场景(2026-10-15 MVP 打磨)
1689
1731
  const slices = (deployment.knowledge_slices || []).map((s) => String(s));
1690
- const hasProfile = slices.some((s) => s.includes('工厂画像'));
1732
+ const hasProfile = slices.some((s) => s.includes('场景画像'));
1691
1733
  const scene = sceneProfile();
1692
1734
  return [
1693
1735
  armAgent.prompt || '你是运维助手。',
@@ -1699,7 +1741,7 @@ function systemPrompt(armAgent, deployment, autonomy, ws) {
1699
1741
  '【自主级别策略】',
1700
1742
  `当前级别 ${LEVEL_NAME[autonomy]}。L0=只读问答;L1=诊断建议+可沉淀经验;L2=可发起需人工确认的执行操作。`,
1701
1743
  '执行类工具发起后会挂起等待用户在界面确认,确认后才真正执行。',
1702
- ...(hasProfile && world.devices.length ? ['', '【工厂画像(知识切片授权)】', `设备: ${world.devices.map(d => `${d.id}(${d.name},${d.type})`).join(';')}`, '高危操作必须人工确认'] : []), // 真实画像数据接入前(world 为空)诚实跳过
1744
+ ...(hasProfile && world.devices.length ? ['', '【场景画像(知识切片授权)】', `设备: ${world.devices.map(d => `${d.id}(${d.name},${d.type})`).join(';')}`, '高危操作必须人工确认'] : []), // 真实画像数据接入前(world 为空)诚实跳过
1703
1745
  '',
1704
1746
  '【能力哲学:少量原语 + 组合,而非功能堆叠】',
1705
1747
  '你拥有通用终端 exec 和平台数据服务(data_describe/data_query/data_write)。不要期待每个功能都有专用工具——先用原语组合:',
@@ -1871,11 +1913,13 @@ const server = http.createServer(async (req, res) => {
1871
1913
  if (req.method === 'GET' && pathname === '/api/settings') {
1872
1914
  const t = armApiToken();
1873
1915
  const k = llmApiKey();
1916
+ const cur = armCurrent();
1874
1917
  return json(res, 200, {
1875
- arm: {
1876
- api_base: armApiBase(), api_base_runtime: !!cfg.arm_api_base, default_base: ARM_DEFAULT_BASE,
1918
+ arm: { // 兼容字段描述的是当前源;多源列表与管理走 /api/arm/sources
1919
+ api_base: armApiBase(), api_base_runtime: !!cur.base, default_base: ARM_DEFAULT_BASE,
1877
1920
  token_set: !!t, token_masked: t ? `${t.slice(0, 10)}…${t.slice(-4)}` : '',
1878
- token_source: cfg.arm_token ? 'runtime' : (env.ARM_API_TOKEN ? 'env' : (ARM_PAT ? 'pat' : 'none')),
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),
1879
1923
  },
1880
1924
  llm: {
1881
1925
  base_url: llmBaseUrl(), base_url_runtime: !!cfg.llm_base_url, default_base: LLM_DEFAULT_BASE,
@@ -1917,12 +1961,13 @@ const server = http.createServer(async (req, res) => {
1917
1961
  sceneChanged = sp !== sceneProfile();
1918
1962
  cfg.scene_profile = sp;
1919
1963
  }
1920
- if (arm_api_base !== undefined) {
1964
+ if (arm_api_base !== undefined) { // 兼容旧字段:改的是当前源(多源增删改走 /api/arm/sources)
1921
1965
  const u = normUrl(arm_api_base);
1922
1966
  if (u && !/^https?:\/\/[^\s/]+/.test(u)) return json(res, 400, { error: `站点地址须为 http(s) URL:${u}` });
1923
- cfg.arm_api_base = u;
1967
+ if (!u && armCurrent().id !== SRC_DEFAULT_ID) return json(res, 400, { error: '非默认源地址不能为空(默认源留空=回落 env/内置默认)' });
1968
+ armCurrent().base = u;
1924
1969
  }
1925
- if (arm_token !== undefined) cfg.arm_token = String(arm_token || '').trim(); // 置空 = 回落 env/PAT
1970
+ if (arm_token !== undefined) armCurrent().token = String(arm_token || '').trim(); // 置空:默认源回落 env/PAT,其他源转匿名
1926
1971
  if (llm_base_url !== undefined) {
1927
1972
  const u = normUrl(llm_base_url);
1928
1973
  if (u && !/^https?:\/\/[^\s/]+/.test(u)) return json(res, 400, { error: `LLM 端点须为 http(s) URL:${u}` });
@@ -1961,7 +2006,7 @@ const server = http.createServer(async (req, res) => {
1961
2006
  try { llm_probe = await llmProbe(); } catch (e) { llm_probe = { online: false, error: String(e.message).slice(0, 150) }; }
1962
2007
  }
1963
2008
  saveCfg();
1964
- audit({ actor: 'settings', action: 'save', arm_api_base: cfg.arm_api_base, 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 });
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 });
1965
2010
  // ARM 保存后即时探测(失败不回滚:允许先配置后启动 ARM)
1966
2011
  let probe = { online: false, error: '' };
1967
2012
  if ([arm_api_base, arm_token].some((v) => v !== undefined)) {
@@ -1976,6 +2021,59 @@ const server = http.createServer(async (req, res) => {
1976
2021
  if (llm_probe && !llm_probe.online) notes.push('LLM 已保存但探针未通过(密钥/端点/模型可用性待确认,对话可能失败,可再试或改回)');
1977
2022
  return json(res, 200, { saved: true, probe, llm_probe, note: notes.join(';') });
1978
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
+ }
1979
2077
  if (req.method === 'GET' && pathname === '/api/arm/status') {
1980
2078
  let online = false, latency_ms = null;
1981
2079
  const t0 = Date.now();
@@ -1984,7 +2082,8 @@ const server = http.createServer(async (req, res) => {
1984
2082
  online = true; latency_ms = Date.now() - t0;
1985
2083
  } catch { }
1986
2084
  const reg = loadRegistry();
1987
- return json(res, 200, { online, latency_ms, api: armApiBase(), pat: !!ARM_PAT, last_sync_at: reg.last_sync_at || null, local_count: reg.agents.length, outbox_pending: outboxCount() });
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() });
1988
2087
  }
1989
2088
  if (req.method === 'POST' && pathname === '/api/arm/sync') {
1990
2089
  try { return json(res, 200, await armSync()); }
@@ -1995,6 +2094,7 @@ const server = http.createServer(async (req, res) => {
1995
2094
  if (req.method === 'GET' && pathname === '/api/agents') {
1996
2095
  const kw = (url.searchParams.get('keyword') || '').trim().toLowerCase();
1997
2096
  const reg = loadRegistry();
2097
+ const src = armCurrent();
1998
2098
  let online = true, remoteCards = [], authError = false;
1999
2099
  try { remoteCards = await armListAgents(url.searchParams.get('keyword') || ''); } // REST 全量
2000
2100
  catch (e) { online = false; authError = /令牌未配置或无权限/.test(String(e.message)); } // 区分"未授权"与"不可达",前端引导配置令牌
@@ -2003,17 +2103,22 @@ const server = http.createServer(async (req, res) => {
2003
2103
  const lm = localManifest(e.agent_id) || {};
2004
2104
  // 搜索时本地库同样按关键词过滤(名称/描述),与远端搜索结果口径一致
2005
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);
2006
2109
  byId.set(e.agent_id, {
2007
2110
  created_via_workstation: !!e.created_via_workstation, // 工作站新建的助手:大厅提供编辑档案/从 ARM 删除
2008
2111
  agent_id: e.agent_id, name: e.name, version: e.installed_version,
2009
2112
  skills: (lm.skills || []).length, knowledges: (lm.knowledges || []).length,
2010
2113
  description: e.description || lm.description || '',
2011
2114
  deployed: !!loadDeployment(e.agent_id), installed: true,
2012
- installed_version: e.installed_version, remote_version: e.remote_version || null,
2013
- update_available: !!(e.remote_version && cmpVersion(e.remote_version, e.installed_version) > 0),
2014
- 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,
2015
2118
  created_by_id: null, created_by: null, // 属主身份以远端目录为准(离线/已下架不可编辑,无需属主)
2016
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)
2017
2122
  });
2018
2123
  }
2019
2124
  if (online) { // 静默刷新远端版本与下架标记(显式事件统计走 /api/arm/sync)——仅在完整拉取时;
@@ -2027,10 +2132,12 @@ const server = http.createServer(async (req, res) => {
2027
2132
  deployed: !!loadDeployment(c.agent_id),
2028
2133
  update_available: cur.installed ? cmpVersion(c.version, cur.installed_version) > 0 : false,
2029
2134
  created_by_id: c.created_by_id || null, created_by: c.created_by || null,
2135
+ remote_present: true, foreign: false, delisted: false, // 当前源目录里有它(含跨源发布后两边都有的情况)
2030
2136
  });
2031
2137
  }
2032
2138
  if (!kw) {
2033
2139
  for (const e of reg.agents) {
2140
+ if (!entryBelongs(e, src)) continue; // 其他源的助手不参与当前源的下架/版本判定
2034
2141
  if (ids.has(e.agent_id)) { e.remote_version = byId.get(e.agent_id)?.remote_version || e.remote_version; e.delisted = false; }
2035
2142
  else e.delisted = true;
2036
2143
  }
@@ -2041,7 +2148,7 @@ const server = http.createServer(async (req, res) => {
2041
2148
  // PAT 身份(编辑/删除门控依据:me.id vs 卡片 created_by_id,或 ADMIN):armMe 有缓存——
2042
2149
  // 身份只随 (ARM 源, 令牌) 变化,搜索防抖高频刷新目录时不重复打 /auth/me;失败降级 null 不阻断目录
2043
2150
  const me = online ? await armMe() : null;
2044
- 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 })) });
2045
2152
  }
2046
2153
  // ── 新建助手:本地起草(AI 生成 prompt)→ ARM JSON 创建(创建即发布)→ 可选知识绑定(幂等)→ 下载物化 ──
2047
2154
  if (req.method === 'POST' && pathname === '/api/agents/draft-prompt') {
@@ -2216,13 +2323,16 @@ const server = http.createServer(async (req, res) => {
2216
2323
  return json(res, 200, { ok: true, uninstalled });
2217
2324
  } catch (e) { return json(res, 502, { error: `ARM 删除失败: ${e.message}` }); }
2218
2325
  }
2219
- // 发布到 ARM(本地未发布档案转正):同 id 上行(新版 ARM),身份不变、本地 deployment/工作台/数据零迁移。
2220
- // 幂等:同 id 已在 ARM(上次发布部分成功)→ 校验同名即续走转正;旧版 ARM 忽略 id 返回新 id → 明确报错提示升级。
2326
+ // 发布到当前 ARM 源:本地未发布档案转正,或把来自其他源的已安装助手推送到当前源(公库 → 私库 / 反向)。同 id 上行(新版 ARM),
2327
+ // 身份不变、本地 deployment/工作台/数据零迁移;知识全文随档案复制并绑定(best-effort),技能/UI 资产是 ARM 侧资产不复制、明示跳过。
2328
+ // 幂等:同 id 已在目标源(上次发布部分成功)→ 校验同名即续走;旧版 ARM 忽略 id 返回新 id → 明确报错提示升级。
2221
2329
  if (req.method === 'POST' && pathname.match(/^\/api\/agents\/[0-9a-f-]{36}\/publish$/)) {
2222
2330
  const id = pathname.match(/^\/api\/agents\/([0-9a-f-]{36})\/publish$/)[1];
2223
2331
  const reg = loadRegistry();
2224
2332
  const le = reg.agents.find((x) => x.agent_id === id);
2225
- if (!le || !le.local_only) return json(res, 400, { error: '该助手不是本地未发布档案(可能已发布)' });
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}」,无需发布;要推送到另一个源,先在大厅切换到目标源` });
2226
2336
  const m = localManifest(id);
2227
2337
  if (!m) return json(res, 400, { error: '本地档案缺失,请先重新创建' });
2228
2338
  try {
@@ -2238,12 +2348,22 @@ const server = http.createServer(async (req, res) => {
2238
2348
  }
2239
2349
  const finalId = created?.id || created?.agent?.id;
2240
2350
  if (finalId && finalId !== id) return json(res, 502, { error: '当前 ARM 版本不支持保持助手身份(忽略客户端 id)——请升级 ARM 后再发布' });
2241
- const manifest = await downloadAgent(id); // 重新物化:source 转正、版本对齐 ARM
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
2242
2362
  const reg2 = loadRegistry(); const en = reg2.agents.find((x) => x.agent_id === id);
2243
- if (en) { en.local_only = false; en.remote_version = manifest.version; } saveRegistry(reg2);
2244
- audit({ actor: 'user', action: 'publish-agent', agentId: id, name: m.name, version: manifest.version });
2245
- return json(res, 200, { ok: true, manifest });
2246
- } catch (e) { return json(res, 502, { error: `发布失败: ${e.message}` }); }
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}` }); }
2247
2367
  }
2248
2368
  // ── 本地助手库:下载 / 升级 / 版本历史 / 回滚 / 卸载 ──
2249
2369
  let mAgt = pathname.match(/^\/api\/agents\/([0-9a-f-]{36})\/(download|upgrade|versions|rollback)$/);
@@ -2375,7 +2495,7 @@ const server = http.createServer(async (req, res) => {
2375
2495
  if (!brief || !model) return json(res, 200, { items: GENERIC_INSPIRATIONS, source: 'generic' });
2376
2496
  try {
2377
2497
  const raw = await llmComplete(
2378
- '你是工业智能体平台的「工作台定制灵感策划」。根据助手的自描述,为它的定制工作台生成 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":"完整需求"},不要输出其他任何文字。',
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":"完整需求"},不要输出其他任何文字。',
2379
2499
  `助手自描述:\n${brief}`,
2380
2500
  );
2381
2501
  const arr = JSON.parse((raw.match(/\[[\s\S]*\]/) || [''])[0]);
@@ -2426,7 +2546,7 @@ const server = http.createServer(async (req, res) => {
2426
2546
  if (!dep) return json(res, 400, { error: '助手未上岗' });
2427
2547
  const arm = await getArmAgent(agent_id);
2428
2548
  const toolsAvail = dep.toolset.map(t => t.tool).join(', ');
2429
- 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 代码围栏。';
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 代码围栏。';
2430
2550
  const user = `助手: ${arm.name} ${arm.version}\n职责: ${arm.prompt}\n可用工具: ${toolsAvail}\n默认自主级别: ${dep.default_autonomy}\n请生成 4-6 个面板,含一个 agent_dialog 和一个 action_group。`;
2431
2551
  let out;
2432
2552
  try { out = await llmComplete(sys, user); }
@@ -3116,6 +3236,37 @@ if (process.argv.includes('--test-attachments')) {
3116
3236
  process.exit(pass ? 0 : 1);
3117
3237
  }
3118
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
+
3119
3270
  applyLLMConfig().then(() => {
3120
3271
  server.listen(PORT, HOST, () => {
3121
3272
  console.log(`[aims-workstation] http://localhost:${PORT}(绑定 ${HOST}) | 模型: ${model.id} @ ${model.baseUrl}`);
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: workbench-dev
3
- description: 为 AIMS 工业智能体平台定制工作台面板插件——编写符合面板插件契约的 htm/React 代码、创建数据集、经平台 API(REST)入库发布。适用于任何具备 exec/fetch 能力、基于 pi-ai 的 harness(ZCode、ARM 工作站 Agent、平台内置工程师)。含自探索工作流(读助手 AGENT.md、实测接口)与产出前校验器。
3
+ description: 为 AIMS 智能体平台定制工作台面板插件——编写符合面板插件契约的 htm/React 代码、创建数据集、经平台 API(REST)入库发布。适用于任何具备 exec/fetch 能力、基于 pi-ai 的 harness(ZCode、ARM 工作站 Agent、平台内置工程师)。含自探索工作流(读助手 AGENT.md、实测接口)与产出前校验器。
4
4
  version: 0.1.0
5
5
  author: aims-platform
6
6
  ---