feat: profile-aware cache isolation and UX improvements

- Fix chat store cache keys to include profile name, prevent data leaking between profiles
- Defer cache hydration to after profile load to avoid race condition
- Remove collapsible sidebar feature (not needed)
- Remove confirmation dialog on profile switch (direct reload)
- Auto-start gateway when creating new profile
- Clear profile-specific localStorage cache on profile delete (safe prefix matching)
- Clean up unused imports in SettingsView

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ekko
2026-04-18 14:32:54 +08:00
parent 17f0cdc1de
commit 27051dcb32
6 changed files with 97 additions and 79 deletions
@@ -35,12 +35,6 @@ async function toggleDetail() {
} }
function handleSwitch() { function handleSwitch() {
dialog.warning({
title: t('profiles.switchTo'),
content: t('profiles.switchConfirm', { name: props.profile.name }),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
profilesStore.switchProfile(props.profile.name).then(ok => { profilesStore.switchProfile(props.profile.name).then(ok => {
if (ok) { if (ok) {
window.location.reload() window.location.reload()
@@ -48,8 +42,6 @@ function handleSwitch() {
message.error(t('profiles.switchFailed')) message.error(t('profiles.switchFailed'))
} }
}) })
},
})
} }
function handleDelete() { function handleDelete() {
+47 -50
View File
@@ -3,6 +3,7 @@ import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type He
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useAppStore } from './app' import { useAppStore } from './app'
import { useProfilesStore } from './profiles'
export interface Attachment { export interface Attachment {
id: string id: string
@@ -160,18 +161,31 @@ function mapHermesSession(s: SessionSummary): Session {
} }
// Cache keys for stale-while-revalidate loading of sessions / messages. // Cache keys for stale-while-revalidate loading of sessions / messages.
// All keys include the active profile name to isolate cache between profiles.
// Rendering from cache on boot avoids the multi-round-trip wait the user sees // Rendering from cache on boot avoids the multi-round-trip wait the user sees
// every time they open the page (esp. noticeable on mobile). // every time they open the page (esp. noticeable on mobile).
const STORAGE_KEY = 'hermes_active_session' const STORAGE_KEY_PREFIX = 'hermes_active_session_'
const SESSIONS_CACHE_KEY = 'hermes_sessions_cache_v1' const SESSIONS_CACHE_KEY_PREFIX = 'hermes_sessions_cache_v1_'
const MSGS_CACHE_KEY_PREFIX = 'hermes_session_msgs_v1_'
// tmux-like resume: persist active run info so a refresh/reopen mid-run can
// pick up the working indicator and poll fetchSession for new progress.
const IN_FLIGHT_KEY_PREFIX = 'hermes_in_flight_v1_'
const IN_FLIGHT_TTL_MS = 15 * 60 * 1000 // Give up after 15 minutes const IN_FLIGHT_TTL_MS = 15 * 60 * 1000 // Give up after 15 minutes
const POLL_INTERVAL_MS = 2000 const POLL_INTERVAL_MS = 2000
const POLL_STABLE_EXITS = 3 // 3 × 2s = 6s of no change → assume run finished const POLL_STABLE_EXITS = 3 // 3 × 2s = 6s of no change → assume run finished
// 获取当前 profile 名称,用于隔离缓存。
// 从 profiles store 的 activeProfileName(同步 localStorage)读取,
// 避免异步加载导致 chat store 初始化时拿到 null。
function getProfileName(): string {
try {
return useProfilesStore().activeProfileName || 'default'
} catch {
return 'default'
}
}
function storageKey(): string { return STORAGE_KEY_PREFIX + getProfileName() }
function sessionsCacheKey(): string { return SESSIONS_CACHE_KEY_PREFIX + getProfileName() }
function msgsCacheKey(sid: string): string { return `hermes_session_msgs_v1_${getProfileName()}_${sid}_` }
function inFlightKey(sid: string): string { return `hermes_in_flight_v1_${getProfileName()}_${sid}` }
interface InFlightRun { interface InFlightRun {
runId: string runId: string
startedAt: number startedAt: number
@@ -216,7 +230,7 @@ function sanitizeForCache(msgs: Message[]): Message[] {
export const useChatStore = defineStore('chat', () => { export const useChatStore = defineStore('chat', () => {
const sessions = ref<Session[]>([]) const sessions = ref<Session[]>([])
const activeSessionId = ref<string | null>(localStorage.getItem(STORAGE_KEY)) const activeSessionId = ref<string | null>(null)
const streamStates = ref<Map<string, AbortController>>(new Map()) const streamStates = ref<Map<string, AbortController>>(new Map())
const isStreaming = computed(() => activeSessionId.value != null && streamStates.value.has(activeSessionId.value)) const isStreaming = computed(() => activeSessionId.value != null && streamStates.value.has(activeSessionId.value))
const isLoadingSessions = ref(false) const isLoadingSessions = ref(false)
@@ -235,25 +249,10 @@ export const useChatStore = defineStore('chat', () => {
const activeSession = ref<Session | null>(null) const activeSession = ref<Session | null>(null)
const messages = computed<Message[]>(() => activeSession.value?.messages || []) const messages = computed<Message[]>(() => activeSession.value?.messages || [])
// Hydrate from cache synchronously so the UI renders instantly on boot.
// Network revalidation happens in loadSessions() below.
const cachedSessions = loadJson<Session[]>(SESSIONS_CACHE_KEY)
if (cachedSessions?.length) {
sessions.value = cachedSessions
if (activeSessionId.value) {
const cachedActive = cachedSessions.find(s => s.id === activeSessionId.value) || null
if (cachedActive) {
const cachedMsgs = loadJson<Message[]>(MSGS_CACHE_KEY_PREFIX + activeSessionId.value)
if (cachedMsgs) cachedActive.messages = cachedMsgs
activeSession.value = cachedActive
}
}
}
function persistSessionsList() { function persistSessionsList() {
// Cache lightweight summaries only (messages are cached per-session). // Cache lightweight summaries only (messages are cached per-session).
saveJson( saveJson(
SESSIONS_CACHE_KEY, sessionsCacheKey(),
sessions.value.map(s => ({ ...s, messages: [] })), sessions.value.map(s => ({ ...s, messages: [] })),
) )
} }
@@ -262,22 +261,22 @@ export const useChatStore = defineStore('chat', () => {
const sid = activeSessionId.value const sid = activeSessionId.value
if (!sid) return if (!sid) return
const s = sessions.value.find(sess => sess.id === sid) const s = sessions.value.find(sess => sess.id === sid)
if (s) saveJson(MSGS_CACHE_KEY_PREFIX + sid, sanitizeForCache(s.messages)) if (s) saveJson(msgsCacheKey(sid), sanitizeForCache(s.messages))
} }
function markInFlight(sid: string, runId: string) { function markInFlight(sid: string, runId: string) {
saveJson(IN_FLIGHT_KEY_PREFIX + sid, { runId, startedAt: Date.now() } as InFlightRun) saveJson(inFlightKey(sid), { runId, startedAt: Date.now() } as InFlightRun)
} }
function clearInFlight(sid: string) { function clearInFlight(sid: string) {
removeItem(IN_FLIGHT_KEY_PREFIX + sid) removeItem(inFlightKey(sid))
} }
function readInFlight(sid: string): InFlightRun | null { function readInFlight(sid: string): InFlightRun | null {
const rec = loadJson<InFlightRun>(IN_FLIGHT_KEY_PREFIX + sid) const rec = loadJson<InFlightRun>(inFlightKey(sid))
if (!rec) return null if (!rec) return null
if (Date.now() - rec.startedAt > IN_FLIGHT_TTL_MS) { if (Date.now() - rec.startedAt > IN_FLIGHT_TTL_MS) {
removeItem(IN_FLIGHT_KEY_PREFIX + sid) removeItem(inFlightKey(sid))
return null return null
} }
return rec return rec
@@ -379,6 +378,22 @@ export const useChatStore = defineStore('chat', () => {
async function loadSessions() { async function loadSessions() {
isLoadingSessions.value = true isLoadingSessions.value = true
try { try {
// 从 profile 对应的缓存中恢复,实现 instant render
const cachedSessions = loadJson<Session[]>(sessionsCacheKey())
if (cachedSessions?.length) {
sessions.value = cachedSessions
const savedId = localStorage.getItem(storageKey())
if (savedId) {
const cachedActive = cachedSessions.find(s => s.id === savedId) || null
if (cachedActive) {
const cachedMsgs = loadJson<Message[]>(msgsCacheKey(savedId))
if (cachedMsgs) cachedActive.messages = cachedMsgs
activeSession.value = cachedActive
activeSessionId.value = savedId
}
}
}
const list = await fetchSessions() const list = await fetchSessions()
const fresh = list.map(mapHermesSession) const fresh = list.map(mapHermesSession)
const freshIds = new Set(fresh.map(s => s.id)) const freshIds = new Set(fresh.map(s => s.id))
@@ -455,7 +470,7 @@ export const useChatStore = defineStore('chat', () => {
async function switchSession(sessionId: string) { async function switchSession(sessionId: string) {
activeSessionId.value = sessionId activeSessionId.value = sessionId
localStorage.setItem(STORAGE_KEY, sessionId) localStorage.setItem(storageKey(), sessionId)
activeSession.value = sessions.value.find(s => s.id === sessionId) || null activeSession.value = sessions.value.find(s => s.id === sessionId) || null
if (!activeSession.value) return if (!activeSession.value) return
@@ -465,7 +480,7 @@ export const useChatStore = defineStore('chat', () => {
// loading state while we fetch. // loading state while we fetch.
const hasLocalMessages = activeSession.value.messages.length > 0 const hasLocalMessages = activeSession.value.messages.length > 0
if (!hasLocalMessages) { if (!hasLocalMessages) {
const cachedMsgs = loadJson<Message[]>(MSGS_CACHE_KEY_PREFIX + sessionId) const cachedMsgs = loadJson<Message[]>(msgsCacheKey(sessionId))
if (cachedMsgs?.length) { if (cachedMsgs?.length) {
activeSession.value.messages = cachedMsgs activeSession.value.messages = cachedMsgs
} }
@@ -559,7 +574,7 @@ export const useChatStore = defineStore('chat', () => {
async function deleteSession(sessionId: string) { async function deleteSession(sessionId: string) {
await deleteSessionApi(sessionId) await deleteSessionApi(sessionId)
sessions.value = sessions.value.filter(s => s.id !== sessionId) sessions.value = sessions.value.filter(s => s.id !== sessionId)
removeItem(MSGS_CACHE_KEY_PREFIX + sessionId) removeItem(msgsCacheKey(sessionId))
persistSessionsList() persistSessionsList()
if (activeSessionId.value === sessionId) { if (activeSessionId.value === sessionId) {
if (sessions.value.length > 0) { if (sessions.value.length > 0) {
@@ -878,29 +893,11 @@ export const useChatStore = defineStore('chat', () => {
} }
} }
// Load sessions on init (cache has already hydrated the UI above). // Tab visibility: re-sync when returning to foreground
loadSessions()
// tmux-like resume on boot: if the last active session has a persisted
// in-flight run that's still fresh, show the working indicator immediately
// and start polling the server. loadSessions() above will call
// switchSession which also triggers this path, but doing it synchronously
// here means the UI shows "working" from the very first frame even while
// loadSessions is still in flight.
if (activeSessionId.value && readInFlight(activeSessionId.value)) {
startPolling(activeSessionId.value)
}
// When the tab returns to the foreground, re-sync the active session from
// the server. Mobile browsers suspend tabs aggressively, and any in-flight
// run that completed while we were backgrounded won't have reached the
// in-memory state otherwise.
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && activeSessionId.value && !isStreaming.value) { if (document.visibilityState === 'visible' && activeSessionId.value && !isStreaming.value) {
void refreshActiveSession() void refreshActiveSession()
// Resume polling too in case we put a run in-flight before going to
// background and the SSE got killed.
if (readInFlight(activeSessionId.value)) { if (readInFlight(activeSessionId.value)) {
startPolling(activeSessionId.value) startPolling(activeSessionId.value)
} }
@@ -3,8 +3,12 @@ import { ref } from 'vue'
import * as profilesApi from '@/api/hermes/profiles' import * as profilesApi from '@/api/hermes/profiles'
import type { HermesProfile, HermesProfileDetail } from '@/api/hermes/profiles' import type { HermesProfile, HermesProfileDetail } from '@/api/hermes/profiles'
const ACTIVE_PROFILE_STORAGE_KEY = 'hermes_active_profile_name'
export const useProfilesStore = defineStore('profiles', () => { export const useProfilesStore = defineStore('profiles', () => {
const profiles = ref<HermesProfile[]>([]) const profiles = ref<HermesProfile[]>([])
// 初始化时同步读 localStorage,确保其他 store(如 chat)在启动时能拿到 profile name
const activeProfileName = ref<string | null>(localStorage.getItem(ACTIVE_PROFILE_STORAGE_KEY))
const activeProfile = ref<HermesProfile | null>(null) const activeProfile = ref<HermesProfile | null>(null)
const detailMap = ref<Record<string, HermesProfileDetail>>({}) const detailMap = ref<Record<string, HermesProfileDetail>>({})
const loading = ref(false) const loading = ref(false)
@@ -15,6 +19,11 @@ export const useProfilesStore = defineStore('profiles', () => {
try { try {
profiles.value = await profilesApi.fetchProfiles() profiles.value = await profilesApi.fetchProfiles()
activeProfile.value = profiles.value.find(p => p.active) ?? null activeProfile.value = profiles.value.find(p => p.active) ?? null
// 同步缓存 profile name,供其他 store 启动时读取
if (activeProfile.value) {
activeProfileName.value = activeProfile.value.name
localStorage.setItem(ACTIVE_PROFILE_STORAGE_KEY, activeProfile.value.name)
}
} catch (err) { } catch (err) {
console.error('Failed to fetch profiles:', err) console.error('Failed to fetch profiles:', err)
} finally { } finally {
@@ -43,11 +52,31 @@ export const useProfilesStore = defineStore('profiles', () => {
const ok = await profilesApi.deleteProfile(name) const ok = await profilesApi.deleteProfile(name)
if (ok) { if (ok) {
delete detailMap.value[name] delete detailMap.value[name]
// 清理该 profile 的 localStorage 缓存
clearProfileCache(name)
await fetchProfiles() await fetchProfiles()
} }
return ok return ok
} }
// 清理指定 profile 的所有 localStorage 缓存(精确匹配缓存 key 前缀)
function clearProfileCache(profileName: string) {
const prefixes = [
`hermes_sessions_cache_v1_${profileName}`,
`hermes_session_msgs_v1_${profileName}_`,
`hermes_in_flight_v1_${profileName}_`,
`hermes_active_session_${profileName}`,
]
const keysToRemove: string[] = []
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i)
if (key && prefixes.some(p => key.startsWith(p))) {
keysToRemove.push(key)
}
}
keysToRemove.forEach(key => localStorage.removeItem(key))
}
async function renameProfile(name: string, newName: string) { async function renameProfile(name: string, newName: string) {
const ok = await profilesApi.renameProfile(name, newName) const ok = await profilesApi.renameProfile(name, newName)
if (ok) { if (ok) {
@@ -81,6 +110,7 @@ export const useProfilesStore = defineStore('profiles', () => {
return { return {
profiles, profiles,
activeProfile, activeProfile,
activeProfileName,
detailMap, detailMap,
loading, loading,
switching, switching,
@@ -3,12 +3,16 @@ import { onMounted } from 'vue'
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue' import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
import { useAppStore } from '@/stores/hermes/app' import { useAppStore } from '@/stores/hermes/app'
import { useChatStore } from '@/stores/hermes/chat' import { useChatStore } from '@/stores/hermes/chat'
import { useProfilesStore } from '@/stores/hermes/profiles'
const appStore = useAppStore() const appStore = useAppStore()
const chatStore = useChatStore() const chatStore = useChatStore()
const profilesStore = useProfilesStore()
onMounted(() => { onMounted(async () => {
appStore.loadModels() appStore.loadModels()
// 先加载 profile,确保缓存 key 使用正确的 profile name
await profilesStore.fetchProfiles()
chatStore.loadSessions() chatStore.loadSessions()
}) })
</script> </script>
@@ -4,10 +4,6 @@ import {
NTabs, NTabs,
NTabPane, NTabPane,
NSpin, NSpin,
NSwitch,
NInput,
NInputNumber,
useMessage,
} from "naive-ui"; } from "naive-ui";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useSettingsStore } from "@/stores/hermes/settings"; import { useSettingsStore } from "@/stores/hermes/settings";
@@ -16,23 +12,13 @@ import AgentSettings from "@/components/hermes/settings/AgentSettings.vue";
import MemorySettings from "@/components/hermes/settings/MemorySettings.vue"; import MemorySettings from "@/components/hermes/settings/MemorySettings.vue";
import SessionSettings from "@/components/hermes/settings/SessionSettings.vue"; import SessionSettings from "@/components/hermes/settings/SessionSettings.vue";
import PrivacySettings from "@/components/hermes/settings/PrivacySettings.vue"; import PrivacySettings from "@/components/hermes/settings/PrivacySettings.vue";
import SettingRow from "@/components/hermes/settings/SettingRow.vue";
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const message = useMessage();
const { t } = useI18n(); const { t } = useI18n();
onMounted(() => { onMounted(() => {
settingsStore.fetchSettings(); settingsStore.fetchSettings();
}); });
async function saveApiServer(values: Record<string, any>) {
try {
await settingsStore.saveSection("platforms", { api_server: values });
message.success(t("settings.saved"));
} catch (err: any) {
message.error(t("settings.saveFailed"));
}
}
</script> </script>
<template> <template>
@@ -32,6 +32,15 @@ profileRoutes.post('/api/hermes/profiles', async (ctx) => {
try { try {
const output = await hermesCli.createProfile(name, clone) const output = await hermesCli.createProfile(name, clone)
// 创建完成后启动该 profile 的网关
const mgr = getGatewayManager()
if (mgr) {
try { await mgr.start(name) } catch (err: any) {
console.error(`[Profile] Failed to start gateway for ${name}:`, err.message)
}
}
ctx.body = { success: true, message: output.trim() } ctx.body = { success: true, message: output.trim() }
} catch (err: any) { } catch (err: any) {
ctx.status = 500 ctx.status = 500