aims-workstation 0.1.9 → 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.9",
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
@@ -183,6 +183,19 @@ async function llmComplete(systemPrompt, userText) { // 无工具的一次性调
183
183
  return (r.content || []).filter(b => b.type === 'text').map(b => b.text).join('');
184
184
  }
185
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
+
186
199
  // ── 现场数据容器:真实数据源(AIMS-MOS)未接入前保持为空——面板按空态渲染,不造假数据
187
200
  //(2026-10-15 MVP 打磨:移除模拟立库场景与工厂画像注入,见 README)
188
201
  const world = { devices: [], alarms: [], kpi: {}, work_orders: [], inspections: {} };
@@ -1346,24 +1359,28 @@ function devSystemPrompt() {
1346
1359
  '插件是一段 JS 代码,必须定义:function Panel(props) { return html`...` }',
1347
1360
  '· html 是 htm 模板标签(绑定 React.createElement),写法:html`<div className="text-xs">${x}</div>`',
1348
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 独立大字标题)',
1349
1368
  '· props(实时现场数据 + 交互):',
1350
1369
  ' devices:[{id,name,type,status:normal|warning|alarm,detail}] alarms:[{id,device,level:高|中|低,text,ts,state}]',
1351
1370
  ' kpi:{出入库吞吐:...} work_orders:[{id,device,priority,state}] selected(当前选中设备id) onSelect(id)(选中设备)',
1352
- '· 样式用 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 等宽;布局紧凑(面板常为窄条/半宽/通栏)',
1353
1373
  '· 禁止 import/require/window 操作;需要更多数据可 fetch("/api/state")',
1354
- '· 面板渲染于工作台左栏(宽约 290px)或中栏(dsl 里 slot:"center"),布局要紧凑',
1355
1374
  '',
1356
1375
  '【参考实现(严格仿照此风格)】',
1357
1376
  'function Panel({ devices, selected, onSelect }) {',
1358
1377
  ' const bad = devices.filter(d => d.status !== "normal")',
1359
- ' return html`<div className="p-2 text-xs">',
1360
- ' <div className="mb-2 font-semibold">异常设备 ${bad.length} 台</div>',
1361
- ' ${devices.filter(d=>d.status!=="normal").map(d => html`',
1362
- ' <div key=${d.id} onClick=${() => onSelect(d.id)}',
1363
- ' 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")}>',
1364
- ' <span className="font-mono">${d.id}</span> <span className="text-muted-foreground">${d.name}</span>',
1365
- ' <div className="text-[10px] text-muted-foreground">${d.detail}</div>',
1366
- ' </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)} />`)}',
1367
1384
  ' </div>`',
1368
1385
  '}',
1369
1386
  '',
@@ -1374,7 +1391,7 @@ function devSystemPrompt() {
1374
1391
  '【自探索工作流(必须遵守)】理解需求 → 探索 → 规划 → 实现 → 提交草稿:',
1375
1392
  '1. 探索:新需求涉及助手既有能力时,必须先 exec 读它的 AGENT.md(按名 grep 定位),必要时读其 skills/脚本与验证相关接口(curl 实测返回结构)。能推送通常就能搜索——从脚本/配置里找到真实的服务地址与 API。',
1376
1393
  '2. 规划:把探索结论映射为面板方案(数据从哪来、经什么能力取、展示成什么),方案要说清"复用了助手的哪个既有能力"。',
1377
- '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/时间范围)减小单次拉取——不要靠放宽超时硬扛全量拉取。',
1378
1395
  '4. 提交:write_panel_plugin + update_workbench 草稿,明确告知用户审阅发布。',
1379
1396
  '【工作方式】改已有面板时基于当前 DSL 增量修改;单一职责,先小后大。',
1380
1397
  '【已装载技能(用前先 load_skill 读全文)】',
@@ -1489,12 +1506,13 @@ function systemPrompt(armAgent, deployment, autonomy, ws) {
1489
1506
  '',
1490
1507
  '【富回复块(a2ui)】当回复更适合组件而非长文本时,输出 ```a2ui 围栏块(内为严格 JSON,应用原生渲染):',
1491
1508
  '- choice:需要用户做一个决定(2-5 个选项,可标 recommended:true;块必须是回复结尾;用户点击后以纯文本回传)',
1509
+ '- 多维度选择(向导):需要用户一次确定多个维度(如 设备+等级+时间范围)时,输出多个**连续** choice 块(彼此间不夹文字,均位于回复结尾)——应用自动合并为分步向导,用户逐题选择、全部完成确认后一次性回传(每行"问题: 答案");不要为多个维度分别发起独立提问',
1492
1510
  '- form:一次收集多个输入(1-6 字段 {id,label,kind,placeholder,required};提交按"label: 答案"逐行回传)',
1493
1511
  '- steps:手工操作步骤(每步一句祈使句;warning/caution 放在它保护的那一步上)',
1494
1512
  '- callout:必须被注意的单一警示/提示(tone: note|tip|caution|warning)',
1495
1513
  '- metrics:少量读数一览(items:[{label,value,unit,warn,danger}],注明 asOf)',
1496
- '规则:块前要有一句引导语;choice/form 之后不再有文字;数据必须来自工具实测并注明 asOf,不得编造;一个回复最多 1-2 个块。用户说"给选项/怎么选/选哪个"时优先用 choice。',
1497
- '强化:凡回复包含 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```',
1498
1516
  ].join('\n');
1499
1517
  }
1500
1518
 
@@ -1997,6 +2015,38 @@ const server = http.createServer(async (req, res) => {
1997
2015
  if (!loadWorkbench(agent_id)) saveWorkbench(agent_id, defaultWorkbench(dep)); // 新上岗自动生成初版工作台
1998
2016
  return json(res, 200, { deployment: dep });
1999
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
+
2000
2050
  if (req.method === 'GET' && pathname === '/api/tools') {
2001
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 })) });
2002
2052
  }
@@ -2068,15 +2118,25 @@ const server = http.createServer(async (req, res) => {
2068
2118
  if (!proxyAllowedHosts().has(host)) {
2069
2119
  return json(res, 403, { error: `主机 ${host} 未在任何插件中声明(代理仅允许插件代码中出现过的主机,防面板成为任意内网跳板)` });
2070
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();
2071
2126
  try {
2072
2127
  const ctrl = new AbortController();
2073
- const timer = setTimeout(() => ctrl.abort(), 8000);
2128
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
2074
2129
  const r = await fetch(u, { signal: ctrl.signal });
2075
2130
  clearTimeout(timer);
2076
- const body = (await r.text()).slice(0, 128 * 1024);
2077
- 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 });
2078
2133
  return json(res, 200, { status: r.status, body });
2079
- } 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
+ }
2080
2140
  }
2081
2141
 
2082
2142
  // ── 面板运行错误(debug 的眼睛:错误边界/编译失败上报,供 AI 调试取证)──
@@ -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 = [