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:
@@ -35,20 +35,12 @@ async function toggleDetail() {
|
||||
}
|
||||
|
||||
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 => {
|
||||
if (ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
message.error(t('profiles.switchFailed'))
|
||||
}
|
||||
})
|
||||
},
|
||||
profilesStore.switchProfile(props.profile.name).then(ok => {
|
||||
if (ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
message.error(t('profiles.switchFailed'))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type He
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useAppStore } from './app'
|
||||
import { useProfilesStore } from './profiles'
|
||||
|
||||
export interface Attachment {
|
||||
id: string
|
||||
@@ -160,18 +161,31 @@ function mapHermesSession(s: SessionSummary): Session {
|
||||
}
|
||||
|
||||
// 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
|
||||
// every time they open the page (esp. noticeable on mobile).
|
||||
const STORAGE_KEY = 'hermes_active_session'
|
||||
const SESSIONS_CACHE_KEY = '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 STORAGE_KEY_PREFIX = 'hermes_active_session_'
|
||||
const SESSIONS_CACHE_KEY_PREFIX = 'hermes_sessions_cache_v1_'
|
||||
const IN_FLIGHT_TTL_MS = 15 * 60 * 1000 // Give up after 15 minutes
|
||||
const POLL_INTERVAL_MS = 2000
|
||||
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 {
|
||||
runId: string
|
||||
startedAt: number
|
||||
@@ -216,7 +230,7 @@ function sanitizeForCache(msgs: Message[]): Message[] {
|
||||
|
||||
export const useChatStore = defineStore('chat', () => {
|
||||
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 isStreaming = computed(() => activeSessionId.value != null && streamStates.value.has(activeSessionId.value))
|
||||
const isLoadingSessions = ref(false)
|
||||
@@ -235,25 +249,10 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const activeSession = ref<Session | null>(null)
|
||||
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() {
|
||||
// Cache lightweight summaries only (messages are cached per-session).
|
||||
saveJson(
|
||||
SESSIONS_CACHE_KEY,
|
||||
sessionsCacheKey(),
|
||||
sessions.value.map(s => ({ ...s, messages: [] })),
|
||||
)
|
||||
}
|
||||
@@ -262,22 +261,22 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const sid = activeSessionId.value
|
||||
if (!sid) return
|
||||
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) {
|
||||
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) {
|
||||
removeItem(IN_FLIGHT_KEY_PREFIX + sid)
|
||||
removeItem(inFlightKey(sid))
|
||||
}
|
||||
|
||||
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 (Date.now() - rec.startedAt > IN_FLIGHT_TTL_MS) {
|
||||
removeItem(IN_FLIGHT_KEY_PREFIX + sid)
|
||||
removeItem(inFlightKey(sid))
|
||||
return null
|
||||
}
|
||||
return rec
|
||||
@@ -379,6 +378,22 @@ export const useChatStore = defineStore('chat', () => {
|
||||
async function loadSessions() {
|
||||
isLoadingSessions.value = true
|
||||
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 fresh = list.map(mapHermesSession)
|
||||
const freshIds = new Set(fresh.map(s => s.id))
|
||||
@@ -455,7 +470,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
|
||||
async function switchSession(sessionId: string) {
|
||||
activeSessionId.value = sessionId
|
||||
localStorage.setItem(STORAGE_KEY, sessionId)
|
||||
localStorage.setItem(storageKey(), sessionId)
|
||||
activeSession.value = sessions.value.find(s => s.id === sessionId) || null
|
||||
|
||||
if (!activeSession.value) return
|
||||
@@ -465,7 +480,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
// loading state while we fetch.
|
||||
const hasLocalMessages = activeSession.value.messages.length > 0
|
||||
if (!hasLocalMessages) {
|
||||
const cachedMsgs = loadJson<Message[]>(MSGS_CACHE_KEY_PREFIX + sessionId)
|
||||
const cachedMsgs = loadJson<Message[]>(msgsCacheKey(sessionId))
|
||||
if (cachedMsgs?.length) {
|
||||
activeSession.value.messages = cachedMsgs
|
||||
}
|
||||
@@ -559,7 +574,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
async function deleteSession(sessionId: string) {
|
||||
await deleteSessionApi(sessionId)
|
||||
sessions.value = sessions.value.filter(s => s.id !== sessionId)
|
||||
removeItem(MSGS_CACHE_KEY_PREFIX + sessionId)
|
||||
removeItem(msgsCacheKey(sessionId))
|
||||
persistSessionsList()
|
||||
if (activeSessionId.value === sessionId) {
|
||||
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).
|
||||
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.
|
||||
// Tab visibility: re-sync when returning to foreground
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible' && activeSessionId.value && !isStreaming.value) {
|
||||
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)) {
|
||||
startPolling(activeSessionId.value)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,12 @@ import { ref } from 'vue'
|
||||
import * as profilesApi 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', () => {
|
||||
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 detailMap = ref<Record<string, HermesProfileDetail>>({})
|
||||
const loading = ref(false)
|
||||
@@ -15,6 +19,11 @@ export const useProfilesStore = defineStore('profiles', () => {
|
||||
try {
|
||||
profiles.value = await profilesApi.fetchProfiles()
|
||||
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) {
|
||||
console.error('Failed to fetch profiles:', err)
|
||||
} finally {
|
||||
@@ -43,11 +52,31 @@ export const useProfilesStore = defineStore('profiles', () => {
|
||||
const ok = await profilesApi.deleteProfile(name)
|
||||
if (ok) {
|
||||
delete detailMap.value[name]
|
||||
// 清理该 profile 的 localStorage 缓存
|
||||
clearProfileCache(name)
|
||||
await fetchProfiles()
|
||||
}
|
||||
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) {
|
||||
const ok = await profilesApi.renameProfile(name, newName)
|
||||
if (ok) {
|
||||
@@ -81,6 +110,7 @@ export const useProfilesStore = defineStore('profiles', () => {
|
||||
return {
|
||||
profiles,
|
||||
activeProfile,
|
||||
activeProfileName,
|
||||
detailMap,
|
||||
loading,
|
||||
switching,
|
||||
|
||||
@@ -3,12 +3,16 @@ import { onMounted } from 'vue'
|
||||
import ChatPanel from '@/components/hermes/chat/ChatPanel.vue'
|
||||
import { useAppStore } from '@/stores/hermes/app'
|
||||
import { useChatStore } from '@/stores/hermes/chat'
|
||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||
|
||||
const appStore = useAppStore()
|
||||
const chatStore = useChatStore()
|
||||
const profilesStore = useProfilesStore()
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
appStore.loadModels()
|
||||
// 先加载 profile,确保缓存 key 使用正确的 profile name
|
||||
await profilesStore.fetchProfiles()
|
||||
chatStore.loadSessions()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -4,10 +4,6 @@ import {
|
||||
NTabs,
|
||||
NTabPane,
|
||||
NSpin,
|
||||
NSwitch,
|
||||
NInput,
|
||||
NInputNumber,
|
||||
useMessage,
|
||||
} from "naive-ui";
|
||||
import { useI18n } from "vue-i18n";
|
||||
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 SessionSettings from "@/components/hermes/settings/SessionSettings.vue";
|
||||
import PrivacySettings from "@/components/hermes/settings/PrivacySettings.vue";
|
||||
import SettingRow from "@/components/hermes/settings/SettingRow.vue";
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const message = useMessage();
|
||||
const { t } = useI18n();
|
||||
|
||||
onMounted(() => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
||||
Reference in New Issue
Block a user