]> acesimba.cloud Git - StationBuilder.git/commitdiff
feat: Asset 资产设置页新增「AI 智能补全」
authorwang.yu <wang.yu@advantech.com>
Tue, 18 Aug 2026 02:41:30 +0000 (10:41 +0800)
committerwang.yu <wang.yu@advantech.com>
Tue, 18 Aug 2026 02:41:30 +0000 (10:41 +0800)
- 后端 db-sidecar 新增 POST /api/sites/:id/complete-assets(流式 NDJSON:思考过程 +
  以【资产配置】标记提取 JSON 设备清单 [{type,count,rule}])与 GET .../complete-assets-prompt
- 前端 Assets 页改为两栏:右栏与基本属性同款「填写建议 + AI 智能补全」(开始补全 +
  查看提示词 + 流式控制台);结果追加到用户已填项下方(不改动已有项),资产层级预览随之重算
- 设计文档 §5.4 与 11.1 同步更新

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

index e3f9842e91e5e2c380742548afc0fcbff26e9f19..d7e1627dca5679a0ac6dd012452f12ac8bbbdb00 100644 (file)
@@ -209,6 +209,15 @@ export default function Home(){
       setModal("completePrompt");
     }catch(e:any){ notify("读取提示词失败: "+e.message); }
   };
+  const viewCompleteAssetsPrompt=async()=>{
+    if(!site?.id) { notify("请先创建或打开一个站点"); return; }
+    try{
+      const qs=new URLSearchParams({name:site.name||"",domain:site.domain||"",description:site.description||"",supplement:site.supplement||""}).toString();
+      const data:{prompt:string}=await fetchDb(`/sites/${site.id}/complete-assets-prompt?${qs}`);
+      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);
@@ -440,7 +449,7 @@ export default function Home(){
       {page==="domainDetail"&&<DomainDetail go={go} notify={notify}/>}
       {page==="styleLibrary"&&<PageStyleLibrary pageStyles={pageStyles} loadPageStyles={loadPageStyles} notify={notify}/>}
       {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}/>}
+      {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}/>}
       {page==="generate"&&<Generate site={site} onSiteChange={setSite} notify={notify} viewPrompt={viewPrompt} viewSitePrompt={viewSitePrompt}/>}
       {page==="result"&&<Result site={site} go={go} setModal={setModal} viewPrompt={viewPrompt} notify={notify} openFeedback={openFeedback} viewSitePrompt={viewSitePrompt}/>}
@@ -562,7 +571,42 @@ function Basic({site,domains,onChange,onAddDomain,onUpload,saving,notify,onViewC
   </div></div>;
 }
 
-function Assets({site,onChange,saving}:{site:SiteDetail|null;onChange:(assets:Asset[])=>void;saving:boolean}){
+function Assets({site,onChange,saving,notify,onViewCompletePrompt}:{site:SiteDetail|null;onChange:(assets:Asset[])=>void;saving:boolean;notify:(m:string)=>void;onViewCompletePrompt:()=>void}){
+  const [completing,setCompleting]=useState(false);
+  const [completeLog,setCompleteLog]=useState("");
+  const completeConsoleRef=useRef<HTMLPreElement>(null);
+  useEffect(()=>{ if(completeConsoleRef.current) completeConsoleRef.current.scrollTop=completeConsoleRef.current.scrollHeight; },[completeLog]);
+  const appendAssets=(items:Array<{type:string;count:number;rule:string}>)=>{
+    const base = site?.assets || [];
+    const next = [...base, ...items.map((it,i)=>({type:it.type, count:Number(it.count)||0, rule:it.rule||"", sort_order: base.length+i}))];
+    onChange(next);
+    notify(`AI 已补充 ${items.length} 类设备到资产配置(位于您已填项下方)`);
+  };
+  const runCompleteAssets=async()=>{
+    if(!site?.id || completing) return;
+    setCompleting(true); setCompleteLog("");
+    const watchdog=setTimeout(()=>{ setCompleting(false); notify("AI 资产补全超时(>6 分钟),请重试"); }, 360000);
+    try{
+      const res=await fetch(`/api/db/sites/${site.id}/complete-assets`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:site.name,domain:site.domain,description:site.description,supplement:site.supplement,assets:site.assets})});
+      if(!res.ok||!res.body) throw new Error("HTTP "+res.status);
+      const reader=res.body.getReader(); const dec=new TextDecoder(); let buf="";
+      for(;;){
+        const {done,value}=await reader.read(); if(done) break;
+        buf+=dec.decode(value,{stream:true});
+        const lines=buf.split("\n"); buf=lines.pop()||"";
+        for(const line of lines){
+          if(!line.trim()) continue;
+          try{
+            const ev=JSON.parse(line);
+            if(ev.log) setCompleteLog(prev=>prev+ev.log);
+            if(Array.isArray(ev.result)) appendAssets(ev.result);
+          }catch{}
+        }
+      }
+      notify("AI 资产补全已完成");
+    }catch(e:any){ notify("AI 资产补全失败:"+(e?.message||e)); }
+    finally{ clearTimeout(watchdog); setCompleting(false); }
+  };
   if(!site) return <Panel><div className="empty-state">加载中…</div></Panel>;
   const rows = site.assets.length ? site.assets : Array.from({length:4},(_,i)=>({type:"",count:0,rule:"",sort_order:i}));
   const total = rows.reduce((n,a)=>n+Number(a.count||0),0);
@@ -572,26 +616,41 @@ function Assets({site,onChange,saving}:{site:SiteDetail|null;onChange:(assets:As
   };
   const add=()=>onChange([...rows,{type:"",count:0,rule:"",sort_order:rows.length}]);
   const remove=(i:number)=>onChange(rows.filter((_,idx)=>idx!==i));
-  return <><div className="kpi-row">{[[rows.length,"设备类型","类"],[total,"设备实例","个"],[Math.max(2, rows.filter(r=>r.type).length+1),"资产层级","层"]].map(x=><div key={x[1]}><span>{x[1]}</span><b>{x[0]}</b><small>{x[2]}</small></div>)}{saving&&<div><span>保存</span><Status>保存中…</Status></div>}</div>
-  <Panel title="设备资产配置" tools={<Button onClick={add}>+ 添加设备类型</Button>}>
-    <table className="data-table editable">
-      <thead><tr><th>设备类型 *</th><th>数量 *</th><th>命名规则</th><th>操作</th></tr></thead>
-      <tbody>{rows.map((row,i)=><tr key={i} className={!row.type?"empty-row":""}>
-        <td><input value={row.type} placeholder="例如:冷水机组" onChange={e=>update(i,{type:e.target.value})}/></td>
-        <td><input className="number" type="number" min={0} value={row.count||""} placeholder="0" onChange={e=>update(i,{count:Number(e.target.value)})}/></td>
-        <td><input value={row.rule} placeholder="例如:CH-##" onChange={e=>update(i,{rule:e.target.value})}/></td>
-        <td><button className="trash" onClick={()=>remove(i)}>⌫</button></td>
-      </tr>)}</tbody>
-    </table>
-  </Panel>
-  <Panel title="资产层级预览"><div className="asset-tree-preview">
-    <div className="tree-root">⌄ <b>{site.name || "未命名站点"}</b></div>
-    {rows.filter(r=>r.type).map((row,i)=><div key={i} className="tree-branch">
-      <div className="tree-node">⌄ {row.type} <span className="tree-count">{row.count}</span></div>
-      {row.count>0&&<div className="tree-leaves">{formatInstances(row.rule, row.count)}</div>}
-    </div>)}
-  </div></Panel>
-  </>;
+  return <div className="two-column">
+    <div>
+      <div className="kpi-row">{[[rows.length,"设备类型","类"],[total,"设备实例","个"],[Math.max(2, rows.filter(r=>r.type).length+1),"资产层级","层"]].map(x=><div key={x[1]}><span>{x[1]}</span><b>{x[0]}</b><small>{x[2]}</small></div>)}{saving&&<div><span>保存</span><Status>保存中…</Status></div>}</div>
+      <Panel title="设备资产配置" tools={<Button onClick={add}>+ 添加设备类型</Button>}>
+        <table className="data-table editable">
+          <thead><tr><th>设备类型 *</th><th>数量 *</th><th>命名规则</th><th>操作</th></tr></thead>
+          <tbody>{rows.map((row,i)=><tr key={i} className={!row.type?"empty-row":""}>
+            <td><input value={row.type} placeholder="例如:冷水机组" onChange={e=>update(i,{type:e.target.value})}/></td>
+            <td><input className="number" type="number" min={0} value={row.count||""} placeholder="0" onChange={e=>update(i,{count:Number(e.target.value)})}/></td>
+            <td><input value={row.rule} placeholder="例如:CH-##" onChange={e=>update(i,{rule:e.target.value})}/></td>
+            <td><button className="trash" onClick={()=>remove(i)}>⌫</button></td>
+          </tr>)}</tbody>
+        </table>
+      </Panel>
+      <Panel title="资产层级预览"><div className="asset-tree-preview">
+        <div className="tree-root">⌄ <b>{site.name || "未命名站点"}</b></div>
+        {rows.filter(r=>r.type).map((row,i)=><div key={i} className="tree-branch">
+          <div className="tree-node">⌄ {row.type} <span className="tree-count">{row.count}</span></div>
+          {row.count>0&&<div className="tree-leaves">{formatInstances(row.rule, row.count)}</div>}
+        </div>)}
+      </div></Panel>
+    </div>
+    <div className="stack">
+      <Panel title="填写建议"><ul className="tips"><li>列出站点可能涉及的主要设备类型(泵、风机、换热器、仪表等)。</li><li>为每种设备估算典型数量与命名规则。</li><li>命名规则用 <code>#</code> 表示序号,如 <code>CH-##</code> 生成 CH-01/CH-02;纯前缀如 <code>beng-</code> 会自动补 -01/-02。</li><li>资产层级按「站点 → 类型 → 实例」三级展开预览。</li></ul></Panel>
+      <Panel title="AI 智能补全">
+        <p className="hint">基于站点名称 / 领域 / 描述 / 补充要求,由 AI 分析并补充设备类型、数量与命名规则到资产配置列表(位于您已填项下方)。</p>
+        <div style={{display:"flex",gap:"8px",alignItems:"center"}}>
+          <button className="button" disabled={completing} onClick={runCompleteAssets}>{completing?"补全中…":"开始补全"}</button>
+          <button className="text-button" onClick={onViewCompletePrompt}>查看提示词</button>
+        </div>
+        {(completing||completeLog)&&<div className="modal-console"><div className="modal-console-head">AI 智能补全</div><pre className="modal-console-body" ref={completeConsoleRef}>{completeLog||"(等待 AI 分析…)"}</pre></div>}
+      </Panel>
+      {saving&&<Panel><div className="empty-state">正在保存…</div></Panel>}
+    </div>
+  </div>;
 }
 
 function Functions({site,muTree,onMuChange,onSiteChange,pageStyles,notify}:{site:SiteDetail|null;muTree:MuNode[];onMuChange:(s:string[])=>void;onSiteChange:(s:SiteDetail)=>void;pageStyles:PageStyle[];notify:(m:string)=>void}){
index ccab5efb521bf421586f010903271718d994d757..1da3dc290c39b9ce786b91601e58f10858e4b5e8 100644 (file)
@@ -917,6 +917,100 @@ async function handleRefineCube(req, res, siteId, cubeId) {
     return respondJson(res, 200, { prompt: buildCompletePrompt(ctx) });
   }
 
+  // ===== AI 资产补全:基于站点信息分析并补充设备类型/数量/命名规则 =====
+  function buildCompleteAssetsPrompt(site, assets) {
+    const existing = (assets || []).filter(a => a && a.type && a.type.trim()).map(a => a.type.trim());
+    const existingText = existing.length ? existing.join("、") : "(无,用户尚未配置)";
+    return `你是一名资深的工业站点数字化方案架构师,擅长根据站点业务背景推导其监控运营所需的设备清单。
+请根据以下站点信息,推断该站点在监控与运营中可能需要的主要设备类型,并为每种设备给出建议数量与命名规则,用于补充「资产配置」列表(作为后续 Cube 大屏看板的设备维度)。
+
+站点信息:
+- 站点名称:${site.name || "未命名站点"}
+- 所属领域:${site.domain || "未指定"}
+- 站点基本情况描述:${site.description || "无"}
+- 补充要求:${site.supplement || "无"}
+- 用户已手工配置的设备类型(请勿重复、不要修改,仅补充新增):${existingText}
+
+要求:
+1. 先简要说明分析思路(思考过程):从站点业务推断典型设备类型、各设备的大致数量级、以及合适的命名规则(命名规则用 # 表示序号,如 CH-## 表示 CH-01/CH-02;纯前缀如 beng- 会自动补 -01/-02)。
+2. 思考过程结束后,另起一段,以「【资产配置】」开头,给出 JSON 数组(仅数组本身,不要多余文字与代码围栏),每个元素为 {"type":"设备类型","count":数量(整数,根据站点规模给合理预设),"rule":"命名规则"}。
+3. 只补充用户尚未配置的设备类型;命名规则尽量贴合设备习惯(如泵类用 beng-、冷水机组用 lsch-、换热器用 hx- 等)。
+
+请直接输出你的分析与【资产配置】JSON。`;
+  }
+
+  function extractAssets(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.type).map(x => ({
+      type: String(x.type).trim(),
+      count: Number(x.count) || 0,
+      rule: x.rule != null ? String(x.rule).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);
+    const fail = (message) => { emit({ task: "系统", status: "failed", message }); res.end(); };
+    if (genLocks.has(`complete-assets-${siteId}`)) return fail("AI 资产补全正在进行中");
+    genLocks.add(`complete-assets-${siteId}`);
+    try {
+      const site = hydrateSite(siteId);
+      if (!site) return fail("站点不存在");
+      const body = await parseBody(req).catch(() => ({}));
+      const ctx = {
+        name: (body.name ?? site.name) || "",
+        domain: (body.domain ?? site.domain) || "",
+        description: (body.description ?? site.description) || "",
+        supplement: (body.supplement ?? site.supplement) || "",
+        assets: body.assets || site.assets || [],
+      };
+      const prompt = buildCompleteAssetsPrompt(ctx, ctx.assets);
+      emit({ prompt });
+      emit({ log: `\n▶ 开始 AI 资产补全:基于站点信息与现有设备,分析并补充设备类型…\n` });
+      emit({ task: "AI 资产补全", status: "running" });
+      const onEvent = (o) => {
+        if (o && 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 });
+        }
+      };
+      const text = await runCodebuddy(prompt, `stationbuilder-complete-assets-${siteId}`, onEvent);
+      const items = extractAssets(text);
+      emit({ log: `\n✓ 已生成 ${items.length} 类设备配置\n` });
+      emit({ result: items });
+      emit({ task: "AI 资产补全", status: "done" });
+      res.end();
+    } catch (e) {
+      emit({ log: `✗ AI 资产补全失败:${e.message}\n` });
+      emit({ task: "系统", status: "failed", message: e.message });
+      res.end();
+    } finally {
+      genLocks.delete(`complete-assets-${siteId}`);
+    }
+  }
+
+  async function handleCompleteAssetsPrompt(req, res, siteId) {
+    const site = hydrateSite(siteId);
+    if (!site) return respondError(res, 404, "站点不存在");
+    const u = new URL(req.url, "http://localhost");
+    const ctx = {
+      name: u.searchParams.get("name") ?? site.name ?? "",
+      domain: u.searchParams.get("domain") ?? site.domain ?? "",
+      description: u.searchParams.get("description") ?? site.description ?? "",
+      supplement: u.searchParams.get("supplement") ?? site.supplement ?? "",
+      assets: site.assets || [],
+    };
+    return respondJson(res, 200, { prompt: buildCompleteAssetsPrompt(ctx, ctx.assets) });
+  }
+
 async function handleGenerateAll(req, res, siteId) {
   res.writeHead(200, { "Content-Type": "application/x-ndjson; charset=utf-8", "Cache-Control": "no-cache" });
   if (genLocks.has(`site-${siteId}`)) {
@@ -1134,6 +1228,8 @@ async function route(req, res) {
   const mCubePrompt = path.match(/^\/api\/sites\/(\d+)\/cubes\/(\d+)\/prompt$/);
   const mSiteComplete = path.match(/^\/api\/sites\/(\d+)\/complete$/);
   const mSiteCompletePrompt = path.match(/^\/api\/sites\/(\d+)\/complete-prompt$/);
+  const mSiteCompleteAssets = path.match(/^\/api\/sites\/(\d+)\/complete-assets$/);
+  const mSiteCompleteAssetsPrompt = path.match(/^\/api\/sites\/(\d+)\/complete-assets-prompt$/);
   const mCubePromptNew = path.match(/^\/api\/sites\/(\d+)\/cubes\/prompt$/);
   const mSiteMenu = path.match(/^\/api\/sites\/(\d+)\/menu$/);
   const mSiteFiles = path.match(/^\/api\/sites\/(\d+)\/files$/);
@@ -1277,6 +1373,8 @@ async function route(req, res) {
     }
     if (mSiteComplete) return handleCompleteSite(req, res, Number(mSiteComplete[1]));
     if (mSiteCompletePrompt) return handleCompletePrompt(req, res, Number(mSiteCompletePrompt[1]));
+    if (mSiteCompleteAssets) return handleCompleteAssets(req, res, Number(mSiteCompleteAssets[1]));
+    if (mSiteCompleteAssetsPrompt) return handleCompleteAssetsPrompt(req, res, Number(mSiteCompleteAssetsPrompt[1]));
     if (mSiteCubes) {
       const siteId = Number(mSiteCubes[1]);
       if (req.method === "GET") return respondJson(res, 200, db.prepare("SELECT c.*, c.prompt, gf.filename as gen_filename FROM cubes c LEFT JOIN gen_files gf ON gf.id=c.gen_file_id WHERE c.site_id=? ORDER BY c.sort_order,c.id").all(siteId));
index f3db96075dafcb4ae98e0c014353ba5bafd6882b..a5234979ee023e5b30f8d1993882ba1879cc98ff 100644 (file)
     <tr><td>KPI(设备类型/实例/层级)</td><td>资产概览</td><td>本地计算</td><td>随表格输入实时变化</td></tr>
     <tr><td>设备资产表格(类型/数量/命名规则/操作)</td><td>配置设备类型与数量</td><td>PUT /api/db/sites</td><td>行内编辑即保存;可增删行</td></tr>
     <tr><td>资产层级预览</td><td>预览「站点→类型→实例」三级结构</td><td>本地计算</td><td>按命名规则展开实例:规则含 <code>#</code> 时替换序号(如 <code>CH-##</code>→CH-01);规则为纯前缀时自动补 <code>-01/-02…</code> 序号(如 <code>beng-</code>→beng-01、<code>guolu</code>→guolu-01) <span class="todo">3 层结构为展示占位,资产层级建模尚未实现</span></td></tr>
+    <tr><td>AI 智能补全</td><td>根据站点名称 / 领域 / 描述 / 补充要求,分析并补充设备类型、数量与命名规则到资产配置列表(位于用户已填项下方,不改动已填项)</td><td>POST /api/db/sites/:id/complete-assets(流式 NDJSON)</td><td>「开始补全」后控制台流式输出思考过程;结果追加到资产配置列表(按模型给出的数量预设与命名规则);资产层级预览随之重算;「查看提示词」可看完整提示词 <span class="todo">设备补充质量将按反馈持续优化</span></td></tr>
   </table>
 
   <h3>5.5 功能与菜单(functions)</h3>
     <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>
+    <li>2026-08-18 扩展「AI 智能补全」至 Asset 资产设置页:右侧新增与基本属性同款的「填写建议 + AI 智能补全」栏;点击「开始补全」后基于站点名称 / 领域 / 描述 / 补充要求分析并补充设备类型、数量预设与命名规则,追加到用户已填项下方(不改动已有项),资产层级预览随之重算(后端 <code>POST /api/db/sites/:id/complete-assets</code> 流式、<code>GET .../complete-assets-prompt</code> 取提示词)。</li>
   </ul>
 
   <ul>