From 5850c8c36ce5aae5920d8cfe6d7cbfd76d7f54f5 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 10 Sep 2026 13:47:39 +0800 Subject: [PATCH] =?utf8?q?feat(device-template-ai):=20AI=E9=87=87=E9=9B=86?= =?utf8?q?=E7=82=B9=E8=A1=A5=E5=85=A8=E6=94=B9=E4=B8=BA=E6=8A=BD=E5=B1=89?= =?utf8?q?=E5=85=A5=E5=8F=A3=E3=80=81=E7=A7=BB=E9=99=A4=C2=B7=E9=87=87?= =?utf8?q?=E9=9B=86=E7=82=B9=E5=90=8E=E7=BC=80=E3=80=81=E6=94=AF=E6=8C=81?= =?utf8?q?=E5=81=9C=E6=AD=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- app/page.tsx | 43 ++++++++++++++++++++++++++++++++----------- db-sidecar.mjs | 26 ++++++++++++++++++++------ 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index 197316c..9601613 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1591,6 +1591,8 @@ function DeviceTemplateCard({dev,notify,onChange,onBack,onViewCpPrompt}:{dev:any 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); const [cps,setCps]=useState((dev.collection_points||[]).map((c:any)=>({...c,key:c.id,checked:false}))); + const [aiOpen,setAiOpen]=useState(false); + const abortRef=useRef(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
@@ -1648,14 +1661,22 @@ function DeviceTemplateCard({dev,notify,onChange,onBack,onViewCpPrompt}:{dev:any ))}
- 基于设备名称 / 领域 / 说明,AI 推荐合适的采集点并追加}> -

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

-
- - +
+ + 基于设备名称 / 领域 / 说明,AI 推荐合适的采集点并追加 +
+ setAiOpen(false)}> +
+

补充采集点

AI 将根据设备名称、所属领域和说明生成合适的采集点,并以追加模式写入该设备模板。

+

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

+
+ + {cpAi.completing&&} + +
+ {(cpAi.completing||cpAi.log)&&
AI 智能补全
{cpAi.log||"(等待 AI 分析…)"}
}
- {(cpAi.completing||cpAi.log)&&
AI 智能补全 · 采集点
{cpAi.log||"(等待 AI 分析…)"}
} - +
{confirmEl}
; diff --git a/db-sidecar.mjs b/db-sidecar.mjs index 147b651..76e27a3 100644 --- a/db-sidecar.mjs +++ b/db-sidecar.mjs @@ -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])); -- 2.43.0