diff --git a/packages/shared/data/cache/cacheValueTypes.ts b/packages/shared/data/cache/cacheValueTypes.ts index 2d35c9fa9d..833bca6f33 100644 --- a/packages/shared/data/cache/cacheValueTypes.ts +++ b/packages/shared/data/cache/cacheValueTypes.ts @@ -26,6 +26,14 @@ export type CacheTopic = Topic */ export type TabType = 'route' | 'webview' +/** + * Tab saved state for hibernation recovery + */ +export interface TabSavedState { + scrollPosition?: number + // 其他必要草稿字段可在此扩展 +} + export interface Tab { id: string type: TabType @@ -33,8 +41,11 @@ export interface Tab { title: string icon?: string metadata?: Record - // TODO: LRU 优化字段,后续添加 - // lastAccessTime?: number + // LRU 字段 + lastAccessTime?: number // open/switch 时更新 + isDormant?: boolean // 是否已休眠 + isPinned?: boolean // 是否置顶(豁免 LRU) + savedState?: TabSavedState // 休眠前保存的状态 } export interface TabsState { diff --git a/src/renderer/src/components/layout/AppShell.tsx b/src/renderer/src/components/layout/AppShell.tsx index 1b2e38dc0a..dcecbe61b3 100644 --- a/src/renderer/src/components/layout/AppShell.tsx +++ b/src/renderer/src/components/layout/AppShell.tsx @@ -43,20 +43,21 @@ export const AppShell = () => { {/* Zone 1: Sidebar */} -
+
{/* Zone 2: Tab Bar */}
- + {tabs.map((tab) => ( - {tab.title} + {/* TODO: pin功能,形式还未确定 */} + {tab.title} {tabs.length > 1 && (
{ {/* Zone 3: Content Area - Multi MemoryRouter Architecture */}
- {/* Route Tabs: Each has independent MemoryRouter */} + {/* Route Tabs: Only render non-dormant tabs */} {tabs - .filter((t) => t.type === 'route') + .filter((t) => t.type === 'route' && !t.isDormant) .map((tab) => ( { /> ))} - {/* Webview Tabs */} + {/* Webview Tabs: Only render non-dormant tabs */} {tabs - .filter((t) => t.type === 'webview') + .filter((t) => t.type === 'webview' && !t.isDormant) .map((tab) => ( ))} diff --git a/src/renderer/src/hooks/useTabs.ts b/src/renderer/src/hooks/useTabs.ts index bd8dc06970..faba51aed3 100644 --- a/src/renderer/src/hooks/useTabs.ts +++ b/src/renderer/src/hooks/useTabs.ts @@ -1,4 +1,6 @@ -import { useCallback, useEffect, useMemo } from 'react' +import { loggerService } from '@logger' +import { TabLRUManager } from '@renderer/services/TabLRUManager' +import { useCallback, useEffect, useMemo, useRef } from 'react' import { usePersistCache } from '../data/hooks/useCache' import { uuid } from '../utils' @@ -6,13 +8,17 @@ import { getDefaultRouteTitle } from '../utils/routeTitle' // Re-export types from shared schema export type { Tab, TabsState, TabType } from '@shared/data/cache/cacheValueTypes' -import type { Tab, TabType } from '@shared/data/cache/cacheValueTypes' +import type { Tab, TabSavedState, TabType } from '@shared/data/cache/cacheValueTypes' + +const logger = loggerService.withContext('useTabs') const DEFAULT_TAB: Tab = { id: 'home', type: 'route', url: '/', - title: getDefaultRouteTitle('/') + title: getDefaultRouteTitle('/'), + lastAccessTime: Date.now(), + isDormant: false } /** @@ -32,6 +38,13 @@ export interface OpenTabOptions { export function useTabs() { const [tabsState, setTabsState] = usePersistCache('ui.tab.state') + // LRU 管理器(单例) + const lruManagerRef = useRef(null) + if (!lruManagerRef.current) { + lruManagerRef.current = new TabLRUManager() + } + const lruManager = lruManagerRef.current + // Ensure at least one default tab exists useEffect(() => { if (tabsState.tabs.length === 0) { @@ -42,17 +55,144 @@ export function useTabs() { const tabs = useMemo(() => (tabsState.tabs.length > 0 ? tabsState.tabs : [DEFAULT_TAB]), [tabsState.tabs]) const activeTabId = tabsState.activeTabId || DEFAULT_TAB.id + /** + * 内部方法:执行休眠检查并休眠超额标签 + */ + const performHibernationCheck = useCallback( + (currentTabs: Tab[], newActiveTabId: string) => { + const toHibernate = lruManager.checkAndGetDormantCandidates(currentTabs, newActiveTabId) + + if (toHibernate.length === 0) { + return currentTabs + } + + // 批量休眠 + return currentTabs.map((tab) => { + if (toHibernate.includes(tab.id)) { + logger.info('Tab hibernated', { tabId: tab.id, route: tab.url }) + // TODO: 保存滚动位置等状态 + const savedState: TabSavedState = { scrollPosition: 0 } + return { ...tab, isDormant: true, savedState } + } + return tab + }) + }, + [lruManager] + ) + + /** + * 休眠标签(手动) + * + * TODO: 目前 savedState 仅为占位符,后续需实现: + * - 捕获真实滚动位置 + * - 保存必要的草稿/表单状态 + */ + const hibernateTab = useCallback( + (tabId: string) => { + const tab = tabsState.tabs.find((t) => t.id === tabId) + if (!tab || tab.isDormant) return + + // TODO: 实现真实的状态捕获 + const savedState: TabSavedState = { scrollPosition: 0 } + + logger.info('Tab hibernated (manual)', { tabId, route: tab.url }) + + setTabsState({ + ...tabsState, + tabs: tabsState.tabs.map((t) => (t.id === tabId ? { ...t, isDormant: true, savedState } : t)) + }) + }, + [tabsState, setTabsState] + ) + + /** + * 唤醒标签 + * + * TODO: 目前仅清除 isDormant 标记,后续需实现: + * - 从 savedState 恢复滚动位置 + * - 恢复草稿/表单状态 + */ + const wakeTab = useCallback( + (tabId: string) => { + const tab = tabsState.tabs.find((t) => t.id === tabId) + if (!tab || !tab.isDormant) return + + logger.info('Tab awakened', { tabId, route: tab.url }) + + // TODO: 实现真实的状态恢复(从 tab.savedState) + setTabsState({ + ...tabsState, + tabs: tabsState.tabs.map((t) => (t.id === tabId ? { ...t, isDormant: false, lastAccessTime: Date.now() } : t)) + }) + }, + [tabsState, setTabsState] + ) + + const updateTab = useCallback( + (id: string, updates: Partial) => { + setTabsState({ + ...tabsState, + tabs: tabsState.tabs.map((t) => (t.id === id ? { ...t, ...updates } : t)) + }) + }, + [tabsState, setTabsState] + ) + + const setActiveTab = useCallback( + (id: string) => { + if (id === activeTabId) return + + const targetTab = tabs.find((t) => t.id === id) + if (!targetTab) return + + // 1. 准备更新后的标签列表 + let updatedTabs = tabsState.tabs.map((t) => + t.id === id + ? { + ...t, + lastAccessTime: Date.now(), + // 如果目标是休眠状态,唤醒它 + isDormant: false + } + : t + ) + + // 2. 如果唤醒了休眠标签,记录日志 + if (targetTab.isDormant) { + logger.info('Tab awakened', { tabId: id, route: targetTab.url }) + } + + // 3. 执行休眠检查(可能需要休眠其他标签) + updatedTabs = performHibernationCheck(updatedTabs, id) + + // 4. 更新状态 + setTabsState({ tabs: updatedTabs, activeTabId: id }) + }, + [activeTabId, tabs, tabsState, setTabsState, performHibernationCheck] + ) + const addTab = useCallback( (tab: Tab) => { const exists = tabs.find((t) => t.id === tab.id) if (exists) { - setTabsState({ ...tabsState, activeTabId: tab.id }) + setActiveTab(tab.id) return } - const newTabs = [...tabs, tab] + + // 添加 LRU 字段 + const newTab: Tab = { + ...tab, + lastAccessTime: Date.now(), + isDormant: false + } + + // 执行休眠检查 + let newTabs = [...tabs, newTab] + newTabs = performHibernationCheck(newTabs, tab.id) + setTabsState({ tabs: newTabs, activeTabId: tab.id }) }, - [tabs, tabsState, setTabsState] + [tabs, setTabsState, setActiveTab, performHibernationCheck] ) const closeTab = useCallback( @@ -71,23 +211,6 @@ export function useTabs() { [tabs, activeTabId, setTabsState] ) - const setActiveTab = useCallback( - (id: string) => { - if (id !== activeTabId) { - setTabsState({ ...tabsState, activeTabId: id }) - } - }, - [activeTabId, tabsState, setTabsState] - ) - - const updateTab = useCallback( - (id: string, updates: Partial) => { - const newTabs = tabs.map((t) => (t.id === id ? { ...t, ...updates } : t)) - setTabsState({ ...tabsState, tabs: newTabs }) - }, - [tabs, tabsState, setTabsState] - ) - const setTabs = useCallback( (newTabs: Tab[] | ((prev: Tab[]) => Tab[])) => { const resolvedTabs = typeof newTabs === 'function' ? newTabs(tabs) : newTabs @@ -128,12 +251,14 @@ export function useTabs() { } } - // Create new tab with default route title + // Create new tab with default route title and LRU fields const newTab: Tab = { id: id || uuid(), type, url, - title: title || getDefaultRouteTitle(url) + title: title || getDefaultRouteTitle(url), + lastAccessTime: Date.now(), + isDormant: false } addTab(newTab) @@ -142,6 +267,28 @@ export function useTabs() { [tabs, setActiveTab, addTab] ) + /** + * Pin a tab (exempt from LRU hibernation) + */ + const pinTab = useCallback( + (id: string) => { + updateTab(id, { isPinned: true }) + logger.info('Tab pinned', { tabId: id }) + }, + [updateTab] + ) + + /** + * Unpin a tab + */ + const unpinTab = useCallback( + (id: string) => { + updateTab(id, { isPinned: false }) + logger.info('Tab unpinned', { tabId: id }) + }, + [updateTab] + ) + /** * Get the currently active tab */ @@ -162,6 +309,12 @@ export function useTabs() { setTabs, // High-level Tab operations - openTab + openTab, + + // LRU operations + hibernateTab, + wakeTab, + pinTab, + unpinTab } } diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index 488c1cbccf..907b1573de 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -34,18 +34,14 @@ const HomePage: FC = () => { const search = useSearch({ strict: false }) as { assistantId?: string; topicId?: string } // 根据 search params 中的 ID 查找对应的 assistant - const assistantFromSearch = search.assistantId - ? assistants.find((a) => a.id === search.assistantId) - : undefined + const assistantFromSearch = search.assistantId ? assistants.find((a) => a.id === search.assistantId) : undefined const [activeAssistant, _setActiveAssistant] = useState( assistantFromSearch || _activeAssistant || assistants[0] ) // 根据 search params 中的 topicId 查找对应的 topic - const topicFromSearch = search.topicId - ? activeAssistant?.topics?.find((t) => t.id === search.topicId) - : undefined + const topicFromSearch = search.topicId ? activeAssistant?.topics?.find((t) => t.id === search.topicId) : undefined const { activeTopic, setActiveTopic: _setActiveTopic } = useActiveTopic(activeAssistant?.id ?? '', topicFromSearch) const [showAssistants] = usePreference('assistant.tab.show') diff --git a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx index 7399dc226a..f540434b87 100644 --- a/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx +++ b/src/renderer/src/pages/home/Messages/Blocks/ErrorBlock.tsx @@ -68,11 +68,7 @@ const ErrorMessage: React.FC<{ block: ErrorMessageBlock }> = ({ block }) => { values={{ provider: getProviderLabel(providerId) }} components={{ provider: ( - + ) }} /> diff --git a/src/renderer/src/services/TabLRUManager.ts b/src/renderer/src/services/TabLRUManager.ts new file mode 100644 index 0000000000..c536bc5d76 --- /dev/null +++ b/src/renderer/src/services/TabLRUManager.ts @@ -0,0 +1,198 @@ +import { loggerService } from '@logger' +import type { Tab } from '@shared/data/cache/cacheValueTypes' + +const logger = loggerService.withContext('TabLRU') + +/** + * Tab LRU limits configuration + * + * Controls when inactive tabs should be hibernated to save memory. + * TODO: 后续可从偏好设置注入 + */ +export const TAB_LIMITS = { + /** + * 软上限:活跃标签数超过此值时触发 LRU 休眠 + * 默认 10,可根据实际内存使用情况调整 + */ + softCap: 10, + + /** + * 硬保险丝:极端兜底,防止 runaway + * 当活跃标签数超过此值时,强制休眠超额部分 + */ + hardCap: 22 +} + +export type TabLimits = typeof TAB_LIMITS + +/** + * TabLRUManager - 管理标签页的 LRU 休眠策略 + * + * 功能: + * - 当活跃标签数超过软上限时,选择 LRU 候选进行休眠 + * - 硬保险丝作为极端兜底,防止内存失控 + * - 支持豁免机制:当前标签、首页、置顶标签不参与休眠 + */ +export class TabLRUManager { + private softCap: number + private hardCap: number + + constructor(limits: TabLimits = TAB_LIMITS) { + this.softCap = limits.softCap + this.hardCap = limits.hardCap + } + + /** + * 检查并返回需要休眠的标签 ID 列表 + * + * 策略: + * - 超过 softCap:休眠到 softCap + * - 超过 hardCap:强制休眠到 softCap(忽略部分豁免,仅保留当前+首页) + * + * @param tabs 所有标签 + * @param activeTabId 当前活动标签 ID + * @returns 需要休眠的标签 ID 数组 + */ + checkAndGetDormantCandidates(tabs: Tab[], activeTabId: string): string[] { + const activeTabs = tabs.filter((t) => !t.isDormant) + const activeCount = activeTabs.length + + // 未超软上限,无需休眠 + if (activeCount <= this.softCap) { + return [] + } + + const isHardCapTriggered = activeCount > this.hardCap + + // 获取候选列表 + // 硬保险丝触发时,使用更宽松的豁免规则(仅保留当前+首页) + const candidates = isHardCapTriggered + ? this.getHardCapCandidates(activeTabs, activeTabId) + : this.getLRUCandidates(activeTabs, activeTabId) + + // 计算需要休眠的数量:始终休眠到 softCap + let toHibernateCount = activeCount - this.softCap + + if (isHardCapTriggered) { + logger.warn('Hard cap triggered - using relaxed exemption rules', { + activeCount, + hardCap: this.hardCap, + softCap: this.softCap, + toHibernate: toHibernateCount + }) + } + + // 只能休眠可用的候选数量 + toHibernateCount = Math.min(toHibernateCount, candidates.length) + + // 检查是否能达到目标 + const afterHibernation = activeCount - toHibernateCount + if (isHardCapTriggered && afterHibernation > this.hardCap) { + // 极端情况:即使放宽豁免,仍无法降到 hardCap 以下 + logger.error('Cannot guarantee hard cap - insufficient candidates', { + activeCount, + candidatesAvailable: candidates.length, + willHibernate: toHibernateCount, + afterHibernation, + hardCap: this.hardCap + }) + } else if (afterHibernation > this.softCap) { + // 一般情况:无法降到 softCap,但仍在 hardCap 以下 + logger.warn('Cannot reach soft cap - limited by available candidates', { + activeCount, + candidatesAvailable: candidates.length, + willHibernate: toHibernateCount, + afterHibernation, + softCap: this.softCap + }) + } + + const result = candidates.slice(0, toHibernateCount).map((t) => t.id) + + if (result.length > 0) { + logger.info('Tabs selected for hibernation', { + count: result.length, + ids: result, + activeCount, + softCap: this.softCap, + hardCapTriggered: isHardCapTriggered + }) + } + + return result + } + + /** + * 硬保险丝候选列表(仅豁免当前标签和首页) + */ + private getHardCapCandidates(tabs: Tab[], activeTabId: string): Tab[] { + return tabs + .filter((tab) => !this.isHardExempt(tab, activeTabId)) + .sort((a, b) => (a.lastAccessTime ?? 0) - (b.lastAccessTime ?? 0)) + } + + /** + * 硬保险丝豁免判断(更严格,仅保留当前+首页) + */ + private isHardExempt(tab: Tab, activeTabId: string): boolean { + return ( + tab.id === activeTabId || // 当前活动标签 + tab.id === 'home' || // 首页 + tab.isDormant === true // 已休眠的不再参与 + ) + // 注意:isPinned 在硬保险丝触发时不再豁免 + } + + /** + * 获取 LRU 候选列表(排除豁免项,按访问时间升序) + */ + private getLRUCandidates(tabs: Tab[], activeTabId: string): Tab[] { + return tabs + .filter((tab) => !this.isExempt(tab, activeTabId)) + .sort((a, b) => (a.lastAccessTime ?? 0) - (b.lastAccessTime ?? 0)) + } + + /** + * 判断标签是否豁免休眠 + * + * 豁免条件: + * - 当前活动标签 + * - 首页标签 (id === 'home') + * - 置顶标签 (isPinned) + * - 已休眠的标签(不重复处理) + */ + private isExempt(tab: Tab, activeTabId: string): boolean { + return ( + tab.id === activeTabId || // 当前活动标签 + tab.id === 'home' || // 首页 + tab.isPinned === true || // 置顶标签 + tab.isDormant === true // 已休眠的不再参与 + ) + } + + /** + * 更新软上限(供未来设置页使用) + */ + updateSoftCap(newSoftCap: number): void { + this.softCap = newSoftCap + logger.info('SoftCap updated', { newSoftCap }) + } + + /** + * 更新硬上限(供未来设置页使用) + */ + updateHardCap(newHardCap: number): void { + this.hardCap = newHardCap + logger.info('HardCap updated', { newHardCap }) + } + + /** + * 获取当前配置 + */ + getLimits(): TabLimits { + return { + softCap: this.softCap, + hardCap: this.hardCap + } + } +}