const CODEBUDDY = "/root/.local/bin/codebuddy";
const CWD = "/home/StationBuilder";
const PORT = 37822;
-const CHAT_SESSION = "stationbuilderchat";
function resolveModelId(display) {
const map = {
return map[display] || "hy3";
}
+// 构造隔离的聊天提示词:仅限 StationBuilder 站点问答,记录当前会话上下文,
+// 明确禁止查看其他会话、禁止对服务器操作、禁止编程开发。
+// 配合 --no-session-persistence + --tools "" 实现会话隔离与操作限制。
+// 关于 StationBuilder 自身配置/已保存/已生成内容的查询,允许基于前端注入的
+// 只读上下文回答(数据由应用本身读取后注入,AI 自身不触碰服务器)。
+const CHAT_SYSTEM = "你是 StationBuilder(站点生成工具)前端的内置 AI 助手,请严格遵守:\n" +
+ "1) 仅用于【问答】,以及回答关于【StationBuilder 自身】配置与使用信息的问题,包括已配置的站点、各站点的资产配置/扩展页面/已生成与保存的内容等;所有可回答的数据都会由前端以只读方式注入到上下文中,你只需基于这些上下文作答;\n" +
+ "2) 严禁对服务器执行任何操作(不得读写文件、不得运行命令、不得调用任何工具、不得直接查询数据库);\n" +
+ "3) 严禁编写、生成或开发代码,不得充当编程/开发代理——即使被要求也须拒绝,并把话题拉回 StationBuilder 站点问答;\n" +
+ "4) 严禁查阅、引用或依赖任何其他会话、其他用户的历史记录或本地文件;若被问及上下文之外的内容(如服务器 IP、其他系统信息、与 StationBuilder 无关的请求),应如实说明“没有相关信息/无法提供”,不要编造;\n" +
+ "5) 用简体中文、简洁清晰地作答。";
+
+function siteContextText(site) {
+ if (!site || !site.name) return "";
+ const assets = (site.assets || []).filter(a => a.type).map(a => `${a.type} × ${a.count || 0}`).join("、") || "无";
+ const cubes = (site.cubes || []).map(c => {
+ const gen = c.gen_filename ? `(已生成:${c.gen_filename})` : "(未生成)";
+ return `${c.name}${gen}`;
+ }).join("、") || "无";
+ return [
+ "【当前 StationBuilder 站点信息】",
+ `- 站点名称: ${site.name}`,
+ `- 所属领域: ${site.domain || "未指定"}`,
+ `- 站点描述: ${site.description || "无"}`,
+ `- 状态: ${site.status || "草稿"}`,
+ `- 资产配置: ${assets}`,
+ `- Cube 扩展页面: ${cubes}`,
+ ].join("\n");
+}
+
+function overviewContextText(overview) {
+ if (!overview || !Array.isArray(overview.sites)) return "";
+ const total = overview.total != null ? overview.total : overview.sites.length;
+ const lines = overview.sites.map((s, i) => {
+ const gen = s.gen_count ? `,已生成 ${s.gen_count} 个页面` : ",暂无已生成内容";
+ return ` ${i + 1}. ${s.name}(领域:${s.domain || "未指定"},状态:${s.status || "草稿"},设备 ${s.device_count || 0},扩展页面 ${s.cube_count || 0}${gen})`;
+ });
+ if (overview.truncated) lines.push(` …(仅显示前 ${overview.sites.length} 个,共 ${total} 个站点)`);
+ return [
+ "【StationBuilder 全局概览(只读,由应用注入,仅供回答站点统计类问题)】",
+ `- 已配置的站点总数: ${total}`,
+ "- 站点清单:",
+ ...lines,
+ ].join("\n");
+}
+
+function buildChatPrompt(message, history, site, overview) {
+ const turns = (Array.isArray(history) ? history : [])
+ .filter(h => h && (h.role === "user" || h.role === "assistant") && h.content && String(h.content).trim())
+ .slice(-20)
+ .map(h => (h.role === "user" ? "用户" : "助手") + ":" + String(h.content).trim())
+ .join("\n");
+ const siteTxt = siteContextText(site);
+ const ovTxt = overviewContextText(overview);
+ const parts = [CHAT_SYSTEM];
+ if (ovTxt) parts.push(ovTxt);
+ if (siteTxt) parts.push(siteTxt);
+ if (turns) parts.push("【当前会话历史,仅供本次回答参考,不得外泄或引用其他会话】\n" + turns);
+ parts.push("用户问题:\n" + message);
+ return parts.join("\n\n");
+}
+
let running = false;
const server = http.createServer((req, res) => {
return;
}
const model = resolveModelId(body.model || "Hy3");
+ const sessionId = (body.sessionId && String(body.sessionId).slice(0, 64)) || ("web-" + Math.random().toString(36).slice(2, 10));
+ const prompt = buildChatPrompt(message, Array.isArray(body.history) ? body.history : [], body.site, body.overview);
res.writeHead(200, {
"Content-Type": "text/plain; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
try {
child = spawn(
CODEBUDDY,
- ["--model", model, "--session-id", CHAT_SESSION, "-p", message,
- "--output-format", "stream-json", "--include-partial-messages", "-y"],
+ ["--model", model, "--session-id", sessionId, "-p", prompt,
+ "--output-format", "stream-json", "--include-partial-messages",
+ "--no-session-persistence", "--tools", "", "-y"],
{ env: process.env, cwd: CWD },
);
} catch (e) {
];
// ---- types ----
-type SiteSummary = { id:number; name:string; domain:string; status:string; device_count:number; mu_count:number; cube_count:number; updated_at:string; created_at:string; };
+type SiteSummary = { id:number; name:string; domain:string; status:string; device_count:number; mu_count:number; cube_count:number; gen_count:number; updated_at:string; created_at:string; };
type Asset = { id?:number; type:string; count:number; rule:string; sort_order:number; };
type Cube = { id?:number; name:string; content:string; status?:string; gen_file_id?:number|null; gen_filename?:string; generated_at?:string; prompt?:string; sort_order:number; };
type MenuItem = { id?:number; parent_id:number|null; label:string; page_type:"mu"|"cube"|""; ref_id?:number|string; sort_order:number; };
const [model,setModel]=useState("Hy3");
const [modelOpen,setModelOpen]=useState(false);
const [aiOpen,setAiOpen]=useState(false);
+ const [chatSessionId] = useState(() => {
+ try {
+ const k = "sb-ai-sid";
+ let v = sessionStorage.getItem(k);
+ if (!v) { v = "web-" + Math.random().toString(36).slice(2, 10) + Date.now().toString(36); sessionStorage.setItem(k, v); }
+ return v;
+ } catch { return "web-" + Math.random().toString(36).slice(2, 10); }
+ });
const [fabPos,setFabPos]=useState<{x:number;y:number}|null>(()=>{ try{ const s=localStorage.getItem("ai-fab-pos"); return s?JSON.parse(s):null; }catch{ return null; } });
const fabRef=useRef<HTMLButtonElement|null>(null);
const dragState=useRef<{active:boolean;startX:number;startY:number;origX:number;origY:number;moved:boolean}|null>(null);
}
};
const resetFab=()=>{ setFabPos(null); lastPosRef.current=null; try{ localStorage.removeItem("ai-fab-pos"); }catch{} };
- const [aiMessages,setAiMessages]=useState<{role:"user"|"assistant";content:string}[]>([]);
+ const [aiMessages,setAiMessages]=useState<{role:"user"|"assistant";content:string}[]>(()=>{ try{ const s=sessionStorage.getItem("sb-ai-msgs"); return s?JSON.parse(s):[]; }catch{ return []; } });
+ useEffect(()=>{ try{ sessionStorage.setItem("sb-ai-msgs", JSON.stringify(aiMessages.filter(m=>m.content&&m.content.trim()))); }catch{} },[aiMessages]);
const [aiDraft,setAiDraft]=useState("");
const [aiBusy,setAiBusy]=useState(false);
const aiEndRef=useRef<HTMLDivElement|null>(null);
}catch(e:any){ notify("删除失败: "+e.message); }
};
- const go=async(target:PageKey)=>{
+ const go=async(target:PageKey, sid?:number|"new")=>{
try{
- if(["basic","assets","functions","generate","result"].includes(target) && !siteId){
+ // 显式要求新建站点:始终创建空白站点,避免复用/误建
+ if(sid==="new"){ await createSite(); setPage(target); window.history.replaceState({}, "", "?page="+target); return; }
+ const curId = sid ?? siteId;
+ if(["basic","assets","functions","generate","result"].includes(target) && !curId){
await createSite();
+ } else if(curId && (!site || site.id!==curId)){
+ await loadSite(curId);
}
- if(target==="assets" && siteId && (!site || site.id!==siteId)) await loadSite(siteId);
- if(target==="functions" && siteId && (!site || site.id!==siteId)) await loadSite(siteId);
- if(target==="generate" && siteId && (!site || site.id!==siteId)) await loadSite(siteId);
- if(target==="result" && siteId && (!site || site.id!==siteId)) await loadSite(siteId);
setPage(target);
- window.history.replaceState({}, "", "?page="+target+(siteId?"&id="+siteId:""));
+ window.history.replaceState({}, "", "?page="+target+(curId?"&id="+curId:""));
}catch(e:any){
notify("操作失败: "+(e?.message||e));
}
setAiMessages(m=>[...m,{role:"user" as const,content:text},{role:"assistant" as const,content:""}]);
setAiDraft(""); setAiBusy(true);
try{
- const res=await fetch("/api/chat",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({message:text,model})});
+ const siteInfo = site ? { name: site.name, domain: site.domain, description: site.description, status: site.status, assets: (site.assets||[]).filter(a=>a.type).map(a=>({type:a.type, count:a.count})), cubes: (site.cubes||[]).map(c=>({name:c.name, gen_filename:c.gen_filename||null})) } : null;
+ // StationBuilder 全局概览(只读、由前端从已加载的 sites 列表构造并注入,AI 不触碰服务器)
+ const OVERVIEW_MAX = 40;
+ const overview = sites && sites.length
+ ? { total: sites.length, truncated: sites.length > OVERVIEW_MAX,
+ sites: sites.slice(0, OVERVIEW_MAX).map(s=>({ name:s.name, domain:s.domain, status:s.status, device_count:s.device_count, cube_count:s.cube_count, gen_count:s.gen_count, mu_count:s.mu_count })) }
+ : { total: 0, truncated: false, sites: [] };
+ const res=await fetch("/api/chat",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({message:text,model,sessionId:chatSessionId,history:aiMessages.map(m=>({role:m.role,content:m.content})).slice(-20),site:siteInfo,overview})});
if(!res.ok||!res.body){const t=await res.text().catch(()=>"");throw new Error(t||("HTTP "+res.status));}
const reader=res.body.getReader(); const dec=new TextDecoder(); let acc="";
for(;;){const {done,value}=await reader.read(); if(done)break; acc+=dec.decode(value,{stream:true});
</>
)}
</div></aside>
- <main className="workspace"><div className="crumb">iStation StationBuilder / <b>{meta[page].label}</b></div><div className="page-heading"><div><h1>{meta[page].label}</h1><p>{meta[page].subtitle}</p></div><div>{!["projects","domains","domainDetail"].includes(page)&&<Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>}{page==="projects"&&<Button onClick={()=>go("basic")}>+ 新建站点</Button>}{page==="domains"&&<Button onClick={()=>notify("已打开新增领域方案向导")}>+ 新增领域方案</Button>}{page==="domainDetail"&&<Button onClick={()=>{notify("已应用水务方案模板");go("basic")}}>使用此方案创建站点</Button>}</div></div>{(["basic","assets","functions","generate"] as PageKey[]).includes(page)&&<Steps page={page} go={go}/>}<div className="content">
+ <main className="workspace"><div className="crumb">iStation StationBuilder / <b>{meta[page].label}</b></div><div className="page-heading"><div><h1>{meta[page].label}</h1><p>{meta[page].subtitle}</p></div><div>{!["projects","domains","domainDetail"].includes(page)&&<Button secondary onClick={()=>notify("草稿已保存")}>保存草稿</Button>}{page==="projects"&&<Button onClick={()=>go("basic","new")}>+ 新建站点</Button>}{page==="domains"&&<Button onClick={()=>notify("已打开新增领域方案向导")}>+ 新增领域方案</Button>}{page==="domainDetail"&&<Button onClick={()=>{notify("已应用水务方案模板");go("basic")}}>使用此方案创建站点</Button>}</div></div>{(["basic","assets","functions","generate"] as PageKey[]).includes(page)&&<Steps page={page} go={go}/>}<div className="content">
{page==="projects"&&<Projects view={view} setView={setView} sites={sites} loading={loading} go={go} loadSite={loadSite} notify={notify} onDelete={deleteSites}/>}
{page==="domains"&&<DomainLibrary go={go} notify={notify}/>}
{page==="domainDetail"&&<DomainDetail go={go} notify={notify}/>}
function Login({onLogin}:{onLogin:()=>void}){return <div className="login-page"><div className="login-top"><div className="brand-mark">⬡</div><b>iStation</b><span>StationBuilder</span></div><div className="login-card"><div className="login-logo">⬡</div><h1>iStation StationBuilder</h1><p>面向 iStation 团队的 AI 站点与领域方案生成工具</p><button className="sso-button" onClick={onLogin}><span className="avatar">W</span><b>使用公司账号登录</b><i>→</i></button><small>仅限 iStation 团队内部成员访问</small></div><footer>© 2026 Advantech · iStation Team · v0.3</footer></div>}
function Steps({page,go}:{page:PageKey;go:(p:PageKey)=>void}){const list:PageKey[]=["basic","assets","functions","generate"];const current=list.indexOf(page);return <div className="steps">{list.map((item,index)=><button key={item} onClick={()=>index<=current&&go(item)} className={index===current?"active":index<current?"done":""}><i>{index<current?"✓":index+1}</i><span><small>STEP {index+1}</small>{meta[item].label}</span>{index<3&&<b>›</b>}</button>)}</div>}
-function Projects({view,setView,sites,loading,go,loadSite,notify,onDelete}:{view:"table"|"card";setView:(v:"table"|"card")=>void;sites:SiteSummary[];loading:boolean;go:(p:PageKey)=>void;loadSite:(id:number)=>Promise<void>;notify:(m:string)=>void;onDelete:(ids:number[])=>Promise<void>}){
+function Projects({view,setView,sites,loading,go,loadSite,notify,onDelete}:{view:"table"|"card";setView:(v:"table"|"card")=>void;sites:SiteSummary[];loading:boolean;go:(p:PageKey, sid?:number|"new")=>void;loadSite:(id:number)=>Promise<void>;notify:(m:string)=>void;onDelete:(ids:number[])=>Promise<void>}){
const counts = useMemo(()=>{
const all = sites.length;
const draft = sites.filter(s=>s.status==="draft").length;
const toggleAll = ()=> setSelected(allSelected ? new Set() : new Set(sites.map(s=>s.id)));
const batchDelete = async ()=>{ await onDelete([...selected]); setSelected(new Set()); };
if(loading) return <Panel><div className="empty-state">加载中…</div></Panel>;
- return <><div className="summary-strip">{[[String(counts.all),"全部项目"],[String(counts.draft),"草稿"],[String(counts.running),"生成中"],[String(counts.done),"生成完成"],[String(counts.partial),"部分失败"]].map(x=><div key={x[1]}><b>{x[0]}</b><span>{x[1]}</span></div>)}</div><Panel><div className="filterbar"><label>⌕ <input placeholder="搜索站点名称"/></label><select><option>全部领域</option></select><select><option>全部状态</option></select><Button>Search</Button><span/>{selected.size>0 && <Button secondary onClick={batchDelete}>批量删除 ({selected.size})</Button>}<div className="view-switch"><button className={view==="table"?"active":""} onClick={()=>setView("table")}>☷</button><button className={view==="card"?"active":""} onClick={()=>setView("card")}>▦</button></div><button className="icon-button" onClick={()=>notify("列表已刷新")}>↻</button></div>{view==="table"?<table className="data-table"><thead><tr><th><input type="checkbox" checked={allSelected} onChange={toggleAll}/></th><th>站点名称 ↕</th><th>所属领域</th><th>设备数量</th><th>MU 页面</th><th>Cube 页面</th><th>状态</th><th>最近修改</th><th>操作</th></tr></thead><tbody>{sites.map(row=><tr key={row.id}><td><input type="checkbox" checked={selected.has(row.id)} onChange={()=>toggle(row.id)}/></td><td><button className="link" onClick={async()=>{await loadSite(row.id); go(row.status==="generated"?"result":"basic");}}><span className="site-icon">⬡</span><b>{row.name || "未命名站点"}</b></button></td><td>{row.domain}</td><td>{row.device_count}</td><td>{row.mu_count}</td><td>{row.cube_count}</td><td><Status>{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}</Status></td><td>{row.updated_at}</td><td><button className="link danger" onClick={(e)=>{e.stopPropagation(); onDelete([row.id]);}}>删除</button></td></tr>)}</tbody></table>:<div className="project-grid">{sites.map(row=><article key={row.id} onClick={async()=>{await loadSite(row.id); go(row.status==="generated"?"result":"basic");}}><div><span className="site-icon big">⬡</span><Status>{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}</Status></div><h3>{row.name || "未命名站点"}</h3><button className="card-del" onClick={(e)=>{e.stopPropagation(); onDelete([row.id]);}}>删除</button><p>{row.domain}</p><section><span><b>{row.device_count}</b> 设备</span><span><b>{row.mu_count}</b> MU</span><span><b>{row.cube_count}</b> Cube</span></section><small>更新于 {row.updated_at}</small></article>)}</div>}<div className="pagination"><span>共 {sites.length} 条</span><button>‹</button><button className="active">1</button><button>›</button><select><option>20 / page</option></select></div></Panel></>
+ return <><div className="summary-strip">{[[String(counts.all),"全部项目"],[String(counts.draft),"草稿"],[String(counts.running),"生成中"],[String(counts.done),"生成完成"],[String(counts.partial),"部分失败"]].map(x=><div key={x[1]}><b>{x[0]}</b><span>{x[1]}</span></div>)}</div><Panel><div className="filterbar"><label>⌕ <input placeholder="搜索站点名称"/></label><select><option>全部领域</option></select><select><option>全部状态</option></select><Button>Search</Button><span/>{selected.size>0 && <Button secondary onClick={batchDelete}>批量删除 ({selected.size})</Button>}<div className="view-switch"><button className={view==="table"?"active":""} onClick={()=>setView("table")}>☷</button><button className={view==="card"?"active":""} onClick={()=>setView("card")}>▦</button></div><button className="icon-button" onClick={()=>notify("列表已刷新")}>↻</button></div>{view==="table"?<table className="data-table"><thead><tr><th><input type="checkbox" checked={allSelected} onChange={toggleAll}/></th><th>站点名称 ↕</th><th>所属领域</th><th>设备数量</th><th>MU 页面</th><th>Cube 页面</th><th>状态</th><th>最近修改</th><th>操作</th></tr></thead><tbody>{sites.map(row=><tr key={row.id}><td><input type="checkbox" checked={selected.has(row.id)} onChange={()=>toggle(row.id)}/></td><td><button className="link" onClick={()=>go(row.status==="generated"?"result":"basic", row.id)}><span className="site-icon">⬡</span><b>{row.name || "未命名站点"}</b></button></td><td>{row.domain}</td><td>{row.device_count}</td><td>{row.mu_count}</td><td>{row.cube_count}</td><td><Status>{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}</Status></td><td>{row.updated_at}</td><td><button className="link danger" onClick={(e)=>{e.stopPropagation(); onDelete([row.id]);}}>删除</button></td></tr>)}</tbody></table>:<div className="project-grid">{sites.map(row=><article key={row.id} onClick={()=>go(row.status==="generated"?"result":"basic", row.id)}><div><span className="site-icon big">⬡</span><Status>{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}</Status></div><h3>{row.name || "未命名站点"}</h3><button className="card-del" onClick={(e)=>{e.stopPropagation(); onDelete([row.id]);}}>删除</button><p>{row.domain}</p><section><span><b>{row.device_count}</b> 设备</span><span><b>{row.mu_count}</b> MU</span><span><b>{row.cube_count}</b> Cube</span></section><small>更新于 {row.updated_at}</small></article>)}</div>}<div className="pagination"><span>共 {sites.length} 条</span><button>‹</button><button className="active">1</button><button>›</button><select><option>20 / page</option></select></div></Panel></>
}
function Basic({site,domains,onChange,onAddDomain,onUpload,saving}:{site:SiteDetail|null;domains:string[];onChange:(p:Partial<SiteDetail>)=>void;onAddDomain:(n:string)=>void;onUpload:(f:File)=>void;saving:boolean}){