Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef } from 'react'; | |
| import { ChatMessage, MessageSender, MessagePurpose } from './types'; | |
| import { generateResponse } from './services/openaiService'; | |
| import ChatInput from './components/ChatInput'; | |
| import MessageBubble from './components/MessageBubble'; | |
| import Notepad from './components/Notepad'; | |
| import ModelConfigManager from './components/ModelConfigManager'; | |
| import { | |
| AiModel, | |
| AiRole, | |
| ApiChannel, | |
| ModelConfigManager as ConfigManager, | |
| DEFAULT_MANUAL_FIXED_TURNS, | |
| MIN_MANUAL_FIXED_TURNS, | |
| MAX_MANUAL_FIXED_TURNS, | |
| MAX_AI_DRIVEN_DISCUSSION_TURNS_PER_MODEL, | |
| INITIAL_NOTEPAD_CONTENT, | |
| NOTEPAD_INSTRUCTION_PROMPT_PART, | |
| NOTEPAD_UPDATE_TAG_START, | |
| NOTEPAD_UPDATE_TAG_END, | |
| DISCUSSION_COMPLETE_TAG, | |
| AI_DRIVEN_DISCUSSION_INSTRUCTION_PROMPT_PART, | |
| DiscussionMode | |
| } from './constants'; | |
| import { | |
| BotMessageSquare, | |
| AlertTriangle, | |
| RefreshCcw, | |
| SlidersHorizontal, | |
| Users, | |
| MessagesSquare, | |
| Bot, | |
| ChevronDown, | |
| Settings, | |
| Play, | |
| Pause, | |
| Square, | |
| Download | |
| } from 'lucide-react'; | |
| interface ParsedAIResponse { | |
| spokenText: string; | |
| newNotepadContent: string | null; | |
| discussionShouldEnd?: boolean; | |
| } | |
| interface ActiveRole extends AiRole { | |
| model: AiModel; | |
| channel: ApiChannel; | |
| isProcessing?: boolean; | |
| } | |
| interface DiscussionState { | |
| currentRoleIndex: number; | |
| currentTurn: number; | |
| discussionLog: string[]; | |
| isFirstMessage: boolean; | |
| previousAISignaledStop: boolean; | |
| discussionEndCount: number; | |
| userQuery: string; | |
| imageApiPart?: any; | |
| commonPromptInstructions: string; | |
| roleOrder: ActiveRole[]; | |
| maxTurnsForLoop: number; | |
| } | |
| const parseAIResponse = (responseText: string): ParsedAIResponse => { | |
| let currentText = responseText.trim(); | |
| let spokenText = ""; | |
| let newNotepadContent: string | null = null; | |
| let discussionShouldEnd = false; | |
| let notepadActionText = ""; | |
| let discussionActionText = ""; | |
| const notepadStartIndex = currentText.lastIndexOf(NOTEPAD_UPDATE_TAG_START); | |
| const notepadEndIndex = currentText.lastIndexOf(NOTEPAD_UPDATE_TAG_END); | |
| if (notepadStartIndex !== -1 && notepadEndIndex !== -1 && notepadEndIndex > notepadStartIndex && currentText.endsWith(NOTEPAD_UPDATE_TAG_END)) { | |
| newNotepadContent = currentText.substring(notepadStartIndex + NOTEPAD_UPDATE_TAG_START.length, notepadEndIndex).trim(); | |
| spokenText = currentText.substring(0, notepadStartIndex).trim(); | |
| if (newNotepadContent) { | |
| notepadActionText = "更新了记事本"; | |
| } else { | |
| notepadActionText = "尝试更新记事本但内容为空"; | |
| } | |
| } else { | |
| spokenText = currentText; | |
| } | |
| if (spokenText.includes(DISCUSSION_COMPLETE_TAG)) { | |
| discussionShouldEnd = true; | |
| spokenText = spokenText.replace(new RegExp(DISCUSSION_COMPLETE_TAG.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), "").trim(); | |
| discussionActionText = "建议结束讨论"; | |
| } | |
| if (!spokenText.trim()) { | |
| if (notepadActionText && discussionActionText) { | |
| spokenText = `(AI ${notepadActionText}并${discussionActionText})`; | |
| } else if (notepadActionText) { | |
| spokenText = `(AI ${notepadActionText})`; | |
| } else if (discussionActionText) { | |
| spokenText = `(AI ${discussionActionText})`; | |
| } else { | |
| spokenText = "(AI 未提供额外文本回复)"; | |
| } | |
| } | |
| return { spokenText: spokenText.trim(), newNotepadContent, discussionShouldEnd }; | |
| }; | |
| const fileToBase64 = (file: File): Promise<string> => { | |
| return new Promise((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.readAsDataURL(file); | |
| reader.onload = () => { | |
| const result = reader.result as string; | |
| resolve(result.split(',')[1]); | |
| }; | |
| reader.onerror = (error) => reject(error); | |
| }); | |
| }; | |
| const createDynamicMessageSender = (roleName: string): MessageSender => { | |
| return roleName as MessageSender; | |
| }; | |
| const App: React.FC = () => { | |
| const [messages, setMessages] = useState<ChatMessage[]>([]); | |
| const [isLoading, setIsLoading] = useState<boolean>(false); | |
| const [currentTotalProcessingTimeMs, setCurrentTotalProcessingTimeMs] = useState<number>(0); | |
| const [notepadContent, setNotepadContent] = useState<string>(INITIAL_NOTEPAD_CONTENT); | |
| const [lastNotepadUpdateBy, setLastNotepadUpdateBy] = useState<MessageSender | null>(null); | |
| const [discussionMode, setDiscussionMode] = useState<DiscussionMode>(DiscussionMode.FixedTurns); | |
| const [manualFixedTurns, setManualFixedTurns] = useState<number>(DEFAULT_MANUAL_FIXED_TURNS); | |
| const [isReducedCapacityEnabled, setIsReducedCapacityEnabled] = useState<boolean>(false); | |
| const [activeRoles, setActiveRoles] = useState<ActiveRole[]>([]); | |
| const [channels, setChannels] = useState<ApiChannel[]>([]); | |
| const [models, setModels] = useState<AiModel[]>([]); | |
| const [isConfigManagerOpen, setIsConfigManagerOpen] = useState<boolean>(false); | |
| const [isRoleSelectorOpen, setIsRoleSelectorOpen] = useState<boolean>(false); | |
| const [isDiscussionActive, setIsDiscussionActive] = useState<boolean>(false); | |
| const [streamingMessages, setStreamingMessages] = useState<Map<string, { text: string; isComplete: boolean }>>(new Map()); | |
| const [currentDiscussion, setCurrentDiscussion] = useState<DiscussionState | null>(null); | |
| const chatContainerRef = useRef<HTMLDivElement>(null); | |
| const currentQueryStartTimeRef = useRef<number | null>(null); | |
| const cancelRequestRef = useRef<boolean>(false); | |
| // 实时流式消息管理 | |
| const createStreamingMessage = (sender: MessageSender, purpose: MessagePurpose): string => { | |
| const messageId = Date.now().toString() + Math.random().toString(36).substr(2, 9); | |
| const message: ChatMessage = { | |
| id: messageId, | |
| text: '', | |
| sender, | |
| purpose, | |
| timestamp: new Date() | |
| }; | |
| setMessages(prev => [...prev, message]); | |
| setStreamingMessages(prev => new Map(prev.set(messageId, { text: '', isComplete: false }))); | |
| return messageId; | |
| }; | |
| const updateStreamingMessage = (messageId: string, fullText: string, isComplete: boolean, durationMs?: number) => { | |
| setMessages(prev => prev.map(msg => | |
| msg.id === messageId ? { | |
| ...msg, | |
| text: fullText, | |
| durationMs: isComplete ? durationMs : msg.durationMs | |
| } : msg | |
| )); | |
| }; | |
| const loadConfiguration = () => { | |
| const allChannels = ConfigManager.getChannels(); | |
| const allModels = ConfigManager.getModels(); | |
| const allRoles = ConfigManager.getActiveRoles(); | |
| setChannels(allChannels); | |
| setModels(allModels); | |
| const rolesWithModelsAndChannels: ActiveRole[] = allRoles.map(role => { | |
| const model = allModels.find(m => m.id === role.modelId); | |
| if (!model) { | |
| console.warn(`Role ${role.name} references non-existent model ${role.modelId}`); | |
| return null; | |
| } | |
| const channel = allChannels.find(ch => ch.id === model.channelId); | |
| if (!channel) { | |
| console.warn(`Model ${model.name} references non-existent channel ${model.channelId}`); | |
| return null; | |
| } | |
| return { ...role, model, channel }; | |
| }).filter(Boolean) as ActiveRole[]; | |
| setActiveRoles(rolesWithModelsAndChannels); | |
| }; | |
| useEffect(() => { | |
| loadConfiguration(); | |
| }, []); | |
| const addMessage = ( | |
| text: string, | |
| sender: MessageSender, | |
| purpose: MessagePurpose, | |
| durationMs?: number, | |
| image?: ChatMessage['image'] | |
| ) => { | |
| const messageId = Date.now().toString() + Math.random().toString(36).substr(2, 9); | |
| const message: ChatMessage = { | |
| id: messageId, | |
| text, | |
| sender, | |
| purpose, | |
| timestamp: new Date(), | |
| durationMs, | |
| image | |
| }; | |
| setMessages(prev => [...prev, message]); | |
| return messageId; | |
| }; | |
| const interruptDiscussion = () => { | |
| if (isLoading && isDiscussionActive) { | |
| cancelRequestRef.current = true; | |
| setIsLoading(false); | |
| setIsDiscussionActive(false); | |
| setCurrentDiscussion(null); | |
| if (currentTotalProcessingTimeMs > 0) { | |
| addMessage( | |
| `讨论已被用户中断 (已进行 ${(currentTotalProcessingTimeMs / 1000).toFixed(2)}秒)`, | |
| MessageSender.System, | |
| MessagePurpose.SystemNotification | |
| ); | |
| } | |
| setCurrentTotalProcessingTimeMs(0); | |
| if (currentQueryStartTimeRef.current) { | |
| currentQueryStartTimeRef.current = null; | |
| } | |
| } | |
| }; | |
| const exportDiscussionRecord = () => { | |
| if (messages.length === 0) { | |
| addMessage('当前没有可导出的消息记录', MessageSender.System, MessagePurpose.SystemNotification); | |
| return; | |
| } | |
| // 生成简洁的文本格式导出 | |
| let exportText = `=== Multi-Mind Chat 对话记录 ===\n`; | |
| exportText += `导出时间: ${new Date().toLocaleString()}\n`; | |
| exportText += `消息总数: ${messages.length}\n\n`; | |
| messages.forEach(msg => { | |
| if (msg.purpose !== MessagePurpose.SystemNotification) { | |
| const timeStr = msg.timestamp.toLocaleTimeString(); | |
| const durationStr = msg.durationMs ? ` (${(msg.durationMs / 1000).toFixed(2)}s)` : ''; | |
| exportText += `[${timeStr}] ${msg.sender}${durationStr}: ${msg.text}\n\n`; | |
| if (msg.image) { | |
| exportText += ` [附件: ${msg.image.name} - ${msg.image.type}]\n\n`; | |
| } | |
| } | |
| }); | |
| if (notepadContent !== INITIAL_NOTEPAD_CONTENT) { | |
| exportText += `=== 最终记事本内容 ===\n`; | |
| exportText += `${notepadContent}\n\n`; | |
| } | |
| const blob = new Blob([exportText], { type: 'text/plain;charset=utf-8' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `对话记录-${new Date().toISOString().split('T')[0]}-${Date.now()}.txt`; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| addMessage('对话记录已导出', MessageSender.System, MessagePurpose.SystemNotification); | |
| }; | |
| const getWelcomeMessageText = () => { | |
| const modeDescription = discussionMode === DiscussionMode.FixedTurns | |
| ? `固定轮次对话 (${manualFixedTurns}轮)` | |
| : "AI驱动对话"; | |
| const roleNames = activeRoles.map(role => role.name).join(' 和 '); | |
| const roleCount = activeRoles.length; | |
| const channelCount = channels.length; | |
| if (channelCount === 0) { | |
| return `欢迎使用Multi-Mind Chat 智囊团!请先配置API渠道。点击设置按钮开始配置。`; | |
| } else if (roleCount === 0) { | |
| return `欢迎使用Multi-Mind Chat 智囊团!已配置 ${channelCount} 个API渠道,请继续配置AI角色和模型。点击设置按钮开始配置。`; | |
| } else if (roleCount === 1) { | |
| return `欢迎使用Multi-Mind Chat 智囊团!当前模式: ${modeDescription}。当前只有一个活跃角色: ${roleNames}。建议添加更多角色以获得更好的协作体验。`; | |
| } else { | |
| return `欢迎使用Multi-Mind Chat 智囊团!当前模式: ${modeDescription}。活跃的AI角色: ${roleNames}。这些角色将协作讨论您的问题并使用共享记事本。`; | |
| } | |
| }; | |
| const initializeChat = () => { | |
| setMessages([]); | |
| setNotepadContent(INITIAL_NOTEPAD_CONTENT); | |
| setLastNotepadUpdateBy(null); | |
| setIsDiscussionActive(false); | |
| setStreamingMessages(new Map()); | |
| setCurrentDiscussion(null); | |
| addMessage( | |
| getWelcomeMessageText(), | |
| MessageSender.System, | |
| MessagePurpose.SystemNotification | |
| ); | |
| }; | |
| useEffect(() => { | |
| initializeChat(); | |
| }, [activeRoles, discussionMode, manualFixedTurns, channels]); | |
| useEffect(() => { | |
| if (chatContainerRef.current) { | |
| chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; | |
| } | |
| }, [messages]); | |
| useEffect(() => { | |
| let intervalId: number | undefined; | |
| if (isLoading && currentQueryStartTimeRef.current) { | |
| intervalId = window.setInterval(() => { | |
| if (currentQueryStartTimeRef.current) { | |
| setCurrentTotalProcessingTimeMs(performance.now() - currentQueryStartTimeRef.current); | |
| } | |
| }, 100); | |
| } else { | |
| if (intervalId) clearInterval(intervalId); | |
| } | |
| return () => { | |
| if (intervalId) clearInterval(intervalId); | |
| }; | |
| }, [isLoading]); | |
| const handleClearChat = () => { | |
| if (isLoading) { | |
| cancelRequestRef.current = true; | |
| } | |
| setIsLoading(false); | |
| setIsDiscussionActive(false); | |
| setCurrentDiscussion(null); | |
| setCurrentTotalProcessingTimeMs(0); | |
| if (currentQueryStartTimeRef.current) { | |
| currentQueryStartTimeRef.current = null; | |
| } | |
| setMessages([]); | |
| setNotepadContent(INITIAL_NOTEPAD_CONTENT); | |
| setLastNotepadUpdateBy(null); | |
| setStreamingMessages(new Map()); | |
| addMessage( | |
| getWelcomeMessageText(), | |
| MessageSender.System, | |
| MessagePurpose.SystemNotification | |
| ); | |
| }; | |
| const handleManualFixedTurnsChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |
| let value = parseInt(e.target.value, 10); | |
| if (isNaN(value)) { | |
| value = DEFAULT_MANUAL_FIXED_TURNS; | |
| } | |
| value = Math.max(MIN_MANUAL_FIXED_TURNS, Math.min(MAX_MANUAL_FIXED_TURNS, value)); | |
| setManualFixedTurns(value); | |
| }; | |
| const toggleRoleActiveState = (roleId: string) => { | |
| const role = activeRoles.find(r => r.id === roleId); | |
| if (role) { | |
| ConfigManager.updateRole(roleId, { isActive: !role.isActive }); | |
| loadConfiguration(); | |
| } | |
| }; | |
| const processNextRole = (state: DiscussionState) => { | |
| if (cancelRequestRef.current) { | |
| setIsDiscussionActive(false); | |
| setCurrentDiscussion(null); | |
| return; | |
| } | |
| // 检查是否完成所有讨论 | |
| if (state.currentTurn === 0 && state.currentRoleIndex >= state.roleOrder.length) { | |
| // 第一轮结束,开始多轮讨论 | |
| if (!state.previousAISignaledStop && state.maxTurnsForLoop > 0) { | |
| const newState = { ...state, currentTurn: 1, currentRoleIndex: 0, discussionEndCount: 0 }; | |
| setCurrentDiscussion(newState); | |
| processNextRole(newState); | |
| return; | |
| } else { | |
| processFinalAnswer(state); | |
| return; | |
| } | |
| } else if (state.currentTurn > 0 && | |
| (state.currentTurn >= state.maxTurnsForLoop || | |
| state.previousAISignaledStop || | |
| state.currentRoleIndex >= state.roleOrder.length)) { | |
| processFinalAnswer(state); | |
| return; | |
| } | |
| const currentRole = state.roleOrder[state.currentRoleIndex]; | |
| const shouldUseReducedCapacity = isReducedCapacityEnabled && currentRole.model.supportsReducedCapacity; | |
| // 显示系统通知 | |
| addMessage( | |
| `${currentRole.name} 正在${state.currentTurn === 0 ? '分析问题并提供观点' : '回应其他角色的观点'} (使用 ${currentRole.model.name} - ${currentRole.channel.name})...`, | |
| MessageSender.System, | |
| MessagePurpose.SystemNotification | |
| ); | |
| // 立即创建消息气泡 | |
| const purpose = state.currentRoleIndex % 2 === 0 ? MessagePurpose.CognitoToMuse : MessagePurpose.MuseToCognito; | |
| const messageId = Date.now().toString() + Math.random().toString(36).substr(2, 9); | |
| // 立即添加空消息到界面,用户会看到正在输入的效果 | |
| const initialMessage: ChatMessage = { | |
| id: messageId, | |
| text: '', // 开始时为空,等待流式输入 | |
| sender: createDynamicMessageSender(currentRole.name), | |
| purpose, | |
| timestamp: new Date() | |
| }; | |
| setMessages(prev => [...prev, initialMessage]); | |
| // 构建提示词 | |
| let prompt: string; | |
| if (state.isFirstMessage) { | |
| prompt = `用户的查询 (中文) 是: "${state.userQuery}". ${state.imageApiPart ? "用户还提供了一张图片。请在您的分析和回复中同时考虑此图片和文本查询。" : ""} 请针对此查询提供您的初步想法或分析。这是一个多AI协作的环境,其他AI角色稍后会对您的观点进行回应和讨论。用中文回答。\n${state.commonPromptInstructions}`; | |
| } else if (state.currentTurn === 0) { | |
| const otherRoles = state.roleOrder.filter(r => r.id !== currentRole.id).map(r => r.name).join(' 和 '); | |
| prompt = `用户的查询 (中文) 是: "${state.userQuery}". ${state.imageApiPart ? "用户还提供了一张图片。请在您的分析和回复中同时考虑此图片和文本查询。" : ""} 当前讨论 (均为中文):\n${state.discussionLog.join("\n")}\n您正在与 ${otherRoles} 协作讨论这个问题。请提供您的观点和分析。用中文回答。\n${state.commonPromptInstructions}`; | |
| } else { | |
| const otherRoles = state.roleOrder.filter(r => r.id !== currentRole.id).map(r => r.name).join(' 和 '); | |
| prompt = `用户的查询 (中文) 是: "${state.userQuery}". ${state.imageApiPart ? "用户还提供了一张图片。请在您的分析和回复中同时考虑此图片和文本查询。" : ""} 当前讨论 (均为中文):\n${state.discussionLog.join("\n")}\n您正在与 ${otherRoles} 协作讨论。请对前面的讨论进行回应,提供您的进一步见解或不同观点。保持简洁并使用中文。\n${NOTEPAD_INSTRUCTION_PROMPT_PART.replace('{notepadContent}', notepadContent)}`; | |
| if (discussionMode === DiscussionMode.AiDriven && state.previousAISignaledStop) { | |
| prompt += `\n注意:之前有AI角色建议结束讨论。如果您同意,请在回复中包含 ${DISCUSSION_COMPLETE_TAG}。否则,请继续讨论。`; | |
| } else if (discussionMode === DiscussionMode.AiDriven) { | |
| prompt += AI_DRIVEN_DISCUSSION_INSTRUCTION_PROMPT_PART; | |
| } | |
| } | |
| // 用于后台累积完整响应文本的变量 | |
| let accumulatedText = ''; | |
| // 开始流式API调用 | |
| generateResponse( | |
| prompt, | |
| currentRole.model.apiName, | |
| currentRole.systemPrompt, | |
| shouldUseReducedCapacity, | |
| state.imageApiPart, | |
| currentRole.channel.baseUrl, | |
| currentRole.channel.apiKey, | |
| // 关键的流式回调函数 | |
| (newChunk: string, fullText: string, isComplete: boolean) => { | |
| // newChunk: 本次新接收到的文本块 | |
| // fullText: API累积的完整文本(用于后续处理) | |
| // isComplete: 是否完成 | |
| // 更新后台累积的完整文本 | |
| accumulatedText = fullText; | |
| // 实时更新界面显示 - 关键是这里直接使用 fullText 进行显示 | |
| setMessages(prev => prev.map(msg => | |
| msg.id === messageId ? { | |
| ...msg, | |
| text: fullText, // 直接显示累积的完整文本,实现打字机效果 | |
| durationMs: isComplete ? (performance.now() - (currentQueryStartTimeRef.current || 0)) : undefined | |
| } : msg | |
| )); | |
| }, | |
| currentRole.model.maxTokens // 传递模型配置的 maxTokens | |
| ).then(response => { | |
| if (cancelRequestRef.current) { | |
| setIsDiscussionActive(false); | |
| setCurrentDiscussion(null); | |
| return; | |
| } | |
| if (response.error) { | |
| if (response.error.includes("API key not valid") || response.error.includes("401")) { | |
| setMessages(prev => prev.map(msg => | |
| msg.id === messageId ? { | |
| ...msg, | |
| text: `API密钥无效 (渠道: ${currentRole.channel.name}),请在配置界面中检查密钥设置。`, | |
| durationMs: response.durationMs | |
| } : msg | |
| )); | |
| setIsLoading(false); | |
| setIsDiscussionActive(false); | |
| setCurrentDiscussion(null); | |
| return; | |
| } | |
| throw new Error(`${currentRole.name}: ${response.text}`); | |
| } | |
| // 确保最终消息内容正确 | |
| setMessages(prev => prev.map(msg => | |
| msg.id === messageId ? { | |
| ...msg, | |
| text: response.text, | |
| durationMs: response.durationMs | |
| } : msg | |
| )); | |
| // 解析响应 | |
| const parsedResponse = parseAIResponse(response.text); | |
| if (parsedResponse.newNotepadContent !== null) { | |
| setNotepadContent(parsedResponse.newNotepadContent); | |
| setLastNotepadUpdateBy(createDynamicMessageSender(currentRole.name)); | |
| } | |
| // 更新讨论日志 - 使用解析后的文本 | |
| const newDiscussionLog = [...state.discussionLog, `${currentRole.name}: ${parsedResponse.spokenText}`]; | |
| // 更新状态 | |
| let newState = { | |
| ...state, | |
| discussionLog: newDiscussionLog, | |
| isFirstMessage: false, | |
| currentRoleIndex: state.currentRoleIndex + 1 | |
| }; | |
| // 处理AI驱动模式的结束信号 | |
| if (discussionMode === DiscussionMode.AiDriven && parsedResponse.discussionShouldEnd) { | |
| if (state.currentTurn > 0) { | |
| newState.discussionEndCount++; | |
| if (state.previousAISignaledStop || newState.discussionEndCount >= Math.ceil(state.roleOrder.length / 2)) { | |
| addMessage(`多数AI角色已同意结束讨论。`, MessageSender.System, MessagePurpose.SystemNotification); | |
| newState.previousAISignaledStop = true; | |
| } else { | |
| addMessage(`${currentRole.name} 已建议结束讨论。`, MessageSender.System, MessagePurpose.SystemNotification); | |
| } | |
| } else { | |
| newState.previousAISignaledStop = true; | |
| addMessage(`${currentRole.name} 已建议结束讨论。`, MessageSender.System, MessagePurpose.SystemNotification); | |
| } | |
| } | |
| setCurrentDiscussion(newState); | |
| // 直接处理下一个角色,不使用setTimeout延迟 | |
| processNextRole(newState); | |
| }).catch(error => { | |
| console.error("处理AI响应时出错:", error); | |
| addMessage(`错误: ${error instanceof Error ? error.message : "处理响应时发生未知错误"}`, MessageSender.System, MessagePurpose.SystemNotification); | |
| setIsLoading(false); | |
| setIsDiscussionActive(false); | |
| setCurrentDiscussion(null); | |
| }); | |
| }; | |
| const processFinalAnswer = (state: DiscussionState) => { | |
| const finalAnswerRole = state.roleOrder[0]; | |
| const shouldUseReducedCapacity = isReducedCapacityEnabled && finalAnswerRole.model.supportsReducedCapacity; | |
| addMessage( | |
| `${finalAnswerRole.name} 正在综合所有讨论内容,准备最终答案 (使用 ${finalAnswerRole.model.name} - ${finalAnswerRole.channel.name})...`, | |
| MessageSender.System, | |
| MessagePurpose.SystemNotification | |
| ); | |
| // 立即创建最终答案消息气泡 | |
| const finalMessageId = Date.now().toString() + Math.random().toString(36).substr(2, 9); | |
| const finalMessage: ChatMessage = { | |
| id: finalMessageId, | |
| text: '', // 开始时为空 | |
| sender: createDynamicMessageSender(finalAnswerRole.name), | |
| purpose: MessagePurpose.FinalResponse, | |
| timestamp: new Date() | |
| }; | |
| setMessages(prev => [...prev, finalMessage]); | |
| const finalPrompt = `用户最初的查询 (中文) 是: "${state.userQuery}". ${state.imageApiPart ? "用户还提供了一张图片。请在您的分析和回复中同时考虑此图片和文本查询。" : ""} 您和其他AI角色进行了以下讨论 (均为中文):\n${state.discussionLog.join("\n")}\n基于整个协作讨论过程和共享记事本的最终状态,请综合所有关键观点,为用户提供一个全面、有用的最终答案。直接回复用户,确保答案结构良好,易于理解,并使用中文。如果相关,您可以在答案中引用记事本内容。如果需要,您也可以最后一次更新记事本。\n${NOTEPAD_INSTRUCTION_PROMPT_PART.replace('{notepadContent}', notepadContent)}`; | |
| generateResponse( | |
| finalPrompt, | |
| finalAnswerRole.model.apiName, | |
| finalAnswerRole.systemPrompt, | |
| shouldUseReducedCapacity, | |
| state.imageApiPart, | |
| finalAnswerRole.channel.baseUrl, | |
| finalAnswerRole.channel.apiKey, | |
| // 最终答案的流式回调 | |
| (newChunk: string, fullText: string, isComplete: boolean) => { | |
| setMessages(prev => prev.map(msg => | |
| msg.id === finalMessageId ? { | |
| ...msg, | |
| text: fullText, // 实时显示累积文本 | |
| durationMs: isComplete ? (performance.now() - (currentQueryStartTimeRef.current || 0)) : undefined | |
| } : msg | |
| )); | |
| }, | |
| finalAnswerRole.model.maxTokens // 传递模型配置的 maxTokens | |
| ).then(finalResponse => { | |
| if (cancelRequestRef.current) { | |
| setIsDiscussionActive(false); | |
| setCurrentDiscussion(null); | |
| return; | |
| } | |
| if (finalResponse.error) { | |
| if (finalResponse.error.includes("API key not valid") || finalResponse.error.includes("401")) { | |
| setMessages(prev => prev.map(msg => | |
| msg.id === finalMessageId ? { | |
| ...msg, | |
| text: `API密钥无效 (渠道: ${finalAnswerRole.channel.name}),请在配置界面中检查密钥设置。`, | |
| durationMs: finalResponse.durationMs | |
| } : msg | |
| )); | |
| setIsLoading(false); | |
| setIsDiscussionActive(false); | |
| setCurrentDiscussion(null); | |
| return; | |
| } | |
| throw new Error(`${finalAnswerRole.name}: ${finalResponse.text}`); | |
| } | |
| setMessages(prev => prev.map(msg => | |
| msg.id === finalMessageId ? { | |
| ...msg, | |
| text: finalResponse.text, | |
| durationMs: finalResponse.durationMs | |
| } : msg | |
| )); | |
| const finalParsedResponse = parseAIResponse(finalResponse.text); | |
| if (finalParsedResponse.newNotepadContent !== null) { | |
| setNotepadContent(finalParsedResponse.newNotepadContent); | |
| setLastNotepadUpdateBy(createDynamicMessageSender(finalAnswerRole.name)); | |
| } | |
| // 讨论完成 | |
| setIsLoading(false); | |
| setIsDiscussionActive(false); | |
| setCurrentDiscussion(null); | |
| currentQueryStartTimeRef.current = null; | |
| }).catch(error => { | |
| console.error("生成最终答案时出错:", error); | |
| addMessage(`错误: ${error instanceof Error ? error.message : "生成最终答案时发生未知错误"}`, MessageSender.System, MessagePurpose.SystemNotification); | |
| setIsLoading(false); | |
| setIsDiscussionActive(false); | |
| setCurrentDiscussion(null); | |
| }); | |
| }; | |
| const handleSendMessage = async (userInput: string, imageFile?: File | null) => { | |
| if (isLoading) return; | |
| if (!userInput.trim() && !imageFile) return; | |
| if (channels.length === 0) { | |
| addMessage("请先配置API渠道。点击设置按钮添加渠道。", MessageSender.System, MessagePurpose.SystemNotification); | |
| return; | |
| } | |
| if (activeRoles.length === 0) { | |
| addMessage("请先配置AI角色。点击设置按钮添加角色。", MessageSender.System, MessagePurpose.SystemNotification); | |
| return; | |
| } | |
| const rolesWithoutApiKey = activeRoles.filter(role => !role.channel.apiKey?.trim()); | |
| if (rolesWithoutApiKey.length > 0) { | |
| const roleNames = rolesWithoutApiKey.map(role => `${role.name}(${role.channel.name})`).join('、'); | |
| addMessage(`以下角色的API渠道缺少API密钥: ${roleNames}。请在配置界面中设置相应的API密钥。`, MessageSender.System, MessagePurpose.SystemNotification); | |
| return; | |
| } | |
| if (imageFile) { | |
| const supportsImages = activeRoles.some(role => role.model.supportsImages); | |
| if (!supportsImages) { | |
| addMessage("当前活跃的角色都不支持图片处理。请添加支持图片的模型和角色,或移除图片。", MessageSender.System, MessagePurpose.SystemNotification); | |
| return; | |
| } | |
| } | |
| setIsDiscussionActive(true); | |
| cancelRequestRef.current = false; | |
| setIsLoading(true); | |
| currentQueryStartTimeRef.current = performance.now(); | |
| setCurrentTotalProcessingTimeMs(0); | |
| let userImageForDisplay: ChatMessage['image'] | undefined = undefined; | |
| if (imageFile) { | |
| const dataUrl = URL.createObjectURL(imageFile); | |
| userImageForDisplay = { dataUrl, name: imageFile.name, type: imageFile.type }; | |
| } | |
| addMessage(userInput, MessageSender.User, MessagePurpose.UserInput, undefined, userImageForDisplay); | |
| let imageApiPart: { inlineData: { mimeType: string; data: string } } | undefined = undefined; | |
| if (imageFile) { | |
| try { | |
| const base64Data = await fileToBase64(imageFile); | |
| imageApiPart = { | |
| inlineData: { | |
| mimeType: imageFile.type, | |
| data: base64Data, | |
| }, | |
| }; | |
| } catch (error) { | |
| console.error("Error converting file to base64:", error); | |
| addMessage("图片处理失败,请重试。", MessageSender.System, MessagePurpose.SystemNotification); | |
| setIsLoading(false); | |
| setIsDiscussionActive(false); | |
| return; | |
| } | |
| } | |
| const discussionModeInstruction = discussionMode === DiscussionMode.AiDriven ? AI_DRIVEN_DISCUSSION_INSTRUCTION_PROMPT_PART : ""; | |
| const commonPromptInstructions = NOTEPAD_INSTRUCTION_PROMPT_PART.replace('{notepadContent}', notepadContent) + discussionModeInstruction; | |
| const roleOrder = [...activeRoles]; | |
| const maxTurnsForLoop = discussionMode === DiscussionMode.AiDriven ? MAX_AI_DRIVEN_DISCUSSION_TURNS_PER_MODEL : manualFixedTurns; | |
| // 初始化讨论状态 | |
| const discussionState: DiscussionState = { | |
| currentRoleIndex: 0, | |
| currentTurn: 0, | |
| discussionLog: [], | |
| isFirstMessage: true, | |
| previousAISignaledStop: false, | |
| discussionEndCount: 0, | |
| userQuery: userInput, | |
| imageApiPart, | |
| commonPromptInstructions, | |
| roleOrder, | |
| maxTurnsForLoop | |
| }; | |
| setCurrentDiscussion(discussionState); | |
| // 开始第一个AI的回复(不等待) | |
| processNextRole(discussionState); | |
| // 清理图片URL | |
| if (userImageForDisplay?.dataUrl.startsWith('blob:')) { | |
| // 延迟清理,确保消息已渲染 | |
| setTimeout(() => { | |
| URL.revokeObjectURL(userImageForDisplay.dataUrl); | |
| }, 5000); | |
| } | |
| }; | |
| const Separator = () => <div className="h-6 w-px bg-gray-600" aria-hidden="true"></div>; | |
| const hasValidChannels = channels.some(ch => ch.apiKey?.trim()); | |
| const isSystemReady = hasValidChannels && activeRoles.length > 0; | |
| return ( | |
| <div className="flex flex-col h-screen max-w-7xl mx-auto bg-gray-900 shadow-2xl rounded-lg overflow-hidden"> | |
| <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"> | |
| <div className="flex items-center shrink-0"> | |
| <BotMessageSquare size={28} className="mr-2 md:mr-3 text-sky-400" /> | |
| <h1 className="text-xl md:text-2xl font-semibold text-sky-400">Multi-Mind Chat 智囊团</h1> | |
| </div> | |
| <div className="flex items-center space-x-2 md:space-x-3 flex-wrap justify-end gap-y-2"> | |
| {/* 讨论控制按钮 */} | |
| {isDiscussionActive && ( | |
| <div className="flex items-center space-x-2"> | |
| <button | |
| onClick={interruptDiscussion} | |
| className="px-3 py-1 bg-red-600 hover:bg-red-700 text-white rounded text-sm flex items-center space-x-1" | |
| title="中断当前讨论" | |
| > | |
| <Square size={16} /> | |
| <span>中断讨论</span> | |
| </button> | |
| <Separator /> | |
| </div> | |
| )} | |
| {/* 导出按钮 - 有消息时始终可用 */} | |
| {messages.length > 1 && ( | |
| <div className="flex items-center space-x-2"> | |
| <button | |
| onClick={exportDiscussionRecord} | |
| className="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-sm flex items-center space-x-1" | |
| title="导出对话记录" | |
| disabled={isLoading} | |
| > | |
| <Download size={16} /> | |
| <span>导出记录</span> | |
| </button> | |
| <Separator /> | |
| </div> | |
| )} | |
| {/* 角色管理器 */} | |
| <div className="relative flex items-center"> | |
| <label className="text-sm text-gray-300 mr-1.5 flex items-center shrink-0"> | |
| <Users size={18} className="mr-1 text-sky-400"/> | |
| 角色: | |
| </label> | |
| <button | |
| onClick={() => setIsRoleSelectorOpen(!isRoleSelectorOpen)} | |
| 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]" | |
| aria-label="管理AI角色" | |
| > | |
| <span className="truncate">{activeRoles.length}个活跃</span> | |
| <ChevronDown size={16} className={`transition-transform ${isRoleSelectorOpen ? 'rotate-180' : ''}`} /> | |
| </button> | |
| {isRoleSelectorOpen && ( | |
| <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"> | |
| <div className="p-3 border-b border-gray-700"> | |
| <div className="flex justify-between items-center"> | |
| <h3 className="text-white font-medium">活跃角色</h3> | |
| <button | |
| onClick={() => { | |
| setIsConfigManagerOpen(true); | |
| setIsRoleSelectorOpen(false); | |
| }} | |
| className="text-xs bg-sky-600 hover:bg-sky-700 text-white px-2 py-1 rounded flex items-center space-x-1" | |
| > | |
| <Settings size={12} /> | |
| <span>配置</span> | |
| </button> | |
| </div> | |
| </div> | |
| {activeRoles.length === 0 ? ( | |
| <div className="p-4 text-center text-gray-400"> | |
| <p className="mb-2">暂无活跃角色</p> | |
| <button | |
| onClick={() => { | |
| setIsConfigManagerOpen(true); | |
| setIsRoleSelectorOpen(false); | |
| }} | |
| className="text-sm bg-sky-600 hover:bg-sky-700 text-white px-3 py-1 rounded" | |
| > | |
| 添加角色 | |
| </button> | |
| </div> | |
| ) : ( | |
| <div className="max-h-64 overflow-y-auto"> | |
| {activeRoles.map((role) => ( | |
| <div key={role.id} className="p-3 border-b border-gray-700 last:border-b-0"> | |
| <div className="flex justify-between items-start"> | |
| <div className="flex-1"> | |
| <div className="flex items-center space-x-2"> | |
| <h4 className="text-white font-medium">{role.name}</h4> | |
| <button | |
| onClick={() => toggleRoleActiveState(role.id)} | |
| className={`p-1 rounded transition-colors ${ | |
| role.isActive ? 'text-green-400 hover:text-green-300' : 'text-gray-500 hover:text-gray-400' | |
| }`} | |
| title={role.isActive ? '暂停角色' : '激活角色'} | |
| > | |
| {role.isActive ? <Play size={14} /> : <Pause size={14} />} | |
| </button> | |
| </div> | |
| <p className="text-gray-400 text-xs">{role.model.name}</p> | |
| <p className="text-gray-500 text-xs">渠道: {role.channel.name}</p> | |
| <div className="flex space-x-1 mt-1"> | |
| {role.model.supportsImages && ( | |
| <span className="text-xs bg-green-600 text-white px-1 rounded">图像</span> | |
| )} | |
| {role.model.supportsReducedCapacity && ( | |
| <span className="text-xs bg-blue-600 text-white px-1 rounded">优化</span> | |
| )} | |
| {!role.channel.apiKey?.trim() && ( | |
| <span className="text-xs bg-red-600 text-white px-1 rounded">缺少密钥</span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {isRoleSelectorOpen && ( | |
| <div | |
| className="fixed inset-0 z-40" | |
| onClick={() => setIsRoleSelectorOpen(false)} | |
| /> | |
| )} | |
| </div> | |
| <Separator /> | |
| <div className="flex items-center space-x-1.5"> | |
| <label | |
| htmlFor="discussionModeToggle" | |
| className="flex items-center text-sm text-gray-300 cursor-pointer hover:text-sky-400" | |
| title={discussionMode === DiscussionMode.FixedTurns ? "切换到AI驱动模式" : "切换到固定轮次模式"} | |
| > | |
| {discussionMode === DiscussionMode.FixedTurns | |
| ? <MessagesSquare size={18} className="mr-1 text-sky-400" /> | |
| : <Bot size={18} className="mr-1 text-sky-400" />} | |
| <span className="mr-1 select-none shrink-0">模式:</span> | |
| <div className="relative"> | |
| <input | |
| type="checkbox" | |
| id="discussionModeToggle" | |
| className="sr-only peer" | |
| checked={discussionMode === DiscussionMode.AiDriven} | |
| onChange={() => setDiscussionMode(prev => prev === DiscussionMode.FixedTurns ? DiscussionMode.AiDriven : DiscussionMode.FixedTurns)} | |
| aria-label="切换对话模式" | |
| disabled={isLoading} | |
| /> | |
| <div className={`block w-10 h-6 rounded-full transition-colors ${discussionMode === DiscussionMode.AiDriven ? 'bg-sky-500' : 'bg-gray-600'}`}></div> | |
| <div className={`absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${discussionMode === DiscussionMode.AiDriven ? 'translate-x-4' : ''}`}></div> | |
| </div> | |
| <span className="ml-1.5 select-none shrink-0 min-w-[4rem] text-left"> | |
| {discussionMode === DiscussionMode.FixedTurns ? '固定' : 'AI驱动'} | |
| </span> | |
| </label> | |
| {discussionMode === DiscussionMode.FixedTurns && ( | |
| <div className="flex items-center text-sm text-gray-300"> | |
| <input | |
| type="number" | |
| id="manualFixedTurnsInput" | |
| value={manualFixedTurns} | |
| onChange={handleManualFixedTurnsChange} | |
| min={MIN_MANUAL_FIXED_TURNS} | |
| max={MAX_MANUAL_FIXED_TURNS} | |
| 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" | |
| aria-label="设置固定轮次数量" | |
| disabled={isLoading} | |
| /> | |
| <span className="ml-1 select-none">轮</span> | |
| </div> | |
| )} | |
| </div> | |
| <Separator /> | |
| <label | |
| htmlFor="capacityToggle" | |
| className="flex items-center text-sm text-gray-300 cursor-pointer hover:text-sky-400" | |
| title={isReducedCapacityEnabled ? "切换为优质模式 (完整性能)" : "切换为快速模式 (降低性能)"} | |
| > | |
| <SlidersHorizontal size={18} className={`mr-1.5 ${!isReducedCapacityEnabled ? 'text-sky-400' : 'text-gray-500'}`} /> | |
| <span className="mr-2 select-none shrink-0">性能:</span> | |
| <div className="relative"> | |
| <input | |
| type="checkbox" | |
| id="capacityToggle" | |
| className="sr-only peer" | |
| checked={!isReducedCapacityEnabled} | |
| onChange={() => setIsReducedCapacityEnabled(!isReducedCapacityEnabled)} | |
| aria-label="切换AI性能模式" | |
| disabled={isLoading} | |
| /> | |
| <div className={`block w-10 h-6 rounded-full transition-colors ${!isReducedCapacityEnabled ? 'bg-sky-500 peer-checked:bg-sky-500' : 'bg-gray-600'}`}></div> | |
| <div className={`absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${!isReducedCapacityEnabled ? 'peer-checked:translate-x-4' : ''}`}></div> | |
| </div> | |
| <span className="ml-2 w-20 text-left select-none shrink-0"> | |
| {!isReducedCapacityEnabled ? '优质' : '快速'} | |
| </span> | |
| </label> | |
| <Separator /> | |
| <button | |
| onClick={() => setIsConfigManagerOpen(true)} | |
| 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" | |
| aria-label="配置管理" | |
| title="配置管理" | |
| > | |
| <Settings size={22} /> | |
| </button> | |
| <button | |
| onClick={handleClearChat} | |
| 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" | |
| aria-label="清空会话" | |
| title="清空会话" | |
| disabled={isLoading} | |
| > | |
| <RefreshCcw size={22} /> | |
| </button> | |
| </div> | |
| </header> | |
| <div className="flex flex-row flex-grow overflow-hidden"> | |
| <div className="flex flex-col w-2/3 md:w-3/5 lg:w-2/3 h-full"> | |
| <div ref={chatContainerRef} className="flex-grow p-4 space-y-4 overflow-y-auto bg-gray-800 scroll-smooth"> | |
| {messages.map((msg) => { | |
| const streamingState = streamingMessages.get(msg.id); | |
| const displayMessage = streamingState && !streamingState.isComplete | |
| ? { ...msg, text: streamingState.text } | |
| : msg; | |
| return <MessageBubble key={msg.id} message={displayMessage} />; | |
| })} | |
| </div> | |
| <ChatInput onSendMessage={handleSendMessage} isLoading={isLoading} isApiKeyMissing={!isSystemReady} /> | |
| </div> | |
| <div className="w-1/3 md:w-2/5 lg:w-1/3 h-full bg-slate-800"> | |
| <Notepad | |
| content={notepadContent} | |
| lastUpdatedBy={lastNotepadUpdateBy} | |
| isLoading={isLoading} | |
| /> | |
| </div> | |
| </div> | |
| {/* 配置管理器 */} | |
| <ModelConfigManager | |
| isOpen={isConfigManagerOpen} | |
| onClose={() => setIsConfigManagerOpen(false)} | |
| onConfigChange={loadConfiguration} | |
| /> | |
| {/* 处理时间显示 */} | |
| {(isLoading || (currentTotalProcessingTimeMs > 0 && !isLoading) || (isLoading && currentTotalProcessingTimeMs === 0)) && ( | |
| <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"> | |
| 总耗时: {(currentTotalProcessingTimeMs / 1000).toFixed(2)}s | |
| {isDiscussionActive && ( | |
| <div className="text-green-400 mt-1">讨论进行中...</div> | |
| )} | |
| </div> | |
| )} | |
| {/* 系统状态提示 */} | |
| {!isSystemReady && ( | |
| <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"> | |
| <AlertTriangle size={20} className="mr-2" /> | |
| {!hasValidChannels ? '请配置API渠道和密钥' : '请配置AI角色'} | |
| 。点击设置按钮进行配置。 | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default App; |