diff --git a/README.md b/README.md
index 4b7b0379..99caa4e4 100644
--- a/README.md
+++ b/README.md
@@ -433,6 +433,7 @@ node scripts/check-stream.mjs # 助手流组件渲染是否正确
node scripts/check-ui.mjs # 真浏览器端到端:URL 寻址、会话、运行详情
node scripts/check-canvas-layout.mjs # 排版与连线几何:线有没有叠、有没有穿过卡片
node scripts/check-canvas-fx.mjs # 运行态动效:协作矩阵、并行度、命中的出口、关掉动效
+node scripts/check-guards.mjs # 护栏:输入法回车、未知节点类型、页面级错误边界(不写库)
node scripts/e2e-check.mjs # 冒烟:打开画布 → 选模板 → 运行 → 观察高亮
```
diff --git a/backend/app/api/copilot.py b/backend/app/api/copilot.py
index dccd9545..41d7c3a0 100644
--- a/backend/app/api/copilot.py
+++ b/backend/app/api/copilot.py
@@ -541,12 +541,17 @@ def _parse_op_line(line: str) -> dict[str, Any] | None:
return obj
+_NODE_TYPES = frozenset(t.value for t in NodeType)
+
+
def _apply_op(nodes: dict[str, dict[str, Any]], edges: list[dict[str, Any]], op: dict[str, Any]) -> bool:
"""把一个操作应用到服务端维护的图状态。返回是否真的改了图。"""
kind = op.get("op")
if kind == "add_node":
node = op.get("node") or {}
- if not node.get("id") or not node.get("type"):
+ # 类型不认识的节点不进图、也不转给前端:前端按类型查节点定义,查不到
+ # 以前是整站白屏,连带没保存的编辑一起丢
+ if not node.get("id") or node.get("type") not in _NODE_TYPES:
return False
nodes[node["id"]] = {
"id": node["id"],
@@ -725,6 +730,7 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
async def event_stream():
explanation = ""
seen_ops: list[dict[str, Any]] = []
+ skipped_types: list[str] = []
# 建完要不要跑,由模型在 done.run 里表态
autorun = True
yield f"data: {json.dumps({'op': 'model', 'model': model_id}, ensure_ascii=False)}\n\n"
@@ -747,6 +753,10 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
autorun = op.get("run") is not False
seen_ops.append(op)
changed = _apply_op(nodes, edges, op)
+ if kind == "add_node" and not changed:
+ bad_type = (op.get("node") or {}).get("type")
+ if bad_type and bad_type not in _NODE_TYPES:
+ skipped_types.append(str(bad_type))
if changed or kind in ("plan", "done"):
yield f"data: {json.dumps(op, ensure_ascii=False)}\n\n"
except Exception as e: # noqa: BLE001
@@ -780,6 +790,12 @@ async def generate_stream(payload: GenerateIn, session: AsyncSession = Depends(g
GraphSpec.model_validate({"nodes": list(nodes.values()), "edges": edges})
)
issues = [i.model_dump() for i in validate_graph(spec).issues]
+ # 跳过的节点要说出来,不能安静地少一步
+ issues += [
+ {"level": "warning", "node_id": None, "edge_id": None,
+ "message": f"模型写了一个不存在的节点类型「{t}」,这一步已跳过"}
+ for t in dict.fromkeys(skipped_types)
+ ]
final = {
"op": "final",
"graph": spec.model_dump(mode="json"),
diff --git a/backend/tests/test_copilot_stream.py b/backend/tests/test_copilot_stream.py
index 79a43bf5..e31b77da 100644
--- a/backend/tests/test_copilot_stream.py
+++ b/backend/tests/test_copilot_stream.py
@@ -113,3 +113,17 @@ async def test_heartbeat_phase_follows_progress(monkeypatch):
assert beats, "add_node 之后的停顿也该有心跳"
assert beats[0]["phase"] == "building", "放过节点了,阶段不该还停在 planning"
+
+
+def test_unknown_node_types_never_reach_the_canvas():
+ """模型编一个不存在的节点类型:不进图、不转给前端。
+
+ 前端按类型查节点定义,查不到以前是整站白屏、连带没保存的编辑一起丢。
+ """
+ nodes: dict = {}
+ edges: list = []
+ ghost = {"op": "add_node", "node": {"id": "x", "type": "no_such_type", "config": {}}}
+ real = {"op": "add_node", "node": {"id": "a", "type": "llm", "config": {}}}
+ assert cp._apply_op(nodes, edges, ghost) is False # 生成流只转发 changed 的操作
+ assert cp._apply_op(nodes, edges, real) is True
+ assert set(nodes) == {"a"}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6ea70195..41a8a8f4 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,5 @@
import { useEffect } from 'react'
-import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
+import { NavLink, Navigate, Route, Routes, useLocation } from 'react-router-dom'
import { BookOpen, FlaskConical, History, MessageSquare, Settings, Wrench } from 'lucide-react'
import clsx from 'clsx'
import { api } from './api/client'
@@ -10,7 +10,7 @@ import { RunsPage } from './pages/RunsPage'
import { ToolsPage } from './pages/ToolsPage'
import { KnowledgePage } from './pages/KnowledgePage'
import { SettingsPage, applyTheme } from './pages/SettingsPage'
-import { Spinner } from './components/ui'
+import { ErrorBoundary, Spinner } from './components/ui'
const NAV = [
{ to: '/chat', label: '问数据', icon: MessageSquare },
@@ -23,6 +23,7 @@ const NAV = [
export default function App() {
const { loaded, refresh, refreshApprovals, approvals } = useCatalog()
+ const location = useLocation()
useEffect(() => {
void refresh()
@@ -79,6 +80,7 @@ export default function App() {
「没带 id」是真要单独处理的一种情况(落到上次那个、或列表第一个),
藏在一个问号里下次就没人记得它存在了。
导航高亮不用动——NavLink 默认前缀匹配,/chat/abc 照样点亮「问数据」。*/}
+
{/* 默认落到对话页:多数人要的是答案,画布留给要自己编排的人 */}
} />
@@ -97,6 +99,7 @@ export default function App() {
} />
} />
+
)
diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx
index a2917d17..3755e5be 100644
--- a/frontend/src/canvas/Inspector.tsx
+++ b/frontend/src/canvas/Inspector.tsx
@@ -30,6 +30,16 @@ export function Inspector() {
}
const def = NODE_DEFS[node.data.nodeType]
+ if (!def) {
+ // 这个界面版本不认识的类型:给出实情和唯一能做的事,而不是读 def.fields 崩掉整页
+ return (
+
+
不认识的节点类型「{node.data.nodeType}」
+
这个版本的界面编辑不了它,运行时后端也会拒绝这张图。
+
+
+ )
+ }
const config = node.data.config ?? {}
const setConfig = (key: string, value: any) =>
updateNode(node.id, { config: { ...config, [key]: value } })
diff --git a/frontend/src/canvas/TemplateText.tsx b/frontend/src/canvas/TemplateText.tsx
index d9692c16..f7545a8c 100644
--- a/frontend/src/canvas/TemplateText.tsx
+++ b/frontend/src/canvas/TemplateText.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
+import { isComposing } from '../components/ui'
import { useStudio } from '../store/studio'
import type { Variable } from '../types'
@@ -74,7 +75,7 @@ export function TemplateText({
// 弹层开着的时候这几个键有原生行为(换行、移光标、跳焦点),得拦下来
if (e.key === 'ArrowDown') { e.preventDefault(); setActive((i) => (i + 1) % matches.length) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive((i) => (i - 1 + matches.length) % matches.length) }
- else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insert(matches[active].path) }
+ else if ((e.key === 'Enter' || e.key === 'Tab') && !isComposing(e)) { e.preventDefault(); insert(matches[active].path) }
else if (e.key === 'Escape') {
// 只关弹层,别让它冒泡上去——外面 InspectorSheet 也在听 Escape
e.preventDefault()
diff --git a/frontend/src/canvas/nodeDefs.ts b/frontend/src/canvas/nodeDefs.ts
index b9a223d9..e1e7e66e 100644
--- a/frontend/src/canvas/nodeDefs.ts
+++ b/frontend/src/canvas/nodeDefs.ts
@@ -438,6 +438,9 @@ export const NODE_CATEGORIES = ['起止', '模型', '执行', '控制', '上下
/** branch / loop / human 的出口由配置决定,这里统一算出来。 */
export function sourceHandles(type: NodeType, config: Record): HandleDef[] {
const def = NODE_DEFS[type]
+ // 不认识的类型给一个普通出口,连线还能接上。以前这里直接读 def.sources,
+ // 一个未知类型就让画布连带整站白屏
+ if (!def) return [{ id: 'out', label: '' }]
if (def.sources) return def.sources
if (type === 'branch') {
const cases = (config.cases ?? []) as { key?: string; label?: string }[]
diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx
index 844b0c83..724eec21 100644
--- a/frontend/src/components/ui.tsx
+++ b/frontend/src/components/ui.tsx
@@ -1,9 +1,61 @@
-import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
+import { Component, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router-dom'
-import type { ReactNode } from 'react'
+import type { ErrorInfo, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react'
import { AlertCircle, CheckCircle2, Info, Loader2, X } from 'lucide-react'
import clsx from 'clsx'
+/**
+ * 输入法正在组字吗。组字期间的回车是"选词",不是"提交"——不判这一条,中文
+ * 用户每选一个词就把半句话发出去一次(问数据页就这么把半句问题送去建图、跑图)。
+ * Safari 在结束组字的那一下 keydown 上 isComposing 已经是 false,keyCode 还是
+ * 229,两个都得看。所有"回车即提交"的输入框都要过这一关。
+ */
+export function isComposing(e: ReactKeyboardEvent): boolean {
+ return e.nativeEvent.isComposing || e.keyCode === 229
+}
+
+/**
+ * 页面级错误边界。没有它,任何一个组件渲染时抛错,React 会卸掉整棵树——整站
+ * 白屏,导航栏也没了,用户只能刷新,没保存的编辑跟着丢。
+ *
+ * 包在路由外面:导航栏还在,换一页(resetKey 变了)就自动恢复。编辑中的图在
+ * store 里、不在组件树上,点「重试」重新渲染时它还在。
+ */
+export class ErrorBoundary extends Component<
+ { resetKey?: string; children: ReactNode }, { error: Error | null }
+> {
+ state: { error: Error | null } = { error: null }
+
+ static getDerivedStateFromError(error: Error) {
+ return { error }
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ console.error('页面渲染出错', error, info.componentStack)
+ }
+
+ componentDidUpdate(prev: { resetKey?: string }) {
+ if (prev.resetKey !== this.props.resetKey && this.state.error) this.setState({ error: null })
+ }
+
+ render() {
+ const { error } = this.state
+ if (!error) return this.props.children
+ return (
+
+
+
这一页出错了
+
{error.message}
+
没保存的编辑还在内存里:先点「重试」,恢复了就尽快保存。
+
+
+
+
+
+ )
+ }
+}
+
// -------------------------------------------------------------------------
// Toast
// -------------------------------------------------------------------------
diff --git a/frontend/src/pages/ChatPage.tsx b/frontend/src/pages/ChatPage.tsx
index c8013e05..9bf69213 100644
--- a/frontend/src/pages/ChatPage.tsx
+++ b/frontend/src/pages/ChatPage.tsx
@@ -8,9 +8,8 @@ import { decodeCopilot, decodeRun } from '../run/decode'
import { useCatalog } from '../store/catalog'
import { useChat, type ChatTurn } from '../store/chat'
import { lastVisited, useConversations } from '../store/conversations'
-import { useStudio } from '../store/studio'
import { ConversationList } from './ConversationList'
-import { useToast } from '../components/ui'
+import { isComposing, useToast } from '../components/ui'
/**
* 对话式入口。
@@ -37,7 +36,7 @@ export function ChatPage() {
const turns = useChat((s) => (currentId ? s.byConversation[currentId] : undefined))
const [draft, setDraft] = useState('')
const sources = useDataSources()
- const setGraph = useStudio((s) => s.setGraph)
+ const refreshCatalog = useCatalog((s) => s.refresh)
const toast = useToast()
// URL → store。这是 currentId 唯一的写入口,别处一律靠导航
@@ -124,12 +123,20 @@ export function ChatPage() {
}
/>
)}
- onOpenGraph={(graph) => {
- // 以前只把图放过去、让用户自己去点导航。有了路由就直接送过去——
- // "已放到画布,切到「编排」继续改"是在让人替系统完成一次跳转
- setGraph(graph)
- navigate('/studio')
- toast('已放到画布', 'ok')
+ onOpenGraph={async (graph, question) => {
+ // 建成一张新工作流,走它自己的地址。以前是 setGraph(graph):只换掉了画布
+ // 上的节点,store 里的 workflow 还是上一次打开的那张图——于是 ⌘S 把那张图
+ // 整张覆盖了(旧版本还在版本表里,界面上却没有入口找回);没打开过图时
+ // ⌘S 则什么都不做,也不说一声
+ try {
+ const name = `问数据:${(question ?? '').trim().slice(0, 24) || '未命名'}`
+ const created = await api.workflows.create({ name, graph })
+ void refreshCatalog()
+ navigate(`/studio/${created.id}`)
+ toast('已放到画布:新建了一个工作流,原来的图不受影响', 'ok')
+ } catch (e: any) {
+ toast(e?.message ?? '放到画布失败', 'error')
+ }
}}
footer={
@@ -146,7 +153,7 @@ export function ChatPage() {
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter 发送,Shift+Enter 换行——对话框的通用约定
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send() }
+ if (e.key === 'Enter' && !e.shiftKey && !isComposing(e)) { e.preventDefault(); void send() }
}}
/>
{busy ? (
diff --git a/frontend/src/pages/ConversationList.tsx b/frontend/src/pages/ConversationList.tsx
index d959ccea..f3dcb99d 100644
--- a/frontend/src/pages/ConversationList.tsx
+++ b/frontend/src/pages/ConversationList.tsx
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Check, MessageSquarePlus, PanelLeftClose, PanelLeftOpen, Pencil, Trash2, X } from 'lucide-react'
import clsx from 'clsx'
+import { isComposing } from '../components/ui'
import { useChat } from '../store/chat'
import { useConversations } from '../store/conversations'
@@ -142,7 +143,7 @@ function RenameField({ initial, onSubmit, onCancel }: {
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
- if (e.key === 'Enter') onSubmit(text)
+ if (e.key === 'Enter' && !isComposing(e)) onSubmit(text)
if (e.key === 'Escape') onCancel()
}}
autoFocus
diff --git a/frontend/src/pages/KnowledgePage.tsx b/frontend/src/pages/KnowledgePage.tsx
index 09c8cb95..16fa54a0 100644
--- a/frontend/src/pages/KnowledgePage.tsx
+++ b/frontend/src/pages/KnowledgePage.tsx
@@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router-dom'
import { BookOpen, Brain, Plus, Search, Sparkles, Trash2, Upload } from 'lucide-react'
import { api } from '../api/client'
import { useCatalog } from '../store/catalog'
-import { Empty, Modal, Spinner, Tabs, useTabRoute, useToast } from '../components/ui'
+import { Empty, isComposing, Modal, Spinner, Tabs, useTabRoute, useToast } from '../components/ui'
import type { KbDocument, MemoryItem, Skill } from '../types'
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上
@@ -375,7 +375,7 @@ function KbList() {
setQuery(e.target.value)}
- onKeyDown={(e) => e.key === 'Enter' && search()} />
+ onKeyDown={(e) => e.key === 'Enter' && !isComposing(e) && search()} />
diff --git a/frontend/src/run/Composer.tsx b/frontend/src/run/Composer.tsx
index 479c52a7..00528503 100644
--- a/frontend/src/run/Composer.tsx
+++ b/frontend/src/run/Composer.tsx
@@ -4,7 +4,7 @@ import clsx from 'clsx'
import { api } from '../api/client'
import { useCatalog, modelOptions } from '../store/catalog'
import { useStudio } from '../store/studio'
-import { Spinner } from '../components/ui'
+import { isComposing, Spinner } from '../components/ui'
/**
* Copilot 的输入。
@@ -114,7 +114,7 @@ export function Composer({ hero, onFocusChange }: {
onKeyDown={(e) => {
// 输入法组字期间的回车是"选词",不是"发送"。不判 isComposing 的话
// 中文用户每打一个词就发出去一次
- if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
+ if (e.key === 'Enter' && !e.shiftKey && !isComposing(e)) {
e.preventDefault()
send()
}
diff --git a/frontend/src/run/RunControl.tsx b/frontend/src/run/RunControl.tsx
index 1608948a..1215bace 100644
--- a/frontend/src/run/RunControl.tsx
+++ b/frontend/src/run/RunControl.tsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Play, RotateCw, ShieldCheck, Square } from 'lucide-react'
import { useStudio } from '../store/studio'
-import { Spinner, useToast } from '../components/ui'
+import { isComposing, Spinner, useToast } from '../components/ui'
/**
* 工具栏上的运行控件。
@@ -151,7 +151,7 @@ export function RunControl() {
placeholder={field.required ? '必填' : '可留空'}
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
onKeyDown={(e) => {
- if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') void launch(false)
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter' && !isComposing(e)) void launch(false)
}}
/>
diff --git a/frontend/src/store/chat.ts b/frontend/src/store/chat.ts
index f91750ad..88321efe 100644
--- a/frontend/src/store/chat.ts
+++ b/frontend/src/store/chat.ts
@@ -272,6 +272,11 @@ export const useChat = create((set, get) => ({
const last = turns[turns.length - 1]
last?.cancel?.()
get().cancel?.()
+ // 上面两句只是把自己这头的事件流关了。运行在后端,断开订阅不会让它停:
+ // 以前点了「停止」,模型和 SQL 其实还在跑、还在计费,界面上却已经显示"已取消"
+ if (last?.run?.id && !['done', 'error', 'waiting'].includes(last.phase)) {
+ void api.runs.cancel(last.run.id).catch(() => undefined) // 已经跑完会回 409,无妨
+ }
if (last?.serverId && !['done', 'error', 'waiting'].includes(last.phase)) {
void api.conversations
.patchTurn(conversationId, last.serverId, { status: 'error', error: '已取消' })
diff --git a/frontend/src/store/studio.ts b/frontend/src/store/studio.ts
index 92eb72aa..2914a277 100644
--- a/frontend/src/store/studio.ts
+++ b/frontend/src/store/studio.ts
@@ -562,7 +562,9 @@ export const useStudio = create((set, get) => ({
break
case 'add_node': {
const n = op.node
- if (!n?.id || !n?.type) break
+ // 类型不认识就不放上画布:节点卡片、属性面板都按类型查定义,查不到
+ // 以前是整站白屏、连带没保存的编辑一起丢。后端也会拦,这里是第二道
+ if (!n?.id || !n?.type || !NODE_DEFS[n.type as NodeType]) break
const node: FlowNode = {
id: n.id, type: 'card', position: { x: 0, y: 0 },
data: { nodeType: n.type, label: n.label ?? '', config: n.config ?? {} },
diff --git a/scripts/check-guards.mjs b/scripts/check-guards.mjs
new file mode 100644
index 00000000..aa165d65
--- /dev/null
+++ b/scripts/check-guards.mjs
@@ -0,0 +1,93 @@
+// 几道"坏了就是整页事故"的护栏:输入法回车、未知节点类型、页面级错误边界。
+//
+// 这三样出事时的样子都很重:中文用户每选一个词就把半句话发去建图、跑图;
+// Copilot 编出一个不存在的节点类型,整站白屏、没保存的编辑一起丢;任何组件渲染
+// 抛错,React 卸掉整棵树,连导航栏都没了。而 decode / stream / ui 三层检查对它们
+// 一个字都不会说——它们喂的都是合法数据。
+//
+// 探针拦掉所有非 GET 的 /api 请求:不写库、不调模型、不花钱,随时可以跑。
+// 往画布 store 里注入节点走的是 dev 构建挂在 window.__studio 上的那一份。
+// 跑之前前端得起着:./scripts/dev.sh
+import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
+
+const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
+// 不用 playwright install:它既下不动也会动到已有缓存。系统 Chrome 就够了。
+const CHROME = process.env.CHROME_PATH
+ ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
+
+let failed = 0
+const check = (name, cond, detail = '') => {
+ console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
+ if (!cond) failed++
+}
+
+const browser = await chromium.launch({ executablePath: CHROME })
+const page = await browser.newPage({ viewport: { width: 1280, height: 860 } })
+const writes = []
+await page.route('**/api/**', (route) => {
+ const r = route.request()
+ if (r.method() === 'GET') return route.continue()
+ writes.push(`${r.method()} ${new URL(r.url()).pathname}`)
+ return route.abort()
+})
+const errors = []
+page.on('pageerror', (e) => errors.push(e.message))
+
+// 等到页面上出现某段文字为止。只在"应该发生"的断言上用;"不应该发生"的
+// 只能等一个窗口——那是它的本性,不是偷懒
+const shows = (text) => page.getByText(text).first().waitFor({ timeout: 8000 }).then(() => true, () => false)
+
+console.log('=== 问数据页:输入法组字时的回车 ===')
+await page.goto(`${WEB}/chat`, { waitUntil: 'networkidle' })
+const box = page.locator('textarea').first()
+await box.fill('帮我查一下本周')
+const fire = (init) => box.evaluate((el, init) => el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })), init)
+writes.length = 0
+await fire({ key: 'Enter', isComposing: true })
+await page.waitForTimeout(400)
+check('isComposing 的回车不发送', writes.length === 0, writes.join(', '))
+await fire({ key: 'Enter', keyCode: 229 })
+await page.waitForTimeout(400)
+check('Safari 式 keyCode=229 的回车不发送', writes.length === 0, writes.join(', '))
+check('草稿还在输入框里', (await box.inputValue()) === '帮我查一下本周')
+const sent = page.waitForRequest((r) => r.method() !== 'GET' && r.url().includes('/api/'), { timeout: 8000 })
+ .then(() => true, () => false)
+await fire({ key: 'Enter' })
+await sent
+check('普通回车照常发送(请求被探针拦下)', writes.length > 0, writes.join(', '))
+
+console.log('\n=== 画布:未知节点类型不再白屏 ===')
+await page.goto(`${WEB}/studio`, { waitUntil: 'networkidle' })
+// 画布会自己跳到第一张图,等它真的把节点装进 store 再注入
+await page.waitForFunction(() => window.__studio?.getState().nodes.length > 0, null, { timeout: 8000 })
+await page.evaluate(() => {
+ const s = window.__studio.getState()
+ window.__studio.setState({ nodes: [...s.nodes, { id: 'ghost', type: 'card', position: { x: 60, y: 60 },
+ data: { nodeType: 'no_such_type', label: '幽灵节点', config: {} } }] })
+ window.__studio.getState().select('ghost')
+})
+check('属性面板说清楚是未知类型', await shows('不认识的节点类型'))
+check('画布还在(没有白屏)', (await page.locator('body').innerText()).includes('幽灵节点'))
+check('没有未捕获的运行时错误', errors.length === 0, errors[0] ?? '')
+
+console.log('\n=== 错误边界:渲染真出错时只坏这一页 ===')
+errors.length = 0
+await page.evaluate(() => {
+ const s = window.__studio.getState()
+ window.__studio.setState({ nodes: [...s.nodes, { id: 'broken', type: 'card', position: { x: 0, y: 0 }, data: null }] })
+})
+check('出错时给出错误页而不是白屏', await shows('这一页出错了'))
+const after = await page.locator('body').innerText()
+check('导航栏还在', after.includes('问数据') && after.includes('编排'))
+await page.evaluate(() => {
+ const s = window.__studio.getState()
+ window.__studio.setState({ nodes: s.nodes.filter((n) => n.id !== 'broken' && n.id !== 'ghost') })
+})
+await page.getByText('问数据', { exact: true }).first().click()
+await page.waitForURL(/\/chat/)
+check('换一页就恢复', await page.getByText('这一页出错了').waitFor({ state: 'detached', timeout: 8000 })
+ .then(() => true, () => false))
+
+await browser.close()
+console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 护栏全部通过')
+process.exit(failed ? 1 : 0)