File size: 15,893 Bytes
f044ea5
ddac6a2
 
 
 
 
 
fa4edd5
 
 
 
 
 
 
 
 
03bd69e
2316bca
 
 
 
 
 
 
03bd69e
f044ea5
 
 
 
 
 
 
24caae0
fa4edd5
03bd69e
ddac6a2
 
 
 
ee61dec
 
fa4edd5
c1944cf
e1fb264
fa4edd5
 
ddac6a2
 
e1fb264
 
 
 
ddac6a2
e1fb264
ddac6a2
 
 
e1fb264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ddac6a2
 
 
e1fb264
 
 
 
 
 
 
 
 
 
ddac6a2
e1fb264
ddac6a2
 
 
 
ee61dec
 
 
 
 
 
c1944cf
 
 
 
 
 
 
 
 
 
 
 
ee61dec
 
e1fb264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa4edd5
 
 
 
 
 
 
 
 
ddac6a2
204e35b
 
 
 
c1944cf
204e35b
c1944cf
 
204e35b
 
 
c1944cf
204e35b
 
 
 
 
 
 
 
 
 
 
 
 
fa4edd5
204e35b
 
 
 
03bd69e
 
204e35b
fa4edd5
 
204e35b
 
 
 
 
 
 
fa4edd5
204e35b
fa4edd5
204e35b
 
 
 
fa4edd5
204e35b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa4edd5
 
 
204e35b
 
03bd69e
204e35b
fa4edd5
 
204e35b
 
 
 
 
fa4edd5
 
204e35b
ee61dec
03bd69e
c1944cf
03bd69e
 
 
 
ee61dec
028b48d
 
ee61dec
 
 
 
 
 
 
 
 
 
 
e1fb264
ddac6a2
ee61dec
 
028b48d
 
 
 
 
 
 
 
 
 
03bd69e
ee61dec
03bd69e
ee61dec
fa4edd5
2316bca
ee61dec
03bd69e
ee61dec
 
 
e1fb264
 
 
 
 
 
f044ea5
ee61dec
 
03bd69e
 
 
 
ee61dec
 
 
 
 
ddac6a2
ee61dec
 
03bd69e
 
 
 
ee61dec
 
 
 
03bd69e
 
fa4edd5
 
 
 
 
 
 
 
2316bca
 
24caae0
 
fa4edd5
 
ee61dec
 
 
 
 
ddac6a2
03bd69e
ddac6a2
 
 
 
03bd69e
ddac6a2
 
 
 
 
 
 
2316bca
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
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;