refactor: make store smaller

This commit is contained in:
lin onetwo 2025-05-13 14:21:58 +08:00
parent afb9150683
commit 7b770fef88
16 changed files with 563 additions and 423 deletions

View file

@ -26,7 +26,9 @@
"Title": "Configuration Issue"
},
"EmptyStateDescription": "Create a new session or select an existing one from the left to start chatting.",
"Error": "Dialogue error",
"InputPlaceholder": "Type a message, Ctrl+Enter to send",
"Loading": "Dialogue loading",
"NewSession": "New Session",
"OpenInWorkspaceTiddler": "Open {{title}} in {{workspace}} workspace",
"Send": "Send",

View file

@ -25,7 +25,9 @@
"Title": "Problème de configuration"
},
"EmptyStateDescription": "Créez une nouvelle session ou sélectionnez-en une existante à gauche pour commencer à discuter.",
"Error": "Erreur de dialogue",
"InputPlaceholder": "Tapez un message, Ctrl+Entrée pour envoyer",
"Loading": "Chargement de la conversation en cours",
"NewSession": "Nouvelle session",
"OpenInWorkspaceTiddler": "Ouvrir {{title}} dans l'espace de travail {{workspace}}",
"Send": "Envoyer",

View file

@ -26,7 +26,9 @@
"Title": "設定の問題"
},
"EmptyStateDescription": "新しいセッションを作成するか、左から既存のセッションを選択して会話を開始してください。",
"Error": "会話エラー",
"InputPlaceholder": "メッセージを入力、Ctrl+Enterで送信",
"Loading": "会話を読み込み中",
"NewSession": "新しいセッション",
"OpenInWorkspaceTiddler": "{{workspace}}ワークスペースで{{title}}を開く",
"Send": "送信",

View file

@ -26,7 +26,9 @@
"Title": "Проблема с конфигурацией"
},
"EmptyStateDescription": "Создайте новую сессию или выберите существующую слева, чтобы начать общение.",
"Error": "Ошибка диалога",
"InputPlaceholder": "Введите сообщение, Ctrl+Enter для отправки",
"Loading": "Диалог загружается",
"NewSession": "Новая сессия",
"OpenInWorkspaceTiddler": "Открыть {{title}} в рабочей области {{workspace}}",
"Send": "Отправить",

View file

@ -26,7 +26,9 @@
"Title": "配置问题"
},
"EmptyStateDescription": "创建一个新会话或者从左侧选择一个已有会话来开始对话。",
"Error": "对话错误",
"InputPlaceholder": "输入消息Ctrl+Enter 发送",
"Loading": "对话加载中",
"NewSession": "新建会话",
"OpenInWorkspaceTiddler": "在{{workspace}}工作区中打开{{title}}",
"Send": "发送",
@ -76,16 +78,6 @@
"QuickAccess": "快速访问",
"SearchPlaceholder": "搜索标签页或智能体..."
},
"Search": {
"AvailableAgents": "可用的智能体",
"FailedToCreateChatWithAgent": "无法创建与智能体的对话",
"FailedToFetchAgents": "获取智能体列表失败",
"NoAgentsFound": "未找到智能体",
"NoClosedTabsFound": "没有最近关闭的标签页",
"NoTabsFound": "没有找到标签页",
"OpenTabs": "打开的标签页",
"RecentlyClosedTabs": "最近关闭的标签页"
},
"Preference": {
"AIModelSelection": "AI 模型选择",
"AIModelSelectionDescription": "选择您想要使用的 AI 提供商和模型",
@ -101,8 +93,8 @@
"BaseURLRequired": "API 地址为必填项",
"Cancel": "取消",
"CancelAddProvider": "取消添加",
"ConfigureProvider": "配置 {{provider}}",
"ConfigureModelParameters": "配置参数",
"ConfigureProvider": "配置 {{provider}}",
"CreateCustomModel": "创建自定义模型",
"Custom": "自定义接口",
"CustomProvider": "自定义提供方",
@ -164,5 +156,15 @@
"TemperatureDescription": "较低的值会产生更确定性、更集中的响应,较高的值会产生更多样化、更创造性的响应",
"TopP": "Top P",
"TopPDescription": "控制响应的随机性。较低的值使响应更确定,较高的值允许更多的可能性"
},
"Search": {
"AvailableAgents": "可用的智能体",
"FailedToCreateChatWithAgent": "无法创建与智能体的对话",
"FailedToFetchAgents": "获取智能体列表失败",
"NoAgentsFound": "未找到智能体",
"NoClosedTabsFound": "没有最近关闭的标签页",
"NoTabsFound": "没有找到标签页",
"OpenTabs": "打开的标签页",
"RecentlyClosedTabs": "最近关闭的标签页"
}
}

View file

@ -91,7 +91,7 @@
"winston-transport": "4.9.0",
"wouter": "^3.6.0",
"zod": "^3.24.3",
"zustand": "^5.0.3",
"zustand": "^5.0.4",
"zx": "8.3.1"
},
"optionalDependencies": {

View file

@ -4,14 +4,14 @@ import SendIcon from '@mui/icons-material/Send';
import SmartToyIcon from '@mui/icons-material/SmartToy';
import TuneIcon from '@mui/icons-material/Tune';
import { Avatar, Box, Button, CircularProgress, IconButton, TextField, Tooltip, Typography } from '@mui/material';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styled from 'styled-components';
import { ModelParametersDialog } from '@/pages/Preferences/sections/ExternalAPI/components/ModelParametersDialog';
import { useTaskConfigManagement } from '@/pages/Preferences/sections/ExternalAPI/useAIConfigManagement';
import { useAgentChatStore } from '../../../store/agentChatStore';
import { IChatTab } from '../../../types/tab';
import { useAgentChat } from '../hooks/useAgentChat';
interface ChatTabContentProps {
tab: IChatTab;
@ -89,10 +89,29 @@ export const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab }) => {
const [parametersDialogOpen, setParametersDialogOpen] = useState(false);
const agentId = tab.agentId;
const agentDefId = tab.agentDefId;
// 使用自定义 hook 获取和管理 Agent 数据
const { messages, loading, error, sendMessage, agent } = useAgentChat(agentId, agentDefId);
// Use store to get and manage Agent data
const { messages, loading, error, agent, fetchAgent, subscribeToUpdates, sendMessage, createAgent } = useAgentChatStore();
// Initialize and subscribe to updates
useEffect(() => {
let cleanupSubscription: (() => void) | undefined;
if (agentId) {
// If we have agentId, fetch and subscribe to updates
void fetchAgent(agentId);
cleanupSubscription = subscribeToUpdates(agentId);
} else if (agentDefId) {
// If we don't have agentId but have agentDefId, create a new Agent
void createAgent(agentDefId);
}
// Cleanup subscription when component unmounts
return () => {
if (cleanupSubscription) cleanupSubscription();
};
}, [agentId, agentDefId, fetchAgent, subscribeToUpdates, createAgent]);
const { config, handleConfigChange } = useTaskConfigManagement({
agentId: agent?.id,
agentDefId: agent?.agentDefId,
@ -111,15 +130,15 @@ export const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab }) => {
}, []);
const handleSendMessage = useCallback(async () => {
if (!inputMessage.trim()) return;
if (!inputMessage.trim() || !agentId) return;
try {
await sendMessage(inputMessage);
await sendMessage(agentId, inputMessage);
setInputMessage('');
} catch (err) {
console.error('Error sending message:', err);
}
}, [inputMessage, sendMessage]);
}, [inputMessage, sendMessage, agentId]);
const handleKeyPress = useCallback((event: React.KeyboardEvent) => {
if (event.key === 'Enter' && !event.shiftKey) {
@ -133,7 +152,7 @@ export const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab }) => {
<ChatHeader>
<Title variant='h6'>{agent?.name || t(tab.title)}</Title>
<Box sx={{ display: 'flex', gap: 1 }}>
{loading && <CircularProgress size={20} sx={{ mr: 1 }} />}
{loading && <CircularProgress size={20} sx={{ mr: 1 }} color='primary' />}
<Tooltip title={t('Preference.ModelParameters', { ns: 'agent' })}>
<IconButton onClick={openParametersDialog} size='small'>
<TuneIcon />
@ -143,38 +162,44 @@ export const ChatTabContent: React.FC<ChatTabContentProps> = ({ tab }) => {
</ChatHeader>
<MessagesContainer>
{loading && messages.length === 0 ? (
<Box sx={{ textAlign: 'center', mt: 4 }}>
<CircularProgress size={40} />
<Typography variant='body1' sx={{ mt: 2 }}>
{t('Chat.Loading')}
</Typography>
</Box>
) : error ? (
<Box sx={{ textAlign: 'center', color: 'error.main', mt: 4 }}>
<Typography variant='body1'>
{t('Chat.Error')}: {error.message}
</Typography>
</Box>
) : messages.length === 0 ? (
<Box sx={{ textAlign: 'center', color: 'text.secondary', mt: 4 }}>
<SmartToyIcon sx={{ fontSize: 48, opacity: 0.5 }} />
<Typography variant='body1' sx={{ mt: 2 }}>
{t('Chat.StartConversation')}
</Typography>
</Box>
) : (
messages.map(message => (
<MessageBubble key={message.id} $isUser={message.role === 'user'}>
<MessageAvatar $isUser={message.role === 'user'}>
{message.role === 'user' ? <PersonIcon /> : <SmartToyIcon />}
</MessageAvatar>
<MessageContent $isUser={message.role === 'user'}>
<Typography variant='body1'>{message.content}</Typography>
</MessageContent>
</MessageBubble>
))
)}
{loading && messages.length === 0
? (
<Box sx={{ textAlign: 'center', mt: 4 }}>
<CircularProgress size={40} color='primary' />
<Typography variant='body1' sx={{ mt: 2 }}>
{t('Chat.Loading')}
</Typography>
</Box>
)
: error
? (
<Box sx={{ textAlign: 'center', color: 'error.main', mt: 4 }}>
<Typography variant='body1'>
{t('Chat.Error')}: {error.message}
</Typography>
</Box>
)
: messages.length === 0
? (
<Box sx={{ textAlign: 'center', color: 'text.secondary', mt: 4 }}>
<SmartToyIcon sx={{ fontSize: 48, opacity: 0.5 }} />
<Typography variant='body1' sx={{ mt: 2 }}>
{t('Chat.StartConversation')}
</Typography>
</Box>
)
: (
messages.map(message => (
<MessageBubble key={message.id} $isUser={message.role === 'user'}>
<MessageAvatar $isUser={message.role === 'user'}>
{message.role === 'user' ? <PersonIcon /> : <SmartToyIcon />}
</MessageAvatar>
<MessageContent $isUser={message.role === 'user'}>
<Typography variant='body1'>{message.content}</Typography>
</MessageContent>
</MessageBubble>
))
)}
</MessagesContainer>
<InputContainer>

View file

@ -1,153 +0,0 @@
/* eslint-disable unicorn/prevent-abbreviations */
import { AgentInstance, AgentInstanceMessage } from '@services/agentInstance/interface';
import { useCallback, useEffect, useState } from 'react';
/**
* Agent Hook
* 使 agentId
*/
export const useAgentChat = (agentId?: string, agentDefId?: string) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [agent, setAgent] = useState<AgentInstance | null>(null);
const [messages, setMessages] = useState<AgentInstanceMessage[]>([]);
// 获取 Agent 实例
const fetchAgent = useCallback(async () => {
if (!agentId) return;
try {
setLoading(true);
setError(null);
const result = await window.service.agentInstance.getAgent(agentId);
if (result) {
setAgent(result);
setMessages(result.messages);
}
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
console.error('Failed to fetch agent:', err);
} finally {
setLoading(false);
}
}, [agentId]);
// 订阅 Agent 更新
const subscribeToUpdates = useCallback(() => {
if (!agentId) return;
try {
const subscription = window.observables.agentInstance.subscribeToAgentUpdates(agentId).subscribe({
next: (updatedAgent) => {
if (updatedAgent) {
setAgent(updatedAgent);
setMessages(updatedAgent.messages);
}
},
error: (err) => {
console.error('Error in agent subscription:', err);
setError(err instanceof Error ? err : new Error(String(err)));
},
});
// 返回清除函数
return () => {
subscription.unsubscribe();
};
} catch (err) {
console.error('Failed to subscribe to agent updates:', err);
setError(err instanceof Error ? err : new Error(String(err)));
}
}, [agentId]);
// 发送消息到 Agent
const sendMessage = useCallback(async (content: string) => {
if (!agentId) {
setError(new Error('No agent ID provided'));
return;
}
try {
setLoading(true);
await window.service.agentInstance.sendMsgToAgent(agentId, { text: content });
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
console.error('Failed to send message:', err);
} finally {
setLoading(false);
}
}, [agentId]);
// 创建新的 Agent 实例
const createAgent = useCallback(async () => {
try {
setLoading(true);
const newAgent = await window.service.agentInstance.createAgent(agentDefId);
setAgent(newAgent);
setMessages(newAgent.messages);
return newAgent;
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
console.error('Failed to create agent:', err);
return null;
} finally {
setLoading(false);
}
}, [agentDefId]);
// 更新 Agent
const updateAgent = useCallback(async (data: Partial<AgentInstance>) => {
if (!agentId) {
setError(new Error('No agent ID provided'));
return;
}
try {
setLoading(true);
const updatedAgent = await window.service.agentInstance.updateAgent(agentId, data);
setAgent(updatedAgent);
setMessages(updatedAgent.messages);
return updatedAgent;
} catch (err) {
setError(err instanceof Error ? err : new Error(String(err)));
console.error('Failed to update agent:', err);
return null;
} finally {
setLoading(false);
}
}, [agentId]);
// 取消当前操作
const cancelAgent = useCallback(async () => {
if (!agentId) return;
try {
await window.service.agentInstance.cancelAgent(agentId);
} catch (err) {
console.error('Failed to cancel agent:', err);
}
}, [agentId]);
// 初始化
useEffect(() => {
if (agentId) {
void fetchAgent();
const cleanup = subscribeToUpdates();
return cleanup;
} else if (agentDefId) {
// 如果没有 agentId 但有 agentDefId则创建新的 Agent
createAgent().catch(console.error);
}
}, [agentId, agentDefId, fetchAgent, subscribeToUpdates, createAgent]);
return {
agent,
messages,
loading,
error,
sendMessage,
createAgent,
updateAgent,
cancelAgent,
};
};

View file

@ -0,0 +1,152 @@
import { AgentInstance, AgentInstanceMessage } from '@services/agentInstance/interface';
import { create } from 'zustand';
interface AgentChatState {
// State
loading: boolean;
error: Error | null;
agent: AgentInstance | null;
messages: AgentInstanceMessage[];
// Actions
fetchAgent: (agentId: string) => Promise<void>;
subscribeToUpdates: (agentId: string) => (() => void) | undefined;
sendMessage: (agentId: string, content: string) => Promise<void>;
createAgent: (agentDefinitionId?: string) => Promise<AgentInstance | null>;
updateAgent: (agentId: string, data: Partial<AgentInstance>) => Promise<AgentInstance | null>;
cancelAgent: (agentId: string) => Promise<void>;
clearError: () => void;
}
export const useAgentChatStore = create<AgentChatState>((set) => ({
// Initial state
loading: false,
error: null,
agent: null,
messages: [],
// Fetch agent instance
fetchAgent: async (agentId: string) => {
if (!agentId) return;
try {
set({ loading: true, error: null });
const result = await window.service.agentInstance.getAgent(agentId);
if (result) {
set({ agent: result, messages: result.messages });
}
} catch (error) {
set({ error: error instanceof Error ? error : new Error(String(error)) });
console.error('Failed to fetch agent:', error);
} finally {
set({ loading: false });
}
},
// Subscribe to agent updates
subscribeToUpdates: (agentId: string) => {
if (!agentId) return undefined;
try {
const subscription = window.observables.agentInstance.subscribeToAgentUpdates(agentId).subscribe({
next: (updatedAgent) => {
if (updatedAgent) {
set({ agent: updatedAgent, messages: updatedAgent.messages });
}
},
error: (error) => {
console.error('Error in agent subscription:', error);
set({ error: error instanceof Error ? error : new Error(String(error)) });
},
});
// Return cleanup function
return () => {
subscription.unsubscribe();
};
} catch (error) {
console.error('Failed to subscribe to agent updates:', error);
set({ error: error instanceof Error ? error : new Error(String(error)) });
return undefined;
}
},
// Send message to agent
sendMessage: async (agentId: string, content: string) => {
if (!agentId) {
set({ error: new Error('No agent ID provided') });
return;
}
try {
set({ loading: true });
await window.service.agentInstance.sendMsgToAgent(agentId, { text: content });
} catch (error) {
set({ error: error instanceof Error ? error : new Error(String(error)) });
console.error('Failed to send message:', error);
} finally {
set({ loading: false });
}
},
// Create new agent instance
createAgent: async (agentDefinitionId?: string) => {
try {
set({ loading: true });
const newAgent = await window.service.agentInstance.createAgent(agentDefinitionId);
set({
agent: newAgent,
messages: newAgent.messages,
error: null,
});
return newAgent;
} catch (error) {
set({ error: error instanceof Error ? error : new Error(String(error)) });
console.error('Failed to create agent:', error);
return null;
} finally {
set({ loading: false });
}
},
// Update agent
updateAgent: async (agentId: string, data: Partial<AgentInstance>) => {
if (!agentId) {
set({ error: new Error('No agent ID provided') });
return null;
}
try {
set({ loading: true });
const updatedAgent = await window.service.agentInstance.updateAgent(agentId, data);
set({
agent: updatedAgent,
messages: updatedAgent.messages,
error: null,
});
return updatedAgent;
} catch (error) {
set({ error: error instanceof Error ? error : new Error(String(error)) });
console.error('Failed to update agent:', error);
return null;
} finally {
set({ loading: false });
}
},
// Cancel current operation
cancelAgent: async (agentId: string) => {
if (!agentId) return;
try {
await window.service.agentInstance.cancelAgent(agentId);
} catch (error) {
console.error('Failed to cancel agent:', error);
}
},
// Clear error
clearError: () => {
set({ error: null });
},
}));

View file

@ -1,64 +1,20 @@
import { nanoid } from 'nanoid';
import { create } from 'zustand';
import { IChatTab, INewTab, IWebTab, TabItem, TabState, TabType } from '../types/tab';
import { createInitialTabs } from './initialData';
import { StateCreator } from 'zustand';
import { IChatTab, INewTab, IWebTab, TabItem, TabState, TabType } from '../../../types/tab';
import { MAX_CLOSED_TABS, TabsState } from '../types';
type TabCloseDirection = 'above' | 'below' | 'other';
interface TabsState {
// All tabs
tabs: TabItem[];
// ID of the currently active tab
activeTabId: string | null;
// IDs of tabs displayed side by side
splitViewIds: string[];
// Split ratio for side-by-side view (20-80)
splitRatio: number;
// Recently closed tabs (for restoration)
closedTabs: TabItem[];
// Operation methods
addTab: (tabType: TabType, initialData?: Partial<TabItem> & { insertPosition?: number }) => Promise<TabItem>;
closeTab: (tabId: string) => void;
setActiveTab: (tabId: string) => void;
pinTab: (tabId: string, isPinned: boolean) => void;
updateTabData: (tabId: string, data: Partial<TabItem>) => void;
transformTabType: (tabId: string, newType: TabType, initialData?: Record<string, unknown>) => void;
// Side-by-side tabs related
addToSplitView: (tabId: string) => void;
removeFromSplitView: (tabId: string) => void;
clearSplitView: () => void;
updateSplitRatio: (ratio: number) => void;
// Batch close and restore tab functions
closeTabs: (direction: TabCloseDirection, fromTabId: string) => void;
restoreClosedTab: () => void;
hasClosedTabs: () => boolean;
// Utility methods
getTabIndex: (tabId: string) => number;
}
// Initialize tab data
const initialTabs = createInitialTabs();
const firstActiveTab = initialTabs.find(tab => tab.state === TabState.ACTIVE);
// Maximum number of closed tabs to save
const MAX_CLOSED_TABS = 10;
export const useTabStore = create<TabsState>((set, get) => ({
tabs: initialTabs,
activeTabId: firstActiveTab?.id || null,
splitViewIds: [],
splitRatio: 50, // Default 50%/50% split ratio
closedTabs: [], // Closed tabs
// Add new tab
/**
*
*/
export const createBasicActions = (): Pick<
TabsState,
'addTab' | 'closeTab' | 'setActiveTab' | 'pinTab' | 'updateTabData' | 'transformTabType'
> => ({
// 添加新标签页
addTab: async (tabType: TabType, initialData = {}) => {
const timestamp = Date.now();
const { insertPosition } = initialData;
delete initialData.insertPosition; // Remove from data passed to tab
const dataWithoutPosition = { ...initialData };
delete dataWithoutPosition.insertPosition;
let newTab: TabItem;
@ -68,18 +24,18 @@ export const useTabStore = create<TabsState>((set, get) => ({
isPinned: false,
createdAt: timestamp,
updatedAt: timestamp,
...initialData,
...dataWithoutPosition,
};
// 如果是聊天类型的标签页,需要先创建 agent 实例
if (tabType === TabType.CHAT) {
const agent = await window.service.agentInstance.createAgent(
(initialData as Partial<IChatTab>).agentDefId,
(dataWithoutPosition as Partial<IChatTab>).agentDefId,
);
newTab = {
...tabBase,
type: TabType.CHAT,
title: initialData.title || agent.name,
title: dataWithoutPosition.title || agent.name,
agentDefId: agent.agentDefId,
agentId: agent.id,
} as IChatTab;
@ -87,18 +43,51 @@ export const useTabStore = create<TabsState>((set, get) => ({
newTab = {
...tabBase,
type: TabType.WEB,
title: initialData.title || 'agent.tabTitle.newWeb',
url: (initialData as Partial<IWebTab>).url || 'about:blank',
title: dataWithoutPosition.title || 'agent.tabTitle.newWeb',
url: (dataWithoutPosition as Partial<IWebTab>).url || 'about:blank',
} as IWebTab;
} else {
newTab = {
...tabBase,
type: TabType.NEW_TAB,
title: initialData.title || 'agent.tabTitle.newTab',
favorites: (initialData as Partial<INewTab>).favorites || [],
title: dataWithoutPosition.title || 'agent.tabTitle.newTab',
favorites: (dataWithoutPosition as Partial<INewTab>).favorites || [],
} as INewTab;
}
return newTab;
},
// 关闭标签页
closeTab: () => {},
// 设置激活的标签页
setActiveTab: () => {},
// 固定/取消固定标签页
pinTab: () => {},
// 更新标签页数据
updateTabData: async () => {},
// 转换标签页类型
transformTabType: async () => {},
});
/**
*
*/
export const basicActionsMiddleware: StateCreator<
TabsState,
[],
[],
Pick<TabsState, 'addTab' | 'closeTab' | 'setActiveTab' | 'pinTab' | 'updateTabData' | 'transformTabType'>
> = (set, _get) => ({
// 添加新标签页
addTab: async (tabType: TabType, initialData = {}) => {
const newTab = await createBasicActions().addTab(tabType, initialData);
const { insertPosition } = initialData;
set(state => {
const updatedTabs = state.tabs.map(tab => ({
...tab,
@ -173,119 +162,6 @@ export const useTabStore = create<TabsState>((set, get) => ({
});
},
// 批量关闭标签页
closeTabs: (direction: TabCloseDirection, fromTabId: string) => {
set(state => {
const tabIndex = state.tabs.findIndex(tab => tab.id === fromTabId);
if (tabIndex === -1) return state;
// 获取要保留的标签页ID列表
const tabsToKeep: TabItem[] = [];
const tabsToClose: TabItem[] = [];
// 根据不同方向,确定要关闭的标签页
state.tabs.forEach((tab, index) => {
// 固定的标签页永远不关闭
if (tab.isPinned) {
tabsToKeep.push(tab);
return;
}
switch (direction) {
case 'above':
if (index <= tabIndex) {
tabsToKeep.push(tab);
} else {
tabsToClose.push(tab);
}
break;
case 'below':
if (index >= tabIndex || index < state.tabs.findIndex(t => !t.isPinned)) {
tabsToKeep.push(tab);
} else {
tabsToClose.push(tab);
}
break;
case 'other':
if (index === tabIndex || tab.isPinned) {
tabsToKeep.push(tab);
} else {
tabsToClose.push(tab);
}
break;
}
});
// 更新激活的标签页ID
let newActiveTabId = state.activeTabId;
if (!tabsToKeep.some(tab => tab.id === newActiveTabId)) {
const targetTab = tabsToKeep.find(tab => tab.id === fromTabId);
if (targetTab) {
newActiveTabId = targetTab.id;
// 设置新激活标签页的状态
tabsToKeep.forEach(tab => {
tab.state = tab.id === newActiveTabId ? TabState.ACTIVE : TabState.INACTIVE;
});
} else {
newActiveTabId = tabsToKeep.length > 0 ? tabsToKeep[0].id : null;
}
}
// 从并排视图中移除已关闭的标签页
const newSplitViewIds = state.splitViewIds.filter(id => tabsToKeep.some(tab => tab.id === id));
// 添加到关闭标签页历史
const newClosedTabs = [...tabsToClose, ...state.closedTabs].slice(0, MAX_CLOSED_TABS);
return {
tabs: tabsToKeep,
activeTabId: newActiveTabId,
splitViewIds: newSplitViewIds,
closedTabs: newClosedTabs,
};
});
},
// 恢复最近关闭的标签页
restoreClosedTab: () => {
set(state => {
if (state.closedTabs.length === 0) return state;
const [tabToRestore, ...remainingClosedTabs] = state.closedTabs;
// 设置所有标签页为非活动状态
const updatedTabs = state.tabs.map(tab => ({
...tab,
state: TabState.INACTIVE,
}));
// 恢复标签页为活动状态
const restoredTab = {
...tabToRestore,
state: TabState.ACTIVE,
createdAt: Date.now(), // 更新创建时间,让它排序在前面
};
return {
tabs: [...updatedTabs, restoredTab],
activeTabId: restoredTab.id,
closedTabs: remainingClosedTabs,
};
});
},
// 检查是否有已关闭的标签页
hasClosedTabs: () => {
return get().closedTabs.length > 0;
},
// 获取标签页在列表中的索引
getTabIndex: (tabId: string) => {
const state = get();
return state.tabs.findIndex(tab => tab.id === tabId);
},
// 设置激活的标签页
setActiveTab: (tabId: string) => {
set(state => {
@ -318,7 +194,7 @@ export const useTabStore = create<TabsState>((set, get) => ({
},
// 更新标签页数据
updateTabData: async (tabId: string, data: Partial<TabItem>) => {
updateTabData: (tabId: string, data: Partial<TabItem>) => {
set(state => {
const timestamp = Date.now();
const updatedTabs = state.tabs.map(tab => {
@ -343,6 +219,7 @@ export const useTabStore = create<TabsState>((set, get) => ({
});
},
// 转换标签页类型
transformTabType: async (tabId: string, newType: TabType, initialData: Record<string, unknown> = {}) => {
// 如果要转换为 CHAT 类型,需要先创建 agent
if (newType === TabType.CHAT) {
@ -361,7 +238,7 @@ export const useTabStore = create<TabsState>((set, get) => ({
const oldTab = state.tabs[tabIndex];
const timestamp = Date.now();
// 创建新标签页的通用基础属性
const baseProps = {
id: oldTab.id,
@ -412,39 +289,4 @@ export const useTabStore = create<TabsState>((set, get) => ({
};
});
},
// 添加到并排视图
addToSplitView: (tabId: string) => {
set(state => {
// 最多同时显示两个并排标签页
if (state.splitViewIds.includes(tabId) || state.splitViewIds.length >= 2) {
return state;
}
return {
splitViewIds: [...state.splitViewIds, tabId],
};
});
},
// 从并排视图中移除
removeFromSplitView: (tabId: string) => {
set(state => ({
splitViewIds: state.splitViewIds.filter(id => id !== tabId),
}));
},
// 清空并排视图
clearSplitView: () => {
set(() => ({
splitViewIds: [],
}));
},
// 更新分割比例
updateSplitRatio: (ratio: number) => {
set(() => ({
splitRatio: Math.max(20, Math.min(80, ratio)),
}));
},
}));
});

View file

@ -0,0 +1,121 @@
import { StateCreator } from 'zustand';
import { TabItem, TabState } from '../../../types/tab';
import { MAX_CLOSED_TABS, TabCloseDirection, TabsState } from '../types';
/**
*
*/
export const historyActionsMiddleware: StateCreator<
TabsState,
[],
[],
Pick<TabsState, 'closeTabs' | 'restoreClosedTab' | 'hasClosedTabs'>
> = (set, get) => ({
// 批量关闭标签页
closeTabs: (direction: TabCloseDirection, fromTabId: string) => {
set(state => {
const tabIndex = state.tabs.findIndex(tab => tab.id === fromTabId);
if (tabIndex === -1) return state;
// 获取要保留的标签页ID列表
const tabsToKeep: TabItem[] = [];
const tabsToClose: TabItem[] = [];
// 根据不同方向,确定要关闭的标签页
state.tabs.forEach((tab, index) => {
// 固定的标签页永远不关闭
if (tab.isPinned) {
tabsToKeep.push(tab);
return;
}
switch (direction) {
case 'above':
if (index <= tabIndex) {
tabsToKeep.push(tab);
} else {
tabsToClose.push(tab);
}
break;
case 'below':
if (index >= tabIndex || index < state.tabs.findIndex(t => !t.isPinned)) {
tabsToKeep.push(tab);
} else {
tabsToClose.push(tab);
}
break;
case 'other':
// 固定标签页的处理已在前面完成,这里只需处理与当前标签页相同的情况
if (index === tabIndex) {
tabsToKeep.push(tab);
} else {
tabsToClose.push(tab);
}
break;
}
});
// 更新激活的标签页ID
let newActiveTabId = state.activeTabId;
if (!tabsToKeep.some(tab => tab.id === newActiveTabId)) {
const targetTab = tabsToKeep.find(tab => tab.id === fromTabId);
if (targetTab) {
newActiveTabId = targetTab.id;
// 设置新激活标签页的状态
tabsToKeep.forEach(tab => {
tab.state = tab.id === newActiveTabId ? TabState.ACTIVE : TabState.INACTIVE;
});
} else {
newActiveTabId = tabsToKeep.length > 0 ? tabsToKeep[0].id : null;
}
}
// 从并排视图中移除已关闭的标签页
const newSplitViewIds = state.splitViewIds.filter(id => tabsToKeep.some(tab => tab.id === id));
// 添加到关闭标签页历史
const newClosedTabs = [...tabsToClose, ...state.closedTabs].slice(0, MAX_CLOSED_TABS);
return {
tabs: tabsToKeep,
activeTabId: newActiveTabId,
splitViewIds: newSplitViewIds,
closedTabs: newClosedTabs,
};
});
},
// 恢复最近关闭的标签页
restoreClosedTab: () => {
set(state => {
if (state.closedTabs.length === 0) return state;
const [tabToRestore, ...remainingClosedTabs] = state.closedTabs;
// 设置所有标签页为非活动状态
const updatedTabs = state.tabs.map(tab => ({
...tab,
state: TabState.INACTIVE,
}));
// 恢复标签页为活动状态
const restoredTab = {
...tabToRestore,
state: TabState.ACTIVE,
createdAt: Date.now(), // 更新创建时间,让它排序在前面
};
return {
tabs: [...updatedTabs, restoredTab],
activeTabId: restoredTab.id,
closedTabs: remainingClosedTabs,
};
});
},
// 检查是否有已关闭的标签页
hasClosedTabs: () => {
return get().closedTabs.length > 0;
},
});

View file

@ -0,0 +1,47 @@
import { StateCreator } from 'zustand';
import { TabsState } from '../types';
/**
*
*/
export const splitViewActionsMiddleware: StateCreator<
TabsState,
[],
[],
Pick<TabsState, 'addToSplitView' | 'removeFromSplitView' | 'clearSplitView' | 'updateSplitRatio'>
> = (set) => ({
// 添加到并排视图
addToSplitView: (tabId: string) => {
set(state => {
// 最多同时显示两个并排标签页
if (state.splitViewIds.includes(tabId) || state.splitViewIds.length >= 2) {
return state;
}
return {
splitViewIds: [...state.splitViewIds, tabId],
};
});
},
// 从并排视图中移除
removeFromSplitView: (tabId: string) => {
set(state => ({
splitViewIds: state.splitViewIds.filter(id => id !== tabId),
}));
},
// 清空并排视图
clearSplitView: () => {
set(() => ({
splitViewIds: [],
}));
},
// 更新分割比例
updateSplitRatio: (ratio: number) => {
set(() => ({
splitRatio: Math.max(20, Math.min(80, ratio)),
}));
},
});

View file

@ -0,0 +1,18 @@
import { StateCreator } from 'zustand';
import { TabsState } from '../types';
/**
*
*/
export const utilityActionsMiddleware: StateCreator<
TabsState,
[],
[],
Pick<TabsState, 'getTabIndex'>
> = (_set, get) => ({
// 获取标签页在列表中的索引
getTabIndex: (tabId: string) => {
const state = get();
return state.tabs.findIndex(tab => tab.id === tabId);
},
});

View file

@ -0,0 +1,31 @@
import { create } from 'zustand';
import { TabState } from '../../types/tab';
import { createInitialTabs } from '../initialData';
import { basicActionsMiddleware } from './actions/basicActions';
import { historyActionsMiddleware } from './actions/historyActions';
import { splitViewActionsMiddleware } from './actions/splitViewActions';
import { utilityActionsMiddleware } from './actions/utilityActions';
import { TabsState } from './types';
/**
*
*/
const initialTabs = createInitialTabs();
const firstActiveTab = initialTabs.find(tab => tab.state === TabState.ACTIVE);
/**
* Store
*/
export const useTabStore = create<TabsState>()((...api) => ({
tabs: initialTabs,
activeTabId: firstActiveTab?.id || null,
splitViewIds: [],
splitRatio: 50, // 默认 50%/50% 拆分比例
closedTabs: [], // 已关闭的标签页
// 组合所有中间件
...basicActionsMiddleware(...api),
...splitViewActionsMiddleware(...api),
...historyActionsMiddleware(...api),
...utilityActionsMiddleware(...api),
}));

View file

@ -0,0 +1,47 @@
import { TabItem, TabType } from '../../types/tab';
/**
*
*/
export type TabCloseDirection = 'above' | 'below' | 'other';
/**
* Store
*/
export interface TabsState {
// All tabs
tabs: TabItem[];
// ID of the currently active tab
activeTabId: string | null;
// IDs of tabs displayed side by side
splitViewIds: string[];
// Split ratio for side-by-side view (20-80)
splitRatio: number;
// Recently closed tabs (for restoration)
closedTabs: TabItem[];
// 基础操作方法
addTab: (tabType: TabType, initialData?: Partial<TabItem> & { insertPosition?: number }) => Promise<TabItem>;
closeTab: (tabId: string) => void;
setActiveTab: (tabId: string) => void;
pinTab: (tabId: string, isPinned: boolean) => void;
updateTabData: (tabId: string, data: Partial<TabItem>) => void;
transformTabType: (tabId: string, newType: TabType, initialData?: Record<string, unknown>) => void;
// 并排视图相关方法
addToSplitView: (tabId: string) => void;
removeFromSplitView: (tabId: string) => void;
clearSplitView: () => void;
updateSplitRatio: (ratio: number) => void;
// 批量关闭和恢复标签页方法
closeTabs: (direction: TabCloseDirection, fromTabId: string) => void;
restoreClosedTab: () => void;
hasClosedTabs: () => boolean;
// 工具方法
getTabIndex: (tabId: string) => number;
}
// 常量
export const MAX_CLOSED_TABS = 10;

View file

@ -1,5 +1,5 @@
{
"exclude": ["template/**/*.js"],
"exclude": ["template/**/*.js", "src/services/wiki/plugin/**/*.js"],
"include": ["src"],
"ts-node": {
"files": true,