]> acesimba.cloud Git - StationBuilder.git/commitdiff
fix: 修复整站生成大量超时失败(北方污水厂)
authorwang.yu <wang.yu@advantech.com>
Tue, 18 Aug 2026 06:12:06 +0000 (14:12 +0800)
committerwang.yu <wang.yu@advantech.com>
Tue, 18 Aug 2026 06:12:06 +0000 (14:12 +0800)
- DASHBOARD_SPEC 撤销「逐项读取模板文件」指令,改为内联各面板紧凑 custom 骨架,消除文件工具开销,单页耗时回到 300s 内
- extractJson 增强容错:原生解析失败再去除注释/尾逗号重试
- runCodebuddy 超时阈值 300s -> 480s
- generateCubeJson 将「调用超时/JSON 解析失败/结构校验失败」统一纳入最多 2 次重试,把确切错误(含问题位置原文)回传模型自我修正
- 提示词加约束:不得添加 query/linkage/triggerByTimer/minW 等规范外字段
- 移除临时调试写盘
- 同步更新 public/design-doc.html 更新记录

验证:北方污水厂 7 个 Cube 全部重新生成成功,custom 结构正确(LineChart/Bar 扁平、其余 dataConfig+chartConfig),导入无空白。

db-sidecar.mjs
public/design-doc.html

index 510df16d050897b60b80b79bd4644a341f3ec3e4..c32b60cd58642a2147df105c6559fdc26a724127 100644 (file)
@@ -97,28 +97,32 @@ KPIIndicator panel specifics:
 - custom can include icon, thresholds, compare.
 
 CRITICAL — custom structure (the #1 cause of blank panels after import):
-- For EVERY panel, the "custom" object MUST mirror the structure of the matching template file under
-  /root/.codebuddy/skills/dashboard-json-generator/assets/templates/. BEFORE writing a panel of type X,
-  READ the corresponding template file and copy its "custom" object's keys/structure EXACTLY (only replace
-  values, keep every key). Do NOT invent new custom keys or rename keys.
-- Type → template file map:
-  KPIIndicator→kpi.json, LineChart→line-chart.json, Bar→bar-chart.json, PieChart→pie-chart.json,
-  Table→table.json, StatusLight→status-light.json, ParameterCard→attribute-card.json, GeoMap→geomap.json,
-  Gauge→gauge.json, ProgressBar→progress-bar.json, RadarChart→radar.json, HeatMap→heatmap.json,
-  Liquidfill→liquid.json, Image→image.json, Container→container.json, Title→title.json.
-  (ProgressRing: follow the ProgressRing section in the Skill's SKILL.md; RichText: use custom.data.text.)
-- Flat vs wrapper rule (must match the template exactly):
-  · Bar / LineChart / HeatMap: FLAT custom = { config, series, xAxis, yAxis, legend, grid, dataZoom } — NO chartConfig wrapper.
-  · PieChart / RadarChart / ProgressRing / GeoMap: custom = { dataConfig, chartConfig } (GeoMap: dataConfig + embedded geoJson, see below).
-- StatusLight: custom = { columns, machineTextSize, machineBold, statusTextSize, statusTextBold, icon, iconSize, configs:[{label,color,value}] }.
-- ParameterCard: custom = { tableCnt, rowCnt, style:{color,fontSize,fontWeight}, styleLeft:{fontSize,fontWeight,color} }.
-- Table: custom = { columns:[{id,unit,type,data:{...},style:{...}}], page:{show,pageSize}, stripe, border, title, rows } — columns[].id MUST equal data.targets[].id.
-- GeoMap: custom.dataConfig MUST include geoFileName:"中国-area.json", theme:"light", zoomTo:0.8, geoType:"effectScatter", longitudeId:"A", latitudeId:"B", coordValId:"C", coordNameId:"D", and data.calc=true with 4 targets of dataType "first". The China geoJson FeatureCollection is auto-injected by the server — DO NOT include geoJson yourself; just set the dataConfig fields above.
-If a 视觉风格要求 section provides a 推荐配色板, use THAT palette first; otherwise use default #0062CC,#00B900,#FF9300,#EBC147,#D73C74,#8048C8,#6D84C9,#34A0D5.
+For EVERY panel the "custom" object MUST match the structure for its TYPE below. Copy the SHAPE exactly (keep every top-level key shown; you may drop rarely-needed sub-fields but keep series/axis wrappers). Do NOT invent or rename keys. Flat vs wrapper: Bar/LineChart/HeatMap are FLAT (NO chartConfig wrapper); PieChart/RadarChart/ProgressRing/GeoMap/Gauge/ProgressBar/Liquidfill/HeatMap use {dataConfig, chartConfig}.
+COLOR_PALETTE = #0062CC,#00B900,#FF9300,#EBC147,#D73C74,#8048C8,#6D84C9,#34A0D5   (use for every color/color[] field, or the 推荐配色板 if provided)
+
+Bar:        { "config": { "direction": "vertical" }, "alignTicks": true, "color": [COLOR_PALETTE], "series": { "config": { "sort": "desc", "stack": false, "unit": "" }, "type": "bar", "label": { "show": false } }, "yAxis": { "type": "value", "axisLabel": { "color": "#787878" } }, "xAxis": { "config": { "mode": "category" }, "axisLabel": { "show": true, "color": "#787878" } }, "legend": { "show": true, "position": "bottom" }, "grid": { "top": 25, "left": 10, "right": 10, "bottom": 10, "containLabel": true }, "dataZoom": [ { "type": "inside", "start": 0, "end": 100 } ] }
+LineChart:  { "config": { "interaction": { "enable": false } }, "multiY": false, "alignTicks": true, "dimension": "", "color": [COLOR_PALETTE], "series": [ { "config": { "stack": false, "unit": "" }, "type": "line", "label": { "show": false }, "lineStyle": { "width": 2 }, "smooth": false, "showSymbol": false, "symbol": "emptyCircle", "sampling": "lttb", "symbolSize": 4, "id": "A" } ], "yAxis": [ { "type": "value", "axisLabel": { "color": "#787878" } } ], "xAxis": { "config": { "mode": "time" }, "axisLabel": { "show": true, "color": "#787878" } }, "legend": { "show": true, "position": "bottom" }, "grid": { "top": 0, "left": 15, "right": 35, "bottom": 10, "containLabel": true }, "dataZoom": [ { "type": "inside", "start": 0, "end": 100 } ] }
+PieChart:   { "dataConfig": { "unit": "", "direction": "column", "dimension": "", "valueFormat": "", "valueType": "current", "roseSort": "", "colorMap": [] }, "chartConfig": { "color": [COLOR_PALETTE], "series": [ { "config": { "showBorder": true, "richLabel": true }, "name": "", "type": "pie", "radius": [45, 75], "center": [50, 50], "label": { "show": true, "position": "outside" } } ], "legend": { "show": false } } }
+Table:      { "columns": [ { "id": "A", "unit": "", "type": "string", "data": { "decimals": 0, "thresholds": { "enable": false }, "format": "", "mapping": { "enable": false, "type": "value", "rules": [] }, "link": { "enable": false, "url": "" } }, "style": { "sort": { "enable": false }, "filter": { "enable": false } } } ], "page": { "show": true, "pageSize": 10 }, "stripe": { "enable": false, "color1": "#fff", "color2": "#fafafa" }, "border": { "enable": true, "show": true, "showCol": false, "borderWidth": 1, "borderColor": "rgba(200,200,200,0.12)" }, "title": { "enable": true, "fontSize": 16, "fontWeight": "bold", "textAlign": "left", "color": "rgb(50,50,50)", "backgroundColor": "transparent", "height": 43, "paddingX": 16, "paddingY": 8 }, "rows": { "enable": true, "fontSize": 14, "fontWeight": "normal", "textAlign": "left", "color": "rgb(50,50,50)", "backgroundColor": "rgba(0,0,0,0)", "height": 43, "paddingX": 16, "paddingY": 8 } } }   // columns[].id MUST equal data.targets[].id
+StatusLight:{ "columns": 3, "machineTextSize": 16, "machineBold": "bold", "statusTextSize": 15, "statusTextBold": "bold", "icon": "status-icon-1", "iconSize": 30, "configs": [ { "label": "运行", "color": "#00CC66", "value": "1" }, { "label": "停止", "color": "#F9C80E", "value": "2" } ] }
+ParameterCard:{ "tableCnt": 1, "rowCnt": 10, "style": { "color": "#323233", "fontSize": 16, "fontWeight": 700 }, "styleLeft": { "fontSize": 16, "fontWeight": 400, "color": "#89898a" } }
+GeoMap:     { "dataConfig": { "geoFileName": "中国-area.json", "theme": "light", "zoomTo": 0.8, "geoType": "effectScatter", "longitudeId": "A", "latitudeId": "B", "coordValId": "C", "coordNameId": "D", "geoJson": null }, "panelType": "carousel", "panelSelectedIds": [] }   // geoJson is auto-injected (set null); data.calc=true, 4 targets dataType "first"
+Gauge:      { "dataConfig": { "dimension": "", "valueFormat": "", "valueType": "current" }, "chartConfig": { "config": { "enableTh": true, "thresholds": [ { "value": 33, "color": "#00CC66" }, { "value": 66, "color": "#FFA400" }, { "value": 100, "color": "#F55D3E" } ] }, "series": { "type": "gauge", "min": 0, "max": 100, "splitNumber": 10, "axisLine": { "show": true, "lineStyle": { "width": 10, "color": [ [0.6, "#00CC66"], [0.8, "#FFA400"], [1, "#F55D3E"] ] } }, "pointer": { "show": true, "length": "60%", "width": 6 }, "axisLabel": { "show": true, "color": "rgb(250,250,250)", "fontSize": 8 }, "detail": { "show": true, "formatter": "{value}" } } } }
+ProgressBar: { "dataConfig": { "realValKey": ["A"], "targetValKey": "B", "isCustTargetKey": true, "min": 0, "max": 100, "isPercent": true, "fixedNum": 1, "legendPosition": "bottom", "isLegendVertical": false, "value": { "colorType": "threshold", "style": { "color": "#008cd6", "fontSize": "40px", "fontWeight": "bold" }, "thresholds": [ { "color": "rgb(245,93,62)", "value1": 0, "operator1": "<=", "value2": 60, "operator2": "<" }, { "color": "rgb(255,164,0)", "value1": 60, "operator1": "<=", "value2": 80, "operator2": "<" }, { "color": "rgb(0,204,102)", "value1": 80, "operator1": "<=", "value2": 100, "operator2": "<" } ] } }, "chartConfig": { "legend": { "show": false }, "xAxis": { "show": false, "type": "value", "max": 100 }, "yAxis": [ { "type": "category", "show": false, "axisLabel": { "color": "rgb(250,250,250)", "fontSize": 11 } } ], "series": [ { "type": "bar", "data": [] } ] } }
+Liquidfill:  { "dataConfig": { "realValKey": "A", "targetValKey": "B", "isCustTargetKey": true, "min": 0, "max": 90, "fixedNum": 2, "value": { "colorType": "default", "style": { "color": "#008CD6", "fontSize": "32px", "fontWeight": "bold" }, "thresholds": [ { "color": "#DF001A", "value1": 0, "operator1": "<=", "value2": 60, "operator2": "<" }, { "color": "#FF9300", "value1": 60, "operator1": "<=", "value2": 80, "operator2": "<" }, { "color": "#00B900", "value1": 80, "operator1": "<=", "value2": null, "operator2": "<" } ] } }, "chartConfig": { "title": { "show": true, "left": "center", "top": "40%", "textStyle": { "fontSize": 14, "color": "rgb(250,250,250)" } }, "series": [ { "type": "liquidFill", "radius": "97%", "center": ["50%", "50%"], "color": ["#5594fa"], "data": [0], "label": { "show": true, "color": "#5594fa", "fontSize": 22 }, "outline": { "borderDistance": 7, "itemStyle": { "borderWidth": 3, "borderColor": "#5594fa" } }, "shape": "rect" } ] } }
+ProgressRing:{ "dataConfig": { "min": 0, "max": 100, "isPercent": true, "realValKey": "A", "isCustTargetKey": true, "value": { "colorType": "default", "style": { "color": "#00B900", "fontSize": "40px", "fontWeight": "bold" }, "thresholds": [ { "color": "#DF001A", "value1": null, "operator1": "<=", "value2": 60, "operator2": "<" }, { "color": "#FF9300", "value1": 60, "operator1": "<=", "value2": 80, "operator2": "<" }, { "color": "#00B900", "value1": 80, "operator1": "<=", "value2": null, "operator2": "<" } ] }, "chartConfig": { "title": { "show": true, "left": "center", "top": "40%", "textStyle": { "fontSize": 17, "fontWeight": "bold", "color": "rgb(132,130,136)" } }, "graphic": { "type": "text", "left": "center", "top": "55%", "style": { "fill": "rgb(0,185,0)", "text": "", "fontSize": 21 } }, "polar": { "center": ["50%", "50%"], "radius": ["63%", "78%"] }, "angleAxis": { "max": 100, "show": false }, "radiusAxis": { "type": "category", "show": true, "axisLabel": { "show": false } }, "series": [ { "type": "bar", "roundCap": false, "showBackground": true, "backgroundStyle": { "color": "rgba(131,130,181,0.5)" }, "data": [95.2], "coordinateSystem": "polar", "itemStyle": { "color": "#5594fa" } } ] }
+RadarChart: { "dataConfig": { "min": 0, "max": 100, "isLimit": false, "value": { "legendColors": [ { "name": "当前水平", "color": "rgb(85,148,250)" } ] } }, "chartConfig": { "tooltip": { "show": true }, "radar": { "radius": "65%", "splitArea": { "show": false }, "indicator": [] }, "series": [ { "name": "当前水平", "type": "radar", "data": [], "itemStyle": { "color": "rgb(85,148,250)" }, "lineStyle": { "color": "rgb(85,148,250)", "width": 2 }, "areaStyle": { "color": "rgba(85,148,250,0.2)" } } ], "legend": { "show": false } } }
+HeatMap:    { "dataConfig": { "direction": "column", "valueFormat": "", "dimensionX": { "key": "A", "dataType": "datetime", "format": "yyyy/MM/dd" }, "dimensionY": { "key": "B", "dataType": "" }, "dimensionZ": { "key": "C" } }, "chartConfig": { "legend": { "show": false }, "visualMap": { "type": "continuous", "min": 0, "max": 100, "inRange": { "color": ["#eaf4e7", "#f2ce81", "#a50026"] } }, "xAxis": { "type": "category", "axisLabel": { "show": true, "color": "#787878" } }, "yAxis": { "type": "category", "axisLabel": { "show": true, "color": "#787878" } }, "series": [ { "type": "heatmap", "data": [], "label": { "show": true } } ], "grid": { "top": 10, "left": 30, "right": 50, "bottom": 50 } } }
+KPIIndicator: { "icon": { "name": "home", "size": 55, "color": "#0062CC", "content": "", "invert": false }, "thresholds": { "enable": true, "rules": [] }, "compare": { "show": false, "data": [] } }
+Title:      { "title": { "label": "<页面名称>", "style": { "color": "#323233", "fontSize": 40, "fontWeight": "bold" }, "bgStyle": { "backgroundColor": "" }, "align": "left" }, "refresh": { "enable": false, "interval": 60, "custom": null, "showUpdateTime": true } }
+RichText:   { "data": { "text": "<p>...</p>" }, "config": { "enable": true, "params": [] } }
+Image/Container: use sensible custom per standard; small note panels are fine.
+xAxis.config.mode: use "time" ONLY for real datetime strings (e.g. "2026-07-29 08:00:00"); otherwise "category".
+If a 视觉风格要求 section provides a 推荐配色板, use THAT palette first; otherwise use COLOR_PALETTE above.
 
 Theme: If a 视觉风格要求 section is provided below, follow IT exactly (its grid colors/borders/gradients take precedence) and ignore the default example colors in the grid above. Otherwise use the default light theme: grid.theme="light", bgColor rgb(237,246,251), panelBgColor #F5F5F5, panel.bgColor #FFFFFF, titleColor #323232, borderColor #C8C8C8. Do not mix darkBlue with light background.
 
-Reminder: the "data" section (data.config.data.columns/rows, data.targets) and the "custom" section are INDEPENDENT. You already populate "data" correctly — now ALSO make "custom" exactly match the template file for that panel type. A panel whose "custom" does not match its template type will render BLANK after import.
+Reminder: the "data" section (data.config.data.columns/rows, data.targets) and the "custom" section are INDEPENDENT. You already populate "data" correctly — now ALSO make "custom" exactly match the structure SHOWN ABOVE for that panel type (no file reading needed). A panel whose "custom" does not match its type will render BLANK after import. Do NOT add spec-outside fields such as query/linkage/triggerByTimer/minW — only the fields shown above.
 
 Output ONLY the raw JSON object. No markdown code fences, no explanation.
 `;
@@ -534,8 +538,8 @@ function runCodebuddy(prompt, sessionId, onEvent) {
     let fullText = "";
     const timer = setTimeout(() => {
       child.kill("SIGKILL");
-      reject(new Error("codebuddy 生成超时(>300s)"));
-    }, 300000);
+      reject(new Error("codebuddy 生成超时(>480s)"));
+    }, 480000);
     const push = (line) => {
       if (!line.trim()) return;
       try {
@@ -565,6 +569,35 @@ function runCodebuddy(prompt, sessionId, onEvent) {
   });
 }
 
+// 去除 JSON 字符串之外的 // 与 /* */ 注释(字符串内的 // 如 http:// 不受影响)。
+function stripJsonComments(s) {
+  let out = "";
+  let i = 0;
+  const n = s.length;
+  let inStr = false;
+  let strCh = "";
+  while (i < n) {
+    const ch = s[i];
+    if (inStr) {
+      out += ch;
+      if (ch === "\\") { out += s[i + 1] ?? ""; i += 2; continue; }
+      if (ch === strCh) inStr = false;
+      i++;
+      continue;
+    }
+    if (ch === '"' || ch === "'") { inStr = true; strCh = ch; out += ch; i++; continue; }
+    if (ch === "/" && s[i + 1] === "/") { while (i < n && s[i] !== "\n") i++; continue; }
+    if (ch === "/" && s[i + 1] === "*") { i += 2; while (i < n && !(s[i] === "*" && s[i + 1] === "/")) i++; i += 2; continue; }
+    out += ch; i++;
+  }
+  return out;
+}
+
+// 去掉对象/数组末尾多余逗号(如 [1,2,]、{a:1,})——LLM 常见非法 JSON。
+function removeTrailingCommas(s) {
+  return s.replace(/,(\s*[}\]])/g, "$1");
+}
+
 async function extractJson(text) {
   // strip markdown fences
   const m = text.match(/```(?:json)?\s*([\s\S]*?)```/);
@@ -574,7 +607,24 @@ async function extractJson(text) {
   let start = text.indexOf("{");
   let end = text.lastIndexOf("}");
   if (start >= 0 && end > start) text = text.slice(start, end + 1);
-  return JSON.parse(text);
+  // 逐步尝试:原生解析 → 去注释+去尾逗号(兜底修复模型输出的轻微非法 JSON)。
+  const candidates = [
+    text,
+    removeTrailingCommas(stripJsonComments(text)),
+  ];
+  let lastErr;
+  for (const c of candidates) {
+    try { return JSON.parse(c); }
+    catch (e) { lastErr = e; }
+  }
+  // 给出错误位置附近的原文片段,便于上层把错误回传给模型自我修正。
+  const posMatch = /position (\d+)/.exec(lastErr?.message || "");
+  let snippet = "";
+  if (posMatch) {
+    const p = Number(posMatch[1]);
+    snippet = `\n问题位置附近的原文:\n${text.slice(Math.max(0, p - 200), p + 80)}`;
+  }
+  throw new Error((lastErr ? lastErr.message : "invalid JSON") + snippet);
 }
 
 function assetLine(site) {
@@ -721,10 +771,26 @@ async function generateCubeJson(site, cube, onEvent, onLog) {
       ? `The previous output failed validation:\n${previousErrors}\n\n${promptBase}\n\nPlease fix the errors and output ONLY the corrected raw dashboard JSON.`
       : promptBase;
     const sessionId = `stationbuilder-gen-${site.id}-${cube.id || 0}-${attempt}`;
-    const text = await runCodebuddy(prompt, sessionId, onEvent);
+    let text;
+    try {
+      text = await runCodebuddy(prompt, sessionId, onEvent);
+    } catch (e) {
+      // 调用超时/失败:重试一次,把错误回传给模型,而非直接判失败。
+      if (attempt >= 2) throw e;
+      previousErrors = `上一次调用失败(${e.message}),请重新输出完整且合法的 dashboard JSON(不要截断、不要添加规范外字段)。`;
+      attempt++;
+      continue;
+    }
     let json;
-    try { json = await extractJson(text); }
-    catch (e) { throw new Error("JSON parse failed: " + e.message); }
+    try {
+      json = await extractJson(text);
+    } catch (e) {
+      // JSON 语法错误:把错误(含问题位置原文)回传给模型自我修正,而非直接失败。
+      if (attempt >= 2) throw new Error("JSON parse failed after retries: " + e.message);
+      previousErrors = "JSON 解析失败,请修正语法错误(多余的括号/逗号、未闭合的对象等)后重新输出完整 JSON:\n" + e.message;
+      attempt++;
+      continue;
+    }
     const { ok, json: fixed, errors } = repairAndValidate(json);
     if (ok) return { json: fixed, prompt: promptBase };
     if (typeof onLog === "function" && errors.length)
index 4e9deb03d70c55b42c50d67997c4259337f27bcc..a7ee37deeb8d6a32661e7f7ada18ceba13fd3231 100644 (file)
 
   <h3>11.1 更新记录</h3>
   <ul>
+    <li>2026-08-18 修复「北方污水厂」等项目整站生成大量失败(多页 300s 超时判失败)。根因:上一轮为修「导入空白」而加入的「逐项读取模板文件」提示词指令使单页提示词过胖并引入文件工具调用,复杂页面(综合总览/工艺监控等)生成耗时偶发超过 300s 硬上限被 SIGKILL。修复:(1) 撤销文件读取指令,改为在 DASHBOARD_SPEC 内联各面板类型的紧凑 custom 骨架(占位 COLOR_PALETTE),消除文件工具开销、把单页耗时拉回 300s 内;(2) extractJson 增强容错——先原生解析,失败再去除注释/尾逗号后重试;(3) runCodebuddy 超时阈值由 300s 提高到 480s,且 generateCubeJson 将「调用超时 / JSON 解析失败 / 结构校验失败」统一纳入最多 2 次重试,把确切错误(含问题位置附近原文)回传模型自我修正,避免一次瑕疵即整页失败;(4) 提示词加约束:不得添加 query/linkage/triggerByTimer/minW 等规范外字段;(5) fixDashboard/normalizePanelCustom 兜底保持不变。验证:北方污水厂 7 个 Cube 全部重新生成成功,面板 custom 结构正确(LineChart/Bar 扁平、PieChart 等 dataConfig+chartConfig),导入无空白。</li>
     <li>2026-08-18 修复右下角 AI 悬浮按钮拖动时常失效的问题:图标改为不可原生拖拽、事件直接落在按钮上,避免浏览器图片拖拽劫持 Pointer 拖拽手势;按下拖动时不再有 1px 移位。</li>
     <li>2026-08-18 修复「下载 Markdown」导入 Azure DevOps Wiki 后仅显示到 §4.2 的问题:代码块改以 4 空格缩进式代码块导出(不再用 ``` 围栏),避免围栏闭合异常吞掉后续章节;表格单元格内将 <code>&lt;</code> 与 <code>&gt;</code> 转义为 HTML 实体,避免 Azure 把 <code>&lt;select&gt;</code> 当成未闭合标签吞掉后续内容。</li>
     <li>2026-08-18 新增「AI 智能补全」功能:站点基本属性页「填写建议」下方新增面板,点击「开始补全」后基于站点名称 / 所属领域 / 站点基本情况描述,由 AI 流式分析并自动写入「补充要求」字段;「查看提示词」可查看完整组合提示词(后端 <code>POST /api/db/sites/:id/complete</code> 流式、<code>GET .../complete-prompt</code> 取提示词)。</li>