From 429398a156e07961871530a9eb89ebfd56c10914 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 25 Sep 2026 15:41:30 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E5=9B=9B=E5=A4=84=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E4=BC=A4=E4=BA=BA=E7=9A=84=E5=9C=B0=E6=96=B9=EF=BC=9A?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E6=B3=95=E5=9B=9E=E8=BD=A6=E3=80=81=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E5=88=AB=E7=9A=84=E5=9B=BE=E3=80=81=E5=81=9C=E6=AD=A2?= =?UTF-8?q?=E4=B8=8D=E5=81=9C=E3=80=81=E6=95=B4=E7=AB=99=E7=99=BD=E5=B1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 中文输入法选词的回车被当成发送。问数据页输入框不判组字状态(Composer 判 了,这里没有):用户每选一个词,半句问题就被送去建图、跑图。同样的漏洞还在 会话重命名、知识库搜索、{{ }} 补全、⌘Enter 运行上。收成 ui.isComposing,六处 统一——Safari 结束组字那一下 isComposing 已经是 false、keyCode 还是 229,两个 都看。 2. 「在画布里打开」后 ⌘S 覆盖上一次打开的工作流。它只调了 setGraph:换掉画布 上的节点,store 里的 workflow 还是上一张图,保存就把那张图整张覆盖了(旧版本 还在版本表里,界面上没有入口找回);没打开过图时 ⌘S 则什么都不做。现在建成 一张新工作流(以这一轮的问题命名)、走它自己的地址,原来的图不受影响。 3. 问数据页的「停止」不停。它只关了自己这头的事件流,从不调 runs.cancel,而后端 断开订阅不会取消运行——模型和 SQL 其实还在跑、还在计费,界面上却已经显示 "已取消"。等人工审批的那一轮照旧不动。 4. Copilot 编出一个不存在的节点类型,整站白屏、没保存的编辑一起丢。三层都补: - 后端 _apply_op 拒收未知类型,不进图、不转发,最终校验里说明跳过了哪一步; - 前端流式加节点时再拦一道;sourceHandles、属性面板遇到未知类型不再崩; - 全站加页面级 ErrorBoundary:导航栏留着,换一页自动恢复;编辑中的图在 store 里,点「重试」时它还在。 新增 scripts/check-guards.mjs(真浏览器,拦下所有非 GET 请求,不写库不花钱): 组字回车与 keyCode 229 不发送、普通回车照常发送、未知类型不白屏、渲染真出错时 只坏这一页且换页恢复,连跑三次全绿。等待一律等事件,只有"不该发生"的断言用 窗口。后端补了未知类型不进图的用例,413 → 414 passed;tsc 通过,decode / stream / canvas-layout 三个检查不回归。 --- README.md | 1 + backend/app/api/copilot.py | 18 ++++- backend/tests/test_copilot_stream.py | 14 ++++ frontend/src/App.tsx | 7 +- frontend/src/canvas/Inspector.tsx | 10 +++ frontend/src/canvas/TemplateText.tsx | 3 +- frontend/src/canvas/nodeDefs.ts | 3 + frontend/src/components/ui.tsx | 56 ++++++++++++++- frontend/src/pages/ChatPage.tsx | 27 ++++--- frontend/src/pages/ConversationList.tsx | 3 +- frontend/src/pages/KnowledgePage.tsx | 4 +- frontend/src/run/AssistantStream.tsx | 8 +-- frontend/src/run/Composer.tsx | 4 +- frontend/src/run/RunControl.tsx | 4 +- frontend/src/store/chat.ts | 5 ++ frontend/src/store/studio.ts | 4 +- scripts/check-guards.mjs | 93 +++++++++++++++++++++++++ 17 files changed, 236 insertions(+), 28 deletions(-) create mode 100644 scripts/check-guards.mjs 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)