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);
{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}/>}
</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);
};
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}){
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}`)) {
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$/);
}
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));