对话式入口:说需求,不用碰画布

阶段③。画布是给编排的人用的——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:
ThearchyHelios
2026-09-17 14:26:28 +08:00
parent a6ee525148
commit 266631d2fd
4 changed files with 637 additions and 2 deletions
+6 -2
View File
@@ -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 />} />
+13
View File
@@ -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(
+335
View File
@@ -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
}
+283
View File
@@ -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 })
}