aims-workstation 0.1.8 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example CHANGED
@@ -16,4 +16,4 @@ ARM_API_TOKEN=<arm_pat_xxx-or-leave-empty>
16
16
 
17
17
  # npm 发版令牌(npmjs.com → Access Tokens 生成;供 scripts/publish-npm.sh 使用,发布免浏览器授权)
18
18
  # 选 Granular(限定本包、Read and write)或 Classic → Automation
19
- # NPM_TOKEN=npm_xxxxxxxxxxxxxxxxxxxx
19
+ # NPM_TOKEN=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
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.8",
16
+ "version": "0.1.11",
17
17
  "description": "AIMS 工作站:本地智能体工作站——ARM 助手本地化(下载/离线/升级)、工作台对话式定制、多工作区隔离、分级自主与审计",
18
18
  "bin": {
19
19
  "aims-workstation": "bin/aims-workstation.js"
@@ -16,9 +16,9 @@ if (/\b(window|document|globalThis)\b/.test(code)) errors.push('E2 沙箱违规
16
16
  // E3 模板完整性:反引号必须成对
17
17
  const ticks = (code.match(/`/g) || []).length;
18
18
  if (ticks % 2 !== 0) errors.push(`E3 模板断裂:反引号数量为奇数(${ticks}),html\`\` 模板未闭合`);
19
- // E4 编译检查:与运行时一致的 new Function 编译
19
+ // E4 编译检查:与运行时一致的 new Function 编译(运行时还注入 ui = panel-kit 组件集,见 web/src/components/panel-kit.tsx)
20
20
  try {
21
- const factory = new Function('React', 'html', 'useState', 'useEffect', 'useRef', 'useMemo',
21
+ const factory = new Function('React', 'html', 'ui', 'useState', 'useEffect', 'useRef', 'useMemo',
22
22
  `"use strict";\n${code}\n;if (typeof Panel !== 'function') throw new Error('no Panel');\nreturn Panel;`);
23
23
  if (typeof factory !== 'function') errors.push('E4 编译失败');
24
24
  } catch (e) { errors.push(`E4 编译失败:${e.message}`); }
@@ -34,6 +34,10 @@ if (/onInput/.test(code) && /onChange/.test(code)) warnings.push('W2 同时使
34
34
  if (!/key=/.test(code) && /\.map\(/.test(code)) warnings.push('W3 列表渲染(.map)缺少 key 属性');
35
35
  if (/await /.test(code) && !/try\s*{|\.catch\(/.test(code)) warnings.push('W4 存在 await 但无 try/catch 或 .catch,失败会静默');
36
36
  if (!/(loading|加载|…|\.\.\.)|useEffect/.test(code) && /store\.(list|create)/.test(code)) warnings.push('W5 异步读写无加载态提示,首帧可能空白');
37
+ // W6:首屏疑似标题行——独立 font-semibold 文本行会与平台面板头重复标识(面板标题由网格渲染成面板头)
38
+ if (/<div[^>]*\bfont-semibold\b[^>]*>([^<]*\$\{[^}]*\})?[^<]*<\/div>/.test(code)) {
39
+ warnings.push('W6 疑似标题行(font-semibold 独立文本行):面板标题由平台面板头渲染,插件首屏直接进入内容,勿重复标题');
40
+ }
37
41
 
38
42
  const verdict = errors.length ? 'REJECT' : 'PASS';
39
43
  const lines = [
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: '', scene_profile: '', access_token: '' };
79
+ const cfg = { arm_api_base: '', arm_token: '', llm_base_url: '', llm_api_key: '', llm_model: '', llm_context_tokens: 0, scene_profile: '', access_token: '' };
80
80
  // 守护栏默认策略(可在「系统设置·守护栏」维护,存 platform.json,保存即热生效):deny=正则源串(命中即永久拒绝,任何级别不豁免);
81
81
  // ro=只读白名单命令名(免确认直行);sudo_u_l2=true 时 `sudo -u <用户>` 降权形式不再永久拒绝、按写命令走 L2 人工确认(裸 sudo 仍拒绝)
82
82
  const GUARD_DEFAULT = {
@@ -106,6 +106,9 @@ const LLM_DEFAULT_BASE = 'http://bifrost.infra.dev.aimstek.cn/v1';
106
106
  const llmBaseUrl = () => cfg.llm_base_url || env.LLM_BASE_URL || LLM_DEFAULT_BASE;
107
107
  const llmApiKey = () => cfg.llm_api_key || env.LLM_API_KEY || 'def';
108
108
  const llmModelName = () => cfg.llm_model || env.LLM_MODEL || 'def';
109
+ // 上下文窗口(token):设置页可配(跟随所选模型的真实窗口),驱动会话历史裁剪预算;0/未配 = 默认 128K
110
+ const LLM_DEFAULT_CONTEXT_TOKENS = 128_000;
111
+ const llmContextTokens = () => { const n = +cfg.llm_context_tokens; return Number.isFinite(n) && n > 0 ? n : LLM_DEFAULT_CONTEXT_TOKENS; };
109
112
  // 场景画像:平台级、全局一份的用户场景描述(团队环境/系统清单/规范),注入所有助手;空则不注入。
110
113
  // 与知识检索互补(常驻事实 vs 按需检索);凭据不进画像(放"凭据在哪、找谁要")。
111
114
  const sceneProfile = () => String(cfg.scene_profile || '').trim();
@@ -175,9 +178,24 @@ async function llmProbe() { // 设置页「保存并测试」:一次极小补
175
178
  }
176
179
  async function llmComplete(systemPrompt, userText) { // 无工具的一次性调用(起草/摘要)
177
180
  const r = await models.complete(model, { systemPrompt, messages: [{ role: 'user', content: userText, timestamp: Date.now() }], tools: [] });
181
+ // pi-ai 把认证/请求失败表现为 stopReason=error 而非异常(同 llmProbe 判定)——不抛会变成空串静默流向调用方
182
+ if (r.stopReason === 'error') throw new Error(`LLM 请求失败: ${r.errorMessage || r.rawStopReason || 'stop=error'}`);
178
183
  return (r.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
179
184
  }
180
185
 
186
+ // 助手自描述(灵感库输入):AGENT.md 优先,manifest description/prompt 兜底
187
+ function agentBrief(agentId) {
188
+ const la = localAgent(agentId);
189
+ if (!la || !la.installed_version) return '';
190
+ const dir = path.join(ARM_AGENTS_DIR, agentId, 'versions', la.installed_version);
191
+ try { return fs.readFileSync(path.join(dir, 'AGENT.md'), 'utf8').slice(0, 3000) } catch { /* 无 AGENT.md */ }
192
+ try {
193
+ const m = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
194
+ return [m.description, m.prompt].filter(Boolean).join('\n').slice(0, 3000);
195
+ } catch { return '' }
196
+ }
197
+ const inspCache = new Map(); // agent 自描述指纹 → 灵感列表(LLM 一次生成,档案不变即复用)
198
+
181
199
  // ── 现场数据容器:真实数据源(AIMS-MOS)未接入前保持为空——面板按空态渲染,不造假数据
182
200
  //(2026-10-15 MVP 打磨:移除模拟立库场景与工厂画像注入,见 README)
183
201
  const world = { devices: [], alarms: [], kpi: {}, work_orders: [], inspections: {} };
@@ -1304,17 +1322,18 @@ const DEV_TOOLS = {
1304
1322
  const devCtx = { agentId: null };
1305
1323
  const devSessions = new Map();
1306
1324
 
1307
- // 定制会话上下文裁剪:前端 sessionId 固定为 dev-<agentId前缀>,会话跨请求长存——不裁剪会无限增长,
1308
- // 最终超过模型上下文窗口被网关整单拒绝(2026-10-21 海立实例"发消息没反应"根因)。
1325
+ // 会话上下文裁剪(dev/主对话共用):前端 sessionId 固定(dev-<agentId前缀> / chat-<agentId>-<wsId>),会话跨请求长存——
1326
+ // 不裁剪会无限增长,最终超过模型上下文窗口被网关整单拒绝(2026-10-21 海立实例"发消息没反应"根因)。
1327
+ // 预算随设置页「上下文窗口(token)」联动:预留 16K token 给系统提示/当前轮/输出,1.6 char/token 对中文最坏比例(~0.67 token/char)仍在预留内。
1328
+ const ctxCharBudget = () => Math.max(4_000, Math.floor((llmContextTokens() - 16_000) * 1.6));
1309
1329
  // 超预算从最旧轮次整轮丢弃;下刀点只选 user 消息边界,剩余前缀不会出现孤儿 toolResult(转换层要求其紧跟 assistant 的 toolCall);当前轮永不裁。
1310
- const DEV_CTX_CHAR_BUDGET = 150_000; // 字符预算:混合中英/代码转录按 ~0.4 token/char 估 ≈ 60K token,对 128K 窗口留足余量
1311
- function trimDevContext(ctx) {
1330
+ function trimContext(ctx, budget = ctxCharBudget()) {
1312
1331
  const msgs = ctx.messages;
1313
1332
  const size = () => msgs.reduce((n, m) => n + JSON.stringify(m.content ?? '').length, 0);
1314
- if (size() <= DEV_CTX_CHAR_BUDGET) return 0;
1333
+ if (size() <= budget) return 0;
1315
1334
  const lastUser = msgs.length - 1 - [...msgs].reverse().findIndex((m) => m.role === 'user');
1316
1335
  let dropped = 0;
1317
- while (size() > DEV_CTX_CHAR_BUDGET) {
1336
+ while (size() > budget) {
1318
1337
  const cut = msgs.slice(1, lastUser - dropped).findIndex((m) => m.role === 'user');
1319
1338
  if (cut < 0) break; // 当前轮之前再无完整轮次可丢(单轮就超预算只能放行,交由模型侧报错)
1320
1339
  dropped += cut + 1;
@@ -1340,24 +1359,28 @@ function devSystemPrompt() {
1340
1359
  '插件是一段 JS 代码,必须定义:function Panel(props) { return html`...` }',
1341
1360
  '· html 是 htm 模板标签(绑定 React.createElement),写法:html`<div className="text-xs">${x}</div>`',
1342
1361
  '· 可用 hooks:useState / useEffect / useRef / useMemo(已注入,直接用)',
1362
+ '· 注入标识符 ui = 面板组件库(优先使用,禁止手拼等价样式):',
1363
+ ' ui.Card(卡片段落,title 仅用于面板内多节分节) ui.Badge(徽标) ui.Button(原生按钮) ui.Stat(KPI卡,可带 trend 趋势线)',
1364
+ ' ui.Spark(迷你趋势线) ui.Bars(横向条形) ui.Matrix(状态点阵) ui.StatusDot/ui.StatusItem(统一状态行,列表一律用它)',
1365
+ ' ui.Empty(空态插画) ui.Loading(骨架屏) ui.Error(错误态)——异步面板三态必用后三者',
1366
+ ' 组件写法:html`<${ui.StatusItem} key=${d.id} tone="warning" title=${d.id} detail=${d.detail} onClick=${() => onSelect(d.id)} />`',
1367
+ '· 【标题分工·重要】面板标题/图标由平台统一渲染成面板头——插件首屏直接进入内容,禁止再写标题行(font-semibold 独立大字标题)',
1343
1368
  '· props(实时现场数据 + 交互):',
1344
1369
  ' devices:[{id,name,type,status:normal|warning|alarm,detail}] alarms:[{id,device,level:高|中|低,text,ts,state}]',
1345
1370
  ' kpi:{出入库吞吐:...} work_orders:[{id,device,priority,state}] selected(当前选中设备id) onSelect(id)(选中设备)',
1346
- '· 样式用 Tailwind className(暗色主题;卡片 bg-card border rounded-lg p-3;主色 text-primary;状态色 emerald/amber/red)',
1371
+ ' store: 平台数据集读写(store.list/create/update/remove,按助手×工作区隔离)——跨面板业务联动走共享数据集',
1372
+ '· 手写样式时用 Tailwind className(暗色主题):语义色 text-primary/text-success/text-warning/text-destructive/text-muted-foreground;底 bg-card/bg-muted;数值 tabular-nums 等宽;布局紧凑(面板常为窄条/半宽/通栏)',
1347
1373
  '· 禁止 import/require/window 操作;需要更多数据可 fetch("/api/state")',
1348
- '· 面板渲染于工作台左栏(宽约 290px)或中栏(dsl 里 slot:"center"),布局要紧凑',
1349
1374
  '',
1350
1375
  '【参考实现(严格仿照此风格)】',
1351
1376
  'function Panel({ devices, selected, onSelect }) {',
1352
1377
  ' const bad = devices.filter(d => d.status !== "normal")',
1353
- ' return html`<div className="p-2 text-xs">',
1354
- ' <div className="mb-2 font-semibold">异常设备 ${bad.length} 台</div>',
1355
- ' ${devices.filter(d=>d.status!=="normal").map(d => html`',
1356
- ' <div key=${d.id} onClick=${() => onSelect(d.id)}',
1357
- ' className=${"mb-1 cursor-pointer rounded border p-1.5 " + (d.status==="alarm" ? "border-red-500/60 bg-red-500/10" : "border-amber-500/50 bg-amber-500/5")}>',
1358
- ' <span className="font-mono">${d.id}</span> <span className="text-muted-foreground">${d.name}</span>',
1359
- ' <div className="text-[10px] text-muted-foreground">${d.detail}</div>',
1360
- ' </div>`)}',
1378
+ ' return html`<div className="space-y-1.5 p-2">',
1379
+ ' ${bad.length === 0 ? html`<${ui.Empty} text="设备运行全部正常" />`',
1380
+ ' : bad.map(d => html`<${ui.StatusItem} key=${d.id}',
1381
+ ' tone=${d.status === "alarm" ? "destructive" : "warning"}',
1382
+ ' title=${d.id} detail=${d.detail} meta=${d.name}',
1383
+ ' active=${selected === d.id} onClick=${() => onSelect(d.id)} />`)}',
1361
1384
  ' </div>`',
1362
1385
  '}',
1363
1386
  '',
@@ -1368,7 +1391,7 @@ function devSystemPrompt() {
1368
1391
  '【自探索工作流(必须遵守)】理解需求 → 探索 → 规划 → 实现 → 提交草稿:',
1369
1392
  '1. 探索:新需求涉及助手既有能力时,必须先 exec 读它的 AGENT.md(按名 grep 定位),必要时读其 skills/脚本与验证相关接口(curl 实测返回结构)。能推送通常就能搜索——从脚本/配置里找到真实的服务地址与 API。',
1370
1393
  '2. 规划:把探索结论映射为面板方案(数据从哪来、经什么能力取、展示成什么),方案要说清"复用了助手的哪个既有能力"。',
1371
- '3. 实现:面板运行在浏览器,内网接口跨域受限——一律经平台代理取数:fetch("/api/proxy?url=" + encodeURIComponent(接口地址)),返回 {status, body}。',
1394
+ '3. 实现:面板运行在浏览器,内网接口跨域受限——一律经平台代理取数:fetch("/api/proxy?url=" + encodeURIComponent(接口地址)),返回 {status, body}。慢接口(>8s 的重查询)追加 &timeout_ms=30000(上限 60s)与 &max_kb=512(默认 128KB 会截断大结果);但优先改用服务端过滤/分页参数(q=/limit/时间范围)减小单次拉取——不要靠放宽超时硬扛全量拉取。',
1372
1395
  '4. 提交:write_panel_plugin + update_workbench 草稿,明确告知用户审阅发布。',
1373
1396
  '【工作方式】改已有面板时基于当前 DSL 增量修改;单一职责,先小后大。',
1374
1397
  '【已装载技能(用前先 load_skill 读全文)】',
@@ -1417,7 +1440,7 @@ async function devChatLoop(session, emit, signal = null) {
1417
1440
  }
1418
1441
  final = await stream.result();
1419
1442
  // pi-ai 把认证/请求失败表现为 stopReason=error 而非异常(同 llmProbe 判定)——不检查会被吞成静默空回复
1420
- if (final?.stopReason === 'error') throw new Error(`LLM 请求失败: ${final.error?.errorMessage || JSON.stringify(final.error || {}).slice(0, 200) || final.stopReason}`);
1443
+ if (final?.stopReason === 'error') throw new Error(`LLM 请求失败: ${final.errorMessage || final.rawStopReason || 'stop=error'}`);
1421
1444
  } catch (e) {
1422
1445
  if (!interrupted()) throw e; // 真实错误上抛给路由 catch → SSE error → 前端 toast;中断路径用占位消息兜底
1423
1446
  }
@@ -1483,12 +1506,13 @@ function systemPrompt(armAgent, deployment, autonomy, ws) {
1483
1506
  '',
1484
1507
  '【富回复块(a2ui)】当回复更适合组件而非长文本时,输出 ```a2ui 围栏块(内为严格 JSON,应用原生渲染):',
1485
1508
  '- choice:需要用户做一个决定(2-5 个选项,可标 recommended:true;块必须是回复结尾;用户点击后以纯文本回传)',
1509
+ '- 多维度选择(向导):需要用户一次确定多个维度(如 设备+等级+时间范围)时,输出多个**连续** choice 块(彼此间不夹文字,均位于回复结尾)——应用自动合并为分步向导,用户逐题选择、全部完成确认后一次性回传(每行"问题: 答案");不要为多个维度分别发起独立提问',
1486
1510
  '- form:一次收集多个输入(1-6 字段 {id,label,kind,placeholder,required};提交按"label: 答案"逐行回传)',
1487
1511
  '- steps:手工操作步骤(每步一句祈使句;warning/caution 放在它保护的那一步上)',
1488
1512
  '- callout:必须被注意的单一警示/提示(tone: note|tip|caution|warning)',
1489
1513
  '- metrics:少量读数一览(items:[{label,value,unit,warn,danger}],注明 asOf)',
1490
- '规则:块前要有一句引导语;choice/form 之后不再有文字;数据必须来自工具实测并注明 asOf,不得编造;一个回复最多 1-2 个块。用户说"给选项/怎么选/选哪个"时优先用 choice。',
1491
- '强化:凡回复包含 2 个及以上并列的可选动作时,一律用 choice 块呈现(不要用编号/短横线列表罗列选项)。需要采集多个参数(设备/日期/原因等)时用 form 块。示例:\n服务器 CPU 持续 90%,如何处置?\n```a2ui\n{"type":"choice","question":"服务器 CPU 90% 的处置方式","options":[{"label":"先持续观察 30 分钟","description":"短期可控"},{"label":"定位高 CPU 进程","description":"top 找出元凶再决定","recommended":true},{"label":"立即重启服务","description":"影响可用性,需值班确认"}]}\n```',
1514
+ '规则:块前要有一句引导语;choice/form 之后不再有文字;数据必须来自工具实测并注明 asOf,不得编造;一个回复最多 1-2 个块(例外:多维度选择的连续 choice 串,按一组合并向导计)。用户说"给选项/怎么选/选哪个"时优先用 choice。',
1515
+ '强化:凡回复包含 2 个及以上并列的可选动作时,一律用 choice 块呈现(不要用编号/短横线列表罗列选项)。需要采集多个输入(设备/日期/原因等)时用 form 块。示例:\n服务器 CPU 持续 90%,如何处置?\n```a2ui\n{"type":"choice","question":"服务器 CPU 90% 的处置方式","options":[{"label":"先持续观察 30 分钟","description":"短期可控"},{"label":"定位高 CPU 进程","description":"top 找出元凶再决定","recommended":true},{"label":"立即重启服务","description":"影响可用性,需值班确认"}]}\n```\n多维度选择示例(连续 choice 串 = 分步向导,用户全选完才回传):生成巡检报告前请确认范围:\n```a2ui\n{"type":"choice","question":"设备范围","options":[{"label":"STK-R1 巷道堆垛机"},{"label":"STK-R3 巷道堆垛机","recommended":true},{"label":"全部立库设备"}]}\n```\n```a2ui\n{"type":"choice","question":"时间范围","options":[{"label":"近 24 小时","recommended":true},{"label":"近 7 天"},{"label":"自定义"}]}\n```\n```a2ui\n{"type":"choice","question":"报告侧重","options":[{"label":"异常与告警优先","recommended":true},{"label":"全面均衡"}]}\n```',
1492
1516
  ].join('\n');
1493
1517
  }
1494
1518
 
@@ -1643,6 +1667,7 @@ const server = http.createServer(async (req, res) => {
1643
1667
  model: llmModelName(), model_runtime: !!cfg.llm_model, catalog: llmCatalog,
1644
1668
  key_set: !!k, key_masked: k ? `${k.slice(0, 6)}…${k.slice(-4)}` : '',
1645
1669
  key_source: cfg.llm_api_key ? 'runtime' : (env.LLM_API_KEY ? 'env' : 'none'),
1670
+ context_tokens: llmContextTokens(), context_tokens_runtime: !!cfg.llm_context_tokens, default_context_tokens: LLM_DEFAULT_CONTEXT_TOKENS,
1646
1671
  },
1647
1672
  scene: { profile: sceneProfile(), length: sceneProfile().length },
1648
1673
  auth: { enabled: !!accessToken(), token_masked: accessToken() ? `${accessToken().slice(0, 4)}…${accessToken().slice(-2)}` : '' },
@@ -1650,7 +1675,7 @@ const server = http.createServer(async (req, res) => {
1650
1675
  });
1651
1676
  }
1652
1677
  if (req.method === 'PUT' && pathname === '/api/settings') {
1653
- const { arm_api_base, arm_token, llm_base_url, llm_api_key, llm_model, scene_profile, access_token, guard } = await body(req);
1678
+ 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);
1654
1679
  // 守护栏维护:deny 正则可编译 / ro 须为单命令名;保存即热生效(classifyExec 每次调用现读配置);删减危险规则记入审计
1655
1680
  let guardAudit = null;
1656
1681
  if (guard !== undefined) {
@@ -1692,6 +1717,15 @@ const server = http.createServer(async (req, res) => {
1692
1717
  if (m && !/^[\w.:@\/-]{1,80}$/.test(m)) return json(res, 400, { error: `模型名不合法:${m}` });
1693
1718
  cfg.llm_model = m;
1694
1719
  }
1720
+ if (llm_context_tokens !== undefined) { // 上下文窗口(token):驱动会话裁剪预算;留空/0 = 默认 128K。不影响 provider,无需热切换
1721
+ const raw = String(llm_context_tokens ?? '').trim();
1722
+ if (raw === '') cfg.llm_context_tokens = 0;
1723
+ else {
1724
+ const n = Math.floor(+raw);
1725
+ if (!Number.isFinite(n) || n < 4_000 || n > 2_000_000) return json(res, 400, { error: `上下文窗口须为 4000-2000000 的 token 数(留空 = 默认 ${LLM_DEFAULT_CONTEXT_TOKENS}):${raw}` });
1726
+ cfg.llm_context_tokens = n;
1727
+ }
1728
+ }
1695
1729
  if (llm_api_key !== undefined) cfg.llm_api_key = String(llm_api_key || '').trim();
1696
1730
  // LLM 热切换:先应用验证,失败整体回滚(应用只是构建对象,无网络请求)
1697
1731
  let llm_probe = null;
@@ -1706,7 +1740,7 @@ const server = http.createServer(async (req, res) => {
1706
1740
  try { llm_probe = await llmProbe(); } catch (e) { llm_probe = { online: false, error: String(e.message).slice(0, 150) }; }
1707
1741
  }
1708
1742
  saveCfg();
1709
- audit({ actor: 'settings', action: 'save', arm_api_base: cfg.arm_api_base, llm_model: cfg.llm_model, llm_base_changed: llm_base_url !== undefined, token_changed: arm_token !== undefined || llm_api_key !== undefined, scene_profile_changed: sceneChanged, scene_profile_len: sceneProfile().length, auth_changed: authChanged, guard_changed: guardAudit });
1743
+ 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_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 });
1710
1744
  // ARM 保存后即时探测(失败不回滚:允许先配置后启动 ARM)
1711
1745
  let probe = { online: false, error: '' };
1712
1746
  if ([arm_api_base, arm_token].some((v) => v !== undefined)) {
@@ -1794,8 +1828,12 @@ const server = http.createServer(async (req, res) => {
1794
1828
  };
1795
1829
  const stream = models.stream(model, ctx);
1796
1830
  let out = '';
1797
- for await (const ev of stream) { if (ev.type === 'text_delta') out += ev.delta; }
1831
+ for await (const ev of stream) {
1832
+ if (ev.type === 'text_delta') out += ev.delta;
1833
+ else if (ev.type === 'error') throw new Error(`LLM 流错误: ${ev.error?.errorMessage || JSON.stringify(ev.error).slice(0, 200)}`); // pi-ai 失败不抛异常,须显式拦截,否则 200 返回空 prompt
1834
+ }
1798
1835
  const final = await stream.result();
1836
+ if (final?.stopReason === 'error') throw new Error(`LLM 请求失败: ${final.errorMessage || final.rawStopReason || 'stop=error'}`);
1799
1837
  const text = ((final.content || []).filter(b => b.type === 'text').map(b => b.text).join('') || out).trim();
1800
1838
  let draftName = '', prompt = text;
1801
1839
  const jm = text.match(/\{[\s\S]*\}/); // JSON 容错解析:失败则整段当 prompt,不阻断
@@ -1977,6 +2015,38 @@ const server = http.createServer(async (req, res) => {
1977
2015
  if (!loadWorkbench(agent_id)) saveWorkbench(agent_id, defaultWorkbench(dep)); // 新上岗自动生成初版工作台
1978
2016
  return json(res, 200, { deployment: dep });
1979
2017
  }
2018
+ // ── 二开灵感库:按助手自描述动态生成(固定库面向设备场景,对其他助手是噪声)──
2019
+ if (req.method === 'GET' && pathname === '/api/dev/inspirations') {
2020
+ const agentId = url.searchParams.get('agent_id') || '';
2021
+ const brief = agentBrief(agentId);
2022
+ const refresh = url.searchParams.get('refresh') === '1'; // 换一批:绕缓存重新生成并覆盖
2023
+ const key = `${agentId}|${brief.length}|${brief.slice(0, 200)}`;
2024
+ const hit = refresh ? null : inspCache.get(key);
2025
+ if (hit) return json(res, 200, { items: hit, source: 'agent' });
2026
+ const GENERIC_INSPIRATIONS = [
2027
+ { emoji: '📋', title: '最近记录', desc: '该助手数据集/操作的最近 20 条时间线', prompt: '新建一个区块:展示我数据集里最近 20 条记录的倒序时间线(ui.StatusItem,按重要程度着色),数据不足时先建合适的数据集' },
2028
+ { emoji: '⭐', title: '关键指标', desc: '一眼看当前状态的大数字卡', prompt: '新建一个区块:用 ui.Stat 大数字卡展示当前最关键的状态指标(数据从 /api/state 或数据集取),异常着色' },
2029
+ { emoji: '✅', title: '待办清单', desc: '可勾选的任务列表', prompt: '先建 todos 数据集(title/done/due),再做一个面板:ui.StatusItem 待办列表 + 完成态切换(store.update),顶部 ui.Button 清除已完成' },
2030
+ { emoji: '📊', title: '分类统计', desc: '按类型/状态计数条形图', prompt: '新建一个区块:对相关数据按类型或状态聚合计数,用 ui.Bars 横向条形展示' },
2031
+ { emoji: '🔍', title: '快速检索', desc: '对其数据集的关键词搜索面板', prompt: '新建一个区块:输入关键词检索我的数据集(store.list filter 或接口查询),结果用 ui.StatusItem 列表展示,带加载/空/错误三态' },
2032
+ { emoji: '🕒', title: '活动流水', desc: '今天发生的事件流', prompt: '新建一个区块:今天发生的事件/变更流水(按时间倒序),新条目突出显示,支持点击查看详情' },
2033
+ ];
2034
+ if (!brief || !model) return json(res, 200, { items: GENERIC_INSPIRATIONS, source: 'generic' });
2035
+ try {
2036
+ const raw = await llmComplete(
2037
+ '你是工业智能体平台的「工作台定制灵感策划」。根据助手的自描述,为它的定制工作台生成 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":"完整需求"},不要输出其他任何文字。',
2038
+ `助手自描述:\n${brief}`,
2039
+ );
2040
+ const arr = JSON.parse((raw.match(/\[[\s\S]*\]/) || [''])[0]);
2041
+ const items = Array.isArray(arr) ? arr.filter((x) => x && x.title && x.prompt).slice(0, 6).map((x) => ({ emoji: String(x.emoji || '✨').slice(0, 2), title: String(x.title).slice(0, 12), desc: String(x.desc || '').slice(0, 40), prompt: String(x.prompt) })) : [];
2042
+ if (!items.length) throw new Error('空结果');
2043
+ inspCache.set(key, items);
2044
+ return json(res, 200, { items, source: 'agent' });
2045
+ } catch {
2046
+ return json(res, 200, { items: GENERIC_INSPIRATIONS, source: 'generic' });
2047
+ }
2048
+ }
2049
+
1980
2050
  if (req.method === 'GET' && pathname === '/api/tools') {
1981
2051
  return json(res, 200, { tools: Object.entries(TOOLS).map(([n, t]) => ({ name: n, description: t.desc, min_level: t.min_level, confirm_required: t.confirm_required })) });
1982
2052
  }
@@ -2017,7 +2087,9 @@ const server = http.createServer(async (req, res) => {
2017
2087
  const toolsAvail = dep.toolset.map(t => t.tool).join(', ');
2018
2088
  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 代码围栏。';
2019
2089
  const user = `助手: ${arm.name} ${arm.version}\n职责: ${arm.prompt}\n可用工具: ${toolsAvail}\n默认自主级别: ${dep.default_autonomy}\n请生成 4-6 个面板,含一个 agent_dialog 和一个 action_group。`;
2020
- const out = await llmComplete(sys, user);
2090
+ let out;
2091
+ try { out = await llmComplete(sys, user); }
2092
+ catch (e) { return json(res, 502, { error: `AI 草稿生成失败: ${e.message}` }); }
2021
2093
  const cleaned = out.replace(/```json|```/g, '').trim();
2022
2094
  let draft;
2023
2095
  try { draft = JSON.parse(cleaned); } catch (e) { return json(res, 502, { error: 'AI 草稿解析失败', raw: cleaned.slice(0, 500) }); }
@@ -2046,15 +2118,25 @@ const server = http.createServer(async (req, res) => {
2046
2118
  if (!proxyAllowedHosts().has(host)) {
2047
2119
  return json(res, 403, { error: `主机 ${host} 未在任何插件中声明(代理仅允许插件代码中出现过的主机,防面板成为任意内网跳板)` });
2048
2120
  }
2121
+ // 按次声明的时限/体量(插件代码里可见、人审可查):轻查询用默认,重接口单独放宽,钳制防无界
2122
+ const clamp = (v, min, max, dflt) => { if (v === null || v === undefined || v === '') return dflt; const n = Math.round(+v); return Number.isFinite(n) ? Math.min(Math.max(n, min), max) : dflt }
2123
+ const timeoutMs = clamp(url.searchParams.get('timeout_ms'), 1000, 60000, 8000);
2124
+ const maxKb = clamp(url.searchParams.get('max_kb'), 16, 512, 128);
2125
+ const t0 = Date.now();
2049
2126
  try {
2050
2127
  const ctrl = new AbortController();
2051
- const timer = setTimeout(() => ctrl.abort(), 8000);
2128
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
2052
2129
  const r = await fetch(u, { signal: ctrl.signal });
2053
2130
  clearTimeout(timer);
2054
- const body = (await r.text()).slice(0, 128 * 1024);
2055
- audit({ actor: 'panel-proxy', url: u.slice(0, 120), status: r.status });
2131
+ const body = (await r.text()).slice(0, maxKb * 1024);
2132
+ audit({ actor: 'panel-proxy', url: u.slice(0, 120), status: r.status, duration_ms: Date.now() - t0, timeout_ms: timeoutMs, max_kb: maxKb });
2056
2133
  return json(res, 200, { status: r.status, body });
2057
- } catch (e) { return json(res, 502, { error: `代理请求失败: ${e.message}` }); }
2134
+ } catch (e) {
2135
+ const timedOut = e.name === 'AbortError';
2136
+ audit({ actor: 'panel-proxy', url: u.slice(0, 120), error: timedOut ? 'timeout' : String(e.message).slice(0, 80), duration_ms: Date.now() - t0, timeout_ms: timeoutMs });
2137
+ if (timedOut) return json(res, 502, { error: `代理超时(${timeoutMs}ms):接口未在时限内返回。可在代理调用追加 &timeout_ms=30000(上限 60s,重接口按需放宽);更优解是改用服务端过滤/分页参数(q=/limit/时间范围)减小单次拉取量`, timeout_ms: timeoutMs });
2138
+ return json(res, 502, { error: `代理请求失败: ${e.message}` });
2139
+ }
2058
2140
  }
2059
2141
 
2060
2142
  // ── 面板运行错误(debug 的眼睛:错误边界/编译失败上报,供 AI 调试取证)──
@@ -2344,7 +2426,7 @@ const server = http.createServer(async (req, res) => {
2344
2426
  s.context.systemPrompt = devSystemPrompt();
2345
2427
  }
2346
2428
  s.context.messages.push({ role: 'user', content: message, timestamp: Date.now() });
2347
- const dropped = trimDevContext(s.context);
2429
+ const dropped = trimContext(s.context);
2348
2430
  if (dropped > 0) emit('status', { text: `会话历史过长,已自动裁剪最早 ${dropped} 条消息` });
2349
2431
  await devChatLoop(s, emit, ctl.signal);
2350
2432
  emit('meta', { hasDraft: !!loadDraft(agentId) });
@@ -2375,6 +2457,8 @@ const server = http.createServer(async (req, res) => {
2375
2457
  return res.end();
2376
2458
  }
2377
2459
  s.context.messages.push({ role: 'user', content: context ? `${message}\n(界面注入上下文:当前选中设备 ${context.device})` : message, timestamp: Date.now() });
2460
+ const dropped = trimContext(s.context); // 主对话 sessionId 同样固定(chat-<agentId>-<wsId>),不裁剪会同样撑死
2461
+ if (dropped > 0) emit('status', { text: `会话历史过长,已自动裁剪最早 ${dropped} 条消息` });
2378
2462
  s.running = true; s.sinceTs = Date.now(); s.abortCtl = ctl;
2379
2463
  const out = await agentLoop(s, deployment, emit, ctl.signal);
2380
2464
  emit('done', { reply: out.reply, pending: out.pending });
@@ -2418,6 +2502,18 @@ const server = http.createServer(async (req, res) => {
2418
2502
  finally { finished = true; if (s) { s.running = false; s.statusText = ''; s.abortCtl = null; } }
2419
2503
  return res.end();
2420
2504
  }
2505
+ // ── 会话清空:主动丢弃上下文重开(上下文污染/换思路);运行中拒绝(先中断),pending 确认卡随会话一并作废 ──
2506
+ if (req.method === 'POST' && pathname === '/api/session/reset') {
2507
+ const { scope, sessionId } = await body(req);
2508
+ if (!sessionId || !String(sessionId).trim()) return json(res, 400, { error: '缺少 sessionId' });
2509
+ const store = scope === 'dev' ? devSessions : sessions;
2510
+ const s = store.get(String(sessionId).trim());
2511
+ if (!s) return json(res, 200, { ok: true, note: '会话本就为空' });
2512
+ if (s.running) return json(res, 409, { error: '会话正在处理中,请先中断再清空' });
2513
+ store.delete(String(sessionId).trim());
2514
+ audit({ actor: 'user', action: 'session-reset', scope: scope === 'dev' ? 'dev' : 'chat', sessionId: String(sessionId).slice(0, 48) });
2515
+ return json(res, 200, { ok: true });
2516
+ }
2421
2517
  // ── 会话历史与进度(切换助手后回来:上下文回放 + 后台运行状态;服务端为唯一事实源)──
2422
2518
  if (req.method === 'GET' && pathname === '/api/chat/history') {
2423
2519
  const sid = url.searchParams.get('sessionId');
@@ -2470,7 +2566,9 @@ const server = http.createServer(async (req, res) => {
2470
2566
  if (!s) return json(res, 400, { error: '会话不存在' });
2471
2567
  const convo = s.context.messages.filter(m => m.role === 'user' || m.role === 'assistant')
2472
2568
  .slice(-12).map(m => `${m.role === 'user' ? '用户' : '助手'}: ${(m.content || []).filter?.(b => b.type === 'text').map(b => b.text).join('') ?? m.content}`).join('\n');
2473
- const md = await llmComplete('你是经验沉淀器。把运维对话提炼为一条结构化经验 markdown,章节固定:## 现象 / ## 根因 / ## 处置步骤 / ## 验证 / ## 预防。事实不确定就写"待确认"。只输出 markdown 正文。', convo || '(空会话)');
2569
+ let md;
2570
+ try { md = await llmComplete('你是经验沉淀器。把运维对话提炼为一条结构化经验 markdown,章节固定:## 现象 / ## 根因 / ## 处置步骤 / ## 验证 / ## 预防。事实不确定就写"待确认"。只输出 markdown 正文。', convo || '(空会话)'); }
2571
+ catch (e) { return json(res, 502, { error: `经验起草失败: ${e.message}` }); }
2474
2572
  return json(res, 200, { markdown: md });
2475
2573
  }
2476
2574
  if (req.method === 'POST' && pathname === '/api/experience/commit') {
@@ -19,12 +19,21 @@ function Panel(props) { return html`<div className="...">…</div>` }
19
19
 
20
20
  - `html`:htm 模板标签(已绑定 React.createElement),写法 `html`<div className="text-xs">${x}</div>``
21
21
  - 可用 hooks:`useState / useEffect / useRef / useMemo`(已注入,无需导入)
22
+ - **UI kit `ui`(已注入,优先使用)**:`ui.Card / ui.Badge / ui.Button / ui.Stat / ui.Spark / ui.Bars /
23
+ ui.Matrix / ui.StatusDot / ui.StatusItem / ui.Empty / ui.Loading / ui.Error`。
24
+ 组件经 htm 语法使用:`` html`<${ui.Card} title="标题">内容<//>` ``(自闭合写 `<${ui.Empty} />`)。
25
+ 三态(加载/空/错误)用 `ui.Loading / ui.Empty / ui.Error`;列表行用 `ui.StatusItem`(统一状态语言);
26
+ KPI 用 `ui.Stat`(可带 trend 趋势线);趋势/分布/多设备健康分别用 `ui.Spark / ui.Bars / ui.Matrix`。
27
+ - **标题分工**:面板标题由平台渲染成面板头——插件首屏直接进入内容,**勿写标题行**;
28
+ DSL 可设 `bare: true` 做通栏视觉面板(无头无边框,适合 KPI 条/图表墙/矩阵)
22
29
  - **props(运行时注入)**:
23
30
  - `devices / alarms / kpi / work_orders`:现场实时数据
24
31
  - `selected / onSelect(id)`:设备选中态与回传
25
32
  - `store`:平台数据集读写 `store.list(name, {filter,sort,limit}) / create / update / remove`(按助手隔离)
26
- - 样式:Tailwind className(暗色主题;卡片 `bg-card border rounded-lg p-3`;主色 `text-primary`;状态色 emerald/amber/red)
27
- - 跨域取数:`fetch("/api/proxy?url=" + encodeURIComponent(url))` → `{status, body}`(平台代取;仅允许插件代码中声明过的主机)
33
+ - 样式:优先 ui kit;手写时用 Tailwind className(暗色主题;语义色 `text-success / text-warning / text-destructive`,
34
+ 状态色 emerald/amber/red)。**可用类词汇以 `web/src/index.css` 的 `@source inline` safelist 为准**——
35
+ safelist 外的新类不会进构建产物(静默丢样式);需要新词汇时先扩 safelist 再用
36
+ - 跨域取数:`fetch("/api/proxy?url=" + encodeURIComponent(url))` → `{status, body}`(平台代取;仅允许插件代码中声明过的主机;慢接口加 `&timeout_ms=30000`(上限 60s)、大结果加 `&max_kb=512`,优先服务端过滤减小拉取)
28
37
  - **禁止**:`import / require / window / document / globalThis`(校验器与沙箱双重拦截)
29
38
  - 完整字段与常见错误见 [references/panel-contract.md](references/panel-contract.md)
30
39
 
@@ -2,10 +2,46 @@
2
2
 
3
3
  ## 运行环境
4
4
 
5
- - 代码经 `new Function('React','html','useState','useEffect','useRef','useMemo', code + ';return Panel')` 在浏览器编译执行
5
+ - 代码经 `new Function('React','html','ui','useState','useEffect','useRef','useMemo', code + ';return Panel')` 在浏览器编译执行
6
6
  - htm 绑定 React.createElement:组件、属性、事件、列表 key 与 React 语义一致
7
7
  - 错误边界包裹:编译失败/运行时错误显示在面板内,不白屏
8
8
 
9
+ ## 标题分工与 bare 模式(面板头契约)
10
+
11
+ - **标识归壳**:面板标题/图标/副标题由平台网格统一渲染成面板头(DSL `title/icon/subtitle`)——
12
+ **插件首屏直接进入内容,禁止写标题行**(font-semibold 独立大字标题)。`ui.Card` 的 `title` 仅用于面板内部多节分节。
13
+ - **bare 逃生舱**(DSL `bare: true`):无面板头/无边框/透明底,内容顶格——适合 KPI 条、图表墙、状态矩阵等
14
+ **全画布视觉面板**。信息型列表面板保持默认框架头。选择标准:面板是"看数据本身"用 bare,是"读一组信息"用框架头。
15
+
16
+ ## ui kit(注入标识符 `ui`,优先于手写样式)
17
+
18
+ 纯 React 组件集(实现见 `web/src/components/panel-kit.tsx`),htm 中经 `<${ui.X} …>` 使用、`<//>` 闭合:
19
+
20
+ | 组件 | 关键 props | 说明 |
21
+ |---|---|---|
22
+ | `ui.Card` | `title?` `actions?` `flush?` `className?` | 卡片容器;DSL 面板标题已由网格渲染成面板头,**插件内 Card 的 title 仅用于内部分节**;`flush` 通栏变体(p-0,标题行变分隔线) |
23
+ | `ui.Badge` | `tone?: primary\|success\|warning\|destructive\|muted` | 圆角状态徽标(语义色) |
24
+ | `ui.Button` | `variant?: primary\|secondary\|danger\|ghost` + 原生 button 属性 | 原生 `<button>`(键盘可达、focus ring),默认 `type="button"` |
25
+ | `ui.Stat` | `label` `value` `hint?` `tone?` `trend?: number[]` | KPI 指标卡;`trend` 传数值数组自动内嵌迷你趋势线;数值等宽对齐;**value 变化自动提亮反馈**(轮询数据跳变可见) |
26
+ | `ui.Spark` | `data: number[]` `tone?`(含 `'chart'`)`height?` | 迷你趋势线(纯 SVG:面积+折线+末端点);单点数据返回 null |
27
+ | `ui.Bars` | `items: {label,value,tone?}[]` | 横向微型条形(告警等级/工单优先级分布),自动归一 |
28
+ | `ui.Matrix` | `items: {tone?,title?}[]` `cell?` `gap?` | 状态点阵——几十个对象健康状态一屏看全,替代长表格 |
29
+ | `ui.StatusDot` | `tone?` `pulse?` | 状态色点;destructive 场景可 pulse |
30
+ | `ui.StatusItem` | `tone?` `title` `detail?` `meta?` `active?` `onClick?` | **统一状态行**(左色条+状态点+背景 tint+选中环,可键盘操作)——设备行/告警行/工单行都用它;active 变 true 时自动 pulse 联动反馈 |
31
+ | `ui.Empty` | `text?` | 空态(雷达线稿插画 + 虚线边框) |
32
+ | `ui.Loading` | `rows?` `text?` | 加载骨架屏(默认 3 行 shimmer) |
33
+ | `ui.Error` | `text?` | 错误态(destructive 底色,自动加"加载失败:"前缀) |
34
+
35
+ 异步面板的三态一律用 `ui.Loading / ui.Empty / ui.Error`(对应校验器 W4/W5 关注点),不再手写。
36
+ 列表行一律 `ui.StatusItem`,不要手拼"色点+文字"的 div。
37
+
38
+ ## Tailwind 类词汇(手写样式时)
39
+
40
+ 暗色主题。优先语义色:`text-primary / text-success / text-warning / text-destructive / text-muted-foreground`,
41
+ 卡片/容器底 `bg-card bg-muted bg-secondary`。**可用类词汇以 `web/src/index.css` 中的 `@source inline` safelist 为准**
42
+ (状态色 emerald/amber/red 系列、常用布局密度类);safelist 之外的新类不会进构建产物——样式会静默丢失。
43
+ 需要新词汇:先扩 safelist(并在本文件登记),再使用。
44
+
9
45
  ## props 全量
10
46
 
11
47
  | prop | 类型 | 说明 |
@@ -37,7 +73,8 @@ store.remove(name, id) // → Promise<true>
37
73
  3. **await 无 catch**:异步失败静默,界面无反馈 → try/catch 或 `.catch(setError)`
38
74
  4. **无加载/空/错误三态**:异步面板首帧空白 → `items===null` 显示加载、`[]` 显示空态、catch 显示错误
39
75
  5. **IME 回车误发**:`onKeyDown` 判断 `e.nativeEvent.isComposing`(或 htm 下 `e.isComposing`)
40
- 6. **Nexus 类 API 参数**:`name=` 是精确匹配(带分组全名),模糊搜索用 `q=`;分页用 `continuationToken` 循环
76
+ 6. **慢接口/大结果**:代理默认 8s 超时、128KB 截断——重查询追加 `&timeout_ms=30000`(上限 60s)与 `&max_kb=512`;但优先用服务端过滤/分页参数(q=/limit/时间范围)减小单次拉取
77
+ 7. **Nexus 类 API 参数**:`name=` 是精确匹配(带分组全名),模糊搜索用 `q=`;分页用 `continuationToken` 循环
41
78
 
42
79
  ## 校验器(产出前必跑)
43
80
 
@@ -52,7 +89,7 @@ node scripts/check-panel.mjs < panel.js # 或 node scripts/check-panel.mjs pan
52
89
  ## 骨架模板
53
90
 
54
91
  ```js
55
- function Panel({ devices, alarms, selected, onSelect, store }) {
92
+ function Panel({ store }) {
56
93
  const [rows, setRows] = useState(null)
57
94
  const [error, setError] = useState(null)
58
95
  useEffect(() => {
@@ -60,14 +97,17 @@ function Panel({ devices, alarms, selected, onSelect, store }) {
60
97
  .then(setRows)
61
98
  .catch(e => setError(e.message))
62
99
  }, [])
63
- if (error) return html`<div className="p-3 text-xs text-red-400">加载失败:${error}</div>`
64
- if (rows === null) return html`<div className="p-3 text-xs text-muted-foreground">加载中…</div>`
65
- if (!rows.length) return html`<div className="p-3 text-xs text-muted-foreground">暂无记录</div>`
66
- return html`<div className="p-2 text-xs">
67
- ${rows.map(r => html`<div key=${r._id} className="mb-1 rounded border bg-card p-1.5">
68
- <span className="font-mono">${r.name}</span>
69
- <div className="text-[10px] text-muted-foreground">${r.detail || ''}</div>
70
- </div>`)}
100
+ if (error) return html`<${ui.Error} text=${error} />`
101
+ if (rows === null) return html`<${ui.Loading} />`
102
+ if (!rows.length) return html`<${ui.Empty} text="暂无记录" />`
103
+ return html`<div className="space-y-1.5 p-2">
104
+ ${rows.map(r => html`<${ui.StatusItem} key=${r._id}
105
+ tone=${r.done ? 'success' : 'warning'} title=${r.name} detail=${r.detail || ''}
106
+ meta=${r.date} onClick=${() => onPick(r)} />`)}
71
107
  </div>`
72
108
  }
73
109
  ```
110
+
111
+ htm 组件语法备忘:自闭合 `<${ui.Empty} text="…" />`;含子元素闭合写 `<//>`。
112
+ KPI 场景:`<${ui.Stat} label="OEE" value="86.5%" trend=${[70,74,80,83,86.5]} tone="success" />`。
113
+ 旧式 6 标识符(无 `ui`)代码仍然兼容可跑,但新代码一律用 kit。
@@ -16,9 +16,9 @@ if (/\b(window|document|globalThis)\b/.test(code)) errors.push('E2 沙箱违规
16
16
  // E3 模板完整性:反引号必须成对
17
17
  const ticks = (code.match(/`/g) || []).length;
18
18
  if (ticks % 2 !== 0) errors.push(`E3 模板断裂:反引号数量为奇数(${ticks}),html\`\` 模板未闭合`);
19
- // E4 编译检查:与运行时一致的 new Function 编译
19
+ // E4 编译检查:与运行时一致的 new Function 编译(运行时还注入 ui = panel-kit 组件集,见 web/src/components/panel-kit.tsx)
20
20
  try {
21
- const factory = new Function('React', 'html', 'useState', 'useEffect', 'useRef', 'useMemo',
21
+ const factory = new Function('React', 'html', 'ui', 'useState', 'useEffect', 'useRef', 'useMemo',
22
22
  `"use strict";\n${code}\n;if (typeof Panel !== 'function') throw new Error('no Panel');\nreturn Panel;`);
23
23
  if (typeof factory !== 'function') errors.push('E4 编译失败');
24
24
  } catch (e) { errors.push(`E4 编译失败:${e.message}`); }
@@ -34,6 +34,10 @@ if (/onInput/.test(code) && /onChange/.test(code)) warnings.push('W2 同时使
34
34
  if (!/key=/.test(code) && /\.map\(/.test(code)) warnings.push('W3 列表渲染(.map)缺少 key 属性');
35
35
  if (/await /.test(code) && !/try\s*{|\.catch\(/.test(code)) warnings.push('W4 存在 await 但无 try/catch 或 .catch,失败会静默');
36
36
  if (!/(loading|加载|…|\.\.\.)|useEffect/.test(code) && /store\.(list|create)/.test(code)) warnings.push('W5 异步读写无加载态提示,首帧可能空白');
37
+ // W6:首屏疑似标题行——独立 font-semibold 文本行会与平台面板头重复标识(面板标题由网格渲染成面板头)
38
+ if (/<div[^>]*\bfont-semibold\b[^>]*>([^<]*\$\{[^}]*\})?[^<]*<\/div>/.test(code)) {
39
+ warnings.push('W6 疑似标题行(font-semibold 独立文本行):面板标题由平台面板头渲染,插件首屏直接进入内容,勿重复标题');
40
+ }
37
41
 
38
42
  const verdict = errors.length ? 'REJECT' : 'PASS';
39
43
  const lines = [