mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:01:58 +00:00
一次对话有自己的地址了:/chat/<chatID>,运行、画布、各页标签同理
在此之前 react-router 只管到页面一级,「页面内部在看哪个」全在 store 和
localStorage 里,全站一处 useParams / useSearchParams / useNavigate 都没有。
后果:
- 一次对话没有地址。想把「这个结论是怎么查出来的」发给同事,只能发截图。
- 刷新回到上次那个对话靠 localStorage,换台机器、换个浏览器就丢。
- 浏览器前进/后退在页面内部彻底失效——切了五个对话,后退一下直接离开问数据页。
- /settings 永远落在「模型接入」,指不到数据源那一屏。
/chat/:conversationId /studio/:workflowId /runs/:runId
/knowledge/:tab /tools/:tab /settings/:tab
每页写两条显式路由(带 id / 不带 id)而不是可选参数 `:id?`:「没带 id」是真要
单独处理的一种情况(落到上次那个、或列表第一个),藏在一个问号里下次就没人
记得它存在了。
**URL 驱动 store,不是反过来。** 两边都能写就一定会打架——点一下列表,store
改了、地址栏没动,刷新又跳回旧的。所以 useConversations.currentId 降级成 URL
的派生缓存,唯一写入口是 ChatPage 的那个同步 effect;列表里的切换、新建、删除
一律改成导航。conversations.load() 不再自作主张决定「当前看哪个」,
remove() 改成返回「接下来该去哪」交给调用方。
localStorage 留着,但它现在只回答另一个问题:**打开不带 id 的 /chat 该去哪**。
**指向不存在的东西**要优雅:等列表加载完再判(在那之前"找不到"是假的,照着它
跳会让每次深链接进入都先闪一下错误提示),落到一个真实的,并说明为什么换了
地方。RunsPage 特别一点——列表只取前 100 条,而一条老运行的链接必须也能打开,
所以详情按 id 直接取,不在列表里找。
**画布多两道闸。** 选择器里本来就有"未保存改动"的确认,但那道闸在它的 onClick
里,而前进/后退会绕过选择器直接换图——改了一半的画布就这么没了。URL→画布那个
effect 里补一道,取消就把地址退回去。
过程中实测抓到三个问题,都修了:
- 同一个坏地址弹三次"那张工作流不在了":effect 在请求回来之前又跑了两遍,
加了在途去重(chat 那边同样的毛病弹两次)。
- 「在画布里打开」现在直接把人送去 /studio,而那是一张还没存、没有 id 的草稿图。
不带 id 就落到第一张图的逻辑会拿第一张把它盖掉,还会先弹一句莫名其妙的
"有未保存的改动"——加了 nodeCount 判断让开。这个坑其实原来就在,只是以前
要手动点导航才踩得到。
- 删掉当前打开的那张图之后地址还指着它,刷新会撞上"不在了"。
顺带:三个 tab 页共用一个 useTabRoute(放在 Tabs 旁边,tab 和它的地址是同一件
事,拆两处迟早各改各的),认不出的 tab 名回落并把地址一起纠正过来。
「在画布里打开」不再是 toast 让用户自己去点导航,直接送过去。
验证:tsc 干净;后端 249 passed(没碰后端,跑一遍确认没带坏);
check-decode / check-stream 不回归;check-ui 新增「URL 指得到」一节 19 条,
真浏览器里验了深链接、切换改地址、刷新、后退、坏地址、tab 直达、
草稿图不被盖掉、越过列表上限的老运行。
This commit is contained in:
@@ -74,15 +74,26 @@ export default function App() {
|
||||
</nav>
|
||||
|
||||
<main className="min-w-0 flex-1">
|
||||
{/* 「在看哪个」属于 URL,不属于 store。
|
||||
每页写成两条显式路由(带 id / 不带 id),而不是可选参数 `:id?`:
|
||||
「没带 id」是真要单独处理的一种情况(落到上次那个、或列表第一个),
|
||||
藏在一个问号里下次就没人记得它存在了。
|
||||
导航高亮不用动——NavLink 默认前缀匹配,/chat/abc 照样点亮「问数据」。*/}
|
||||
<Routes>
|
||||
{/* 默认落到对话页:多数人要的是答案,画布留给要自己编排的人 */}
|
||||
<Route path="/" element={<Navigate to="/chat" replace />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/chat/:conversationId" element={<ChatPage />} />
|
||||
<Route path="/studio" element={<StudioPage />} />
|
||||
<Route path="/studio/:workflowId" element={<StudioPage />} />
|
||||
<Route path="/runs" element={<RunsPage />} />
|
||||
<Route path="/runs/:runId" element={<RunsPage />} />
|
||||
<Route path="/tools" element={<ToolsPage />} />
|
||||
<Route path="/tools/:tab" element={<ToolsPage />} />
|
||||
<Route path="/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/knowledge/:tab" element={<KnowledgePage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/:tab" element={<SettingsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import type { ReactNode } from 'react'
|
||||
import { AlertCircle, CheckCircle2, Info, Loader2, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
@@ -101,6 +102,34 @@ export function Empty({ icon, title, hint, action }: {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 Tabs 的选中项接到 URL 的最后一段上:/settings/datasources。
|
||||
*
|
||||
* 和 Tabs 放在一起而不是单开一个 hooks 文件——tab 和它的地址是同一件事,
|
||||
* 拆到两处迟早各改各的。三个 tab 页(知识 / 工具 / 设置)各接一行。
|
||||
*
|
||||
* 用 replace 而不是 push:切 tab 不该在后退历史里堆一串,按后退是想离开
|
||||
* 这一页,不是想把四个标签倒着走一遍。
|
||||
*
|
||||
* 认不出的 tab 名(链接过期、手输错了)回落到 fallback,并把地址一起纠正过来,
|
||||
* 免得地址栏和眼前这一屏说的不是同一件事。
|
||||
*/
|
||||
export function useTabRoute(
|
||||
valid: readonly string[], fallback: string,
|
||||
): [string, (key: string) => void] {
|
||||
const { tab } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { pathname } = useLocation()
|
||||
const base = '/' + pathname.split('/').filter(Boolean)[0]
|
||||
const active = tab && valid.includes(tab) ? tab : fallback
|
||||
|
||||
useEffect(() => {
|
||||
if (tab !== active) navigate(`${base}/${active}`, { replace: true })
|
||||
}, [tab, active, base, navigate])
|
||||
|
||||
return [active, (key: string) => navigate(`${base}/${key}`, { replace: true })]
|
||||
}
|
||||
|
||||
export function Tabs({ tabs, active, onChange }: {
|
||||
tabs: { key: string; label: string; badge?: number }[]
|
||||
active: string
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Database, ListTree, MessageSquare, Play, Send, Square } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
import { ApprovalCard } from '../run/RunPanel'
|
||||
@@ -6,7 +7,7 @@ import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantS
|
||||
import { decodeCopilot, decodeRun } from '../run/decode'
|
||||
import { useCatalog } from '../store/catalog'
|
||||
import { useChat, type ChatTurn } from '../store/chat'
|
||||
import { useConversations } from '../store/conversations'
|
||||
import { lastVisited, useConversations } from '../store/conversations'
|
||||
import { useStudio } from '../store/studio'
|
||||
import { ConversationList } from './ConversationList'
|
||||
import { useToast } from '../components/ui'
|
||||
@@ -26,14 +27,48 @@ import { useToast } from '../components/ui'
|
||||
*/
|
||||
export function ChatPage() {
|
||||
const { busy, ask, stop, load, loadSteps, runNow, turnsOf } = useChat()
|
||||
const { conversationId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const currentId = useConversations((s) => s.currentId)
|
||||
const create = useConversations((s) => s.create)
|
||||
const select = useConversations((s) => s.select)
|
||||
const list = useConversations((s) => s.list)
|
||||
const listLoading = useConversations((s) => s.loading)
|
||||
const turns = useChat((s) => (currentId ? s.byConversation[currentId] : undefined))
|
||||
const [draft, setDraft] = useState('')
|
||||
const sources = useDataSources()
|
||||
const setGraph = useStudio((s) => s.setGraph)
|
||||
const toast = useToast()
|
||||
|
||||
// URL → store。这是 currentId 唯一的写入口,别处一律靠导航
|
||||
useEffect(() => { select(conversationId ?? null) }, [conversationId, select])
|
||||
|
||||
// /chat 不带 id:落到上次那个,没有就列表第一条。用 replace 而不是 push,
|
||||
// 否则按后退会回到这个空壳地址、又被弹回来,人就卡在这儿出不去了
|
||||
useEffect(() => {
|
||||
if (conversationId || listLoading || !list.length) return
|
||||
const last = lastVisited()
|
||||
const target = list.find((c) => c.id === last)?.id ?? list[0].id
|
||||
navigate(`/chat/${target}`, { replace: true })
|
||||
}, [conversationId, listLoading, list, navigate])
|
||||
|
||||
// 地址指着一个不存在的对话(被删了、链接过期了)。不能白屏,也不能一声不响
|
||||
// 地跳走——点开一个链接却到了别的地方,得让人知道为什么。
|
||||
//
|
||||
// 必须等列表加载完再判:列表是异步取的,在那之前"找不到"是假的,
|
||||
// 照着它跳会让每次深链接进入都先闪一下错误提示
|
||||
const reported = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (!conversationId || listLoading || !list.length) return
|
||||
if (list.some((c) => c.id === conversationId)) return
|
||||
// 同一个坏地址只说一次。StrictMode 会把 effect 跑两遍,依赖里的 list
|
||||
// 变一次又会再跑一遍——不记着的话同一句提示会连弹好几条
|
||||
if (reported.current === conversationId) return
|
||||
reported.current = conversationId
|
||||
toast('那个对话不在了,已经带你回到最近一个', 'info')
|
||||
navigate(`/chat/${list[0].id}`, { replace: true })
|
||||
}, [conversationId, listLoading, list, navigate, toast])
|
||||
|
||||
// 切到哪个会话就把哪个会话的内容取回来。内存里已经有的不会回源——
|
||||
// 那一份带着这次跑出来的完整步骤,回源拿到的只有问题和答案
|
||||
useEffect(() => { if (currentId) void load(currentId) }, [currentId, load])
|
||||
@@ -43,6 +78,8 @@ export function ChatPage() {
|
||||
if (!q || busy) return
|
||||
// 没有会话就先开一个。第一次进来的人不该先被要求"新建对话"
|
||||
const id = currentId ?? (await create())
|
||||
// 地址要跟上,否则内容已经在新对话里、地址栏还停在 /chat
|
||||
if (id !== currentId) navigate(`/chat/${id}`, { replace: true })
|
||||
ask(id, q)
|
||||
setDraft('')
|
||||
}
|
||||
@@ -81,8 +118,11 @@ export function ChatPage() {
|
||||
/>
|
||||
)}
|
||||
onOpenGraph={(graph) => {
|
||||
// 以前只把图放过去、让用户自己去点导航。有了路由就直接送过去——
|
||||
// "已放到画布,切到「编排」继续改"是在让人替系统完成一次跳转
|
||||
setGraph(graph)
|
||||
toast('已放到画布,切到「编排」继续改', 'ok')
|
||||
navigate('/studio')
|
||||
toast('已放到画布', 'ok')
|
||||
}}
|
||||
footer={
|
||||
<div className="shrink-0 border-t p-3">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Check, MessageSquarePlus, PanelLeftClose, PanelLeftOpen, Pencil, Trash2, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { useChat } from '../store/chat'
|
||||
@@ -14,9 +15,26 @@ import { useConversations } from '../store/conversations'
|
||||
* "改图指令",和这里的"问题"不是一回事,混进同一个列表只会让两种都更难找。
|
||||
*/
|
||||
export function ConversationList() {
|
||||
const { list, currentId, loading, load, create, select, rename, remove } = useConversations()
|
||||
const { list, currentId, loading, load, create, rename, remove } = useConversations()
|
||||
const forget = useChat((s) => s.forget)
|
||||
const busy = useChat((s) => s.busy)
|
||||
const navigate = useNavigate()
|
||||
|
||||
// 切会话 = 换地址,不是改 store。store 那个 currentId 是 URL 的派生缓存,
|
||||
// 在这里直接 set 的话,地址栏不动、刷新就跳回旧的那个,前进后退也全失效
|
||||
const open = (id: string) => navigate(`/chat/${id}`)
|
||||
|
||||
const startNew = async () => {
|
||||
const id = await create()
|
||||
navigate(`/chat/${id}`)
|
||||
}
|
||||
|
||||
const drop = async (id: string) => {
|
||||
forget(id)
|
||||
const next = await remove(id)
|
||||
// 删的是当前这个才需要换地方;remove 会把该去哪告诉我们
|
||||
if (next !== currentId) navigate(next ? `/chat/${next}` : '/chat', { replace: true })
|
||||
}
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
const [editing, setEditing] = useState<string | null>(null)
|
||||
|
||||
@@ -34,7 +52,7 @@ export function ConversationList() {
|
||||
onClick={() => setCollapsed(false)}>
|
||||
<PanelLeftOpen size={13} />
|
||||
</button>
|
||||
<button className="btn btn-sm btn-ghost" title="新对话" onClick={() => void create()}>
|
||||
<button className="btn btn-sm btn-ghost" title="新对话" onClick={() => void startNew()}>
|
||||
<MessageSquarePlus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
@@ -45,7 +63,7 @@ export function ConversationList() {
|
||||
<aside className="flex w-52 shrink-0 flex-col border-r bg-panel">
|
||||
<div className="flex items-center gap-1 px-2 py-2">
|
||||
<button className="btn btn-sm btn-ghost flex-1 justify-start gap-1.5 text-[12px]"
|
||||
onClick={() => void create()}>
|
||||
onClick={() => void startNew()}>
|
||||
<MessageSquarePlus size={13} /> 新对话
|
||||
</button>
|
||||
<button className="btn btn-sm btn-ghost" title="收起" onClick={() => setCollapsed(true)}>
|
||||
@@ -75,7 +93,7 @@ export function ConversationList() {
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => select(c.id)}
|
||||
onClick={() => open(c.id)}
|
||||
title={c.last_question || c.title}
|
||||
>
|
||||
<div className="truncate">{c.title || '新对话'}</div>
|
||||
@@ -93,7 +111,7 @@ export function ConversationList() {
|
||||
className="btn btn-xs btn-ghost"
|
||||
title={busy && c.id === currentId ? '正在运行,先停下来再删' : '删除'}
|
||||
disabled={busy && c.id === currentId}
|
||||
onClick={() => { forget(c.id); void remove(c.id) }}
|
||||
onClick={() => void drop(c.id)}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
|
||||
@@ -2,22 +2,21 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { BookOpen, Brain, Plus, Search, Sparkles, Trash2, Upload } from 'lucide-react'
|
||||
import { api } from '../api/client'
|
||||
import { useCatalog } from '../store/catalog'
|
||||
import { Empty, Modal, Spinner, Tabs, useToast } from '../components/ui'
|
||||
import { Empty, Modal, Spinner, Tabs, useTabRoute, useToast } from '../components/ui'
|
||||
import type { KbDocument, MemoryItem, Skill } from '../types'
|
||||
|
||||
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上
|
||||
const TABS = [
|
||||
{ key: 'kb', label: '知识库' },
|
||||
{ key: 'memory', label: '长期记忆' },
|
||||
{ key: 'skills', label: '方法论 Skill' },
|
||||
]
|
||||
|
||||
export function KnowledgePage() {
|
||||
const [tab, setTab] = useState('kb')
|
||||
const [tab, setTab] = useTabRoute(TABS.map((t) => t.key), 'kb')
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ key: 'kb', label: '知识库' },
|
||||
{ key: 'memory', label: '长期记忆' },
|
||||
{ key: 'skills', label: '方法论 Skill' },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{tab === 'kb' && <KbTab />}
|
||||
{tab === 'memory' && <MemoryTab />}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { Code2, GitFork, History, RefreshCw, Trash2 } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../api/client'
|
||||
@@ -12,6 +13,8 @@ import type { Run, RunEvent } from '../types'
|
||||
/** 运行历史。每次运行的完整事件流都落了库,所以这里能完整回放。 */
|
||||
export function RunsPage() {
|
||||
const toast = useToast()
|
||||
const { runId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [runs, setRuns] = useState<Run[]>([])
|
||||
const [selected, setSelected] = useState<Run | null>(null)
|
||||
const [events, setEvents] = useState<RunEvent[]>([])
|
||||
@@ -31,12 +34,32 @@ export function RunsPage() {
|
||||
}
|
||||
useEffect(() => { void load() }, [])
|
||||
|
||||
const open = async (run: Run) => {
|
||||
setSelected(run)
|
||||
setEvents([])
|
||||
setEvents(await api.runs.events(run.id).catch(() => []))
|
||||
// URL 说看哪条就看哪条。**按 id 直接取**,不在 runs 里找——列表只有前 100 条,
|
||||
// 而一条老运行的链接必须也能打开,否则"可分享"就只对最近的运行成立
|
||||
const refreshDetail = async (id: string) => {
|
||||
const run = await api.runs.get(id).catch(() => null)
|
||||
if (run) setSelected(run)
|
||||
setEvents(await api.runs.events(id).catch(() => []))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!runId) { setSelected(null); setEvents([]); return }
|
||||
let live = true
|
||||
setEvents([])
|
||||
void (async () => {
|
||||
const run = await api.runs.get(runId).catch(() => null)
|
||||
if (!live) return
|
||||
if (!run) {
|
||||
toast('那次运行不在了', 'info')
|
||||
navigate('/runs', { replace: true })
|
||||
return
|
||||
}
|
||||
setSelected(run)
|
||||
setEvents(await api.runs.events(runId).catch(() => []))
|
||||
})()
|
||||
return () => { live = false }
|
||||
}, [runId])
|
||||
|
||||
const shown = runs.filter(
|
||||
(r) => !filter || r.workflow_name?.includes(filter) || r.status === filter
|
||||
|| summarizeRun(r.input, r.output).includes(filter),
|
||||
@@ -81,7 +104,7 @@ export function RunsPage() {
|
||||
return (
|
||||
<button
|
||||
key={run.id}
|
||||
onClick={() => open(run)}
|
||||
onClick={() => navigate(`/runs/${run.id}`)}
|
||||
className={clsx(
|
||||
'flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left hover:bg-hover',
|
||||
selected?.id === run.id && 'bg-hover',
|
||||
@@ -160,7 +183,7 @@ export function RunsPage() {
|
||||
onClick={async () => {
|
||||
if (!confirm('删除这条运行记录?')) return
|
||||
await api.runs.remove(selected.id)
|
||||
setSelected(null)
|
||||
navigate('/runs', { replace: true })
|
||||
await load()
|
||||
toast('已删除', 'ok')
|
||||
}}
|
||||
@@ -175,7 +198,7 @@ export function RunsPage() {
|
||||
{pendingHere.length > 0 && (
|
||||
<div className="shrink-0 border-b" style={{ borderColor: 'var(--warn)' }}>
|
||||
{pendingHere.map((a) => (
|
||||
<ApprovalCard key={a.id} approval={a} onResolved={() => open(selected)} />
|
||||
<ApprovalCard key={a.id} approval={a} onResolved={refreshDetail} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -3,24 +3,23 @@ import { Check, Cpu, KeyRound, Plus, Radio, Trash2, X } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../api/client'
|
||||
import { useCatalog } from '../store/catalog'
|
||||
import { Empty, Modal, Spinner, Tabs, useToast } from '../components/ui'
|
||||
import { Empty, Modal, Spinner, Tabs, useTabRoute, useToast } from '../components/ui'
|
||||
import { DataSourcesTab } from './DataSourcesTab'
|
||||
import type { Provider } from '../types'
|
||||
|
||||
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上
|
||||
const TABS = [
|
||||
{ key: 'providers', label: '模型接入' },
|
||||
{ key: 'datasources', label: '数据源' },
|
||||
{ key: 'prefs', label: '偏好设置' },
|
||||
{ key: 'system', label: '运行环境' },
|
||||
]
|
||||
|
||||
export function SettingsPage() {
|
||||
const [tab, setTab] = useState('providers')
|
||||
const [tab, setTab] = useTabRoute(TABS.map((t) => t.key), 'providers')
|
||||
return (
|
||||
<div className="mx-auto flex h-full w-full max-w-4xl flex-col">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ key: 'providers', label: '模型接入' },
|
||||
{ key: 'datasources', label: '数据源' },
|
||||
{ key: 'prefs', label: '偏好设置' },
|
||||
{ key: 'system', label: '运行环境' },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{tab === 'providers' && <ProvidersTab />}
|
||||
{tab === 'datasources' && <DataSourcesTab />}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
AlertTriangle, Check, ChevronDown, Copy, LayoutGrid, Plus, Save, ShieldCheck,
|
||||
Trash2, Variable, Wand2,
|
||||
@@ -18,23 +19,66 @@ import type { Workflow } from '../types'
|
||||
|
||||
export function StudioPage() {
|
||||
const toast = useToast()
|
||||
const { workflowId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { workflows, refresh } = useCatalog()
|
||||
const catalogLoaded = useCatalog((s) => s.loaded)
|
||||
const workflow = useStudio((s) => s.workflow)
|
||||
const dirty = useStudio((s) => s.dirty)
|
||||
// 只订阅数量,不订阅整个 nodes——后者每拖一下都会让整页重渲染
|
||||
const nodeCount = useStudio((s) => s.nodes.length)
|
||||
const issues = useStudio((s) => s.issues)
|
||||
const streaming = useStudio((s) => s.streaming)
|
||||
const { load, save, setGraph, select } = useStudio()
|
||||
|
||||
/** 正在按 id 去取哪张图。防止 effect 重入时重复发请求、重复弹提示 */
|
||||
const resolving = useRef<string | null>(null)
|
||||
|
||||
const [picker, setPicker] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [publishing, setPublishing] = useState(false)
|
||||
const [vars, setVars] = useState(false)
|
||||
const copilotActive = useStudio((s) => s.copilot.active)
|
||||
|
||||
// 首次进来自动打开第一张图
|
||||
// /studio 不带 id:落到第一张图。replace 而不是 push,否则按后退会回到
|
||||
// 这个空壳地址、又被弹回来,人就在这儿出不去了。
|
||||
//
|
||||
// 画布上已经有东西就别跳——问数据页的「在画布里打开」正是这么送过来的:
|
||||
// 一张还没存、还没有 id 的草稿图。跳过去会拿第一张图把它盖掉,
|
||||
// 而且因为 setGraph 标了脏,还会先弹一句莫名其妙的"有未保存的改动"
|
||||
useEffect(() => {
|
||||
if (!workflow && workflows.length) load(workflows[0])
|
||||
}, [workflows, workflow, load])
|
||||
if (workflowId || !workflows.length || nodeCount) return
|
||||
navigate(`/studio/${workflows[0].id}`, { replace: true })
|
||||
}, [workflowId, workflows, nodeCount, navigate])
|
||||
|
||||
// URL → 画布。地址说打开哪张就打开哪张
|
||||
useEffect(() => {
|
||||
if (!workflowId || workflow?.id === workflowId || !catalogLoaded) return
|
||||
|
||||
// 选择器里本来就有一道"未保存改动"的确认,但那道闸在它的 onClick 里,
|
||||
// 而浏览器前进/后退会绕过选择器直接换图——改了一半的画布就这么没了。
|
||||
// 所以这里补一道,取消就把地址退回去
|
||||
if (dirty && !confirm('当前画布有未保存的改动,切换后会丢失。确定要切换吗?')) {
|
||||
navigate(workflow ? `/studio/${workflow.id}` : '/studio', { replace: true })
|
||||
return
|
||||
}
|
||||
|
||||
const known = workflows.find((w) => w.id === workflowId)
|
||||
if (known) { load(known); return }
|
||||
|
||||
// 不在目录里:目录可能是旧的,也可能真被删了。按 id 取一次问清楚,
|
||||
// 而不是直接判死——分享出去的链接不该因为对方目录没刷新就打不开。
|
||||
//
|
||||
// 这一路是异步的,而 effect 的依赖里有好几个会变的东西(目录、dirty),
|
||||
// 请求还没回来它就又跑了一遍:实测同一个坏地址弹了三次"不在了"。
|
||||
// 用一个 ref 记住正在解哪个 id,重复的直接让开
|
||||
if (resolving.current === workflowId) return
|
||||
resolving.current = workflowId
|
||||
void api.workflows.get(workflowId).then(load).catch(() => {
|
||||
toast('那张工作流不在了', 'info')
|
||||
navigate('/studio', { replace: true })
|
||||
})
|
||||
}, [workflowId, workflow, workflows, catalogLoaded, dirty, load, navigate, toast])
|
||||
|
||||
// 开始跑图或生成时,属性面板让开——不然进展发生在一块被盖住的地方。
|
||||
// (选中节点即滑出属性面板,取消选中即收起,不再需要手动切 tab)
|
||||
@@ -202,7 +246,7 @@ export function StudioPage() {
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
function NewWorkflowButton({ onDone }: { onDone: () => void }) {
|
||||
const load = useStudio((s) => s.load)
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
return (
|
||||
<button
|
||||
@@ -227,7 +271,9 @@ function NewWorkflowButton({ onDone }: { onDone: () => void }) {
|
||||
edges: [{ source: 'start', target: 'done' }],
|
||||
},
|
||||
})
|
||||
load(created)
|
||||
// 走地址而不是直接 load:新图也得有自己的 URL,否则刚建完
|
||||
// 刷新一下就回到了第一张
|
||||
navigate(`/studio/${created.id}`)
|
||||
onDone()
|
||||
toast('已创建', 'ok')
|
||||
} catch (e: any) {
|
||||
@@ -242,7 +288,7 @@ function NewWorkflowButton({ onDone }: { onDone: () => void }) {
|
||||
|
||||
function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { workflows, refresh } = useCatalog()
|
||||
const load = useStudio((s) => s.load)
|
||||
const navigate = useNavigate()
|
||||
const current = useStudio((s) => s.workflow)
|
||||
const toast = useToast()
|
||||
|
||||
@@ -269,14 +315,16 @@ function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () => void
|
||||
)}
|
||||
onClick={() => {
|
||||
// 切换会把当前画布整个换掉。没存过的改动就这么没了,
|
||||
// 而且全局没有撤销——至少问一句
|
||||
// 而且全局没有撤销——至少问一句。
|
||||
// 闸放在导航**之前**:地址一变,URL→画布那个 effect 就会加载,
|
||||
// 那时候再问就晚了(虽然那边也补了一道,兜的是前进/后退)
|
||||
if (
|
||||
useStudio.getState().dirty &&
|
||||
!confirm('当前画布有未保存的改动,切换后会丢失。确定要切换吗?')
|
||||
) {
|
||||
return
|
||||
}
|
||||
load(w)
|
||||
navigate(`/studio/${w.id}`)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
@@ -306,6 +354,9 @@ function WorkflowPicker({ open, onClose }: { open: boolean; onClose: () => void
|
||||
e.stopPropagation()
|
||||
if (confirm(`删除「${w.name}」?运行记录也会一并删除。`)) {
|
||||
void act(() => api.workflows.remove(w.id), '已删除')
|
||||
// 删的就是眼前这张:地址还指着它,不挪走的话刷新一下
|
||||
// 就会撞上"那张工作流不在了"
|
||||
if (w.id === current?.id) navigate('/studio', { replace: true })
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -3,23 +3,22 @@ import { Play, Plug, Plus, RefreshCw, Terminal, Trash2, Wrench } from 'lucide-re
|
||||
import clsx from 'clsx'
|
||||
import { api } from '../api/client'
|
||||
import { useCatalog } from '../store/catalog'
|
||||
import { Empty, JsonInput, Modal, Spinner, Tabs, useToast } from '../components/ui'
|
||||
import { Empty, JsonInput, Modal, Spinner, Tabs, useTabRoute, useToast } from '../components/ui'
|
||||
import type { ToolInfo } from '../types'
|
||||
|
||||
// 提到模块级:tab 名同时是 URL 的最后一段,两处各写一份迟早对不上
|
||||
const TABS = [
|
||||
{ key: 'tools', label: '工具库' },
|
||||
{ key: 'sandbox', label: '沙箱试验台' },
|
||||
{ key: 'custom', label: '自定义工具' },
|
||||
{ key: 'mcp', label: 'MCP 接入' },
|
||||
]
|
||||
|
||||
export function ToolsPage() {
|
||||
const [tab, setTab] = useState('tools')
|
||||
const [tab, setTab] = useTabRoute(TABS.map((t) => t.key), 'tools')
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ key: 'tools', label: '工具库' },
|
||||
{ key: 'sandbox', label: '沙箱试验台' },
|
||||
{ key: 'custom', label: '自定义工具' },
|
||||
{ key: 'mcp', label: 'MCP 接入' },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
<Tabs tabs={TABS} active={tab} onChange={setTab} />
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{tab === 'tools' && <ToolLibrary />}
|
||||
{tab === 'sandbox' && <SandboxLab />}
|
||||
|
||||
@@ -8,45 +8,53 @@ import type { Conversation } from '../types'
|
||||
* 只管"有哪些对话、当前在看哪个"。对话里的内容归 chat store —— 两者分开是
|
||||
* 因为列表要常驻(切换时不能闪),而内容是按会话缓存的一大坨东西。
|
||||
*
|
||||
* 当前会话 id 存进 localStorage:刷新后回到刚才那个对话,而不是甩回空白页。
|
||||
* 这是"对话持久化"里用户最先感知到的一半——另一半(内容本身)在 chat store。
|
||||
* **当前在看哪个由 URL 说了算**(/chat/:conversationId),这里的 currentId 只是
|
||||
* 它的派生缓存——十几处在读这个字段,但写它的只有 ChatPage 的那个同步 effect。
|
||||
* 两边都能写就一定会打架:点一下列表,store 改了、地址栏没改,刷新又回到旧的。
|
||||
*
|
||||
* localStorage 留着,但它回答的是另一个问题:**打开不带 id 的 /chat 该去哪**。
|
||||
* URL 里有 id 时一律以 URL 为准。
|
||||
*/
|
||||
|
||||
const LAST_KEY = 'agentlab.lastConversation'
|
||||
|
||||
/** 上次看的是哪个。只在 /chat 不带 id 时用来决定落到哪 */
|
||||
export const lastVisited = (): string | null => {
|
||||
try {
|
||||
return localStorage.getItem(LAST_KEY)
|
||||
} catch {
|
||||
return null // 隐私模式下 localStorage 会抛
|
||||
}
|
||||
}
|
||||
|
||||
interface ConversationState {
|
||||
list: Conversation[]
|
||||
currentId: string | null
|
||||
loading: boolean
|
||||
|
||||
load: () => Promise<void>
|
||||
/** 新建(或复用一条空的),返回它的 id。**不负责切过去**——那是导航的事 */
|
||||
create: () => Promise<string>
|
||||
/** 由 URL 回流调用,把「在看哪个」记下来。不要在别处直接调它 */
|
||||
select: (id: string | null) => void
|
||||
rename: (id: string, title: string) => Promise<void>
|
||||
remove: (id: string) => Promise<void>
|
||||
/** 删掉,返回接下来该去哪个(列表空了就是 null),由调用方导航 */
|
||||
remove: (id: string) => Promise<string | null>
|
||||
/** 有人在这个会话里说话了,把它顶到列表最前并刷新副标题 */
|
||||
touch: (id: string, lastQuestion?: string) => void
|
||||
}
|
||||
|
||||
export const useConversations = create<ConversationState>((set, get) => ({
|
||||
list: [],
|
||||
currentId: localStorage.getItem(LAST_KEY),
|
||||
currentId: null, // 由 URL 填,见 ChatPage 的同步 effect
|
||||
loading: false,
|
||||
|
||||
load: async () => {
|
||||
// 只管把列表取回来。「当前在看哪个」以前也在这里决定,现在归 URL 管——
|
||||
// 两边都能定就会互相覆盖:深链接进来,列表一加载完又被拽回上次那个
|
||||
set({ loading: true })
|
||||
try {
|
||||
const list = await api.conversations.list('chat')
|
||||
// 记着的那个可能已经被删了(另一个标签页里删的)。指着一个不存在的
|
||||
// 会话会让页面停在空白,这时候退回最近一条,而不是让人对着空屏幕
|
||||
const current = get().currentId
|
||||
const alive = current && list.some((c) => c.id === current)
|
||||
const next = alive ? current : (list[0]?.id ?? null)
|
||||
if (next !== current) {
|
||||
if (next) localStorage.setItem(LAST_KEY, next)
|
||||
else localStorage.removeItem(LAST_KEY)
|
||||
}
|
||||
set({ list, currentId: next, loading: false })
|
||||
set({ list: await api.conversations.list('chat'), loading: false })
|
||||
} catch {
|
||||
set({ loading: false })
|
||||
}
|
||||
@@ -56,19 +64,17 @@ export const useConversations = create<ConversationState>((set, get) => ({
|
||||
// 已经有一条空会话就直接用它。连点几次"新对话"不该留下一串一模一样的
|
||||
// 空壳——用户的意思是"我要从头开始说",不是"我要三个空对话"
|
||||
const blank = get().list.find((c) => c.turn_count === 0)
|
||||
if (blank) {
|
||||
get().select(blank.id)
|
||||
return blank.id
|
||||
}
|
||||
if (blank) return blank.id
|
||||
const created = await api.conversations.create({ kind: 'chat' })
|
||||
localStorage.setItem(LAST_KEY, created.id)
|
||||
set((s) => ({ list: [created, ...s.list], currentId: created.id }))
|
||||
set((s) => ({ list: [created, ...s.list] }))
|
||||
return created.id
|
||||
},
|
||||
|
||||
select: (id) => {
|
||||
if (id) localStorage.setItem(LAST_KEY, id)
|
||||
else localStorage.removeItem(LAST_KEY)
|
||||
try {
|
||||
if (id) localStorage.setItem(LAST_KEY, id)
|
||||
else localStorage.removeItem(LAST_KEY)
|
||||
} catch { /* 隐私模式,记不住就算了,不影响这次 */ }
|
||||
set({ currentId: id })
|
||||
},
|
||||
|
||||
@@ -86,13 +92,10 @@ export const useConversations = create<ConversationState>((set, get) => ({
|
||||
|
||||
remove: async (id) => {
|
||||
await api.conversations.remove(id)
|
||||
set((s) => {
|
||||
const list = s.list.filter((c) => c.id !== id)
|
||||
const currentId = s.currentId === id ? (list[0]?.id ?? null) : s.currentId
|
||||
if (currentId) localStorage.setItem(LAST_KEY, currentId)
|
||||
else localStorage.removeItem(LAST_KEY)
|
||||
return { list, currentId }
|
||||
})
|
||||
const list = get().list.filter((c) => c.id !== id)
|
||||
set({ list })
|
||||
// 删的不是当前这个就原地不动;删的是当前这个才需要换地方
|
||||
return get().currentId === id ? (list[0]?.id ?? null) : get().currentId
|
||||
},
|
||||
|
||||
touch: (id, lastQuestion) => {
|
||||
|
||||
@@ -261,6 +261,151 @@ console.log('\n=== 会话 ===')
|
||||
await page.close()
|
||||
}
|
||||
|
||||
console.log('\n=== URL 指得到 ===')
|
||||
{
|
||||
// 在此之前「在看哪个对话/哪次运行/哪一屏」全在 store 和 localStorage 里:
|
||||
// 一次对话没有地址,发给同事只能发截图;刷新靠 localStorage,换台机器就丢;
|
||||
// 浏览器后退会直接离开整个页面而不是回到上一个对话。
|
||||
const convs = await (await fetch(`${API}/conversations`)).json()
|
||||
const seeded = convs.filter((c) => c.turn_count > 0)
|
||||
|
||||
if (seeded.length >= 2) {
|
||||
const [a, b] = seeded
|
||||
const { page, errors } = await visit(`/chat/${a.id}`)
|
||||
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
||||
check('深链接直达那个对话', (await page.locator('main').innerText()).includes(a.title),
|
||||
`想要「${a.title}」`)
|
||||
check('地址里就是那个 chatID', page.url().endsWith(`/chat/${a.id}`), page.url())
|
||||
|
||||
// 点另一个:地址要跟着走,而不是只改 store
|
||||
await page.locator('aside button[title]').filter({ hasText: b.title }).first().click()
|
||||
await page.waitForTimeout(600)
|
||||
check('切换对话会改地址', page.url().endsWith(`/chat/${b.id}`), page.url())
|
||||
|
||||
// 刷新靠的是 URL,不是 localStorage
|
||||
await page.reload({ waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(800)
|
||||
check('刷新后还在同一个对话', page.url().endsWith(`/chat/${b.id}`)
|
||||
&& (await page.locator('main').innerText()).includes(b.title))
|
||||
|
||||
// 后退回到上一个对话,而不是甩出整个页面——这条以前是彻底失效的
|
||||
await page.goBack({ waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(700)
|
||||
check('后退回到上一个对话', page.url().endsWith(`/chat/${a.id}`), page.url())
|
||||
await shot(page, 'url-chat')
|
||||
await page.close()
|
||||
} else {
|
||||
check('(跳过:库里不足两个带轮次的会话)', true)
|
||||
}
|
||||
|
||||
{
|
||||
// 链接过期/对话被删:不能白屏,也不能一声不响地跳走
|
||||
const { page } = await visit('/chat/这个id根本不存在')
|
||||
const body = await page.locator('body').innerText()
|
||||
check('指向不存在的对话时不白屏', body.length > 40, `${body.length} 字`)
|
||||
check('落到了一个真实对话', /\/chat\/[0-9a-f]{8,}$/.test(page.url()), page.url())
|
||||
// 认整句提示,不认"不在了"三个字——会话标题里碰巧有这仨字就会把它蒙对
|
||||
const notice = body.includes('那个对话不在了')
|
||||
check('说清楚了为什么换了地方', notice,
|
||||
notice ? '' : body.slice(-200).replace(/\n/g, ' '))
|
||||
await page.close()
|
||||
}
|
||||
|
||||
{
|
||||
// tab 也得能指:/settings 永远落在「模型接入」,以前指不到数据源那一屏
|
||||
const { page } = await visit('/settings/datasources')
|
||||
check('深链接直达数据源那一屏', (await page.locator('body').innerText()).includes('数据源'))
|
||||
await page.close()
|
||||
const bare = await visit('/settings')
|
||||
check('不带 tab 时落到第一屏并纠正地址',
|
||||
bare.page.url().endsWith('/settings/providers'), bare.page.url())
|
||||
await bare.page.close()
|
||||
const bogus = await visit('/knowledge/没这个tab')
|
||||
check('认不出的 tab 名回落而不是空白',
|
||||
bogus.page.url().endsWith('/knowledge/kb'), bogus.page.url())
|
||||
await bogus.page.close()
|
||||
}
|
||||
|
||||
{
|
||||
// 画布也该能指。这条路最容易出事:前进/后退会绕过选择器里那道
|
||||
// "未保存改动"的确认直接换图
|
||||
const wfs = await (await fetch(`${API}/workflows`)).json()
|
||||
if (wfs.length >= 2) {
|
||||
const bare = await visit('/studio')
|
||||
check('/studio 落到第一张图并纠正地址',
|
||||
bare.page.url().endsWith(`/studio/${wfs[0].id}`), bare.page.url())
|
||||
await bare.page.close()
|
||||
|
||||
const { page } = await visit(`/studio/${wfs[1].id}`)
|
||||
check('深链接直达那张图', (await page.locator('body').innerText()).includes(wfs[1].name),
|
||||
`想要「${wfs[1].name}」`)
|
||||
await page.close()
|
||||
} else {
|
||||
check(`(跳过:库里只有 ${wfs.length} 张工作流)`, true)
|
||||
}
|
||||
|
||||
const { page } = await visit('/studio/根本没这张图')
|
||||
const body = await page.locator('body').innerText()
|
||||
// 说一次就够。这里真弹过三次——effect 在请求回来之前又跑了两遍
|
||||
const times = (body.match(/那张工作流不在了/g) || []).length
|
||||
check('指向不存在的图时说一次并让开', times === 1 && /\/studio\/[0-9a-f]{8,}$/.test(page.url()),
|
||||
`提示 ${times} 次,落在 ${page.url()}`)
|
||||
await page.close()
|
||||
}
|
||||
|
||||
{
|
||||
// 问数据页的「在画布里打开」:现在会直接把人送去 /studio。
|
||||
// 送过去的是一张还没存、没有 id 的草稿图,所以那边的"不带 id 就落到
|
||||
// 第一张图"必须让开——不让开就会拿第一张图把它盖掉,而且因为 setGraph
|
||||
// 标了脏,还会先弹一句莫名其妙的"有未保存的改动"
|
||||
const convs = await (await fetch(`${API}/conversations`)).json()
|
||||
let seed = null
|
||||
for (const c of convs.slice(0, 8)) {
|
||||
const d = await (await fetch(`${API}/conversations/${c.id}`)).json()
|
||||
const t = (d.turns || []).find((x) => x.graph?.nodes?.length)
|
||||
if (t) { seed = { id: c.id, want: t.graph.nodes.length }; break }
|
||||
}
|
||||
if (seed) {
|
||||
const page = await ctx.newPage()
|
||||
let dialogs = 0
|
||||
page.on('dialog', async (d) => { dialogs++; await d.dismiss() })
|
||||
await page.goto(`${WEB}/chat/${seed.id}`, { waitUntil: 'networkidle' })
|
||||
await page.waitForTimeout(900)
|
||||
const btn = page.getByRole('button', { name: '在画布里打开' }).first()
|
||||
if (await btn.count()) {
|
||||
await btn.click()
|
||||
await page.waitForTimeout(1600)
|
||||
check('「在画布里打开」会把人送到画布', page.url().endsWith('/studio'), page.url())
|
||||
check('送过去的草稿图没被第一张工作流盖掉',
|
||||
(await page.locator('.react-flow__node').count()) === seed.want,
|
||||
`${await page.locator('.react-flow__node').count()} / ${seed.want} 个节点`)
|
||||
check('没有弹莫名其妙的"未保存改动"', dialogs === 0, `弹了 ${dialogs} 次`)
|
||||
} else {
|
||||
check('(跳过:这一轮没有「在画布里打开」)', true)
|
||||
}
|
||||
await page.close()
|
||||
} else {
|
||||
check('(跳过:前 8 个会话里没有带图的轮次)', true)
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// 运行列表只取前 100 条,而一条老运行的链接必须也能打开——
|
||||
// 所以详情是按 id 直接取的,不是在列表里找
|
||||
const all = await (await fetch(`${API}/runs?limit=200`)).json()
|
||||
if (all.length > 100) {
|
||||
const old = all[all.length - 1]
|
||||
const { page } = await visit(`/runs/${old.id}`)
|
||||
const body = await page.locator('body').innerText()
|
||||
check('第 100 条之后的老运行也打得开', body.includes(old.id.slice(0, 6)),
|
||||
`run ${old.id.slice(0, 8)}`)
|
||||
await page.close()
|
||||
} else {
|
||||
check(`(跳过:库里只有 ${all.length} 条运行,不足以验证越过列表上限)`, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== 三处讲的是同一个故事 ===')
|
||||
{
|
||||
// 同一次运行,运行页详情和 preview 里的 dense 渲染应该给出同一批步骤。
|
||||
|
||||
Reference in New Issue
Block a user