]> acesimba.cloud Git - StationBuilder.git/commitdiff
feat(device-template-ai): AI采集点补全改为抽屉入口、移除·采集点后缀、支持停止
authorroot <root@localhost.localdomain>
Thu, 10 Sep 2026 05:47:39 +0000 (13:47 +0800)
committerroot <root@localhost.localdomain>
Thu, 10 Sep 2026 05:47:39 +0000 (13:47 +0800)
app/page.tsx
db-sidecar.mjs

index 197316c38281d6ee13b65b718f6db7c2406f1b91..9601613a58ba6eb8c93917a1e49f5bcc004173df 100644 (file)
@@ -1591,6 +1591,8 @@ function DeviceTemplateCard({dev,notify,onChange,onBack,onViewCpPrompt}:{dev:any
   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);
   const [cps,setCps]=useState<any[]>((dev.collection_points||[]).map((c:any)=>({...c,key:c.id,checked:false})));
+  const [aiOpen,setAiOpen]=useState(false);
+  const abortRef=useRef<AbortController|null>(null);
   const {confirm, el:confirmEl}=useConfirm();
   const onPick=async(f:File)=>{ setImgPreview(URL.createObjectURL(f)); const b=await fileToBase64(f); setImgB64({filename:b.filename,mime:b.mime,data:b.data}); };
   const save=async()=>{ const body:any={name,description}; if(imgB64) body.image=imgB64; await fetchDb(`/device-templates/${dev.id}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}); notify("设备已保存"); onChange(); };
@@ -1606,16 +1608,27 @@ function DeviceTemplateCard({dev,notify,onChange,onBack,onViewCpPrompt}:{dev:any
   const runCpComplete=async()=>{
     if(cpAi.completing) return;
     cpAi.setCompleting(true); cpAi.setLog("");
-    const wd=setTimeout(()=>{ cpAi.setCompleting(false); notify("AI 采集点补全超时(>10 分钟),请重试"); },600000);
+    if(abortRef.current){ abortRef.current.abort(); }
+    const controller=new AbortController();
+    abortRef.current=controller;
+    const wd=setTimeout(()=>{ controller.abort(); cpAi.setCompleting(false); notify("AI 采集点补全超时(>10 分钟),请重试"); },600000);
     try{
-      const res=await fetch(`/api/db/device-templates/${dev.id}/complete-collection-points`,{method:"POST"});
+      const res=await fetch(`/api/db/device-templates/${dev.id}/complete-collection-points`,{method:"POST",signal:controller.signal});
       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); }
+    }catch(e:any){
+      if(e?.name==="AbortError" || controller.signal.aborted){ cpAi.setLog(prev=>prev+"\n⏹ 已停止\n"); notify("AI 采集点补全已停止"); }
+      else { notify("AI 采集点补全失败:"+(e?.message||e)); }
+    }
+    finally{ clearTimeout(wd); cpAi.setCompleting(false); abortRef.current=null; }
+  };
+  const stopCpComplete=async()=>{
+    if(!cpAi.completing || !abortRef.current) return;
+    try{ await fetchDb(`/device-templates/${dev.id}/complete-collection-points/stop`,{method:"POST"}); }catch{}
+    abortRef.current.abort();
   };
   const viewCpCompletePrompt=()=>onViewCpPrompt(dev.id);
   return <div className="device-card">
@@ -1648,14 +1661,22 @@ function DeviceTemplateCard({dev,notify,onChange,onBack,onViewCpPrompt}:{dev:any
         ))}
       </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 className="field-assist" style={{marginTop:16}}>
+      <Button secondary onClick={()=>setAiOpen(true)}><RobotOutlined/> AI 智能补全</Button>
+      <span className="muted">基于设备名称 / 领域 / 说明,AI 推荐合适的采集点并追加</span>
+    </div>
+    <Drawer title="AI 智能补全" width={440} open={aiOpen} onClose={()=>setAiOpen(false)}>
+      <div className="ai-assist-drawer">
+        <div className="ai-assist-intro"><RobotOutlined/><div><h3>补充采集点</h3><p>AI 将根据设备名称、所属领域和说明生成合适的采集点,并以追加模式写入该设备模板。</p></div></div>
+        <p className="hint">为当前设备模板补充一套合适的采集点(追加模式,不覆盖已手动录入的采集点)。</p>
+        <div className="drawer-actions">
+          <Button disabled={cpAi.completing} onClick={runCpComplete}>{cpAi.completing?"采集中…":"开始补全"}</Button>
+          {cpAi.completing&&<Button secondary onClick={stopCpComplete}>停止</Button>}
+          <Button secondary 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>}
       </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>
+    </Drawer>
     <div style={{marginTop:8}}><Button onClick={save}>保存设备</Button></div>
     {confirmEl}
   </div>;
index 147b651dcb82cf54c5f9dfe2d23432557bfeabf3..76e27a3ca921c73f3f56dbcebd6d9ce46af2e300 100644 (file)
@@ -667,7 +667,7 @@ function aiRelease() {
   if (aiSem.waiters.length) { aiSem.active++; aiSem.waiters.shift()(); }
 }
 
-async function runCodebuddy(prompt, sessionId, onEvent) {
+async function runCodebuddy(prompt, sessionId, onEvent, jobKey = null) {
   await aiAcquire();
   let released = false;
   const release = () => { if (!released) { released = true; aiRelease(); } };
@@ -689,11 +689,13 @@ async function runCodebuddy(prompt, sessionId, onEvent) {
       release();
       return reject(e);
     }
+    if (jobKey) genChildren.set(jobKey, child);
     let buf = "";
     let fullText = "";
+    const cleanup = () => { clearTimeout(timer); release(); if (jobKey) genChildren.delete(jobKey); };
     const timer = setTimeout(() => {
       child.kill("SIGKILL");
-      release();
+      cleanup();
       reject(new Error("codebuddy 生成超时(>480s)"));
     }, 480000);
     const push = (line) => {
@@ -715,11 +717,10 @@ async function runCodebuddy(prompt, sessionId, onEvent) {
       }
     });
     child.stderr.on("data", c => push(c.toString("utf-8")));
-    child.on("error", (e) => { release(); reject(e); });
+    child.on("error", (e) => { cleanup(); reject(e); });
     child.on("close", code => {
-      clearTimeout(timer);
+      cleanup();
       if (buf.trim()) push(buf);
-      release();
       if (code && code !== 0) return reject(new Error("codebuddy exit " + code));
       resolve(fullText);
     });
@@ -1078,6 +1079,7 @@ function safeFilename(name) {
 }
 
 const genLocks = new Set();
+const genChildren = new Map();
 
 async function handleGenerateCube(req, res, siteId, cubeId) {
   if (genLocks.has(`cube-${cubeId}`)) return respondError(res, 429, "该页面正在生成中");
@@ -1960,7 +1962,7 @@ async function resolveDeviceTemplate(name, domainId, tmplId) {
     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 text = await runCodebuddy(prompt, `stationbuilder-dt-cp-${devId}`, onEvent || (() => {}), lockKey);
       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 (?,?,?,?,?,?)");
@@ -2277,6 +2279,16 @@ async function handleDeviceCompleteCollectionPoints(req, res, devId) {
   }
 }
 
+async function handleDeviceCompleteCollectionPointsStop(res, devId) {
+  const key = `dt-cp-${devId}`;
+  const child = genChildren.get(key);
+  if (child && !child.killed) {
+    child.kill("SIGKILL");
+    genChildren.delete(key);
+  }
+  respondJson(res, 200, { ok: true });
+}
+
 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, "设备模板不存在");
@@ -2476,6 +2488,7 @@ async function route(req, res) {
   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 mDevCpCompleteStop = path.match(/^\/api\/device-templates\/(\d+)\/complete-collection-points\/stop$/);
   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+)$/);
@@ -2767,6 +2780,7 @@ async function route(req, res) {
       if (req.method === "DELETE") return handleCollectionPointDelete(res, Number(mCp[1]));
     }
     if (mDevCpComplete) { if (req.method === "POST") return handleDeviceCompleteCollectionPoints(req, res, Number(mDevCpComplete[1])); }
+    if (mDevCpCompleteStop) { if (req.method === "POST") return handleDeviceCompleteCollectionPointsStop(res, Number(mDevCpCompleteStop[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]));