moelove's picture
Merge xsai integration with MCP features
320da1c
import { useState, useMemo } from 'react';
import ChatList from './components/ChatList';
import ChatWindow from './components/ChatWindow';
import Settings from './components/Settings';
import useLocalStorage from './hooks/useLocalStorage';
function App() {
const [profiles, setProfiles] = useLocalStorage('profiles', [
{
id: 'default',
name: 'Default Profile',
apiEndpoint: '',
apiKey: '',
model: 'DeepSeek-R1'
}
]);
const [summarizationProfile, setSummarizationProfile] = useLocalStorage('summarizationProfile', {
id: 'default-summarization-profile',
name: 'Summarization Profile',
apiEndpoint: '',
apiKey: '',
model: 'DeepSeek-R1'
});
const [mcpServers, setMcpServers] = useLocalStorage('mcpServers', [], { disableCache: false });
// 过滤出正在运行的 MCP 服务器
const activeMcpServers = useMemo(() => {
console.log('Filtering active MCP servers:', mcpServers);
return mcpServers.filter(server => !server.isBuiltIn || server.isRunning);
}, [mcpServers]);
const [activeProfileId, setActiveProfileId] = useLocalStorage('activeProfileId', 'default');
const [chats, setChats] = useLocalStorage('chats', []);
const [currentChatId, setCurrentChatId] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showProfileDropdown, setShowProfileDropdown] = useState(false);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [streamingChats, setStreamingChats] = useState(new Set());
const activeProfile = profiles.find(p => p.id === activeProfileId) || profiles[0];
const createNewChat = () => {
// 生成一个新的聊天ID,确保它是唯一的
const newChatId = Date.now();
console.log(`Creating new chat with ID: ${newChatId}`);
const newChat = {
id: newChatId,
title: 'New Conversation',
messages: []
};
// 使用函数式更新来确保我们使用最新的聊天列表
setChats(prevChats => {
// 检查是否已经存在相同 ID 的聊天,避免重复
const chatExists = prevChats.some(chat => chat.id === newChatId);
if (chatExists) {
console.log(`Chat with ID ${newChatId} already exists, not creating duplicate`);
return prevChats;
}
console.log(`Adding new chat to chats array, current count: ${prevChats.length}, new count: ${prevChats.length + 1}`);
return [...prevChats, newChat];
});
// 设置当前聊天ID
setCurrentChatId(newChatId);
};
const deleteChat = (chatId) => {
console.log(`Deleting chat with ID: ${chatId}`);
// 使用函数式更新来确保我们使用最新的聊天列表
setChats(prevChats => {
const filteredChats = prevChats.filter(chat => chat.id !== chatId);
console.log(`Removed chat, previous count: ${prevChats.length}, new count: ${filteredChats.length}`);
return filteredChats;
});
// 如果删除的是当前活动的聊天,则将当前聊天ID设置为空
if (currentChatId === chatId) {
console.log(`Deleted chat was the current active chat, setting currentChatId to null`);
setCurrentChatId(null);
}
};
const toggleSettings = () => {
setShowSettings(!showSettings);
};
const toggleSidebar = () => {
setSidebarCollapsed(!sidebarCollapsed);
// 在移动设备上,如果侧边栏是展开的,点击收起按钮也应该关闭移动菜单
if (window.innerWidth <= 768 && !sidebarCollapsed) {
setMobileMenuOpen(false);
}
};
const toggleMobileMenu = () => {
setMobileMenuOpen(!mobileMenuOpen);
// 如果侧边栏是收起的,则展开它
if (sidebarCollapsed) {
setSidebarCollapsed(false);
}
};
// 添加聊天到正在流式传输的列表
const addStreamingChat = (chatId) => {
setStreamingChats(prev => {
const newSet = new Set(prev);
newSet.add(chatId);
return newSet;
});
};
// 从正在流式传输的列表中移除聊天
const removeStreamingChat = (chatId) => {
setStreamingChats(prev => {
const newSet = new Set(prev);
newSet.delete(chatId);
return newSet;
});
};
// 检查聊天是否正在流式传输
const isStreamingChat = (chatId) => {
return streamingChats.has(chatId);
};
const toggleProfileDropdown = () => {
setShowProfileDropdown(!showProfileDropdown);
};
const handleProfileSelect = (profileId) => {
setActiveProfileId(profileId);
setShowProfileDropdown(false);
};
return (
<div className="flex flex-col h-screen w-full overflow-hidden bg-background-secondary">
{/* Modern Enterprise Header */}
<header className="flex justify-between items-center px-6 h-header bg-background-elevated border-b border-border shadow-sm">
<div className="flex items-center gap-4">
<button
className="md:hidden bg-transparent border-0 text-lg text-muted cursor-pointer flex items-center justify-center z-10 w-10 h-10 rounded-lg hover:bg-background-secondary hover:text-primary transition-all duration-fast"
onClick={toggleMobileMenu}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
{/* Brand Section */}
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-gradient-to-br from-primary to-primary-dark rounded-lg flex items-center justify-center shadow-sm">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<div>
<h1 className="text-xl font-bold text-primary bg-gradient-to-r from-primary to-primary-dark bg-clip-text text-transparent">Thinking Model Client</h1>
<p className="text-xs text-muted hidden sm:block">Enterprise AI Assistant</p>
</div>
</div>
</div>
{/* Controls Section */}
<div className="flex items-center gap-3">
{/* Profile Selector */}
<div className="relative">
<button
className="flex items-center gap-2 py-2 px-4 bg-background-secondary border border-border rounded-lg text-sm text-secondary hover:bg-background-tertiary hover:text-primary hover:border-primary transition-all duration-fast shadow-sm"
onClick={toggleProfileDropdown}
>
<div className="w-6 h-6 bg-primary rounded-full flex items-center justify-center text-xs text-white font-medium">
{activeProfile.name.charAt(0).toUpperCase()}
</div>
<span className="hidden sm:inline font-medium">{activeProfile.name}</span>
<svg className={`w-4 h-4 transition-transform duration-fast ${showProfileDropdown ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showProfileDropdown && (
<div className="absolute top-full right-0 w-64 bg-background-elevated border border-border rounded-xl shadow-xl z-50 mt-2 animate-slide-up">
<div className="p-2">
<div className="px-3 py-2 text-xs font-semibold text-muted uppercase tracking-wider border-b border-border mb-2">
Select Profile
</div>
{profiles.map(profile => (
<button
key={profile.id}
className={`w-full flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-all duration-fast text-sm text-left ${
profile.id === activeProfileId
? 'bg-primary-light text-primary font-medium shadow-sm'
: 'hover:bg-background-secondary text-secondary'
}`}
onClick={() => handleProfileSelect(profile.id)}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
profile.id === activeProfileId ? 'bg-primary text-white' : 'bg-background-tertiary text-muted'
}`}>
{profile.name.charAt(0).toUpperCase()}
</div>
<div className="flex-1">
<div className="font-medium">{profile.name}</div>
<div className="text-xs text-muted truncate">{profile.model}</div>
</div>
{profile.id === activeProfileId && (
<svg className="w-4 h-4 text-primary" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</button>
))}
</div>
</div>
)}
</div>
{/* Settings Button */}
<button
className="flex items-center gap-2 py-2 px-4 bg-background-secondary border border-border rounded-lg text-sm text-secondary hover:bg-background-tertiary hover:text-primary hover:border-primary transition-all duration-fast shadow-sm"
onClick={toggleSettings}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span className="hidden sm:inline font-medium">Settings</span>
</button>
</div>
</header>
<div className="flex flex-1 overflow-hidden">
<div className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''} ${mobileMenuOpen ? 'mobile-open' : ''}`}>
<div className="flex justify-between items-center py-3 px-4 border-b border-border">
<h2 className="text-sm font-semibold text-light-text m-0">Conversations</h2>
<button
className="bg-transparent border-0 text-base text-lightest-text cursor-pointer flex items-center justify-center z-10 w-6 h-6 hover:text-text"
onClick={toggleSidebar}
aria-label={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{sidebarCollapsed ? '→' : '←'}
</button>
</div>
<ChatList
chats={chats}
currentChat={chats.find(c => c.id === currentChatId)}
onSelectChat={setCurrentChatId}
onDeleteChat={deleteChat}
onCreateNewChat={createNewChat}
collapsed={sidebarCollapsed}
isStreamingChat={isStreamingChat}
/>
</div>
{/* Floating expand button that appears when sidebar is collapsed */}
<button
className="expand-sidebar-button"
onClick={toggleSidebar}
aria-label="Expand sidebar"
title="Expand sidebar"
>
</button>
<div className="flex-1 flex flex-col overflow-hidden bg-background">
{currentChatId ? (
<ChatWindow
chat={chats.find(c => c.id === currentChatId)}
profile={activeProfile}
summarizationProfile={summarizationProfile}
onUpdateChat={(updatedChat) => {
setChats(chats.map(c =>
c.id === updatedChat.id ? updatedChat : c
));
}}
onCreateNewChat={createNewChat}
addStreamingChat={addStreamingChat}
removeStreamingChat={removeStreamingChat}
isStreamingChat={isStreamingChat}
allChats={chats}
currentChatId={currentChatId}
mcpServers={activeMcpServers}
/>
) : (
<div className="flex flex-col items-center justify-center h-full p-5 text-center">
<h2 className="text-2xl font-semibold mb-2.5 text-text">Welcome to Thinking Model Client</h2>
<p className="text-light-text mb-5 text-sm">Start a new conversation or select an existing one.</p>
<button className="py-2.5 px-5 bg-primary text-white border-none rounded cursor-pointer text-sm transition-colors duration-200 hover:bg-primary-hover" onClick={createNewChat}>
Start New Conversation
</button>
</div>
)}
</div>
</div>
{showSettings && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[1000]">
<div className="bg-background rounded-lg w-[600px] max-w-[90%] max-h-[90vh] overflow-y-auto relative p-6 shadow-md">
<button
className="absolute top-4 right-4 bg-transparent border-none text-xl cursor-pointer text-lightest-text w-6 h-6 flex items-center justify-center rounded hover:bg-hover hover:text-text"
onClick={toggleSettings}
>
×
</button>
<Settings
profiles={profiles}
activeProfileId={activeProfileId}
onSaveProfiles={(newProfiles) => {
setProfiles(newProfiles);
// Ensure the active profile still exists, otherwise select the first one
if (!newProfiles.some(p => p.id === activeProfileId)) {
setActiveProfileId(newProfiles[0]?.id || null);
}
}}
onSaveSummarizationProfile={setSummarizationProfile}
summarizationProfile={summarizationProfile}
mcpServers={mcpServers}
onSaveMcpServers={setMcpServers}
onChangeActiveProfile={setActiveProfileId}
onCloseSettings={() => setShowSettings(false)}
/>
</div>
</div>
)}
{error && (
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-red-100 text-red-600 py-2.5 px-5 rounded text-sm shadow-md z-[1000]">
Error: {error}
</div>
)}
{loading && (
<div className="fixed bottom-5 left-1/2 -translate-x-1/2 bg-blue-50 text-blue-700 py-2.5 px-5 rounded text-sm shadow-md z-[1000]">
Loading...
</div>
)}
</div>
);
}
export default App;