右栏重构:助手常驻,属性改为盖上去的一层

两个问题都源于同一件事——一栏 360px 被要求同时干两件活。

**没地方引导写问题。** Copilot 输入框是个 38px 高的框,挤在一条长长的流
底下,比它上面任何一块都不起眼;例句被截断成 16 个字的 chip,示范"该说到
什么颗粒度"这个作用就没了,只剩装饰。改成一个组件两种体量:没说过话时它
就是这一栏的主体(竖直居中、三行高、自动聚焦、完整句子的例句),并且告诉
用户它能够到什么(N 个数据源 / M 个工具)——不然"查销售库"这种话有没有意义
只能靠猜。有了对话之后它退到底部两行。不做成两个组件:那就是两个实例,
切换时会卸载其中一个,草稿、光标、输入法候选词全没。

**Copilot 和属性挤在一起。** 它们原是 [助手|属性] 两个 tab,而 tab 是互斥的
——跑图跑到一半点开节点看配置,整条执行过程就从眼前消失,切回来滚动位置和
输入草稿也没了(切 tab 会卸载组件)。改成属性滑出来盖在助手上:助手一直在
下面活着,属性是临时造访者。头部给一个明确的"‹ 助手",Esc 关得掉;盖住
期间底部留一条执行进度,点一下回去——不能因为换了实现就把原来最难受的
那个场景原样留着。

运行控件搬去工具栏。它原先和 Copilot 输入框一起常驻助手栏,两个都是"主要
动作"互相压着;而它们是两种意图(跑这张图 / 改这张图)。运行是对整张图的
动作,和保存、发布同类。有输入字段就弹表单,没有就一键直接跑。

顺带修了两处状态说反的地方:
- 停在人工介入时,承载的节点还在转蓝圈。转圈说的是"它在忙,你等着",而
  实际正相反——它在等你。改成待办色 + 手势图标。
- "继续执行"的去重原先靠"界面上还有没有 waiting 步骤",上一条改动多了一处
  产生 waiting 的地方就失效了。改用事件相邻性判断——那是这两条事件的固有
  性质,不会因为别处改了展示而变。

动效:面板滑入、例句依次入场、正在出字的那一步有一道光扫过(比转圈多说
一件事:它在产出,不只是在等)、空态标记轻微呼吸。全部 240ms 以内,缓动
统一 cubic-bezier(.32,.72,0,1)。另外补上了此前完全没有的
prefers-reduced-motion——这套界面里动的东西不少(边在流动、节点在脉冲、
面板在滑),系统里关了还照播,对前庭敏感的人是实打实的难受。

验证:check-ui 重写了助手栏那一节,核心断言是"选中节点再回来,输入的字
还在"——那正是 tab 方案做不到的。新增一条用 emulateMedia 验证关掉动效后
动画确实停了。真跑了一次带人工审批的图,期间打开属性面板确认进度条还在。
四套检查 + 82 个后端测试全过,生产构建通过。
This commit is contained in:
ThearchyHelios
2026-09-18 00:05:22 +08:00
parent 3ff6840b99
commit 3de7567377
12 changed files with 877 additions and 457 deletions
+13 -5
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { Copy, Plus, Settings2, Trash2, X } from 'lucide-react'
import { ChevronLeft, Copy, Plus, Settings2, Trash2, X } from 'lucide-react'
import clsx from 'clsx'
import { NODE_DEFS, type FieldDef } from './nodeDefs'
import { useStudio } from '../store/studio'
@@ -39,7 +39,18 @@ export function Inspector() {
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b px-3 py-2">
<div className="flex items-center gap-1.5 border-b px-2 py-2">
{/* 这块是盖在助手栏上的一层。不给一个明确的"回去",用户就不知道
底下还有东西——只看到一个 ×,会以为关掉之后什么都没有了 */}
<button
className="flex items-center gap-0.5 rounded-md px-1 py-1 text-faint transition-colors hover:bg-hover hover:text-fg"
title="返回助手(Esc)"
onClick={() => select(null)}
>
<ChevronLeft size={14} />
<span className="text-[10px]">助手</span>
</button>
<span className="mx-0.5 h-3.5 w-px shrink-0" style={{ background: 'var(--border)' }} />
<def.icon size={13} style={{ color: 'var(--nt)' }} className={`nt-${node.data.nodeType}`} />
<span className="flex-1 truncate text-xs font-semibold">{def.label}</span>
<button className="btn btn-ghost btn-sm" title="复制节点" onClick={() => duplicateNode(node.id)}>
@@ -48,9 +59,6 @@ export function Inspector() {
<button className="btn btn-ghost btn-sm" title="删除节点" onClick={() => removeNode(node.id)}>
<Trash2 size={12} className="text-[var(--err)]" />
</button>
<button className="btn btn-ghost btn-sm" onClick={() => select(null)}>
<X size={12} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-3">
+100
View File
@@ -0,0 +1,100 @@
import { useEffect } from 'react'
import { ChevronLeft, Hand } from 'lucide-react'
import clsx from 'clsx'
import { useCatalog } from '../store/catalog'
import { useStudio } from '../store/studio'
import { decodeRun, type Step } from '../run/decode'
import { Spinner } from '../components/ui'
import { Inspector } from './Inspector'
/**
* 属性面板:盖在助手栏上的一层,不是和它并列的 tab。
*
* 之前两者是 [助手|属性] 两个 tab。tab 是互斥的,而这两件事在时间上不互斥——
* 跑图跑到一半想点开某个节点看配置,整条执行过程就从眼前消失了;切回来时
* 滚动位置、展开的步骤、输入框里打了一半的字全没(tab 切换会卸载组件)。
*
* 改成"盖上去"之后:助手一直在下面活着,属性是临时造访者,关掉就回到原处。
* 选中节点即滑入,Esc 或点画布空白处即滑出——不需要专门去找一个 tab。
*/
export function InspectorSheet() {
const selectedId = useStudio((s) => s.selectedId)
const select = useStudio((s) => s.select)
// Esc 关闭。这是"覆盖层"这种东西的通用约定,没有它就只能去找那个 ×
useEffect(() => {
if (!selectedId) return
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return
// 正在输入框里打字时 Esc 是给输入法用的,别抢
const el = document.activeElement
const typing = el instanceof HTMLElement
&& (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)
if (typing) return
select(null)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [selectedId, select])
if (!selectedId) return null
return (
// key 带上 selectedId:换一个节点时重播一次滑入,让人知道内容换了。
// 不重播的话,点另一个节点看起来像什么都没发生
<div key={selectedId}
className="sheet-in absolute inset-0 z-20 flex flex-col overflow-hidden bg-panel"
style={{ boxShadow: '-10px 0 28px -14px rgba(0,0,0,.5)' }}>
<div className="min-h-0 flex-1">
<Inspector />
</div>
<LiveStrip />
</div>
)
}
/**
* 盖住助手栏期间,底部留一条执行进度。
*
* 不留的话,跑图跑到一半点开节点看配置,运行就从眼前彻底消失了——这正是
* 原来 tab 方案最难受的地方,不能因为换成"盖一层"就原样留着。点一下回去。
*/
function LiveStrip() {
const streaming = useStudio((s) => s.streaming)
const events = useStudio((s) => s.events)
const select = useStudio((s) => s.select)
const approvals = useCatalog((s) => s.approvals)
const run = useStudio((s) => s.run)
const waiting = approvals.some((a) => a.run_id === run?.id && a.status === 'pending')
if (!streaming && !waiting) return null
// 只取最后一步说给人听,不在这里重演整条流——这是一条提示,不是第二个时间线
const steps = decodeRun(events)
const last = flattenLast(steps)
return (
<button
className="fade-up flex w-full shrink-0 items-center gap-2 border-t px-2.5 py-1.5 text-left text-[10.5px] transition-colors hover:bg-hover"
style={waiting ? { background: 'color-mix(in srgb, var(--warn) 10%, transparent)' } : undefined}
title="回到助手看完整过程"
onClick={() => select(null)}
>
{waiting
? <Hand size={11} className="shrink-0" style={{ color: 'var(--warn)' }} />
: <Spinner size={10} />}
<span className={clsx('min-w-0 flex-1 truncate', waiting && 'text-[var(--warn)]')}>
{waiting ? '等你确认' : last?.title || '正在执行…'}
</span>
<ChevronLeft size={11} className="shrink-0 rotate-180 text-faint" />
</button>
)
}
/** 最后一条有意义的步骤:优先取最深的子步骤,那才是"此刻在干什么"。 */
function flattenLast(steps: Step[]): Step | undefined {
const flat: Step[] = []
const walk = (list: Step[]) => list.forEach((s) => { flat.push(s); if (s.children) walk(s.children) })
walk(steps)
return [...flat].reverse().find((s) => s.kind !== 'lifecycle') ?? flat[flat.length - 1]
}
+86
View File
@@ -221,6 +221,92 @@ pre,
animation: fade-up 0.16s ease-out;
}
/* --------------------------------------------------------------------------
助手栏的动效。
缓动统一用 cubic-bezier(.32,.72,0,1):起步快、尾部长,是"被推出来"而不是
"匀速平移"的手感。时长都压在 240ms 以内——面板滑动超过这个数就开始像卡顿,
而不是像流畅。
-------------------------------------------------------------------------- */
/* 属性面板:从右侧盖到助手栏上。它是临时造访者,助手才是常住的,所以用
"盖上去再拿走"而不是切 tab——切 tab 会把下面那层卸载掉,滚动位置、
输入草稿、展开状态全丢。 */
@keyframes sheet-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: none; }
}
.sheet-in {
animation: sheet-in 0.22s cubic-bezier(0.32, 0.72, 0, 1);
}
/* 例句卡片依次入场。--i 是序号,错开 40ms 就够读出"一条条出现",
再多就开始等 */
@keyframes rise-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: none; }
}
.rise-in {
opacity: 0;
animation: rise-in 0.34s cubic-bezier(0.32, 0.72, 0, 1) forwards;
animation-delay: calc(var(--i, 0) * 40ms);
}
/* 正在流的那一步:一道光扫过文字。比转圈多说一件事——它在出字,不只是在等 */
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
.shimmer {
background: linear-gradient(
90deg,
var(--text-faint) 0%, var(--text-faint) 35%,
var(--text) 50%,
var(--text-faint) 65%, var(--text-faint) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: shimmer 2.4s linear infinite;
}
/* 空态那枚标记的呼吸感。很轻——它是在暗示"可以开始了",不是在要求注意力 */
@keyframes breathe {
0%, 100% { opacity: 0.55; transform: scale(1); }
50% { opacity: 1; transform: scale(1.06); }
}
.breathe {
animation: breathe 4s ease-in-out infinite;
}
/* 输入坞聚焦时的光晕。焦点是这一栏最重要的状态,值得比 1px 描边更明确 */
.dock-focus {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
border-color: var(--accent);
}
/* --------------------------------------------------------------------------
前庭敏感的人看这些动效会难受,而这套界面里动的东西不少(边在流动、节点在
脉冲、面板在滑)。系统里关掉动效的,这里就全部停掉——不是调慢,是停掉。
-------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
.rise-in { opacity: 1; }
.shimmer {
background: none;
-webkit-background-clip: border-box;
background-clip: border-box;
color: var(--text-dim);
}
}
/* 表单控件统一外观 */
.field {
width: 100%;
+21 -32
View File
@@ -6,12 +6,13 @@ import {
import clsx from 'clsx'
import { api } from '../api/client'
import { FlowCanvas } from '../canvas/FlowCanvas'
import { Inspector } from '../canvas/Inspector'
import { Palette } from '../canvas/Palette'
import { InspectorSheet } from '../canvas/InspectorSheet'
import { AssistantPanel } from '../run/AssistantPanel'
import { RunControl } from '../run/RunControl'
import { useStudio, toGraph } from '../store/studio'
import { useCatalog } from '../store/catalog'
import { Empty, Modal, Spinner, Tabs, useToast } from '../components/ui'
import { Empty, Modal, Spinner, useToast } from '../components/ui'
import type { Workflow } from '../types'
export function StudioPage() {
@@ -20,11 +21,9 @@ export function StudioPage() {
const workflow = useStudio((s) => s.workflow)
const dirty = useStudio((s) => s.dirty)
const issues = useStudio((s) => s.issues)
const selectedId = useStudio((s) => s.selectedId)
const streaming = useStudio((s) => s.streaming)
const { load, save, setGraph } = useStudio()
const { load, save, setGraph, select } = useStudio()
const [tab, setTab] = useState<'inspect' | 'run'>('run')
const [picker, setPicker] = useState(false)
const [saving, setSaving] = useState(false)
const [publishing, setPublishing] = useState(false)
@@ -35,14 +34,11 @@ export function StudioPage() {
if (!workflow && workflows.length) load(workflows[0])
}, [workflows, workflow, load])
// 选中节点时自动切到属性页,省一次点击
// 开始跑图或生成时,属性面板让开——不然进展发生在一块被盖住的地方。
// (选中节点即滑出属性面板,取消选中即收起,不再需要手动切 tab)
useEffect(() => {
if (selectedId) setTab('inspect')
}, [selectedId])
// 开始跑图或开始生成时切回助手栏,否则进展发生在一个看不见的 tab 里
useEffect(() => {
if (streaming || copilotActive) setTab('run')
}, [streaming, copilotActive])
if (streaming || copilotActive) select(null)
}, [streaming, copilotActive, select])
const doSave = useCallback(async () => {
if (!workflow) return
@@ -137,14 +133,13 @@ export function StudioPage() {
<div className="flex-1" />
{/* Copilot 不再是弹窗:它就在右栏里,这个按钮只负责把人送过去。
弹窗的问题是每改一次图都要重开一次,而且关掉之后需求文本就没了 */}
{/* Copilot 不再是弹窗:它就在右栏里,这个按钮只负责把焦点送过去。
弹窗的问题是每改一次图都要重开一次,关掉之后需求文本就没了 */}
<button
className="btn"
title="用自然语言生成或修改工作流"
onClick={() => {
setTab('run')
// 等这一帧渲染完输入框才在 DOM 里
select(null) // 属性面板盖着的话先让开
requestAnimationFrame(() =>
window.dispatchEvent(new Event('agentlab:focus-copilot')))
}}
@@ -156,9 +151,12 @@ export function StudioPage() {
title={dirty ? '先保存再发布' : '把当前版本立为正式版本,正式运行只认它'}>
<ShieldCheck size={12} /> 发布
</button>
<button className="btn btn-primary" onClick={doSave} disabled={saving || !dirty}>
<button className="btn" onClick={doSave} disabled={saving || !dirty}>
{saving ? <Spinner /> : <Save size={12} />} 保存
</button>
{/* 运行是对整张图的动作,和保存、发布同类,属于工具栏。放在助手栏里
会和 Copilot 输入框两个"主要动作"互相压着 */}
<RunControl />
</div>
{/* 三栏 */}
@@ -169,21 +167,12 @@ export function StudioPage() {
<main className="relative min-w-0 flex-1">
<FlowCanvas />
</main>
<aside className="flex w-[360px] shrink-0 flex-col border-l bg-panel">
<Tabs
tabs={[{ key: 'run', label: '助手' }, { key: 'inspect', label: '属性' }]}
active={tab}
onChange={(k) => setTab(k as any)}
/>
{/* 两个都挂着、用 hidden 切换,而不是三元卸载其中一个。
助手栏里有输入框草稿、展开状态、滚动位置——点一下"属性"看个
节点再切回来全没了,等于逼人别用属性页 */}
<div className={clsx('min-h-0 flex-1', tab !== 'run' && 'hidden')}>
<AssistantPanel />
</div>
<div className={clsx('min-h-0 flex-1 overflow-y-auto', tab !== 'inspect' && 'hidden')}>
<Inspector />
</div>
{/* 助手常驻,属性是盖在它上面的一层。做成两个 tab 的话它们就互斥了,
而这两件事在时间上并不互斥——跑图跑到一半点开节点看配置,整条
执行过程会从眼前消失,切回来滚动位置和输入草稿也没了 */}
<aside className="relative flex w-[360px] shrink-0 flex-col border-l bg-panel">
<AssistantPanel />
<InspectorSheet />
</aside>
</div>
+95 -253
View File
@@ -1,27 +1,24 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Code2, Send, Settings2, Sparkles, Square, Trash2 } from 'lucide-react'
import { useMemo, useRef, useState, useEffect } from 'react'
import { Code2, Eraser } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../api/client'
import { useCatalog, modelOptions } from '../store/catalog'
import { useCatalog } from '../store/catalog'
import { useStudio } from '../store/studio'
import { Spinner, useToast } from '../components/ui'
import { AssistantStream, StreamEmpty, type StreamTurn } from './AssistantStream'
import { Spinner } from '../components/ui'
import { AssistantStream, type StreamTurn } from './AssistantStream'
import { Composer } from './Composer'
import { decodeCopilot, decodeRun, formatDuration } from './decode'
import { ApprovalCard, RunLauncher } from './RunPanel'
import { ApprovalCard } from './RunPanel'
/**
* 画布右栏的助手。
* 画布右栏:助手。
*
* 取代原来的「运行 / 属性 → 时间线 / 成果 / 事件」两层嵌套(5 个视图,
* 每个只装一小块,找什么都得先猜在哪一层)。现在一栏一条流:你让它做什么、
* 它做了什么、结果是什么,按时间从上往下。
* 这一栏现在只有一件事——和 Copilot 说话,以及看它和运行做了什么。属性不再
* 和它平级挤在两个 tab 里:选中一个节点会滑出属性面板盖在上面,关掉就回到
* 原处。之前是切 tab,而 tab 是互斥的——跑图跑到一半点开一个节点看配置,
* 整条执行过程就从眼前消失了,回来还得重新找位置。
*
* 两件事在这里合流——Copilot 建图和跑图。它们在协议上完全无关(SSE 操作流
* vs 事件 WebSocket,不共享 seq 和 node_id),但对用户是同一件事的两半:
* "帮我搭一个能算月度毛利的流程" → "跑一下"。decode.ts 把两种流翻译成同一种
* Step,这里才能把它们排进同一条时间轴。
*
* 右栏不能删:它是发起运行和处理审批的唯一入口,RunLauncher 必须留着。
* 两种体量:没说过话时输入框是主体(竖直居中、带例句),有对话之后它退到
* 底部,上面让给过程。
*/
export function AssistantPanel() {
const run = useStudio((s) => s.run)
@@ -40,30 +37,28 @@ export function AssistantPanel() {
id: t.id,
question: t.instruction,
phase: t.phase === 'running' ? 'running' : t.phase === 'error' ? 'error' : 'done',
// 生成期间不能只给一个孤零零的转圈图标。具体阶段从 copilot 实时状态
// 里取——它只对当前这一轮有效,历史轮次用固定文案
status: t.phase === 'running'
? (copilot.lastOp || PHASE_TEXT[copilot.phase] || '正在生成流程…')
// 生成期间不能只给一个孤零零的转圈图标。具体阶段从 copilot 实时状态里取
status: t.phase === 'running' ? (copilot.lastOp || '正在生成流程…')
: t.phase === 'done' ? '流程已更新到画布' : '',
steps: decodeCopilot(t.ops),
output: t.explanation ? { 说明: t.explanation } : null,
error: t.error,
}))
if (run) {
const steps = decodeRun(events)
// run 对象是启动时的快照,成果在 run.finished 事件里
const finished = [...events].reverse().find((e) => e.type === 'run.finished')
const failedEvent = [...events].reverse().find((e) => e.type === 'run.failed')
const waiting = pending.length > 0
list.push({
id: run.id,
question: undefined, // 运行的"问题"是输入表单,已经在上面填过了
// 运行的"问题"是输入表单,已经在工具栏那边填过了
question: undefined,
phase: failedEvent ? 'error'
: waiting ? 'waiting'
: streaming ? 'running'
: finished ? 'done' : 'running',
status: streaming ? '正在执行…' : waiting ? '等待你的确认' : finished ? '完成' : '',
steps,
steps: decodeRun(events),
// run 对象是启动时的快照,成果在 run.finished 事件里
output: finished?.data?.output ?? run.output ?? null,
error: failedEvent ? String(failedEvent.data?.error ?? '运行失败') : run.error || undefined,
runId: run.id,
@@ -71,250 +66,97 @@ export function AssistantPanel() {
})
}
return list
}, [copilotTurns, run, events, streaming, pending.length, copilot.lastOp, copilot.phase])
}, [copilotTurns, run, events, streaming, pending.length, copilot.lastOp])
const empty = !turns.length
return (
<div className="flex h-full flex-col">
<RunLauncher />
{pending.map((approval) => (
<ApprovalCard key={approval.id} approval={approval}
onResolved={(runId) => attachRun(runId)} />
))}
<div className="min-h-0 flex-1">
{raw ? <RawEvents /> : (
<AssistantStream
turns={turns}
dense
empty={<PanelEmpty />}
/>
)}
</div>
<Composer />
<div className="flex items-center gap-2 border-t px-2.5 py-1 text-[10px] text-faint">
{run ? (
<>
<span className="mono truncate" title={run.id}>#{run.id.slice(0, 8)}</span>
{run.run_class === 'formal'
? <span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>正式 v{run.version}</span>
: <span className="chip">探索</span>}
{streaming && <span className="text-[var(--accent)]">● 实时</span>}
<span className="flex-1" />
{!!run.usage?.total_tokens && <span>{run.usage.total_tokens} tok</span>}
{run.usage?.cost_usd ? <span>${Number(run.usage.cost_usd).toFixed(4)}</span> : null}
{run.usage?.duration_ms ? <span>{formatDuration(run.usage.duration_ms)}</span> : null}
</>
) : <span className="flex-1" />}
{!!copilotTurns.length && !raw && (
<button className="hover:text-dim" title="清空助手记录(不影响画布)"
onClick={clearCopilot}>
<Trash2 size={11} />
</button>
)}
{/* 原始事件是排查用的开发者视图,不该占一个常驻 tab——但也不能删,
翻译层出问题时它是唯一能对照的东西 */}
<button
className={clsx('flex items-center gap-1 hover:text-dim', raw && 'text-[var(--accent)]')}
title={raw ? '回到助手视图' : `看原始事件(${events.length} 条)`}
onClick={() => setRaw((v) => !v)}
>
<Code2 size={11} />
</button>
</div>
</div>
)
}
function PanelEmpty() {
const nodes = useStudio((s) => s.nodes)
return (
<StreamEmpty
icon={<Sparkles size={20} />}
title={nodes.length ? '还没有动静' : '从一句话开始'}
hint={nodes.length
? '上面填好输入点「试运行」,或者在下面告诉它要改什么'
: '在下面描述你要的流程,它会直接画到左边的画布上'}
/>
)
}
// -------------------------------------------------------------------------
const EXAMPLES = [
'读取用户上传的问题,先查知识库,查到就基于资料回答并标注出处,查不到就联网搜索',
'把一段长文本拆成要点,逐条用模型打分,低分的让模型重写一次,最后汇总成表格',
'写代码分析数据,在沙箱里跑,出错就把报错喂回去让模型修,最多修三次',
]
/**
* Copilot 输入框。
*
* 原来是个 Modal:点按钮 → 弹窗 → 输入 → 关闭弹窗 → 盯着画布上的浮条看。
* 每改一次图都要重走一遍,而且弹窗关掉之后需求文本就没了,想微调只能重打。
* 放进侧栏之后它就是个普通输入框,说完一句接着说下一句。
*/
function Composer() {
const nodes = useStudio((s) => s.nodes)
const copilot = useStudio((s) => s.copilot)
const { runCopilot, stopCopilot, retryCopilot } = useStudio()
const providers = useCatalog((s) => s.providers)
const [text, setText] = useState('')
const [opts, setOpts] = useState(false)
const [model, setModel] = useState('')
const [effective, setEffective] = useState('')
const [useBase, setUseBase] = useState(true)
const ref = useRef<HTMLTextAreaElement>(null)
// Copilot 的模型是独立设置:它写的是编排本身,值得和工作流节点分开选
useEffect(() => {
void api.copilot.getModel().then((m) => {
setModel(m.model ?? '')
setEffective(m.effective_model ?? '')
}).catch(() => undefined)
}, [])
// 工具栏的 Copilot 按钮把焦点甩过来
useEffect(() => {
const focus = () => ref.current?.focus()
window.addEventListener('agentlab:focus-copilot', focus)
return () => window.removeEventListener('agentlab:focus-copilot', focus)
}, [])
const pickModel = (value: string) => {
setModel(value)
void api.copilot.setModel({ model: value || null })
.then((m) => setEffective(m.effective_model ?? ''))
.catch(() => undefined)
}
const send = () => {
const q = text.trim()
if (!q || copilot.active) return
runCopilot(q, useBase && nodes.length > 0, model || undefined)
setText('')
}
const options = modelOptions(providers)
const groups = [...new Set(options.map((o) => o.group))]
return (
<div className="shrink-0 border-t">
{copilot.error && (
<div className="flex items-center gap-2 border-b px-2.5 py-1.5 text-[10.5px]"
style={{ color: 'var(--err)' }}>
<span className="min-w-0 flex-1">{copilot.error}</span>
{/* 失败多半跟需求本身无关(模型抽风、协议跑偏、网断了),
不该逼用户把需求再敲一遍 */}
{copilot.lastInstruction && (
<button className="btn btn-sm shrink-0" onClick={retryCopilot}>重试</button>
{!empty && (
<div className="flex shrink-0 items-center gap-2 border-b px-2.5 py-1.5">
<span className="text-[11px] font-semibold">助手</span>
{streaming && (
<span className="flex items-center gap-1 text-[10px]" style={{ color: 'var(--accent)' }}>
<Spinner size={9} /> 执行中
</span>
)}
</div>
)}
{!nodes.length && !copilot.active && (
<div className="flex flex-wrap gap-1 border-b px-2.5 py-1.5">
{EXAMPLES.map((ex) => (
<button key={ex} className="chip text-left hover:border-[var(--accent)]"
title={ex} onClick={() => { setText(ex); ref.current?.focus() }}>
{ex.slice(0, 16)}…
<span className="flex-1" />
{run && (
<span className="mono text-[9.5px] text-faint" title={run.id}>
#{run.id.slice(0, 6)}
</span>
)}
{/* 原始事件是排查用的开发者视图,不该占一个常驻 tab——但也不能删,
翻译层出问题时它是唯一能对照的东西 */}
<button
className={clsx('rounded p-1 transition-colors hover:bg-hover',
raw ? 'text-[var(--accent)]' : 'text-faint')}
title={raw ? '回到助手视图' : `看原始事件(${events.length} 条)`}
onClick={() => setRaw((v) => !v)}
>
<Code2 size={12} />
</button>
{!!copilotTurns.length && (
<button className="rounded p-1 text-faint transition-colors hover:bg-hover"
title="清空助手记录(不影响画布)" onClick={clearCopilot}>
<Eraser size={12} />
</button>
))}
)}
</div>
)}
{opts && (
<div className="space-y-2 border-b px-2.5 py-2">
<div>
<label className="label">Copilot 用哪个模型</label>
<select className="field" value={model} onChange={(e) => pickModel(e.target.value)}>
<option value="">跟随默认{effective ? `(当前:${effective})` : ''}</option>
{groups.map((g) => (
<optgroup key={g} label={g}>
{options.filter((o) => o.group === g).map((o) => (
<option key={g + o.value} value={o.value}>{o.label}</option>
))}
</optgroup>
))}
</select>
<div className="mt-1 text-[10px] leading-snug text-faint">
只影响 Copilot 自己,不改节点上的模型。它要按协议逐行输出操作,
指令遵循弱的模型生成不出东西。
</div>
{empty ? (
// 没说过话时输入框就是这一栏的主体,竖直居中。之前它是个 38px 高的
// 框挤在最底下,比上面任何一块都不起眼——"没地方引导写问题"说的就是它
<div className="flex min-h-0 flex-1 flex-col justify-center overflow-y-auto py-6">
<Composer hero />
</div>
) : (
<>
<div className="min-h-0 flex-1">
{raw ? <RawEvents /> : (
<AssistantStream
turns={turns}
dense
// 审批卡插在它所属的那一轮里,而不是浮在整栏顶上:
// "等你确认"和确认的按钮之间隔着整条执行过程,是两件事
approvalsFor={(t) => (
t.runId && pending.length ? (
<div className="fade-up mt-2 overflow-hidden rounded-lg border"
style={{ borderColor: 'var(--warn)' }}>
{pending.map((a) => (
<ApprovalCard key={a.id} approval={a}
onResolved={(runId) => attachRun(runId)} />
))}
</div>
) : null
)}
/>
)}
</div>
{!!nodes.length && (
<label className="flex items-center gap-2 text-[11px]">
<input type="checkbox" checked={useBase} className="accent-[var(--accent)]"
onChange={(e) => setUseBase(e.target.checked)} />
在当前这张图上改(不勾就重新生成)
</label>
)}
</div>
)}
<div className="flex items-end gap-1.5 p-2">
<button className={clsx('btn btn-sm btn-ghost shrink-0', opts && 'text-[var(--accent)]')}
title="模型和生成方式" onClick={() => setOpts((v) => !v)}>
<Settings2 size={12} />
</button>
<textarea
ref={ref}
className="field min-h-[38px] flex-1 resize-none py-1.5 text-[11.5px]"
rows={2}
value={text}
placeholder={nodes.length ? '告诉它要改什么…' : '描述你要的流程…'}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
}}
/>
{copilot.active ? (
<button className="btn btn-sm btn-danger shrink-0" onClick={stopCopilot} title="取消生成">
<Square size={11} />
</button>
) : (
<button className="btn btn-sm btn-primary shrink-0" onClick={send}
disabled={!text.trim()} title="生成到画布(Enter)">
<Send size={11} />
</button>
)}
</div>
{copilot.active && (
<div className="flex items-center gap-1.5 border-t px-2.5 py-1 text-[10px] text-faint">
<Spinner size={10} />
<span className="min-w-0 flex-1 truncate">
{copilot.lastOp || PHASE_TEXT[copilot.phase] || '正在起草…'}
</span>
{copilot.model && <span className="truncate">{copilot.model}</span>}
{copilot.elapsedMs > 0 && (
<span className="mono">{Math.round(copilot.elapsedMs / 1000)}s</span>
)}
</div>
)}
{!copilot.active && !!copilot.explanation && (
<div className="border-t px-2.5 py-1 text-[10px] text-faint">
结果不会自动保存,检查无误记得 ⌘S
</div>
<Composer hero={false} />
{run && <RunFooter />}
</>
)}
</div>
)
}
// 从提交到第一个节点落地中间有 5~30 秒。阶段会变本身就是"它还活着"的信号,
// 恒定的"正在起草…"让人分不清是在想还是已经卡死
const PHASE_TEXT: Record<string, string> = {
connecting: '正在连接模型…',
planning: '正在理解需求、规划结构…',
building: '正在放置节点…',
wiring: '正在连接数据流…',
finalizing: '正在排版和校验…',
function RunFooter() {
const run = useStudio((s) => s.run)!
return (
<div className="flex shrink-0 items-center gap-2 border-t px-2.5 py-1 text-[9.5px] text-faint">
{run.run_class === 'formal'
? <span className="chip" style={{ color: 'var(--ok)', borderColor: 'var(--ok)' }}>正式 v{run.version}</span>
: <span className="chip">探索</span>}
<span className="flex-1" />
{!!run.usage?.total_tokens && <span>{run.usage.total_tokens} tok</span>}
{run.usage?.cost_usd ? <span>${Number(run.usage.cost_usd).toFixed(4)}</span> : null}
{run.usage?.duration_ms ? <span>{formatDuration(run.usage.duration_ms)}</span> : null}
</div>
)
}
// -------------------------------------------------------------------------
/** 原始事件。翻译层出问题时用来对照,平时收着。 */
function RawEvents() {
const events = useStudio((s) => s.events)
+7 -2
View File
@@ -189,8 +189,11 @@ function StepRow({ step, dense, depth }: { step: Step; dense: boolean; depth: nu
// 失败要压过 kind:一条 lifecycle 跑挂了还画成打勾的"完成"图标,
// 是把失败说成了成功
const failed = step.status === 'failed' || step.level === 'error'
const Glyph = failed ? XCircle : Icon
// "等你确认"不是"正在忙"。转圈说的是"你等着",而这里正相反
const waiting = step.status === 'waiting'
const Glyph = failed ? XCircle : waiting ? Hand : Icon
const color = failed ? 'var(--err)'
: waiting ? 'var(--warn)'
: step.level === 'warn' ? 'var(--warn)'
: step.kind === 'think' ? 'var(--text-faint)'
: step.status === 'running' ? 'var(--accent)'
@@ -214,7 +217,9 @@ function StepRow({ step, dense, depth }: { step: Step; dense: boolean; depth: nu
</span>
<span className={clsx('min-w-0 flex-1 leading-relaxed',
dense ? 'text-[11px]' : 'text-[11.5px]',
step.kind === 'think' && 'italic')}>
step.kind === 'think' && 'italic',
// 一道光扫过文字。比转圈多说一件事——它在出东西,不只是在等
step.status === 'running' && !step.children?.length && 'shimmer')}>
{step.title}
</span>
{step.meta && (
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useRef, useState } from 'react'
import { ArrowUp, Database, Settings2, Sparkles, Square, Wrench } from 'lucide-react'
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'
/**
* Copilot 的输入。
*
* 一个组件两种体量,而不是两个组件:
*
* - **hero**:还没说过话的时候,它就是这一栏的主体,竖直居中、三行高、自动
* 聚焦,配上例句。这时候"写点什么"是唯一该做的事,输入框就该是最大的东西。
* 之前它是一个 38px 高的框挤在长长的流底下,比它上面任何一块都不起眼——
* 用户说"没有一个很好的地方引导写问题",说的就是这个。
* - **dock**:一旦有了对话,注意力转移到上面的过程上,它退到底部两行。
*
* 不做成两个组件的原因和 AssistantStream 一样:两个组件就是两个实例,切换时
* React 会卸载其中一个,草稿、光标、输入法编辑中的候选词全没。
*/
export function Composer({ hero, onFocusChange }: {
hero: boolean
onFocusChange?: (focused: boolean) => void
}) {
const nodes = useStudio((s) => s.nodes)
const copilot = useStudio((s) => s.copilot)
const { runCopilot, stopCopilot, retryCopilot } = useStudio()
const providers = useCatalog((s) => s.providers)
const tools = useCatalog((s) => s.tools)
const [text, setText] = useState('')
const [opts, setOpts] = useState(false)
const [focused, setFocused] = useState(false)
const [model, setModel] = useState('')
const [effective, setEffective] = useState('')
const [useBase, setUseBase] = useState(true)
const [sources, setSources] = useState<any[]>([])
const ref = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
void api.copilot.getModel().then((m) => {
setModel(m.model ?? '')
setEffective(m.effective_model ?? '')
}).catch(() => undefined)
void api.datasources.list().then(setSources).catch(() => undefined)
}, [])
// 工具栏的 Copilot 按钮把焦点甩过来
useEffect(() => {
const focus = () => ref.current?.focus()
window.addEventListener('agentlab:focus-copilot', focus)
return () => window.removeEventListener('agentlab:focus-copilot', focus)
}, [])
// hero 态就是"请开始",不该还要求用户先点一下
useEffect(() => {
if (hero) ref.current?.focus()
}, [hero])
const pickModel = (value: string) => {
setModel(value)
void api.copilot.setModel({ model: value || null })
.then((m) => setEffective(m.effective_model ?? ''))
.catch(() => undefined)
}
const send = (override?: string) => {
const q = (override ?? text).trim()
if (!q || copilot.active) return
runCopilot(q, useBase && nodes.length > 0, model || undefined)
setText('')
}
const options = modelOptions(providers)
const groups = [...new Set(options.map((o) => o.group))]
const examples = hero ? exampleFor(nodes.length, sources) : []
return (
<div className={clsx('shrink-0', hero ? 'px-4' : 'border-t px-2.5 pb-2.5 pt-2')}>
{hero && <HeroHeader sources={sources} toolCount={tools?.length ?? 0} />}
{copilot.error && (
<div className="fade-up mb-2 flex items-center gap-2 rounded-lg border px-2.5 py-1.5 text-[10.5px]"
style={{ borderColor: 'var(--err)', color: 'var(--err)' }}>
<span className="min-w-0 flex-1 leading-relaxed">{copilot.error}</span>
{/* 失败多半跟需求本身无关(模型抽风、协议跑偏、网断了),
不该逼用户把需求再敲一遍 */}
{copilot.lastInstruction && (
<button className="btn btn-sm shrink-0" onClick={retryCopilot}>重试</button>
)}
</div>
)}
{/* 输入坞。整块是一个框,输入区和操作条在里面——比"输入框 + 旁边三个按钮"
更像一个可以对它说话的地方 */}
<div
className={clsx(
'rounded-xl border bg-bg transition-[box-shadow,border-color] duration-150',
focused && 'dock-focus',
)}
>
<textarea
ref={ref}
className="w-full resize-none bg-transparent px-3 pt-2.5 text-[12.5px] leading-relaxed outline-none placeholder:text-faint"
rows={hero ? 3 : 2}
value={text}
placeholder={nodes.length
? '告诉它这张图要改成什么样…'
: '描述你想要的流程,它会直接画到左边'}
onChange={(e) => setText(e.target.value)}
onFocus={() => { setFocused(true); onFocusChange?.(true) }}
onBlur={() => { setFocused(false); onFocusChange?.(false) }}
onKeyDown={(e) => {
// 输入法组字期间的回车是"选词",不是"发送"。不判 isComposing 的话
// 中文用户每打一个词就发出去一次
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault()
send()
}
}}
/>
<div className="flex items-center gap-1 px-2 pb-2">
<button
className={clsx('rounded-md p-1.5 transition-colors hover:bg-hover',
opts ? 'text-[var(--accent)]' : 'text-faint')}
title="模型和生成方式"
onClick={() => setOpts((v) => !v)}
>
<Settings2 size={13} />
</button>
{!!nodes.length && (
<button
className={clsx('rounded-md px-1.5 py-1 text-[10px] transition-colors hover:bg-hover',
useBase ? 'text-dim' : 'text-faint')}
title={useBase ? '在当前这张图上改' : '不看现有的图,重新生成一张'}
onClick={() => setUseBase((v) => !v)}
>
{useBase ? '改现有的图' : '重新生成'}
</button>
)}
<span className="flex-1" />
<span className="pr-1 text-[9.5px] text-faint">
{text.trim() ? '⏎ 发送 · ⇧⏎ 换行' : ''}
</span>
{copilot.active ? (
<button
className="flex h-7 w-7 items-center justify-center rounded-lg transition-colors"
style={{ background: 'var(--err)', color: '#fff' }}
title="停止生成" onClick={stopCopilot}
>
<Square size={11} fill="currentColor" />
</button>
) : (
<button
className="flex h-7 w-7 items-center justify-center rounded-lg transition-all duration-150 disabled:opacity-30"
style={{
background: text.trim() ? 'var(--accent)' : 'var(--bg-hover)',
color: text.trim() ? '#fff' : 'var(--text-faint)',
}}
disabled={!text.trim()}
title="发送(⏎)"
onClick={() => send()}
>
<ArrowUp size={14} />
</button>
)}
</div>
</div>
{opts && (
<div className="fade-up mt-2 space-y-2 rounded-lg border bg-panel p-2.5">
<div>
<label className="label">Copilot 用哪个模型</label>
<select className="field" value={model} onChange={(e) => pickModel(e.target.value)}>
<option value="">跟随默认{effective ? `(当前:${effective})` : ''}</option>
{groups.map((g) => (
<optgroup key={g} label={g}>
{options.filter((o) => o.group === g).map((o) => (
<option key={g + o.value} value={o.value}>{o.label}</option>
))}
</optgroup>
))}
</select>
<div className="mt-1 text-[10px] leading-snug text-faint">
只影响 Copilot 自己,不改节点上的模型。它要按协议逐行输出操作,
指令遵循弱的模型生成不出东西。
</div>
</div>
</div>
)}
{copilot.active && (
<div className="fade-up mt-2 flex items-center gap-1.5 text-[10px] text-faint">
<Spinner size={10} />
<span className="min-w-0 flex-1 truncate">
{copilot.lastOp || PHASE_TEXT[copilot.phase] || '正在起草…'}
</span>
{copilot.model && <span className="truncate">{copilot.model}</span>}
{copilot.elapsedMs > 0 && (
<span className="mono">{Math.round(copilot.elapsedMs / 1000)}s</span>
)}
</div>
)}
{!!examples.length && (
<div className="mt-3 space-y-1.5">
{examples.map((ex, i) => (
<button
key={ex}
className="rise-in w-full rounded-lg border bg-panel px-3 py-2 text-left text-[11.5px] leading-relaxed text-dim transition-colors hover:border-[var(--accent)] hover:text-fg"
style={{ '--i': i + 1 } as any}
onClick={() => send(ex)}
>
{ex}
</button>
))}
</div>
)}
</div>
)
}
/**
* hero 态的抬头。
*
* 不只是一句标语:它要回答"我能让它干什么"。Copilot 能接到的数据源和工具
* 数量摆在这里,用户才知道"查销售库"这种话是有意义的——否则只能猜。
*/
function HeroHeader({ sources, toolCount }: { sources: any[]; toolCount: number }) {
return (
<div className="mb-4 text-center">
<div className="breathe mx-auto mb-2.5 flex h-10 w-10 items-center justify-center rounded-2xl"
style={{ background: 'color-mix(in srgb, var(--accent) 14%, transparent)' }}>
<Sparkles size={18} style={{ color: 'var(--accent)' }} />
</div>
<div className="text-[13.5px] font-semibold">想让它做什么?</div>
<div className="mt-1 text-[11px] leading-relaxed text-faint">
说一句话,它把流程画到左边的画布上
</div>
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-faint">
<span className="flex items-center gap-1">
<Database size={10} />
{sources.length ? `${sources.length} 个数据源` : '未接数据源'}
</span>
<span className="flex items-center gap-1"><Wrench size={10} />{toolCount} 个工具</span>
</div>
</div>
)
}
/**
* 例句。
*
* 用完整句子而不是截断成 16 个字的 chip——例句的作用是示范"说到什么颗粒度
* 才够",截断之后这个作用就没了,只剩装饰。
*
* 有数据源就用真实的库名造句:用户一眼看到的是自己的数据,而不是别人的示例。
*/
function exampleFor(nodeCount: number, sources: any[]): string[] {
if (nodeCount) {
return [
'在最后加一步人工审核,通过了才输出',
'中间那步改成 agent,让它自己决定要不要查数据库',
'出错的时候重试两次,还不行就走另一条分支',
]
}
const name = sources[0]?.name
return [
name
? `从 ${name} 里取上季度的数据,算出同比,写一段中文分析`
: '读取用户的问题,先查知识库,查到就基于资料回答并标注出处,查不到就联网搜索',
'把一段长文本拆成要点,逐条用模型打分,低分的让模型重写一次,最后汇总成表格',
'写代码分析数据,在沙箱里跑,出错就把报错喂回去让模型修,最多修三次',
]
}
// 从提交到第一个节点落地中间有 5~30 秒。阶段会变本身就是"它还活着"的信号,
// 恒定的"正在起草…"让人分不清是在想还是已经卡死
export const PHASE_TEXT: Record<string, string> = {
connecting: '正在连接模型…',
planning: '正在理解需求、规划结构…',
building: '正在放置节点…',
wiring: '正在连接数据流…',
finalizing: '正在排版和校验…',
}
+172
View File
@@ -0,0 +1,172 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, Play, ShieldCheck, Square } from 'lucide-react'
import { useStudio } from '../store/studio'
import { Spinner, useToast } from '../components/ui'
/**
* 工具栏上的运行控件。
*
* 从右栏顶部搬上来的。原先它和 Copilot 输入框一起常驻在助手栏里,两个都是
* "主要动作",互相压着——而它们其实是两种不同的意图(改这张图 / 跑这张图),
* 不该争同一块地方。运行是对整个文档的动作,和保存、发布是一类,属于工具栏。
*
* 有输入字段就弹出表单填了再跑;没有字段就一键直接跑,不为了统一而多一次点击。
*/
export function RunControl() {
const nodes = useStudio((s) => s.nodes)
const run = useStudio((s) => s.run)
const streaming = useStudio((s) => s.streaming)
const issues = useStudio((s) => s.issues)
const workflow = useStudio((s) => s.workflow)
const dirty = useStudio((s) => s.dirty)
const { startRun, startFormalRun, stopRun } = useStudio()
const toast = useToast()
const [open, setOpen] = useState(false)
const [values, setValues] = useState<Record<string, string>>({})
const [busy, setBusy] = useState(false)
const wrap = useRef<HTMLDivElement>(null)
const inputFields = useMemo(() => {
const entry = nodes.find((n) => n.data.nodeType === 'input')
return (entry?.data.config?.fields ?? []) as any[]
}, [nodes])
const errors = issues.filter((i) => i.level === 'error')
const canFormal = !!workflow
&& (workflow.status === 'published' || workflow.status === 'governed')
&& !!workflow.published_version
const running = streaming || run?.status === 'running'
// 点外面关掉。浮层不能只靠再点一次按钮关——那是很容易漏掉的死路
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent) => {
if (!wrap.current?.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
window.addEventListener('mousedown', onDown)
window.addEventListener('keydown', onKey)
return () => {
window.removeEventListener('mousedown', onDown)
window.removeEventListener('keydown', onKey)
}
}, [open])
const launch = async (formal = false) => {
setBusy(true)
try {
const payload: Record<string, any> = {}
for (const field of inputFields) {
if (!field.name) continue
const raw = values[field.name] ?? field.default ?? ''
// 看起来像 JSON 就按 JSON 传,让数组/对象类型的输入能用
if (typeof raw === 'string' && /^\s*[[{]/.test(raw)) {
try { payload[field.name] = JSON.parse(raw) } catch { payload[field.name] = raw }
} else {
payload[field.name] = raw
}
}
if (formal) await startFormalRun(payload)
else await startRun(payload)
setOpen(false)
} catch (e: any) {
toast(e.message ?? '启动失败', 'error')
} finally {
setBusy(false)
}
}
if (running) {
return (
<button className="btn btn-danger" onClick={stopRun}>
<Square size={11} fill="currentColor" /> 停止
</button>
)
}
const blocked = !!errors.length || !nodes.length
return (
<div className="relative" ref={wrap}>
<button
className="btn btn-primary gap-1.5"
disabled={busy || blocked}
title={blocked
? (errors.length ? `${errors.length} 个问题会阻止运行:${errors[0].message}` : '画布是空的')
: '用画布当前内容跑一次,结果标探索性'}
onClick={() => {
// 没有输入字段就没什么可填的,多弹一层只是多一次点击
if (!inputFields.length && !canFormal) void launch(false)
else setOpen((v) => !v)
}}
>
{busy ? <Spinner size={11} /> : <Play size={11} fill="currentColor" />}
运行
{(inputFields.length > 0 || canFormal) && (
<ChevronDown size={10} className="opacity-60 transition-transform"
style={{ transform: open ? 'rotate(180deg)' : 'none' }} />
)}
</button>
{open && (
<div className="sheet-in absolute right-0 top-full z-50 mt-1.5 w-[340px] rounded-xl border bg-panel p-3 shadow-xl">
{inputFields.map((field) => (
<div key={field.name} className="mb-2.5">
<label className="label">
{field.name}
{field.required && <span className="ml-1 text-[var(--err)]">*</span>}
{field.description && (
<span className="ml-1.5 font-normal text-faint">{field.description}</span>
)}
</label>
<textarea
className="field"
rows={2}
autoFocus={field === inputFields[0]}
value={values[field.name] ?? (typeof field.default === 'string'
? field.default
: field.default ? JSON.stringify(field.default) : '')}
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)
}}
/>
</div>
))}
{!inputFields.length && (
<div className="mb-2.5 text-[11px] text-faint">这张图没有输入节点,将直接运行</div>
)}
<div className="flex gap-2">
<button className="btn btn-primary flex-1 justify-center" disabled={busy}
onClick={() => launch(false)}>
<Play size={11} fill="currentColor" /> 试运行
</button>
{canFormal && (
<button
className="btn flex-1 justify-center"
style={{ borderColor: 'var(--ok)', color: 'var(--ok)' }}
disabled={busy || dirty}
title={dirty
? '有未保存改动,正式运行只跑已发布版本'
: `从已发布的 v${workflow?.published_version} 不可变版本发起`}
onClick={() => launch(true)}
>
<ShieldCheck size={11} /> 正式 v{workflow?.published_version}
</button>
)}
</div>
{canFormal && dirty && (
<div className="mt-1.5 text-[10px] leading-relaxed text-faint">
画布有未保存改动;正式运行永远执行已发布的不可变版本
</div>
)}
{!!inputFields.length && (
<div className="mt-1.5 text-[10px] text-faint">⌘⏎ 直接运行</div>
)}
</div>
)}
</div>
)
}
+6 -143
View File
@@ -1,152 +1,15 @@
import { useEffect, useMemo, useState } from 'react'
import {
Ban, Check, ChevronRight, Hand, Play, ShieldCheck, Square, Wrench,
} from 'lucide-react'
import { useState } from 'react'
import { Ban, Check, Hand, Wrench } from 'lucide-react'
import { api } from '../api/client'
import { useStudio } from '../store/studio'
import { useCatalog } from '../store/catalog'
import { NODE_DEFS } from '../canvas/nodeDefs'
import { StatusDot, useToast } from '../components/ui'
/**
* 发起运行。
*
* 这是整个界面里**唯一**能发起运行的地方,不能因为改版就顺手删掉——一起没掉的
* 还有审批入口,那会让卡在人工介入上的运行在界面里彻底没有出口。
*
* 跑起来之后自动收起:这时候用户要看的是执行到哪了,不是再填一遍输入。
*/
export function RunLauncher() {
const nodes = useStudio((s) => s.nodes)
const run = useStudio((s) => s.run)
const streaming = useStudio((s) => s.streaming)
const issues = useStudio((s) => s.issues)
const workflow = useStudio((s) => s.workflow)
const dirty = useStudio((s) => s.dirty)
const { startRun, startFormalRun, stopRun } = useStudio()
const canFormal = !!workflow && (workflow.status === 'published' || workflow.status === 'governed')
&& !!workflow.published_version
const toast = useToast()
const [values, setValues] = useState<Record<string, string>>({})
const [busy, setBusy] = useState(false)
const [open, setOpen] = useState(true)
// 入口节点声明了什么字段,这里就渲染什么表单
const inputFields = useMemo(() => {
const entry = nodes.find((n) => n.data.nodeType === 'input')
return (entry?.data.config?.fields ?? []) as any[]
}, [nodes])
// 跑起来就让位给执行过程;停下来再自己展开
useEffect(() => { if (streaming) setOpen(false) }, [streaming])
const errors = issues.filter((i) => i.level === 'error')
const launch = async (formal = false) => {
setBusy(true)
try {
const payload: Record<string, any> = {}
for (const field of inputFields) {
if (!field.name) continue
const raw = values[field.name] ?? field.default ?? ''
// 看起来像 JSON 就按 JSON 传,让数组/对象类型的输入能用
if (typeof raw === 'string' && /^\s*[[{]/.test(raw)) {
try { payload[field.name] = JSON.parse(raw) } catch { payload[field.name] = raw }
} else {
payload[field.name] = raw
}
}
if (formal) await startFormalRun(payload)
else await startRun(payload)
} catch (e: any) {
toast(e.message ?? '启动失败', 'error')
} finally {
setBusy(false)
}
}
const running = streaming || run?.status === 'running'
return (
<div className="shrink-0 border-b p-2.5">
{!!inputFields.length && (
<button className="mb-1.5 flex w-full 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' }} />
输入({inputFields.length} 项)
{!open && (
<span className="ml-1 min-w-0 flex-1 truncate text-left">
{inputFields.map((f) => values[f.name] ?? f.default ?? '').filter(Boolean).join(' · ')}
</span>
)}
</button>
)}
{open && inputFields.map((field) => (
<div key={field.name} className="mb-2">
<label className="label">
{field.name}
{field.required && <span className="ml-1 text-[var(--err)]">*</span>}
{field.description && <span className="ml-1.5 font-normal text-faint">{field.description}</span>}
</label>
<textarea
className="field"
rows={2}
value={values[field.name] ?? (typeof field.default === 'string' ? field.default : field.default ? JSON.stringify(field.default) : '')}
placeholder={field.required ? '必填' : '可留空'}
onChange={(e) => setValues({ ...values, [field.name]: e.target.value })}
/>
</div>
))}
{!inputFields.length && !!nodes.length && (
<div className="mb-2 text-[10.5px] text-faint">这张图没有输入节点,将直接运行</div>
)}
{!!errors.length && (
<div className="mb-2 rounded border px-2 py-1.5 text-[10.5px] leading-snug"
style={{ borderColor: 'var(--err)', color: 'var(--err)' }}>
{errors.length} 个问题会阻止运行:{errors[0].message}
</div>
)}
<div className="flex gap-2">
{running ? (
<button className="btn btn-danger flex-1 justify-center" onClick={stopRun}>
<Square size={12} /> 停止
</button>
) : (
<>
<button
className="btn btn-primary flex-1 justify-center"
onClick={() => launch(false)}
disabled={busy || !!errors.length || !nodes.length}
title="用画布当前内容跑,结果标探索性"
>
<Play size={12} /> 试运行
</button>
{canFormal && (
<button
className="btn flex-1 justify-center"
style={{ borderColor: 'var(--ok)', color: 'var(--ok)' }}
onClick={() => launch(true)}
disabled={busy || dirty}
title={dirty ? '有未保存改动,正式运行只跑已发布版本' : `从已发布的 v${workflow?.published_version} 不可变版本发起`}
>
<ShieldCheck size={12} /> 正式运行 v{workflow?.published_version}
</button>
)}
</>
)}
</div>
{canFormal && dirty && (
<div className="mt-1.5 text-[10px] text-faint">画布有未保存改动;正式运行永远执行已发布的不可变版本</div>
)}
</div>
)
}
// -------------------------------------------------------------------------
// 发起运行的表单搬去了 run/RunControl.tsx 的工具栏控件里。它原先常驻在
// 助手栏顶部,和 Copilot 输入框两个"主要动作"互相压着——而它们是两种不同的
// 意图(跑这张图 / 改这张图),不该争同一块地方。运行是对整张图的动作,
// 和保存、发布同类,属于工具栏。
/**
* 人工介入卡片。
+21 -6
View File
@@ -315,24 +315,34 @@ export function decodeRun(events: RunEvent[]): Step[] {
return true
}
/** 上一条就是恢复事件——用来认出紧随其后的那条重复的"继续执行" */
let justResumed = false
for (const event of events) {
const type = String(event.type)
if (EPHEMERAL.has(type) || SILENT.has(type)) continue
const d: any = event.data ?? {}
const seq = event.seq ?? 0
const nodeId = event.node_id ?? undefined
// 每条事件都会把"刚恢复过"清掉;只有恢复分支会重新点亮它,
// 所以这个标记只在紧挨着的下一条上为真
const wasJustResumed = justResumed
justResumed = false
switch (type) {
case 'run.started':
case 'run.resumed': {
const resumed = type === 'run.resumed' || !!d.resumed
// 恢复一次会同时来 run.resumed 和 run.started(resumed=true),
// 说两遍"继续执行"只会让人以为恢复了两次
if (resumed && out.some((s) => s.kind === 'lifecycle' && s.title === '继续执行')
&& !out.some((s) => s.status === 'waiting')) {
break
}
// 恢复一次会紧挨着发两条:run.resumed 和 run.started(resumed=true)。
// 说两遍"继续执行"会让人以为恢复了两次。
//
// 判据是"这两条是不是紧挨着的",不是"界面上还有没有待办"——后者
// 曾经能用,但只要多一处会产生 waiting 状态的地方(比如把被中断的
// 节点也标成等待),它就失效了。相邻性是这两条事件的固有性质,
// 不会因为别处改了展示而变。
if (resumed && wasJustResumed) break
if (resumed) {
justResumed = true
// 人已经答过了,那条不该再是橙色的"等你确认"。但配对关系要留着:
// 紧接着 LangGraph 会重放该节点,又发一条一模一样的
// human.requested,配对还开着才能认出那是重放而不是新一轮。
@@ -349,6 +359,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
}
case 'node.started': {
// 恢复后节点重放,它从"等你"回到"在跑"(下面会复用同一条 Step)
// data.label 是节点标题**随事件传递的唯一来源**。靠画布 nodes 反查的话,
// 助手栏和运行页没加载画布,标题会退化成裸 node_id。
// label 是节点标题随事件传递的唯一来源,但没起过名字的节点它等于
@@ -537,6 +548,10 @@ export function decodeRun(events: RunEvent[]): Step[] {
}
openInterrupts.set(key, step)
push(step, nodeId)
// 承载它的那个节点也不是"在跑"——它停下来等人了。转着蓝圈说的是
// "在忙,你等着",而实际情况正相反:它在等你
const host = nodeId ? nodeSteps.get(nodeId) : undefined
if (host && host.status === 'running') host.status = 'waiting'
break
}
+20
View File
@@ -92,6 +92,26 @@ console.log('\n=== 人工审批(含重放)===')
!all.some((s) => s.status === 'done' && s.level === 'warn'))
}
console.log('\n=== 停在审批时的状态 ===')
{
// 只喂到中断为止,模拟"运行正卡在人工介入上"这一刻
const upto = fixtures.human.slice(0, fixtures.human.findIndex((e) => e.type === 'run.interrupted') + 1)
const all = flatten(mod.decodeRun(upto))
const host = all.find((s) => s.kind === 'node' && s.nodeId === 'h')
// 转圈说的是"它在忙,你等着",而实际情况正相反:它在等你。
// 两种状态的行动含义完全相反,不能共用一个图标
check('被中断的节点是"等你"而不是"在跑"', host?.status === 'waiting', host?.status)
check('那一刻没有任何东西在转圈',
!all.some((s) => s.kind === 'node' && s.status === 'running'),
all.filter((s) => s.status === 'running').map((s) => s.title).join(','))
// 恢复之后它得回到"在跑",否则整条流永远显示成在等人
const after = mod.decodeRun(fixtures.human)
const doneHost = flatten(after).find((s) => s.kind === 'node' && s.nodeId === 'h')
check('恢复后回到正常状态', doneHost?.status === 'done', doneHost?.status)
}
console.log('\n=== 循环里的多轮审批 ===')
{
// 真实运行:驳回 → 改写 → 再驳回(带备注)→ 改写 → 放行。三轮,每轮都有
+49 -16
View File
@@ -42,25 +42,38 @@ console.log('=== 画布助手栏 ===')
const { page, errors } = await visit('/studio')
check('没有运行时报错', errors.length === 0, errors.join(' | '))
const tabs = await page.locator('nav, [role=tablist], button').allInnerTexts()
const flat = tabs.join(' ')
check('右栏是「助手 / 属性」两个 tab', flat.includes('助手') && flat.includes('属性'))
check('不再有「时间线 / 成果 / 事件」的第二层嵌套',
!flat.includes('时间线') && !flat.includes('原始事件'))
const body = await page.locator('body').innerText()
check('不再有两层 tab 嵌套',
!body.includes('时间线') && !body.includes('原始事件'))
// 改版最容易顺手删掉的两个东西
check('发起运行的入口还在', await page.getByText('试运行').count() > 0)
check('Copilot 输入框就在栏里(不再是弹窗)',
await page.locator('textarea[placeholder*="告诉它"], textarea[placeholder*="描述你要"]').count() > 0)
// 用户的原话:"没有一个很好的地方引导用户写问题"。空态下输入区就该是主体
check('空态给出了明确的邀请', body.includes('想让它做什么'))
check('说清楚它能够到什么', /\d+ 个工具/.test(body), body.match(/\d+ 个工具/)?.[0])
const examples = await page.locator('aside button').filter({ hasText: /。|,/ }).count()
check('例句是完整句子而不是截断的 chip', examples >= 2, `${examples} 条`)
// 切到属性再切回来,输入框里的草稿不该没
const composer = page.locator('textarea[placeholder*="告诉它"], textarea[placeholder*="描述你要"]').first()
const composer = page.locator('aside textarea').first()
check('输入框自动聚焦,不用先点一下',
await composer.evaluate((el) => el === document.activeElement))
// 改版最容易顺手删掉的东西:唯一的运行入口
check('发起运行的入口还在(已搬到工具栏)',
await page.getByRole('button', { name: /^运行/ }).count() > 0)
// 这是这次重构的核心承诺:属性是盖在助手上的一层,不是把它换掉。
// 切 tab 会卸载组件,草稿、滚动位置、展开状态全丢——那正是要修的问题
await composer.fill('测试草稿不要丢')
await page.getByRole('button', { name: '属性' }).click()
await page.waitForTimeout(200)
await page.getByRole('button', { name: '助手' }).click()
await page.waitForTimeout(200)
check('切走再切回来,输入的字还在', await composer.inputValue() === '测试草稿不要丢',
await page.locator('.react-flow__node').first().click()
await page.waitForTimeout(350)
const sheet = await page.locator('body').innerText()
check('选中节点滑出属性面板', sheet.includes('节点名称') || sheet.includes('ID:'))
check('面板上有明确的返回,让人知道底下还有东西',
await page.getByTitle('返回助手(Esc)').count() > 0)
await page.keyboard.press('Escape')
await page.waitForTimeout(350)
check('Esc 关得掉', await page.getByTitle('返回助手(Esc)').count() === 0)
check('回来之后输入的字还在', await composer.inputValue() === '测试草稿不要丢',
await composer.inputValue())
await composer.fill('')
@@ -71,6 +84,26 @@ console.log('=== 画布助手栏 ===')
await page.close()
}
console.log('\n=== 动效对前庭敏感者可关 ===')
{
// 这套界面里动的东西不少(边在流动、节点在脉冲、面板在滑)。
// 系统里关了动效还照播,对前庭敏感的人是实打实的难受
const page = await ctx.newPage()
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto(`${WEB}/studio`, { waitUntil: 'networkidle' })
await page.waitForTimeout(500)
const durations = await page.evaluate(() => {
const probe = document.createElement('div')
probe.className = 'rise-in'
document.body.appendChild(probe)
const d = getComputedStyle(probe).animationDuration
probe.remove()
return d
})
check('关掉动效后动画确实停了', parseFloat(durations) < 0.01, durations)
await page.close()
}
console.log('\n=== 运行历史 ===')
{
const { page, errors } = await visit('/runs')