一次对话有自己的地址了:/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:
ThearchyHelios
2026-09-20 14:09:40 +08:00
parent c980da65d0
commit 542bcb65bb
11 changed files with 406 additions and 89 deletions
+11
View File
@@ -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>
+29
View File
@@ -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
+43 -3
View File
@@ -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">
+23 -5
View File
@@ -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>
+10 -11
View File
@@ -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 />}
+30 -7
View File
@@ -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>
)}
+11 -12
View File
@@ -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 />}
+60 -9
View File
@@ -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 })
}
}}
>
+11 -12
View File
@@ -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 />}
+33 -30
View File
@@ -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) => {
+145
View File
@@ -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 渲染应该给出同一批步骤。