AgentHub 桌面端前端:瘦客户端、SSO 登录与多 agent 状态管理
本文拆解 AgentHub 桌面端的前端层(代码在
desktop/src/):它为什么被刻意做薄、SSO 登录这条最复杂的链路怎么在前端 / Rust 壳 / SSO 服务器三方之间流转、apiFetch怎么把横切关注点收拢到一处,以及后端多 agent 化之后,前端的状态模型怎么从"一条扁平消息列表"演进成「agent × 会话」的二维结构。
一句话概括
AgentHub 桌面端前端是跑在 WebView 里的 Vue 3 瘦客户端,只负责展示、交互、转发请求,所有业务(LLM 调用、文件访问、会话管理)全打后端 :8900;它最有设计含量的两处是「前端 + Rust 壳 + SSO 三方协作的登录流程」和「apiFetch 统一封装自动注入认证头」。随着后端从单 agent 演进到多 agent,前端新增了三个 agent 管理页面和需求管理页,把多 agent 的状态维度直接塞进了 chat store(而非单独建 agent store)——如今 chat store 已是「agent × 会话」二维模型,消息收发也从一问一答的 POST 升级成了 SSE 流式。
核心设计
1. 为什么是瘦客户端:业务全走后端
前端跑在 Tauri 的 WebView 里(Windows 用 WebView2、Mac 用 WKWebView),它不直接调 LLM、不存数据、不读文件,所有业务都通过 HTTP/WS 打后端 127.0.0.1:8900。前端只管三件事:展示 + 交互 + 转发请求。
这是个刻意的架构选择。把前端做薄、后端做厚,换来三个好处:
- 逻辑集中:文件访问、LLM 调用、会话管理都在后端一处实现,不用在 JS 里再写一遍。CLI 渠道、HTTP API、桌面端前端复用同一个后端。
- 安全:敏感操作(文件读写、LLM、邮件)全在后端,受护栏层约束(SSRF 防护、文件三级访问、邮件发信读信护栏)。前端是"哑终端",绕不过后端护栏——即使前端被改,也拿不到后端不给的权限。
- 可替换:前端只是后端 REST + WS 的一个消费者,换成 CLI 或纯 HTTP client 一样能用。
代价是前端每个操作都要一次网络往返,但后端是本机 sidecar(localhost:8900),延迟可以忽略——这个取舍在桌面端场景是划算的。
技术栈:Vue 3(组合式 API)+ TypeScript(前后端同语言)+ Pinia(状态管理)+ Vue Router + shadcn-vue/Tailwind(UI)+ Vite(打包成 dist/ 给 Tauri 嵌入)+ @tauri-apps/api/plugin-http(调 Rust 壳能力)。
注意前端有两套 HTTP 能力,这是理解 SSO 流程的前提:
- 普通
fetch:打后端 :8900(localhost)。 tauriFetch(@tauri-apps/plugin-http的封装,stores/auth.ts内定义):打外部域名(如 SSO 服务器<SSO_HOST>),通过 Rust 层发请求绕过浏览器 CORS。
为什么需要两套:WebView 里的 fetch 受浏览器同源策略限制,直接打外部 SSO 域名会被 CORS 拦掉。Tauri 的 plugin-http 让请求走 Rust 原生 HTTP 客户端,不受 CORS 约束。
tauriFetch 封装本身有个值得注意的细节:它是动态 import plugin-http(不进首屏包),且必须 await——非 Tauri 环境下 tauriFetchRaw 返回的是 rejected promise,不 await 的话 rejection 会逃逸到调用方无法被 catch;调用失败时回退到普通 fetch,这样浏览器直开前端页面时登录流程也能部分工作。
前端在三进程模型里的位置:
用户双击 AgentHub.exe
│
├─ Rust 壳进程(开窗口 + spawn sidecar)
│ ├─ WebView 渲染 Vue 前端 ← 本文讲这层
│ └─ spawn agenthub.exe(后端,:8900)
│ └─ HTTP + WS 服务
│
└─ 前端通过 HTTP/WS 打 127.0.0.1:8900 跟后端通信
2. SSO 登录:前端 + Rust 壳 + SSO 服务器三方协作
这是前端最复杂的流程。公司内部有统一 SSO,员工一个账号登所有系统。AgentHub 接 SSO 而非自建账号体系,理由有三个:员工不用记新密码;登录态由 SSO 统一管理,离职自动失效;用户信息(部门、邮箱、角色)从 SSO 拿,权威准确。URL 里的 service=<SERVICE_CODE> 是 AgentHub 在 SSO 侧的服务标识,SSO 据此区分是哪个系统在请求认证。
登录是一条六步链路,前端、Rust、SSO 各司其职:
用户点"系统登录"
│
▼ ① LoginPage.handleLogin: invoke("open_sso_login")
│ └─ Rust 开 SSO 子窗口,用户输密码登录
│ SSO 重定向带 ticket 的 URL,Rust on_navigation 拦截 ticket
│
▼ ② Rust emit("sso-ticket", {ticket})
│ └─ LoginPage listen("sso-ticket") 收到 ticket
│
▼ ③ authStore.loginWithTicket(ticket)
│ ├─ tauriFetch(SSO /validate2?ticket) → sessionId
│ └─ tauriFetch(SSO /userinfo, header:sid) → userInfo
│ (两次都走 Rust 绕 CORS)
│
▼ ④ setSession(sid, userInfo)
│ ├─ Pinia state(sessionId/userInfo)
│ └─ localStorage(sid + 7 天用户身份缓存)
│
▼ ⑤ apiFetch("/api/user-session", PUT, userInfo)
│ └─ 后端写 auth/current_user.json(供 run_skill_script 注入操作者身份)
│
▼ ⑥ router.replace("/chat")
第 ① 步——触发登录:invoke("open_sso_login") 是 Tauri 的 command 调用,前端调 Rust 壳暴露的命令。Rust 侧创建子 WebView 导航到 SSO 登录页,用户登录后 SSO 重定向带 ticket 的 URL,Rust 的 on_navigation 拦截 ticket。前端调用前先用 isTauri()(判 window.__TAURI_INTERNALS__)检查环境——开发时前端可能单独跑在 Vite dev server(5173),那时没有 Tauri 内核,invoke 会失败,所以非 Tauri 环境直接提示"请在桌面应用中登录"。登录成功后前端不是立刻跳转,而是 setTimeout(300ms) 再 router.replace("/chat"),给"登录成功"状态留一点可见时间。
第 ② 步——事件驱动:前端不轮询,用 @tauri-apps/api/event 的 listen 监听 Rust 发的事件。sso-ticket(Rust 拦到 ticket 后 emit,前端收到开始换 session);sso-cancelled(设计意图是用户没登录就关了子窗口,前端把状态从 waiting 恢复 idle)。onMounted 里注册监听,onUnmounted 里注销防内存泄漏。
一个值得点名的现状:sso-cancelled 的监听前端还保留着(LoginPage.vue),但 Rust 侧全仓库已经没有任何 emit("sso-cancelled")——lib.rs 唯一的 emit 是 sso-ticket,这段监听目前是死代码,用户直接关掉子窗口时 status 会停在 waiting(是有意移除还是遗漏待确认)。
第 ③ 步——ticket 换 session 换 userInfo,分两步换:这是流程里最值得讲的点。
// stores/auth.ts loginWithTicket
// 1. ticket 换 sessionId
const validateResp = await tauriFetch(
`https://<SSO_HOST>/validate2?service=<SERVICE_CODE>&ticket=${encodeURIComponent(ticket)}`
);
const sid = (await validateResp.json()).result as string;
// 2. sessionId 换 userInfo
const userResp = await tauriFetch(
`https://<SSO_HOST>/userinfo?service=<SERVICE_CODE>`,
{ headers: { "X-Usercenter-Session": sid } } // 用 sid 当 header
);
const info = (await userResp.json()).result as SsoUserInfo;
setSession(sid, info);
为什么要分两步:ticket 是一次性临时凭证(SSO 登录成功后给的,用一次就废,且只能用于换 session,不能直接拿用户信息);sessionId 是长期有效的会话 id;userInfo 接口要拿 sessionId 当 X-Usercenter-Session header 认证才给返回。所以必须先 validate2 拿 sid,再拿 sid 去 userinfo 查用户信息,两步不能合并。这也是标准的 CAS/SSO 票据模式——ticket 短时一次性降低泄露风险,真正的会话凭证由服务端换发。两次调用都走 tauriFetch 绕 CORS,且都校验响应的 success 字段,失败抛错给 UI。
第 ④ 步——登录态存两个地方:Pinia state(sessionId/userInfo ref,内存里,刷新就没)+ localStorage(持久化,刷新/重启后还在)。isLoggedIn 是 computed:!!userInfo.value——只看有没有身份,不看 sid,原因是下面的"限时免登录"兜底路径恢复身份时 SSO session 已死、根本没有 sid 可设。
关键细节:localStorage 里存两样东西——ATLANTIS_SESSION_ID(sid)和 ATLANTIS_USER_CACHE(用户身份缓存,带 cachedAt 时间戳)。sid 只存 id 不存 userInfo(userInfo 每次从 SSO 实时拉,保证新鲜);身份缓存则正是 userInfo 的快照,但它有明确的定位差异:
- "限时免登录"机制(
stores/auth.ts的readUserCache):登录成功时缓存 userinfo,SSO session 过期后 7 天内,可以直接用缓存身份进主界面,不必重新走登录。这是"本机信任换体验"的取舍——网关侧的身份头本就是客户端自填的、权限兜底在 RBAC,缓存一份身份到本机不会扩大权限面,却省掉了 7 天内反复重登。logout会主动清缓存:退出是明确动作,下次必须真登录。
第 ⑤ 步——同步 userInfo 给后端:登录成功后 PUT /api/user-session 把 userInfo 给后端,后端写 auth/current_user.json。这个文件的用途是:run_skill_script 工具从这里读操作者身份注入脚本环境变量(比如某个 Skill 需要知道是谁在跑)。这里用 .catch(() => {}) 做"尽力同步"——同步失败不影响登录(前端登录态已存),只是脚本拿不到操作者。
刷新/重启恢复(restoreSession) :
async function doRestoreSession(epoch: number): Promise<boolean> {
const sid = localStorage.getItem("ATLANTIS_SESSION_ID");
if (sid) {
try {
const resp = await tauriFetch(`https://<SSO_HOST>/userinfo?service=<SERVICE_CODE>`,
{ headers: { "X-Usercenter-Session": sid } });
const data = await resp.json();
if (!data.success || !data.result) throw new Error("Session expired");
if (epoch !== sessionEpoch) return false; // 等待期间已登出/重登:本结果作废
setSession(sid, data.result);
apiFetch("/api/user-session", { method: "PUT", body: JSON.stringify(data.result) }).catch(() => {});
return true;
} catch {
localStorage.removeItem("ATLANTIS_SESSION_ID"); // sid 过期,清掉
}
}
// 免登录兜底:SSO session 不可用但本地缓存未过期,直接用缓存身份进主界面
const cached = readUserCache();
if (cached && epoch === sessionEpoch) {
sessionId.value = null;
userInfo.value = cached;
return true;
}
return false;
}
三个设计点:
- 只校验、不重登:
restoreSession不重新走登录流程,只用 sid 调 SSO/userinfo验一下还认不认。用户刷新页面不用重新输密码。校验成功后还会重新 PUT/api/user-session把最新 userInfo 同步给后端——恢复和首次登录走的是同一条后端同步链路。 - 两级兜底:sid 校验失败(过期、甚至断网)都会落到身份缓存兜底——缓存没过期就用缓存身份进主界面。注释里写得很清楚:断网时本地 8900 的功能照常可用,这是有意行为而非漏洞;
current_user.json上次登录已落盘,后端不依赖 SSO session,所以这条路径无需重放 PUT。 - epoch 守卫 + restorePromise 缓存:logout/login 时递增
sessionEpoch,in-flight 的旧恢复请求 resolve 时发现 epoch 变了直接作废——否则登出后 10 秒内已发出的请求会把登录态"复活"。restorePromise缓存让main.ts预热和路由守卫的并发调用只发一次网络请求,且结果保留 10 秒供守卫命中,之后才允许重新恢复(如登出后再登录)。
启动顺序:立即 mount,恢复异步做。main.ts 是先 app.mount 再调 restoreSession() 预热——不等网络,首路由的登录判断交给路由守卫(守卫里 isLoggedIn 不成立会 await 一次 restoreSession,大概率命中刚发起的那次缓存请求)。mount 前 index.html 里有内联启动屏兜底视觉反馈,避免白屏。相比"先恢复再 mount"的方案,这样首帧更快,也不会出现"先显示登录页又跳主页"的闪烁。
退出(logout) :清前端登录态(Pinia + sid + 身份缓存)+ DELETE /api/user-session 通知后端清 current_user.json。注意 logout 不清 SSO 那边的 session(sid 在 SSO 仍有效)——完整的"退出"是前端 logout + Rust 侧 clear_sso_session(打开隐藏 WebView 访问 SSO logout URL 清 WebView cookie,3 秒后自动关窗)配合,前端只负责清本地态。
3. apiFetch:统一请求封装 + 认证头注入
登录态有了,但每个打后端的请求都要带认证头。apiFetch(lib/api.ts)是统一封装,把认证、base URL、Content-Type 三个横切关注点集中处理:
const isTauri = Boolean((window as any).__TAURI_INTERNALS__);
const isDev5173 = location.port === "5173";
// 显式 IPv4:网关只绑 IPv4 回环,localhost 在部分环境优先解析 ::1(IPv6)
// 导致 fetch 失败且不可回退(lib/api.ts 顶部注释)
export const API_BASE = (isTauri || isDev5173) ? "http://127.0.0.1:8900" : "";
export const WS_BASE = (isTauri || isDev5173)
? "ws://127.0.0.1:8900"
: `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}`;
export function getAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = {};
// 网关配了 ADMIN_API_TOKEN 时走 Bearer(构建时注入;未配置则不发,
// 避免空 Bearer 头导致后端鉴权比较失败)
const adminToken = import.meta.env.VITE_ADMIN_API_TOKEN as string | undefined;
if (adminToken) {
headers["Authorization"] = `Bearer ${adminToken}`;
}
const sid = localStorage.getItem("ATLANTIS_SESSION_ID");
if (sid) {
headers["X-Usercenter-Session"] = sid; // SSO 会话头
headers["X-Service-Name"] = "<SERVICE_CODE>"; // 服务标识
}
return headers;
}
export async function apiFetch(path, init?): Promise<Response> {
const url = `${API_BASE}${path}`;
const headers = { ...getAuthHeaders(), ...(init?.headers || {}) };
if (!headers["Content-Type"] && !(init?.body instanceof FormData)) {
headers["Content-Type"] = "application/json";
}
return fetch(url, { ...init, headers });
}
三个设计点:
(a) API_BASE 按环境切换,且写死 127.0.0.1 而非 localhost。Tauri 打包后前端是 tauri:// 协议、后端在本机回环,不同源要写全 http://127.0.0.1:8900;Vite dev(5173)前端后端跨端口也要写全;其他(浏览器直接访问)同源,用相对路径 ""。(isTauri || isDev5173) 判断覆盖了打包和开发两种"需要写全 URL"的场景。用 127.0.0.1 而不用 localhost 是踩过坑的:后端 HTTP channel 默认只绑 IPv4 回环(channels/http/index.ts 里 hostname ?? "127.0.0.1"),而 localhost 在部分环境会优先解析到 IPv6 的 ::1,fetch 直接失败且不会自动回退到 IPv4——显式写 IPv4 地址消除这个歧义。WS 同理,另有 WS_BASE 导出给 /ws/logs 用。
(b) getAuthHeaders 自动注入认证 header。从 localStorage 读 sid,每个请求自动带 X-Usercenter-Session(后端据此认会话)+ X-Service-Name(服务标识);另外若构建时配了 VITE_ADMIN_API_TOKEN,还会注入 Authorization: Bearer <token>(未配置则不发——空 Bearer 头反而会让后端鉴权比较失败)。任何页面调 apiFetch 都自动带认证,不用每个调用点手动加——这是横切关注点统一处理的典型模式。后端对 /api/agents/*、/api/upload 等管理类路径校验 Bearer token。
(c) Content-Type 自动加。除非 body 是 FormData(文件上传,FormData 自己管 boundary),否则自动加 application/json,调用点不用每次写。
有了这个封装,任意页面调后端就是 apiFetch("/api/sessions").then(r => r.json()),不用管 header、不用管 base URL。认证头如果散落在几十个调用点,漏一个就是 bug,改一次要动所有地方;收敛到一处后,调用点只写业务路径和 body。base URL 的三种环境差异同理,统一在 API_BASE 判断,调用点无感知。
4. 多 agent 演进:页面清单 + chat store 的「agent × 会话」二维模型
后端从"一个全局 agent 实例"演进到"多个可配置的 agent 实例并存"后,前端跟着长出了三块:页面、路由、chat store 的状态模型。
页面共 9 个:六个非 agent 页(LoginPage/DashboardPage/ChatPage/ConfigPage/SkillsPage/RequirementsPage)+ 三个 agent 管理页面。注意两处集合变化:原有的 WorkspacePage(工作区/备份页)已删除,备份能力并入 AgentDetailPage 的「备份」tab;RequirementsPage(需求管理看板)是后加的,详见 16 号笔记。
| agent 页 | 职责 |
|---|---|
AgentsPage.vue | agent 列表(展示所有 active agent) |
AgentDetailPage.vue | agent 详情 / 编辑(10 个 tab,见 §7) |
AgentCreatePage.vue | 对话式创建——用户和一个"元 agent"对话,边聊边搭配置草稿,走 HTTP POST /api/agents/create-session + SSE 流 |
导航与默认路由:侧边导航顺序固定为 聊天 → Agent 管理 → 需求管理 → Skill 市场 → 配置 → 仪表盘(AppLayout.vue 的 navItems,Skills 在导航里的名字是「Skill 市场」);默认路由 / 重定向到 /chat,登录成功、已登录进登录页也都跳 /chat。
router 路由(router/index.ts):agent 四条(/agents 列表、/agents/new 与 /agents/new/:sessionId 对话式创建、/agents/:name 按名字进详情)+ /requirements。这些路由都不带 meta.requiresAuth: false,照样过认证守卫。另外除登录页外全部组件都是路由懒加载,首屏只打包 LoginPage;登录页挂载后 prefetchPages() 用 requestIdleCallback 空闲预取主要页面 chunk(注释点明:都是本地静态资源,预取零成本)。一个细节:预取列表是 ChatPage/DashboardPage/ConfigPage/SkillsPage/AgentsPage/RequirementsPage,不含 AgentDetailPage/AgentCreatePage——这两个页面较重且不是落地页,首次点入才加载。
chat store 重写为「agent × 会话」二维模型——这是前端多 agent 化的核心。改造前 chat store 只有 messages: ref<Message[]> 一条扁平消息列表;第一版多 agent 改造是按 agent 一维分桶;现在每个 agent 下还有多个会话(类似 ChatGPT 左侧的会话列表),消息按 agent:sessionId 二维分桶:
export const useChatStore = defineStore("chat", () => {
const currentAgent = ref("default");
const currentSessionId = ref<string>("");
const agents = ref<AgentInfo[]>([]);
const sessionsMap = ref<Map<string, SessionInfo[]>>(new Map()); // agent -> 会话列表
const chatStates = ref<Map<string, SessionChatState>>(new Map()); // "agent:sessionId" -> 消息态
// 派生:当前会话的消息/loading/会话列表按 currentAgent + currentSessionId 取
const currentMessages = computed(() => chatStates.value.get(chatKey(currentAgent.value, currentSessionId.value))?.messages ?? []);
const currentSessions = computed(() => sessionsMap.value.get(currentAgent.value) ?? []);
async function selectAgent(name: string) {
const token = ++selectToken; // 竞态守卫:快速连点只采纳最后一次
currentAgent.value = name;
let sessions = sessionsMap.value.get(name) ?? await loadSessions(name); // 首次切才拉会话列表
if (token !== selectToken) return;
// 有会话则切到第一个,没有则新建一个
sessions.length > 0 ? await switchSession(name, sessions[0]!.chat_id) : await createSession(name);
}
async function switchSession(agent: string, chatId: string) {
currentSessionId.value = chatId;
const state = getOrCreateState(agent, chatId);
if (!state.historyLoaded) await pullHistory(agent, chatId); // 首次进该会话才拉历史
}
async function send(content: string, attachments?: ChatAttachment[], modelOverride?: {...}) {
// ... 推入 user 消息 + 一条带 streaming 标志的 assistant 占位消息
const handle = streamChat(agent, sessionId, content, {
onToken: (text) => { fullText += text; assistantMsg.content = fullText; }, // 逐 token 追加
onDone: (...) => { assistantMsg.streaming = false; refreshSessionMeta(...); },
onError: (msg) => { assistantMsg.content = fullText + `\n\n⚠️ ${msg}`; ... },
onClose: () => { ... },
}, useAuthStore().userInfo?.username, attachments, modelOverride);
activeStreams.set(chatKey(agent, sessionId), { agent, sessionId, close: handle.close });
}
});
设计要点:
- 两张 Map 各管一维:
sessionsMap: Map<agent, SessionInfo[]>管会话列表(title/pinned/updated_at),chatStates: Map<"agent:sessionId", SessionChatState>管每个会话的{ messages, loading, historyLoaded }。chatKey()拼的agent:sessionId跟后端三段式 session_key(如agent:http/chat_id)同名不同义,只是前端本地键。切 agent、切会话互不干扰,不用清空重拉。 currentMessages/currentLoading/currentSessions都是 computed,从两张 Map 里按currentAgent+currentSessionId派生。组件只订阅这几个 computed,切换时 Vue 响应式自动切显示。- 懒加载落在会话维度:
selectAgent首次切到某 agent 才loadSessions(打/api/agents/:name/sessions),switchSession首次进某会话才pullHistory(打/api/agents/:name/chat-history?chat_id=),加载过就不重复拉。且loadSessions网络异常时不写缓存(返回 null),避免把一个空列表当成有效缓存;selectAgent拉取失败时会同时清掉currentSessionId,防止后续send把消息发到错误归属的会话。 - 会话 CRUD 是一整套:
createSession/deleteSession/renameSession/pinSession/autoTitleSession,对应后端/api/agents/:name/sessions系列端点。首次发消息后refreshSessionMeta把默认标题「新会话」换成首条消息前 40 字,并调auto-title让后端起个更贴切的标题;若发现会话不在本地缓存(其他窗口创建/缓存丢失),会先补一条 fallback 再更新updated_at,避免该会话的排序永不刷新。createSession只在用户仍停留在该 agent 时才切换过去,防止慢响应覆盖用户后续的选择。 - 历史消息做元数据剥离:
pullHistory用stripMetaPrefix把后端buildUserContent注入的[channel/user_id/chat_id]元数据前缀剥掉——前缀只在消息开头连续若干行,用锚定行首的正则剥,正文中间的同类样式行不动。这个函数与后端storage/sessions.ts的stripSessionMetaPrefix互为镜像,注释里明确约定:新增前缀字段时两边同步。 send从"POST 等完整响应"升级为 SSE 流式:streamChat打POST /chat?stream=1,按event:行分发 text/tool_call/tool_result/done/error 五类事件(lib/sseChat.ts);assistant 占位消息带streaming标志逐 token 追加,长回答体验从"转圈等全文"变成"打字机"。请求体在agent/chat_id/content之外还支持user_id(取自 auth store 的域帐号)、attachments(附件)和model/provider(模型覆盖,见 §7 ModelSelector)。解析还有单行容错:某行 JSON 畸形时跳过该行而不是杀死整条流。- 流按所属会话登记,abort 找流不找当前态:活跃流存在
activeStreams: Map<chatKey, StreamHandle>,abort(agent, sessionId)停的是流所属的那条会话——用户切到别的会话再点停止,停的仍是正确的那条。 - 两处竞态处理值得学:一是
selectToken单调递增令牌,快速连点 agent 时只采纳最后一次selectAgent的状态写入;二是deleteSession先 abort 流再 DELETE——反过来做的话,DELETE 后仍在跑的 SSE 流 onDone 里的refreshSessionMeta会把已删会话当"缓存丢失"重新插回列表,产生幽灵会话(代码注释原话)。 - REST 已全部收敛到 apiFetch(loadAgents/loadSessions/pullHistory/会话 CRUD),认证头一致;唯一例外是 SSE 链路的
streamChat仍是裸 fetch、不带 SSO 头,见第八节。
一个值得展开的设计选择:没有单独建 agent store,而是把 agent 维度塞进 chat store。理由是前端要管的 agent 状态,本质上全是聊天维度的状态——哪个 agent 是当前对话对象、每个 agent 有哪些会话、每个会话的消息列表、历史加没加载过、发消息带哪个 agent 哪个会话。这些数据和聊天强耦合:切 agent 就是切当前对话,拉历史就是拉某会话的消息。如果拆成两个 store,currentAgent 在 agent store、messages 在 chat store,那 currentMessages 这个 computed 就要跨 store 依赖,反而增加耦合和心智负担。把"当前 agent/会话"和"按会话分桶的消息"放一起,currentMessages = chatStates.get(chatKey(currentAgent, currentSessionId))?.messages 一个 computed 就搞定,内聚。至于 AgentsPage 的 CRUD、AgentDetailPage 的编辑,这些是"管理 agent 配置"而非"和 agent 聊天",它们直接打 /api/agents/* 接口,是无状态的一次性请求,不需要常驻 store。所以按"哪些状态需要跨组件常驻共享"来划分:agent 的聊天态天然属于 chat store,配置态不需要 store——没有第四个 store 的位置。
5. 路由守卫 + store 职责划分
路由守卫每个路由跳转前都过一遍:
router.beforeEach(async (to, _from, next) => {
if (to.meta.requiresAuth === false) { next(); return; } // 登录页放行
const authStore = useAuthStore();
if (authStore.isLoggedIn) { next(); return; } // 已登录放行
const restored = await authStore.restoreSession(); // 尝试恢复
if (restored) { next(); return; }
next("/login"); // 都不行,跳登录页
});
第三步是关键:刷新页面时 Pinia state 丢了(isLoggedIn 为 false),但 localStorage 有 sid 和身份缓存。守卫调 restoreSession,sid 有效就恢复登录态放行;sid 过期还有 7 天免登录缓存兜底。main.ts 启动时也预热调一次(立即 mount 后发起,守卫首次导航大概率命中缓存);守卫里再调是兜底(session 过期后用户点导航)。由于 restorePromise 缓存 + 10 秒结果保留,两处调用不会重复打 SSO。
Pinia store 各管一摊(注意:多 agent 后没有新增 agent store,agent 状态在 chat store 里;需求管理有独立的三件套):
| store | 管什么 | 谁用 |
|---|---|---|
| auth | SSO 登录态(userInfo/sessionId)+ 7 天身份缓存 + 登录/恢复/退出方法 | 路由守卫、LoginPage、所有需认证的请求 |
| gateway | sidecar 运行状态 + 主题(dark/light)+ 侧边栏折叠 | DashboardPage(启停控制)、AppLayout(主题/侧边栏) |
| chat | 按「agent × 会话」分桶的聊天状态 + agent 列表 + 会话 CRUD + SSE 流 | ChatPage、AgentsPage |
| requirements / reqChat / members | 需求管理三块:需求看板数据、聊需求(SSE 流式,复用 streamChat)、成员 | RequirementsPage 及需求组件 |
| updater | 检查更新 / 下载进度 / 安装状态 | UpdateDialog、配置页「关于/更新」 |
store 都用组合式 API(setup 风格)定义,ref 暴露状态、函数暴露操作,组件用 useXxxStore() 拿到同一个单例跨组件共享。举例:DashboardPage 点"停止 sidecar" → 调 apiFetch → 更新 gatewayStore.status → 顶栏订阅 status 的组件自动更新显示,这是 Vue 响应式的价值。gateway store 里主题和侧边栏折叠是纯 UI 偏好:主题切换改 <html> 的 dark class 让 Tailwind 切换深浅色,并同步 color-scheme 让滚动条/原生控件跟随;偏好持久化在 localStorage agenthub-theme,initTheme() 启动时恢复。
6. 前后端接口契约
前端所有业务都通过后端 REST + WS,散见的接口汇总:
| 接口 | 方法 | 干什么 | 谁用 |
|---|---|---|---|
/api/user-session | PUT/DELETE | 同步/清除 SSO userInfo | auth store(登录后/恢复后/退出) |
/api/gateway/* | GET/POST | sidecar 启停重启 + 状态 | DashboardPage |
/api/config | GET/PUT | 读/改配置(也提供 llm.providers 模型列表) | ConfigPage、ModelSelector |
/api/skills/* | GET/POST | 技能市场 | SkillsPage |
/api/agents | GET | active agent 列表 | chat store / AgentsPage |
/api/agents/:name/chat-history | GET | 某 agent 某会话的对话历史(?chat_id=) | chat store(按会话懒加载) |
/api/agents/:name/sessions 系列 | GET/POST/DELETE/PATCH + /pin + /auto-title | 会话 CRUD/置顶/自动命名 | chat store |
/api/agents/* | GET/POST/PUT/DELETE | agent CRUD + 生命周期(publish/archive/rollback)+ versions(版本/备份)+ subagents + 对话式创建(create-session,SSE) | AgentsPage/AgentDetailPage/AgentCreatePage |
/api/agents/:name/backups | GET/POST | 备份列表/还原(替代已废弃的 /api/backups*,前端已无任何旧接口调用) | AgentDetailPage「备份」tab |
/api/mcp-servers、/api/chats/seen | GET | MCP 服务列表、渠道会话已读上报 | AgentDetailPage |
/api/upload | POST | 附件上传(返回 AttachmentMeta,随 /chat 的 attachments 发出) | ChatPage(粘贴/拖拽) |
/chat | POST | 发消息给 agent;?stream=1 走 SSE(text/tool_call/tool_result/done/error),带 agent/chat_id/user_id/attachments/model 字段 | chat store(streamChat) |
/ws/logs | WS | 实时日志流 | ConfigPage(通过 useLogStream composable 消费) |
注意 /api/agents/* 这组若后端配了 ADMIN_API_TOKEN 需带 Authorization: Bearer <token>——由 getAuthHeaders 在构建时注入 VITE_ADMIN_API_TOKEN 自动携带(见 §3)。发消息接口是 /chat(不带 /api 前缀),历史接口才在 /api/agents/:name/chat-history。
7. 其他值得讲的实现细节
7.1 MarkdownMessage:流式 Markdown 渲染的性能三件套
assistant 消息用 MarkdownMessage.vue 渲染,技术栈 marked(解析)+ DOMPurify(消毒)+ highlight.js(高亮),三个性能取舍都可圈可点:
- highlight.js 按需注册 14 种语言(javascript/typescript/python/bash/json/yaml/sql/java/go/cpp/xml/css/markdown/diff):不用
lib/common全量 36 语言(~119kB gzip),只 registerLanguage 常用语言并注册别名,明显的包体积优化。 - 流式期间 ~120ms 节流渲染:token 到达频率远高于人眼感知,每个 token 全量 parse+sanitize+highlight 会打满主线程(注释原话:"页面整体卡顿的元凶"),所以流式期间批量提交,结束时立即同步最终内容。
- 流式期间未标语言的代码块跳过 highlightAuto:正则探测语言很贵,流式中间态的代码块本来也不完整,直接原样输出,结束后再补高亮。
另外 marked 的 code renderer 包了层 header(语言标签 + 复制按钮),DOMPurify 兜住 XSS——LLM 输出是不可信内容,必须消毒。
7.2 聊天页三栏布局与渲染窗口化
ChatPage 是三栏:Agent 列表(可折叠)/ 会话列表(搜索框 + 置顶 + 按「置顶」与日期分组)/ 聊天区。这三栏正好对应 chat store 的三层数据:agents → sessionsMap → chatStates,UI 结构和状态模型是同构的。
消息列表做了渲染侧窗口化分页:lib/messageWindow.ts 定义 MESSAGE_PAGE_SIZE = 30,首屏只渲染最近 30 条,顶部「加载更早的 N 条消息」按需展开;加载更早期间还悬挂自动置底并维持视口锚点,否则一加载就跳到底。注意这不是接口分页——后端 chat-history 仍一次返回全量(admin.ts 的 getAgentChatHistory 直接 sessionHistory.load(key) 全量返回),窗口化解决的是长会话的 DOM 渲染压力,不是网络传输。
7.3 ModelSelector:按 agent 记忆的模型切换器
components/chat/ModelSelector.vue,主聊天和聊需求共用的公共组件。模型列表是模块级共享状态(modelOptions.ts):从 /api/config 读 llm.providers 全部模型,带 30 秒缓存窗口,main.ts 启动时预取,进页面即刻渲染不等 fetch;同时解析出 defaultModelKey(全局 default_provider 的默认模型)。选中项按 scope 记忆在 localStorage chat:model-override:<scope>——主聊天传 agent 名作 scope,每个 agent 各自记住模型偏好;聊需求等无 agent 上下文的场景回落到全局键。两个兜底细节:选中项恰好等于默认模型时视同「默认」(默认模型不在选项列表里,直接绑定会显示空白);选中项已不在可选列表(模型被下线)时自动回退默认并落盘。选中的覆盖最终作为 model/provider 字段随 streamChat 发给后端,「默认」= 不发送覆盖,跟随全局 default_provider。
7.4 附件上传
/api/upload 上传后拿到 AttachmentMeta,随 /chat 的 attachments 字段发出;交互上支持粘贴和拖拽入聊天区。用户消息本地预览附件,历史消息不回放附件(chat.ts 的 Message 接口注释)。
7.5 AgentDetailPage 的 10 个 tab
agent 详情页是配置编辑的主战场,10 个 tab:basic(基本信息)/channels(渠道)/tools(工具)/skills(Skills)/mcp(MCP)/email(邮件)/shell(Shell)/file-access(文件访问)/subagents(子 Agent)/versions(备份)。注意没有独立的「模型」tab——模型选择在聊天页的 ModelSelector,不在配置页。原 WorkspacePage 的备份能力就并入了这里的 versions tab(接口也换成了 /api/agents/:name/backups)。
7.6 需求管理(详见 16 号笔记)
RequirementsPage + requirements 组件 + requirements/reqChat/members 三个 store + useReqCache,是一块完整的需求看板 + 「聊需求」功能;聊需求复用主聊天的 streamChat SSE 实现(chat_id 用 req-draft:<draftId> 与主聊天的 desktop 区分);docParser 动态 import mammoth/xlsx 解析上传的 Word/Excel 需求文档(动态 import 是为了不把这两个重库打进首屏)。
7.7 前端测试
vitest + jsdom,脚本 test: vitest run(desktop/package.json),文档在 desktop/docs/FRONTEND_TESTING.md,目前 17 个 .test.ts,覆盖页面级(如 ConfigPage/auth store/KeepAlive 集成)和工具库级(messageWindow/logBuffer/scroll)。
7.8 工程化细节(一句话一条)
路由懒加载 + 登录后空闲预取(§4 已述);KeepAlive 页面缓存,include 显式列出 7 个页面(注意不含 DashboardPage——它是状态页,每次进入都该看到最新数据,不该吃缓存),切 tab 不丢页面状态,另有注释提醒 include 逗号后不能有空格(Vue 不 trim,带空格永不匹配);粘性导航 useStickyNav(记住各主 tab 内最后停留的子路径,dashboard 除外);开机自启的三态记忆(lib/autostart.ts,见 01 号笔记);Tooltip/SaveBar/ConfirmDialog 等组件抽取;aria-label/tabindex 等 a11y 改进;主题持久化(§5 已述)。
现状与可以改进的地方
最后如实盘点:哪些坑已经填了,哪些还在。
已经落地的:主题偏好持久化(localStorage + color-scheme 同步);chat store 的 REST 全部收敛到 apiFetch;聊天流式——send 已从 POST 等完整响应升级为 POST /chat?stream=1 的 SSE(§4),并且主聊天和聊需求共用同一个 streamChat。分页做了一半:渲染侧的 30 条窗口(§7.2)解决了长会话 DOM 压力,但后端 chat-history 仍一次返回全量,消息特别多时网络传输和首屏解析还是全量的。
还开着的:
- SSE 链路不带认证头:
streamChat是裸 fetch,只有 Content-Type/Accept,没有getAuthHeaders的那套头。目前 localhost 隔离下能跑,但如果后端把 header 校验真启用到/chat,这条链路会缺认证——理想做法是让 streamChat 也复用getAuthHeaders。 - chat store 的错误处理偏粗:多数
catch吞掉或塞一条错误消息,没有重试和更细的错误区分。 - 主聊天还没接工具进度:SSE 通道里有 tool_call/tool_result 事件,但只有 AgentCreatePage/reqChat 消费(onToolCall/onToolResult),主聊天的 send 没传这两个回调——主聊天界面暂无工具进度展示。
- AgentsPage 只展示 active agent(
loadAgents里 filter),draft/archived 的 agent 在这个列表看不到,要管理它们得走别的入口。 sso-cancelled是死代码(§2),用户直接关 SSO 子窗口后 LoginPage 的 status 会停在 waiting。- 中期方向:真·接口分页(按时间倒序 + 滚动加载);agent × 会话数量大时给
sessionsMap/chatStates两张 Map 加 LRU 淘汰,避免所有会话的消息常驻内存。
小结
AgentHub 桌面端前端是 Vue 3 瘦客户端,业务全打后端 :8900(换来逻辑集中、安全、可复用);它用普通 fetch 打后端、tauriFetch 走 Rust 绕 CORS 打外部 SSO;SSO 登录是前端 invoke 开子窗口、Rust 拦 ticket emit、前端两次 tauriFetch(ticket 换 sid、sid 换 userInfo,分两步是因为 ticket 一次性且不带用户信息)的三方协作,外加一个 7 天身份缓存做免登录兜底;apiFetch 把认证头(SSO 双 header + 可选 Bearer)/base URL(显式 127.0.0.1 避 IPv6 坑)/Content-Type 三个横切关注点统一注入;刷新时 restoreSession 用 sid 校验免重登(mount 立即执行、恢复异步预热,恢复成功还会重新同步 user-session,epoch 守卫防旧请求复活登录态);后端多 agent 化后前端页面定型为 9 个(WorkspacePage 删除、新增三个 agent 页和需求管理页,导航 聊天→Agent 管理→需求管理→Skill 市场→配置→仪表盘,默认 /chat),并把聊天状态重写成「agent × 会话」二维模型(sessionsMap + chatStates,会话 CRUD 走 /api/agents/:name/sessions),收发升级为 streamChat 的 SSE 流式(POST /chat?stream=1,逐 token 渲染,MarkdownMessage 用 120ms 节流 + 按需高亮兜底性能)——这些状态本质是聊天态、和聊天强耦合,所以塞进 chat store 而非单独建 agent store,单独拆反而增加跨 store 依赖。