mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
前端四处直接伤人的地方:输入法回车、覆盖别的图、停止不停、整站白屏
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 三个检查不回归。
This commit is contained in:
@@ -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 # 冒烟:打开画布 → 选模板 → 运行 → 观察高亮
|
||||
```
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 照样点亮「问数据」。*/}
|
||||
<ErrorBoundary resetKey={location.pathname}>
|
||||
<Routes>
|
||||
{/* 默认落到对话页:多数人要的是答案,画布留给要自己编排的人 */}
|
||||
<Route path="/" element={<Navigate to="/chat" replace />} />
|
||||
@@ -97,6 +99,7 @@ export default function App() {
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/:tab" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -30,6 +30,16 @@ export function Inspector() {
|
||||
}
|
||||
|
||||
const def = NODE_DEFS[node.data.nodeType]
|
||||
if (!def) {
|
||||
// 这个界面版本不认识的类型:给出实情和唯一能做的事,而不是读 def.fields 崩掉整页
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-6 text-center">
|
||||
<div className="text-xs">不认识的节点类型「{node.data.nodeType}」</div>
|
||||
<div className="text-[11px] text-faint">这个版本的界面编辑不了它,运行时后端也会拒绝这张图。</div>
|
||||
<button className="btn btn-sm" onClick={() => removeNode(node.id)}>删除这个节点</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const config = node.data.config ?? {}
|
||||
const setConfig = (key: string, value: any) =>
|
||||
updateNode(node.id, { config: { ...config, [key]: value } })
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -438,6 +438,9 @@ export const NODE_CATEGORIES = ['起止', '模型', '执行', '控制', '上下
|
||||
/** branch / loop / human 的出口由配置决定,这里统一算出来。 */
|
||||
export function sourceHandles(type: NodeType, config: Record<string, any>): 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 }[]
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
|
||||
<AlertCircle size={22} className="text-[var(--err)]" />
|
||||
<div className="text-sm font-medium">这一页出错了</div>
|
||||
<div className="max-w-md break-all text-[11.5px] text-faint">{error.message}</div>
|
||||
<div className="text-[11px] text-faint">没保存的编辑还在内存里:先点「重试」,恢复了就尽快保存。</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn btn-sm" onClick={() => this.setState({ error: null })}>重试</button>
|
||||
<button className="btn btn-sm btn-ghost" onClick={() => location.reload()}>刷新页面</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Toast
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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={
|
||||
<div className="shrink-0 border-t p-3">
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<input className="field" placeholder="测试检索效果…" value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && search()} />
|
||||
onKeyDown={(e) => e.key === 'Enter' && !isComposing(e) && search()} />
|
||||
<button className="btn" onClick={search}><Search size={12} /> 检索</button>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-[11px] text-faint">
|
||||
|
||||
@@ -66,7 +66,7 @@ export function AssistantStream({
|
||||
empty?: ReactNode
|
||||
/** 这一轮要不要插审批卡——插槽而不是内建,因为不同页面接流的方式不同 */
|
||||
approvalsFor?: (turn: StreamTurn) => ReactNode
|
||||
onOpenGraph?: (graph: any) => void
|
||||
onOpenGraph?: (graph: any, question?: string) => void
|
||||
footer?: ReactNode
|
||||
}) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
@@ -99,7 +99,7 @@ export function AssistantStream({
|
||||
|
||||
function TurnCard({ turn, dense, approvals, onOpenGraph }: {
|
||||
turn: StreamTurn; dense: boolean; approvals?: ReactNode
|
||||
onOpenGraph?: (graph: any) => void
|
||||
onOpenGraph?: (graph: any, question?: string) => void
|
||||
}) {
|
||||
const running = turn.phase === 'running'
|
||||
|
||||
@@ -182,8 +182,8 @@ function TurnCard({ turn, dense, approvals, onOpenGraph }: {
|
||||
)}
|
||||
|
||||
{turn.graph && (
|
||||
<GraphPeek graph={turn.graph} note={turn.graphNote}
|
||||
dense={dense} onOpen={onOpenGraph} />
|
||||
<GraphPeek graph={turn.graph} note={turn.graphNote} dense={dense}
|
||||
onOpen={onOpenGraph && ((g) => onOpenGraph(g, turn.question))} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -272,6 +272,11 @@ export const useChat = create<ChatState>((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: '已取消' })
|
||||
|
||||
@@ -562,7 +562,9 @@ export const useStudio = create<StudioState>((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 ?? {} },
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user