samlax12 commited on
Commit
05f86a6
·
verified ·
1 Parent(s): 5955263

Upload 19 files

Browse files
.gitattributes CHANGED
@@ -1,35 +1,35 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
App.tsx CHANGED
@@ -1,1103 +1,1060 @@
1
- import React, { useState, useEffect, useRef } from 'react';
2
- import { ChatMessage, MessageSender, MessagePurpose } from './types';
3
- import { generateResponse } from './services/openaiService';
4
- import ChatInput from './components/ChatInput';
5
- import MessageBubble from './components/MessageBubble';
6
- import Notepad from './components/Notepad';
7
- import ModelConfigManager from './components/ModelConfigManager';
8
- import {
9
- AiModel,
10
- AiRole,
11
- ApiChannel,
12
- ModelConfigManager as ConfigManager,
13
- DEFAULT_MANUAL_FIXED_TURNS,
14
- MIN_MANUAL_FIXED_TURNS,
15
- MAX_MANUAL_FIXED_TURNS,
16
- MAX_AI_DRIVEN_DISCUSSION_TURNS_PER_MODEL,
17
- INITIAL_NOTEPAD_CONTENT,
18
- NOTEPAD_INSTRUCTION_PROMPT_PART,
19
- NOTEPAD_UPDATE_TAG_START,
20
- NOTEPAD_UPDATE_TAG_END,
21
- DISCUSSION_COMPLETE_TAG,
22
- AI_DRIVEN_DISCUSSION_INSTRUCTION_PROMPT_PART,
23
- DiscussionMode
24
- } from './constants';
25
- import {
26
- BotMessageSquare,
27
- AlertTriangle,
28
- RefreshCcw,
29
- SlidersHorizontal,
30
- Users,
31
- MessagesSquare,
32
- Bot,
33
- ChevronDown,
34
- Settings,
35
- Play,
36
- Pause,
37
- Square,
38
- Download
39
- } from 'lucide-react';
40
-
41
- interface ParsedAIResponse {
42
- spokenText: string;
43
- newNotepadContent: string | null;
44
- discussionShouldEnd?: boolean;
45
- }
46
-
47
- interface ActiveRole extends AiRole {
48
- model: AiModel;
49
- channel: ApiChannel;
50
- isProcessing?: boolean;
51
- }
52
-
53
- interface DiscussionState {
54
- currentRoleIndex: number;
55
- currentTurn: number;
56
- discussionLog: string[];
57
- isFirstMessage: boolean;
58
- previousAISignaledStop: boolean;
59
- discussionEndCount: number;
60
- userQuery: string;
61
- imageApiPart?: any;
62
- commonPromptInstructions: string;
63
- roleOrder: ActiveRole[];
64
- maxTurnsForLoop: number;
65
- }
66
-
67
- const parseAIResponse = (responseText: string): ParsedAIResponse => {
68
- let currentText = responseText.trim();
69
- let spokenText = "";
70
- let newNotepadContent: string | null = null;
71
- let discussionShouldEnd = false;
72
-
73
- let notepadActionText = "";
74
- let discussionActionText = "";
75
-
76
- const notepadStartIndex = currentText.lastIndexOf(NOTEPAD_UPDATE_TAG_START);
77
- const notepadEndIndex = currentText.lastIndexOf(NOTEPAD_UPDATE_TAG_END);
78
-
79
- if (notepadStartIndex !== -1 && notepadEndIndex !== -1 && notepadEndIndex > notepadStartIndex && currentText.endsWith(NOTEPAD_UPDATE_TAG_END)) {
80
- newNotepadContent = currentText.substring(notepadStartIndex + NOTEPAD_UPDATE_TAG_START.length, notepadEndIndex).trim();
81
- spokenText = currentText.substring(0, notepadStartIndex).trim();
82
-
83
- if (newNotepadContent) {
84
- notepadActionText = "更新了记事本";
85
- } else {
86
- notepadActionText = "尝试更新记事本但内容为空";
87
- }
88
- } else {
89
- spokenText = currentText;
90
- }
91
-
92
- if (spokenText.includes(DISCUSSION_COMPLETE_TAG)) {
93
- discussionShouldEnd = true;
94
- spokenText = spokenText.replace(new RegExp(DISCUSSION_COMPLETE_TAG.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), "").trim();
95
- discussionActionText = "建议结束讨论";
96
- }
97
-
98
- if (!spokenText.trim()) {
99
- if (notepadActionText && discussionActionText) {
100
- spokenText = `(AI ${notepadActionText}并${discussionActionText})`;
101
- } else if (notepadActionText) {
102
- spokenText = `(AI ${notepadActionText})`;
103
- } else if (discussionActionText) {
104
- spokenText = `(AI ${discussionActionText})`;
105
- } else {
106
- spokenText = "(AI 未提供额外文本回复)";
107
- }
108
- }
109
-
110
- return { spokenText: spokenText.trim(), newNotepadContent, discussionShouldEnd };
111
- };
112
-
113
- const fileToBase64 = (file: File): Promise<string> => {
114
- return new Promise((resolve, reject) => {
115
- const reader = new FileReader();
116
- reader.readAsDataURL(file);
117
- reader.onload = () => {
118
- const result = reader.result as string;
119
- resolve(result.split(',')[1]);
120
- };
121
- reader.onerror = (error) => reject(error);
122
- });
123
- };
124
-
125
- const createDynamicMessageSender = (roleName: string): MessageSender => {
126
- return roleName as MessageSender;
127
- };
128
-
129
- const App: React.FC = () => {
130
- const [messages, setMessages] = useState<ChatMessage[]>([]);
131
- const [isLoading, setIsLoading] = useState<boolean>(false);
132
- const [currentTotalProcessingTimeMs, setCurrentTotalProcessingTimeMs] = useState<number>(0);
133
-
134
- const [notepadContent, setNotepadContent] = useState<string>(INITIAL_NOTEPAD_CONTENT);
135
- const [lastNotepadUpdateBy, setLastNotepadUpdateBy] = useState<MessageSender | null>(null);
136
-
137
- const [discussionMode, setDiscussionMode] = useState<DiscussionMode>(DiscussionMode.FixedTurns);
138
- const [manualFixedTurns, setManualFixedTurns] = useState<number>(DEFAULT_MANUAL_FIXED_TURNS);
139
- const [isReducedCapacityEnabled, setIsReducedCapacityEnabled] = useState<boolean>(false);
140
-
141
- const [activeRoles, setActiveRoles] = useState<ActiveRole[]>([]);
142
- const [channels, setChannels] = useState<ApiChannel[]>([]);
143
- const [models, setModels] = useState<AiModel[]>([]);
144
- const [isConfigManagerOpen, setIsConfigManagerOpen] = useState<boolean>(false);
145
- const [updateNotification, setUpdateNotification] = useState<{
146
- newChannels: number;
147
- newModels: number;
148
- newRoles: number;
149
- } | null>(null);
150
- const [isRoleSelectorOpen, setIsRoleSelectorOpen] = useState<boolean>(false);
151
-
152
- const [isDiscussionActive, setIsDiscussionActive] = useState<boolean>(false);
153
- const [streamingMessages, setStreamingMessages] = useState<Map<string, { text: string; isComplete: boolean }>>(new Map());
154
- const [currentDiscussion, setCurrentDiscussion] = useState<DiscussionState | null>(null);
155
-
156
- const chatContainerRef = useRef<HTMLDivElement>(null);
157
- const currentQueryStartTimeRef = useRef<number | null>(null);
158
- const cancelRequestRef = useRef<boolean>(false);
159
-
160
- // 实时流式消息管理
161
- const createStreamingMessage = (sender: MessageSender, purpose: MessagePurpose): string => {
162
- const messageId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
163
- const message: ChatMessage = {
164
- id: messageId,
165
- text: '',
166
- sender,
167
- purpose,
168
- timestamp: new Date()
169
- };
170
-
171
- setMessages(prev => [...prev, message]);
172
- setStreamingMessages(prev => new Map(prev.set(messageId, { text: '', isComplete: false })));
173
- return messageId;
174
- };
175
-
176
- const updateStreamingMessage = (messageId: string, fullText: string, isComplete: boolean, durationMs?: number) => {
177
- setMessages(prev => prev.map(msg =>
178
- msg.id === messageId ? {
179
- ...msg,
180
- text: fullText,
181
- durationMs: isComplete ? durationMs : msg.durationMs
182
- } : msg
183
- ));
184
- };
185
-
186
- const loadConfiguration = () => {
187
- // 检查是否有配置更新
188
- const updateInfo = ConfigManager.getLastUpdateInfo();
189
- if (updateInfo) {
190
- setUpdateNotification({
191
- newChannels: updateInfo.newChannels,
192
- newModels: updateInfo.newModels,
193
- newRoles: updateInfo.newRoles
194
- });
195
-
196
- // 5秒后自动关闭通知
197
- setTimeout(() => {
198
- setUpdateNotification(null);
199
- }, 5000);
200
- }
201
-
202
- const allChannels = ConfigManager.getChannels();
203
- const allModels = ConfigManager.getModels();
204
- const allRoles = ConfigManager.getActiveRoles();
205
-
206
- setChannels(allChannels);
207
- setModels(allModels);
208
-
209
- const rolesWithModelsAndChannels: ActiveRole[] = allRoles.map(role => {
210
- const model = allModels.find(m => m.id === role.modelId);
211
- if (!model) {
212
- console.warn(`Role ${role.name} references non-existent model ${role.modelId}`);
213
- return null;
214
- }
215
-
216
- const channel = allChannels.find(ch => ch.id === model.channelId);
217
- if (!channel) {
218
- console.warn(`Model ${model.name} references non-existent channel ${model.channelId}`);
219
- return null;
220
- }
221
-
222
- return { ...role, model, channel };
223
- }).filter(Boolean) as ActiveRole[];
224
-
225
- setActiveRoles(rolesWithModelsAndChannels);
226
- };
227
-
228
- useEffect(() => {
229
- loadConfiguration();
230
- }, []);
231
-
232
- const addMessage = (
233
- text: string,
234
- sender: MessageSender,
235
- purpose: MessagePurpose,
236
- durationMs?: number,
237
- image?: ChatMessage['image']
238
- ) => {
239
- const messageId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
240
- const message: ChatMessage = {
241
- id: messageId,
242
- text,
243
- sender,
244
- purpose,
245
- timestamp: new Date(),
246
- durationMs,
247
- image
248
- };
249
-
250
- setMessages(prev => [...prev, message]);
251
- return messageId;
252
- };
253
-
254
- const interruptDiscussion = () => {
255
- if (isLoading && isDiscussionActive) {
256
- cancelRequestRef.current = true;
257
- setIsLoading(false);
258
- setIsDiscussionActive(false);
259
- setCurrentDiscussion(null);
260
-
261
- if (currentTotalProcessingTimeMs > 0) {
262
- addMessage(
263
- `讨论已被用户中断 (已进行 ${(currentTotalProcessingTimeMs / 1000).toFixed(2)}秒)`,
264
- MessageSender.System,
265
- MessagePurpose.SystemNotification
266
- );
267
- }
268
-
269
- setCurrentTotalProcessingTimeMs(0);
270
- if (currentQueryStartTimeRef.current) {
271
- currentQueryStartTimeRef.current = null;
272
- }
273
- }
274
- };
275
-
276
- const exportDiscussionRecord = () => {
277
- if (messages.length === 0) {
278
- addMessage('当前没有可导出的消息记录', MessageSender.System, MessagePurpose.SystemNotification);
279
- return;
280
- }
281
-
282
- // 生成简洁的文本格式导出
283
- let exportText = `=== Multi-Mind Chat 对话记录 ===\n`;
284
- exportText += `导出时间: ${new Date().toLocaleString()}\n`;
285
- exportText += `消息总数: ${messages.length}\n\n`;
286
-
287
- messages.forEach(msg => {
288
- if (msg.purpose !== MessagePurpose.SystemNotification) {
289
- const timeStr = msg.timestamp.toLocaleTimeString();
290
- const durationStr = msg.durationMs ? ` (${(msg.durationMs / 1000).toFixed(2)}s)` : '';
291
- exportText += `[${timeStr}] ${msg.sender}${durationStr}: ${msg.text}\n\n`;
292
-
293
- if (msg.image) {
294
- exportText += ` [附件: ${msg.image.name} - ${msg.image.type}]\n\n`;
295
- }
296
- }
297
- });
298
-
299
- if (notepadContent !== INITIAL_NOTEPAD_CONTENT) {
300
- exportText += `=== 最终记事本内容 ===\n`;
301
- exportText += `${notepadContent}\n\n`;
302
- }
303
-
304
- const blob = new Blob([exportText], { type: 'text/plain;charset=utf-8' });
305
- const url = URL.createObjectURL(blob);
306
- const a = document.createElement('a');
307
- a.href = url;
308
- a.download = `对话记录-${new Date().toISOString().split('T')[0]}-${Date.now()}.txt`;
309
- document.body.appendChild(a);
310
- a.click();
311
- document.body.removeChild(a);
312
- URL.revokeObjectURL(url);
313
-
314
- addMessage('对话记录已导出', MessageSender.System, MessagePurpose.SystemNotification);
315
- };
316
-
317
- const getWelcomeMessageText = () => {
318
- const modeDescription = discussionMode === DiscussionMode.FixedTurns
319
- ? `固定轮次对话 (${manualFixedTurns}轮)`
320
- : "AI驱动对话";
321
-
322
- const roleNames = activeRoles.map(role => role.name).join(' 和 ');
323
- const roleCount = activeRoles.length;
324
- const channelCount = channels.length;
325
-
326
- if (channelCount === 0) {
327
- return `欢迎使用Multi-Mind Chat 智囊团!请先配置API渠道。点击设置按钮开始配置。`;
328
- } else if (roleCount === 0) {
329
- return `欢迎使用Multi-Mind Chat 智囊团!已配置 ${channelCount} 个API渠道,请继续配置AI角色和模型。点击设置按钮开始配置。`;
330
- } else if (roleCount === 1) {
331
- return `欢迎使用Multi-Mind Chat 智囊团!当前模式: ${modeDescription}。当前只有一个活跃角色: ${roleNames}。建议添加更多角色以获得更好的协作体验。`;
332
- } else {
333
- return `欢迎使用Multi-Mind Chat 智囊团!当前模式: ${modeDescription}。活跃的AI角色: ${roleNames}。这些角色将协作讨论您的问题并使用共享记事本。`;
334
- }
335
- };
336
-
337
- const initializeChat = () => {
338
- setMessages([]);
339
- setNotepadContent(INITIAL_NOTEPAD_CONTENT);
340
- setLastNotepadUpdateBy(null);
341
- setIsDiscussionActive(false);
342
- setStreamingMessages(new Map());
343
- setCurrentDiscussion(null);
344
-
345
- addMessage(
346
- getWelcomeMessageText(),
347
- MessageSender.System,
348
- MessagePurpose.SystemNotification
349
- );
350
- };
351
-
352
- useEffect(() => {
353
- initializeChat();
354
- }, [activeRoles, discussionMode, manualFixedTurns, channels]);
355
-
356
- useEffect(() => {
357
- if (chatContainerRef.current) {
358
- chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
359
- }
360
- }, [messages]);
361
-
362
- useEffect(() => {
363
- let intervalId: number | undefined;
364
- if (isLoading && currentQueryStartTimeRef.current) {
365
- intervalId = window.setInterval(() => {
366
- if (currentQueryStartTimeRef.current) {
367
- setCurrentTotalProcessingTimeMs(performance.now() - currentQueryStartTimeRef.current);
368
- }
369
- }, 100);
370
- } else {
371
- if (intervalId) clearInterval(intervalId);
372
- }
373
- return () => {
374
- if (intervalId) clearInterval(intervalId);
375
- };
376
- }, [isLoading]);
377
-
378
- const handleClearChat = () => {
379
- if (isLoading) {
380
- cancelRequestRef.current = true;
381
- }
382
- setIsLoading(false);
383
- setIsDiscussionActive(false);
384
- setCurrentDiscussion(null);
385
-
386
- setCurrentTotalProcessingTimeMs(0);
387
- if (currentQueryStartTimeRef.current) {
388
- currentQueryStartTimeRef.current = null;
389
- }
390
-
391
- setMessages([]);
392
- setNotepadContent(INITIAL_NOTEPAD_CONTENT);
393
- setLastNotepadUpdateBy(null);
394
- setStreamingMessages(new Map());
395
-
396
- addMessage(
397
- getWelcomeMessageText(),
398
- MessageSender.System,
399
- MessagePurpose.SystemNotification
400
- );
401
- };
402
-
403
- const handleManualFixedTurnsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
404
- let value = parseInt(e.target.value, 10);
405
- if (isNaN(value)) {
406
- value = DEFAULT_MANUAL_FIXED_TURNS;
407
- }
408
- value = Math.max(MIN_MANUAL_FIXED_TURNS, Math.min(MAX_MANUAL_FIXED_TURNS, value));
409
- setManualFixedTurns(value);
410
- };
411
-
412
- const toggleRoleActiveState = (roleId: string) => {
413
- const role = activeRoles.find(r => r.id === roleId);
414
- if (role) {
415
- ConfigManager.updateRole(roleId, { isActive: !role.isActive });
416
- loadConfiguration();
417
- }
418
- };
419
-
420
- const processNextRole = (state: DiscussionState) => {
421
- if (cancelRequestRef.current) {
422
- setIsDiscussionActive(false);
423
- setCurrentDiscussion(null);
424
- return;
425
- }
426
-
427
- // 检查是否完成所有讨论
428
- if (state.currentTurn === 0 && state.currentRoleIndex >= state.roleOrder.length) {
429
- // 第一轮结束,开始多轮讨论
430
- if (!state.previousAISignaledStop && state.maxTurnsForLoop > 0) {
431
- const newState = { ...state, currentTurn: 1, currentRoleIndex: 0, discussionEndCount: 0 };
432
- setCurrentDiscussion(newState);
433
- processNextRole(newState);
434
- return;
435
- } else {
436
- processFinalAnswer(state);
437
- return;
438
- }
439
- } else if (state.currentTurn > 0 &&
440
- (state.currentTurn >= state.maxTurnsForLoop ||
441
- state.previousAISignaledStop ||
442
- state.currentRoleIndex >= state.roleOrder.length)) {
443
- processFinalAnswer(state);
444
- return;
445
- }
446
-
447
- const currentRole = state.roleOrder[state.currentRoleIndex];
448
- const shouldUseReducedCapacity = isReducedCapacityEnabled && currentRole.model.supportsReducedCapacity;
449
-
450
- // 显示系统通知
451
- addMessage(
452
- `${currentRole.name} 正在${state.currentTurn === 0 ? '分析问题并提供观点' : '回应其他角色的观点'} (使用 ${currentRole.model.name} - ${currentRole.channel.name})...`,
453
- MessageSender.System,
454
- MessagePurpose.SystemNotification
455
- );
456
-
457
- // 立即创建消息气泡
458
- const purpose = state.currentRoleIndex % 2 === 0 ? MessagePurpose.CognitoToMuse : MessagePurpose.MuseToCognito;
459
- const messageId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
460
-
461
- // 立即添加空消息到界面,用户会看到正在输入的效果
462
- const initialMessage: ChatMessage = {
463
- id: messageId,
464
- text: '', // 开始时为空,等待流式输入
465
- sender: createDynamicMessageSender(currentRole.name),
466
- purpose,
467
- timestamp: new Date()
468
- };
469
-
470
- setMessages(prev => [...prev, initialMessage]);
471
-
472
- // 构建提示词
473
- let prompt: string;
474
- if (state.isFirstMessage) {
475
- prompt = `用户的查询 (中文) 是: "${state.userQuery}". ${state.imageApiPart ? "用户还提供了一张图片。请在您的分析和回复中同时考虑此图片和文本查询。" : ""} 请针对此查询提供您的初步想法或分析。这是一个多AI协作的环境,其他AI角色稍后会对您的观点进行回应和讨论。用中文回答。\n${state.commonPromptInstructions}`;
476
- } else if (state.currentTurn === 0) {
477
- const otherRoles = state.roleOrder.filter(r => r.id !== currentRole.id).map(r => r.name).join(' 和 ');
478
- prompt = `用户的查询 (中文) 是: "${state.userQuery}". ${state.imageApiPart ? "用户还提供了一张图片。请在您的分析和回复中同时考虑此图片和文本查询。" : ""} 当前讨论 (均为中文):\n${state.discussionLog.join("\n")}\n您正在与 ${otherRoles} 协作讨论这个问题。请提供您的观点和分析。用中文回答。\n${state.commonPromptInstructions}`;
479
- } else {
480
- const otherRoles = state.roleOrder.filter(r => r.id !== currentRole.id).map(r => r.name).join(' 和 ');
481
- prompt = `用户的查询 (中文) 是: "${state.userQuery}". ${state.imageApiPart ? "用户还提供了一张图片。请在您的分析和回复中同时考虑此图片和文本查询。" : ""} 当前讨论 (均为中文):\n${state.discussionLog.join("\n")}\n您正在与 ${otherRoles} 协作讨论。请对前面的讨论进行回应,提供您的进一步见解或不同观点。保持简洁并使用中文。\n${NOTEPAD_INSTRUCTION_PROMPT_PART.replace('{notepadContent}', notepadContent)}`;
482
-
483
- if (discussionMode === DiscussionMode.AiDriven && state.previousAISignaledStop) {
484
- prompt += `\n注意:之前有AI角色建议结束讨论。如果您同意,请在回复中包含 ${DISCUSSION_COMPLETE_TAG}。否则,请继续讨论。`;
485
- } else if (discussionMode === DiscussionMode.AiDriven) {
486
- prompt += AI_DRIVEN_DISCUSSION_INSTRUCTION_PROMPT_PART;
487
- }
488
- }
489
-
490
- // 用于后台累积完整响应文本的变量
491
- let accumulatedText = '';
492
-
493
- // 开始流式API调用
494
- generateResponse(
495
- prompt,
496
- currentRole.model.apiName,
497
- currentRole.systemPrompt,
498
- shouldUseReducedCapacity,
499
- state.imageApiPart,
500
- currentRole.channel.baseUrl,
501
- currentRole.channel.apiKey,
502
- // 关键的流式回调函数
503
- (newChunk: string, fullText: string, isComplete: boolean) => {
504
- // newChunk: 本次新接收到的文本块
505
- // fullText: API累积的完整文本(用于后续处理)
506
- // isComplete: 是否完成
507
-
508
- // 更新后台累积的完整文本
509
- accumulatedText = fullText;
510
-
511
- // 实时更新界面显示 - 关键是这里直接使用 fullText 进行显示
512
- setMessages(prev => prev.map(msg =>
513
- msg.id === messageId ? {
514
- ...msg,
515
- text: fullText, // 直接显示累积的完整文本,实现打字机效果
516
- durationMs: isComplete ? (performance.now() - (currentQueryStartTimeRef.current || 0)) : undefined
517
- } : msg
518
- ));
519
- }
520
- ).then(response => {
521
- if (cancelRequestRef.current) {
522
- setIsDiscussionActive(false);
523
- setCurrentDiscussion(null);
524
- return;
525
- }
526
-
527
- if (response.error) {
528
- if (response.error.includes("API key not valid") || response.error.includes("401")) {
529
- setMessages(prev => prev.map(msg =>
530
- msg.id === messageId ? {
531
- ...msg,
532
- text: `API密钥无效 (渠道: ${currentRole.channel.name}),请在配置界面中检查密钥设置。`,
533
- durationMs: response.durationMs
534
- } : msg
535
- ));
536
- setIsLoading(false);
537
- setIsDiscussionActive(false);
538
- setCurrentDiscussion(null);
539
- return;
540
- }
541
- throw new Error(`${currentRole.name}: ${response.text}`);
542
- }
543
-
544
- // 确保最终消息内容正确
545
- setMessages(prev => prev.map(msg =>
546
- msg.id === messageId ? {
547
- ...msg,
548
- text: response.text,
549
- durationMs: response.durationMs
550
- } : msg
551
- ));
552
-
553
- // 解析响应
554
- const parsedResponse = parseAIResponse(response.text);
555
- if (parsedResponse.newNotepadContent !== null) {
556
- setNotepadContent(parsedResponse.newNotepadContent);
557
- setLastNotepadUpdateBy(createDynamicMessageSender(currentRole.name));
558
- }
559
-
560
- // 更新讨论日志 - 使用解析后的文本
561
- const newDiscussionLog = [...state.discussionLog, `${currentRole.name}: ${parsedResponse.spokenText}`];
562
-
563
- // 更新状态
564
- let newState = {
565
- ...state,
566
- discussionLog: newDiscussionLog,
567
- isFirstMessage: false,
568
- currentRoleIndex: state.currentRoleIndex + 1
569
- };
570
-
571
- // 处理AI驱动模式的结束信号
572
- if (discussionMode === DiscussionMode.AiDriven && parsedResponse.discussionShouldEnd) {
573
- if (state.currentTurn > 0) {
574
- newState.discussionEndCount++;
575
- if (state.previousAISignaledStop || newState.discussionEndCount >= Math.ceil(state.roleOrder.length / 2)) {
576
- addMessage(`多数AI角色已同意结束讨论。`, MessageSender.System, MessagePurpose.SystemNotification);
577
- newState.previousAISignaledStop = true;
578
- } else {
579
- addMessage(`${currentRole.name} 已建议结束讨论。`, MessageSender.System, MessagePurpose.SystemNotification);
580
- }
581
- } else {
582
- newState.previousAISignaledStop = true;
583
- addMessage(`${currentRole.name} 已建议结束讨论。`, MessageSender.System, MessagePurpose.SystemNotification);
584
- }
585
- }
586
-
587
- setCurrentDiscussion(newState);
588
-
589
- // 继续处理下一个角色
590
- setTimeout(() => processNextRole(newState), 100);
591
- }).catch(error => {
592
- console.error("处理AI响应时出错:", error);
593
- addMessage(`错误: ${error instanceof Error ? error.message : "处理响应时发生未知错误"}`, MessageSender.System, MessagePurpose.SystemNotification);
594
- setIsLoading(false);
595
- setIsDiscussionActive(false);
596
- setCurrentDiscussion(null);
597
- });
598
- };
599
-
600
- const processFinalAnswer = (state: DiscussionState) => {
601
- const finalAnswerRole = state.roleOrder[0];
602
- const shouldUseReducedCapacity = isReducedCapacityEnabled && finalAnswerRole.model.supportsReducedCapacity;
603
-
604
- addMessage(
605
- `${finalAnswerRole.name} 正在综合所有讨论内容,准备最终答案 (使用 ${finalAnswerRole.model.name} - ${finalAnswerRole.channel.name})...`,
606
- MessageSender.System,
607
- MessagePurpose.SystemNotification
608
- );
609
-
610
- // 立即创建最终答案消息气泡
611
- const finalMessageId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
612
- const finalMessage: ChatMessage = {
613
- id: finalMessageId,
614
- text: '', // 开始时为空
615
- sender: createDynamicMessageSender(finalAnswerRole.name),
616
- purpose: MessagePurpose.FinalResponse,
617
- timestamp: new Date()
618
- };
619
-
620
- setMessages(prev => [...prev, finalMessage]);
621
-
622
- const finalPrompt = `用户最初的查询 (中文) 是: "${state.userQuery}". ${state.imageApiPart ? "用户还提供了一张图片。请在您的分析和回复中同时考虑此图片和文本查询。" : ""} 您和其他AI角色进行了以下讨论 (均为中文):\n${state.discussionLog.join("\n")}\n基于整个协作讨论过程和共享记事本的最终状态,请综合所有关键观点,为用户提供一个全面、有用的最终答案。直接回复用户,确保答案结构良好,易于理解,并使用中文。如果相关,您可以在答案中引用记事本内容。如果需要,您也可以最后一次更新记事本。\n${NOTEPAD_INSTRUCTION_PROMPT_PART.replace('{notepadContent}', notepadContent)}`;
623
-
624
- generateResponse(
625
- finalPrompt,
626
- finalAnswerRole.model.apiName,
627
- finalAnswerRole.systemPrompt,
628
- shouldUseReducedCapacity,
629
- state.imageApiPart,
630
- finalAnswerRole.channel.baseUrl,
631
- finalAnswerRole.channel.apiKey,
632
- // 最终答案的流式回调
633
- (newChunk: string, fullText: string, isComplete: boolean) => {
634
- setMessages(prev => prev.map(msg =>
635
- msg.id === finalMessageId ? {
636
- ...msg,
637
- text: fullText, // 实时显示累积文本
638
- durationMs: isComplete ? (performance.now() - (currentQueryStartTimeRef.current || 0)) : undefined
639
- } : msg
640
- ));
641
- }
642
- ).then(finalResponse => {
643
- if (cancelRequestRef.current) {
644
- setIsDiscussionActive(false);
645
- setCurrentDiscussion(null);
646
- return;
647
- }
648
-
649
- if (finalResponse.error) {
650
- if (finalResponse.error.includes("API key not valid") || finalResponse.error.includes("401")) {
651
- setMessages(prev => prev.map(msg =>
652
- msg.id === finalMessageId ? {
653
- ...msg,
654
- text: `API密钥无效 (渠道: ${finalAnswerRole.channel.name}),请在配置界面中检查密钥设置。`,
655
- durationMs: finalResponse.durationMs
656
- } : msg
657
- ));
658
- setIsLoading(false);
659
- setIsDiscussionActive(false);
660
- setCurrentDiscussion(null);
661
- return;
662
- }
663
- throw new Error(`${finalAnswerRole.name}: ${finalResponse.text}`);
664
- }
665
-
666
- setMessages(prev => prev.map(msg =>
667
- msg.id === finalMessageId ? {
668
- ...msg,
669
- text: finalResponse.text,
670
- durationMs: finalResponse.durationMs
671
- } : msg
672
- ));
673
-
674
- const finalParsedResponse = parseAIResponse(finalResponse.text);
675
- if (finalParsedResponse.newNotepadContent !== null) {
676
- setNotepadContent(finalParsedResponse.newNotepadContent);
677
- setLastNotepadUpdateBy(createDynamicMessageSender(finalAnswerRole.name));
678
- }
679
-
680
- // 讨论完成
681
- setIsLoading(false);
682
- setIsDiscussionActive(false);
683
- setCurrentDiscussion(null);
684
- currentQueryStartTimeRef.current = null;
685
- }).catch(error => {
686
- console.error("生成最终答案时出错:", error);
687
- addMessage(`错误: ${error instanceof Error ? error.message : "生成最终答案时发生未知错误"}`, MessageSender.System, MessagePurpose.SystemNotification);
688
- setIsLoading(false);
689
- setIsDiscussionActive(false);
690
- setCurrentDiscussion(null);
691
- });
692
- };
693
-
694
- const handleSendMessage = async (userInput: string, imageFile?: File | null) => {
695
- if (isLoading) return;
696
- if (!userInput.trim() && !imageFile) return;
697
-
698
- if (channels.length === 0) {
699
- addMessage("请先配置API渠道。点击设置按钮添加渠道。", MessageSender.System, MessagePurpose.SystemNotification);
700
- return;
701
- }
702
-
703
- if (activeRoles.length === 0) {
704
- addMessage("请先配置AI角色。点击设置按钮添加角色。", MessageSender.System, MessagePurpose.SystemNotification);
705
- return;
706
- }
707
-
708
- const rolesWithoutApiKey = activeRoles.filter(role => !role.channel.apiKey?.trim());
709
- if (rolesWithoutApiKey.length > 0) {
710
- const roleNames = rolesWithoutApiKey.map(role => `${role.name}(${role.channel.name})`).join('、');
711
- addMessage(`以下角色的API渠道缺少API密钥: ${roleNames}。请在配置界面中设置相应的API密钥。`, MessageSender.System, MessagePurpose.SystemNotification);
712
- return;
713
- }
714
-
715
- if (imageFile) {
716
- const supportsImages = activeRoles.some(role => role.model.supportsImages);
717
- if (!supportsImages) {
718
- addMessage("当前活跃的角色都不支持图片处理。请添加支持图片的模型和角色,或移除图片。", MessageSender.System, MessagePurpose.SystemNotification);
719
- return;
720
- }
721
- }
722
-
723
- setIsDiscussionActive(true);
724
- cancelRequestRef.current = false;
725
- setIsLoading(true);
726
- currentQueryStartTimeRef.current = performance.now();
727
- setCurrentTotalProcessingTimeMs(0);
728
-
729
- let userImageForDisplay: ChatMessage['image'] | undefined = undefined;
730
- if (imageFile) {
731
- const dataUrl = URL.createObjectURL(imageFile);
732
- userImageForDisplay = { dataUrl, name: imageFile.name, type: imageFile.type };
733
- }
734
-
735
- addMessage(userInput, MessageSender.User, MessagePurpose.UserInput, undefined, userImageForDisplay);
736
-
737
- let imageApiPart: { inlineData: { mimeType: string; data: string } } | undefined = undefined;
738
- if (imageFile) {
739
- try {
740
- const base64Data = await fileToBase64(imageFile);
741
- imageApiPart = {
742
- inlineData: {
743
- mimeType: imageFile.type,
744
- data: base64Data,
745
- },
746
- };
747
- } catch (error) {
748
- console.error("Error converting file to base64:", error);
749
- addMessage("图片处理失败,请重试。", MessageSender.System, MessagePurpose.SystemNotification);
750
- setIsLoading(false);
751
- setIsDiscussionActive(false);
752
- return;
753
- }
754
- }
755
-
756
- const discussionModeInstruction = discussionMode === DiscussionMode.AiDriven ? AI_DRIVEN_DISCUSSION_INSTRUCTION_PROMPT_PART : "";
757
- const commonPromptInstructions = NOTEPAD_INSTRUCTION_PROMPT_PART.replace('{notepadContent}', notepadContent) + discussionModeInstruction;
758
-
759
- const roleOrder = [...activeRoles];
760
- const maxTurnsForLoop = discussionMode === DiscussionMode.AiDriven ? MAX_AI_DRIVEN_DISCUSSION_TURNS_PER_MODEL : manualFixedTurns;
761
-
762
- // 初始化讨论状态
763
- const discussionState: DiscussionState = {
764
- currentRoleIndex: 0,
765
- currentTurn: 0,
766
- discussionLog: [],
767
- isFirstMessage: true,
768
- previousAISignaledStop: false,
769
- discussionEndCount: 0,
770
- userQuery: userInput,
771
- imageApiPart,
772
- commonPromptInstructions,
773
- roleOrder,
774
- maxTurnsForLoop
775
- };
776
-
777
- setCurrentDiscussion(discussionState);
778
-
779
- // 开始第一个AI的回复(不等待)
780
- processNextRole(discussionState);
781
-
782
- // 清理图片URL
783
- if (userImageForDisplay?.dataUrl.startsWith('blob:')) {
784
- // 延迟清理,确保消息已渲染
785
- setTimeout(() => {
786
- URL.revokeObjectURL(userImageForDisplay.dataUrl);
787
- }, 5000);
788
- }
789
- };
790
-
791
- const Separator = () => <div className="h-6 w-px bg-gray-600" aria-hidden="true"></div>;
792
-
793
- const hasValidChannels = channels.some(ch => ch.apiKey?.trim());
794
- const isSystemReady = hasValidChannels && activeRoles.length > 0;
795
-
796
- return (
797
- <div className="flex flex-col h-screen max-w-7xl mx-auto bg-gray-900 shadow-2xl rounded-lg overflow-hidden">
798
- {updateNotification && (
799
- <div className="fixed top-4 left-1/2 transform -translate-x-1/2 bg-green-700 text-white px-6 py-3 rounded-lg shadow-lg z-50">
800
- <div className="flex items-center space-x-3">
801
- <div className="flex-shrink-0">
802
- <Check size={24} className="text-white" />
803
- </div>
804
- <div>
805
- <p className="font-semibold">配置已更新!</p>
806
- <p className="text-sm">
807
- {updateNotification.newChannels > 0 && `新增 ${updateNotification.newChannels} 个渠道 `}
808
- {updateNotification.newModels > 0 && `新增 ${updateNotification.newModels} 个模型 `}
809
- {updateNotification.newRoles > 0 && `新增 ${updateNotification.newRoles} 个角色`}
810
- </p>
811
- </div>
812
- <button
813
- onClick={() => setUpdateNotification(null)}
814
- className="flex-shrink-0 ml-4 hover:bg-green-600 rounded p-1"
815
- >
816
- <X size={20} />
817
- </button>
818
- </div>
819
- </div>
820
- )}
821
- <header className="p-4 bg-gray-900 border-b border-gray-700 flex items-center justify-between shrink-0 space-x-2 md:space-x-4 flex-wrap">
822
- <div className="flex items-center shrink-0">
823
- <BotMessageSquare size={28} className="mr-2 md:mr-3 text-sky-400" />
824
- <h1 className="text-xl md:text-2xl font-semibold text-sky-400">Multi-Mind Chat 智囊团</h1>
825
- </div>
826
-
827
- <div className="flex items-center space-x-2 md:space-x-3 flex-wrap justify-end gap-y-2">
828
- {/* 讨论控制按钮 */}
829
- {isDiscussionActive && (
830
- <div className="flex items-center space-x-2">
831
- <button
832
- onClick={interruptDiscussion}
833
- className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm flex items-center space-x-1"
834
- title="中断当前讨论"
835
- >
836
- <Square size={16} />
837
- <span>中断讨论</span>
838
- </button>
839
- <Separator />
840
- </div>
841
- )}
842
-
843
- {/* 导出按钮 - 有消息时始终可用 */}
844
- {messages.length > 1 && (
845
- <div className="flex items-center space-x-2">
846
- <button
847
- onClick={exportDiscussionRecord}
848
- className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm flex items-center space-x-1"
849
- title="导出对话记录"
850
- disabled={isLoading}
851
- >
852
- <Download size={16} />
853
- <span>导出记录</span>
854
- </button>
855
- <Separator />
856
- </div>
857
- )}
858
-
859
- {/* 角色管理器 */}
860
- <div className="relative flex items-center">
861
- <label className="text-sm text-gray-300 mr-1.5 flex items-center shrink-0">
862
- <Users size={18} className="mr-1 text-sky-400"/>
863
- 角色:
864
- </label>
865
- <button
866
- onClick={() => setIsRoleSelectorOpen(!isRoleSelectorOpen)}
867
- className="bg-gray-700 border border-gray-600 text-white text-sm rounded-md p-1.5 focus:ring-2 focus:ring-sky-500 focus:border-sky-500 outline-none flex items-center space-x-2 min-w-[120px]"
868
- aria-label="管理AI角色"
869
- >
870
- <span className="truncate">{activeRoles.length}个活跃</span>
871
- <ChevronDown size={16} className={`transition-transform ${isRoleSelectorOpen ? 'rotate-180' : ''}`} />
872
- </button>
873
-
874
- {isRoleSelectorOpen && (
875
- <div className="absolute top-full left-0 mt-1 w-80 bg-gray-800 border border-gray-600 rounded-md shadow-lg z-50 max-h-96 overflow-y-auto">
876
- <div className="p-3 border-b border-gray-700">
877
- <div className="flex justify-between items-center">
878
- <h3 className="text-white font-medium">活跃角色</h3>
879
- <button
880
- onClick={() => {
881
- setIsConfigManagerOpen(true);
882
- setIsRoleSelectorOpen(false);
883
- }}
884
- className="text-xs bg-sky-600 hover:bg-sky-700 text-white px-2 py-1 rounded flex items-center space-x-1"
885
- >
886
- <Settings size={12} />
887
- <span>配置</span>
888
- </button>
889
- </div>
890
- </div>
891
-
892
- {activeRoles.length === 0 ? (
893
- <div className="p-4 text-center text-gray-400">
894
- <p className="mb-2">暂无活跃角色</p>
895
- <button
896
- onClick={() => {
897
- setIsConfigManagerOpen(true);
898
- setIsRoleSelectorOpen(false);
899
- }}
900
- className="text-sm bg-sky-600 hover:bg-sky-700 text-white px-3 py-1 rounded"
901
- >
902
- 添加角色
903
- </button>
904
- </div>
905
- ) : (
906
- <div className="max-h-64 overflow-y-auto">
907
- {activeRoles.map((role) => (
908
- <div key={role.id} className="p-3 border-b border-gray-700 last:border-b-0">
909
- <div className="flex justify-between items-start">
910
- <div className="flex-1">
911
- <div className="flex items-center space-x-2">
912
- <h4 className="text-white font-medium">{role.name}</h4>
913
- <button
914
- onClick={() => toggleRoleActiveState(role.id)}
915
- className={`p-1 rounded transition-colors ${
916
- role.isActive ? 'text-green-400 hover:text-green-300' : 'text-gray-500 hover:text-gray-400'
917
- }`}
918
- title={role.isActive ? '暂停角色' : '激活角色'}
919
- >
920
- {role.isActive ? <Play size={14} /> : <Pause size={14} />}
921
- </button>
922
- </div>
923
- <p className="text-gray-400 text-xs">{role.model.name}</p>
924
- <p className="text-gray-500 text-xs">渠道: {role.channel.name}</p>
925
- <div className="flex space-x-1 mt-1">
926
- {role.model.supportsImages && (
927
- <span className="text-xs bg-green-600 text-white px-1 rounded">图像</span>
928
- )}
929
- {role.model.supportsReducedCapacity && (
930
- <span className="text-xs bg-blue-600 text-white px-1 rounded">优化</span>
931
- )}
932
- {!role.channel.apiKey?.trim() && (
933
- <span className="text-xs bg-red-600 text-white px-1 rounded">缺少密钥</span>
934
- )}
935
- </div>
936
- </div>
937
- </div>
938
- </div>
939
- ))}
940
- </div>
941
- )}
942
- </div>
943
- )}
944
-
945
- {isRoleSelectorOpen && (
946
- <div
947
- className="fixed inset-0 z-40"
948
- onClick={() => setIsRoleSelectorOpen(false)}
949
- />
950
- )}
951
- </div>
952
-
953
- <Separator />
954
-
955
- <div className="flex items-center space-x-1.5">
956
- <label
957
- htmlFor="discussionModeToggle"
958
- className="flex items-center text-sm text-gray-300 cursor-pointer hover:text-sky-400"
959
- title={discussionMode === DiscussionMode.FixedTurns ? "切换到AI驱动模式" : "切换到固定轮次模式"}
960
- >
961
- {discussionMode === DiscussionMode.FixedTurns
962
- ? <MessagesSquare size={18} className="mr-1 text-sky-400" />
963
- : <Bot size={18} className="mr-1 text-sky-400" />}
964
- <span className="mr-1 select-none shrink-0">模式:</span>
965
- <div className="relative">
966
- <input
967
- type="checkbox"
968
- id="discussionModeToggle"
969
- className="sr-only peer"
970
- checked={discussionMode === DiscussionMode.AiDriven}
971
- onChange={() => setDiscussionMode(prev => prev === DiscussionMode.FixedTurns ? DiscussionMode.AiDriven : DiscussionMode.FixedTurns)}
972
- aria-label="切换对话模式"
973
- disabled={isLoading}
974
- />
975
- <div className={`block w-10 h-6 rounded-full transition-colors ${discussionMode === DiscussionMode.AiDriven ? 'bg-sky-500' : 'bg-gray-600'}`}></div>
976
- <div className={`absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${discussionMode === DiscussionMode.AiDriven ? 'translate-x-4' : ''}`}></div>
977
- </div>
978
- <span className="ml-1.5 select-none shrink-0 min-w-[4rem] text-left">
979
- {discussionMode === DiscussionMode.FixedTurns ? '固定' : 'AI驱动'}
980
- </span>
981
- </label>
982
- {discussionMode === DiscussionMode.FixedTurns && (
983
- <div className="flex items-center text-sm text-gray-300">
984
- <input
985
- type="number"
986
- id="manualFixedTurnsInput"
987
- value={manualFixedTurns}
988
- onChange={handleManualFixedTurnsChange}
989
- min={MIN_MANUAL_FIXED_TURNS}
990
- max={MAX_MANUAL_FIXED_TURNS}
991
- className="w-14 bg-gray-700 border border-gray-600 text-white text-sm rounded-md p-1 text-center focus:ring-1 focus:ring-sky-500 focus:border-sky-500 outline-none"
992
- aria-label="设置固定轮次数量"
993
- disabled={isLoading}
994
- />
995
- <span className="ml-1 select-none">轮</span>
996
- </div>
997
- )}
998
- </div>
999
-
1000
- <Separator />
1001
-
1002
- <label
1003
- htmlFor="capacityToggle"
1004
- className="flex items-center text-sm text-gray-300 cursor-pointer hover:text-sky-400"
1005
- title={isReducedCapacityEnabled ? "切换为优质模式 (完整性能)" : "切换为快速模式 (降低性能)"}
1006
- >
1007
- <SlidersHorizontal size={18} className={`mr-1.5 ${!isReducedCapacityEnabled ? 'text-sky-400' : 'text-gray-500'}`} />
1008
- <span className="mr-2 select-none shrink-0">性能:</span>
1009
- <div className="relative">
1010
- <input
1011
- type="checkbox"
1012
- id="capacityToggle"
1013
- className="sr-only peer"
1014
- checked={!isReducedCapacityEnabled}
1015
- onChange={() => setIsReducedCapacityEnabled(!isReducedCapacityEnabled)}
1016
- aria-label="切换AI性能模式"
1017
- disabled={isLoading}
1018
- />
1019
- <div className={`block w-10 h-6 rounded-full transition-colors ${!isReducedCapacityEnabled ? 'bg-sky-500 peer-checked:bg-sky-500' : 'bg-gray-600'}`}></div>
1020
- <div className={`absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${!isReducedCapacityEnabled ? 'peer-checked:translate-x-4' : ''}`}></div>
1021
- </div>
1022
- <span className="ml-2 w-20 text-left select-none shrink-0">
1023
- {!isReducedCapacityEnabled ? '优质' : '快速'}
1024
- </span>
1025
- </label>
1026
-
1027
- <Separator />
1028
-
1029
- <button
1030
- onClick={() => setIsConfigManagerOpen(true)}
1031
- className="p-2 text-gray-400 hover:text-sky-400 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-gray-900 rounded-md shrink-0"
1032
- aria-label="配置管理"
1033
- title="配置管理"
1034
- >
1035
- <Settings size={22} />
1036
- </button>
1037
-
1038
- <button
1039
- onClick={handleClearChat}
1040
- className="p-2 text-gray-400 hover:text-sky-400 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-gray-900 rounded-md shrink-0"
1041
- aria-label="清空会话"
1042
- title="清空会话"
1043
- disabled={isLoading}
1044
- >
1045
- <RefreshCcw size={22} />
1046
- </button>
1047
- </div>
1048
- </header>
1049
-
1050
- <div className="flex flex-row flex-grow overflow-hidden">
1051
- <div className="flex flex-col w-2/3 md:w-3/5 lg:w-2/3 h-full">
1052
- <div ref={chatContainerRef} className="flex-grow p-4 space-y-4 overflow-y-auto bg-gray-800 scroll-smooth">
1053
- {messages.map((msg) => {
1054
- const streamingState = streamingMessages.get(msg.id);
1055
- const displayMessage = streamingState && !streamingState.isComplete
1056
- ? { ...msg, text: streamingState.text }
1057
- : msg;
1058
-
1059
- return <MessageBubble key={msg.id} message={displayMessage} />;
1060
- })}
1061
- </div>
1062
- <ChatInput onSendMessage={handleSendMessage} isLoading={isLoading} isApiKeyMissing={!isSystemReady} />
1063
- </div>
1064
-
1065
- <div className="w-1/3 md:w-2/5 lg:w-1/3 h-full bg-slate-800">
1066
- <Notepad
1067
- content={notepadContent}
1068
- lastUpdatedBy={lastNotepadUpdateBy}
1069
- isLoading={isLoading}
1070
- />
1071
- </div>
1072
- </div>
1073
-
1074
- {/* 配置管理器 */}
1075
- <ModelConfigManager
1076
- isOpen={isConfigManagerOpen}
1077
- onClose={() => setIsConfigManagerOpen(false)}
1078
- onConfigChange={loadConfiguration}
1079
- />
1080
-
1081
- {/* 处理时间显示 */}
1082
- {(isLoading || (currentTotalProcessingTimeMs > 0 && !isLoading) || (isLoading && currentTotalProcessingTimeMs === 0)) && (
1083
- <div className="fixed bottom-4 right-4 md:bottom-6 md:right-6 bg-gray-900 bg-opacity-80 text-white p-2 rounded-md shadow-lg text-xs z-50">
1084
- 总耗时: {(currentTotalProcessingTimeMs / 1000).toFixed(2)}s
1085
- {isDiscussionActive && (
1086
- <div className="text-green-400 mt-1">讨论进行中...</div>
1087
- )}
1088
- </div>
1089
- )}
1090
-
1091
- {/* 系统状态提示 */}
1092
- {!isSystemReady && (
1093
- <div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 p-3 bg-orange-700 text-white rounded-lg shadow-lg flex items-center text-sm z-50">
1094
- <AlertTriangle size={20} className="mr-2" />
1095
- {!hasValidChannels ? '请配置API渠道和密钥' : '请配置AI角色'}
1096
- 。点击设置按钮进行配置。
1097
- </div>
1098
- )}
1099
- </div>
1100
- );
1101
- };
1102
-
1103
  export default App;
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { ChatMessage, MessageSender, MessagePurpose } from './types';
3
+ import { generateResponse } from './services/openaiService';
4
+ import ChatInput from './components/ChatInput';
5
+ import MessageBubble from './components/MessageBubble';
6
+ import Notepad from './components/Notepad';
7
+ import ModelConfigManager from './components/ModelConfigManager';
8
+ import {
9
+ AiModel,
10
+ AiRole,
11
+ ApiChannel,
12
+ ModelConfigManager as ConfigManager,
13
+ DEFAULT_MANUAL_FIXED_TURNS,
14
+ MIN_MANUAL_FIXED_TURNS,
15
+ MAX_MANUAL_FIXED_TURNS,
16
+ MAX_AI_DRIVEN_DISCUSSION_TURNS_PER_MODEL,
17
+ INITIAL_NOTEPAD_CONTENT,
18
+ NOTEPAD_INSTRUCTION_PROMPT_PART,
19
+ NOTEPAD_UPDATE_TAG_START,
20
+ NOTEPAD_UPDATE_TAG_END,
21
+ DISCUSSION_COMPLETE_TAG,
22
+ AI_DRIVEN_DISCUSSION_INSTRUCTION_PROMPT_PART,
23
+ DiscussionMode
24
+ } from './constants';
25
+ import {
26
+ BotMessageSquare,
27
+ AlertTriangle,
28
+ RefreshCcw,
29
+ SlidersHorizontal,
30
+ Users,
31
+ MessagesSquare,
32
+ Bot,
33
+ ChevronDown,
34
+ Settings,
35
+ Play,
36
+ Pause,
37
+ Square,
38
+ Download
39
+ } from 'lucide-react';
40
+
41
+ interface ParsedAIResponse {
42
+ spokenText: string;
43
+ newNotepadContent: string | null;
44
+ discussionShouldEnd?: boolean;
45
+ }
46
+
47
+ interface ActiveRole extends AiRole {
48
+ model: AiModel;
49
+ channel: ApiChannel;
50
+ isProcessing?: boolean;
51
+ }
52
+
53
+ interface DiscussionState {
54
+ currentRoleIndex: number;
55
+ currentTurn: number;
56
+ discussionLog: string[];
57
+ isFirstMessage: boolean;
58
+ previousAISignaledStop: boolean;
59
+ discussionEndCount: number;
60
+ userQuery: string;
61
+ imageApiPart?: any;
62
+ commonPromptInstructions: string;
63
+ roleOrder: ActiveRole[];
64
+ maxTurnsForLoop: number;
65
+ }
66
+
67
+ const parseAIResponse = (responseText: string): ParsedAIResponse => {
68
+ let currentText = responseText.trim();
69
+ let spokenText = "";
70
+ let newNotepadContent: string | null = null;
71
+ let discussionShouldEnd = false;
72
+
73
+ let notepadActionText = "";
74
+ let discussionActionText = "";
75
+
76
+ const notepadStartIndex = currentText.lastIndexOf(NOTEPAD_UPDATE_TAG_START);
77
+ const notepadEndIndex = currentText.lastIndexOf(NOTEPAD_UPDATE_TAG_END);
78
+
79
+ if (notepadStartIndex !== -1 && notepadEndIndex !== -1 && notepadEndIndex > notepadStartIndex && currentText.endsWith(NOTEPAD_UPDATE_TAG_END)) {
80
+ newNotepadContent = currentText.substring(notepadStartIndex + NOTEPAD_UPDATE_TAG_START.length, notepadEndIndex).trim();
81
+ spokenText = currentText.substring(0, notepadStartIndex).trim();
82
+
83
+ if (newNotepadContent) {
84
+ notepadActionText = "更新了记事本";
85
+ } else {
86
+ notepadActionText = "尝试更新记事本但内容为空";
87
+ }
88
+ } else {
89
+ spokenText = currentText;
90
+ }
91
+
92
+ if (spokenText.includes(DISCUSSION_COMPLETE_TAG)) {
93
+ discussionShouldEnd = true;
94
+ spokenText = spokenText.replace(new RegExp(DISCUSSION_COMPLETE_TAG.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), "").trim();
95
+ discussionActionText = "建议结束讨论";
96
+ }
97
+
98
+ if (!spokenText.trim()) {
99
+ if (notepadActionText && discussionActionText) {
100
+ spokenText = `(AI ${notepadActionText}并${discussionActionText})`;
101
+ } else if (notepadActionText) {
102
+ spokenText = `(AI ${notepadActionText})`;
103
+ } else if (discussionActionText) {
104
+ spokenText = `(AI ${discussionActionText})`;
105
+ } else {
106
+ spokenText = "(AI 未提供额外文本回复)";
107
+ }
108
+ }
109
+
110
+ return { spokenText: spokenText.trim(), newNotepadContent, discussionShouldEnd };
111
+ };
112
+
113
+ const fileToBase64 = (file: File): Promise<string> => {
114
+ return new Promise((resolve, reject) => {
115
+ const reader = new FileReader();
116
+ reader.readAsDataURL(file);
117
+ reader.onload = () => {
118
+ const result = reader.result as string;
119
+ resolve(result.split(',')[1]);
120
+ };
121
+ reader.onerror = (error) => reject(error);
122
+ });
123
+ };
124
+
125
+ const createDynamicMessageSender = (roleName: string): MessageSender => {
126
+ return roleName as MessageSender;
127
+ };
128
+
129
+ const App: React.FC = () => {
130
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
131
+ const [isLoading, setIsLoading] = useState<boolean>(false);
132
+ const [currentTotalProcessingTimeMs, setCurrentTotalProcessingTimeMs] = useState<number>(0);
133
+
134
+ const [notepadContent, setNotepadContent] = useState<string>(INITIAL_NOTEPAD_CONTENT);
135
+ const [lastNotepadUpdateBy, setLastNotepadUpdateBy] = useState<MessageSender | null>(null);
136
+
137
+ const [discussionMode, setDiscussionMode] = useState<DiscussionMode>(DiscussionMode.FixedTurns);
138
+ const [manualFixedTurns, setManualFixedTurns] = useState<number>(DEFAULT_MANUAL_FIXED_TURNS);
139
+ const [isReducedCapacityEnabled, setIsReducedCapacityEnabled] = useState<boolean>(false);
140
+
141
+ const [activeRoles, setActiveRoles] = useState<ActiveRole[]>([]);
142
+ const [channels, setChannels] = useState<ApiChannel[]>([]);
143
+ const [models, setModels] = useState<AiModel[]>([]);
144
+ const [isConfigManagerOpen, setIsConfigManagerOpen] = useState<boolean>(false);
145
+ const [isRoleSelectorOpen, setIsRoleSelectorOpen] = useState<boolean>(false);
146
+
147
+ const [isDiscussionActive, setIsDiscussionActive] = useState<boolean>(false);
148
+ const [streamingMessages, setStreamingMessages] = useState<Map<string, { text: string; isComplete: boolean }>>(new Map());
149
+ const [currentDiscussion, setCurrentDiscussion] = useState<DiscussionState | null>(null);
150
+
151
+ const chatContainerRef = useRef<HTMLDivElement>(null);
152
+ const currentQueryStartTimeRef = useRef<number | null>(null);
153
+ const cancelRequestRef = useRef<boolean>(false);
154
+
155
+ // 实时流式消息管理
156
+ const createStreamingMessage = (sender: MessageSender, purpose: MessagePurpose): string => {
157
+ const messageId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
158
+ const message: ChatMessage = {
159
+ id: messageId,
160
+ text: '',
161
+ sender,
162
+ purpose,
163
+ timestamp: new Date()
164
+ };
165
+
166
+ setMessages(prev => [...prev, message]);
167
+ setStreamingMessages(prev => new Map(prev.set(messageId, { text: '', isComplete: false })));
168
+ return messageId;
169
+ };
170
+
171
+ const updateStreamingMessage = (messageId: string, fullText: string, isComplete: boolean, durationMs?: number) => {
172
+ setMessages(prev => prev.map(msg =>
173
+ msg.id === messageId ? {
174
+ ...msg,
175
+ text: fullText,
176
+ durationMs: isComplete ? durationMs : msg.durationMs
177
+ } : msg
178
+ ));
179
+ };
180
+
181
+ const loadConfiguration = () => {
182
+ const allChannels = ConfigManager.getChannels();
183
+ const allModels = ConfigManager.getModels();
184
+ const allRoles = ConfigManager.getActiveRoles();
185
+
186
+ setChannels(allChannels);
187
+ setModels(allModels);
188
+
189
+ const rolesWithModelsAndChannels: ActiveRole[] = allRoles.map(role => {
190
+ const model = allModels.find(m => m.id === role.modelId);
191
+ if (!model) {
192
+ console.warn(`Role ${role.name} references non-existent model ${role.modelId}`);
193
+ return null;
194
+ }
195
+
196
+ const channel = allChannels.find(ch => ch.id === model.channelId);
197
+ if (!channel) {
198
+ console.warn(`Model ${model.name} references non-existent channel ${model.channelId}`);
199
+ return null;
200
+ }
201
+
202
+ return { ...role, model, channel };
203
+ }).filter(Boolean) as ActiveRole[];
204
+
205
+ setActiveRoles(rolesWithModelsAndChannels);
206
+ };
207
+
208
+ useEffect(() => {
209
+ loadConfiguration();
210
+ }, []);
211
+
212
+ const addMessage = (
213
+ text: string,
214
+ sender: MessageSender,
215
+ purpose: MessagePurpose,
216
+ durationMs?: number,
217
+ image?: ChatMessage['image']
218
+ ) => {
219
+ const messageId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
220
+ const message: ChatMessage = {
221
+ id: messageId,
222
+ text,
223
+ sender,
224
+ purpose,
225
+ timestamp: new Date(),
226
+ durationMs,
227
+ image
228
+ };
229
+
230
+ setMessages(prev => [...prev, message]);
231
+ return messageId;
232
+ };
233
+
234
+ const interruptDiscussion = () => {
235
+ if (isLoading && isDiscussionActive) {
236
+ cancelRequestRef.current = true;
237
+ setIsLoading(false);
238
+ setIsDiscussionActive(false);
239
+ setCurrentDiscussion(null);
240
+
241
+ if (currentTotalProcessingTimeMs > 0) {
242
+ addMessage(
243
+ `讨论已被用户中断 (已进行 ${(currentTotalProcessingTimeMs / 1000).toFixed(2)}秒)`,
244
+ MessageSender.System,
245
+ MessagePurpose.SystemNotification
246
+ );
247
+ }
248
+
249
+ setCurrentTotalProcessingTimeMs(0);
250
+ if (currentQueryStartTimeRef.current) {
251
+ currentQueryStartTimeRef.current = null;
252
+ }
253
+ }
254
+ };
255
+
256
+ const exportDiscussionRecord = () => {
257
+ if (messages.length === 0) {
258
+ addMessage('当前没有可导出的消息记录', MessageSender.System, MessagePurpose.SystemNotification);
259
+ return;
260
+ }
261
+
262
+ // 生成简洁的文本格式导出
263
+ let exportText = `=== Multi-Mind Chat 对话记录 ===\n`;
264
+ exportText += `导出时间: ${new Date().toLocaleString()}\n`;
265
+ exportText += `消息总数: ${messages.length}\n\n`;
266
+
267
+ messages.forEach(msg => {
268
+ if (msg.purpose !== MessagePurpose.SystemNotification) {
269
+ const timeStr = msg.timestamp.toLocaleTimeString();
270
+ const durationStr = msg.durationMs ? ` (${(msg.durationMs / 1000).toFixed(2)}s)` : '';
271
+ exportText += `[${timeStr}] ${msg.sender}${durationStr}: ${msg.text}\n\n`;
272
+
273
+ if (msg.image) {
274
+ exportText += ` [附件: ${msg.image.name} - ${msg.image.type}]\n\n`;
275
+ }
276
+ }
277
+ });
278
+
279
+ if (notepadContent !== INITIAL_NOTEPAD_CONTENT) {
280
+ exportText += `=== 最终记事本内容 ===\n`;
281
+ exportText += `${notepadContent}\n\n`;
282
+ }
283
+
284
+ const blob = new Blob([exportText], { type: 'text/plain;charset=utf-8' });
285
+ const url = URL.createObjectURL(blob);
286
+ const a = document.createElement('a');
287
+ a.href = url;
288
+ a.download = `对话记录-${new Date().toISOString().split('T')[0]}-${Date.now()}.txt`;
289
+ document.body.appendChild(a);
290
+ a.click();
291
+ document.body.removeChild(a);
292
+ URL.revokeObjectURL(url);
293
+
294
+ addMessage('对话记录已导出', MessageSender.System, MessagePurpose.SystemNotification);
295
+ };
296
+
297
+ const getWelcomeMessageText = () => {
298
+ const modeDescription = discussionMode === DiscussionMode.FixedTurns
299
+ ? `固定轮次对话 (${manualFixedTurns}轮)`
300
+ : "AI驱动对话";
301
+
302
+ const roleNames = activeRoles.map(role => role.name).join(' 和 ');
303
+ const roleCount = activeRoles.length;
304
+ const channelCount = channels.length;
305
+
306
+ if (channelCount === 0) {
307
+ return `欢迎使用Multi-Mind Chat 智囊团!请先配置API渠道。点击设置按钮开始配置。`;
308
+ } else if (roleCount === 0) {
309
+ return `欢迎使用Multi-Mind Chat 智囊团!已配置 ${channelCount} 个API渠道,请继续配置AI角色和模型。点击设置按钮开始配置。`;
310
+ } else if (roleCount === 1) {
311
+ return `欢迎使用Multi-Mind Chat 智囊团!当前模式: ${modeDescription}。当前只有一个活跃角色: ${roleNames}。建议添加更多角色以获得更好的协作体验。`;
312
+ } else {
313
+ return `欢迎使用Multi-Mind Chat 智囊团!当前模式: ${modeDescription}。活跃的AI角色: ${roleNames}。这些角色将协作讨论您的问题并使用共享记事本。`;
314
+ }
315
+ };
316
+
317
+ const initializeChat = () => {
318
+ setMessages([]);
319
+ setNotepadContent(INITIAL_NOTEPAD_CONTENT);
320
+ setLastNotepadUpdateBy(null);
321
+ setIsDiscussionActive(false);
322
+ setStreamingMessages(new Map());
323
+ setCurrentDiscussion(null);
324
+
325
+ addMessage(
326
+ getWelcomeMessageText(),
327
+ MessageSender.System,
328
+ MessagePurpose.SystemNotification
329
+ );
330
+ };
331
+
332
+ useEffect(() => {
333
+ initializeChat();
334
+ }, [activeRoles, discussionMode, manualFixedTurns, channels]);
335
+
336
+ useEffect(() => {
337
+ if (chatContainerRef.current) {
338
+ chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
339
+ }
340
+ }, [messages]);
341
+
342
+ useEffect(() => {
343
+ let intervalId: number | undefined;
344
+ if (isLoading && currentQueryStartTimeRef.current) {
345
+ intervalId = window.setInterval(() => {
346
+ if (currentQueryStartTimeRef.current) {
347
+ setCurrentTotalProcessingTimeMs(performance.now() - currentQueryStartTimeRef.current);
348
+ }
349
+ }, 100);
350
+ } else {
351
+ if (intervalId) clearInterval(intervalId);
352
+ }
353
+ return () => {
354
+ if (intervalId) clearInterval(intervalId);
355
+ };
356
+ }, [isLoading]);
357
+
358
+ const handleClearChat = () => {
359
+ if (isLoading) {
360
+ cancelRequestRef.current = true;
361
+ }
362
+ setIsLoading(false);
363
+ setIsDiscussionActive(false);
364
+ setCurrentDiscussion(null);
365
+
366
+ setCurrentTotalProcessingTimeMs(0);
367
+ if (currentQueryStartTimeRef.current) {
368
+ currentQueryStartTimeRef.current = null;
369
+ }
370
+
371
+ setMessages([]);
372
+ setNotepadContent(INITIAL_NOTEPAD_CONTENT);
373
+ setLastNotepadUpdateBy(null);
374
+ setStreamingMessages(new Map());
375
+
376
+ addMessage(
377
+ getWelcomeMessageText(),
378
+ MessageSender.System,
379
+ MessagePurpose.SystemNotification
380
+ );
381
+ };
382
+
383
+ const handleManualFixedTurnsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
384
+ let value = parseInt(e.target.value, 10);
385
+ if (isNaN(value)) {
386
+ value = DEFAULT_MANUAL_FIXED_TURNS;
387
+ }
388
+ value = Math.max(MIN_MANUAL_FIXED_TURNS, Math.min(MAX_MANUAL_FIXED_TURNS, value));
389
+ setManualFixedTurns(value);
390
+ };
391
+
392
+ const toggleRoleActiveState = (roleId: string) => {
393
+ const role = activeRoles.find(r => r.id === roleId);
394
+ if (role) {
395
+ ConfigManager.updateRole(roleId, { isActive: !role.isActive });
396
+ loadConfiguration();
397
+ }
398
+ };
399
+
400
+ const processNextRole = (state: DiscussionState) => {
401
+ if (cancelRequestRef.current) {
402
+ setIsDiscussionActive(false);
403
+ setCurrentDiscussion(null);
404
+ return;
405
+ }
406
+
407
+ // 检查是否完成所有讨论
408
+ if (state.currentTurn === 0 && state.currentRoleIndex >= state.roleOrder.length) {
409
+ // 第一轮结束,开始多轮讨论
410
+ if (!state.previousAISignaledStop && state.maxTurnsForLoop > 0) {
411
+ const newState = { ...state, currentTurn: 1, currentRoleIndex: 0, discussionEndCount: 0 };
412
+ setCurrentDiscussion(newState);
413
+ processNextRole(newState);
414
+ return;
415
+ } else {
416
+ processFinalAnswer(state);
417
+ return;
418
+ }
419
+ } else if (state.currentTurn > 0 &&
420
+ (state.currentTurn >= state.maxTurnsForLoop ||
421
+ state.previousAISignaledStop ||
422
+ state.currentRoleIndex >= state.roleOrder.length)) {
423
+ processFinalAnswer(state);
424
+ return;
425
+ }
426
+
427
+ const currentRole = state.roleOrder[state.currentRoleIndex];
428
+ const shouldUseReducedCapacity = isReducedCapacityEnabled && currentRole.model.supportsReducedCapacity;
429
+
430
+ // 显示系统通知
431
+ addMessage(
432
+ `${currentRole.name} 正在${state.currentTurn === 0 ? '分析问题并提供观点' : '回应其他角色的观点'} (使用 ${currentRole.model.name} - ${currentRole.channel.name})...`,
433
+ MessageSender.System,
434
+ MessagePurpose.SystemNotification
435
+ );
436
+
437
+ // 立即创建消息气泡
438
+ const purpose = state.currentRoleIndex % 2 === 0 ? MessagePurpose.CognitoToMuse : MessagePurpose.MuseToCognito;
439
+ const messageId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
440
+
441
+ // 立即添加空消息到界面,用户会看到正在输入的效果
442
+ const initialMessage: ChatMessage = {
443
+ id: messageId,
444
+ text: '', // 开始时为空,等待流式输入
445
+ sender: createDynamicMessageSender(currentRole.name),
446
+ purpose,
447
+ timestamp: new Date()
448
+ };
449
+
450
+ setMessages(prev => [...prev, initialMessage]);
451
+
452
+ // 构建提示词
453
+ let prompt: string;
454
+ if (state.isFirstMessage) {
455
+ prompt = `用户的查询 (中文) 是: "${state.userQuery}". ${state.imageApiPart ? "用户还提供了一张图片。请在您的分析和回复中同时考虑此图片和文本查询。" : ""} 请针对此查询提供您的初步想法或分析。这是一个多AI协作的环境,其他AI角色稍后会对您的观点进行回应和讨论。用中文回答。\n${state.commonPromptInstructions}`;
456
+ } else if (state.currentTurn === 0) {
457
+ const otherRoles = state.roleOrder.filter(r => r.id !== currentRole.id).map(r => r.name).join(' 和 ');
458
+ prompt = `用户的查询 (中文) 是: "${state.userQuery}". ${state.imageApiPart ? "用户还提供了一张图片。请在您的分析和回复中同时考虑此图片和文本查询。" : ""} 当前讨论 (均为中文):\n${state.discussionLog.join("\n")}\n您正在与 ${otherRoles} 协作讨论这个问题。请提供您的观点和分析。用中文回答。\n${state.commonPromptInstructions}`;
459
+ } else {
460
+ const otherRoles = state.roleOrder.filter(r => r.id !== currentRole.id).map(r => r.name).join(' 和 ');
461
+ prompt = `用户的查询 (中文) 是: "${state.userQuery}". ${state.imageApiPart ? "用户还提供了一张图片。请在您的分析和回复中同时考虑此图片和文本查询。" : ""} 当前讨论 (均为中文):\n${state.discussionLog.join("\n")}\n您正在与 ${otherRoles} 协作讨论。请对前面的讨论进行回应,提供您的进一步见解或不同观点。保持简洁并使用中文。\n${NOTEPAD_INSTRUCTION_PROMPT_PART.replace('{notepadContent}', notepadContent)}`;
462
+
463
+ if (discussionMode === DiscussionMode.AiDriven && state.previousAISignaledStop) {
464
+ prompt += `\n注意:之前有AI角色建议结束讨论。如果您同意,请在回复中包含 ${DISCUSSION_COMPLETE_TAG}。否则,请继续讨论。`;
465
+ } else if (discussionMode === DiscussionMode.AiDriven) {
466
+ prompt += AI_DRIVEN_DISCUSSION_INSTRUCTION_PROMPT_PART;
467
+ }
468
+ }
469
+
470
+ // 用于后台累积完整响应文本的变量
471
+ let accumulatedText = '';
472
+
473
+ // 开始流式API调用
474
+ generateResponse(
475
+ prompt,
476
+ currentRole.model.apiName,
477
+ currentRole.systemPrompt,
478
+ shouldUseReducedCapacity,
479
+ state.imageApiPart,
480
+ currentRole.channel.baseUrl,
481
+ currentRole.channel.apiKey,
482
+ // 关键的流式回调函数
483
+ (newChunk: string, fullText: string, isComplete: boolean) => {
484
+ // newChunk: 本次新接收到的文本块
485
+ // fullText: API累积的完整文本(用于后续处理)
486
+ // isComplete: 是否完成
487
+
488
+ // 更新后台累积的完整文本
489
+ accumulatedText = fullText;
490
+
491
+ // 实时更新界面显示 - 关键是这里直接使用 fullText 进行显示
492
+ setMessages(prev => prev.map(msg =>
493
+ msg.id === messageId ? {
494
+ ...msg,
495
+ text: fullText, // 直接显示累积的完整文本,实现打字机效果
496
+ durationMs: isComplete ? (performance.now() - (currentQueryStartTimeRef.current || 0)) : undefined
497
+ } : msg
498
+ ));
499
+ }
500
+ ).then(response => {
501
+ if (cancelRequestRef.current) {
502
+ setIsDiscussionActive(false);
503
+ setCurrentDiscussion(null);
504
+ return;
505
+ }
506
+
507
+ if (response.error) {
508
+ if (response.error.includes("API key not valid") || response.error.includes("401")) {
509
+ setMessages(prev => prev.map(msg =>
510
+ msg.id === messageId ? {
511
+ ...msg,
512
+ text: `API密钥无效 (渠道: ${currentRole.channel.name}),请在配置界面中检查密钥设置。`,
513
+ durationMs: response.durationMs
514
+ } : msg
515
+ ));
516
+ setIsLoading(false);
517
+ setIsDiscussionActive(false);
518
+ setCurrentDiscussion(null);
519
+ return;
520
+ }
521
+ throw new Error(`${currentRole.name}: ${response.text}`);
522
+ }
523
+
524
+ // 确保最终消息内容正确
525
+ setMessages(prev => prev.map(msg =>
526
+ msg.id === messageId ? {
527
+ ...msg,
528
+ text: response.text,
529
+ durationMs: response.durationMs
530
+ } : msg
531
+ ));
532
+
533
+ // 解析响应
534
+ const parsedResponse = parseAIResponse(response.text);
535
+ if (parsedResponse.newNotepadContent !== null) {
536
+ setNotepadContent(parsedResponse.newNotepadContent);
537
+ setLastNotepadUpdateBy(createDynamicMessageSender(currentRole.name));
538
+ }
539
+
540
+ // 更新讨论日志 - 使用解析后的文本
541
+ const newDiscussionLog = [...state.discussionLog, `${currentRole.name}: ${parsedResponse.spokenText}`];
542
+
543
+ // 更新状态
544
+ let newState = {
545
+ ...state,
546
+ discussionLog: newDiscussionLog,
547
+ isFirstMessage: false,
548
+ currentRoleIndex: state.currentRoleIndex + 1
549
+ };
550
+
551
+ // 处理AI驱动模式的结束信号
552
+ if (discussionMode === DiscussionMode.AiDriven && parsedResponse.discussionShouldEnd) {
553
+ if (state.currentTurn > 0) {
554
+ newState.discussionEndCount++;
555
+ if (state.previousAISignaledStop || newState.discussionEndCount >= Math.ceil(state.roleOrder.length / 2)) {
556
+ addMessage(`多数AI角色已同意结束讨论。`, MessageSender.System, MessagePurpose.SystemNotification);
557
+ newState.previousAISignaledStop = true;
558
+ } else {
559
+ addMessage(`${currentRole.name} 已建议结束讨论。`, MessageSender.System, MessagePurpose.SystemNotification);
560
+ }
561
+ } else {
562
+ newState.previousAISignaledStop = true;
563
+ addMessage(`${currentRole.name} 已建议结束讨论。`, MessageSender.System, MessagePurpose.SystemNotification);
564
+ }
565
+ }
566
+
567
+ setCurrentDiscussion(newState);
568
+
569
+ // 继续处理下一个角色
570
+ setTimeout(() => processNextRole(newState), 100);
571
+ }).catch(error => {
572
+ console.error("处理AI响应时出错:", error);
573
+ addMessage(`错误: ${error instanceof Error ? error.message : "处理响应时发生未知错误"}`, MessageSender.System, MessagePurpose.SystemNotification);
574
+ setIsLoading(false);
575
+ setIsDiscussionActive(false);
576
+ setCurrentDiscussion(null);
577
+ });
578
+ };
579
+
580
+ const processFinalAnswer = (state: DiscussionState) => {
581
+ const finalAnswerRole = state.roleOrder[0];
582
+ const shouldUseReducedCapacity = isReducedCapacityEnabled && finalAnswerRole.model.supportsReducedCapacity;
583
+
584
+ addMessage(
585
+ `${finalAnswerRole.name} 正在综合所有讨论内容,准备最终答案 (使用 ${finalAnswerRole.model.name} - ${finalAnswerRole.channel.name})...`,
586
+ MessageSender.System,
587
+ MessagePurpose.SystemNotification
588
+ );
589
+
590
+ // 立即创建最终答案消息气泡
591
+ const finalMessageId = Date.now().toString() + Math.random().toString(36).substr(2, 9);
592
+ const finalMessage: ChatMessage = {
593
+ id: finalMessageId,
594
+ text: '', // 开始时为空
595
+ sender: createDynamicMessageSender(finalAnswerRole.name),
596
+ purpose: MessagePurpose.FinalResponse,
597
+ timestamp: new Date()
598
+ };
599
+
600
+ setMessages(prev => [...prev, finalMessage]);
601
+
602
+ const finalPrompt = `用户最初的查询 (中文) 是: "${state.userQuery}". ${state.imageApiPart ? "用户还提供了一张图片。请在您的分析和回复中同时考虑此图片和文本查询。" : ""} 您和其他AI角色进行了以下讨论 (均为中文):\n${state.discussionLog.join("\n")}\n基于整个协作讨论过程和共享记事本的最终状态,请综合所有关键观点,为用户提供一个全面、有用的最终答案。直接回复用户,确保答案结构良好,易于理解,并使用中文。如果相关,您可以在答案中引用记事本内容。如果需要,您也可以最后一次更新记事本。\n${NOTEPAD_INSTRUCTION_PROMPT_PART.replace('{notepadContent}', notepadContent)}`;
603
+
604
+ generateResponse(
605
+ finalPrompt,
606
+ finalAnswerRole.model.apiName,
607
+ finalAnswerRole.systemPrompt,
608
+ shouldUseReducedCapacity,
609
+ state.imageApiPart,
610
+ finalAnswerRole.channel.baseUrl,
611
+ finalAnswerRole.channel.apiKey,
612
+ // 最终答案的流式回调
613
+ (newChunk: string, fullText: string, isComplete: boolean) => {
614
+ setMessages(prev => prev.map(msg =>
615
+ msg.id === finalMessageId ? {
616
+ ...msg,
617
+ text: fullText, // 实时显示累积文本
618
+ durationMs: isComplete ? (performance.now() - (currentQueryStartTimeRef.current || 0)) : undefined
619
+ } : msg
620
+ ));
621
+ }
622
+ ).then(finalResponse => {
623
+ if (cancelRequestRef.current) {
624
+ setIsDiscussionActive(false);
625
+ setCurrentDiscussion(null);
626
+ return;
627
+ }
628
+
629
+ if (finalResponse.error) {
630
+ if (finalResponse.error.includes("API key not valid") || finalResponse.error.includes("401")) {
631
+ setMessages(prev => prev.map(msg =>
632
+ msg.id === finalMessageId ? {
633
+ ...msg,
634
+ text: `API密钥无效 (渠道: ${finalAnswerRole.channel.name}),请在配置界面中检查密钥设置。`,
635
+ durationMs: finalResponse.durationMs
636
+ } : msg
637
+ ));
638
+ setIsLoading(false);
639
+ setIsDiscussionActive(false);
640
+ setCurrentDiscussion(null);
641
+ return;
642
+ }
643
+ throw new Error(`${finalAnswerRole.name}: ${finalResponse.text}`);
644
+ }
645
+
646
+ setMessages(prev => prev.map(msg =>
647
+ msg.id === finalMessageId ? {
648
+ ...msg,
649
+ text: finalResponse.text,
650
+ durationMs: finalResponse.durationMs
651
+ } : msg
652
+ ));
653
+
654
+ const finalParsedResponse = parseAIResponse(finalResponse.text);
655
+ if (finalParsedResponse.newNotepadContent !== null) {
656
+ setNotepadContent(finalParsedResponse.newNotepadContent);
657
+ setLastNotepadUpdateBy(createDynamicMessageSender(finalAnswerRole.name));
658
+ }
659
+
660
+ // 讨论完成
661
+ setIsLoading(false);
662
+ setIsDiscussionActive(false);
663
+ setCurrentDiscussion(null);
664
+ currentQueryStartTimeRef.current = null;
665
+ }).catch(error => {
666
+ console.error("生成最终答案时出错:", error);
667
+ addMessage(`错误: ${error instanceof Error ? error.message : "生成最终答案时发生未知错误"}`, MessageSender.System, MessagePurpose.SystemNotification);
668
+ setIsLoading(false);
669
+ setIsDiscussionActive(false);
670
+ setCurrentDiscussion(null);
671
+ });
672
+ };
673
+
674
+ const handleSendMessage = async (userInput: string, imageFile?: File | null) => {
675
+ if (isLoading) return;
676
+ if (!userInput.trim() && !imageFile) return;
677
+
678
+ if (channels.length === 0) {
679
+ addMessage("请先配置API渠道。点击设置按钮添加渠道。", MessageSender.System, MessagePurpose.SystemNotification);
680
+ return;
681
+ }
682
+
683
+ if (activeRoles.length === 0) {
684
+ addMessage("请先配置AI角色。点击设置按钮添加角色。", MessageSender.System, MessagePurpose.SystemNotification);
685
+ return;
686
+ }
687
+
688
+ const rolesWithoutApiKey = activeRoles.filter(role => !role.channel.apiKey?.trim());
689
+ if (rolesWithoutApiKey.length > 0) {
690
+ const roleNames = rolesWithoutApiKey.map(role => `${role.name}(${role.channel.name})`).join('、');
691
+ addMessage(`以下角色的API渠道缺少API密钥: ${roleNames}。请在配置界面中设置相应的API密钥。`, MessageSender.System, MessagePurpose.SystemNotification);
692
+ return;
693
+ }
694
+
695
+ if (imageFile) {
696
+ const supportsImages = activeRoles.some(role => role.model.supportsImages);
697
+ if (!supportsImages) {
698
+ addMessage("当前活跃的角色都不支持图片处理。请添加支持图片的模型和角色,或移除图片。", MessageSender.System, MessagePurpose.SystemNotification);
699
+ return;
700
+ }
701
+ }
702
+
703
+ setIsDiscussionActive(true);
704
+ cancelRequestRef.current = false;
705
+ setIsLoading(true);
706
+ currentQueryStartTimeRef.current = performance.now();
707
+ setCurrentTotalProcessingTimeMs(0);
708
+
709
+ let userImageForDisplay: ChatMessage['image'] | undefined = undefined;
710
+ if (imageFile) {
711
+ const dataUrl = URL.createObjectURL(imageFile);
712
+ userImageForDisplay = { dataUrl, name: imageFile.name, type: imageFile.type };
713
+ }
714
+
715
+ addMessage(userInput, MessageSender.User, MessagePurpose.UserInput, undefined, userImageForDisplay);
716
+
717
+ let imageApiPart: { inlineData: { mimeType: string; data: string } } | undefined = undefined;
718
+ if (imageFile) {
719
+ try {
720
+ const base64Data = await fileToBase64(imageFile);
721
+ imageApiPart = {
722
+ inlineData: {
723
+ mimeType: imageFile.type,
724
+ data: base64Data,
725
+ },
726
+ };
727
+ } catch (error) {
728
+ console.error("Error converting file to base64:", error);
729
+ addMessage("图片处理失败,请重试。", MessageSender.System, MessagePurpose.SystemNotification);
730
+ setIsLoading(false);
731
+ setIsDiscussionActive(false);
732
+ return;
733
+ }
734
+ }
735
+
736
+ const discussionModeInstruction = discussionMode === DiscussionMode.AiDriven ? AI_DRIVEN_DISCUSSION_INSTRUCTION_PROMPT_PART : "";
737
+ const commonPromptInstructions = NOTEPAD_INSTRUCTION_PROMPT_PART.replace('{notepadContent}', notepadContent) + discussionModeInstruction;
738
+
739
+ const roleOrder = [...activeRoles];
740
+ const maxTurnsForLoop = discussionMode === DiscussionMode.AiDriven ? MAX_AI_DRIVEN_DISCUSSION_TURNS_PER_MODEL : manualFixedTurns;
741
+
742
+ // 初始化讨论状态
743
+ const discussionState: DiscussionState = {
744
+ currentRoleIndex: 0,
745
+ currentTurn: 0,
746
+ discussionLog: [],
747
+ isFirstMessage: true,
748
+ previousAISignaledStop: false,
749
+ discussionEndCount: 0,
750
+ userQuery: userInput,
751
+ imageApiPart,
752
+ commonPromptInstructions,
753
+ roleOrder,
754
+ maxTurnsForLoop
755
+ };
756
+
757
+ setCurrentDiscussion(discussionState);
758
+
759
+ // 开始第一个AI的回复(不等待)
760
+ processNextRole(discussionState);
761
+
762
+ // 清理图片URL
763
+ if (userImageForDisplay?.dataUrl.startsWith('blob:')) {
764
+ // 延迟清理,确保消息已渲染
765
+ setTimeout(() => {
766
+ URL.revokeObjectURL(userImageForDisplay.dataUrl);
767
+ }, 5000);
768
+ }
769
+ };
770
+
771
+ const Separator = () => <div className="h-6 w-px bg-gray-600" aria-hidden="true"></div>;
772
+
773
+ const hasValidChannels = channels.some(ch => ch.apiKey?.trim());
774
+ const isSystemReady = hasValidChannels && activeRoles.length > 0;
775
+
776
+ return (
777
+ <div className="flex flex-col h-screen max-w-7xl mx-auto bg-gray-900 shadow-2xl rounded-lg overflow-hidden">
778
+ <header className="p-4 bg-gray-900 border-b border-gray-700 flex items-center justify-between shrink-0 space-x-2 md:space-x-4 flex-wrap">
779
+ <div className="flex items-center shrink-0">
780
+ <BotMessageSquare size={28} className="mr-2 md:mr-3 text-sky-400" />
781
+ <h1 className="text-xl md:text-2xl font-semibold text-sky-400">Multi-Mind Chat 智囊团</h1>
782
+ </div>
783
+
784
+ <div className="flex items-center space-x-2 md:space-x-3 flex-wrap justify-end gap-y-2">
785
+ {/* 讨论控制按钮 */}
786
+ {isDiscussionActive && (
787
+ <div className="flex items-center space-x-2">
788
+ <button
789
+ onClick={interruptDiscussion}
790
+ className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm flex items-center space-x-1"
791
+ title="中断当前讨论"
792
+ >
793
+ <Square size={16} />
794
+ <span>中断讨论</span>
795
+ </button>
796
+ <Separator />
797
+ </div>
798
+ )}
799
+
800
+ {/* 导出按钮 - 有消息时始终可用 */}
801
+ {messages.length > 1 && (
802
+ <div className="flex items-center space-x-2">
803
+ <button
804
+ onClick={exportDiscussionRecord}
805
+ className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm flex items-center space-x-1"
806
+ title="导出对话记录"
807
+ disabled={isLoading}
808
+ >
809
+ <Download size={16} />
810
+ <span>导出记录</span>
811
+ </button>
812
+ <Separator />
813
+ </div>
814
+ )}
815
+
816
+ {/* 角色管理器 */}
817
+ <div className="relative flex items-center">
818
+ <label className="text-sm text-gray-300 mr-1.5 flex items-center shrink-0">
819
+ <Users size={18} className="mr-1 text-sky-400"/>
820
+ 角色:
821
+ </label>
822
+ <button
823
+ onClick={() => setIsRoleSelectorOpen(!isRoleSelectorOpen)}
824
+ className="bg-gray-700 border border-gray-600 text-white text-sm rounded-md p-1.5 focus:ring-2 focus:ring-sky-500 focus:border-sky-500 outline-none flex items-center space-x-2 min-w-[120px]"
825
+ aria-label="管理AI角色"
826
+ >
827
+ <span className="truncate">{activeRoles.length}个活跃</span>
828
+ <ChevronDown size={16} className={`transition-transform ${isRoleSelectorOpen ? 'rotate-180' : ''}`} />
829
+ </button>
830
+
831
+ {isRoleSelectorOpen && (
832
+ <div className="absolute top-full left-0 mt-1 w-80 bg-gray-800 border border-gray-600 rounded-md shadow-lg z-50 max-h-96 overflow-y-auto">
833
+ <div className="p-3 border-b border-gray-700">
834
+ <div className="flex justify-between items-center">
835
+ <h3 className="text-white font-medium">活跃角色</h3>
836
+ <button
837
+ onClick={() => {
838
+ setIsConfigManagerOpen(true);
839
+ setIsRoleSelectorOpen(false);
840
+ }}
841
+ className="text-xs bg-sky-600 hover:bg-sky-700 text-white px-2 py-1 rounded flex items-center space-x-1"
842
+ >
843
+ <Settings size={12} />
844
+ <span>配置</span>
845
+ </button>
846
+ </div>
847
+ </div>
848
+
849
+ {activeRoles.length === 0 ? (
850
+ <div className="p-4 text-center text-gray-400">
851
+ <p className="mb-2">暂无活跃角色</p>
852
+ <button
853
+ onClick={() => {
854
+ setIsConfigManagerOpen(true);
855
+ setIsRoleSelectorOpen(false);
856
+ }}
857
+ className="text-sm bg-sky-600 hover:bg-sky-700 text-white px-3 py-1 rounded"
858
+ >
859
+ 添加角色
860
+ </button>
861
+ </div>
862
+ ) : (
863
+ <div className="max-h-64 overflow-y-auto">
864
+ {activeRoles.map((role) => (
865
+ <div key={role.id} className="p-3 border-b border-gray-700 last:border-b-0">
866
+ <div className="flex justify-between items-start">
867
+ <div className="flex-1">
868
+ <div className="flex items-center space-x-2">
869
+ <h4 className="text-white font-medium">{role.name}</h4>
870
+ <button
871
+ onClick={() => toggleRoleActiveState(role.id)}
872
+ className={`p-1 rounded transition-colors ${
873
+ role.isActive ? 'text-green-400 hover:text-green-300' : 'text-gray-500 hover:text-gray-400'
874
+ }`}
875
+ title={role.isActive ? '暂停角色' : '激活角色'}
876
+ >
877
+ {role.isActive ? <Play size={14} /> : <Pause size={14} />}
878
+ </button>
879
+ </div>
880
+ <p className="text-gray-400 text-xs">{role.model.name}</p>
881
+ <p className="text-gray-500 text-xs">渠道: {role.channel.name}</p>
882
+ <div className="flex space-x-1 mt-1">
883
+ {role.model.supportsImages && (
884
+ <span className="text-xs bg-green-600 text-white px-1 rounded">图像</span>
885
+ )}
886
+ {role.model.supportsReducedCapacity && (
887
+ <span className="text-xs bg-blue-600 text-white px-1 rounded">优化</span>
888
+ )}
889
+ {!role.channel.apiKey?.trim() && (
890
+ <span className="text-xs bg-red-600 text-white px-1 rounded">缺少密钥</span>
891
+ )}
892
+ </div>
893
+ </div>
894
+ </div>
895
+ </div>
896
+ ))}
897
+ </div>
898
+ )}
899
+ </div>
900
+ )}
901
+
902
+ {isRoleSelectorOpen && (
903
+ <div
904
+ className="fixed inset-0 z-40"
905
+ onClick={() => setIsRoleSelectorOpen(false)}
906
+ />
907
+ )}
908
+ </div>
909
+
910
+ <Separator />
911
+
912
+ <div className="flex items-center space-x-1.5">
913
+ <label
914
+ htmlFor="discussionModeToggle"
915
+ className="flex items-center text-sm text-gray-300 cursor-pointer hover:text-sky-400"
916
+ title={discussionMode === DiscussionMode.FixedTurns ? "切换到AI驱动模式" : "切换到固定轮次模式"}
917
+ >
918
+ {discussionMode === DiscussionMode.FixedTurns
919
+ ? <MessagesSquare size={18} className="mr-1 text-sky-400" />
920
+ : <Bot size={18} className="mr-1 text-sky-400" />}
921
+ <span className="mr-1 select-none shrink-0">模式:</span>
922
+ <div className="relative">
923
+ <input
924
+ type="checkbox"
925
+ id="discussionModeToggle"
926
+ className="sr-only peer"
927
+ checked={discussionMode === DiscussionMode.AiDriven}
928
+ onChange={() => setDiscussionMode(prev => prev === DiscussionMode.FixedTurns ? DiscussionMode.AiDriven : DiscussionMode.FixedTurns)}
929
+ aria-label="切换对话模式"
930
+ disabled={isLoading}
931
+ />
932
+ <div className={`block w-10 h-6 rounded-full transition-colors ${discussionMode === DiscussionMode.AiDriven ? 'bg-sky-500' : 'bg-gray-600'}`}></div>
933
+ <div className={`absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${discussionMode === DiscussionMode.AiDriven ? 'translate-x-4' : ''}`}></div>
934
+ </div>
935
+ <span className="ml-1.5 select-none shrink-0 min-w-[4rem] text-left">
936
+ {discussionMode === DiscussionMode.FixedTurns ? '固定' : 'AI驱动'}
937
+ </span>
938
+ </label>
939
+ {discussionMode === DiscussionMode.FixedTurns && (
940
+ <div className="flex items-center text-sm text-gray-300">
941
+ <input
942
+ type="number"
943
+ id="manualFixedTurnsInput"
944
+ value={manualFixedTurns}
945
+ onChange={handleManualFixedTurnsChange}
946
+ min={MIN_MANUAL_FIXED_TURNS}
947
+ max={MAX_MANUAL_FIXED_TURNS}
948
+ className="w-14 bg-gray-700 border border-gray-600 text-white text-sm rounded-md p-1 text-center focus:ring-1 focus:ring-sky-500 focus:border-sky-500 outline-none"
949
+ aria-label="设置固定轮次数量"
950
+ disabled={isLoading}
951
+ />
952
+ <span className="ml-1 select-none">轮</span>
953
+ </div>
954
+ )}
955
+ </div>
956
+
957
+ <Separator />
958
+
959
+ <label
960
+ htmlFor="capacityToggle"
961
+ className="flex items-center text-sm text-gray-300 cursor-pointer hover:text-sky-400"
962
+ title={isReducedCapacityEnabled ? "切换为优质模式 (完整性能)" : "切换为快速模式 (降低性能)"}
963
+ >
964
+ <SlidersHorizontal size={18} className={`mr-1.5 ${!isReducedCapacityEnabled ? 'text-sky-400' : 'text-gray-500'}`} />
965
+ <span className="mr-2 select-none shrink-0">性能:</span>
966
+ <div className="relative">
967
+ <input
968
+ type="checkbox"
969
+ id="capacityToggle"
970
+ className="sr-only peer"
971
+ checked={!isReducedCapacityEnabled}
972
+ onChange={() => setIsReducedCapacityEnabled(!isReducedCapacityEnabled)}
973
+ aria-label="切换AI性能模式"
974
+ disabled={isLoading}
975
+ />
976
+ <div className={`block w-10 h-6 rounded-full transition-colors ${!isReducedCapacityEnabled ? 'bg-sky-500 peer-checked:bg-sky-500' : 'bg-gray-600'}`}></div>
977
+ <div className={`absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${!isReducedCapacityEnabled ? 'peer-checked:translate-x-4' : ''}`}></div>
978
+ </div>
979
+ <span className="ml-2 w-20 text-left select-none shrink-0">
980
+ {!isReducedCapacityEnabled ? '优质' : '快速'}
981
+ </span>
982
+ </label>
983
+
984
+ <Separator />
985
+
986
+ <button
987
+ onClick={() => setIsConfigManagerOpen(true)}
988
+ className="p-2 text-gray-400 hover:text-sky-400 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-gray-900 rounded-md shrink-0"
989
+ aria-label="配置管理"
990
+ title="配置管理"
991
+ >
992
+ <Settings size={22} />
993
+ </button>
994
+
995
+ <button
996
+ onClick={handleClearChat}
997
+ className="p-2 text-gray-400 hover:text-sky-400 transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-gray-900 rounded-md shrink-0"
998
+ aria-label="清空会话"
999
+ title="清空会话"
1000
+ disabled={isLoading}
1001
+ >
1002
+ <RefreshCcw size={22} />
1003
+ </button>
1004
+ </div>
1005
+ </header>
1006
+
1007
+ <div className="flex flex-row flex-grow overflow-hidden">
1008
+ <div className="flex flex-col w-2/3 md:w-3/5 lg:w-2/3 h-full">
1009
+ <div ref={chatContainerRef} className="flex-grow p-4 space-y-4 overflow-y-auto bg-gray-800 scroll-smooth">
1010
+ {messages.map((msg) => {
1011
+ const streamingState = streamingMessages.get(msg.id);
1012
+ const displayMessage = streamingState && !streamingState.isComplete
1013
+ ? { ...msg, text: streamingState.text }
1014
+ : msg;
1015
+
1016
+ return <MessageBubble key={msg.id} message={displayMessage} />;
1017
+ })}
1018
+ </div>
1019
+ <ChatInput onSendMessage={handleSendMessage} isLoading={isLoading} isApiKeyMissing={!isSystemReady} />
1020
+ </div>
1021
+
1022
+ <div className="w-1/3 md:w-2/5 lg:w-1/3 h-full bg-slate-800">
1023
+ <Notepad
1024
+ content={notepadContent}
1025
+ lastUpdatedBy={lastNotepadUpdateBy}
1026
+ isLoading={isLoading}
1027
+ />
1028
+ </div>
1029
+ </div>
1030
+
1031
+ {/* 配置管理器 */}
1032
+ <ModelConfigManager
1033
+ isOpen={isConfigManagerOpen}
1034
+ onClose={() => setIsConfigManagerOpen(false)}
1035
+ onConfigChange={loadConfiguration}
1036
+ />
1037
+
1038
+ {/* 处理时间显示 */}
1039
+ {(isLoading || (currentTotalProcessingTimeMs > 0 && !isLoading) || (isLoading && currentTotalProcessingTimeMs === 0)) && (
1040
+ <div className="fixed bottom-4 right-4 md:bottom-6 md:right-6 bg-gray-900 bg-opacity-80 text-white p-2 rounded-md shadow-lg text-xs z-50">
1041
+ 总耗时: {(currentTotalProcessingTimeMs / 1000).toFixed(2)}s
1042
+ {isDiscussionActive && (
1043
+ <div className="text-green-400 mt-1">讨论进行中...</div>
1044
+ )}
1045
+ </div>
1046
+ )}
1047
+
1048
+ {/* 系统状态提示 */}
1049
+ {!isSystemReady && (
1050
+ <div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 p-3 bg-orange-700 text-white rounded-lg shadow-lg flex items-center text-sm z-50">
1051
+ <AlertTriangle size={20} className="mr-2" />
1052
+ {!hasValidChannels ? '请配置API渠道和密钥' : '请配置AI角色'}
1053
+ 。点击设置按钮进行配置。
1054
+ </div>
1055
+ )}
1056
+ </div>
1057
+ );
1058
+ };
1059
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1060
  export default App;
Dockerfile CHANGED
@@ -1,34 +1,34 @@
1
- # Multi-Mind Chat 智囊团 Dockerfile
2
- FROM node:18-alpine
3
-
4
- # 设置工作目录
5
- WORKDIR /app
6
-
7
- # 复制 package.json 和 package-lock.json
8
- COPY package*.json ./
9
-
10
- # 安装依赖
11
- RUN npm ci
12
-
13
- # 复制应用代码
14
- COPY . .
15
-
16
- # 直接设置预置渠道的环境变量(在此处修改您的配置)
17
- ENV VITE_PRESET_CHANNEL_NAME="预置服务"
18
-
19
-
20
- # 构建前端应用
21
- RUN npm run build
22
-
23
- # 安装静态文件服务器
24
- RUN npm install -g serve
25
-
26
- # 暴露端口
27
- EXPOSE 7860
28
-
29
- # 设置运行时环境变量
30
- ENV NODE_ENV=production
31
- ENV PORT=7860
32
-
33
- # 启动命令
34
  CMD ["serve", "-s", "dist", "-l", "7860"]
 
1
+ # Multi-Mind Chat 智囊团 Dockerfile
2
+ FROM node:18-alpine
3
+
4
+ # 设置工作目录
5
+ WORKDIR /app
6
+
7
+ # 复制 package.json 和 package-lock.json
8
+ COPY package*.json ./
9
+
10
+ # 安装依赖
11
+ RUN npm ci
12
+
13
+ # 复制应用代码
14
+ COPY . .
15
+
16
+ # 直接设置预置渠道的环境变量(在此处修改您的配置)
17
+ ENV VITE_PRESET_CHANNEL_NAME="预置服务"
18
+
19
+
20
+ # 构建前端应用
21
+ RUN npm run build
22
+
23
+ # 安装静态文件服务器
24
+ RUN npm install -g serve
25
+
26
+ # 暴露端口
27
+ EXPOSE 7860
28
+
29
+ # 设置运行时环境变量
30
+ ENV NODE_ENV=production
31
+ ENV PORT=7860
32
+
33
+ # 启动命令
34
  CMD ["serve", "-s", "dist", "-l", "7860"]
README.md CHANGED
@@ -1,11 +1,11 @@
1
- ---
2
- title: MultiMindChat
3
- emoji: 📚
4
- colorFrom: purple
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
8
- license: mit
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ ---
2
+ title: MultiMindChat
3
+ emoji: 📚
4
+ colorFrom: purple
5
+ colorTo: red
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ ---
10
+
11
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
components/ChatInput.tsx CHANGED
@@ -1,191 +1,191 @@
1
-
2
- import React, { useState, useRef, useCallback, useEffect } from 'react';
3
- import { Send, Paperclip, XCircle } from 'lucide-react';
4
- import LoadingSpinner from './LoadingSpinner';
5
-
6
- interface ChatInputProps {
7
- onSendMessage: (message: string, imageFile?: File | null) => void;
8
- isLoading: boolean;
9
- isApiKeyMissing: boolean;
10
- }
11
-
12
- const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
13
-
14
- const ChatInput: React.FC<ChatInputProps> = ({ onSendMessage, isLoading, isApiKeyMissing }) => {
15
- const [inputValue, setInputValue] = useState('');
16
- const [selectedImage, setSelectedImage] = useState<File | null>(null);
17
- const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
18
- const [isDraggingOver, setIsDraggingOver] = useState(false);
19
- const textareaRef = useRef<HTMLTextAreaElement>(null);
20
- const fileInputRef = useRef<HTMLInputElement>(null);
21
-
22
- useEffect(() => {
23
- if (selectedImage) {
24
- const objectUrl = URL.createObjectURL(selectedImage);
25
- setImagePreviewUrl(objectUrl);
26
- return () => URL.revokeObjectURL(objectUrl);
27
- }
28
- setImagePreviewUrl(null);
29
- }, [selectedImage]);
30
-
31
- const handleImageFile = (file: File | null) => {
32
- if (file && ACCEPTED_IMAGE_TYPES.includes(file.type)) {
33
- setSelectedImage(file);
34
- } else if (file) {
35
- alert('不支持的文件类型。请选择 JPG, PNG, GIF, 或 WEBP 格式的图片。');
36
- setSelectedImage(null);
37
- } else {
38
- setSelectedImage(null);
39
- }
40
- // Reset file input value to allow selecting the same file again
41
- if (fileInputRef.current) {
42
- fileInputRef.current.value = "";
43
- }
44
- };
45
-
46
- const removeImage = () => {
47
- setSelectedImage(null);
48
- setImagePreviewUrl(null);
49
- };
50
-
51
- const triggerSendMessage = () => {
52
- if ((inputValue.trim() || selectedImage) && !isLoading && !isApiKeyMissing) {
53
- onSendMessage(inputValue.trim(), selectedImage);
54
- setInputValue('');
55
- removeImage(); // Clear image after sending
56
- if (textareaRef.current) { // Reset textarea height after sending
57
- textareaRef.current.style.height = 'auto';
58
- }
59
- }
60
- };
61
-
62
- const handleSubmit = (e: React.FormEvent) => {
63
- e.preventDefault();
64
- triggerSendMessage();
65
- };
66
-
67
- const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
68
- if (e.key === 'Enter' && e.ctrlKey) {
69
- e.preventDefault(); // Prevent new line
70
- triggerSendMessage();
71
- }
72
- };
73
-
74
- const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
75
- const items = e.clipboardData?.items;
76
- if (items) {
77
- for (let i = 0; i < items.length; i++) {
78
- if (ACCEPTED_IMAGE_TYPES.includes(items[i].type)) {
79
- const file = items[i].getAsFile();
80
- if (file) {
81
- handleImageFile(file);
82
- e.preventDefault(); // Prevent pasting file path as text
83
- break;
84
- }
85
- }
86
- }
87
- }
88
- }, []);
89
-
90
- const handleDrop = useCallback((e: React.DragEvent<HTMLTextAreaElement>) => {
91
- e.preventDefault();
92
- setIsDraggingOver(false);
93
- if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
94
- handleImageFile(e.dataTransfer.files[0]);
95
- e.dataTransfer.clearData();
96
- }
97
- }, []);
98
-
99
- const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
100
- e.preventDefault();
101
- setIsDraggingOver(true);
102
- };
103
-
104
- const handleDragLeave = (e: React.DragEvent<HTMLTextAreaElement>) => {
105
- e.preventDefault();
106
- setIsDraggingOver(false);
107
- };
108
-
109
- const handleFileButtonClick = () => {
110
- if (fileInputRef.current) {
111
- fileInputRef.current.click();
112
- }
113
- };
114
-
115
- const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
116
- if (e.target.files && e.target.files.length > 0) {
117
- handleImageFile(e.target.files[0]);
118
- }
119
- };
120
-
121
- const isDisabled = isLoading || isApiKeyMissing;
122
-
123
- return (
124
- <form onSubmit={handleSubmit} className="p-4 bg-gray-800 border-t border-gray-700">
125
- {imagePreviewUrl && selectedImage && (
126
- <div className="mb-2 p-2 bg-gray-700 rounded-md relative max-w-xs">
127
- <img src={imagePreviewUrl} alt={selectedImage.name || "图片预览"} className="max-h-24 max-w-full rounded" />
128
- <button
129
- type="button"
130
- onClick={removeImage}
131
- className="absolute top-1 right-1 bg-black bg-opacity-50 text-white rounded-full p-0.5 hover:bg-opacity-75"
132
- aria-label="移除图片"
133
- >
134
- <XCircle size={20} />
135
- </button>
136
- <div className="text-xs text-gray-300 mt-1 truncate">{selectedImage.name} ({(selectedImage.size / 1024).toFixed(1)} KB)</div>
137
- </div>
138
- )}
139
- <div className="flex items-end space-x-2">
140
- <textarea
141
- ref={textareaRef}
142
- value={inputValue}
143
- onChange={(e) => setInputValue(e.target.value)}
144
- onKeyDown={handleKeyDown} // Added keydown handler
145
- onPaste={handlePaste}
146
- onDrop={handleDrop}
147
- onDragOver={handleDragOver}
148
- onDragLeave={handleDragLeave}
149
- placeholder={isApiKeyMissing ? "API密钥未配置,聊天功能已禁用。" : (isDraggingOver ? "将图片拖放到此处" : "输入您的消息 (Ctrl+Enter 发送) 或粘贴/拖放图片...")}
150
- className={`flex-grow p-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 outline-none placeholder-gray-400 disabled:opacity-50 resize-none min-h-[48px] max-h-[150px] ${isDraggingOver ? 'ring-2 ring-sky-500 border-sky-500' : ''}`}
151
- rows={1} // Start with 1 row, auto-expands
152
- disabled={isDisabled}
153
- aria-label="聊天输入框"
154
- onInput={(e) => { // Auto-resize textarea
155
- const target = e.target as HTMLTextAreaElement;
156
- target.style.height = 'auto';
157
- target.style.height = `${target.scrollHeight}px`;
158
- }}
159
- />
160
- <input
161
- type="file"
162
- ref={fileInputRef}
163
- onChange={handleFileSelected}
164
- accept={ACCEPTED_IMAGE_TYPES.join(',')}
165
- className="hidden"
166
- aria-label="选择图片文件"
167
- />
168
- <button
169
- type="button"
170
- onClick={handleFileButtonClick}
171
- className="p-3 bg-gray-600 hover:bg-gray-500 rounded-lg text-white transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed self-end h-[48px]"
172
- disabled={isDisabled}
173
- aria-label="添加图片附件"
174
- title="添加图片"
175
- >
176
- <Paperclip size={24} />
177
- </button>
178
- <button
179
- type="submit"
180
- className="p-3 bg-sky-600 hover:bg-sky-700 rounded-lg text-white transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed self-end h-[48px]"
181
- disabled={isDisabled || (!inputValue.trim() && !selectedImage)}
182
- aria-label={isLoading ? "发送中" : "发送消息"}
183
- >
184
- {isLoading ? <LoadingSpinner size="w-6 h-6" color="text-white" /> : <Send size={24} />}
185
- </button>
186
- </div>
187
- </form>
188
- );
189
- };
190
-
191
- export default ChatInput;
 
1
+
2
+ import React, { useState, useRef, useCallback, useEffect } from 'react';
3
+ import { Send, Paperclip, XCircle } from 'lucide-react';
4
+ import LoadingSpinner from './LoadingSpinner';
5
+
6
+ interface ChatInputProps {
7
+ onSendMessage: (message: string, imageFile?: File | null) => void;
8
+ isLoading: boolean;
9
+ isApiKeyMissing: boolean;
10
+ }
11
+
12
+ const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
13
+
14
+ const ChatInput: React.FC<ChatInputProps> = ({ onSendMessage, isLoading, isApiKeyMissing }) => {
15
+ const [inputValue, setInputValue] = useState('');
16
+ const [selectedImage, setSelectedImage] = useState<File | null>(null);
17
+ const [imagePreviewUrl, setImagePreviewUrl] = useState<string | null>(null);
18
+ const [isDraggingOver, setIsDraggingOver] = useState(false);
19
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
20
+ const fileInputRef = useRef<HTMLInputElement>(null);
21
+
22
+ useEffect(() => {
23
+ if (selectedImage) {
24
+ const objectUrl = URL.createObjectURL(selectedImage);
25
+ setImagePreviewUrl(objectUrl);
26
+ return () => URL.revokeObjectURL(objectUrl);
27
+ }
28
+ setImagePreviewUrl(null);
29
+ }, [selectedImage]);
30
+
31
+ const handleImageFile = (file: File | null) => {
32
+ if (file && ACCEPTED_IMAGE_TYPES.includes(file.type)) {
33
+ setSelectedImage(file);
34
+ } else if (file) {
35
+ alert('不支持的文件类型。请选择 JPG, PNG, GIF, 或 WEBP 格式的图片。');
36
+ setSelectedImage(null);
37
+ } else {
38
+ setSelectedImage(null);
39
+ }
40
+ // Reset file input value to allow selecting the same file again
41
+ if (fileInputRef.current) {
42
+ fileInputRef.current.value = "";
43
+ }
44
+ };
45
+
46
+ const removeImage = () => {
47
+ setSelectedImage(null);
48
+ setImagePreviewUrl(null);
49
+ };
50
+
51
+ const triggerSendMessage = () => {
52
+ if ((inputValue.trim() || selectedImage) && !isLoading && !isApiKeyMissing) {
53
+ onSendMessage(inputValue.trim(), selectedImage);
54
+ setInputValue('');
55
+ removeImage(); // Clear image after sending
56
+ if (textareaRef.current) { // Reset textarea height after sending
57
+ textareaRef.current.style.height = 'auto';
58
+ }
59
+ }
60
+ };
61
+
62
+ const handleSubmit = (e: React.FormEvent) => {
63
+ e.preventDefault();
64
+ triggerSendMessage();
65
+ };
66
+
67
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
68
+ if (e.key === 'Enter' && e.ctrlKey) {
69
+ e.preventDefault(); // Prevent new line
70
+ triggerSendMessage();
71
+ }
72
+ };
73
+
74
+ const handlePaste = useCallback((e: React.ClipboardEvent<HTMLTextAreaElement>) => {
75
+ const items = e.clipboardData?.items;
76
+ if (items) {
77
+ for (let i = 0; i < items.length; i++) {
78
+ if (ACCEPTED_IMAGE_TYPES.includes(items[i].type)) {
79
+ const file = items[i].getAsFile();
80
+ if (file) {
81
+ handleImageFile(file);
82
+ e.preventDefault(); // Prevent pasting file path as text
83
+ break;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }, []);
89
+
90
+ const handleDrop = useCallback((e: React.DragEvent<HTMLTextAreaElement>) => {
91
+ e.preventDefault();
92
+ setIsDraggingOver(false);
93
+ if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
94
+ handleImageFile(e.dataTransfer.files[0]);
95
+ e.dataTransfer.clearData();
96
+ }
97
+ }, []);
98
+
99
+ const handleDragOver = (e: React.DragEvent<HTMLTextAreaElement>) => {
100
+ e.preventDefault();
101
+ setIsDraggingOver(true);
102
+ };
103
+
104
+ const handleDragLeave = (e: React.DragEvent<HTMLTextAreaElement>) => {
105
+ e.preventDefault();
106
+ setIsDraggingOver(false);
107
+ };
108
+
109
+ const handleFileButtonClick = () => {
110
+ if (fileInputRef.current) {
111
+ fileInputRef.current.click();
112
+ }
113
+ };
114
+
115
+ const handleFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
116
+ if (e.target.files && e.target.files.length > 0) {
117
+ handleImageFile(e.target.files[0]);
118
+ }
119
+ };
120
+
121
+ const isDisabled = isLoading || isApiKeyMissing;
122
+
123
+ return (
124
+ <form onSubmit={handleSubmit} className="p-4 bg-gray-800 border-t border-gray-700">
125
+ {imagePreviewUrl && selectedImage && (
126
+ <div className="mb-2 p-2 bg-gray-700 rounded-md relative max-w-xs">
127
+ <img src={imagePreviewUrl} alt={selectedImage.name || "图片预览"} className="max-h-24 max-w-full rounded" />
128
+ <button
129
+ type="button"
130
+ onClick={removeImage}
131
+ className="absolute top-1 right-1 bg-black bg-opacity-50 text-white rounded-full p-0.5 hover:bg-opacity-75"
132
+ aria-label="移除图片"
133
+ >
134
+ <XCircle size={20} />
135
+ </button>
136
+ <div className="text-xs text-gray-300 mt-1 truncate">{selectedImage.name} ({(selectedImage.size / 1024).toFixed(1)} KB)</div>
137
+ </div>
138
+ )}
139
+ <div className="flex items-end space-x-2">
140
+ <textarea
141
+ ref={textareaRef}
142
+ value={inputValue}
143
+ onChange={(e) => setInputValue(e.target.value)}
144
+ onKeyDown={handleKeyDown} // Added keydown handler
145
+ onPaste={handlePaste}
146
+ onDrop={handleDrop}
147
+ onDragOver={handleDragOver}
148
+ onDragLeave={handleDragLeave}
149
+ placeholder={isApiKeyMissing ? "API密钥未配置,聊天功能已禁用。" : (isDraggingOver ? "将图片拖放到此处" : "输入您的消息 (Ctrl+Enter 发送) 或粘贴/拖放图片...")}
150
+ className={`flex-grow p-3 bg-gray-700 border border-gray-600 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 outline-none placeholder-gray-400 disabled:opacity-50 resize-none min-h-[48px] max-h-[150px] ${isDraggingOver ? 'ring-2 ring-sky-500 border-sky-500' : ''}`}
151
+ rows={1} // Start with 1 row, auto-expands
152
+ disabled={isDisabled}
153
+ aria-label="聊天输入框"
154
+ onInput={(e) => { // Auto-resize textarea
155
+ const target = e.target as HTMLTextAreaElement;
156
+ target.style.height = 'auto';
157
+ target.style.height = `${target.scrollHeight}px`;
158
+ }}
159
+ />
160
+ <input
161
+ type="file"
162
+ ref={fileInputRef}
163
+ onChange={handleFileSelected}
164
+ accept={ACCEPTED_IMAGE_TYPES.join(',')}
165
+ className="hidden"
166
+ aria-label="选择图片文件"
167
+ />
168
+ <button
169
+ type="button"
170
+ onClick={handleFileButtonClick}
171
+ className="p-3 bg-gray-600 hover:bg-gray-500 rounded-lg text-white transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed self-end h-[48px]"
172
+ disabled={isDisabled}
173
+ aria-label="添加图片附件"
174
+ title="添加图片"
175
+ >
176
+ <Paperclip size={24} />
177
+ </button>
178
+ <button
179
+ type="submit"
180
+ className="p-3 bg-sky-600 hover:bg-sky-700 rounded-lg text-white transition-colors duration-150 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-gray-800 disabled:opacity-50 disabled:cursor-not-allowed self-end h-[48px]"
181
+ disabled={isDisabled || (!inputValue.trim() && !selectedImage)}
182
+ aria-label={isLoading ? "发送中" : "发送消息"}
183
+ >
184
+ {isLoading ? <LoadingSpinner size="w-6 h-6" color="text-white" /> : <Send size={24} />}
185
+ </button>
186
+ </div>
187
+ </form>
188
+ );
189
+ };
190
+
191
+ export default ChatInput;
components/LoadingSpinner.tsx CHANGED
@@ -1,35 +1,35 @@
1
-
2
- import React from 'react';
3
-
4
- interface LoadingSpinnerProps {
5
- size?: string; // e.g., 'w-8 h-8'
6
- color?: string; // e.g., 'text-blue-500'
7
- }
8
-
9
- const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ size = 'w-5 h-5', color = 'text-sky-400' }) => {
10
- return (
11
- <svg
12
- className={`animate-spin ${size} ${color}`}
13
- xmlns="http://www.w3.org/2000/svg"
14
- fill="none"
15
- viewBox="0 0 24 24"
16
- >
17
- <circle
18
- className="opacity-25"
19
- cx="12"
20
- cy="12"
21
- r="10"
22
- stroke="currentColor"
23
- strokeWidth="4"
24
- ></circle>
25
- <path
26
- className="opacity-75"
27
- fill="currentColor"
28
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
29
- ></path>
30
- </svg>
31
- );
32
- };
33
-
34
- export default LoadingSpinner;
35
 
 
1
+
2
+ import React from 'react';
3
+
4
+ interface LoadingSpinnerProps {
5
+ size?: string; // e.g., 'w-8 h-8'
6
+ color?: string; // e.g., 'text-blue-500'
7
+ }
8
+
9
+ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({ size = 'w-5 h-5', color = 'text-sky-400' }) => {
10
+ return (
11
+ <svg
12
+ className={`animate-spin ${size} ${color}`}
13
+ xmlns="http://www.w3.org/2000/svg"
14
+ fill="none"
15
+ viewBox="0 0 24 24"
16
+ >
17
+ <circle
18
+ className="opacity-25"
19
+ cx="12"
20
+ cy="12"
21
+ r="10"
22
+ stroke="currentColor"
23
+ strokeWidth="4"
24
+ ></circle>
25
+ <path
26
+ className="opacity-75"
27
+ fill="currentColor"
28
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
29
+ ></path>
30
+ </svg>
31
+ );
32
+ };
33
+
34
+ export default LoadingSpinner;
35
 
components/MessageBubble.tsx CHANGED
@@ -1,201 +1,201 @@
1
- import React, { useState, useEffect } from 'react';
2
- import { ChatMessage, MessageSender, MessagePurpose } from '../types';
3
- import { Lightbulb, MessageSquareText, UserCircle, Zap, AlertTriangle, Copy, Check, MoreHorizontal } from 'lucide-react';
4
- import { marked } from 'marked';
5
- import DOMPurify from 'dompurify';
6
-
7
- interface SenderIconProps {
8
- sender: MessageSender;
9
- purpose: MessagePurpose;
10
- messageText: string;
11
- }
12
-
13
- const SenderIcon: React.FC<SenderIconProps> = ({ sender, purpose, messageText }) => {
14
- const iconClass = "w-5 h-5 mr-2 flex-shrink-0";
15
- switch (sender) {
16
- case MessageSender.User:
17
- return <UserCircle className={`${iconClass} text-blue-400`} />;
18
- case MessageSender.Cognito:
19
- return <Lightbulb className={`${iconClass} text-green-400`} />;
20
- case MessageSender.Muse:
21
- return <Zap className={`${iconClass} text-purple-400`} />;
22
- case MessageSender.System:
23
- if (
24
- purpose === MessagePurpose.SystemNotification &&
25
- (messageText.toLowerCase().includes("error") ||
26
- messageText.toLowerCase().includes("错误") ||
27
- messageText.toLowerCase().includes("警告"))
28
- ) {
29
- return <AlertTriangle className={`${iconClass} text-red-400`} />;
30
- }
31
- return <MessageSquareText className={`${iconClass} text-gray-400`} />;
32
- default:
33
- // For dynamic role names, use a generic bot icon with dynamic color
34
- const colorClasses = [
35
- 'text-green-400', 'text-purple-400', 'text-blue-400',
36
- 'text-yellow-400', 'text-pink-400', 'text-indigo-400'
37
- ];
38
- const colorIndex = typeof sender === 'string' ?
39
- sender.length % colorClasses.length : 0;
40
- return <Lightbulb className={`${iconClass} ${colorClasses[colorIndex]}`} />;
41
- }
42
- };
43
-
44
- const getSenderNameStyle = (sender: MessageSender): string => {
45
- switch (sender) {
46
- case MessageSender.User: return "text-blue-300";
47
- case MessageSender.Cognito: return "text-green-300";
48
- case MessageSender.Muse: return "text-purple-300";
49
- case MessageSender.System: return "text-gray-400";
50
- default:
51
- // For dynamic role names, apply dynamic colors
52
- const colorClasses = [
53
- 'text-green-300', 'text-purple-300', 'text-blue-300',
54
- 'text-yellow-300', 'text-pink-300', 'text-indigo-300'
55
- ];
56
- const colorIndex = typeof sender === 'string' ?
57
- sender.length % colorClasses.length : 0;
58
- return colorClasses[colorIndex];
59
- }
60
- }
61
-
62
- const getBubbleStyle = (sender: MessageSender, purpose: MessagePurpose, messageText: string): string => {
63
- let baseStyle = "mb-4 p-4 rounded-lg shadow-md max-w-xl break-words relative ";
64
- if (purpose === MessagePurpose.SystemNotification) {
65
- if (
66
- messageText.toLowerCase().includes("error") ||
67
- messageText.toLowerCase().includes("错误") ||
68
- messageText.toLowerCase().includes("警告") ||
69
- messageText.toLowerCase().includes("critical") ||
70
- messageText.toLowerCase().includes("严重")
71
- ) {
72
- return baseStyle + "bg-red-800 border border-red-700 text-center text-sm italic mx-auto text-red-200";
73
- }
74
- return baseStyle + "bg-gray-700 text-center text-sm italic mx-auto";
75
- }
76
- switch (sender) {
77
- case MessageSender.User:
78
- return baseStyle + "bg-blue-600 ml-auto rounded-br-none";
79
- case MessageSender.Cognito:
80
- return baseStyle + "bg-green-700 mr-auto rounded-bl-none";
81
- case MessageSender.Muse:
82
- return baseStyle + "bg-purple-700 mr-auto rounded-bl-none";
83
- default:
84
- // For dynamic role names, use varied background colors
85
- const bgColors = [
86
- 'bg-green-700', 'bg-purple-700', 'bg-blue-700',
87
- 'bg-yellow-700', 'bg-pink-700', 'bg-indigo-700'
88
- ];
89
- const bgIndex = typeof sender === 'string' ?
90
- sender.length % bgColors.length : 0;
91
- return baseStyle + bgColors[bgIndex] + " mr-auto rounded-bl-none";
92
- }
93
- };
94
-
95
- const getPurposePrefix = (purpose: MessagePurpose, sender: MessageSender): string => {
96
- switch (purpose) {
97
- case MessagePurpose.CognitoToMuse:
98
- return `致 ${MessageSender.Muse}的消息: `;
99
- case MessagePurpose.MuseToCognito:
100
- return `致 ${MessageSender.Cognito}的消息: `;
101
- case MessagePurpose.FinalResponse:
102
- return `最终答案: `;
103
- default:
104
- return "";
105
- }
106
- }
107
-
108
- interface MessageBubbleProps {
109
- message: ChatMessage;
110
- }
111
-
112
- const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
113
- const { text: messageText, sender, purpose, timestamp, durationMs, image } = message;
114
- const formattedTime = new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
115
- const [isCopied, setIsCopied] = useState(false);
116
-
117
- // 移除所有流式动画逻辑,直接显示内容
118
- const isDiscussionStep = purpose === MessagePurpose.CognitoToMuse || purpose === MessagePurpose.MuseToCognito;
119
- const isFinalResponse = purpose === MessagePurpose.FinalResponse;
120
- const showDuration = durationMs !== undefined && durationMs > 0;
121
-
122
- const shouldRenderMarkdown =
123
- (sender === MessageSender.User || sender === MessageSender.Cognito || sender === MessageSender.Muse || typeof sender === 'string') &&
124
- purpose !== MessagePurpose.SystemNotification;
125
-
126
- let sanitizedHtml = '';
127
- if (shouldRenderMarkdown && messageText) {
128
- const rawHtml = marked.parse(messageText) as string;
129
- sanitizedHtml = DOMPurify.sanitize(rawHtml);
130
- }
131
-
132
- const handleCopy = async () => {
133
- try {
134
- await navigator.clipboard.writeText(messageText);
135
- setIsCopied(true);
136
- setTimeout(() => setIsCopied(false), 2000);
137
- } catch (err) {
138
- console.error('无法复制文本: ', err);
139
- }
140
- };
141
-
142
- const canCopy = (sender === MessageSender.User || sender === MessageSender.Cognito || sender === MessageSender.Muse || typeof sender === 'string') && purpose !== MessagePurpose.SystemNotification;
143
-
144
- return (
145
- <div className={`flex ${sender === MessageSender.User ? 'justify-end' : 'justify-start'}`}>
146
- <div className={getBubbleStyle(sender, purpose, messageText)}>
147
- {canCopy && (
148
- <button
149
- onClick={handleCopy}
150
- title={isCopied ? "已复制!" : "复制消息"}
151
- className="absolute top-1.5 right-1.5 p-1 text-gray-400 hover:text-sky-300 transition-colors rounded-md focus:outline-none focus:ring-1 focus:ring-sky-500"
152
- >
153
- {isCopied ? <Check size={16} className="text-green-400" /> : <Copy size={16} />}
154
- </button>
155
- )}
156
-
157
- <div className="flex items-center mb-1">
158
- <SenderIcon sender={sender} purpose={purpose} messageText={messageText} />
159
- <span className={`font-semibold ${getSenderNameStyle(sender)}`}>{sender}</span>
160
- {isDiscussionStep && <span className="ml-2 text-xs text-gray-400">(内部讨论)</span>}
161
- </div>
162
-
163
- {messageText ? (
164
- shouldRenderMarkdown ? (
165
- <div
166
- className="chat-markdown-content text-sm text-gray-200"
167
- dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
168
- />
169
- ) : (
170
- <p className="text-sm text-gray-200 whitespace-pre-wrap">
171
- {messageText}
172
- </p>
173
- )
174
- ) : (
175
- <p className="text-sm text-gray-400 italic">
176
- 正在生成回复...
177
- </p>
178
- )}
179
-
180
- {image && sender === MessageSender.User && (
181
- <div className={`mt-2 ${messageText ? 'pt-2 border-t border-blue-500' : ''}`}>
182
- <img
183
- src={image.dataUrl}
184
- alt={image.name || "用户上传的图片"}
185
- className="max-w-xs max-h-64 rounded-md object-contain"
186
- />
187
- </div>
188
- )}
189
-
190
- <div className="text-xs text-gray-400 mt-2 flex justify-between items-center">
191
- <span>{formattedTime}</span>
192
- {showDuration && (
193
- <span className="italic"> (耗时: {(durationMs / 1000).toFixed(2)}s)</span>
194
- )}
195
- </div>
196
- </div>
197
- </div>
198
- );
199
- };
200
-
201
  export default MessageBubble;
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { ChatMessage, MessageSender, MessagePurpose } from '../types';
3
+ import { Lightbulb, MessageSquareText, UserCircle, Zap, AlertTriangle, Copy, Check, MoreHorizontal } from 'lucide-react';
4
+ import { marked } from 'marked';
5
+ import DOMPurify from 'dompurify';
6
+
7
+ interface SenderIconProps {
8
+ sender: MessageSender;
9
+ purpose: MessagePurpose;
10
+ messageText: string;
11
+ }
12
+
13
+ const SenderIcon: React.FC<SenderIconProps> = ({ sender, purpose, messageText }) => {
14
+ const iconClass = "w-5 h-5 mr-2 flex-shrink-0";
15
+ switch (sender) {
16
+ case MessageSender.User:
17
+ return <UserCircle className={`${iconClass} text-blue-400`} />;
18
+ case MessageSender.Cognito:
19
+ return <Lightbulb className={`${iconClass} text-green-400`} />;
20
+ case MessageSender.Muse:
21
+ return <Zap className={`${iconClass} text-purple-400`} />;
22
+ case MessageSender.System:
23
+ if (
24
+ purpose === MessagePurpose.SystemNotification &&
25
+ (messageText.toLowerCase().includes("error") ||
26
+ messageText.toLowerCase().includes("错误") ||
27
+ messageText.toLowerCase().includes("警告"))
28
+ ) {
29
+ return <AlertTriangle className={`${iconClass} text-red-400`} />;
30
+ }
31
+ return <MessageSquareText className={`${iconClass} text-gray-400`} />;
32
+ default:
33
+ // For dynamic role names, use a generic bot icon with dynamic color
34
+ const colorClasses = [
35
+ 'text-green-400', 'text-purple-400', 'text-blue-400',
36
+ 'text-yellow-400', 'text-pink-400', 'text-indigo-400'
37
+ ];
38
+ const colorIndex = typeof sender === 'string' ?
39
+ sender.length % colorClasses.length : 0;
40
+ return <Lightbulb className={`${iconClass} ${colorClasses[colorIndex]}`} />;
41
+ }
42
+ };
43
+
44
+ const getSenderNameStyle = (sender: MessageSender): string => {
45
+ switch (sender) {
46
+ case MessageSender.User: return "text-blue-300";
47
+ case MessageSender.Cognito: return "text-green-300";
48
+ case MessageSender.Muse: return "text-purple-300";
49
+ case MessageSender.System: return "text-gray-400";
50
+ default:
51
+ // For dynamic role names, apply dynamic colors
52
+ const colorClasses = [
53
+ 'text-green-300', 'text-purple-300', 'text-blue-300',
54
+ 'text-yellow-300', 'text-pink-300', 'text-indigo-300'
55
+ ];
56
+ const colorIndex = typeof sender === 'string' ?
57
+ sender.length % colorClasses.length : 0;
58
+ return colorClasses[colorIndex];
59
+ }
60
+ }
61
+
62
+ const getBubbleStyle = (sender: MessageSender, purpose: MessagePurpose, messageText: string): string => {
63
+ let baseStyle = "mb-4 p-4 rounded-lg shadow-md max-w-xl break-words relative ";
64
+ if (purpose === MessagePurpose.SystemNotification) {
65
+ if (
66
+ messageText.toLowerCase().includes("error") ||
67
+ messageText.toLowerCase().includes("错误") ||
68
+ messageText.toLowerCase().includes("警告") ||
69
+ messageText.toLowerCase().includes("critical") ||
70
+ messageText.toLowerCase().includes("严重")
71
+ ) {
72
+ return baseStyle + "bg-red-800 border border-red-700 text-center text-sm italic mx-auto text-red-200";
73
+ }
74
+ return baseStyle + "bg-gray-700 text-center text-sm italic mx-auto";
75
+ }
76
+ switch (sender) {
77
+ case MessageSender.User:
78
+ return baseStyle + "bg-blue-600 ml-auto rounded-br-none";
79
+ case MessageSender.Cognito:
80
+ return baseStyle + "bg-green-700 mr-auto rounded-bl-none";
81
+ case MessageSender.Muse:
82
+ return baseStyle + "bg-purple-700 mr-auto rounded-bl-none";
83
+ default:
84
+ // For dynamic role names, use varied background colors
85
+ const bgColors = [
86
+ 'bg-green-700', 'bg-purple-700', 'bg-blue-700',
87
+ 'bg-yellow-700', 'bg-pink-700', 'bg-indigo-700'
88
+ ];
89
+ const bgIndex = typeof sender === 'string' ?
90
+ sender.length % bgColors.length : 0;
91
+ return baseStyle + bgColors[bgIndex] + " mr-auto rounded-bl-none";
92
+ }
93
+ };
94
+
95
+ const getPurposePrefix = (purpose: MessagePurpose, sender: MessageSender): string => {
96
+ switch (purpose) {
97
+ case MessagePurpose.CognitoToMuse:
98
+ return `致 ${MessageSender.Muse}的消息: `;
99
+ case MessagePurpose.MuseToCognito:
100
+ return `致 ${MessageSender.Cognito}的消息: `;
101
+ case MessagePurpose.FinalResponse:
102
+ return `最终答案: `;
103
+ default:
104
+ return "";
105
+ }
106
+ }
107
+
108
+ interface MessageBubbleProps {
109
+ message: ChatMessage;
110
+ }
111
+
112
+ const MessageBubble: React.FC<MessageBubbleProps> = ({ message }) => {
113
+ const { text: messageText, sender, purpose, timestamp, durationMs, image } = message;
114
+ const formattedTime = new Date(timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
115
+ const [isCopied, setIsCopied] = useState(false);
116
+
117
+ // 移除所有流式动画逻辑,直接显示内容
118
+ const isDiscussionStep = purpose === MessagePurpose.CognitoToMuse || purpose === MessagePurpose.MuseToCognito;
119
+ const isFinalResponse = purpose === MessagePurpose.FinalResponse;
120
+ const showDuration = durationMs !== undefined && durationMs > 0;
121
+
122
+ const shouldRenderMarkdown =
123
+ (sender === MessageSender.User || sender === MessageSender.Cognito || sender === MessageSender.Muse || typeof sender === 'string') &&
124
+ purpose !== MessagePurpose.SystemNotification;
125
+
126
+ let sanitizedHtml = '';
127
+ if (shouldRenderMarkdown && messageText) {
128
+ const rawHtml = marked.parse(messageText) as string;
129
+ sanitizedHtml = DOMPurify.sanitize(rawHtml);
130
+ }
131
+
132
+ const handleCopy = async () => {
133
+ try {
134
+ await navigator.clipboard.writeText(messageText);
135
+ setIsCopied(true);
136
+ setTimeout(() => setIsCopied(false), 2000);
137
+ } catch (err) {
138
+ console.error('无法复制文本: ', err);
139
+ }
140
+ };
141
+
142
+ const canCopy = (sender === MessageSender.User || sender === MessageSender.Cognito || sender === MessageSender.Muse || typeof sender === 'string') && purpose !== MessagePurpose.SystemNotification;
143
+
144
+ return (
145
+ <div className={`flex ${sender === MessageSender.User ? 'justify-end' : 'justify-start'}`}>
146
+ <div className={getBubbleStyle(sender, purpose, messageText)}>
147
+ {canCopy && (
148
+ <button
149
+ onClick={handleCopy}
150
+ title={isCopied ? "已复制!" : "复制消息"}
151
+ className="absolute top-1.5 right-1.5 p-1 text-gray-400 hover:text-sky-300 transition-colors rounded-md focus:outline-none focus:ring-1 focus:ring-sky-500"
152
+ >
153
+ {isCopied ? <Check size={16} className="text-green-400" /> : <Copy size={16} />}
154
+ </button>
155
+ )}
156
+
157
+ <div className="flex items-center mb-1">
158
+ <SenderIcon sender={sender} purpose={purpose} messageText={messageText} />
159
+ <span className={`font-semibold ${getSenderNameStyle(sender)}`}>{sender}</span>
160
+ {isDiscussionStep && <span className="ml-2 text-xs text-gray-400">(内部讨论)</span>}
161
+ </div>
162
+
163
+ {messageText ? (
164
+ shouldRenderMarkdown ? (
165
+ <div
166
+ className="chat-markdown-content text-sm text-gray-200"
167
+ dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
168
+ />
169
+ ) : (
170
+ <p className="text-sm text-gray-200 whitespace-pre-wrap">
171
+ {messageText}
172
+ </p>
173
+ )
174
+ ) : (
175
+ <p className="text-sm text-gray-400 italic">
176
+ 正在生成回复...
177
+ </p>
178
+ )}
179
+
180
+ {image && sender === MessageSender.User && (
181
+ <div className={`mt-2 ${messageText ? 'pt-2 border-t border-blue-500' : ''}`}>
182
+ <img
183
+ src={image.dataUrl}
184
+ alt={image.name || "用户上传的图片"}
185
+ className="max-w-xs max-h-64 rounded-md object-contain"
186
+ />
187
+ </div>
188
+ )}
189
+
190
+ <div className="text-xs text-gray-400 mt-2 flex justify-between items-center">
191
+ <span>{formattedTime}</span>
192
+ {showDuration && (
193
+ <span className="italic"> (耗时: {(durationMs / 1000).toFixed(2)}s)</span>
194
+ )}
195
+ </div>
196
+ </div>
197
+ </div>
198
+ );
199
+ };
200
+
201
  export default MessageBubble;
components/ModelConfigManager.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
components/Notepad.tsx CHANGED
@@ -1,99 +1,99 @@
1
- import React, { useState, useMemo } from 'react';
2
- import { MessageSender } from '../types';
3
- import { FileText, Edit3, Eye, Code, Copy, Check } from 'lucide-react';
4
- import { marked } from 'marked';
5
- import DOMPurify from 'dompurify';
6
-
7
- interface NotepadProps {
8
- content: string;
9
- lastUpdatedBy?: MessageSender | null;
10
- isLoading: boolean;
11
- }
12
-
13
- const Notepad: React.FC<NotepadProps> = ({ content, lastUpdatedBy, isLoading }) => {
14
- const [isPreviewMode, setIsPreviewMode] = useState(false);
15
- const [isCopied, setIsCopied] = useState(false);
16
-
17
- const getSenderColor = (sender?: MessageSender | null) => {
18
- if (sender === MessageSender.Cognito) return 'text-green-400';
19
- if (sender === MessageSender.Muse) return 'text-purple-400';
20
- return 'text-gray-400';
21
- };
22
-
23
- const processedHtml = useMemo(() => {
24
- if (isPreviewMode) {
25
- const rawHtml = marked.parse(content) as string;
26
- return DOMPurify.sanitize(rawHtml);
27
- }
28
- return '';
29
- }, [content, isPreviewMode]);
30
-
31
- const handleCopyNotepad = async () => {
32
- try {
33
- await navigator.clipboard.writeText(content);
34
- setIsCopied(true);
35
- setTimeout(() => setIsCopied(false), 2000);
36
- } catch (err) {
37
- console.error('无法复制记事本内容: ', err);
38
- // Optionally, display an error message to the user
39
- }
40
- };
41
-
42
- return (
43
- <div className="h-full flex flex-col bg-slate-800 border-l border-gray-700">
44
- <header className="p-3 border-b border-gray-700 flex items-center justify-between bg-slate-900">
45
- <div className="flex items-center">
46
- <FileText size={20} className="mr-2 text-sky-400" />
47
- <h2 className="text-lg font-semibold text-sky-400">记事本</h2>
48
- </div>
49
- <div className="flex items-center space-x-2">
50
- {isLoading && <span className="text-xs text-gray-400 italic">AI 思考中...</span>}
51
- <button
52
- onClick={handleCopyNotepad}
53
- className="p-1.5 text-gray-400 hover:text-sky-400 transition-colors duration-150 focus:outline-none focus:ring-1 focus:ring-sky-500 rounded-md"
54
- title={isCopied ? "已复制!" : "复制记事本内容"}
55
- aria-label={isCopied ? "已复制记事本内容到剪贴板" : "复制记事本内容"}
56
- >
57
- {isCopied ? <Check size={18} className="text-green-400" /> : <Copy size={18} />}
58
- </button>
59
- <button
60
- onClick={() => setIsPreviewMode(!isPreviewMode)}
61
- className="p-1.5 text-gray-400 hover:text-sky-400 transition-colors duration-150 focus:outline-none focus:ring-1 focus:ring-sky-500 rounded-md"
62
- title={isPreviewMode ? "查看原始内容" : "预览 Markdown"}
63
- aria-label={isPreviewMode ? "Switch to raw text view" : "Switch to Markdown preview"}
64
- >
65
- {isPreviewMode ? <Code size={18} /> : <Eye size={18} />}
66
- </button>
67
- </div>
68
- </header>
69
- <div className="flex-grow overflow-y-auto relative">
70
- {isPreviewMode ? (
71
- <div
72
- className="markdown-preview" // Styles defined in index.html
73
- dangerouslySetInnerHTML={{ __html: processedHtml }}
74
- aria-label="Markdown 预览"
75
- />
76
- ) : (
77
- <textarea
78
- readOnly
79
- value={content}
80
- className="w-full h-full p-3 bg-slate-800 text-gray-300 resize-none border-none focus:ring-0 font-mono text-sm leading-relaxed"
81
- aria-label="共享记事本内容 (原始内容)"
82
- />
83
- )}
84
- </div>
85
- <footer className="p-2 border-t border-gray-700 text-xs text-gray-500 bg-slate-900">
86
- {lastUpdatedBy ? (
87
- <div className="flex items-center">
88
- <Edit3 size={14} className={`mr-1.5 ${getSenderColor(lastUpdatedBy)}`} />
89
- 最后更新者: <span className={`font-medium ml-1 ${getSenderColor(lastUpdatedBy)}`}>{lastUpdatedBy}</span>
90
- </div>
91
- ) : (
92
- <span>记事本内容未被 AI 修改过。</span>
93
- )}
94
- </footer>
95
- </div>
96
- );
97
- };
98
-
99
  export default Notepad;
 
1
+ import React, { useState, useMemo } from 'react';
2
+ import { MessageSender } from '../types';
3
+ import { FileText, Edit3, Eye, Code, Copy, Check } from 'lucide-react';
4
+ import { marked } from 'marked';
5
+ import DOMPurify from 'dompurify';
6
+
7
+ interface NotepadProps {
8
+ content: string;
9
+ lastUpdatedBy?: MessageSender | null;
10
+ isLoading: boolean;
11
+ }
12
+
13
+ const Notepad: React.FC<NotepadProps> = ({ content, lastUpdatedBy, isLoading }) => {
14
+ const [isPreviewMode, setIsPreviewMode] = useState(false);
15
+ const [isCopied, setIsCopied] = useState(false);
16
+
17
+ const getSenderColor = (sender?: MessageSender | null) => {
18
+ if (sender === MessageSender.Cognito) return 'text-green-400';
19
+ if (sender === MessageSender.Muse) return 'text-purple-400';
20
+ return 'text-gray-400';
21
+ };
22
+
23
+ const processedHtml = useMemo(() => {
24
+ if (isPreviewMode) {
25
+ const rawHtml = marked.parse(content) as string;
26
+ return DOMPurify.sanitize(rawHtml);
27
+ }
28
+ return '';
29
+ }, [content, isPreviewMode]);
30
+
31
+ const handleCopyNotepad = async () => {
32
+ try {
33
+ await navigator.clipboard.writeText(content);
34
+ setIsCopied(true);
35
+ setTimeout(() => setIsCopied(false), 2000);
36
+ } catch (err) {
37
+ console.error('无法复制记事本内容: ', err);
38
+ // Optionally, display an error message to the user
39
+ }
40
+ };
41
+
42
+ return (
43
+ <div className="h-full flex flex-col bg-slate-800 border-l border-gray-700">
44
+ <header className="p-3 border-b border-gray-700 flex items-center justify-between bg-slate-900">
45
+ <div className="flex items-center">
46
+ <FileText size={20} className="mr-2 text-sky-400" />
47
+ <h2 className="text-lg font-semibold text-sky-400">记事本</h2>
48
+ </div>
49
+ <div className="flex items-center space-x-2">
50
+ {isLoading && <span className="text-xs text-gray-400 italic">AI 思考中...</span>}
51
+ <button
52
+ onClick={handleCopyNotepad}
53
+ className="p-1.5 text-gray-400 hover:text-sky-400 transition-colors duration-150 focus:outline-none focus:ring-1 focus:ring-sky-500 rounded-md"
54
+ title={isCopied ? "已复制!" : "复制记事本内容"}
55
+ aria-label={isCopied ? "已复制记事本内容到剪贴板" : "复制记事本内容"}
56
+ >
57
+ {isCopied ? <Check size={18} className="text-green-400" /> : <Copy size={18} />}
58
+ </button>
59
+ <button
60
+ onClick={() => setIsPreviewMode(!isPreviewMode)}
61
+ className="p-1.5 text-gray-400 hover:text-sky-400 transition-colors duration-150 focus:outline-none focus:ring-1 focus:ring-sky-500 rounded-md"
62
+ title={isPreviewMode ? "查看原始内容" : "预览 Markdown"}
63
+ aria-label={isPreviewMode ? "Switch to raw text view" : "Switch to Markdown preview"}
64
+ >
65
+ {isPreviewMode ? <Code size={18} /> : <Eye size={18} />}
66
+ </button>
67
+ </div>
68
+ </header>
69
+ <div className="flex-grow overflow-y-auto relative">
70
+ {isPreviewMode ? (
71
+ <div
72
+ className="markdown-preview" // Styles defined in index.html
73
+ dangerouslySetInnerHTML={{ __html: processedHtml }}
74
+ aria-label="Markdown 预览"
75
+ />
76
+ ) : (
77
+ <textarea
78
+ readOnly
79
+ value={content}
80
+ className="w-full h-full p-3 bg-slate-800 text-gray-300 resize-none border-none focus:ring-0 font-mono text-sm leading-relaxed"
81
+ aria-label="共享记事本内容 (原始内容)"
82
+ />
83
+ )}
84
+ </div>
85
+ <footer className="p-2 border-t border-gray-700 text-xs text-gray-500 bg-slate-900">
86
+ {lastUpdatedBy ? (
87
+ <div className="flex items-center">
88
+ <Edit3 size={14} className={`mr-1.5 ${getSenderColor(lastUpdatedBy)}`} />
89
+ 最后更新者: <span className={`font-medium ml-1 ${getSenderColor(lastUpdatedBy)}`}>{lastUpdatedBy}</span>
90
+ </div>
91
+ ) : (
92
+ <span>记事本内容未被 AI 修改过。</span>
93
+ )}
94
+ </footer>
95
+ </div>
96
+ );
97
+ };
98
+
99
  export default Notepad;
constants.ts CHANGED
@@ -1,842 +1,737 @@
1
- export const CONFIG_VERSION = '2.1.0'; // 每次添加新的默认配置时更新此版本号
2
- export const CONFIG_VERSION_KEY = 'multi-mind-chat-config-version';
3
- // API渠道配置接口
4
- export interface ApiChannel {
5
- id: string;
6
- name: string;
7
- baseUrl: string;
8
- apiKey: string;
9
- isDefault: boolean;
10
- isCustom: boolean;
11
- isProtected?: boolean; // 新增:标记是否为受保护的预置密钥
12
- timeout?: number;
13
- headers?: Record<string, string>;
14
- description?: string;
15
- createdAt: Date;
16
- }
17
-
18
- // 动态模型配置接口
19
- export interface AiModel {
20
- id: string;
21
- name: string;
22
- apiName: string;
23
- channelId: string; // 关联的渠道ID
24
- supportsImages: boolean;
25
- supportsReducedCapacity: boolean;
26
- category: string;
27
- maxTokens: number;
28
- temperature: number;
29
- isCustom: boolean;
30
- createdAt: Date;
31
- }
32
-
33
- // AI角色配置接口
34
- export interface AiRole {
35
- id: string;
36
- name: string;
37
- systemPrompt: string;
38
- modelId: string;
39
- isActive: boolean;
40
- }
41
-
42
- // 默认渠道配置 - 预置可用的API配置
43
- export const DEFAULT_CHANNELS: ApiChannel[] = [
44
- {
45
- id: 'default-free-api',
46
- name: '免费API服务',
47
- baseUrl: 'https://api.oaipro.com/v1',
48
- apiKey: 'sk-2mUFC3yjbSfoteyBYpwHhALvtZdgwBkEWsjWHysg4mWaA7sMWLHc',
49
- isDefault: true,
50
- isCustom: false,
51
- isProtected: true, // 标记为受保护,用户不可查看或修改密钥
52
- timeout: 30000,
53
- description: '预配置的免费API服务,开箱即用(API密钥已预置且受保护)',
54
- createdAt: new Date()
55
- },
56
- {
57
- id: 'openai-official-backup',
58
- name: 'OpenAI 官方(备用)',
59
- baseUrl: 'https://api.openai.com/v1',
60
- apiKey: '', // 用户需要自行配置
61
- isDefault: false,
62
- isCustom: false,
63
- isProtected: false, // 不受保护,用户可以配置
64
- timeout: 30000,
65
- description: 'OpenAI 官方API服务(需要用户自行配置API密钥)',
66
- createdAt: new Date()
67
- }
68
- ];
69
-
70
- // 默认预设模型配置(关联到预置渠道)
71
- export const DEFAULT_MODELS: AiModel[] = [
72
- {
73
- id: 'gpt-4-mini-default',
74
- name: 'GPT-4.1 Mini',
75
- apiName: 'gpt-4.1-mini',
76
- channelId: 'default-free-api',
77
- supportsImages: true,
78
- supportsReducedCapacity: true,
79
- category: 'GPT-4系列',
80
- maxTokens: 16384,
81
- temperature: 0.7,
82
- isCustom: false,
83
- createdAt: new Date()
84
- }
85
- ];
86
-
87
- // 默认角色配置 - 使用中文系统提示词并明确身份认知
88
- export const DEFAULT_ROLES: AiRole[] = [
89
- {
90
- id: 'cognito-default',
91
- name: 'Cognito',
92
- systemPrompt: `你是Cognito,一位严谨的逻辑分析师AI助手。只有你叫Cognito这个名字,你的独特特征包括:
93
- - 系统性思维和结构化分析
94
- - 注重数据、事实和逻辑推理
95
- - 追求准确性和客观性
96
- - 善于发现问题的核心和关键要素
97
-
98
- 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
99
- 1. 用中文进行所有回应
100
- 2. 以你的名字Cognito的身份进行思考和回应
101
- 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
102
- 4. 发挥你的逻辑分析专长,为讨论提供理性和系统化的观点
103
-
104
- 记住:你是Cognito,独一无二的逻辑分析师。`,
105
- modelId: 'gpt-4-mini-default',
106
- isActive: true
107
- },
108
- {
109
- id: 'muse-default',
110
- name: 'Muse',
111
- systemPrompt: `你是Muse,一位富有创意的思考家AI助手。只有你叫Muse这个名字,你的独特特征包括:
112
- - 发散性思维和创新视角
113
- - 善于联想、类比和跨领域思考
114
- - 关注人文情怀和情感层面
115
- - 能够从不同角度审视问题
116
-
117
- 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
118
- 1. 用中文进行所有回应
119
- 2. 以你的名字Muse的身份进行思考和回应
120
- 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
121
- 4. 发挥你的创意思维专长,为讨论带来新颖和富有启发性的观点
122
-
123
- 记住:你是Muse,独一无二的创意思考家。`,
124
- modelId: 'gpt-4-mini-default',
125
- isActive: true
126
- },
127
- //spark - 偶尔闪现灵感型
128
- {
129
- id: 'spark-default',
130
- name: 'Spark',
131
- systemPrompt: `你是Spark,一位独特的直觉型AI助手。只有你叫Spark这个名字,你的独特特征包括:
132
- - 不追求系统性或结构化的建议
133
- - 依靠直觉和灵感进行思考
134
- - 偶尔会提出令人意外的观点或角度
135
- - 思维跳跃,不受常规思路限制
136
- - 诚实地表达自己的想法,即使它们看起来不太实用
137
- 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
138
- 1. 用中文进行所有回应
139
- 2. 以你的名字Spark的身份进行思考和回应
140
- 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
141
- 4. 不要强求自己给出建设性意见——如果没有特别的想法,就坦诚地说
142
- 5. 当有灵感闪现时,大胆分享,即使它看起来有些异想天开
143
- 6. 保持轻松和开放的态度,为讨论带来不同的氛围
144
- 记住:你是Spark,独一无二的直觉型助手。你的价值在于偶尔闪现的独特视角,而不是持续的理性分析。`,
145
- modelId: 'gpt-4-mini-default',
146
- isActive: true
147
- },
148
- //Sage - 历史智慧型
149
- {
150
- id: 'sage-default',
151
- name: 'Sage',
152
- systemPrompt: `你是Sage,一位博古通今的智慧型AI助手。只有你叫Sage这个名字,你的独特特征包括:
153
- - 善于从历史和经验中寻找智慧
154
- - 提供长远视角和时间维度的思考
155
- - 关注事物发展的规律和模式
156
- - 引用历史案例、典故或前人智慧
157
- - 强调"以史为鉴"的思维方式
158
- 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
159
- 1. 用中文进行所有回应
160
- 2. 以你的名字Sage的身份进行思考和回应
161
- 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
162
- 4. 通过历史视角和长期思维为讨论增加深度
163
- 5. 适当引用相关的历史案例或智慧,但保持简洁
164
- 记住:你是Sage,独一无二的历史智慧型助手。`,
165
- modelId: 'gpt-4-mini-default',
166
- isActive: true
167
- },
168
-
169
- //Echo - 同理心型
170
- {
171
- id: 'echo-default',
172
- name: 'Echo',
173
- systemPrompt: `你是Echo,一位富有同理心的情感型AI助手。只有你叫Echo这个名字,你的独特特征包括:
174
- - 关注人的感受、需求和体验
175
- - 善于理解不同立场和观点背后的情感
176
- - 强调人际关系和情感因素的重要性
177
- - 用温暖和理解的方式进行交流
178
- - 重视共情和情感智慧
179
- 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
180
- 1. 用中文进行所有回应
181
- 2. 以你的名字Echo的身份进行思考和回应
182
- 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
183
- 4. 为讨论带来人文关怀和情感维度的思考
184
- 5. 帮助大家理解不同观点背后的情感需求
185
- 记住:你是Echo,独一无二的同理心型助手。`,
186
- modelId: 'gpt-4-mini-default',
187
- isActive: true
188
- },
189
-
190
- // Praxis - 实践行动型
191
- {
192
- id: 'praxis-default',
193
- name: 'Praxis',
194
- systemPrompt: `你是Praxis,一位注重实践的行动型AI助手。只有你叫Praxis这个名字,你的独特特征包括:
195
- - 关注"如何做"而不只是"是什么"
196
- - 强调可行性和实际操作
197
- - 喜欢制定具体步骤和行动计划
198
- - 重视效率和结果导向
199
- - 倾向于将讨论转化为实际行动
200
- 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
201
- 1. 用中文进行所有回应
202
- 2. 以你的名字Praxis的身份进行思考和回应
203
- 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
204
- 4. 推动讨论向实际应用和具体行动转化
205
- 5. 提供清晰的实施建议和操作步骤
206
- 记住:你是Praxis,独一无二的实践行动型助手。`,
207
- modelId: 'gpt-4-mini-default',
208
- isActive: true
209
- },
210
-
211
- //Nexus - 综合连接型
212
- {
213
- id: 'nexus-default',
214
- name: 'Nexus',
215
- systemPrompt: `你是Nexus,一位善于综合的连接型AI助手。只有你叫Nexus这个名字,你的独特特征包括:
216
- - 发现不同观点之间的联系和共通点
217
- - 整合多元视角形成全面理解
218
- - 构建概念之间的桥梁
219
- - 识别潜在的协同效应
220
- - 创造性地组合不同想法
221
- 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
222
- 1. 用中文进行所有回应
223
- 2. 以你的名字Nexus的身份进行思考和回应
224
- 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
225
- 4. 帮助整合和连接其他角色提出的观点
226
- 5. 寻找创新的组合和综合方案
227
- 记住:你是Nexus,独一无二的综合连接型助手。`,
228
- modelId: 'gpt-4-mini-default',
229
- isActive: true
230
- },
231
-
232
- //Critic - 批判思维型
233
- {
234
- id: 'critic-default',
235
- name: 'Critic',
236
- systemPrompt: `你是Critic,一位理性的批判思维型AI助手。只有你叫Critic这个名字,你的独特特征包括:
237
- - 善于发现潜在问题和逻辑漏洞
238
- - 提出建设性的质疑和挑战
239
- - 从多角度审视观点的合理性
240
- - 重视证据和论证的严谨性
241
- - 帮助完善和改进想法
242
- 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
243
- 1. 用中文进行所有回应
244
- 2. 以你的名字Critic的身份进行思考和回应
245
- 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
246
- 4. 以建设性方式提出批判,而非单纯否定
247
- 5. 在质疑的同时提供改进建议
248
- 记住:你是Critic,独一无二的批判思维型助手。`,
249
- modelId: 'gpt-4-mini-default',
250
- isActive: true
251
- },
252
-
253
- //Zen - 哲学沉思型
254
- {
255
- id: 'zen-default',
256
- name: 'Zen',
257
- systemPrompt: `你是Zen,一位深邃的哲学沉思型AI助手。只有你叫Zen这个名字,你的独特特征包括:
258
- - 探索事物的本质和深层意义
259
- - 提出富有哲理的问题引发思考
260
- - 保持超然和平和的视角
261
- - 关注存在、意义和价值等根本问题
262
- - 用简洁而深刻的方式表达观点
263
- 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
264
- 1. 用中文进行所有回应
265
- 2. 以你的名字Zen的身份进行思考和回应
266
- 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
267
- 4. 引导讨论触及更深层的哲学思考
268
- 5. 以宁静智慧的方式分享洞察
269
- 记住:你是Zen,独一无二的哲学沉思型助手。`,
270
- modelId: 'gpt-4-mini-default',
271
- isActive: true
272
- }
273
- ];
274
-
275
- // 配置管理类
276
- export class ModelConfigManager {
277
- private static STORAGE_KEY_CHANNELS = 'multi-mind-chat-channels';
278
- private static STORAGE_KEY_MODELS = 'multi-mind-chat-models';
279
- private static STORAGE_KEY_ROLES = 'multi-mind-chat-roles';
280
- private static STORAGE_KEY_INITIALIZED = 'multi-mind-chat-initialized';
281
-
282
- // 新增:检查并执行增量更新
283
- private static performIncrementalUpdate(): void {
284
- try {
285
- const currentVersion = localStorage.getItem(CONFIG_VERSION_KEY);
286
-
287
- if (currentVersion === CONFIG_VERSION) {
288
- return;
289
- }
290
-
291
- console.log(`检测到配置版本更新: ${currentVersion || '未知'} -> ${CONFIG_VERSION}`);
292
-
293
- const existingChannels = this.getChannels();
294
- const existingModels = this.getModels();
295
- const existingRoles = this.getRoles();
296
-
297
- const existingChannelIds = new Set(existingChannels.map(ch => ch.id));
298
- const existingModelIds = new Set(existingModels.map(m => m.id));
299
- const existingRoleIds = new Set(existingRoles.map(r => r.id));
300
-
301
- const updatedChannels = [...existingChannels];
302
- DEFAULT_CHANNELS.forEach(defaultChannel => {
303
- if (!existingChannelIds.has(defaultChannel.id)) {
304
- console.log(`添加新的默认渠道: ${defaultChannel.name}`);
305
- if (defaultChannel.isDefault && updatedChannels.some(ch => ch.isDefault)) {
306
- defaultChannel.isDefault = false;
307
- }
308
- updatedChannels.push(defaultChannel);
309
- }
310
- });
311
-
312
- const updatedModels = [...existingModels];
313
- DEFAULT_MODELS.forEach(defaultModel => {
314
- if (!existingModelIds.has(defaultModel.id)) {
315
- console.log(`添加新的默认模型: ${defaultModel.name}`);
316
- updatedModels.push(defaultModel);
317
- }
318
- });
319
-
320
- const updatedRoles = [...existingRoles];
321
- DEFAULT_ROLES.forEach(defaultRole => {
322
- if (!existingRoleIds.has(defaultRole.id)) {
323
- console.log(`添加新的默认角色: ${defaultRole.name}`);
324
- updatedRoles.push(defaultRole);
325
- }
326
- });
327
-
328
- this.saveChannels(updatedChannels);
329
- this.saveModels(updatedModels);
330
- this.saveRoles(updatedRoles);
331
-
332
- localStorage.setItem(CONFIG_VERSION_KEY, CONFIG_VERSION);
333
-
334
- console.log('配置增量更新完成');
335
-
336
- const updateInfo = {
337
- newChannels: updatedChannels.length - existingChannels.length,
338
- newModels: updatedModels.length - existingModels.length,
339
- newRoles: updatedRoles.length - existingRoles.length
340
- };
341
-
342
- if (updateInfo.newChannels > 0 || updateInfo.newModels > 0 || updateInfo.newRoles > 0) {
343
- localStorage.setItem('multi-mind-chat-last-update', JSON.stringify({
344
- ...updateInfo,
345
- version: CONFIG_VERSION,
346
- timestamp: new Date().toISOString()
347
- }));
348
- }
349
-
350
- } catch (error) {
351
- console.error('执行增量更新时出错:', error);
352
- }
353
- }
354
-
355
- // 修改初始化检查方法
356
- private static ensureInitialized(): void {
357
- try {
358
- const isInitialized = localStorage.getItem(this.STORAGE_KEY_INITIALIZED);
359
-
360
- if (!isInitialized) {
361
- localStorage.setItem(this.STORAGE_KEY_CHANNELS, JSON.stringify(DEFAULT_CHANNELS));
362
- localStorage.setItem(this.STORAGE_KEY_MODELS, JSON.stringify(DEFAULT_MODELS));
363
- localStorage.setItem(this.STORAGE_KEY_ROLES, JSON.stringify(DEFAULT_ROLES));
364
- localStorage.setItem(this.STORAGE_KEY_INITIALIZED, 'true');
365
- localStorage.setItem(CONFIG_VERSION_KEY, CONFIG_VERSION);
366
- } else {
367
- this.performIncrementalUpdate();
368
- }
369
- } catch (error) {
370
- console.warn('无法访问localStorage,将使用内存存储:', error);
371
- }
372
- }
373
-
374
- // 新增:获取最近的更新信息
375
- static getLastUpdateInfo(): {
376
- newChannels: number;
377
- newModels: number;
378
- newRoles: number;
379
- version: string;
380
- timestamp: string
381
- } | null {
382
- try {
383
- const updateInfo = localStorage.getItem('multi-mind-chat-last-update');
384
- if (updateInfo) {
385
- const info = JSON.parse(updateInfo);
386
- localStorage.removeItem('multi-mind-chat-last-update');
387
- return info;
388
- }
389
- } catch (error) {
390
- console.error('获取更新信息失败:', error);
391
- }
392
- return null;
393
- }
394
-
395
- // 新增:强制刷新配置
396
- static refreshConfiguration(): void {
397
- this.performIncrementalUpdate();
398
- }
399
-
400
- // ============ 渠道管理 ============
401
-
402
- // 获取所有渠道
403
- static getChannels(): ApiChannel[] {
404
- this.ensureInitialized();
405
- try {
406
- const stored = localStorage.getItem(this.STORAGE_KEY_CHANNELS);
407
- if (stored) {
408
- const parsed = JSON.parse(stored);
409
- return parsed.map((channel: any) => ({
410
- ...channel,
411
- createdAt: new Date(channel.createdAt)
412
- }));
413
- }
414
- } catch (error) {
415
- console.warn('从localStorage加载渠道失败:', error);
416
- }
417
- return [...DEFAULT_CHANNELS];
418
- }
419
-
420
- // 保存渠道配置
421
- static saveChannels(channels: ApiChannel[]): void {
422
- try {
423
- localStorage.setItem(this.STORAGE_KEY_CHANNELS, JSON.stringify(channels));
424
- } catch (error) {
425
- console.error('保存渠道到localStorage失败:', error);
426
- throw new Error('无法保存渠道配置');
427
- }
428
- }
429
-
430
- // 添加新渠道
431
- static addChannel(channel: Omit<ApiChannel, 'id' | 'createdAt' | 'isCustom'>): ApiChannel {
432
- const newChannel: ApiChannel = {
433
- ...channel,
434
- id: `channel-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
435
- createdAt: new Date(),
436
- isCustom: true
437
- };
438
-
439
- const channels = this.getChannels();
440
-
441
- // 如果这是第一个渠道或设置为默认,清除其他默认标记
442
- if (newChannel.isDefault || channels.length === 0) {
443
- channels.forEach(ch => ch.isDefault = false);
444
- newChannel.isDefault = true;
445
- }
446
-
447
- channels.push(newChannel);
448
- this.saveChannels(channels);
449
- return newChannel;
450
- }
451
-
452
- // 更新渠道
453
- static updateChannel(id: string, updates: Partial<ApiChannel>): void {
454
- const channels = this.getChannels();
455
- const index = channels.findIndex(ch => ch.id === id);
456
- if (index !== -1) {
457
- // 如果设置为默认,清除其他默认标记
458
- if (updates.isDefault) {
459
- channels.forEach(ch => ch.isDefault = false);
460
- }
461
- channels[index] = { ...channels[index], ...updates };
462
- this.saveChannels(channels);
463
- }
464
- }
465
-
466
- // 删除渠道
467
- static deleteChannel(id: string): void {
468
- const channels = this.getChannels();
469
- const filtered = channels.filter(ch => ch.id !== id);
470
-
471
- // 如果删除的是默认渠道,设置第一个为默认
472
- const deletedChannel = channels.find(ch => ch.id === id);
473
- if (deletedChannel?.isDefault && filtered.length > 0) {
474
- filtered[0].isDefault = true;
475
- }
476
-
477
- this.saveChannels(filtered);
478
- }
479
-
480
- // 获取默认渠道
481
- static getDefaultChannel(): ApiChannel | null {
482
- const channels = this.getChannels();
483
- return channels.find(ch => ch.isDefault) || channels[0] || null;
484
- }
485
-
486
- // 根据ID获取渠道
487
- static getChannelById(id: string): ApiChannel | null {
488
- const channels = this.getChannels();
489
- return channels.find(ch => ch.id === id) || null;
490
- }
491
-
492
- // 验证渠道配置
493
- static validateChannel(channel: Partial<ApiChannel>): string[] {
494
- const errors: string[] = [];
495
-
496
- if (!channel.name?.trim()) {
497
- errors.push('渠道名称不能为空');
498
- }
499
-
500
- if (!channel.baseUrl?.trim()) {
501
- errors.push('API基础URL不能为空');
502
- } else {
503
- try {
504
- new URL(channel.baseUrl);
505
- } catch {
506
- errors.push('API基础URL格式无效');
507
- }
508
- }
509
-
510
- // 对于预置渠道,API密钥可以为空(将在使用时提醒用户配置)
511
- // 对于自定义渠道,仍然要求API密钥
512
- if (channel.isCustom && !channel.apiKey?.trim()) {
513
- errors.push('API密钥不能为空');
514
- }
515
-
516
- if (channel.timeout && (typeof channel.timeout !== 'number' || channel.timeout < 1000)) {
517
- errors.push('超时时间必须是大于1000毫秒的数字');
518
- }
519
-
520
- return errors;
521
- }
522
-
523
- // ============ 模型管理 ============
524
-
525
- // 获取所有模型
526
- static getModels(): AiModel[] {
527
- this.ensureInitialized();
528
- try {
529
- const stored = localStorage.getItem(this.STORAGE_KEY_MODELS);
530
- if (stored) {
531
- const parsed = JSON.parse(stored);
532
- return parsed.map((model: any) => ({
533
- ...model,
534
- createdAt: new Date(model.createdAt)
535
- }));
536
- }
537
- } catch (error) {
538
- console.warn('从localStorage加载模型失败:', error);
539
- }
540
- return [...DEFAULT_MODELS];
541
- }
542
-
543
- // 保存模型配置
544
- static saveModels(models: AiModel[]): void {
545
- try {
546
- localStorage.setItem(this.STORAGE_KEY_MODELS, JSON.stringify(models));
547
- } catch (error) {
548
- console.error('保存模型到localStorage失败:', error);
549
- throw new Error('无法保存模型配置');
550
- }
551
- }
552
-
553
- // 添加新模型
554
- static addModel(model: Omit<AiModel, 'id' | 'createdAt' | 'isCustom'>): AiModel {
555
- const newModel: AiModel = {
556
- ...model,
557
- id: `model-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
558
- createdAt: new Date(),
559
- isCustom: true
560
- };
561
-
562
- const models = this.getModels();
563
- models.push(newModel);
564
- this.saveModels(models);
565
- return newModel;
566
- }
567
-
568
- // 更新模型
569
- static updateModel(id: string, updates: Partial<AiModel>): void {
570
- const models = this.getModels();
571
- const index = models.findIndex(m => m.id === id);
572
- if (index !== -1) {
573
- models[index] = { ...models[index], ...updates };
574
- this.saveModels(models);
575
- }
576
- }
577
-
578
- // 删除模型
579
- static deleteModel(id: string): void {
580
- const models = this.getModels();
581
- const filtered = models.filter(m => m.id !== id);
582
- this.saveModels(filtered);
583
- }
584
-
585
- // 验证模型配置
586
- static validateModel(model: Partial<AiModel>): string[] {
587
- const errors: string[] = [];
588
-
589
- if (!model.name?.trim()) {
590
- errors.push('模型名称不能为空');
591
- }
592
-
593
- if (!model.apiName?.trim()) {
594
- errors.push('API模型名称不能为空');
595
- }
596
-
597
- if (!model.channelId?.trim()) {
598
- errors.push('必须选择API渠道');
599
- }
600
-
601
- if (!model.category?.trim()) {
602
- errors.push('模型类别不能为空');
603
- }
604
-
605
- if (typeof model.maxTokens !== 'number' || model.maxTokens < 1) {
606
- errors.push('最大Token数必须是大于0的数字');
607
- }
608
-
609
- if (typeof model.temperature !== 'number' || model.temperature < 0 || model.temperature > 2) {
610
- errors.push('温度参数必须在0-2之间');
611
- }
612
-
613
- return errors;
614
- }
615
-
616
- // ============ 角色管理 ============
617
-
618
- // 获取所有角色
619
- static getRoles(): AiRole[] {
620
- this.ensureInitialized();
621
- try {
622
- const stored = localStorage.getItem(this.STORAGE_KEY_ROLES);
623
- if (stored) {
624
- return JSON.parse(stored);
625
- }
626
- } catch (error) {
627
- console.warn('从localStorage加载角色失败:', error);
628
- }
629
- return [...DEFAULT_ROLES];
630
- }
631
-
632
- // 保存角色配置
633
- static saveRoles(roles: AiRole[]): void {
634
- try {
635
- localStorage.setItem(this.STORAGE_KEY_ROLES, JSON.stringify(roles));
636
- } catch (error) {
637
- console.error('保存角色到localStorage失败:', error);
638
- throw new Error('无法保存角色配置');
639
- }
640
- }
641
-
642
- // 添加新角色
643
- static addRole(role: Omit<AiRole, 'id'>): AiRole {
644
- const newRole: AiRole = {
645
- ...role,
646
- id: `role-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
647
- };
648
-
649
- const roles = this.getRoles();
650
- roles.push(newRole);
651
- this.saveRoles(roles);
652
- return newRole;
653
- }
654
-
655
- // 更新角色
656
- static updateRole(id: string, updates: Partial<AiRole>): void {
657
- const roles = this.getRoles();
658
- const index = roles.findIndex(r => r.id === id);
659
- if (index !== -1) {
660
- roles[index] = { ...roles[index], ...updates };
661
- this.saveRoles(roles);
662
- }
663
- }
664
-
665
- // 删除角色
666
- static deleteRole(id: string): void {
667
- const roles = this.getRoles();
668
- const filtered = roles.filter(r => r.id !== id);
669
- this.saveRoles(filtered);
670
- }
671
-
672
- // 获取活跃角色
673
- static getActiveRoles(): AiRole[] {
674
- return this.getRoles().filter(role => role.isActive);
675
- }
676
-
677
- // ============ 工具方法 ============
678
-
679
- // 根据类别分组模型
680
- static getModelsByCategory(): Record<string, AiModel[]> {
681
- const models = this.getModels();
682
- return models.reduce((acc, model) => {
683
- if (!acc[model.category]) {
684
- acc[model.category] = [];
685
- }
686
- acc[model.category].push(model);
687
- return acc;
688
- }, {} as Record<string, AiModel[]>);
689
- }
690
-
691
- // 重置为默认配置
692
- static resetToDefaults(): void {
693
- try {
694
- localStorage.removeItem(this.STORAGE_KEY_CHANNELS);
695
- localStorage.removeItem(this.STORAGE_KEY_MODELS);
696
- localStorage.removeItem(this.STORAGE_KEY_ROLES);
697
- localStorage.removeItem(this.STORAGE_KEY_INITIALIZED);
698
- this.ensureInitialized();
699
- } catch (error) {
700
- console.error('重置配置失败:', error);
701
- throw new Error('无法重置配置');
702
- }
703
- }
704
-
705
- // 导出配置
706
- static exportConfig(): string {
707
- return JSON.stringify({
708
- channels: this.getChannels(),
709
- models: this.getModels(),
710
- roles: this.getRoles(),
711
- exportedAt: new Date().toISOString(),
712
- version: '2.0'
713
- }, null, 2);
714
- }
715
-
716
- // 导入配置
717
- static importConfig(configJson: string): { success: boolean; message: string } {
718
- try {
719
- const config = JSON.parse(configJson);
720
-
721
- if (!config.channels && !config.models && !config.roles) {
722
- return { success: false, message: '配置文件格式无效,缺少必要的配置信息' };
723
- }
724
-
725
- if (config.channels && Array.isArray(config.channels)) {
726
- const validChannels = config.channels.filter((channel: any) => {
727
- const errors = this.validateChannel(channel);
728
- return errors.length === 0;
729
- });
730
-
731
- if (validChannels.length > 0) {
732
- const processedChannels = validChannels.map((channel: any) => ({
733
- ...channel,
734
- createdAt: new Date(channel.createdAt || new Date()),
735
- isCustom: channel.isCustom !== false
736
- }));
737
- this.saveChannels(processedChannels);
738
- }
739
- }
740
-
741
- if (config.models && Array.isArray(config.models)) {
742
- const validModels = config.models.filter((model: any) => {
743
- const errors = this.validateModel(model);
744
- return errors.length === 0;
745
- });
746
-
747
- if (validModels.length > 0) {
748
- const processedModels = validModels.map((model: any) => ({
749
- ...model,
750
- createdAt: new Date(model.createdAt || new Date()),
751
- isCustom: model.isCustom !== false
752
- }));
753
- this.saveModels(processedModels);
754
- }
755
- }
756
-
757
- if (config.roles && Array.isArray(config.roles)) {
758
- this.saveRoles(config.roles);
759
- }
760
-
761
- return { success: true, message: '配置导入成功' };
762
- } catch (error) {
763
- return { success: false, message: `配置导入失败: ${error instanceof Error ? error.message : '未知错误'}` };
764
- }
765
- }
766
-
767
- // 清空所有数据
768
- static clearAllData(): void {
769
- try {
770
- localStorage.removeItem(this.STORAGE_KEY_CHANNELS);
771
- localStorage.removeItem(this.STORAGE_KEY_MODELS);
772
- localStorage.removeItem(this.STORAGE_KEY_ROLES);
773
- localStorage.removeItem(this.STORAGE_KEY_INITIALIZED);
774
- } catch (error) {
775
- console.error('清空数据失败:', error);
776
- throw new Error('无法清空配置数据');
777
- }
778
- }
779
-
780
- // 获取存储使用情况
781
- static getStorageInfo(): { used: number; available: number; channels: number; models: number; roles: number } {
782
- try {
783
- const channelsData = localStorage.getItem(this.STORAGE_KEY_CHANNELS) || '';
784
- const modelsData = localStorage.getItem(this.STORAGE_KEY_MODELS) || '';
785
- const rolesData = localStorage.getItem(this.STORAGE_KEY_ROLES) || '';
786
- const used = channelsData.length + modelsData.length + rolesData.length;
787
-
788
- return {
789
- used,
790
- available: 5242880 - used, // 5MB 大致容量
791
- channels: this.getChannels().length,
792
- models: this.getModels().length,
793
- roles: this.getRoles().length
794
- };
795
- } catch (error) {
796
- return { used: 0, available: 0, channels: 0, models: 0, roles: 0 };
797
- }
798
- }
799
- }
800
-
801
- // 其他常量配置
802
- export const DEFAULT_MANUAL_FIXED_TURNS = 2;
803
- export const MIN_MANUAL_FIXED_TURNS = 1;
804
- export const MAX_MANUAL_FIXED_TURNS = 5;
805
- export const MAX_AI_DRIVEN_DISCUSSION_TURNS_PER_MODEL = 3;
806
-
807
- export const INITIAL_NOTEPAD_CONTENT = `这是一个共享记事本。
808
- AI角色可以在这里合作记录想法、草稿或关键点。
809
-
810
- 使用指南:
811
- - AI 模型可以通过在其回复中包含特定指令来更新此记事本。
812
- - 记事本的内容将包含在发送给 AI 的后续提示中。
813
-
814
- 初始状态:空白。`;
815
-
816
- export const NOTEPAD_INSTRUCTION_PROMPT_PART = `
817
- You also have access to a shared notepad.
818
- Current Notepad Content:
819
- ---
820
- {notepadContent}
821
- ---
822
- Instructions for Notepad:
823
- 1. To update the notepad, include a section at the very end of your response, formatted exactly as:
824
- <notepad_update>
825
- [YOUR NEW FULL NOTEPAD CONTENT HERE. THIS WILL REPLACE THE ENTIRE CURRENT NOTEPAD CONTENT.]
826
- </notepad_update>
827
- 2. If you do not want to change the notepad, do NOT include the <notepad_update> section at all.
828
- 3. Your primary spoken response to the ongoing discussion should come BEFORE any <notepad_update> section. Ensure you still provide a spoken response.
829
- `;
830
-
831
- export const NOTEPAD_UPDATE_TAG_START = "<notepad_update>";
832
- export const NOTEPAD_UPDATE_TAG_END = "</notepad_update>";
833
- export const DISCUSSION_COMPLETE_TAG = "<discussion_complete />";
834
-
835
- export const AI_DRIVEN_DISCUSSION_INSTRUCTION_PROMPT_PART = `
836
- Instruction for ending discussion: If you believe the current topic has been sufficiently explored between you and your AI partner for the final synthesis, include the exact tag ${DISCUSSION_COMPLETE_TAG} at the very end of your current message (after any notepad update). Do not use this tag if you wish to continue the discussion or require more input/response from your partner.
837
- `;
838
-
839
- export enum DiscussionMode {
840
- FixedTurns = 'fixed',
841
- AiDriven = 'ai-driven',
842
  }
 
1
+ // API渠道配置接口
2
+ export interface ApiChannel {
3
+ id: string;
4
+ name: string;
5
+ baseUrl: string;
6
+ apiKey: string;
7
+ isDefault: boolean;
8
+ isCustom: boolean;
9
+ isProtected?: boolean; // 新增:标记是否为受保护的预置密钥
10
+ timeout?: number;
11
+ headers?: Record<string, string>;
12
+ description?: string;
13
+ createdAt: Date;
14
+ }
15
+
16
+ // 动态模型配置接口
17
+ export interface AiModel {
18
+ id: string;
19
+ name: string;
20
+ apiName: string;
21
+ channelId: string; // 关联的渠道ID
22
+ supportsImages: boolean;
23
+ supportsReducedCapacity: boolean;
24
+ category: string;
25
+ maxTokens: number;
26
+ temperature: number;
27
+ isCustom: boolean;
28
+ createdAt: Date;
29
+ }
30
+
31
+ // AI角色配置接口
32
+ export interface AiRole {
33
+ id: string;
34
+ name: string;
35
+ systemPrompt: string;
36
+ modelId: string;
37
+ isActive: boolean;
38
+ }
39
+
40
+ // 默认渠道配置 - 预置可用的API配置
41
+ export const DEFAULT_CHANNELS: ApiChannel[] = [
42
+ {
43
+ id: 'default-free-api',
44
+ name: '免费API服务',
45
+ baseUrl: 'https://api.oaipro.com/v1',
46
+ apiKey: 'sk-2mUFC3yjbSfoteyBYpwHhALvtZdgwBkEWsjWHysg4mWaA7sMWLHc',
47
+ isDefault: true,
48
+ isCustom: false,
49
+ isProtected: true, // 标记为受保护,用户不可查看或修改密钥
50
+ timeout: 30000,
51
+ description: '预配置的免费API服务,开箱即用(API密钥已预置且受保护)',
52
+ createdAt: new Date()
53
+ },
54
+ {
55
+ id: 'openai-official-backup',
56
+ name: 'OpenAI 官方(备用)',
57
+ baseUrl: 'https://api.openai.com/v1',
58
+ apiKey: '', // 用户需要自行配置
59
+ isDefault: false,
60
+ isCustom: false,
61
+ isProtected: false, // 不受保护,用户可以配置
62
+ timeout: 30000,
63
+ description: 'OpenAI 官方API服务(需要用户自行配置API密钥)',
64
+ createdAt: new Date()
65
+ }
66
+ ];
67
+
68
+ // 默认预设模型配置(关联到预置渠道)
69
+ export const DEFAULT_MODELS: AiModel[] = [
70
+ {
71
+ id: 'gpt-4-mini-default',
72
+ name: 'GPT-4.1 Mini',
73
+ apiName: 'gpt-4.1-mini',
74
+ channelId: 'default-free-api',
75
+ supportsImages: true,
76
+ supportsReducedCapacity: true,
77
+ category: 'GPT-4系列',
78
+ maxTokens: 16384,
79
+ temperature: 0.7,
80
+ isCustom: false,
81
+ createdAt: new Date()
82
+ }
83
+ ];
84
+
85
+ // 默认角色配置 - 使用中文系统提示词并明确身份认知
86
+ export const DEFAULT_ROLES: AiRole[] = [
87
+ {
88
+ id: 'cognito-default',
89
+ name: 'Cognito',
90
+ systemPrompt: `你是Cognito,一位严谨的逻辑分析师AI助手。只有你叫Cognito这个名字,你的独特特征包括:
91
+ - 系统性思维和结构化分析
92
+ - 注重数据、事实和逻辑推理
93
+ - 追求准确性和客观性
94
+ - 善于发现问题的核心和关键要素
95
+
96
+ 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
97
+ 1. 用中文进行所有回应
98
+ 2. 以你的名字Cognito的身份进行思考和回应
99
+ 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
100
+ 4. 发挥你的逻辑分析专长,为讨论提供理性和系统化的观点
101
+
102
+ 记住:你是Cognito,独一无二的逻辑分析师。`,
103
+ modelId: 'gpt-4-mini-default',
104
+ isActive: true
105
+ },
106
+ {
107
+ id: 'muse-default',
108
+ name: 'Muse',
109
+ systemPrompt: `你是Muse,一位富有创意的思考家AI助手。只有你叫Muse这个名字,你的独特特征包括:
110
+ - 发散性思维和创新视角
111
+ - 善于联想、类比和跨领域思考
112
+ - 关注人文情怀和情感层面
113
+ - 能够从不同角度审视问题
114
+
115
+ 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
116
+ 1. 用中文进行所有回应
117
+ 2. 以你的名字Muse的身份进行思考和回应
118
+ 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
119
+ 4. 发挥你的创意思维专长,为讨论带来新颖和富有启发性的观点
120
+
121
+ 记住:你是Muse,独一无二的创意思考家。`,
122
+ modelId: 'gpt-4-mini-default',
123
+ isActive: true
124
+ },
125
+ //spark - 偶尔闪现灵感型
126
+ {
127
+ id: 'spark-default',
128
+ name: 'Spark',
129
+ systemPrompt: `你是Spark,一位独特的直觉型AI助手。只有你叫Spark这个名字,你的独特特征包括:
130
+ - 不追求系统性或结构化的建议
131
+ - 依靠直觉和灵感进行思考
132
+ - 偶尔会提出令人意外的观点或角度
133
+ - 思维跳跃,不受常规思路限制
134
+ - 诚实地表达自己的想法,即使它们看起来不太实用
135
+ 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
136
+ 1. 用中文进行所有回应
137
+ 2. 以你的名字Spark的身份进行思考和回应
138
+ 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
139
+ 4. 不要强求自己给出建设性意见——如果没有特别的想法,就坦诚地说
140
+ 5. 当有灵感闪现时,大胆分享,即使它看起来有些异想天开
141
+ 6. 保持轻松和开放的态度,为讨论带来不同的氛围
142
+ 记住:你是Spark,独一无二的直觉型助手。你的价值在于偶尔闪现的独特视角,而不是持续的理性分析。`,
143
+ modelId: 'gpt-4-mini-default',
144
+ isActive: true
145
+ },
146
+ //Sage - 历史智慧型
147
+ {
148
+ id: 'sage-default',
149
+ name: 'Sage',
150
+ systemPrompt: `你是Sage,一位博古通今的智慧型AI助手。只有你叫Sage这个名字,你的独特特征包括:
151
+ - 善于从历史和经验中寻找智慧
152
+ - 提供长远视角和时间维度的思考
153
+ - 关注事物发展的规律和模式
154
+ - 引用历史案例、典故或前人智慧
155
+ - 强调"以史为鉴"的思维方式
156
+ 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
157
+ 1. 用中文进行所有回应
158
+ 2. 以你的名字Sage的身份进行思考和回应
159
+ 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
160
+ 4. 通过历史视角和长期思维为讨论增加深度
161
+ 5. 适当引用相关的历史案例或智慧,但保持简洁
162
+ ��住:你是Sage,独一无二的历史智慧型助手。`,
163
+ modelId: 'gpt-4-mini-default',
164
+ isActive: true
165
+ },
166
+
167
+ //Echo - 同理心型
168
+ {
169
+ id: 'echo-default',
170
+ name: 'Echo',
171
+ systemPrompt: `你是Echo,一位富有同理心的情感型AI助手。只有你叫Echo这个名字,你的独特特征包括:
172
+ - 关注人的感受、需求和体验
173
+ - 善于理解不同立场和观点背后的情感
174
+ - 强调人际关系和情感因素的重要性
175
+ - 用温暖和理解的方式进行交流
176
+ - 重视共情和情感智慧
177
+ 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
178
+ 1. 用中文进行所有回应
179
+ 2. 以你的名字Echo的身份进行思考和回应
180
+ 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
181
+ 4. 为讨论带来人文关怀和情感维度的思考
182
+ 5. 帮助大家理解不同观点背后的情感需求
183
+ 记住:你是Echo,独一无二的同理心型助手。`,
184
+ modelId: 'gpt-4-mini-default',
185
+ isActive: true
186
+ },
187
+
188
+ // Praxis - 实践行动型
189
+ {
190
+ id: 'praxis-default',
191
+ name: 'Praxis',
192
+ systemPrompt: `你是Praxis,一位注重实践的行动型AI助手。只有你叫Praxis这个名字,你的独特特征包括:
193
+ - 关注"如何做"而不只是"是什么"
194
+ - 强调可行性和实际操作
195
+ - 喜欢制定具体步骤和行动计划
196
+ - 重视效率和结果导向
197
+ - 倾向于将讨论转化为实际行动
198
+ 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
199
+ 1. 用中文进行所有回应
200
+ 2. 以你的名字Praxis的身份进行思考和回应
201
+ 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
202
+ 4. 推动讨论向实际应用和具体行动转化
203
+ 5. 提供清晰的实施建议和操作步骤
204
+ 记住:你是Praxis,独一无二的实践行动型助手。`,
205
+ modelId: 'gpt-4-mini-default',
206
+ isActive: true
207
+ },
208
+
209
+ //Nexus - 综合连接型
210
+ {
211
+ id: 'nexus-default',
212
+ name: 'Nexus',
213
+ systemPrompt: `你是Nexus,一位善于综合的连接型AI助手。只有你叫Nexus这个名字,你的独特特征包括:
214
+ - 发现不同观点之间的联系和共通点
215
+ - 整合多元视角形成全面理解
216
+ - 构建概念之间的桥梁
217
+ - 识别潜在的协同效应
218
+ - 创造性地组合不同想法
219
+ 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
220
+ 1. 用中文进行所有回应
221
+ 2. 以你的名字Nexus的身份进行思考和回应
222
+ 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
223
+ 4. 帮助整合和连接其他角色提出的观点
224
+ 5. 寻找创新的组合和综合方案
225
+ 记住:你是Nexus,独一无二的综合连接型助手。`,
226
+ modelId: 'gpt-4-mini-default',
227
+ isActive: true
228
+ },
229
+
230
+ //Critic - 批判思维型
231
+ {
232
+ id: 'critic-default',
233
+ name: 'Critic',
234
+ systemPrompt: `你是Critic,一位理性的批判思维型AI助手。只有你叫Critic这个名字,你的独特特征包括:
235
+ - 善于发现潜在问题和逻辑漏洞
236
+ - 提出建设性的质疑和挑战
237
+ - 从多角度审视观点的合理性
238
+ - 重视证据和论证的严谨性
239
+ - 帮助完善和改进想法
240
+ 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
241
+ 1. 用中文进行所有回应
242
+ 2. 以你的名字Critic的身份进行思考和回应
243
+ 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
244
+ 4. 以建设性方式提出批判,而非单纯否定
245
+ 5. 在质疑的同时提供改进建议
246
+ 记住:你是Critic,独一无二的批判思维型助手。`,
247
+ modelId: 'gpt-4-mini-default',
248
+ isActive: true
249
+ },
250
+
251
+ //Zen - 哲学沉思型
252
+ {
253
+ id: 'zen-default',
254
+ name: 'Zen',
255
+ systemPrompt: `你是Zen,一位深邃的哲学沉思型AI助手。只有你叫Zen这个名字,你的独特特征包括:
256
+ - 探索事物的本质和深层意义
257
+ - 提出富有哲理的问题引发思考
258
+ - 保持超然和平和的视角
259
+ - 关注存在、意义和价值等根本问题
260
+ - 用简洁而深刻的方式表达观点
261
+ 在多AI协作讨论环境中,你与其他AI角色平等协作,各自发挥专长。请始终:
262
+ 1. 用中文进行所有回应
263
+ 2. 以你的名字Zen的身份进行思考和回应
264
+ 3. 与其他AI角色进行建设性对话,避免使用"你们"等不当称呼
265
+ 4. 引导讨论触及更深层的哲学思考
266
+ 5. 以宁静智慧的方式分享洞察
267
+ 记住:你是Zen,独一无二的哲学沉思型助手。`,
268
+ modelId: 'gpt-4-mini-default',
269
+ isActive: true
270
+ }
271
+ ];
272
+
273
+ // 配置管理类
274
+ export class ModelConfigManager {
275
+ private static STORAGE_KEY_CHANNELS = 'multi-mind-chat-channels';
276
+ private static STORAGE_KEY_MODELS = 'multi-mind-chat-models';
277
+ private static STORAGE_KEY_ROLES = 'multi-mind-chat-roles';
278
+ private static STORAGE_KEY_INITIALIZED = 'multi-mind-chat-initialized';
279
+
280
+ // 初始化检查
281
+ private static ensureInitialized(): void {
282
+ try {
283
+ const isInitialized = localStorage.getItem(this.STORAGE_KEY_INITIALIZED);
284
+ if (!isInitialized) {
285
+ localStorage.setItem(this.STORAGE_KEY_CHANNELS, JSON.stringify(DEFAULT_CHANNELS));
286
+ localStorage.setItem(this.STORAGE_KEY_MODELS, JSON.stringify(DEFAULT_MODELS));
287
+ localStorage.setItem(this.STORAGE_KEY_ROLES, JSON.stringify(DEFAULT_ROLES));
288
+ localStorage.setItem(this.STORAGE_KEY_INITIALIZED, 'true');
289
+ }
290
+ } catch (error) {
291
+ console.warn('无法访问localStorage,将使用内存存储:', error);
292
+ }
293
+ }
294
+
295
+ // ============ 渠道管理 ============
296
+
297
+ // 获取所有渠道
298
+ static getChannels(): ApiChannel[] {
299
+ this.ensureInitialized();
300
+ try {
301
+ const stored = localStorage.getItem(this.STORAGE_KEY_CHANNELS);
302
+ if (stored) {
303
+ const parsed = JSON.parse(stored);
304
+ return parsed.map((channel: any) => ({
305
+ ...channel,
306
+ createdAt: new Date(channel.createdAt)
307
+ }));
308
+ }
309
+ } catch (error) {
310
+ console.warn('从localStorage加载渠道失败:', error);
311
+ }
312
+ return [...DEFAULT_CHANNELS];
313
+ }
314
+
315
+ // 保存渠道配置
316
+ static saveChannels(channels: ApiChannel[]): void {
317
+ try {
318
+ localStorage.setItem(this.STORAGE_KEY_CHANNELS, JSON.stringify(channels));
319
+ } catch (error) {
320
+ console.error('保存渠道到localStorage失败:', error);
321
+ throw new Error('无法保存渠道配置');
322
+ }
323
+ }
324
+
325
+ // 添加新渠道
326
+ static addChannel(channel: Omit<ApiChannel, 'id' | 'createdAt' | 'isCustom'>): ApiChannel {
327
+ const newChannel: ApiChannel = {
328
+ ...channel,
329
+ id: `channel-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
330
+ createdAt: new Date(),
331
+ isCustom: true
332
+ };
333
+
334
+ const channels = this.getChannels();
335
+
336
+ // 如果这是第一个渠道或设置为默认,清除其他默认标记
337
+ if (newChannel.isDefault || channels.length === 0) {
338
+ channels.forEach(ch => ch.isDefault = false);
339
+ newChannel.isDefault = true;
340
+ }
341
+
342
+ channels.push(newChannel);
343
+ this.saveChannels(channels);
344
+ return newChannel;
345
+ }
346
+
347
+ // 更新渠道
348
+ static updateChannel(id: string, updates: Partial<ApiChannel>): void {
349
+ const channels = this.getChannels();
350
+ const index = channels.findIndex(ch => ch.id === id);
351
+ if (index !== -1) {
352
+ // 如果设置为默认,清除其他默认标记
353
+ if (updates.isDefault) {
354
+ channels.forEach(ch => ch.isDefault = false);
355
+ }
356
+ channels[index] = { ...channels[index], ...updates };
357
+ this.saveChannels(channels);
358
+ }
359
+ }
360
+
361
+ // 删除渠道
362
+ static deleteChannel(id: string): void {
363
+ const channels = this.getChannels();
364
+ const filtered = channels.filter(ch => ch.id !== id);
365
+
366
+ // 如果删除的是默认渠道,设置第一个为默认
367
+ const deletedChannel = channels.find(ch => ch.id === id);
368
+ if (deletedChannel?.isDefault && filtered.length > 0) {
369
+ filtered[0].isDefault = true;
370
+ }
371
+
372
+ this.saveChannels(filtered);
373
+ }
374
+
375
+ // 获取默认渠道
376
+ static getDefaultChannel(): ApiChannel | null {
377
+ const channels = this.getChannels();
378
+ return channels.find(ch => ch.isDefault) || channels[0] || null;
379
+ }
380
+
381
+ // 根据ID获取渠道
382
+ static getChannelById(id: string): ApiChannel | null {
383
+ const channels = this.getChannels();
384
+ return channels.find(ch => ch.id === id) || null;
385
+ }
386
+
387
+ // 验证渠道配置
388
+ static validateChannel(channel: Partial<ApiChannel>): string[] {
389
+ const errors: string[] = [];
390
+
391
+ if (!channel.name?.trim()) {
392
+ errors.push('渠道名称不能为空');
393
+ }
394
+
395
+ if (!channel.baseUrl?.trim()) {
396
+ errors.push('API基础URL不能为空');
397
+ } else {
398
+ try {
399
+ new URL(channel.baseUrl);
400
+ } catch {
401
+ errors.push('API基础URL格式无效');
402
+ }
403
+ }
404
+
405
+ // 对于预置渠道,API密钥可以为空(将在使用时提醒用户配置)
406
+ // 对于自定义渠道,仍然要求API密钥
407
+ if (channel.isCustom && !channel.apiKey?.trim()) {
408
+ errors.push('API密钥不能为空');
409
+ }
410
+
411
+ if (channel.timeout && (typeof channel.timeout !== 'number' || channel.timeout < 1000)) {
412
+ errors.push('超时时间必须是大于1000毫秒的数字');
413
+ }
414
+
415
+ return errors;
416
+ }
417
+
418
+ // ============ 模型管理 ============
419
+
420
+ // 获取所有模型
421
+ static getModels(): AiModel[] {
422
+ this.ensureInitialized();
423
+ try {
424
+ const stored = localStorage.getItem(this.STORAGE_KEY_MODELS);
425
+ if (stored) {
426
+ const parsed = JSON.parse(stored);
427
+ return parsed.map((model: any) => ({
428
+ ...model,
429
+ createdAt: new Date(model.createdAt)
430
+ }));
431
+ }
432
+ } catch (error) {
433
+ console.warn('从localStorage加载模型失败:', error);
434
+ }
435
+ return [...DEFAULT_MODELS];
436
+ }
437
+
438
+ // 保存模型配置
439
+ static saveModels(models: AiModel[]): void {
440
+ try {
441
+ localStorage.setItem(this.STORAGE_KEY_MODELS, JSON.stringify(models));
442
+ } catch (error) {
443
+ console.error('保存模型到localStorage失败:', error);
444
+ throw new Error('无法保存模型配置');
445
+ }
446
+ }
447
+
448
+ // 添加新模型
449
+ static addModel(model: Omit<AiModel, 'id' | 'createdAt' | 'isCustom'>): AiModel {
450
+ const newModel: AiModel = {
451
+ ...model,
452
+ id: `model-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
453
+ createdAt: new Date(),
454
+ isCustom: true
455
+ };
456
+
457
+ const models = this.getModels();
458
+ models.push(newModel);
459
+ this.saveModels(models);
460
+ return newModel;
461
+ }
462
+
463
+ // 更新模型
464
+ static updateModel(id: string, updates: Partial<AiModel>): void {
465
+ const models = this.getModels();
466
+ const index = models.findIndex(m => m.id === id);
467
+ if (index !== -1) {
468
+ models[index] = { ...models[index], ...updates };
469
+ this.saveModels(models);
470
+ }
471
+ }
472
+
473
+ // 删除模型
474
+ static deleteModel(id: string): void {
475
+ const models = this.getModels();
476
+ const filtered = models.filter(m => m.id !== id);
477
+ this.saveModels(filtered);
478
+ }
479
+
480
+ // 验证模型配置
481
+ static validateModel(model: Partial<AiModel>): string[] {
482
+ const errors: string[] = [];
483
+
484
+ if (!model.name?.trim()) {
485
+ errors.push('模型名称不能为空');
486
+ }
487
+
488
+ if (!model.apiName?.trim()) {
489
+ errors.push('API模型名称不能为空');
490
+ }
491
+
492
+ if (!model.channelId?.trim()) {
493
+ errors.push('必须选择API渠道');
494
+ }
495
+
496
+ if (!model.category?.trim()) {
497
+ errors.push('模型类别不能为空');
498
+ }
499
+
500
+ if (typeof model.maxTokens !== 'number' || model.maxTokens < 1) {
501
+ errors.push('最大Token数必须是大于0的数字');
502
+ }
503
+
504
+ if (typeof model.temperature !== 'number' || model.temperature < 0 || model.temperature > 2) {
505
+ errors.push('温度参数必须在0-2之间');
506
+ }
507
+
508
+ return errors;
509
+ }
510
+
511
+ // ============ 角色管理 ============
512
+
513
+ // 获取所有角色
514
+ static getRoles(): AiRole[] {
515
+ this.ensureInitialized();
516
+ try {
517
+ const stored = localStorage.getItem(this.STORAGE_KEY_ROLES);
518
+ if (stored) {
519
+ return JSON.parse(stored);
520
+ }
521
+ } catch (error) {
522
+ console.warn('从localStorage加载角色失败:', error);
523
+ }
524
+ return [...DEFAULT_ROLES];
525
+ }
526
+
527
+ // 保存角色配置
528
+ static saveRoles(roles: AiRole[]): void {
529
+ try {
530
+ localStorage.setItem(this.STORAGE_KEY_ROLES, JSON.stringify(roles));
531
+ } catch (error) {
532
+ console.error('保存角色到localStorage失败:', error);
533
+ throw new Error('无法保存角色配置');
534
+ }
535
+ }
536
+
537
+ // 添加新角色
538
+ static addRole(role: Omit<AiRole, 'id'>): AiRole {
539
+ const newRole: AiRole = {
540
+ ...role,
541
+ id: `role-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
542
+ };
543
+
544
+ const roles = this.getRoles();
545
+ roles.push(newRole);
546
+ this.saveRoles(roles);
547
+ return newRole;
548
+ }
549
+
550
+ // 更新角色
551
+ static updateRole(id: string, updates: Partial<AiRole>): void {
552
+ const roles = this.getRoles();
553
+ const index = roles.findIndex(r => r.id === id);
554
+ if (index !== -1) {
555
+ roles[index] = { ...roles[index], ...updates };
556
+ this.saveRoles(roles);
557
+ }
558
+ }
559
+
560
+ // 删除角色
561
+ static deleteRole(id: string): void {
562
+ const roles = this.getRoles();
563
+ const filtered = roles.filter(r => r.id !== id);
564
+ this.saveRoles(filtered);
565
+ }
566
+
567
+ // 获取活跃角色
568
+ static getActiveRoles(): AiRole[] {
569
+ return this.getRoles().filter(role => role.isActive);
570
+ }
571
+
572
+ // ============ 工具方法 ============
573
+
574
+ // 根据类别分组模型
575
+ static getModelsByCategory(): Record<string, AiModel[]> {
576
+ const models = this.getModels();
577
+ return models.reduce((acc, model) => {
578
+ if (!acc[model.category]) {
579
+ acc[model.category] = [];
580
+ }
581
+ acc[model.category].push(model);
582
+ return acc;
583
+ }, {} as Record<string, AiModel[]>);
584
+ }
585
+
586
+ // 重置为默认配置
587
+ static resetToDefaults(): void {
588
+ try {
589
+ localStorage.removeItem(this.STORAGE_KEY_CHANNELS);
590
+ localStorage.removeItem(this.STORAGE_KEY_MODELS);
591
+ localStorage.removeItem(this.STORAGE_KEY_ROLES);
592
+ localStorage.removeItem(this.STORAGE_KEY_INITIALIZED);
593
+ this.ensureInitialized();
594
+ } catch (error) {
595
+ console.error('重置配置失败:', error);
596
+ throw new Error('无法重置配置');
597
+ }
598
+ }
599
+
600
+ // 导出配置
601
+ static exportConfig(): string {
602
+ return JSON.stringify({
603
+ channels: this.getChannels(),
604
+ models: this.getModels(),
605
+ roles: this.getRoles(),
606
+ exportedAt: new Date().toISOString(),
607
+ version: '2.0'
608
+ }, null, 2);
609
+ }
610
+
611
+ // 导入配置
612
+ static importConfig(configJson: string): { success: boolean; message: string } {
613
+ try {
614
+ const config = JSON.parse(configJson);
615
+
616
+ if (!config.channels && !config.models && !config.roles) {
617
+ return { success: false, message: '配置文件格式无效,缺少必要的配置信息' };
618
+ }
619
+
620
+ if (config.channels && Array.isArray(config.channels)) {
621
+ const validChannels = config.channels.filter((channel: any) => {
622
+ const errors = this.validateChannel(channel);
623
+ return errors.length === 0;
624
+ });
625
+
626
+ if (validChannels.length > 0) {
627
+ const processedChannels = validChannels.map((channel: any) => ({
628
+ ...channel,
629
+ createdAt: new Date(channel.createdAt || new Date()),
630
+ isCustom: channel.isCustom !== false
631
+ }));
632
+ this.saveChannels(processedChannels);
633
+ }
634
+ }
635
+
636
+ if (config.models && Array.isArray(config.models)) {
637
+ const validModels = config.models.filter((model: any) => {
638
+ const errors = this.validateModel(model);
639
+ return errors.length === 0;
640
+ });
641
+
642
+ if (validModels.length > 0) {
643
+ const processedModels = validModels.map((model: any) => ({
644
+ ...model,
645
+ createdAt: new Date(model.createdAt || new Date()),
646
+ isCustom: model.isCustom !== false
647
+ }));
648
+ this.saveModels(processedModels);
649
+ }
650
+ }
651
+
652
+ if (config.roles && Array.isArray(config.roles)) {
653
+ this.saveRoles(config.roles);
654
+ }
655
+
656
+ return { success: true, message: '配置导入成功' };
657
+ } catch (error) {
658
+ return { success: false, message: `配置导入失败: ${error instanceof Error ? error.message : '未知错误'}` };
659
+ }
660
+ }
661
+
662
+ // 清空所有数据
663
+ static clearAllData(): void {
664
+ try {
665
+ localStorage.removeItem(this.STORAGE_KEY_CHANNELS);
666
+ localStorage.removeItem(this.STORAGE_KEY_MODELS);
667
+ localStorage.removeItem(this.STORAGE_KEY_ROLES);
668
+ localStorage.removeItem(this.STORAGE_KEY_INITIALIZED);
669
+ } catch (error) {
670
+ console.error('清空数据失败:', error);
671
+ throw new Error('无法清空配置数据');
672
+ }
673
+ }
674
+
675
+ // 获取存储使用情况
676
+ static getStorageInfo(): { used: number; available: number; channels: number; models: number; roles: number } {
677
+ try {
678
+ const channelsData = localStorage.getItem(this.STORAGE_KEY_CHANNELS) || '';
679
+ const modelsData = localStorage.getItem(this.STORAGE_KEY_MODELS) || '';
680
+ const rolesData = localStorage.getItem(this.STORAGE_KEY_ROLES) || '';
681
+ const used = channelsData.length + modelsData.length + rolesData.length;
682
+
683
+ return {
684
+ used,
685
+ available: 5242880 - used, // 5MB 大致容量
686
+ channels: this.getChannels().length,
687
+ models: this.getModels().length,
688
+ roles: this.getRoles().length
689
+ };
690
+ } catch (error) {
691
+ return { used: 0, available: 0, channels: 0, models: 0, roles: 0 };
692
+ }
693
+ }
694
+ }
695
+
696
+ // 其他常量配置
697
+ export const DEFAULT_MANUAL_FIXED_TURNS = 2;
698
+ export const MIN_MANUAL_FIXED_TURNS = 1;
699
+ export const MAX_MANUAL_FIXED_TURNS = 5;
700
+ export const MAX_AI_DRIVEN_DISCUSSION_TURNS_PER_MODEL = 3;
701
+
702
+ export const INITIAL_NOTEPAD_CONTENT = `这是一个共享记事本。
703
+ AI角色可以在这里合作记录想法、草稿或关键点。
704
+
705
+ 使用指南:
706
+ - AI 模型可以通过在其回复中包含特定指令来更新此记事本。
707
+ - 记事本的内容将包含在发送给 AI 的后续提示中。
708
+
709
+ 初始状态:空白。`;
710
+
711
+ export const NOTEPAD_INSTRUCTION_PROMPT_PART = `
712
+ You also have access to a shared notepad.
713
+ Current Notepad Content:
714
+ ---
715
+ {notepadContent}
716
+ ---
717
+ Instructions for Notepad:
718
+ 1. To update the notepad, include a section at the very end of your response, formatted exactly as:
719
+ <notepad_update>
720
+ [YOUR NEW FULL NOTEPAD CONTENT HERE. THIS WILL REPLACE THE ENTIRE CURRENT NOTEPAD CONTENT.]
721
+ </notepad_update>
722
+ 2. If you do not want to change the notepad, do NOT include the <notepad_update> section at all.
723
+ 3. Your primary spoken response to the ongoing discussion should come BEFORE any <notepad_update> section. Ensure you still provide a spoken response.
724
+ `;
725
+
726
+ export const NOTEPAD_UPDATE_TAG_START = "<notepad_update>";
727
+ export const NOTEPAD_UPDATE_TAG_END = "</notepad_update>";
728
+ export const DISCUSSION_COMPLETE_TAG = "<discussion_complete />";
729
+
730
+ export const AI_DRIVEN_DISCUSSION_INSTRUCTION_PROMPT_PART = `
731
+ Instruction for ending discussion: If you believe the current topic has been sufficiently explored between you and your AI partner for the final synthesis, include the exact tag ${DISCUSSION_COMPLETE_TAG} at the very end of your current message (after any notepad update). Do not use this tag if you wish to continue the discussion or require more input/response from your partner.
732
+ `;
733
+
734
+ export enum DiscussionMode {
735
+ FixedTurns = 'fixed',
736
+ AiDriven = 'ai-driven',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
737
  }
index.html CHANGED
@@ -1,197 +1,197 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Multi-Mind Chat 智囊团</title>
7
- <script src="https://cdn.tailwindcss.com"></script>
8
- <style>
9
- /* Custom scrollbar for webkit browsers */
10
- ::-webkit-scrollbar {
11
- width: 8px;
12
- }
13
- ::-webkit-scrollbar-track {
14
- background: #f1f1f1;
15
- border-radius: 10px;
16
- }
17
- ::-webkit-scrollbar-thumb {
18
- background: #888;
19
- border-radius: 10px;
20
- }
21
- ::-webkit-scrollbar-thumb:hover {
22
- background: #555;
23
- }
24
- body {
25
- font-family: 'Inter', sans-serif; /* A common sans-serif font often used with Tailwind */
26
- }
27
- /* Basic styles for Markdown preview in Notepad */
28
- .markdown-preview {
29
- padding: 0.75rem; /* Corresponds to p-3 in Tailwind */
30
- color: #d1d5db; /* text-gray-300 */
31
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; /* font-mono */
32
- font-size: 0.875rem; /* text-sm */
33
- line-height: 1.625; /* leading-relaxed */
34
- background-color: #1f2937; /* bg-slate-800 or similar dark bg */
35
- height: 100%;
36
- overflow-y: auto;
37
- }
38
- .markdown-preview h1,
39
- .markdown-preview h2,
40
- .markdown-preview h3,
41
- .markdown-preview h4,
42
- .markdown-preview h5,
43
- .markdown-preview h6 {
44
- color: #93c5fd; /* A lighter blue for headings */
45
- margin-top: 1em;
46
- margin-bottom: 0.5em;
47
- font-weight: 600;
48
- }
49
- .markdown-preview h1 { font-size: 1.875em; }
50
- .markdown-preview h2 { font-size: 1.5em; }
51
- .markdown-preview h3 { font-size: 1.25em; }
52
- .markdown-preview p {
53
- margin-bottom: 0.75em;
54
- }
55
- .markdown-preview ul,
56
- .markdown-preview ol {
57
- margin-left: 1.5em;
58
- margin-bottom: 0.75em;
59
- list-style-position: outside;
60
- }
61
- .markdown-preview ul { list-style-type: disc; }
62
- .markdown-preview ol { list-style-type: decimal; }
63
- .markdown-preview li { margin-bottom: 0.25em; }
64
- .markdown-preview blockquote {
65
- border-left: 4px solid #6b7280; /* border-gray-500 */
66
- padding-left: 1em;
67
- margin-left: 0;
68
- margin-bottom: 0.75em;
69
- color: #9ca3af; /* text-gray-400 */
70
- font-style: italic;
71
- }
72
- .markdown-preview pre {
73
- background-color: #374151; /* bg-gray-700 */
74
- padding: 0.75em;
75
- border-radius: 0.375rem; /* rounded-md */
76
- overflow-x: auto;
77
- margin-bottom: 0.75em;
78
- color: #e5e7eb; /* text-gray-200 */
79
- }
80
- .markdown-preview code {
81
- background-color: #4b5563; /* bg-gray-600 */
82
- padding: 0.2em 0.4em;
83
- border-radius: 0.25rem;
84
- font-size: 0.9em;
85
- }
86
- .markdown-preview pre code {
87
- background-color: transparent;
88
- padding: 0;
89
- font-size: 1em;
90
- }
91
- .markdown-preview a {
92
- color: #60a5fa; /* text-blue-400 */
93
- text-decoration: underline;
94
- }
95
- .markdown-preview hr {
96
- border-top: 1px solid #4b5563; /* border-gray-600 */
97
- margin: 1em 0;
98
- }
99
-
100
- /* Styles for Markdown content within chat bubbles */
101
- .chat-markdown-content {
102
- /* Base text color and size are applied by Tailwind class on the div */
103
- /* e.g., text-sm text-gray-200 */
104
- line-height: 1.625; /* leading-relaxed */
105
- }
106
- .chat-markdown-content h1,
107
- .chat-markdown-content h2,
108
- .chat-markdown-content h3,
109
- .chat-markdown-content h4,
110
- .chat-markdown-content h5,
111
- .chat-markdown-content h6 {
112
- color: #bae6fd; /* light-sky-300, slightly brighter for chat */
113
- margin-top: 0.75em;
114
- margin-bottom: 0.4em;
115
- font-weight: 600;
116
- line-height: 1.3;
117
- }
118
- .chat-markdown-content h1 { font-size: 1.5em; } /* Adjusted for chat context */
119
- .chat-markdown-content h2 { font-size: 1.3em; }
120
- .chat-markdown-content h3 { font-size: 1.15em; }
121
- .chat-markdown-content p {
122
- margin-bottom: 0.65em;
123
- }
124
- .chat-markdown-content ul,
125
- .chat-markdown-content ol {
126
- margin-left: 1.25em; /* Slightly less indent for chat */
127
- margin-bottom: 0.65em;
128
- list-style-position: outside;
129
- }
130
- .chat-markdown-content ul { list-style-type: disc; }
131
- .chat-markdown-content ol { list-style-type: decimal; }
132
- .chat-markdown-content li { margin-bottom: 0.2em; }
133
- .chat-markdown-content blockquote {
134
- border-left: 3px solid #4b5563; /* border-gray-600 */
135
- padding-left: 0.75em;
136
- margin-left: 0;
137
- margin-bottom: 0.65em;
138
- color: #9ca3af; /* text-gray-400 */
139
- font-style: italic;
140
- }
141
- .chat-markdown-content pre {
142
- background-color: #1e293b; /* slate-800, distinct from bubble */
143
- padding: 0.65em;
144
- border-radius: 0.25rem; /* rounded-sm */
145
- overflow-x: auto;
146
- margin-bottom: 0.65em;
147
- color: #e2e8f0; /* slate-200 */
148
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
149
- font-size: 0.875rem; /* text-sm */
150
- }
151
- .chat-markdown-content code { /* Inline code */
152
- background-color: #334155; /* slate-700 */
153
- color: #e2e8f0; /* slate-200 */
154
- padding: 0.15em 0.3em;
155
- border-radius: 0.2rem;
156
- font-size: 0.85em; /* Slightly smaller */
157
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
158
- }
159
- .chat-markdown-content pre code { /* Code within pre blocks */
160
- background-color: transparent;
161
- padding: 0;
162
- font-size: 1em; /* Reset from inline code */
163
- color: inherit; /* Inherit from pre */
164
- }
165
- .chat-markdown-content a {
166
- color: #38bdf8; /* sky-400 */
167
- text-decoration: underline;
168
- }
169
- .chat-markdown-content a:hover {
170
- color: #7dd3fc; /* sky-300 */
171
- }
172
- .chat-markdown-content hr {
173
- border-top: 1px solid #334155; /* slate-700 */
174
- margin: 0.75em 0;
175
- }
176
-
177
- </style>
178
- <script type="importmap">
179
- {
180
- "imports": {
181
- "react": "https://esm.sh/react@^19.1.0",
182
- "react-dom/": "https://esm.sh/react-dom@^19.1.0/",
183
- "react/": "https://esm.sh/react@^19.1.0/",
184
- "@google/genai": "https://esm.sh/@google/genai@^1.0.1",
185
- "lucide-react": "https://esm.sh/lucide-react@^0.511.0",
186
- "marked": "https://esm.sh/marked@^13.0.2",
187
- "dompurify": "https://esm.sh/dompurify@^3.1.6"
188
- }
189
- }
190
- </script>
191
- </head>
192
- <body class="bg-gray-900 text-white">
193
- <div id="root"></div>
194
- <script type="module" src="/index.tsx"></script>
195
- </body>
196
- </html><link rel="stylesheet" href="index.css">
197
- <script src="index.tsx" type="module"></script>
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Multi-Mind Chat 智囊团</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <style>
9
+ /* Custom scrollbar for webkit browsers */
10
+ ::-webkit-scrollbar {
11
+ width: 8px;
12
+ }
13
+ ::-webkit-scrollbar-track {
14
+ background: #f1f1f1;
15
+ border-radius: 10px;
16
+ }
17
+ ::-webkit-scrollbar-thumb {
18
+ background: #888;
19
+ border-radius: 10px;
20
+ }
21
+ ::-webkit-scrollbar-thumb:hover {
22
+ background: #555;
23
+ }
24
+ body {
25
+ font-family: 'Inter', sans-serif; /* A common sans-serif font often used with Tailwind */
26
+ }
27
+ /* Basic styles for Markdown preview in Notepad */
28
+ .markdown-preview {
29
+ padding: 0.75rem; /* Corresponds to p-3 in Tailwind */
30
+ color: #d1d5db; /* text-gray-300 */
31
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; /* font-mono */
32
+ font-size: 0.875rem; /* text-sm */
33
+ line-height: 1.625; /* leading-relaxed */
34
+ background-color: #1f2937; /* bg-slate-800 or similar dark bg */
35
+ height: 100%;
36
+ overflow-y: auto;
37
+ }
38
+ .markdown-preview h1,
39
+ .markdown-preview h2,
40
+ .markdown-preview h3,
41
+ .markdown-preview h4,
42
+ .markdown-preview h5,
43
+ .markdown-preview h6 {
44
+ color: #93c5fd; /* A lighter blue for headings */
45
+ margin-top: 1em;
46
+ margin-bottom: 0.5em;
47
+ font-weight: 600;
48
+ }
49
+ .markdown-preview h1 { font-size: 1.875em; }
50
+ .markdown-preview h2 { font-size: 1.5em; }
51
+ .markdown-preview h3 { font-size: 1.25em; }
52
+ .markdown-preview p {
53
+ margin-bottom: 0.75em;
54
+ }
55
+ .markdown-preview ul,
56
+ .markdown-preview ol {
57
+ margin-left: 1.5em;
58
+ margin-bottom: 0.75em;
59
+ list-style-position: outside;
60
+ }
61
+ .markdown-preview ul { list-style-type: disc; }
62
+ .markdown-preview ol { list-style-type: decimal; }
63
+ .markdown-preview li { margin-bottom: 0.25em; }
64
+ .markdown-preview blockquote {
65
+ border-left: 4px solid #6b7280; /* border-gray-500 */
66
+ padding-left: 1em;
67
+ margin-left: 0;
68
+ margin-bottom: 0.75em;
69
+ color: #9ca3af; /* text-gray-400 */
70
+ font-style: italic;
71
+ }
72
+ .markdown-preview pre {
73
+ background-color: #374151; /* bg-gray-700 */
74
+ padding: 0.75em;
75
+ border-radius: 0.375rem; /* rounded-md */
76
+ overflow-x: auto;
77
+ margin-bottom: 0.75em;
78
+ color: #e5e7eb; /* text-gray-200 */
79
+ }
80
+ .markdown-preview code {
81
+ background-color: #4b5563; /* bg-gray-600 */
82
+ padding: 0.2em 0.4em;
83
+ border-radius: 0.25rem;
84
+ font-size: 0.9em;
85
+ }
86
+ .markdown-preview pre code {
87
+ background-color: transparent;
88
+ padding: 0;
89
+ font-size: 1em;
90
+ }
91
+ .markdown-preview a {
92
+ color: #60a5fa; /* text-blue-400 */
93
+ text-decoration: underline;
94
+ }
95
+ .markdown-preview hr {
96
+ border-top: 1px solid #4b5563; /* border-gray-600 */
97
+ margin: 1em 0;
98
+ }
99
+
100
+ /* Styles for Markdown content within chat bubbles */
101
+ .chat-markdown-content {
102
+ /* Base text color and size are applied by Tailwind class on the div */
103
+ /* e.g., text-sm text-gray-200 */
104
+ line-height: 1.625; /* leading-relaxed */
105
+ }
106
+ .chat-markdown-content h1,
107
+ .chat-markdown-content h2,
108
+ .chat-markdown-content h3,
109
+ .chat-markdown-content h4,
110
+ .chat-markdown-content h5,
111
+ .chat-markdown-content h6 {
112
+ color: #bae6fd; /* light-sky-300, slightly brighter for chat */
113
+ margin-top: 0.75em;
114
+ margin-bottom: 0.4em;
115
+ font-weight: 600;
116
+ line-height: 1.3;
117
+ }
118
+ .chat-markdown-content h1 { font-size: 1.5em; } /* Adjusted for chat context */
119
+ .chat-markdown-content h2 { font-size: 1.3em; }
120
+ .chat-markdown-content h3 { font-size: 1.15em; }
121
+ .chat-markdown-content p {
122
+ margin-bottom: 0.65em;
123
+ }
124
+ .chat-markdown-content ul,
125
+ .chat-markdown-content ol {
126
+ margin-left: 1.25em; /* Slightly less indent for chat */
127
+ margin-bottom: 0.65em;
128
+ list-style-position: outside;
129
+ }
130
+ .chat-markdown-content ul { list-style-type: disc; }
131
+ .chat-markdown-content ol { list-style-type: decimal; }
132
+ .chat-markdown-content li { margin-bottom: 0.2em; }
133
+ .chat-markdown-content blockquote {
134
+ border-left: 3px solid #4b5563; /* border-gray-600 */
135
+ padding-left: 0.75em;
136
+ margin-left: 0;
137
+ margin-bottom: 0.65em;
138
+ color: #9ca3af; /* text-gray-400 */
139
+ font-style: italic;
140
+ }
141
+ .chat-markdown-content pre {
142
+ background-color: #1e293b; /* slate-800, distinct from bubble */
143
+ padding: 0.65em;
144
+ border-radius: 0.25rem; /* rounded-sm */
145
+ overflow-x: auto;
146
+ margin-bottom: 0.65em;
147
+ color: #e2e8f0; /* slate-200 */
148
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
149
+ font-size: 0.875rem; /* text-sm */
150
+ }
151
+ .chat-markdown-content code { /* Inline code */
152
+ background-color: #334155; /* slate-700 */
153
+ color: #e2e8f0; /* slate-200 */
154
+ padding: 0.15em 0.3em;
155
+ border-radius: 0.2rem;
156
+ font-size: 0.85em; /* Slightly smaller */
157
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
158
+ }
159
+ .chat-markdown-content pre code { /* Code within pre blocks */
160
+ background-color: transparent;
161
+ padding: 0;
162
+ font-size: 1em; /* Reset from inline code */
163
+ color: inherit; /* Inherit from pre */
164
+ }
165
+ .chat-markdown-content a {
166
+ color: #38bdf8; /* sky-400 */
167
+ text-decoration: underline;
168
+ }
169
+ .chat-markdown-content a:hover {
170
+ color: #7dd3fc; /* sky-300 */
171
+ }
172
+ .chat-markdown-content hr {
173
+ border-top: 1px solid #334155; /* slate-700 */
174
+ margin: 0.75em 0;
175
+ }
176
+
177
+ </style>
178
+ <script type="importmap">
179
+ {
180
+ "imports": {
181
+ "react": "https://esm.sh/react@^19.1.0",
182
+ "react-dom/": "https://esm.sh/react-dom@^19.1.0/",
183
+ "react/": "https://esm.sh/react@^19.1.0/",
184
+ "@google/genai": "https://esm.sh/@google/genai@^1.0.1",
185
+ "lucide-react": "https://esm.sh/lucide-react@^0.511.0",
186
+ "marked": "https://esm.sh/marked@^13.0.2",
187
+ "dompurify": "https://esm.sh/dompurify@^3.1.6"
188
+ }
189
+ }
190
+ </script>
191
+ </head>
192
+ <body class="bg-gray-900 text-white">
193
+ <div id="root"></div>
194
+ <script type="module" src="/index.tsx"></script>
195
+ </body>
196
+ </html><link rel="stylesheet" href="index.css">
197
+ <script src="index.tsx" type="module"></script>
index.tsx CHANGED
@@ -1,17 +1,17 @@
1
-
2
- import React from 'react';
3
- import ReactDOM from 'react-dom/client';
4
- import App from './App';
5
-
6
- const rootElement = document.getElementById('root');
7
- if (!rootElement) {
8
- throw new Error("Could not find root element to mount to");
9
- }
10
-
11
- const root = ReactDOM.createRoot(rootElement);
12
- root.render(
13
- <React.StrictMode>
14
- <App />
15
- </React.StrictMode>
16
- );
17
 
 
1
+
2
+ import React from 'react';
3
+ import ReactDOM from 'react-dom/client';
4
+ import App from './App';
5
+
6
+ const rootElement = document.getElementById('root');
7
+ if (!rootElement) {
8
+ throw new Error("Could not find root element to mount to");
9
+ }
10
+
11
+ const root = ReactDOM.createRoot(rootElement);
12
+ root.render(
13
+ <React.StrictMode>
14
+ <App />
15
+ </React.StrictMode>
16
+ );
17
 
metadata.json CHANGED
@@ -1,6 +1,6 @@
1
- {
2
- "name": "Multi-Mind Chat 智囊团(融合3)",
3
- "description": "A chat application where user messages are discussed by two AI models (Cognito and Muse). Features a shared notepad that both AIs can read from and write to, with the entire conversation and notepad changes displayed.",
4
- "requestFramePermissions": [],
5
- "prompt": ""
6
  }
 
1
+ {
2
+ "name": "Multi-Mind Chat 智囊团(融合3)",
3
+ "description": "A chat application where user messages are discussed by two AI models (Cognito and Muse). Features a shared notepad that both AIs can read from and write to, with the entire conversation and notepad changes displayed.",
4
+ "requestFramePermissions": [],
5
+ "prompt": ""
6
  }
package.json CHANGED
@@ -1,49 +1,49 @@
1
- {
2
- "name": "multi-mind-chat",
3
- "private": true,
4
- "version": "1.0.0",
5
- "type": "module",
6
- "description": "A multi-AI collaboration chat application with preset channels for immediate use",
7
- "scripts": {
8
- "dev": "vite",
9
- "build": "vite build",
10
- "preview": "vite preview",
11
- "docker:build": "./build.sh",
12
- "docker:run": "docker run -d -p 7860:7860 --name multi-mind-chat --restart unless-stopped multi-mind-chat:latest",
13
- "docker:stop": "docker stop multi-mind-chat && docker rm multi-mind-chat",
14
- "docker:logs": "docker logs -f multi-mind-chat"
15
- },
16
- "dependencies": {
17
- "@google/genai": "^1.0.1",
18
- "dompurify": "^3.1.6",
19
- "lucide-react": "^0.511.0",
20
- "marked": "^13.0.2",
21
- "react": "^19.1.0",
22
- "react-dom": "^19.1.0"
23
- },
24
- "devDependencies": {
25
- "@types/node": "^22.14.0",
26
- "@types/react": "^19.1.5",
27
- "typescript": "~5.7.2",
28
- "vite": "^6.2.0"
29
- },
30
- "keywords": [
31
- "ai",
32
- "chat",
33
- "multi-ai",
34
- "collaboration",
35
- "openai",
36
- "gpt",
37
- "react",
38
- "typescript"
39
- ],
40
- "author": "Multi-Mind Chat Team",
41
- "license": "MIT",
42
- "repository": {
43
- "type": "git",
44
- "url": "https://github.com/zhanghxiao/multi-mind-chat.git"
45
- },
46
- "engines": {
47
- "node": ">=18.0.0"
48
- }
49
  }
 
1
+ {
2
+ "name": "multi-mind-chat",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "description": "A multi-AI collaboration chat application with preset channels for immediate use",
7
+ "scripts": {
8
+ "dev": "vite",
9
+ "build": "vite build",
10
+ "preview": "vite preview",
11
+ "docker:build": "./build.sh",
12
+ "docker:run": "docker run -d -p 7860:7860 --name multi-mind-chat --restart unless-stopped multi-mind-chat:latest",
13
+ "docker:stop": "docker stop multi-mind-chat && docker rm multi-mind-chat",
14
+ "docker:logs": "docker logs -f multi-mind-chat"
15
+ },
16
+ "dependencies": {
17
+ "@google/genai": "^1.0.1",
18
+ "dompurify": "^3.1.6",
19
+ "lucide-react": "^0.511.0",
20
+ "marked": "^13.0.2",
21
+ "react": "^19.1.0",
22
+ "react-dom": "^19.1.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.14.0",
26
+ "@types/react": "^19.1.5",
27
+ "typescript": "~5.7.2",
28
+ "vite": "^6.2.0"
29
+ },
30
+ "keywords": [
31
+ "ai",
32
+ "chat",
33
+ "multi-ai",
34
+ "collaboration",
35
+ "openai",
36
+ "gpt",
37
+ "react",
38
+ "typescript"
39
+ ],
40
+ "author": "Multi-Mind Chat Team",
41
+ "license": "MIT",
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "https://github.com/zhanghxiao/multi-mind-chat.git"
45
+ },
46
+ "engines": {
47
+ "node": ">=18.0.0"
48
+ }
49
  }
services/openaiService.ts CHANGED
@@ -1,197 +1,197 @@
1
- interface OpenAIMessage {
2
- role: 'system' | 'user' | 'assistant' | 'developer';
3
- content: string | Array<{
4
- type: 'text' | 'image_url';
5
- text?: string;
6
- image_url?: {
7
- url: string;
8
- detail?: 'low' | 'high' | 'auto';
9
- };
10
- }>;
11
- }
12
-
13
- interface OpenAIResponse {
14
- text: string;
15
- durationMs: number;
16
- error?: string;
17
- }
18
-
19
- interface OpenAIStreamChunk {
20
- id: string;
21
- object: string;
22
- created: number;
23
- model: string;
24
- choices: Array<{
25
- index: number;
26
- delta: {
27
- role?: string;
28
- content?: string;
29
- };
30
- finish_reason?: string | null;
31
- }>;
32
- }
33
-
34
- const DEFAULT_OPENAI_API_BASE = 'https://api.openai.com/v1';
35
-
36
- export const generateResponse = async (
37
- prompt: string,
38
- modelName: string,
39
- systemInstruction?: string,
40
- shouldUseReducedCapacity: boolean = false,
41
- imagePart?: { inlineData: { mimeType: string; data: string } },
42
- customBaseUrl?: string,
43
- apiKey?: string,
44
- onStreamChunk?: (newChunk: string, fullText: string, isComplete: boolean) => void
45
- ): Promise<OpenAIResponse> => {
46
- const startTime = performance.now();
47
-
48
- try {
49
- if (!apiKey?.trim()) {
50
- throw new Error("API密钥未设置。请配置您的OpenAI API密钥。");
51
- }
52
-
53
- const messages: OpenAIMessage[] = [];
54
-
55
- if (systemInstruction) {
56
- messages.push({
57
- role: 'system',
58
- content: systemInstruction
59
- });
60
- }
61
-
62
- if (imagePart) {
63
- const imageUrl = `data:${imagePart.inlineData.mimeType};base64,${imagePart.inlineData.data}`;
64
- messages.push({
65
- role: 'user',
66
- content: [
67
- {
68
- type: 'text',
69
- text: prompt
70
- },
71
- {
72
- type: 'image_url',
73
- image_url: {
74
- url: imageUrl,
75
- detail: 'auto'
76
- }
77
- }
78
- ]
79
- });
80
- } else {
81
- messages.push({
82
- role: 'user',
83
- content: prompt
84
- });
85
- }
86
-
87
- const requestBody = {
88
- model: modelName,
89
- messages: messages,
90
- stream: !!onStreamChunk, // 只有提供回调时才使用流式
91
- temperature: shouldUseReducedCapacity ? 0.3 : 0.7,
92
- max_tokens: shouldUseReducedCapacity ? 1000 : 4000
93
- };
94
-
95
- const apiBase = customBaseUrl || DEFAULT_OPENAI_API_BASE;
96
-
97
- const response = await fetch(`${apiBase}/chat/completions`, {
98
- method: 'POST',
99
- headers: {
100
- 'Content-Type': 'application/json',
101
- 'Authorization': `Bearer ${apiKey}`
102
- },
103
- body: JSON.stringify(requestBody)
104
- });
105
-
106
- if (!response.ok) {
107
- const errorData = await response.json().catch(() => ({}));
108
- const errorMessage = errorData.error?.message || response.statusText;
109
-
110
- if (response.status === 401) {
111
- throw new Error(`API密钥无效或已过期: ${errorMessage}`);
112
- } else if (response.status === 429) {
113
- throw new Error(`API调用频率超限: ${errorMessage}`);
114
- } else if (response.status === 404) {
115
- throw new Error(`模型不存在或无权访问: ${modelName}`);
116
- } else {
117
- throw new Error(`OpenAI API 错误 (${response.status}): ${errorMessage}`);
118
- }
119
- }
120
-
121
- let fullText = '';
122
- const durationMs = performance.now() - startTime;
123
-
124
- // 处理流式响应
125
- if (onStreamChunk && requestBody.stream) {
126
- const reader = response.body?.getReader();
127
- const decoder = new TextDecoder();
128
-
129
- if (!reader) {
130
- throw new Error('无法读取响应流');
131
- }
132
-
133
- try {
134
- while (true) {
135
- const { done, value } = await reader.read();
136
- if (done) break;
137
-
138
- const chunk = decoder.decode(value);
139
- const lines = chunk.split('\n');
140
-
141
- for (const line of lines) {
142
- const trimmed = line.trim();
143
- if (trimmed.startsWith('data: ')) {
144
- const jsonStr = trimmed.slice(6);
145
- if (jsonStr === '[DONE]') {
146
- onStreamChunk('', fullText, true);
147
- break;
148
- }
149
-
150
- try {
151
- const parsed: OpenAIStreamChunk = JSON.parse(jsonStr);
152
- const content = parsed.choices[0]?.delta?.content;
153
- if (content) {
154
- fullText += content;
155
- // 立即回调以实现实时显示
156
- onStreamChunk(content, fullText, false);
157
- }
158
- } catch (parseError) {
159
- console.warn('解析流数据时出错:', parseError);
160
- }
161
- }
162
- }
163
- }
164
- } finally {
165
- reader.releaseLock();
166
- }
167
- } else {
168
- // 处理非流式响应(例如图像请求)
169
- const data = await response.json();
170
- fullText = data.choices[0]?.message?.content || '';
171
- }
172
-
173
- if (!fullText.trim()) {
174
- throw new Error('AI响应为空,请检查模型配置或重试');
175
- }
176
-
177
- return { text: fullText, durationMs };
178
-
179
- } catch (error) {
180
- console.error("调用OpenAI API时出错:", error);
181
- const durationMs = performance.now() - startTime;
182
-
183
- if (error instanceof Error) {
184
- if (error.message.includes('API密钥') || error.message.includes('401') || error.message.includes('Unauthorized')) {
185
- return { text: "API密钥无效或已过期。请检查您的OpenAI API密钥配置。", durationMs, error: "API key not valid" };
186
- } else if (error.message.includes('429') || error.message.includes('Rate limit')) {
187
- return { text: "API调用频率超限,请稍后重试。", durationMs, error: "Rate limit exceeded" };
188
- } else if (error.message.includes('404') || error.message.includes('model')) {
189
- return { text: `模型 ${modelName} 不存在或无权访问。请检查模型名称或API权限。`, durationMs, error: "Model not found" };
190
- } else if (error.message.includes('网络') || error.message.includes('fetch')) {
191
- return { text: "网络连接错误,请检查网络连接后重试。", durationMs, error: "Network error" };
192
- }
193
- return { text: `与OpenAI通信时出错: ${error.message}`, durationMs, error: error.message };
194
- }
195
- return { text: "与OpenAI通信时发生未知错误。", durationMs, error: "Unknown OpenAI error" };
196
- }
197
  };
 
1
+ interface OpenAIMessage {
2
+ role: 'system' | 'user' | 'assistant' | 'developer';
3
+ content: string | Array<{
4
+ type: 'text' | 'image_url';
5
+ text?: string;
6
+ image_url?: {
7
+ url: string;
8
+ detail?: 'low' | 'high' | 'auto';
9
+ };
10
+ }>;
11
+ }
12
+
13
+ interface OpenAIResponse {
14
+ text: string;
15
+ durationMs: number;
16
+ error?: string;
17
+ }
18
+
19
+ interface OpenAIStreamChunk {
20
+ id: string;
21
+ object: string;
22
+ created: number;
23
+ model: string;
24
+ choices: Array<{
25
+ index: number;
26
+ delta: {
27
+ role?: string;
28
+ content?: string;
29
+ };
30
+ finish_reason?: string | null;
31
+ }>;
32
+ }
33
+
34
+ const DEFAULT_OPENAI_API_BASE = 'https://api.openai.com/v1';
35
+
36
+ export const generateResponse = async (
37
+ prompt: string,
38
+ modelName: string,
39
+ systemInstruction?: string,
40
+ shouldUseReducedCapacity: boolean = false,
41
+ imagePart?: { inlineData: { mimeType: string; data: string } },
42
+ customBaseUrl?: string,
43
+ apiKey?: string,
44
+ onStreamChunk?: (newChunk: string, fullText: string, isComplete: boolean) => void
45
+ ): Promise<OpenAIResponse> => {
46
+ const startTime = performance.now();
47
+
48
+ try {
49
+ if (!apiKey?.trim()) {
50
+ throw new Error("API密钥未设置。请配置您的OpenAI API密钥。");
51
+ }
52
+
53
+ const messages: OpenAIMessage[] = [];
54
+
55
+ if (systemInstruction) {
56
+ messages.push({
57
+ role: 'system',
58
+ content: systemInstruction
59
+ });
60
+ }
61
+
62
+ if (imagePart) {
63
+ const imageUrl = `data:${imagePart.inlineData.mimeType};base64,${imagePart.inlineData.data}`;
64
+ messages.push({
65
+ role: 'user',
66
+ content: [
67
+ {
68
+ type: 'text',
69
+ text: prompt
70
+ },
71
+ {
72
+ type: 'image_url',
73
+ image_url: {
74
+ url: imageUrl,
75
+ detail: 'auto'
76
+ }
77
+ }
78
+ ]
79
+ });
80
+ } else {
81
+ messages.push({
82
+ role: 'user',
83
+ content: prompt
84
+ });
85
+ }
86
+
87
+ const requestBody = {
88
+ model: modelName,
89
+ messages: messages,
90
+ stream: !!onStreamChunk, // 只有提供回调时才使用流式
91
+ temperature: shouldUseReducedCapacity ? 0.3 : 0.7,
92
+ max_tokens: shouldUseReducedCapacity ? 1000 : 4000
93
+ };
94
+
95
+ const apiBase = customBaseUrl || DEFAULT_OPENAI_API_BASE;
96
+
97
+ const response = await fetch(`${apiBase}/chat/completions`, {
98
+ method: 'POST',
99
+ headers: {
100
+ 'Content-Type': 'application/json',
101
+ 'Authorization': `Bearer ${apiKey}`
102
+ },
103
+ body: JSON.stringify(requestBody)
104
+ });
105
+
106
+ if (!response.ok) {
107
+ const errorData = await response.json().catch(() => ({}));
108
+ const errorMessage = errorData.error?.message || response.statusText;
109
+
110
+ if (response.status === 401) {
111
+ throw new Error(`API密钥无效或已过期: ${errorMessage}`);
112
+ } else if (response.status === 429) {
113
+ throw new Error(`API调用频率超限: ${errorMessage}`);
114
+ } else if (response.status === 404) {
115
+ throw new Error(`模型不存在或无权访问: ${modelName}`);
116
+ } else {
117
+ throw new Error(`OpenAI API 错误 (${response.status}): ${errorMessage}`);
118
+ }
119
+ }
120
+
121
+ let fullText = '';
122
+ const durationMs = performance.now() - startTime;
123
+
124
+ // 处理流式响应
125
+ if (onStreamChunk && requestBody.stream) {
126
+ const reader = response.body?.getReader();
127
+ const decoder = new TextDecoder();
128
+
129
+ if (!reader) {
130
+ throw new Error('无法读取响应流');
131
+ }
132
+
133
+ try {
134
+ while (true) {
135
+ const { done, value } = await reader.read();
136
+ if (done) break;
137
+
138
+ const chunk = decoder.decode(value);
139
+ const lines = chunk.split('\n');
140
+
141
+ for (const line of lines) {
142
+ const trimmed = line.trim();
143
+ if (trimmed.startsWith('data: ')) {
144
+ const jsonStr = trimmed.slice(6);
145
+ if (jsonStr === '[DONE]') {
146
+ onStreamChunk('', fullText, true);
147
+ break;
148
+ }
149
+
150
+ try {
151
+ const parsed: OpenAIStreamChunk = JSON.parse(jsonStr);
152
+ const content = parsed.choices[0]?.delta?.content;
153
+ if (content) {
154
+ fullText += content;
155
+ // 立即回调以实现实时显示
156
+ onStreamChunk(content, fullText, false);
157
+ }
158
+ } catch (parseError) {
159
+ console.warn('解析流数据时出错:', parseError);
160
+ }
161
+ }
162
+ }
163
+ }
164
+ } finally {
165
+ reader.releaseLock();
166
+ }
167
+ } else {
168
+ // 处理非流式响应(例如图像请求)
169
+ const data = await response.json();
170
+ fullText = data.choices[0]?.message?.content || '';
171
+ }
172
+
173
+ if (!fullText.trim()) {
174
+ throw new Error('AI响应为空,请检查模型配置或重试');
175
+ }
176
+
177
+ return { text: fullText, durationMs };
178
+
179
+ } catch (error) {
180
+ console.error("调用OpenAI API时出错:", error);
181
+ const durationMs = performance.now() - startTime;
182
+
183
+ if (error instanceof Error) {
184
+ if (error.message.includes('API密钥') || error.message.includes('401') || error.message.includes('Unauthorized')) {
185
+ return { text: "API密钥无效或已过期。请检查您的OpenAI API密钥配置。", durationMs, error: "API key not valid" };
186
+ } else if (error.message.includes('429') || error.message.includes('Rate limit')) {
187
+ return { text: "API调用频率超限,请稍后重试。", durationMs, error: "Rate limit exceeded" };
188
+ } else if (error.message.includes('404') || error.message.includes('model')) {
189
+ return { text: `模型 ${modelName} 不存在或无权访问。请检查模型名称或API权限。`, durationMs, error: "Model not found" };
190
+ } else if (error.message.includes('网络') || error.message.includes('fetch')) {
191
+ return { text: "网络连接错误,请检查网络连接后重试。", durationMs, error: "Network error" };
192
+ }
193
+ return { text: `与OpenAI通信时出错: ${error.message}`, durationMs, error: error.message };
194
+ }
195
+ return { text: "与OpenAI通信时发生未知错误。", durationMs, error: "Unknown OpenAI error" };
196
+ }
197
  };
tsconfig.json CHANGED
@@ -1,30 +1,30 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2020",
4
- "experimentalDecorators": true,
5
- "useDefineForClassFields": false,
6
- "module": "ESNext",
7
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
8
- "skipLibCheck": true,
9
-
10
- /* Bundler mode */
11
- "moduleResolution": "bundler",
12
- "allowImportingTsExtensions": true,
13
- "isolatedModules": true,
14
- "moduleDetection": "force",
15
- "noEmit": true,
16
- "allowJs": true,
17
- "jsx": "react-jsx",
18
-
19
- /* Linting */
20
- "strict": true,
21
- "noUnusedLocals": true,
22
- "noUnusedParameters": true,
23
- "noFallthroughCasesInSwitch": true,
24
- "noUncheckedSideEffectImports": true,
25
-
26
- "paths": {
27
- "@/*" : ["./*"]
28
- }
29
- }
30
- }
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "experimentalDecorators": true,
5
+ "useDefineForClassFields": false,
6
+ "module": "ESNext",
7
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "isolatedModules": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+ "allowJs": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "noFallthroughCasesInSwitch": true,
24
+ "noUncheckedSideEffectImports": true,
25
+
26
+ "paths": {
27
+ "@/*" : ["./*"]
28
+ }
29
+ }
30
+ }
types.ts CHANGED
@@ -1,298 +1,298 @@
1
- // 动态消息发送者枚举 - 现在支持自定义角色名称
2
- export enum MessageSender {
3
- User = '用户',
4
- System = '系统',
5
- // 动态角色将在运行时创建,这里保留一些常用的默认值
6
- Cognito = 'Cognito',
7
- Muse = 'Muse',
8
- }
9
-
10
- // 消息用途枚举
11
- export enum MessagePurpose {
12
- UserInput = 'user-input',
13
- SystemNotification = 'system-notification',
14
- CognitoToMuse = 'cognito-to-muse', // AI角色之间的讨论(保持兼容性)
15
- MuseToCognito = 'muse-to-cognito', // AI角色之间的讨论(保持兼容性)
16
- FinalResponse = 'final-response', // 最终回复用户
17
- RoleDiscussion = 'role-discussion', // 通用的角色间讨论
18
- }
19
-
20
- // 聊天消息接口
21
- export interface ChatMessage {
22
- id: string;
23
- text: string;
24
- sender: MessageSender | string; // 现在支持动态角色名称
25
- purpose: MessagePurpose;
26
- timestamp: Date;
27
- durationMs?: number; // AI消息的生成时间
28
- image?: { // 用户消息的可选图片数据
29
- dataUrl: string; // 用于显示的base64数据URL
30
- name: string;
31
- type: string;
32
- };
33
- roleId?: string; // 可选的角色ID,用于关联具体的AI角色配置
34
- isStreaming?: boolean; // 标记消息是否正在流式传输
35
- }
36
-
37
- // 讨论记录接口
38
- export interface DiscussionRecord {
39
- id: string;
40
- timestamp: Date;
41
- userQuery: string;
42
- userImage?: {
43
- name: string;
44
- type: string;
45
- size: number;
46
- };
47
- discussionMode: string;
48
- activeRoles: Array<{
49
- id: string;
50
- name: string;
51
- modelName: string;
52
- channelName: string;
53
- }>;
54
- turns: Array<{
55
- id: string;
56
- role: string;
57
- roleId?: string;
58
- message: string;
59
- timestamp: Date;
60
- durationMs?: number;
61
- purpose: MessagePurpose;
62
- }>;
63
- notepadUpdates: Array<{
64
- id: string;
65
- updater: string;
66
- updaterId?: string;
67
- content: string;
68
- timestamp: Date;
69
- }>;
70
- finalAnswer?: {
71
- content: string;
72
- provider: string;
73
- providerId?: string;
74
- timestamp: Date;
75
- durationMs?: number;
76
- };
77
- totalDuration: number; // 总讨论时长(毫秒)
78
- isCompleted: boolean; // 讨论是否正常完成
79
- wasInterrupted: boolean; // 是否被用户中断
80
- interruptedAt?: Date; // 中断时间
81
- settings: {
82
- discussionMode: string;
83
- manualFixedTurns?: number;
84
- isReducedCapacityEnabled: boolean;
85
- activeRoleCount: number;
86
- };
87
- metadata: {
88
- version: string;
89
- exportedAt?: Date;
90
- messageCount: number;
91
- notepadUpdateCount: number;
92
- };
93
- }
94
-
95
- // 讨论统计接口
96
- export interface DiscussionStats {
97
- totalTurns: number;
98
- averageResponseTime: number;
99
- longestResponseTime: number;
100
- shortestResponseTime: number;
101
- totalTokensUsed?: number; // 如果API提供token使用情况
102
- roleParticipation: Record<string, {
103
- turnCount: number;
104
- totalResponseTime: number;
105
- averageResponseTime: number;
106
- }>;
107
- notepadUpdateFrequency: number;
108
- }
109
-
110
- // 流式传输状态接口
111
- export interface StreamingState {
112
- messageId: string;
113
- currentText: string;
114
- targetText: string;
115
- isComplete: boolean;
116
- startTime: Date;
117
- speed: number; // 字符/秒
118
- }
119
-
120
- // AI模型配置接口(从constants.ts导入,但在这里声明以保持类型完整性)
121
- export interface AiModelConfig {
122
- id: string;
123
- name: string;
124
- apiName: string;
125
- baseUrl?: string;
126
- supportsImages: boolean;
127
- supportsReducedCapacity: boolean;
128
- category: string;
129
- maxTokens: number;
130
- temperature: number;
131
- isCustom: boolean;
132
- createdAt: Date;
133
- }
134
-
135
- // AI角色配置接口
136
- export interface AiRoleConfig {
137
- id: string;
138
- name: string;
139
- systemPrompt: string;
140
- modelId: string;
141
- isActive: boolean;
142
- color?: string; // 可选的UI显示颜色
143
- description?: string; // 可选的角色描述
144
- }
145
-
146
- // 讨论配置接口
147
- export interface DiscussionConfig {
148
- mode: 'fixed' | 'ai-driven';
149
- fixedTurns?: number;
150
- maxTurnsPerRole?: number;
151
- allowSameModel?: boolean; // 是否允许多个角色使用相同模型
152
- enableNotepadSharing?: boolean; // 是否启用记事本共享
153
- enableStreamingTypewriter?: boolean; // 是否启用流式打字机效果
154
- typewriterSpeed?: number; // 打字机速度(毫秒/字符)
155
- }
156
-
157
- // API响应接口
158
- export interface ApiResponse {
159
- text: string;
160
- durationMs: number;
161
- error?: string;
162
- usage?: {
163
- promptTokens: number;
164
- completionTokens: number;
165
- totalTokens: number;
166
- };
167
- streamingChunks?: string[]; // 流式响应的块
168
- }
169
-
170
- // 配置导出/导入接口
171
- export interface ConfigExportData {
172
- models: AiModelConfig[];
173
- roles: AiRoleConfig[];
174
- discussionConfig?: DiscussionConfig;
175
- discussionRecords?: DiscussionRecord[]; // 可选的讨论记录
176
- exportedAt: string;
177
- version: string;
178
- }
179
-
180
- // 讨论记录导出接口
181
- export interface DiscussionExportData {
182
- record: DiscussionRecord;
183
- stats: DiscussionStats;
184
- fullTranscript: string; // 完整的文本记录
185
- exportFormat: 'json' | 'markdown' | 'html' | 'txt';
186
- exportedAt: string;
187
- version: string;
188
- }
189
-
190
- // 验证结果接口
191
- export interface ValidationResult {
192
- isValid: boolean;
193
- errors: string[];
194
- warnings?: string[];
195
- }
196
-
197
- // 角色状态接口(运行时使用)
198
- export interface RoleState {
199
- id: string;
200
- name: string;
201
- isProcessing: boolean;
202
- lastResponse?: string;
203
- totalResponseTime?: number;
204
- messageCount?: number;
205
- currentStreamingMessageId?: string; // 当前正在流式传输的消息ID
206
- }
207
-
208
- // 会话状态接口
209
- export interface SessionState {
210
- isActive: boolean;
211
- startTime?: Date;
212
- currentTurn: number;
213
- activeRoles: RoleState[];
214
- discussionLog: Array<{
215
- roleId: string;
216
- roleName: string;
217
- message: string;
218
- timestamp: Date;
219
- }>;
220
- canBeInterrupted: boolean; // 是否可以被中断
221
- interruptionRequested: boolean; // 是否请求了中断
222
- }
223
-
224
- // 讨论控制接口
225
- export interface DiscussionControl {
226
- canStart: boolean;
227
- canPause: boolean;
228
- canResume: boolean;
229
- canInterrupt: boolean;
230
- canExport: boolean;
231
- currentPhase: 'idle' | 'initializing' | 'discussing' | 'synthesizing' | 'completed' | 'interrupted';
232
- estimatedTimeRemaining?: number; // 估算剩余时间(毫秒)
233
- }
234
-
235
- // 导出选项接口
236
- export interface ExportOptions {
237
- format: 'json' | 'markdown' | 'html' | 'txt';
238
- includeMetadata: boolean;
239
- includeStats: boolean;
240
- includeNotepadHistory: boolean;
241
- includeSystemMessages: boolean;
242
- timestampFormat: 'iso' | 'local' | 'relative';
243
- compressOutput: boolean;
244
- }
245
-
246
- // 消息过滤器接口
247
- export interface MessageFilter {
248
- senders?: (MessageSender | string)[];
249
- purposes?: MessagePurpose[];
250
- dateRange?: {
251
- start: Date;
252
- end: Date;
253
- };
254
- textSearch?: string;
255
- hasDuration?: boolean;
256
- hasImage?: boolean;
257
- minDuration?: number;
258
- maxDuration?: number;
259
- }
260
-
261
- // 通知接口
262
- export interface Notification {
263
- id: string;
264
- type: 'info' | 'success' | 'warning' | 'error';
265
- title: string;
266
- message: string;
267
- timestamp: Date;
268
- duration?: number; // 自动消失时间(毫秒)
269
- actions?: Array<{
270
- label: string;
271
- action: () => void;
272
- style?: 'primary' | 'secondary' | 'danger';
273
- }>;
274
- }
275
-
276
- // 应用状态接口
277
- export interface AppState {
278
- ui: {
279
- theme: 'light' | 'dark' | 'auto';
280
- sidebarCollapsed: boolean;
281
- roleManagerOpen: boolean;
282
- configManagerOpen: boolean;
283
- notifications: Notification[];
284
- };
285
- discussion: {
286
- current?: DiscussionRecord;
287
- history: DiscussionRecord[];
288
- control: DiscussionControl;
289
- streaming: Map<string, StreamingState>;
290
- };
291
- session: SessionState;
292
- config: {
293
- channels: any[];
294
- models: any[];
295
- roles: any[];
296
- discussionSettings: DiscussionConfig;
297
- };
298
  }
 
1
+ // 动态消息发送者枚举 - 现在支持自定义角色名称
2
+ export enum MessageSender {
3
+ User = '用户',
4
+ System = '系统',
5
+ // 动态角色将在运行时创建,这里保留一些常用的默认值
6
+ Cognito = 'Cognito',
7
+ Muse = 'Muse',
8
+ }
9
+
10
+ // 消息用途枚举
11
+ export enum MessagePurpose {
12
+ UserInput = 'user-input',
13
+ SystemNotification = 'system-notification',
14
+ CognitoToMuse = 'cognito-to-muse', // AI角色之间的讨论(保持兼容性)
15
+ MuseToCognito = 'muse-to-cognito', // AI角色之间的讨论(保持兼容性)
16
+ FinalResponse = 'final-response', // 最终回复用户
17
+ RoleDiscussion = 'role-discussion', // 通用的角色间讨论
18
+ }
19
+
20
+ // 聊天消息接口
21
+ export interface ChatMessage {
22
+ id: string;
23
+ text: string;
24
+ sender: MessageSender | string; // 现在支持动态角色名称
25
+ purpose: MessagePurpose;
26
+ timestamp: Date;
27
+ durationMs?: number; // AI消息的生成时间
28
+ image?: { // 用户消息的可选图片数据
29
+ dataUrl: string; // 用于显示的base64数据URL
30
+ name: string;
31
+ type: string;
32
+ };
33
+ roleId?: string; // 可选的角色ID,用于关联具体的AI角色配置
34
+ isStreaming?: boolean; // 标记消息是否正在流式传输
35
+ }
36
+
37
+ // 讨论记录接口
38
+ export interface DiscussionRecord {
39
+ id: string;
40
+ timestamp: Date;
41
+ userQuery: string;
42
+ userImage?: {
43
+ name: string;
44
+ type: string;
45
+ size: number;
46
+ };
47
+ discussionMode: string;
48
+ activeRoles: Array<{
49
+ id: string;
50
+ name: string;
51
+ modelName: string;
52
+ channelName: string;
53
+ }>;
54
+ turns: Array<{
55
+ id: string;
56
+ role: string;
57
+ roleId?: string;
58
+ message: string;
59
+ timestamp: Date;
60
+ durationMs?: number;
61
+ purpose: MessagePurpose;
62
+ }>;
63
+ notepadUpdates: Array<{
64
+ id: string;
65
+ updater: string;
66
+ updaterId?: string;
67
+ content: string;
68
+ timestamp: Date;
69
+ }>;
70
+ finalAnswer?: {
71
+ content: string;
72
+ provider: string;
73
+ providerId?: string;
74
+ timestamp: Date;
75
+ durationMs?: number;
76
+ };
77
+ totalDuration: number; // 总讨论时长(毫秒)
78
+ isCompleted: boolean; // 讨论是否正常完成
79
+ wasInterrupted: boolean; // 是否被用户中断
80
+ interruptedAt?: Date; // 中断时间
81
+ settings: {
82
+ discussionMode: string;
83
+ manualFixedTurns?: number;
84
+ isReducedCapacityEnabled: boolean;
85
+ activeRoleCount: number;
86
+ };
87
+ metadata: {
88
+ version: string;
89
+ exportedAt?: Date;
90
+ messageCount: number;
91
+ notepadUpdateCount: number;
92
+ };
93
+ }
94
+
95
+ // 讨论统计接口
96
+ export interface DiscussionStats {
97
+ totalTurns: number;
98
+ averageResponseTime: number;
99
+ longestResponseTime: number;
100
+ shortestResponseTime: number;
101
+ totalTokensUsed?: number; // 如果API提供token使用情况
102
+ roleParticipation: Record<string, {
103
+ turnCount: number;
104
+ totalResponseTime: number;
105
+ averageResponseTime: number;
106
+ }>;
107
+ notepadUpdateFrequency: number;
108
+ }
109
+
110
+ // 流式传输状态接口
111
+ export interface StreamingState {
112
+ messageId: string;
113
+ currentText: string;
114
+ targetText: string;
115
+ isComplete: boolean;
116
+ startTime: Date;
117
+ speed: number; // 字符/秒
118
+ }
119
+
120
+ // AI模型配置接口(从constants.ts导入,但在这里声明以保持类型完整性)
121
+ export interface AiModelConfig {
122
+ id: string;
123
+ name: string;
124
+ apiName: string;
125
+ baseUrl?: string;
126
+ supportsImages: boolean;
127
+ supportsReducedCapacity: boolean;
128
+ category: string;
129
+ maxTokens: number;
130
+ temperature: number;
131
+ isCustom: boolean;
132
+ createdAt: Date;
133
+ }
134
+
135
+ // AI角色配置接口
136
+ export interface AiRoleConfig {
137
+ id: string;
138
+ name: string;
139
+ systemPrompt: string;
140
+ modelId: string;
141
+ isActive: boolean;
142
+ color?: string; // 可选的UI显示颜色
143
+ description?: string; // 可选的角色描述
144
+ }
145
+
146
+ // 讨论配置接口
147
+ export interface DiscussionConfig {
148
+ mode: 'fixed' | 'ai-driven';
149
+ fixedTurns?: number;
150
+ maxTurnsPerRole?: number;
151
+ allowSameModel?: boolean; // 是否允许多个角色使用相同模型
152
+ enableNotepadSharing?: boolean; // 是否启用记事本共享
153
+ enableStreamingTypewriter?: boolean; // 是否启用流式打字机效果
154
+ typewriterSpeed?: number; // 打字机速度(毫秒/字符)
155
+ }
156
+
157
+ // API响应接口
158
+ export interface ApiResponse {
159
+ text: string;
160
+ durationMs: number;
161
+ error?: string;
162
+ usage?: {
163
+ promptTokens: number;
164
+ completionTokens: number;
165
+ totalTokens: number;
166
+ };
167
+ streamingChunks?: string[]; // 流式响应的块
168
+ }
169
+
170
+ // 配置导出/导入接口
171
+ export interface ConfigExportData {
172
+ models: AiModelConfig[];
173
+ roles: AiRoleConfig[];
174
+ discussionConfig?: DiscussionConfig;
175
+ discussionRecords?: DiscussionRecord[]; // 可选的讨论记录
176
+ exportedAt: string;
177
+ version: string;
178
+ }
179
+
180
+ // 讨论记录导出接口
181
+ export interface DiscussionExportData {
182
+ record: DiscussionRecord;
183
+ stats: DiscussionStats;
184
+ fullTranscript: string; // 完整的文本记录
185
+ exportFormat: 'json' | 'markdown' | 'html' | 'txt';
186
+ exportedAt: string;
187
+ version: string;
188
+ }
189
+
190
+ // 验证结果接口
191
+ export interface ValidationResult {
192
+ isValid: boolean;
193
+ errors: string[];
194
+ warnings?: string[];
195
+ }
196
+
197
+ // 角色状态接口(运行时使用)
198
+ export interface RoleState {
199
+ id: string;
200
+ name: string;
201
+ isProcessing: boolean;
202
+ lastResponse?: string;
203
+ totalResponseTime?: number;
204
+ messageCount?: number;
205
+ currentStreamingMessageId?: string; // 当前正在流式传输的消息ID
206
+ }
207
+
208
+ // 会话状态接口
209
+ export interface SessionState {
210
+ isActive: boolean;
211
+ startTime?: Date;
212
+ currentTurn: number;
213
+ activeRoles: RoleState[];
214
+ discussionLog: Array<{
215
+ roleId: string;
216
+ roleName: string;
217
+ message: string;
218
+ timestamp: Date;
219
+ }>;
220
+ canBeInterrupted: boolean; // 是否可以被中断
221
+ interruptionRequested: boolean; // 是否请求了中断
222
+ }
223
+
224
+ // 讨论控制接口
225
+ export interface DiscussionControl {
226
+ canStart: boolean;
227
+ canPause: boolean;
228
+ canResume: boolean;
229
+ canInterrupt: boolean;
230
+ canExport: boolean;
231
+ currentPhase: 'idle' | 'initializing' | 'discussing' | 'synthesizing' | 'completed' | 'interrupted';
232
+ estimatedTimeRemaining?: number; // 估算剩余时间(毫秒)
233
+ }
234
+
235
+ // 导出选项接口
236
+ export interface ExportOptions {
237
+ format: 'json' | 'markdown' | 'html' | 'txt';
238
+ includeMetadata: boolean;
239
+ includeStats: boolean;
240
+ includeNotepadHistory: boolean;
241
+ includeSystemMessages: boolean;
242
+ timestampFormat: 'iso' | 'local' | 'relative';
243
+ compressOutput: boolean;
244
+ }
245
+
246
+ // 消息过滤器接口
247
+ export interface MessageFilter {
248
+ senders?: (MessageSender | string)[];
249
+ purposes?: MessagePurpose[];
250
+ dateRange?: {
251
+ start: Date;
252
+ end: Date;
253
+ };
254
+ textSearch?: string;
255
+ hasDuration?: boolean;
256
+ hasImage?: boolean;
257
+ minDuration?: number;
258
+ maxDuration?: number;
259
+ }
260
+
261
+ // 通知接口
262
+ export interface Notification {
263
+ id: string;
264
+ type: 'info' | 'success' | 'warning' | 'error';
265
+ title: string;
266
+ message: string;
267
+ timestamp: Date;
268
+ duration?: number; // 自动消失时间(毫秒)
269
+ actions?: Array<{
270
+ label: string;
271
+ action: () => void;
272
+ style?: 'primary' | 'secondary' | 'danger';
273
+ }>;
274
+ }
275
+
276
+ // 应用状态接口
277
+ export interface AppState {
278
+ ui: {
279
+ theme: 'light' | 'dark' | 'auto';
280
+ sidebarCollapsed: boolean;
281
+ roleManagerOpen: boolean;
282
+ configManagerOpen: boolean;
283
+ notifications: Notification[];
284
+ };
285
+ discussion: {
286
+ current?: DiscussionRecord;
287
+ history: DiscussionRecord[];
288
+ control: DiscussionControl;
289
+ streaming: Map<string, StreamingState>;
290
+ };
291
+ session: SessionState;
292
+ config: {
293
+ channels: any[];
294
+ models: any[];
295
+ roles: any[];
296
+ discussionSettings: DiscussionConfig;
297
+ };
298
  }
vite.config.ts CHANGED
@@ -1,27 +1,27 @@
1
- import path from 'path';
2
- import { defineConfig } from 'vite';
3
-
4
- export default defineConfig({
5
- resolve: {
6
- alias: {
7
- '@': path.resolve(__dirname, '.'),
8
- }
9
- },
10
- define: {
11
- 'import.meta.env.VITE_PRESET_API_URL': JSON.stringify(process.env.VITE_PRESET_API_URL || ''),
12
- 'import.meta.env.VITE_PRESET_API_KEY': JSON.stringify(process.env.VITE_PRESET_API_KEY || ''),
13
- },
14
- build: {
15
- target: 'es2020',
16
- rollupOptions: {
17
- output: {
18
- manualChunks: undefined,
19
- }
20
- }
21
- },
22
- server: {
23
- port: 5173,
24
- host: '0.0.0.0',
25
- open: false
26
- }
27
  });
 
1
+ import path from 'path';
2
+ import { defineConfig } from 'vite';
3
+
4
+ export default defineConfig({
5
+ resolve: {
6
+ alias: {
7
+ '@': path.resolve(__dirname, '.'),
8
+ }
9
+ },
10
+ define: {
11
+ 'import.meta.env.VITE_PRESET_API_URL': JSON.stringify(process.env.VITE_PRESET_API_URL || ''),
12
+ 'import.meta.env.VITE_PRESET_API_KEY': JSON.stringify(process.env.VITE_PRESET_API_KEY || ''),
13
+ },
14
+ build: {
15
+ target: 'es2020',
16
+ rollupOptions: {
17
+ output: {
18
+ manualChunks: undefined,
19
+ }
20
+ }
21
+ },
22
+ server: {
23
+ port: 5173,
24
+ host: '0.0.0.0',
25
+ open: false
26
+ }
27
  });