]> acesimba.cloud Git - StationBuilder.git/commitdiff
feat: 设备模板采集点 AI 智能补全(领域生成自动补全 + 单独打开补全)
authorroot <root@localhost.localdomain>
Fri, 21 Aug 2026 03:24:23 +0000 (11:24 +0800)
committerroot <root@localhost.localdomain>
Fri, 21 Aug 2026 03:24:23 +0000 (11:24 +0800)
- 后端新增 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)

app/page.tsx
db-sidecar.mjs
public/design-doc.html

index 52aa0c97c4f751a349c82035441f1db85041ecbd..2ee55372ebe994556a84dec33ace6d8301feac64 100644 (file)
@@ -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(){
         ? <DomainDetail id={openDomainTemplateId==="create"?null:openDomainTemplateId} go={go} notify={notify} onBack={()=>setOpenDomainTemplateId(null)} onCreated={setOpenDomainTemplateId} viewCompletePrompt={viewDtCompletePrompt} viewCompleteAssetsPrompt={viewDtCompleteAssetsPrompt} viewCompleteCubesPrompt={viewDtCompleteCubesPrompt} pageStyles={pageStyles}/>
         : <DomainLibrary go={go} notify={notify} onOpen={(id:number)=>setOpenDomainTemplateId(id)} domainFilter={domainFilter} domainOptions={domainOptions}/>)}
       {page==="styleLibrary"&&<PageStyleLibrary pageStyles={pageStyles} loadPageStyles={loadPageStyles} notify={notify}/>}
-      {page==="deviceTemplates"&&<DeviceTemplates notify={notify}/>}
+      {page==="deviceTemplates"&&<DeviceTemplates notify={notify} onViewCpPrompt={viewDeviceCpCompletePrompt}/>}
       {page==="basic"&&<Basic site={site} domains={domains} onChange={saveSiteBasic} onAddDomain={addDomain} onUpload={uploadFile} saving={saving} notify={notify} onViewCompletePrompt={viewCompletePrompt}/>}
       {page==="assets"&&<Assets site={site} onChange={saveAssets} saving={saving} notify={notify} onViewCompletePrompt={viewCompleteAssetsPrompt}/>}
       {page==="functions"&&<Functions site={site} muTree={muTree} onMuChange={saveMu} onSiteChange={setSite} pageStyles={pageStyles} notify={notify} onViewCompletePrompt={viewCompleteCubesPrompt}/>}
@@ -1197,7 +1204,7 @@ function DeviceImageField({imageUrl,onPick}:{imageUrl:string|null;onPick:(f:File
   </div>;
 }
 
-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<string|null>(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 <div className="device-card">
     <div className="device-card-head"><b>{name||"(未命名)"}</b><button className="text-button" onClick={delDev}>删除设备</button></div>
     <div className="form-grid">
@@ -1243,6 +1266,14 @@ function DeviceTemplateCard({dev,notify,onChange,onBack}:{dev:any;notify:(m:stri
         ))}
       </tbody></table>
     </div>
+    <Panel title="AI 智能补全 · 采集点" tools={<span className="muted">基于设备名称 / 领域 / 说明,AI 推荐合适的采集点并追加</span>}>
+      <p className="hint">为当前设备模板补充一套合适的采集点(追加模式,不覆盖已手动录入的采集点)。</p>
+      <div style={{display:"flex",gap:"8px",alignItems:"center"}}>
+        <button className="button" disabled={cpAi.completing} onClick={runCpComplete}>{cpAi.completing?"采集中…":"开始补全"}</button>
+        <button className="text-button" onClick={viewCpCompletePrompt}>查看提示词</button>
+      </div>
+      {(cpAi.completing||cpAi.log)&&<div className="modal-console"><div className="modal-console-head">AI 智能补全 · 采集点</div><pre className="modal-console-body" ref={cpAi.ref}>{cpAi.log||"(等待 AI 分析…)"}</pre></div>}
+    </Panel>
     <div style={{marginTop:8}}><Button onClick={save}>保存设备</Button></div>
     {confirmEl}
   </div>;
@@ -1262,7 +1293,7 @@ function DeviceTemplateCreate({notify,onDone,onCancel}:{notify:(m:string)=>void;
   </div></Panel>;
 }
 
-function DeviceTemplates({notify}:{notify:(m:string)=>void}){
+function DeviceTemplates({notify,onViewCpPrompt}:{notify:(m:string)=>void;onViewCpPrompt:(id:number)=>void}){
   const [list,setList]=useState<any[]>([]);
   const [loading,setLoading]=useState(true);
   const [openId,setOpenId]=useState<number|"create"|null>(null);
@@ -1299,7 +1330,7 @@ function DeviceTemplates({notify}:{notify:(m:string)=>void}){
   if(!dev) return <p>加载中…</p>;
   return <>
     <div className="domain-detail-head"><button className="text-button" onClick={()=>setOpenId(null)}>← 返回列表</button></div>
-    <DeviceTemplateCard dev={dev} notify={notify} onChange={load} onBack={()=>setOpenId(null)}/>
+    <DeviceTemplateCard dev={dev} notify={notify} onChange={load} onBack={()=>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);
index de47d9658adebfecc310e7608495685f45a95e9b..292f8f8b420fdcc8de2e28e5ed74a4e9af537286 100644 (file)
@@ -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]));
index e5b316a8580234da5aa1f4a13cdb3b5d19188754..3683ed688726288b51bdefe806f6942905d4e4d0 100644 (file)
   <nav class="doc-nav"><div class="doc-nav-inner" id="toc"></div></nav>
 
 <main id="doc">
-  <p class="muted">文档版本:v0.27 最后更新:2026-08-21 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
+  <p class="muted">文档版本:v0.28 最后更新:2026-08-21 修改人:wang.yu 状态:开发中(演示数据已标注)</p>
   <h1>StationBuilder 产品与交互设计方案</h1>
 
   <h2>1. 文档说明</h2>
     <tr><td>设备名称</td><td>GET/PUT /api/db/device-templates/:id</td><td>可编辑</td><td>文本输入</td><td>设备模版名</td></tr>
     <tr><td>设备图片</td><td>同上(image base64)</td><td>可编辑</td><td>点击预览区上传(<code>accept="image/*"</code>),<code>object-fit:cover</code> 长方形预览</td><td>方案选用设备缩略图</td></tr>
     <tr><td>说明</td><td>同上</td><td>可编辑</td><td>多行文本</td><td>设备描述</td></tr>
-    <tr><td>é\87\87é\9b\86ç\82¹ï¼\88表格ï¼\9aå¤\8dé\80\89/å\90\8dç§°/说æ\98\8e/å\8d\95ä½\8d/æ\95°å\80¼è\8c\83å\9b´/æ\8f\8fè¿°ï¼\89</td><td>GET è¯¦æ\83\85 <code>collection_points</code>ï¼\9bPOST /device-templates/:id/collection-pointsï¼\9bPUT/DELETE /api/db/collection-points/:id</td><td>å\8f¯ç¼\96è¾\91</td><td>å\90\8dç§°/说æ\98\8e/å\8d\95ä½\8d/æ\8f\8fè¿° = æ\96\87æ\9c¬è¾\93å\85¥ï¼\9b<b>æ\95°å\80¼è\8c\83å\9b´</b> = ä¸\8bæ\8b\89é\80\89æ\8b©å\99¨ï¼\88é¢\84设 0~100 / 0~1000 / -20~80 / -40~85 / 0~50 / 0~5 / 0~1 / 0~100%ï¼\8cæ\88\96ã\80\8cè\87ªå®\9aä¹\89â\80¦ã\80\8dæ\96\87æ\9c¬è¾\93å\85¥ï¼\89</td><td>设å¤\87é\87\87é\9b\86ç\82¹å®\9aä¹\89ï¼\88ä¾\9b设å¤\87å®\9eä¾\8bç\9b\91æ\8e§ï¼\9bå½\93å\89\8dç\94\9fæ\88\90æ\9cªç\9b´æ\8e¥æ¶\88è´¹)</td></tr>
+    <tr><td>é\87\87é\9b\86ç\82¹ï¼\88表格ï¼\9aå¤\8dé\80\89/å\90\8dç§°/说æ\98\8e/å\8d\95ä½\8d/æ\95°å\80¼è\8c\83å\9b´/æ\8f\8fè¿°ï¼\89</td><td>GET è¯¦æ\83\85 <code>collection_points</code>ï¼\9bPOST /device-templates/:id/collection-pointsï¼\9bPUT/DELETE /api/db/collection-points/:id</td><td>å\8f¯ç¼\96è¾\91</td><td>å\90\8dç§°/说æ\98\8e/å\8d\95ä½\8d/æ\8f\8fè¿° = æ\96\87æ\9c¬è¾\93å\85¥ï¼\9b<b>æ\95°å\80¼è\8c\83å\9b´</b> = ä¸\8bæ\8b\89é\80\89æ\8b©å\99¨ï¼\88é¢\84设 0~100 / 0~1000 / -20~80 / -40~85 / 0~50 / 0~5 / 0~1 / 0~100%ï¼\8cæ\88\96ã\80\8cè\87ªå®\9aä¹\89â\80¦ã\80\8dæ\96\87æ\9c¬è¾\93å\85¥ï¼\89</td><td>设å¤\87é\87\87é\9b\86ç\82¹å®\9aä¹\89ï¼\88ä¾\9b设å¤\87å®\9eä¾\8bç\9b\91æ\8e§ï¼\89ï¼\9bå\8f¯ç\94±ã\80\8cAI æ\99ºè\83½è¡¥å\85¨ Â· é\87\87é\9b\86ç\82¹ã\80\8dä¸\80é\94®è¡¥å\85¨ï¼\88è§\81 4.12.2)</td></tr>
   </table>
   <p><b>② 按钮与操作</b></p>
   <table>
     <tr><td>详情「+ 新增一行」</td><td>追加空白采集点行(临时负 key)</td><td>—</td><td>无弹窗</td></tr>
     <tr><td>详情「删除选中」</td><td>勾选 → DELETE 已有 id(<code>collection-points/:id</code>)</td><td>多选</td><td>二次确认后批量删除</td></tr>
     <tr><td>详情「保存采集点」</td><td>已有行 PUT /collection-points/:id;新建行 POST /device-templates/:id/collection-points</td><td>—</td><td>写回并提示"采集点已保存"</td></tr>
+    <tr><td>详情「AI 智能补全 · 采集点」→ 开始补全</td><td>POST /api/db/device-templates/:id/complete-collection-points(流式 NDJSON;后端用 codebuddy 生成并写库)</td><td>—</td><td>补全完成后前端重新拉取详情并刷新采集点表格(追加模式,不覆盖用户已录入的同名采集点);控制台实时显示推导过程</td></tr>
+    <tr><td>详情「AI 智能补全 · 采集点」→ 查看提示词</td><td>GET /api/db/device-templates/:id/complete-collection-points-prompt</td><td>—</td><td>弹窗展示将发送给模型的完整组合提示词(设备名称/领域/说明 + 领域知识)</td></tr>
     <tr><td>详情「删除设备」</td><td>DELETE 设备</td><td>—</td><td>二次确认后删除并 → 返回列表</td></tr>
     <tr><td>← 返回列表</td><td><code>openId(null)</code></td><td>—</td><td>返回列表态</td></tr>
   </table>
     <li><b>下一步</b>:无(详情为末层);亦可由领域方案详情「选用设备模版 → + 新增设备模版」跳转至此新建。</li>
   </ul>
 
+  <h4>4.12.2 AI 采集点补全</h4>
+  <p>采集点是设备监控运营的核心数据点。StationBuilder 提供两处 AI 补全入口,统一由后端 <code>buildCompleteCollectionPointsPrompt</code> 构造提示词、<code>runCodebuddy</code>(模型 <code>hy3</code>)生成、<code>extractCollectionPoints</code> 解析为 <code>{name, collect_note, unit, value_range, description}[]</code> 后批量写入 <code>device_collection_points</code>。</p>
+  <p><b>① 领域方案生成时自动补全(Part A)</b>:在领域方案详情「AI 资产补全」(<code>POST /api/db/domain-templates/:id/complete-assets</code>) 中,每当 AI 推理出某设备类型并<b>新创建</b>对应设备模板(<code>resolveDeviceTemplate</code> 返回 <code>created:true</code>)后,后端立即以该设备名称为设备模板生成 6~12 个合适采集点并写库(追加模式,单个失败不影响整体;推导过程实时回流到资产补全控制台)。已存在的设备模板(<code>created:false</code>)不重复补全,避免覆盖用户数据。</p>
+  <p><b>② 单独打开设备模板时补全(Part B)</b>:设备模板详情(DeviceTemplateCard)新增「AI 智能补全 · 采集点」面板,含「开始补全」与「查看提示词」按钮。点击「开始补全」调用 <code>POST /api/db/device-templates/:id/complete-collection-points</code>(流式 NDJSON),后端用 codebuddy 生成采集点并写库,前端补全完成后重新拉取详情刷新采集点表格;「查看提示词」弹窗展示将发送给模型的完整组合提示词(设备名称/所属领域/说明 + 领域知识)。</p>
+  <p><b>补全策略</b>:默认<b>追加</b>模式——保留用户已手动录入的采集点,仅新增 AI 推荐、且与已有采集点<b>名称不重复</b>的条目(按 <code>name</code> 去重),不覆盖已录入数据;数值范围优先从预设集合(0~100 / 0~1000 / -20~80 / -40~85 / 0~50 / 0~5 / 0~1 / 0~100%)中选取。</p>
+
   <h3>4.13 界面截图(UI 总览)</h3>
   <p>以下截图覆盖了当前 StationBuilder 原型的主要页面。截图在 1440×900 视口下使用 Playwright + Chromium 自动抓取,图片存放于 <code>public/design-doc-assets/</code>。各页的字段、按钮与导航流逐项说明见 4.2–4.12 的「交互细节」子章;其中 4.3 / 4.4 / 4.5 另含「AI 智能补全」子章(4.3.2 / 4.4.2 / 4.5.2),展示 AI 流式输出过程。</p>
   <p class="note">可达性说明:站点项目、基本属性、Asset、功能与菜单、生成确认、站点结果(站点项目点击<b>已生成</b>站点进入)、领域方案库、方案详情、页面样式库、设备模版均可正常访问;<b>页面预览(4.8)当前无 UI 入口</b>(代码中无 <code>go("preview")</code> 调用),故未单独截图,其演示外壳见 4.8.1。</p>
   <h3>10.1 版本变更记录(Changelog)</h3>
   <table class="changelog">
     <tr><th>版本</th><th>日期</th><th>修改人</th><th>说明</th></tr>
+    <tr><td>v0.28</td><td>2026-08-21</td><td>wang.yu</td><td>设备模板「采集点」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。</td></tr>
     <tr><td>v0.27</td><td>2026-08-21</td><td>wang.yu</td><td>领域编辑迁移至左侧导航栏:① 移除「领域方案库」首页的「管理领域」面板,首页方案列表相应上移(直接进入列表,不再被管理面板挤占首屏)。② 领域增删改入口迁至左侧「领域方案库模块 → 领域分类」分组,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 字以内领域名稳定单行显示。</td></tr>
     <tr><td>v0.26</td><td>2026-08-21</td><td>wang.yu</td><td>导出修复(设计方案 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` 类选择器未命中正文 `<figure>` 元素导致约束不生效、图片按原始像素溢出页面(即此前「240 显示不下」的真相),改为 `figure img` 元素选择器 + max-width:300px 后正常限宽并完整显示;图后插入回车(&lt;br&gt;)使图注文字换行到下一行。⑤ 补充 @page 打印页码(A4 页脚居中「第 X 页 / 共 Y 页」;Chrome 另存为 PDF 若未显示,在打印对话框勾选「页眉和页脚」兜底)。</td></tr>
     <tr><td>v0.24</td><td>2026-08-20</td><td>wang.yu</td><td>AI 并发调用统一限流:新增全局信号量 <code>AI_CONCURRENCY = 2</code>,包裹 <code>runCodebuddy</code>(db-sidecar.mjs),使站点生成(含批内并行)、各项补全(complete / complete-assets / complete-cubes)、领域方案补全(dt-complete 系列)等全部 AI 功能任意时刻最多 2 个 codebuddy 子进程并发,避免无脑全并行抢占资源导致超时。站点「生成全部」批内并行数 <code>GEN_CONCURRENCY</code> 由硬编码 3 改为复用 <code>AI_CONCURRENCY</code>,对应日志文案同步为「最多 2 个并行调用」。AI 聊天侧(ai-sidecar.mjs /api/chat)本身以 <code>running</code> 布尔量串行(同一时刻仅 1 个),已满足 ≤2。</td></tr>