From: root Date: Fri, 21 Aug 2026 03:24:23 +0000 (+0800) Subject: feat: 设备模板采集点 AI 智能补全(领域生成自动补全 + 单独打开补全) X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=5aa8fc59f8402328e5963a31fb40889c668f607b;p=StationBuilder.git feat: 设备模板采集点 AI 智能补全(领域生成自动补全 + 单独打开补全) - 后端新增 buildCompleteCollectionPointsPrompt / extractCollectionPoints / generateDeviceTemplatePoints:以设备名称/领域/说明推导 6~12 个合适采集点并批量写库(按 name 去重追加,不覆盖用户已录入采集点;数值范围优先取预设集合) - 领域方案「AI 资产补全」自动创建设备模板后,立即为该新模板补全采集点(created:true 触发;单个失败不影响整体;推导过程回流到资产补全控制台) - 设备模板详情新增「AI 智能补全 · 采集点」面板:开始补全(POST /api/db/device-templates/:id/complete-collection-points,流式)+ 查看提示词(GET ...-prompt),补全后重新拉取详情刷新表格 - 前端透传 onViewCpPrompt 复用 App 级提示词弹窗;资产补全超时守卫 6→10 分钟 - 同步更新 design-doc.html(v0.28,新增 §4.12.2) --- diff --git a/app/page.tsx b/app/page.tsx index 52aa0c9..2ee5537 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -260,6 +260,13 @@ export default function Home(){ setModal("completePrompt"); }catch(e:any){ notify("读取提示词失败: "+e.message); } }; + const viewDeviceCpCompletePrompt=async(id:number)=>{ + try{ + const data:{prompt:string}=await fetchDb(`/device-templates/${id}/complete-collection-points-prompt`); + setPromptData({name:"AI 采集点补全", text:data.prompt}); + setModal("completePrompt"); + }catch(e:any){ notify("读取提示词失败: "+e.message); } + }; const openFeedback=(cube:Cube)=>{ if(!cube.id){ notify("请先生成该页面再提出修改意见"); return; } setFeedbackCube(cube); @@ -527,7 +534,7 @@ export default function Home(){ ? setOpenDomainTemplateId(null)} onCreated={setOpenDomainTemplateId} viewCompletePrompt={viewDtCompletePrompt} viewCompleteAssetsPrompt={viewDtCompleteAssetsPrompt} viewCompleteCubesPrompt={viewDtCompleteCubesPrompt} pageStyles={pageStyles}/> : setOpenDomainTemplateId(id)} domainFilter={domainFilter} domainOptions={domainOptions}/>)} {page==="styleLibrary"&&} - {page==="deviceTemplates"&&} + {page==="deviceTemplates"&&} {page==="basic"&&} {page==="assets"&&} {page==="functions"&&} @@ -1197,7 +1204,7 @@ function DeviceImageField({imageUrl,onPick}:{imageUrl:string|null;onPick:(f:File ; } -function DeviceTemplateCard({dev,notify,onChange,onBack}:{dev:any;notify:(m:string)=>void;onChange:()=>void;onBack:()=>void}){ +function DeviceTemplateCard({dev,notify,onChange,onBack,onViewCpPrompt}:{dev:any;notify:(m:string)=>void;onChange:()=>void;onBack:()=>void;onViewCpPrompt:(id:number)=>void}){ const [name,setName]=useState(dev.name); const [description,setDesc]=useState(dev.description||""); const [imgPreview,setImgPreview]=useState(dev.image_path?`/api/db/device-templates/${dev.id}/image`:null); const [imgB64,setImgB64]=useState<{filename:string;mime:string;data:string}|null>(null); @@ -1213,6 +1220,22 @@ function DeviceTemplateCard({dev,notify,onChange,onBack}:{dev:any;notify:(m:stri const toggleAll=()=>setCps(cps.map(c=>({...c,checked:!allChecked}))); const delCpRows=async()=>{ const sel=cps.filter(c=>c.checked); if(sel.length===0){ notify("请先勾选要删除的采集点"); return; } if(!(await confirm(`确定删除选中的 ${sel.length} 个采集点?`))) return; for(const c of sel){ if(c.id) await fetchDb(`/collection-points/${c.id}`,{method:"DELETE"}); } setCps(cps.filter(c=>!c.checked)); onChange(); }; const saveCpRows=async()=>{ try{ for(const c of cps){ const payload={name:c.name,collect_note:c.collect_note,unit:c.unit,value_range:c.value_range,description:c.description}; if(c.id){ await fetchDb(`/collection-points/${c.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)}); } else { await fetchDb(`/device-templates/${dev.id}/collection-points`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)}); } } notify("采集点已保存"); onChange(); }catch(e:any){ notify("保存失败:"+e.message); } }; + const cpAi=useStreamComplete(); + const runCpComplete=async()=>{ + if(cpAi.completing) return; + cpAi.setCompleting(true); cpAi.setLog(""); + const wd=setTimeout(()=>{ cpAi.setCompleting(false); notify("AI 采集点补全超时(>10 分钟),请重试"); },600000); + try{ + const res=await fetch(`/api/db/device-templates/${dev.id}/complete-collection-points`,{method:"POST"}); + if(!res.ok||!res.body) throw new Error("HTTP "+res.status); + await streamInto(res, cpAi.setLog, ()=>{}); + const fresh:any=await fetchDb(`/device-templates/${dev.id}`); + setCps((fresh.collection_points||[]).map((c:any,i:number)=>({...c,key:c.id||-(i+1),checked:false}))); + notify("AI 采集点补全已完成(已保存到该设备模板)"); + }catch(e:any){ notify("AI 采集点补全失败:"+(e?.message||e)); } + finally{ clearTimeout(wd); cpAi.setCompleting(false); } + }; + const viewCpCompletePrompt=()=>onViewCpPrompt(dev.id); return
{name||"(未命名)"}
@@ -1243,6 +1266,14 @@ function DeviceTemplateCard({dev,notify,onChange,onBack}:{dev:any;notify:(m:stri ))}
+ 基于设备名称 / 领域 / 说明,AI 推荐合适的采集点并追加}> +

为当前设备模板补充一套合适的采集点(追加模式,不覆盖已手动录入的采集点)。

+
+ + +
+ {(cpAi.completing||cpAi.log)&&
AI 智能补全 · 采集点
{cpAi.log||"(等待 AI 分析…)"}
} +
{confirmEl}
; @@ -1262,7 +1293,7 @@ function DeviceTemplateCreate({notify,onDone,onCancel}:{notify:(m:string)=>void; ; } -function DeviceTemplates({notify}:{notify:(m:string)=>void}){ +function DeviceTemplates({notify,onViewCpPrompt}:{notify:(m:string)=>void;onViewCpPrompt:(id:number)=>void}){ const [list,setList]=useState([]); const [loading,setLoading]=useState(true); const [openId,setOpenId]=useState(null); @@ -1299,7 +1330,7 @@ function DeviceTemplates({notify}:{notify:(m:string)=>void}){ if(!dev) return

加载中…

; return <>
- setOpenId(null)}/> + setOpenId(null)} onViewCpPrompt={onViewCpPrompt}/> ; } @@ -1532,7 +1563,7 @@ function DomainWizard({id,doms,go,notify,onBack,viewCompletePrompt,viewCompleteA const runAssetsComplete=async()=>{ if(assetsAi.completing) return; assetsAi.setCompleting(true); assetsAi.setLog(""); - const wd=setTimeout(()=>{ assetsAi.setCompleting(false); notify("AI 资产补全超时(>6 分钟),请重试"); },360000); + const wd=setTimeout(()=>{ assetsAi.setCompleting(false); notify("AI 资产补全超时(>10 分钟),请重试"); },600000); try{ const res=await fetch(`/api/db/domain-templates/${id}/complete-assets`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:m.name,domain:domainName,description:m.description,supplement:m.supplement,assets:assets.map(a=>({type:a.device_name,count:a.count,rule:a.rule}))})}); if(!res.ok||!res.body) throw new Error("HTTP "+res.status); diff --git a/db-sidecar.mjs b/db-sidecar.mjs index de47d96..292f8f8 100644 --- a/db-sidecar.mjs +++ b/db-sidecar.mjs @@ -1340,6 +1340,45 @@ async function handleRefineCube(req, res, siteId, cubeId) { })); } + // ===== AI 采集点补全:为设备模板推导合适的采集点(device_collection_points)===== + async function buildCompleteCollectionPointsPrompt({ name = "", domain = "", description = "", id } = {}) { + const base = `你是一名资深的工业自动化与物联网(IoT)工程师,擅长为各类设备推导其监测运营所需的「采集点」(测点 / 数据点)。 +请根据以下设备信息,为该设备列举一套合理、完整的采集点清单,覆盖运行监测、能耗、状态、告警等关键维度。 + +设备信息: +- 设备名称:${name || "未命名设备"} +- 所属领域/行业:${domain || "未指定"} +- 设备说明:${description || "无"} + +要求: +1. 先简要说明推导思路(思考过程):从设备类型与行业推断典型监测参数、常用单位与合理量程。 +2. 思考过程结束后,另起一段,以「【采集点】」开头,给出 JSON 数组(仅数组本身,不要代码围栏),每个元素为 {"name":"采集点名称","collect_note":"采集说明/安装位置","unit":"单位","value_range":"数值范围(如 0~100、0~1000、-20~80)","description":"该采集点的含义/用途"}。 +3. 采集点数量建议 6~12 个,名称简洁专业(如 进水流量、出水压力、电机电流、水池液位、运行频率 等),单位与数值范围需符合该参数常识;数值范围尽量从以下预设中选取:0~100、0~1000、-20~80、-40~85、0~50、0~5、0~1、0~100%。 +4. 不要包含与设备无关的采集点。 + +请直接输出你的分析与【采集点】JSON。`; + return base + (await buildKnowledgePrompt({ id })); + } + + function extractCollectionPoints(text) { + const marker = "【采集点】"; + const idx = text.lastIndexOf(marker); + let body = idx >= 0 ? text.slice(idx + marker.length) : text; + body = body.replace(/```(?:json)?/gi, "").trim(); + const s = body.indexOf("["); + const e = body.lastIndexOf("]"); + if (s < 0 || e <= s) throw new Error("未在输出中找到采集点 JSON"); + const arr = JSON.parse(body.slice(s, e + 1)); + if (!Array.isArray(arr)) throw new Error("采集点不是数组"); + return arr.filter(x => x && x.name).map(x => ({ + name: String(x.name).trim(), + collect_note: x.collect_note != null ? String(x.collect_note).trim() : "", + unit: x.unit != null ? String(x.unit).trim() : "", + value_range: x.value_range != null ? String(x.value_range).trim() : "", + description: x.description != null ? String(x.description).trim() : "", + })); + } + async function handleCompleteAssets(req, res, siteId) { res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" }); const emit = (obj) => streamEvent(res, obj); @@ -1848,11 +1887,31 @@ async function resolveDeviceTemplate(name, domainId, tmplId) { const info = db.prepare("INSERT INTO device_templates (name, description) VALUES (?,?)").run(name, `由领域方案 AI 资产补全自动创建设备模板`); const devId = info.lastInsertRowid; if (domainId) db.prepare("INSERT OR IGNORE INTO device_template_domains (device_template_id, domain_id) VALUES (?,?)").run(devId, domainId); - if (tmplId) db.prepare("INSERT OR IGNORE INTO device_template_domain_templates (device_template_id, domain_template_id) VALUES (?,?)").run(devId, tmplId); + if (tmplId) db.prepare("INSERT OR IGNORE INTO device_template_domain_templates (device_template_id, domain_template_id) VALUES (?,?)").run(devId, tmplId); return { id: devId, created: true }; -} + } -async function handleDomainComplete(req, res, tmplId) { + // 为设备模板生成采集点:运行 AI 并写入 device_collection_points。 + // replace=false(默认,追加模式):跳过与已有同名的采集点,不覆盖用户手动录入;replace=true:先清空再写入。 + async function generateDeviceTemplatePoints(devId, name, domainName, description, tmplId, replace = false, onEvent) { + const lockKey = `dt-cp-${devId}`; + if (genLocks.has(lockKey)) throw new Error("采集点 AI 补全正在进行中"); + genLocks.add(lockKey); + try { + const prompt = await buildCompleteCollectionPointsPrompt({ name, domain: domainName, description, id: tmplId }); + const text = await runCodebuddy(prompt, `stationbuilder-dt-cp-${devId}`, onEvent || (() => {})); + const pts = extractCollectionPoints(text); + const existingNames = new Set((db.prepare("SELECT name FROM device_collection_points WHERE device_template_id=?").all(devId) || []).map(r => String(r.name).trim())); + const ins = db.prepare("INSERT INTO device_collection_points (device_template_id,name,collect_note,unit,value_range,description) VALUES (?,?,?,?,?,?)"); + for (const p of pts) { + if (!replace && existingNames.has(p.name)) continue; + ins.run(devId, p.name, p.collect_note || "", p.unit || "", p.value_range || "", p.description || ""); + } + return pts; + } finally { genLocks.delete(lockKey); } + } + + async function handleDomainComplete(req, res, tmplId) { res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" }); const emit = (o) => streamEvent(res, o); const fail = (m) => { emit({ task: "系统", status: "failed", message: m }); res.end(); }; @@ -1908,10 +1967,11 @@ async function handleDomainCompleteAssets(req, res, tmplId) { const text = await runCodebuddy(prompt, `stationbuilder-dt-complete-assets-${tmplId}`, onEvent); const items = extractAssets(text); const createdNames = []; + const newDevIds = []; const assets = []; for (const it of items) { const r = await resolveDeviceTemplate(it.type, dt.domain_id, tmplId); - if (r.created) createdNames.push(it.type); + if (r.created) { createdNames.push(it.type); newDevIds.push({ id: r.id, name: it.type }); } assets.push({ device_template_id: r.id, count: it.count, rule: it.rule }); } // 落库:追加写入 domain_template_assets(按 device_template_id 去重,避免覆盖用户手动已录入的行) @@ -1924,6 +1984,16 @@ async function handleDomainCompleteAssets(req, res, tmplId) { if (exists.get(tmplId, a.device_template_id)) continue; ins.run(tmplId, Number(a.device_template_id), Number(a.count) || 0, a.rule || "", so++); } + // 为自动新建设备模板补全采集点(追加模式,不覆盖用户手动录入;单个失败不影响整体) + if (newDevIds.length) { + emit({ log: `\n▶ 正在为自动创建设备模板补全采集点…\n` }); + for (const nd of newDevIds) { + try { + const pts = await generateDeviceTemplatePoints(nd.id, nd.name, dt.domain_name, dt.description || "", tmplId, false, onEvent); + emit({ log: `\n✓ 设备模板「${nd.name}」已补全 ${pts.length} 个采集点\n` }); + } catch (e) { emit({ log: `\n✗ 设备模板「${nd.name}」采集点补全失败:${e.message}\n` }); } + } + } emit({ log: `\n✓ 已生成 ${assets.length} 类设备配置${createdNames.length ? `,并自动创建设备模板:${createdNames.join("、")}` : ""}\n` }); emit({ result: assets, created: createdNames }); emit({ task: "AI 资产补全", status: "done" }); @@ -2122,6 +2192,36 @@ async function handleCollectionPointDelete(res, id) { respondJson(res, 200, { ok: true }); } +async function handleDeviceCompleteCollectionPoints(req, res, devId) { + res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" }); + const emit = (o) => streamEvent(res, o); + const fail = (m) => { emit({ task: "系统", status: "failed", message: m }); res.end(); }; + const dev = db.prepare("SELECT dt.*, (SELECT d.name FROM device_template_domains dtd JOIN domains d ON d.id=dtd.domain_id WHERE dtd.device_template_id=dt.id LIMIT 1) AS domain_name FROM device_templates dt WHERE dt.id=?").get(devId); + if (!dev) return fail("设备模板不存在"); + const onEvent = (o) => { if (o?.type === "stream_event" && o.event?.type === "content_block_delta" && o.event.delta?.type === "text_delta" && o.event.delta.text) emit({ log: o.event.delta.text }); }; + try { + const prompt = await buildCompleteCollectionPointsPrompt({ name: dev.name, domain: dev.domain_name || "", description: dev.description || "", id: null }); + emit({ prompt }); + emit({ log: `\n▶ 开始 AI 采集点补全:为「${dev.name}」生成合适的采集点…\n` }); + emit({ task: "AI 采集点补全", status: "running" }); + const pts = await generateDeviceTemplatePoints(devId, dev.name, dev.domain_name || "", dev.description || "", null, false, onEvent); + emit({ log: `\n✓ 已补全 ${pts.length} 个采集点(已追加,不覆盖已有采集点)\n` }); + emit({ result: pts }); + emit({ task: "AI 采集点补全", status: "done" }); + res.end(); + } catch (e) { + emit({ log: `✗ AI 采集点补全失败:${e.message}\n` }); + emit({ task: "系统", status: "failed", message: e.message }); + res.end(); + } +} + +async function handleDeviceCompleteCollectionPointsPrompt(req, res, devId) { + const dev = db.prepare("SELECT dt.*, (SELECT d.name FROM device_template_domains dtd JOIN domains d ON d.id=dtd.domain_id WHERE dtd.device_template_id=dt.id LIMIT 1) AS domain_name FROM device_templates dt WHERE dt.id=?").get(devId); + if (!dev) return respondError(res, 404, "设备模板不存在"); + return respondJson(res, 200, { prompt: await buildCompleteCollectionPointsPrompt({ name: dev.name, domain: dev.domain_name || "", description: dev.description || "", id: null }) }); +} + async function handleDomainPageCreate(req, res, tmplId) { const body = await parseBody(req); const name = (body.name || "").toString().trim(); @@ -2312,6 +2412,8 @@ async function route(req, res) { const mDevCp = path.match(/^\/api\/device-templates\/(\d+)\/collection-points$/); const mDev = path.match(/^\/api\/device-templates\/(\d+)$/); const mDevImage = path.match(/^\/api\/device-templates\/(\d+)\/image$/); + const mDevCpComplete = path.match(/^\/api\/device-templates\/(\d+)\/complete-collection-points$/); + const mDevCpCompletePrompt = path.match(/^\/api\/device-templates\/(\d+)\/complete-collection-points-prompt$/); const mCp = path.match(/^\/api\/collection-points\/(\d+)$/); const mDp = path.match(/^\/api\/domain-pages\/(\d+)$/); const mMenu = path.match(/^\/api\/template-menu-items\/(\d+)$/); @@ -2555,6 +2657,8 @@ async function route(req, res) { if (req.method === "PUT") return handleCollectionPointUpdate(req, res, Number(mCp[1])); if (req.method === "DELETE") return handleCollectionPointDelete(res, Number(mCp[1])); } + if (mDevCpComplete) { if (req.method === "POST") return handleDeviceCompleteCollectionPoints(req, res, Number(mDevCpComplete[1])); } + if (mDevCpCompletePrompt) { if (req.method === "GET") return handleDeviceCompleteCollectionPointsPrompt(req, res, Number(mDevCpCompletePrompt[1])); } if (mDp) { if (req.method === "PUT") return handleDomainPageUpdate(req, res, Number(mDp[1])); if (req.method === "DELETE") return handleDomainPageDelete(res, Number(mDp[1])); diff --git a/public/design-doc.html b/public/design-doc.html index e5b316a..3683ed6 100644 --- a/public/design-doc.html +++ b/public/design-doc.html @@ -111,7 +111,7 @@
-

文档版本:v0.27 最后更新:2026-08-21 修改人:wang.yu 状态:开发中(演示数据已标注)

+

文档版本:v0.28 最后更新:2026-08-21 修改人:wang.yu 状态:开发中(演示数据已标注)

StationBuilder 产品与交互设计方案

1. 文档说明

@@ -647,7 +647,7 @@ 设备名称GET/PUT /api/db/device-templates/:id可编辑文本输入设备模版名 设备图片同上(image base64)可编辑点击预览区上传(accept="image/*"),object-fit:cover 长方形预览方案选用设备缩略图 说明同上可编辑多行文本设备描述 - 采集点(表格:复选/名称/说明/单位/数值范围/描述)GET 详情 collection_points;POST /device-templates/:id/collection-points;PUT/DELETE /api/db/collection-points/:id可编辑名称/说明/单位/描述 = 文本输入;数值范围 = 下拉选择器(预设 0~100 / 0~1000 / -20~80 / -40~85 / 0~50 / 0~5 / 0~1 / 0~100%,或「自定义…」文本输入)设备采集点定义(供设备实例监控;当前生成未直接消费) + 采集点(表格:复选/名称/说明/单位/数值范围/描述)GET 详情 collection_points;POST /device-templates/:id/collection-points;PUT/DELETE /api/db/collection-points/:id可编辑名称/说明/单位/描述 = 文本输入;数值范围 = 下拉选择器(预设 0~100 / 0~1000 / -20~80 / -40~85 / 0~50 / 0~5 / 0~1 / 0~100%,或「自定义…」文本输入)设备采集点定义(供设备实例监控);可由「AI 智能补全 · 采集点」一键补全(见 4.12.2)

② 按钮与操作

@@ -660,6 +660,8 @@ + +
详情「+ 新增一行」追加空白采集点行(临时负 key)—无弹窗
详情「删除选中」勾选 → DELETE 已有 id(collection-points/:id)多选二次确认后批量删除
详情「保存采集点」已有行 PUT /collection-points/:id;新建行 POST /device-templates/:id/collection-points—写回并提示"采集点已保存"
详情「AI 智能补全 · 采集点」→ 开始补全POST /api/db/device-templates/:id/complete-collection-points(流式 NDJSON;后端用 codebuddy 生成并写库)—补全完成后前端重新拉取详情并刷新采集点表格(追加模式,不覆盖用户已录入的同名采集点);控制台实时显示推导过程
详情「AI 智能补全 · 采集点」→ 查看提示词GET /api/db/device-templates/:id/complete-collection-points-prompt—弹窗展示将发送给模型的完整组合提示词(设备名称/领域/说明 + 领域知识)
详情「删除设备」DELETE 设备—二次确认后删除并 → 返回列表
← 返回列表openId(null)—返回列表态
@@ -670,6 +672,12 @@
  • 下一步:无(详情为末层);亦可由领域方案详情「选用设备模版 → + 新增设备模版」跳转至此新建。
  • +

    4.12.2 AI 采集点补全

    +

    采集点是设备监控运营的核心数据点。StationBuilder 提供两处 AI 补全入口,统一由后端 buildCompleteCollectionPointsPrompt 构造提示词、runCodebuddy(模型 hy3)生成、extractCollectionPoints 解析为 {name, collect_note, unit, value_range, description}[] 后批量写入 device_collection_points。

    +

    ① 领域方案生成时自动补全(Part A):在领域方案详情「AI 资产补全」(POST /api/db/domain-templates/:id/complete-assets) 中,每当 AI 推理出某设备类型并新创建对应设备模板(resolveDeviceTemplate 返回 created:true)后,后端立即以该设备名称为设备模板生成 6~12 个合适采集点并写库(追加模式,单个失败不影响整体;推导过程实时回流到资产补全控制台)。已存在的设备模板(created:false)不重复补全,避免覆盖用户数据。

    +

    ② 单独打开设备模板时补全(Part B):设备模板详情(DeviceTemplateCard)新增「AI 智能补全 · 采集点」面板,含「开始补全」与「查看提示词」按钮。点击「开始补全」调用 POST /api/db/device-templates/:id/complete-collection-points(流式 NDJSON),后端用 codebuddy 生成采集点并写库,前端补全完成后重新拉取详情刷新采集点表格;「查看提示词」弹窗展示将发送给模型的完整组合提示词(设备名称/所属领域/说明 + 领域知识)。

    +

    补全策略:默认追加模式——保留用户已手动录入的采集点,仅新增 AI 推荐、且与已有采集点名称不重复的条目(按 name 去重),不覆盖已录入数据;数值范围优先从预设集合(0~100 / 0~1000 / -20~80 / -40~85 / 0~50 / 0~5 / 0~1 / 0~100%)中选取。

    +

    4.13 界面截图(UI 总览)

    以下截图覆盖了当前 StationBuilder 原型的主要页面。截图在 1440×900 视口下使用 Playwright + Chromium 自动抓取,图片存放于 public/design-doc-assets/。各页的字段、按钮与导航流逐项说明见 4.2–4.12 的「交互细节」子章;其中 4.3 / 4.4 / 4.5 另含「AI 智能补全」子章(4.3.2 / 4.4.2 / 4.5.2),展示 AI 流式输出过程。

    可达性说明:站点项目、基本属性、Asset、功能与菜单、生成确认、站点结果(站点项目点击已生成站点进入)、领域方案库、方案详情、页面样式库、设备模版均可正常访问;页面预览(4.8)当前无 UI 入口(代码中无 go("preview") 调用),故未单独截图,其演示外壳见 4.8.1。

    @@ -751,6 +759,7 @@

    10.1 版本变更记录(Changelog)

    +
    版本日期修改人说明
    v0.282026-08-21wang.yu设备模板「采集点」AI 智能补全(两处入口):① 领域方案「AI 资产补全」自动创建设备模板后,后端立即以设备名称为其生成 6~12 个合适采集点并写库(resolveDeviceTemplate 返回 created:true 时触发;追加模式,单个失败不影响整体,推导过程回流到资产补全控制台;已存在模板不重复补全)。② 设备模板详情(DeviceTemplateCard)新增「AI 智能补全 · 采集点」面板:「开始补全」调用新增接口 POST /api/db/device-templates/:id/complete-collection-points(流式 NDJSON,codebuddy 生成 + 写库,前端补全后重新拉取详情刷新表格),「查看提示词」GET /api/db/device-templates/:id/complete-collection-points-prompt 弹窗展示完整组合提示词。后端新增 buildCompleteCollectionPointsPrompt / extractCollectionPoints / generateDeviceTemplatePoints(按 name 去重追加,不覆盖用户已录入采集点;数值范围优先取预设集合);前端新增 DeviceTemplateCard 的 cpAi=useStreamComplete() 流式面板,并透传 onViewCpPrompt 复用 App 级提示词弹窗;资产补全前端超时守卫由 6 分钟放宽到 10 分钟(因额外补全采集点)。Playwright E2E:临时设备点「开始补全」→ 实际运行 codebuddy 生成 12 个采集点并持久化(GET 详情 collection_points.length=12),控制台显示「✓ 已补全 12 个采集点」,0 控制台报错,临时设备已清理。设计文档同步更新 4.12 / 新增 4.12.2、版本 v0.28。
    v0.272026-08-21wang.yu领域编辑迁移至左侧导航栏:① 移除「领域方案库」首页的「管理领域」面板,首页方案列表相应上移(直接进入列表,不再被管理面板挤占首屏)。② 领域增删改入口迁至左侧「领域方案库模块 → 领域分类」分组,UI 与现有领域分类导航项一致:每个领域行尾加 × 删除按钮(仅非「其他」领域,点击经 window.confirm 二次确认后 DELETE /api/domains/:id,其下方案归入「其他」,随后 reloadDomains 刷新导航);行末新增「+ 新增领域」按钮,点击展开行内输入框(最多 6 字,Enter 确认 / Esc 取消),POST /api/domains 后刷新导航。③ 修复 addDomain 原只更新前端 domains 字符串数组、未刷新导航用 domainOptions 导致新增后导航不出现的问题——现改由 reloadDomains 统一刷新两个 state。④ globals.css 新增 .domain-nav-row / .domain-nav-del / .domain-nav-ok / .domain-nav-add / .domain-nav-input 等样式,保持与导航按钮一致的 41px 行高与激活态。Playwright 验证:面板已移除、导航显示 6 领域行(5 个 × + 1 个 +)、新增/删除临时领域均生效、「其他」无删除按钮、0 控制台报错。后续微调:领域名按钮与 ×/+ 按钮原共用 `.domain-nav-row>button` 的 `flex:1` 导致 × 也等分给到约 95px、过滤按钮被压窄至 95px、4 字以内领域名在用户浏览器中换行;改为 `flex:1` 仅作用于过滤按钮(`:not(.domain-nav-del):not(.domain-nav-ok)`),×/✓ 固定 30px,领域名加 `white-space:nowrap` 并包入 `.domain-nav-name` 超出省略号,4 字以内领域名稳定单行显示。
    v0.262026-08-21wang.yu导出修复(设计方案 Word / PDF):① PDF 由 html2pdf(jsPDF+html2canvas,中文乱码且长文档卡死)改为调用浏览器原生打印(window.print),输出真实文字、微软雅黑、纯黑、1.5 倍行距,无乱码;打印时临时注入静态目录(buildStaticToc,含 h2/h3/h4)于文首,打印后移除、不污染屏幕 UI;屏幕 UI 截图尺寸不变(仍 360px)。② Word 页脚页码修复:footer 元素补 id 并改用规范 Word 域标记(PAGE / NUMPAGES 字段,含 field-begin/separator/end),由「仅首页顶部显示 1」修正为每页底部「第 X 页 / 共 Y 页」。③ Word 目录由 TOC 域(打开时常为空)改为默认插入可见的静态目录(buildStaticToc,含 h2/h3/h4 三级)。④ 截图限宽 300px(等比例):Word 设 width=300 属性 + 行内样式;PDF 原 @media print 用 `.figure img` 类选择器未命中正文 `
    ` 元素导致约束不生效、图片按原始像素溢出页面(即此前「240 显示不下」的真相),改为 `figure img` 元素选择器 + max-width:300px 后正常限宽并完整显示;图后插入回车(<br>)使图注文字换行到下一行。⑤ 补充 @page 打印页码(A4 页脚居中「第 X 页 / 共 Y 页」;Chrome 另存为 PDF 若未显示,在打印对话框勾选「页眉和页脚」兜底)。
    v0.242026-08-20wang.yuAI 并发调用统一限流:新增全局信号量 AI_CONCURRENCY = 2,包裹 runCodebuddy(db-sidecar.mjs),使站点生成(含批内并行)、各项补全(complete / complete-assets / complete-cubes)、领域方案补全(dt-complete 系列)等全部 AI 功能任意时刻最多 2 个 codebuddy 子进程并发,避免无脑全并行抢占资源导致超时。站点「生成全部」批内并行数 GEN_CONCURRENCY 由硬编码 3 改为复用 AI_CONCURRENCY,对应日志文案同步为「最多 2 个并行调用」。AI 聊天侧(ai-sidecar.mjs /api/chat)本身以 running 布尔量串行(同一时刻仅 1 个),已满足 ≤2。