From: wang.yu Date: Sun, 16 Aug 2026 13:56:51 +0000 (+0800) Subject: feat: AI 助手会话隔离与 StationBuilder 概览接入;fix: 点击站点卡片误建空站点 X-Git-Url: http://acesimba.cloud/gitweb/?a=commitdiff_plain;h=54810273da0c3ec759c2e4af8f6c91240854f2f0;p=StationBuilder.git feat: AI 助手会话隔离与 StationBuilder 概览接入;fix: 点击站点卡片误建空站点 ## AI 助手:会话隔离 + 基于注入上下文回答 StationBuilder 自身信息 - ai-sidecar: 每次会话使用独立 sessionId,配合 --no-session-persistence 与 --tools "", 实现会话隔离(不跨会话、不查其他会话、不触碰服务器/文件/命令); 守卫明确仅允许基于前端注入的只读上下文作答 StationBuilder 配置与已生成内容。 - 前端在 /api/chat 请求中注入 overview(站点总数 + 每站点的领域/状态/设备数/Cube 数/ 已生成数)与当前站点的已生成文件名;db-sidecar siteList 增加 gen_count 列。 - 会话上下文前端持久化到 sessionStorage,刷新不丢失。 ## fix: 点击站点卡片误建空站点(未命名站点) 根因:go() 在卡片点击后读取过期的 siteId 闭包(首次打开仍为 null),误判"未选站点" 而自动 createSite,生成多余空站点,导致首次打开为空、二次才正常。 - go(target, sid?) 显式接收站点 id;卡片/表格点击传 row.id,不再依赖闭包。 - "新建站点" 改用 go("basic","new") 哨兵,始终新建空白站点。 - Projects 组件 go 属性类型同步放宽。 验证:已通过 tsc 类型检查与实机测试(站点总数/已生成内容可答,服务器 IP/编程/无关请求仍拒; 多次点击站点不再产生未命名站点)。 --- diff --git a/ai-sidecar.mjs b/ai-sidecar.mjs index bd5d636..cadf41d 100644 --- a/ai-sidecar.mjs +++ b/ai-sidecar.mjs @@ -8,7 +8,6 @@ import { spawn } from "node:child_process"; const CODEBUDDY = "/root/.local/bin/codebuddy"; const CWD = "/home/StationBuilder"; const PORT = 37822; -const CHAT_SESSION = "stationbuilderchat"; function resolveModelId(display) { const map = { @@ -20,6 +19,68 @@ function resolveModelId(display) { 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) => { @@ -45,6 +106,8 @@ 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", @@ -55,8 +118,9 @@ const server = http.createServer((req, res) => { 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) { diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index ce2e58e..81e3e36 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -30,7 +30,7 @@ export async function POST(req: NextRequest) { const upstream = await fetch(SIDECAR, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message, model: body.model || "Hy3" }), + body: JSON.stringify({ message, model: body.model || "Hy3", sessionId: body.sessionId, history: body.history, site: body.site, overview: body.overview }), }); if (!upstream.ok || !upstream.body) { const t = await upstream.text().catch(() => ""); diff --git a/app/page.tsx b/app/page.tsx index e91b0af..b1fa526 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -33,7 +33,7 @@ const MODELS = [ ]; // ---- 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; }; @@ -110,6 +110,14 @@ export default function Home(){ 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(null); const dragState=useRef<{active:boolean;startX:number;startY:number;origX:number;origY:number;moved:boolean}|null>(null); @@ -141,7 +149,8 @@ export default function Home(){ } }; 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(null); @@ -221,17 +230,18 @@ export default function Home(){ }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)); } @@ -294,7 +304,14 @@ export default function Home(){ 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}); @@ -334,7 +351,7 @@ export default function Home(){ )} -
iStation StationBuilder / {meta[page].label}

{meta[page].label}

{meta[page].subtitle}

{!["projects","domains","domainDetail"].includes(page)&&}{page==="projects"&&}{page==="domains"&&}{page==="domainDetail"&&}
{(["basic","assets","functions","generate"] as PageKey[]).includes(page)&&}
+
iStation StationBuilder / {meta[page].label}

{meta[page].label}

{meta[page].subtitle}

{!["projects","domains","domainDetail"].includes(page)&&}{page==="projects"&&}{page==="domains"&&}{page==="domainDetail"&&}
{(["basic","assets","functions","generate"] as PageKey[]).includes(page)&&}
{page==="projects"&&} {page==="domains"&&} {page==="domainDetail"&&} @@ -376,7 +393,7 @@ export default function Home(){ function Login({onLogin}:{onLogin:()=>void}){return
⬡
iStationStationBuilder
⬡

iStation StationBuilder

面向 iStation 团队的 AI 站点与领域方案生成工具

仅限 iStation 团队内部成员访问
© 2026 Advantech · iStation Team · v0.3
} function Steps({page,go}:{page:PageKey;go:(p:PageKey)=>void}){const list:PageKey[]=["basic","assets","functions","generate"];const current=list.indexOf(page);return
{list.map((item,index)=>)}
} -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;notify:(m:string)=>void;onDelete:(ids:number[])=>Promise}){ +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;notify:(m:string)=>void;onDelete:(ids:number[])=>Promise}){ const counts = useMemo(()=>{ const all = sites.length; const draft = sites.filter(s=>s.status==="draft").length; @@ -391,7 +408,7 @@ function Projects({view,setView,sites,loading,go,loadSite,notify,onDelete}:{view 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
加载中…
; - return <>
{[[String(counts.all),"全部项目"],[String(counts.draft),"草稿"],[String(counts.running),"生成中"],[String(counts.done),"生成完成"],[String(counts.partial),"部分失败"]].map(x=>
{x[0]}{x[1]}
)}
{selected.size>0 && }
{view==="table"?{sites.map(row=>)}
站点名称 ↕所属领域设备数量MU 页面Cube 页面状态最近修改操作
toggle(row.id)}/>{row.domain}{row.device_count}{row.mu_count}{row.cube_count}{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}{row.updated_at}
:
{sites.map(row=>
{await loadSite(row.id); go(row.status==="generated"?"result":"basic");}}>
⬡{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}

{row.name || "未命名站点"}

{row.domain}

{row.device_count} 设备{row.mu_count} MU{row.cube_count} Cube
更新于 {row.updated_at}
)}
}
共 {sites.length} 条
+ return <>
{[[String(counts.all),"全部项目"],[String(counts.draft),"草稿"],[String(counts.running),"生成中"],[String(counts.done),"生成完成"],[String(counts.partial),"部分失败"]].map(x=>
{x[0]}{x[1]}
)}
{selected.size>0 && }
{view==="table"?{sites.map(row=>)}
站点名称 ↕所属领域设备数量MU 页面Cube 页面状态最近修改操作
toggle(row.id)}/>{row.domain}{row.device_count}{row.mu_count}{row.cube_count}{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}{row.updated_at}
:
{sites.map(row=>
go(row.status==="generated"?"result":"basic", row.id)}>
⬡{row.status==="generated"?"生成完成":row.status==="partial"?"部分失败":row.status==="running"?"生成中":"草稿"}

{row.name || "未命名站点"}

{row.domain}

{row.device_count} 设备{row.mu_count} MU{row.cube_count} Cube
更新于 {row.updated_at}
)}
}
共 {sites.length} 条
} function Basic({site,domains,onChange,onAddDomain,onUpload,saving}:{site:SiteDetail|null;domains:string[];onChange:(p:Partial)=>void;onAddDomain:(n:string)=>void;onUpload:(f:File)=>void;saving:boolean}){ diff --git a/db-sidecar.mjs b/db-sidecar.mjs index 3b12d2b..72e53da 100644 --- a/db-sidecar.mjs +++ b/db-sidecar.mjs @@ -265,6 +265,7 @@ function siteList() { SELECT s.*, (SELECT COALESCE(SUM(count),0) FROM assets WHERE site_id = s.id) as device_count, (SELECT COUNT(*) FROM cubes WHERE site_id = s.id) as cube_count, + (SELECT COUNT(*) FROM cubes WHERE site_id = s.id AND gen_file_id IS NOT NULL) as gen_count, (SELECT COUNT(*) FROM mu_selections WHERE site_id = s.id AND selected = 1) as mu_count FROM sites s ORDER BY s.updated_at DESC `).all();