From 3de756737726f3e335364ab44def8b3fd990176f Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 18 Sep 2026 00:05:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=B3=E6=A0=8F=E9=87=8D=E6=9E=84=EF=BC=9A?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=E5=B8=B8=E9=A9=BB=EF=BC=8C=E5=B1=9E=E6=80=A7?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=9B=96=E4=B8=8A=E5=8E=BB=E7=9A=84=E4=B8=80?= =?UTF-8?q?=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个问题都源于同一件事——一栏 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 个后端测试全过,生产构建通过。 --- frontend/src/canvas/Inspector.tsx | 18 +- frontend/src/canvas/InspectorSheet.tsx | 100 +++++++ frontend/src/index.css | 86 ++++++ frontend/src/pages/StudioPage.tsx | 53 ++-- frontend/src/run/AssistantPanel.tsx | 348 +++++++------------------ frontend/src/run/AssistantStream.tsx | 9 +- frontend/src/run/Composer.tsx | 287 ++++++++++++++++++++ frontend/src/run/RunControl.tsx | 172 ++++++++++++ frontend/src/run/RunPanel.tsx | 149 +---------- frontend/src/run/decode.ts | 27 +- scripts/check-decode.mjs | 20 ++ scripts/check-ui.mjs | 65 +++-- 12 files changed, 877 insertions(+), 457 deletions(-) create mode 100644 frontend/src/canvas/InspectorSheet.tsx create mode 100644 frontend/src/run/Composer.tsx create mode 100644 frontend/src/run/RunControl.tsx diff --git a/frontend/src/canvas/Inspector.tsx b/frontend/src/canvas/Inspector.tsx index c0df3c72..26e4d7ee 100644 --- a/frontend/src/canvas/Inspector.tsx +++ b/frontend/src/canvas/Inspector.tsx @@ -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 (
-
+
+ {/* 这块是盖在助手栏上的一层。不给一个明确的"回去",用户就不知道 + 底下还有东西——只看到一个 ×,会以为关掉之后什么都没有了 */} + + {def.label} -
diff --git a/frontend/src/canvas/InspectorSheet.tsx b/frontend/src/canvas/InspectorSheet.tsx new file mode 100644 index 00000000..66bd01fa --- /dev/null +++ b/frontend/src/canvas/InspectorSheet.tsx @@ -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:换一个节点时重播一次滑入,让人知道内容换了。 + // 不重播的话,点另一个节点看起来像什么都没发生 +
+
+ +
+ +
+ ) +} + +/** + * 盖住助手栏期间,底部留一条执行进度。 + * + * 不留的话,跑图跑到一半点开节点看配置,运行就从眼前彻底消失了——这正是 + * 原来 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 ( + + ) +} + +/** 最后一条有意义的步骤:优先取最深的子步骤,那才是"此刻在干什么"。 */ +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] +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 37581db1..a006ed99 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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%; diff --git a/frontend/src/pages/StudioPage.tsx b/frontend/src/pages/StudioPage.tsx index 33f6f59c..df512197 100644 --- a/frontend/src/pages/StudioPage.tsx +++ b/frontend/src/pages/StudioPage.tsx @@ -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() {
- {/* Copilot 不再是弹窗:它就在右栏里,这个按钮只负责把人送过去。 - 弹窗的问题是每改一次图都要重开一次,而且关掉之后需求文本就没了 */} + {/* Copilot 不再是弹窗:它就在右栏里,这个按钮只负责把焦点送过去。 + 弹窗的问题是每改一次图都要重开一次,关掉之后需求文本就没了 */} - + {/* 运行是对整张图的动作,和保存、发布同类,属于工具栏。放在助手栏里 + 会和 Copilot 输入框两个"主要动作"互相压着 */} +
{/* 三栏 */} @@ -169,21 +167,12 @@ export function StudioPage() {
-
diff --git a/frontend/src/run/AssistantPanel.tsx b/frontend/src/run/AssistantPanel.tsx index 07f8d6c5..46fc4cf5 100644 --- a/frontend/src/run/AssistantPanel.tsx +++ b/frontend/src/run/AssistantPanel.tsx @@ -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 (
- - - {pending.map((approval) => ( - attachRun(runId)} /> - ))} - -
- {raw ? : ( - } - /> - )} -
- - - -
- {run ? ( - <> - #{run.id.slice(0, 8)} - {run.run_class === 'formal' - ? 正式 v{run.version} - : 探索} - {streaming && ● 实时} - - {!!run.usage?.total_tokens && {run.usage.total_tokens} tok} - {run.usage?.cost_usd ? ${Number(run.usage.cost_usd).toFixed(4)} : null} - {run.usage?.duration_ms ? {formatDuration(run.usage.duration_ms)} : null} - - ) : } - {!!copilotTurns.length && !raw && ( - - )} - {/* 原始事件是排查用的开发者视图,不该占一个常驻 tab——但也不能删, - 翻译层出问题时它是唯一能对照的东西 */} - -
-
- ) -} - -function PanelEmpty() { - const nodes = useStudio((s) => s.nodes) - return ( - } - 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(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 ( -
- {copilot.error && ( -
- {copilot.error} - {/* 失败多半跟需求本身无关(模型抽风、协议跑偏、网断了), - 不该逼用户把需求再敲一遍 */} - {copilot.lastInstruction && ( - + {!empty && ( +
+ 助手 + {streaming && ( + + 执行中 + )} -
- )} - - {!nodes.length && !copilot.active && ( -
- {EXAMPLES.map((ex) => ( - + {!!copilotTurns.length && ( + - ))} + )}
)} - {opts && ( -
-
- - -
- 只影响 Copilot 自己,不改节点上的模型。它要按协议逐行输出操作, - 指令遵循弱的模型生成不出东西。 -
+ {empty ? ( + // 没说过话时输入框就是这一栏的主体,竖直居中。之前它是个 38px 高的 + // 框挤在最底下,比上面任何一块都不起眼——"没地方引导写问题"说的就是它 +
+ +
+ ) : ( + <> +
+ {raw ? : ( + ( + t.runId && pending.length ? ( +
+ {pending.map((a) => ( + attachRun(runId)} /> + ))} +
+ ) : null + )} + /> + )}
- {!!nodes.length && ( - - )} -
- )} - -
- -