mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:51:59 +00:00
对话式入口:说需求,不用碰画布
阶段③。画布是给编排的人用的——15 种节点、每种十几个配置项,那是把控制权 交到手里的代价。但多数时候人要的只是一个答案:"MES 不合格数据里记录最多的 工厂是哪个",不关心它是 input→agent→output 还是别的什么。 这一页把图藏起来:你问,它自己接数据源、建流程、跑完、给结论。图仍然真实 存在(可以展开看"它是怎么做的",也能一键放到画布继续改),只是不再是必经之路。 默认路由也改到这里,画布退到第二项。 ## 状态分开放 新建 store/chat.ts 而不是复用 studio store:画布那套状态(选中节点、脏标记、 校验问题)在这里一个都用不上,混进去只会让两边互相牵制——对话页跑一次图就把 人家画布上没保存的东西冲掉,那是灾难。 ## 事件翻译成人话 Copilot 的操作流和运行事件都不直接暴露。add_node → "正在搭建流程…(3 步)", tool.start(db_query) → "查询数据",tool.end 里的 row_count 追加成"· 246 行"。 SQL 原文折叠在步骤里,想看能点开——不给看和硬塞给人看都不对。 等待期间显示模型的思考(复用上一轮加的 thinking 事件),这是 40 秒建图过程里 唯一能看的东西。 ## 人工介入就地处理 停在审批时直接在对话流里渲染 ApprovalCard(上一轮导出的),处理完自动重新 接上事件流看后续,不用切去别的页面再切回来。 ## 起始建议用真实表名 空状态不写死示例,而是读真实数据源的表名造建议——用户一眼看到的是自己的数据。 没有数据源时直接指路"设置 → 数据源",而不是给一个点了没反应的输入框。 ## 实测 按 store 的同样调用顺序跑完整链路:建图 44 秒(其中 39 秒是模型在想, 期间有心跳和 plan 摘要顶着)→ 3 步图 → agent 先查表结构再聚合 → "记录最多的工厂是上海头枕(工厂编码 1051),共 190470 条记录。"
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { useEffect } from 'react'
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { BookOpen, FlaskConical, History, Settings, Wrench } from 'lucide-react'
|
||||
import { BookOpen, FlaskConical, History, MessageSquare, Settings, Wrench } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from './api/client'
|
||||
import { useCatalog } from './store/catalog'
|
||||
import { ChatPage } from './pages/ChatPage'
|
||||
import { StudioPage } from './pages/StudioPage'
|
||||
import { RunsPage } from './pages/RunsPage'
|
||||
import { ToolsPage } from './pages/ToolsPage'
|
||||
@@ -12,6 +13,7 @@ import { SettingsPage, applyTheme } from './pages/SettingsPage'
|
||||
import { Spinner } from './components/ui'
|
||||
|
||||
const NAV = [
|
||||
{ to: '/chat', label: '问数据', icon: MessageSquare },
|
||||
{ to: '/studio', label: '编排', icon: FlaskConical },
|
||||
{ to: '/runs', label: '运行', icon: History },
|
||||
{ to: '/tools', label: '工具', icon: Wrench },
|
||||
@@ -73,7 +75,9 @@ export default function App() {
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/studio" replace />} />
|
||||
{/* 默认落到对话页:多数人要的是答案,画布留给要自己编排的人 */}
|
||||
<Route path="/" element={<Navigate to="/chat" replace />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/studio" element={<StudioPage />} />
|
||||
<Route path="/runs" element={<RunsPage />} />
|
||||
<Route path="/tools" element={<ToolsPage />} />
|
||||
|
||||
@@ -77,6 +77,19 @@ export const api = {
|
||||
},
|
||||
|
||||
// ---- 运行 ----
|
||||
datasources: {
|
||||
list: () => get<any[]>('/datasources'),
|
||||
kinds: () => get<any>('/datasources/kinds'),
|
||||
create: (body: any) => post<any>('/datasources', body),
|
||||
update: (id: string, body: any) => patch<any>(`/datasources/${id}`, body),
|
||||
remove: (id: string) => del(`/datasources/${id}`),
|
||||
test: (id: string) => post<any>(`/datasources/${id}/test`, {}),
|
||||
introspect: (id: string, schema?: string) =>
|
||||
post<any>(`/datasources/${id}/introspect${schema ? `?schema=${encodeURIComponent(schema)}` : ''}`, {}),
|
||||
schema: (id: string, table?: string) =>
|
||||
get<any>(`/datasources/${id}/schema${table ? `?table=${encodeURIComponent(table)}` : ''}`),
|
||||
},
|
||||
|
||||
runs: {
|
||||
list: (params?: { workflow_id?: string; status?: string; limit?: number }) => {
|
||||
const q = new URLSearchParams(
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
ChevronRight, Database, MessageSquare, Send, Square, Table2, Trash2, Wrench, XCircle,
|
||||
} from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../api/client'
|
||||
import { ApprovalCard } from '../run/RunPanel'
|
||||
import { useCatalog } from '../store/catalog'
|
||||
import { useChat, type ChatTurn } from '../store/chat'
|
||||
import { useStudio, toFlow } from '../store/studio'
|
||||
import { Empty, Spinner, useToast } from '../components/ui'
|
||||
|
||||
/**
|
||||
* 对话式入口。
|
||||
*
|
||||
* 画布是给编排的人用的:15 种节点、每种十几个配置项,那是把控制权交到手里的
|
||||
* 代价。但大多数时候人要的只是一个答案——"上季度华东销量怎么样",不关心它
|
||||
* 是 input→agent→output 还是别的什么。
|
||||
*
|
||||
* 所以这一页把图藏起来:你问,它自己接数据源、建流程、跑完、给结论。图仍然
|
||||
* 真实存在(可以展开看,也能拿到画布里继续改),只是不再是必经之路。
|
||||
*/
|
||||
export function ChatPage() {
|
||||
const { turns, busy, ask, stop, clear } = useChat()
|
||||
const [draft, setDraft] = useState('')
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const sources = useDataSources()
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [turns.length, turns[turns.length - 1]?.phase])
|
||||
|
||||
const send = () => {
|
||||
const q = draft.trim()
|
||||
if (!q || busy) return
|
||||
ask(q)
|
||||
setDraft('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<header className="flex items-center gap-2 border-b px-4 py-2">
|
||||
<MessageSquare size={14} style={{ color: 'var(--accent)' }} />
|
||||
<span className="text-[13px] font-semibold">问数据</span>
|
||||
<span className="text-[11px] text-faint">
|
||||
说需求,它自己接数据源、建流程、跑完给结论
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{!!turns.length && (
|
||||
<button className="btn btn-sm btn-ghost" onClick={clear} title="清空对话">
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
{!turns.length ? (
|
||||
<StarterHints sources={sources} onPick={(q) => { setDraft(q); }} />
|
||||
) : (
|
||||
<div className="mx-auto max-w-3xl space-y-5">
|
||||
{turns.map((turn) => <Turn key={turn.id} turn={turn} />)}
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<div className="border-t p-3">
|
||||
<div className="mx-auto flex max-w-3xl items-end gap-2">
|
||||
<textarea
|
||||
className="field flex-1 resize-none"
|
||||
rows={2}
|
||||
value={draft}
|
||||
placeholder={
|
||||
sources.length
|
||||
? `问点什么,比如「${sources[0].description || sources[0].name} 里有多少数据」`
|
||||
: '还没有接入数据源,先去「设置 → 数据源」加一个'
|
||||
}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Enter 发送,Shift+Enter 换行——对话框的通用约定
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
|
||||
}}
|
||||
/>
|
||||
{busy ? (
|
||||
<button className="btn btn-danger h-9" onClick={stop}>
|
||||
<Square size={12} /> 停止
|
||||
</button>
|
||||
) : (
|
||||
<button className="btn btn-primary h-9" onClick={send} disabled={!draft.trim()}>
|
||||
<Send size={12} /> 发送
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function Turn({ turn }: { turn: ChatTurn }) {
|
||||
const running = ['planning', 'building', 'running'].includes(turn.phase)
|
||||
|
||||
return (
|
||||
<div className="fade-up">
|
||||
{/* 用户的问题 */}
|
||||
<div className="mb-2 flex justify-end">
|
||||
<div className="max-w-[80%] rounded-lg rounded-br-sm px-3 py-2 text-[12.5px]"
|
||||
style={{ background: 'var(--accent)', color: '#fff' }}>
|
||||
{turn.question}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 回应 */}
|
||||
<div className="rounded-lg border bg-panel p-3">
|
||||
<div className="flex items-center gap-2 text-[11.5px]">
|
||||
{running && <Spinner size={12} />}
|
||||
{turn.phase === 'error' && <XCircle size={12} className="text-[var(--err)]" />}
|
||||
<span className={clsx(turn.phase === 'error' && 'text-[var(--err)]')}>
|
||||
{turn.phase === 'error' ? turn.error : turn.status}
|
||||
</span>
|
||||
{turn.run && (
|
||||
<span className="mono text-[10px] text-faint">#{turn.run.id.slice(0, 8)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 模型在想什么——等待期间唯一能看的东西 */}
|
||||
{running && turn.thinking && (
|
||||
<div className="mt-2 max-h-24 overflow-y-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 text-[10.5px] leading-relaxed text-dim">
|
||||
{turn.thinking}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 干了什么:查了哪张表、跑了什么 SQL */}
|
||||
{!!turn.steps.length && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{turn.steps.map((s, i) => <Step key={i} step={s} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 停在人工介入:就地处理,不用切去别的页面 */}
|
||||
{turn.phase === 'waiting' && turn.run && <Approvals turn={turn} />}
|
||||
|
||||
{/* 成果 */}
|
||||
{turn.output && <Output output={turn.output} />}
|
||||
|
||||
{/* 生成的图:默认藏起来,想看再展开 */}
|
||||
{turn.graph && <GraphPeek turn={turn} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Step({ step }: { step: { icon: string; text: string; detail?: string } }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const Icon = step.icon === 'db' ? Database
|
||||
: step.icon === 'schema' ? Table2
|
||||
: step.icon === 'error' ? XCircle : Wrench
|
||||
const color = step.icon === 'error' ? 'var(--err)' : 'var(--text-faint)'
|
||||
|
||||
return (
|
||||
<div className="text-[11px]">
|
||||
<button
|
||||
className="flex w-full items-start gap-1.5 text-left hover:text-dim"
|
||||
style={{ color }}
|
||||
onClick={() => step.detail && setOpen((v) => !v)}
|
||||
>
|
||||
<Icon size={11} className="mt-[2px] shrink-0" />
|
||||
<span className="min-w-0 flex-1">{step.text}</span>
|
||||
{step.detail && (
|
||||
<ChevronRight size={10} className="mt-[2px] shrink-0"
|
||||
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
|
||||
)}
|
||||
</button>
|
||||
{open && step.detail && (
|
||||
<pre className="mono mt-1 max-h-32 overflow-auto rounded bg-bg px-2 py-1.5 text-[10px] leading-relaxed text-dim whitespace-pre-wrap">
|
||||
{step.detail}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Approvals({ turn }: { turn: ChatTurn }) {
|
||||
const approvals = useCatalog((s) => s.approvals)
|
||||
const reattach = useChat((s) => s.reattach)
|
||||
const pending = approvals.filter((a) => a.run_id === turn.run?.id && a.status === 'pending')
|
||||
|
||||
// 审批处理完后运行会继续,重新接上事件流才能看到后续
|
||||
useEffect(() => {
|
||||
if (turn.phase === 'waiting' && !pending.length) void reattach(turn.id)
|
||||
}, [pending.length, turn.phase, turn.id, reattach])
|
||||
|
||||
if (!pending.length) return null
|
||||
return (
|
||||
<div className="mt-2 overflow-hidden rounded border" style={{ borderColor: 'var(--warn)' }}>
|
||||
{pending.map((a) => <ApprovalCard key={a.id} approval={a} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Output({ output }: { output: Record<string, any> }) {
|
||||
const entries = useMemo(
|
||||
() => Object.entries(output).filter(([k]) => !k.startsWith('_')),
|
||||
[output],
|
||||
)
|
||||
if (!entries.length) return null
|
||||
|
||||
return (
|
||||
<div className="mt-2 space-y-2 border-t pt-2">
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
{entries.length > 1 && (
|
||||
<div className="mb-0.5 text-[10.5px] font-semibold text-faint">{key}</div>
|
||||
)}
|
||||
<div className="whitespace-pre-wrap text-[12.5px] leading-relaxed">
|
||||
{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GraphPeek({ turn }: { turn: ChatTurn }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const setGraph = useStudio((s) => s.setGraph)
|
||||
const toast = useToast()
|
||||
const nodes = turn.graph?.nodes ?? []
|
||||
|
||||
return (
|
||||
<div className="mt-2 border-t pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="flex items-center gap-1 text-[10.5px] text-faint hover:text-dim"
|
||||
onClick={() => setOpen((v) => !v)}>
|
||||
<ChevronRight size={10}
|
||||
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
|
||||
它是怎么做的({nodes.length} 步)
|
||||
</button>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
className="btn btn-sm btn-ghost text-[10.5px]"
|
||||
title="把这张图放到画布上继续改"
|
||||
onClick={() => {
|
||||
if (!turn.graph) return
|
||||
setGraph(turn.graph)
|
||||
toast('已放到画布,切到「编排」继续改', 'ok')
|
||||
}}
|
||||
>
|
||||
在画布里打开
|
||||
</button>
|
||||
</div>
|
||||
{open && (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{turn.explanation && (
|
||||
<div className="rounded bg-bg px-2 py-1.5 text-[11px] leading-relaxed text-dim">
|
||||
{turn.explanation}
|
||||
</div>
|
||||
)}
|
||||
{nodes.map((n: any, i: number) => (
|
||||
<div key={n.id} className="flex items-center gap-2 text-[10.5px] text-faint">
|
||||
<span className="mono w-4 text-right">{i + 1}</span>
|
||||
<span className="chip">{n.type}</span>
|
||||
<span className="truncate">{n.data?.label || n.id}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function StarterHints({ sources, onPick }: {
|
||||
sources: any[]
|
||||
onPick: (q: string) => void
|
||||
}) {
|
||||
if (!sources.length) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<Database size={22} />}
|
||||
title="还没有接入数据源"
|
||||
hint="去「设置 → 数据源」加一个数据库,然后就能直接问它问题了"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 用真实的表名造建议,而不是写死的示例——用户一眼看到的是自己的数据
|
||||
const suggestions = sources.flatMap((s: any) => {
|
||||
const tables: string[] = s.tables_preview ?? []
|
||||
return [
|
||||
`${s.name} 里都有哪些数据?挑一个最大的表说说`,
|
||||
tables[0] ? `${tables[0]} 有多少条记录?最近的数据是什么时候` : null,
|
||||
].filter(Boolean) as string[]
|
||||
}).slice(0, 4)
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Empty
|
||||
icon={<MessageSquare size={22} />}
|
||||
title="问点什么"
|
||||
hint="它会自己接数据源、写查询、跑完给结论——你不用碰画布"
|
||||
/>
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{suggestions.map((q) => (
|
||||
<button key={q}
|
||||
className="w-full rounded-lg border px-3 py-2 text-left text-[12px] hover:bg-hover"
|
||||
onClick={() => onPick(q)}>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function useDataSources() {
|
||||
const [sources, setSources] = useState<any[]>([])
|
||||
useEffect(() => {
|
||||
void api.datasources.list()
|
||||
.then(async (rows) => {
|
||||
// 取一点表名做建议语,失败也无所谓
|
||||
const withTables = await Promise.all(rows.map(async (r: any) => {
|
||||
try {
|
||||
const s = await api.datasources.schema(r.id)
|
||||
return { ...r, tables_preview: (s.tables ?? []).slice(0, 3) }
|
||||
} catch { return r }
|
||||
}))
|
||||
setSources(withTables)
|
||||
})
|
||||
.catch(() => setSources([]))
|
||||
}, [])
|
||||
return sources
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { create } from 'zustand'
|
||||
import { api, streamCopilot, streamRun } from '../api/client'
|
||||
import type { GraphSpec, Run, RunEvent } from '../types'
|
||||
|
||||
/**
|
||||
* 对话式入口的状态。
|
||||
*
|
||||
* 和 studio store 分开:画布那套状态(选中节点、脏标记、校验问题)在这里
|
||||
* 一个都用不上,混进去只会让两边互相牵制——对话页跑一次图就把人家画布上
|
||||
* 没保存的东西冲掉,那是灾难。
|
||||
*
|
||||
* 一轮对话 = 一次「建图 → 跑图 → 出结果」。图对用户是隐藏的(想看可以展开),
|
||||
* 但它仍然是真实存在的工作流,跑完能存成模板、能在画布里继续改。
|
||||
*/
|
||||
|
||||
export type Phase =
|
||||
| 'idle'
|
||||
| 'planning' // Copilot 在想
|
||||
| 'building' // 图在长出来
|
||||
| 'running' // 图在跑
|
||||
| 'waiting' // 停在人工介入
|
||||
| 'done'
|
||||
| 'error'
|
||||
|
||||
export interface ChatTurn {
|
||||
id: string
|
||||
question: string
|
||||
phase: Phase
|
||||
/** 一句话进度,给人看"现在到哪了" */
|
||||
status: string
|
||||
/** 模型的思考(支持 thinking 的模型才有) */
|
||||
thinking: string
|
||||
/** Copilot 建出来的图 */
|
||||
graph: GraphSpec | null
|
||||
/** Copilot 对这张图的说明 */
|
||||
explanation: string
|
||||
run: Run | null
|
||||
/** 跑图过程中值得показ的事件(查了什么、拿到什么) */
|
||||
steps: { icon: string; text: string; detail?: string }[]
|
||||
/** 最终成果 */
|
||||
output: Record<string, any> | null
|
||||
error: string
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
turns: ChatTurn[]
|
||||
busy: boolean
|
||||
cancel: (() => void) | null
|
||||
|
||||
ask: (question: string) => void
|
||||
stop: () => void
|
||||
clear: () => void
|
||||
/** 审批完成后重新接上运行 */
|
||||
reattach: (turnId: string) => Promise<void>
|
||||
}
|
||||
|
||||
let seq = 0
|
||||
const newId = () => `t${Date.now().toString(36)}${seq++}`
|
||||
|
||||
const PHASE_TEXT: Record<Phase, string> = {
|
||||
idle: '',
|
||||
planning: '正在理解需求…',
|
||||
building: '正在搭建流程…',
|
||||
running: '正在执行…',
|
||||
waiting: '等待你的确认',
|
||||
done: '完成',
|
||||
error: '出错了',
|
||||
}
|
||||
|
||||
export const useChat = create<ChatState>((set, get) => ({
|
||||
turns: [],
|
||||
busy: false,
|
||||
cancel: null,
|
||||
|
||||
ask: (question) => {
|
||||
const id = newId()
|
||||
const patch = (fn: (t: ChatTurn) => Partial<ChatTurn>) =>
|
||||
set((s) => ({
|
||||
turns: s.turns.map((t) => (t.id === id ? { ...t, ...fn(t) } : t)),
|
||||
}))
|
||||
|
||||
set((s) => ({
|
||||
busy: true,
|
||||
turns: [
|
||||
...s.turns,
|
||||
{
|
||||
id, question, phase: 'planning', status: PHASE_TEXT.planning,
|
||||
thinking: '', graph: null, explanation: '', run: null,
|
||||
steps: [], output: null, error: '', startedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
// 建图阶段:Copilot 的操作流直接翻译成人话,不让用户看 add_node/add_edge
|
||||
const nodes: any[] = []
|
||||
const edges: any[] = []
|
||||
|
||||
const cancelCopilot = streamCopilot(
|
||||
{ instruction: question, base_graph: null },
|
||||
(op) => {
|
||||
switch (op.op) {
|
||||
case 'thinking':
|
||||
patch((t) => ({
|
||||
phase: 'planning',
|
||||
thinking: (t.thinking + (op.delta ?? '')).slice(-2000),
|
||||
}))
|
||||
break
|
||||
case 'heartbeat':
|
||||
patch(() => ({ status: PHASE_TEXT[(op.phase as Phase) ?? 'planning'] ?? '正在处理…' }))
|
||||
break
|
||||
case 'plan':
|
||||
patch(() => ({ phase: 'building', status: op.summary || '正在搭建流程…' }))
|
||||
break
|
||||
case 'add_node':
|
||||
if (op.node) nodes.push(op.node)
|
||||
patch((t) => ({
|
||||
phase: 'building',
|
||||
status: `正在搭建流程…(${nodes.length} 步)`,
|
||||
steps: [...t.steps],
|
||||
}))
|
||||
break
|
||||
case 'add_edge':
|
||||
if (op.edge) edges.push(op.edge)
|
||||
break
|
||||
case 'done':
|
||||
patch(() => ({ explanation: op.explanation ?? '' }))
|
||||
break
|
||||
case 'final': {
|
||||
// 后端排版校验后的最终图。拿到它就可以直接跑了
|
||||
const graph = op.graph as GraphSpec
|
||||
patch(() => ({
|
||||
graph, phase: 'running', status: PHASE_TEXT.running,
|
||||
explanation: op.explanation ?? '',
|
||||
}))
|
||||
void launch(id, graph, patch, set, get)
|
||||
break
|
||||
}
|
||||
case 'error':
|
||||
patch(() => ({ phase: 'error', error: op.message ?? '生成失败' }))
|
||||
set({ busy: false, cancel: null })
|
||||
break
|
||||
}
|
||||
},
|
||||
(error) => {
|
||||
if (!error) return
|
||||
patch((t) => (t.phase === 'error' ? {} : { phase: 'error', error }))
|
||||
set({ busy: false, cancel: null })
|
||||
},
|
||||
)
|
||||
set({ cancel: cancelCopilot })
|
||||
},
|
||||
|
||||
stop: () => {
|
||||
get().cancel?.()
|
||||
set((s) => ({
|
||||
busy: false,
|
||||
cancel: null,
|
||||
turns: s.turns.map((t) =>
|
||||
t.phase === 'done' || t.phase === 'error' ? t : { ...t, phase: 'error', error: '已取消' },
|
||||
),
|
||||
}))
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
get().cancel?.()
|
||||
set({ turns: [], busy: false, cancel: null })
|
||||
},
|
||||
|
||||
reattach: async (turnId) => {
|
||||
const turn = get().turns.find((t) => t.id === turnId)
|
||||
if (!turn?.run) return
|
||||
const patch = (fn: (t: ChatTurn) => Partial<ChatTurn>) =>
|
||||
set((s) => ({ turns: s.turns.map((t) => (t.id === turnId ? { ...t, ...fn(t) } : t)) }))
|
||||
patch(() => ({ phase: 'running', status: PHASE_TEXT.running }))
|
||||
set({ busy: true })
|
||||
watch(turn.run.id, turnId, patch, set)
|
||||
},
|
||||
}))
|
||||
|
||||
/** 图建好了就直接跑——用户要的是答案,不是一张图。 */
|
||||
async function launch(
|
||||
turnId: string,
|
||||
graph: GraphSpec,
|
||||
patch: (fn: (t: ChatTurn) => Partial<ChatTurn>) => void,
|
||||
set: any,
|
||||
_get: any,
|
||||
) {
|
||||
try {
|
||||
const run = await api.runs.start({ graph, input: {} })
|
||||
patch(() => ({ run }))
|
||||
watch(run.id, turnId, patch, set)
|
||||
} catch (e: any) {
|
||||
patch(() => ({ phase: 'error', error: e?.message ?? '启动失败' }))
|
||||
set({ busy: false, cancel: null })
|
||||
}
|
||||
}
|
||||
|
||||
/** 订阅运行事件,把关键动作翻译成人话。 */
|
||||
function watch(
|
||||
runId: string,
|
||||
turnId: string,
|
||||
patch: (fn: (t: ChatTurn) => Partial<ChatTurn>) => void,
|
||||
set: any,
|
||||
) {
|
||||
const stop = streamRun(runId, (event: RunEvent) => {
|
||||
const d: any = event.data ?? {}
|
||||
switch (event.type) {
|
||||
case 'tool.start': {
|
||||
const tool = String(d.tool ?? '')
|
||||
const sql = String(d.args?.sql ?? '')
|
||||
if (tool.startsWith('db_query')) {
|
||||
patch((t) => ({
|
||||
status: '正在查询数据…',
|
||||
steps: [...t.steps, { icon: 'db', text: '查询数据', detail: sql }],
|
||||
}))
|
||||
} else if (tool.startsWith('db_schema')) {
|
||||
patch((t) => ({
|
||||
status: '正在了解数据结构…',
|
||||
steps: [...t.steps, { icon: 'schema', text: `查看表结构 ${d.args?.table ?? ''}` }],
|
||||
}))
|
||||
} else if (tool) {
|
||||
patch((t) => ({
|
||||
status: `正在调用 ${tool}…`,
|
||||
steps: [...t.steps, { icon: 'tool', text: `调用 ${tool}` }],
|
||||
}))
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'tool.end': {
|
||||
const preview = String(d.preview ?? '')
|
||||
patch((t) => {
|
||||
const steps = [...t.steps]
|
||||
const last = steps[steps.length - 1]
|
||||
if (last && !last.detail?.startsWith('→')) {
|
||||
// 查询结果里最有用的是行数,塞进上一条步骤而不是新起一行
|
||||
const m = preview.match(/"row_count":\s*(\d+)/)
|
||||
if (m) last.text = `${last.text} · ${m[1]} 行`
|
||||
}
|
||||
return { steps }
|
||||
})
|
||||
break
|
||||
}
|
||||
case 'llm.start':
|
||||
patch(() => ({ status: '正在分析…' }))
|
||||
break
|
||||
case 'run.interrupted':
|
||||
patch(() => ({ phase: 'waiting', status: PHASE_TEXT.waiting }))
|
||||
set({ busy: false })
|
||||
break
|
||||
case 'node.failed':
|
||||
patch((t) => ({
|
||||
steps: [...t.steps, { icon: 'error', text: String(d.error ?? '某一步失败了') }],
|
||||
}))
|
||||
break
|
||||
case 'run.failed':
|
||||
patch(() => ({ phase: 'error', error: String(d.error ?? '运行失败') }))
|
||||
set({ busy: false, cancel: null })
|
||||
break
|
||||
case 'run.finished':
|
||||
void finish(runId, turnId, patch, set)
|
||||
break
|
||||
}
|
||||
})
|
||||
set({ cancel: stop })
|
||||
}
|
||||
|
||||
async function finish(
|
||||
runId: string,
|
||||
_turnId: string,
|
||||
patch: (fn: (t: ChatTurn) => Partial<ChatTurn>) => void,
|
||||
set: any,
|
||||
) {
|
||||
try {
|
||||
const run = await api.runs.get(runId)
|
||||
patch(() => ({
|
||||
run, phase: 'done', status: PHASE_TEXT.done, output: run.output ?? null,
|
||||
}))
|
||||
} catch {
|
||||
patch(() => ({ phase: 'done', status: PHASE_TEXT.done }))
|
||||
}
|
||||
set({ busy: false, cancel: null })
|
||||
}
|
||||
Reference in New Issue
Block a user