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(); };
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">
))}
</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>;
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(); } };
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) => {
}
});
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);
});
}
const genLocks = new Set();
+const genChildren = new Map();
async function handleGenerateCube(req, res, siteId, cubeId) {
if (genLocks.has(`cube-${cubeId}`)) return respondError(res, 429, "该页面正在生成中");
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 (?,?,?,?,?,?)");
}
}
+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, "设备模板不存在");
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+)$/);
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]));