diff --git a/src/renderer/src/hooks/useMCPServers.ts b/src/renderer/src/hooks/useMCPServers.ts index e6993a34..b8d28cb7 100644 --- a/src/renderer/src/hooks/useMCPServers.ts +++ b/src/renderer/src/hooks/useMCPServers.ts @@ -2,6 +2,7 @@ import store, { useAppDispatch, useAppSelector } from '@renderer/store' import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp' import { MCPServer } from '@renderer/types' import { IpcChannel } from '@shared/IpcChannel' +import { useMemo } from 'react' const ipcRenderer = window.electron.ipcRenderer @@ -12,7 +13,7 @@ ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => { export const useMCPServers = () => { const mcpServers = useAppSelector((state) => state.mcp.servers) - const activedMcpServers = mcpServers.filter((server) => server.isActive) + const activedMcpServers = useMemo(() => mcpServers.filter((server) => server.isActive), [mcpServers]) const dispatch = useAppDispatch() return { diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index c08fa49a..b7c1f15e 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -22,7 +22,7 @@ import WebSearchService from '@renderer/services/WebSearchService' import { useAppDispatch } from '@renderer/store' import { sendMessage as _sendMessage } from '@renderer/store/messages' import { setSearching } from '@renderer/store/runtime' -import { Assistant, FileType, KnowledgeBase, KnowledgeItem, MCPServer, Message, Model, Topic } from '@renderer/types' +import { Assistant, FileType, KnowledgeBase, KnowledgeItem, Message, Model, Topic } from '@renderer/types' import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils' import { getFilesFromDropEvent } from '@renderer/utils/input' import { documentExts, imageExts, textExts } from '@shared/config/constant' @@ -107,7 +107,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const [isTranslating, setIsTranslating] = useState(false) const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState([]) const [mentionModels, setMentionModels] = useState([]) - const [enabledMCPs, setEnabledMCPs] = useState(assistant.mcpServers || []) const [isDragging, setIsDragging] = useState(false) const [textareaHeight, setTextareaHeight] = useState() const startDragY = useRef(0) @@ -122,7 +121,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const quickPanel = useQuickPanel() const showKnowledgeIcon = useSidebarIconShow('knowledge') - // const showMCPToolsIcon = isFunctionCallingModel(model) const [tokenCount, setTokenCount] = useState(0) @@ -168,11 +166,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } }, [textareaHeight]) - // Reset to assistant knowledge mcp servers - useEffect(() => { - setEnabledMCPs(assistant.mcpServers || []) - }, [assistant.mcpServers]) - const sendMessage = useCallback(async () => { if (inputEmpty || loading) { return @@ -202,8 +195,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = userMessage.mentions = mentionModels } - if (!isEmpty(enabledMCPs) && !isEmpty(activedMcpServers)) { - userMessage.enabledMCPs = activedMcpServers.filter((server) => enabledMCPs?.some((s) => s.id === server.id)) + if (!isEmpty(assistant.mcpServers) && !isEmpty(activedMcpServers)) { + userMessage.enabledMCPs = activedMcpServers.filter((server) => + assistant.mcpServers?.some((s) => s.id === server.id) + ) } userMessage.usage = await estimateMessageUsage(userMessage) @@ -225,9 +220,9 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = console.error('Failed to send message:', error) } }, [ + activedMcpServers, assistant, dispatch, - enabledMCPs, files, inputEmpty, loading, @@ -235,8 +230,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = resizeTextArea, selectedKnowledgeBases, text, - topic, - activedMcpServers + topic ]) const translate = useCallback(async () => { @@ -507,9 +501,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = // Reset to assistant default model assistant.defaultModel && setModel(assistant.defaultModel) - // Reset to assistant knowledge mcp servers - !isEmpty(assistant.mcpServers) && setEnabledMCPs(assistant.mcpServers || []) - addTopic(topic) setActiveTopic(topic) @@ -773,17 +764,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setSelectedKnowledgeBases(newKnowledgeBases ?? []) } - const toggelEnableMCP = (mcp: MCPServer) => { - setEnabledMCPs((prev) => { - const exists = prev.some((item) => item.id === mcp.id) - if (exists) { - return prev.filter((item) => item.id !== mcp.id) - } else { - return [...prev, mcp] - } - }) - } - const showWebSearchEnableModal = () => { window.modal.confirm({ title: t('chat.input.web_search.enable'), @@ -967,9 +947,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = /> )} - enabledMCPs: MCPServer[] setInputValue: React.Dispatch> resizeTextArea: () => void - toggelEnableMCP: (server: MCPServer) => void ToolbarButton: any } -const MCPToolsButton: FC = ({ - ref, - setInputValue, - resizeTextArea, - enabledMCPs, - toggelEnableMCP, - ToolbarButton -}) => { +// 添加类型定义 +interface PromptArgument { + name: string + description?: string + required?: boolean +} + +interface MCPPromptWithArgs extends MCPPrompt { + arguments?: PromptArgument[] +} + +interface ResourceData { + blob?: string + mimeType?: string + name?: string + text?: string + uri?: string +} + +// 提取到组件外的工具函数 +const extractPromptContent = (response: any): string | null => { + // Handle string response (backward compatibility) + if (typeof response === 'string') { + return response + } + + // Handle GetMCPPromptResponse format + if (response && Array.isArray(response.messages)) { + let formattedContent = '' + + for (const message of response.messages) { + if (!message.content) continue + + // Add role prefix if available + const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : '' + + // Process different content types + switch (message.content.type) { + case 'text': + formattedContent += `${rolePrefix}${message.content.text}\n\n` + break + + case 'image': + if (message.content.data && message.content.mimeType) { + if (rolePrefix) { + formattedContent += `${rolePrefix}\n` + } + formattedContent += `![Image](data:${message.content.mimeType};base64,${message.content.data})\n\n` + } + break + + case 'audio': + formattedContent += `${rolePrefix}[Audio content available]\n\n` + break + + case 'resource': + if (message.content.text) { + formattedContent += `${rolePrefix}${message.content.text}\n\n` + } else { + formattedContent += `${rolePrefix}[Resource content available]\n\n` + } + break + + default: + if (message.content.text) { + formattedContent += `${rolePrefix}${message.content.text}\n\n` + } + } + } + + return formattedContent.trim() + } + + // Fallback handling for single message format + if (response && response.messages && response.messages.length > 0) { + const message = response.messages[0] + if (message.content && message.content.text) { + const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : '' + return `${rolePrefix}${message.content.text}` + } + } + + return null +} + +const MCPToolsButton: FC = ({ ref, setInputValue, resizeTextArea, ToolbarButton, ...props }) => { const { activedMcpServers } = useMCPServers() const { t } = useTranslation() const quickPanel = useQuickPanel() const navigate = useNavigate() - // Create form instance at the top level const [form] = Form.useForm() - const availableMCPs = activedMcpServers.filter((server) => enabledMCPs.some((s) => s.id === server.id)) + const { updateAssistant, assistant } = useAssistant(props.assistant.id) - const buttonEnabled = availableMCPs.length > 0 + // 使用 useRef 存储不需要触发重渲染的值 + const isMountedRef = useRef(true) + + useEffect(() => { + return () => { + isMountedRef.current = false + } + }, []) + + const mcpServers = useMemo(() => assistant.mcpServers || [], [assistant.mcpServers]) + const assistantMcpServers = useMemo( + () => activedMcpServers.filter((server) => mcpServers.some((s) => s.id === server.id)), + [activedMcpServers, mcpServers] + ) + + const buttonEnabled = assistantMcpServers.length > 0 + + const handleMcpServerSelect = useCallback( + (server: MCPServer) => { + if (assistantMcpServers.some((s) => s.id === server.id)) { + updateAssistant({ ...assistant, mcpServers: mcpServers?.filter((s) => s.id !== server.id) }) + } else { + updateAssistant({ ...assistant, mcpServers: [...mcpServers, server] }) + } + }, + [assistant, assistantMcpServers, mcpServers, updateAssistant] + ) + + // 使用 useRef 缓存事件处理函数 + const handleMcpServerSelectRef = useRef(handleMcpServerSelect) + handleMcpServerSelectRef.current = handleMcpServerSelect + + useEffect(() => { + const handler = (server: MCPServer) => handleMcpServerSelectRef.current(server) + EventEmitter.on('mcp-server-select', handler) + return () => EventEmitter.off('mcp-server-select', handler) + }, []) const menuItems = useMemo(() => { const newList: QuickPanelListItem[] = activedMcpServers.map((server) => ({ label: server.name, description: server.description || server.baseUrl, icon: , - action: () => toggelEnableMCP(server), - isSelected: enabledMCPs.some((s) => s.id === server.id) + action: () => EventEmitter.emit('mcp-server-select', server), + isSelected: assistantMcpServers.some((s) => s.id === server.id) })) newList.push({ @@ -55,8 +170,9 @@ const MCPToolsButton: FC = ({ icon: , action: () => navigate('/settings/mcp') }) + return newList - }, [activedMcpServers, t, enabledMCPs, toggelEnableMCP, navigate]) + }, [activedMcpServers, t, assistantMcpServers, navigate]) const openQuickPanel = useCallback(() => { quickPanel.open({ @@ -69,97 +185,25 @@ const MCPToolsButton: FC = ({ } }) }, [menuItems, quickPanel, t]) - // Extract and format all content from the prompt response - const extractPromptContent = useCallback((response: any): string | null => { - // Handle string response (backward compatibility) - if (typeof response === 'string') { - return response - } - // Handle GetMCPPromptResponse format - if (response && Array.isArray(response.messages)) { - let formattedContent = '' - - for (const message of response.messages) { - if (!message.content) continue - - // Add role prefix if available - const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : '' - - // Process different content types - switch (message.content.type) { - case 'text': - // Add formatted text content with role - formattedContent += `${rolePrefix}${message.content.text}\n\n` - break - - case 'image': - // Format image as markdown with proper attribution - if (message.content.data && message.content.mimeType) { - const imageData = message.content.data - const mimeType = message.content.mimeType - // Include role if available - if (rolePrefix) { - formattedContent += `${rolePrefix}\n` - } - formattedContent += `![Image](data:${mimeType};base64,${imageData})\n\n` - } - break - - case 'audio': - // Add indicator for audio content with role - formattedContent += `${rolePrefix}[Audio content available]\n\n` - break - - case 'resource': - // Add indicator for resource content with role - if (message.content.text) { - formattedContent += `${rolePrefix}${message.content.text}\n\n` - } else { - formattedContent += `${rolePrefix}[Resource content available]\n\n` - } - break - - default: - // Add text content if available with role, otherwise show placeholder - if (message.content.text) { - formattedContent += `${rolePrefix}${message.content.text}\n\n` - } - } - } - - return formattedContent.trim() - } - - // Fallback handling for single message format - if (response && response.messages && response.messages.length > 0) { - const message = response.messages[0] - if (message.content && message.content.text) { - const rolePrefix = message.role ? `**${message.role.charAt(0).toUpperCase() + message.role.slice(1)}:** ` : '' - return `${rolePrefix}${message.content.text}` - } - } - - return null - }, []) - - // Helper function to insert prompt into text area + // 使用 useCallback 优化 insertPromptIntoTextArea const insertPromptIntoTextArea = useCallback( (promptText: string) => { setInputValue((prev) => { const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement - if (!textArea) return prev + promptText // Fallback if we can't find the textarea + if (!textArea) return prev + promptText const cursorPosition = textArea.selectionStart const selectionStart = cursorPosition const selectionEndPosition = cursorPosition + promptText.length const newText = prev.slice(0, cursorPosition) + promptText + prev.slice(cursorPosition) - setTimeout(() => { + // 使用 requestAnimationFrame 优化 DOM 操作 + requestAnimationFrame(() => { textArea.focus() textArea.setSelectionRange(selectionStart, selectionEndPosition) resizeTextArea() - }, 10) + }) return newText }) }, @@ -167,102 +211,104 @@ const MCPToolsButton: FC = ({ ) const handlePromptSelect = useCallback( - (prompt: MCPPrompt) => { - // Using a 10ms delay to ensure the modal or UI updates are fully rendered before executing the logic. - setTimeout(async () => { - const server = enabledMCPs.find((s) => s.id === prompt.serverId) - if (server) { - try { - // Check if the prompt has arguments - if (prompt.arguments && prompt.arguments.length > 0) { - // Reset form when opening a new modal - form.resetFields() + (prompt: MCPPromptWithArgs) => { + const server = activedMcpServers.find((s) => s.id === prompt.serverId) + if (!server) return - Modal.confirm({ - title: `${t('settings.mcp.prompts.arguments')}: ${prompt.name}`, - content: ( -
- {prompt.arguments.map((arg, index) => ( - - - - ))} -
- ), - onOk: async () => { - try { - // Validate and get form values - const values = await form.validateFields() + const handlePromptResponse = async (response: any) => { + const promptContent = extractPromptContent(response) + if (promptContent) { + insertPromptIntoTextArea(promptContent) + } else { + throw new Error('Invalid prompt response format') + } + } - const response = await window.api.mcp.getPrompt({ - server, - name: prompt.name, - args: values - }) + const handlePromptWithArgs = async () => { + try { + form.resetFields() - // Extract and format prompt content from the response - const promptContent = extractPromptContent(response) - if (promptContent) { - insertPromptIntoTextArea(promptContent) - } else { - throw new Error('Invalid prompt response format') - } + const result = await new Promise>((resolve, reject) => { + window.modal.confirm({ + title: `${t('settings.mcp.prompts.arguments')}: ${prompt.name}`, + content: ( +
+ {prompt.arguments?.map((arg, index) => ( + + + + ))} +
+ ), + onOk: async () => { + try { + const values = await form.validateFields() + resolve(values) + } catch (error) { + reject(error) + } + }, + onCancel: () => reject(new Error('cancelled')), + okText: t('common.confirm'), + cancelText: t('common.cancel') + }) + }) - return Promise.resolve() - } catch (error: Error | any) { - if (error.errorFields) { - // This is a form validation error, handled by Ant Design - return Promise.reject(error) - } + const response = await window.api.mcp.getPrompt({ + server, + name: prompt.name, + args: result + }) - Modal.error({ - title: t('common.error'), - content: error.message || t('settings.mcp.prompts.genericError') - }) - return Promise.reject(error) - } - }, - okText: t('common.confirm'), - cancelText: t('common.cancel') - }) - } else { - // If no arguments, get the prompt directly - const response = await window.api.mcp.getPrompt({ - server, - name: prompt.name - }) - - // Extract and format prompt content from the response - const promptContent = extractPromptContent(response) - if (promptContent) { - insertPromptIntoTextArea(promptContent) - } else { - throw new Error('Invalid prompt response format') - } - } - } catch (error: Error | any) { - Modal.error({ + await handlePromptResponse(response) + } catch (error: Error | any) { + if (error.message !== 'cancelled') { + window.modal.error({ title: t('common.error'), - content: error.message || t('settings.mcp.prompt.genericError') + content: error.message || t('settings.mcp.prompts.genericError') }) } } - }, 10) + } + + const handlePromptWithoutArgs = async () => { + try { + const response = await window.api.mcp.getPrompt({ + server, + name: prompt.name + }) + await handlePromptResponse(response) + } catch (error: Error | any) { + window.modal.error({ + title: t('common.error'), + content: error.message || t('settings.mcp.prompt.genericError') + }) + } + } + + requestAnimationFrame(() => { + const hasArguments = prompt.arguments && prompt.arguments.length > 0 + if (hasArguments) { + handlePromptWithArgs() + } else { + handlePromptWithoutArgs() + } + }) }, - [enabledMCPs, form, t, extractPromptContent, insertPromptIntoTextArea] // Add form to dependencies + [activedMcpServers, form, t, insertPromptIntoTextArea] ) const promptList = useMemo(async () => { const prompts: MCPPrompt[] = [] - for (const server of enabledMCPs) { + for (const server of activedMcpServers) { const serverPrompts = await window.api.mcp.listPrompts(server) prompts.push(...serverPrompts) } @@ -271,9 +317,9 @@ const MCPToolsButton: FC = ({ label: prompt.name, description: prompt.description, icon: , - action: () => handlePromptSelect(prompt) + action: () => handlePromptSelect(prompt as MCPPromptWithArgs) })) - }, [handlePromptSelect, enabledMCPs]) + }, [handlePromptSelect, activedMcpServers]) const openPromptList = useCallback(async () => { const prompts = await promptList @@ -287,99 +333,80 @@ const MCPToolsButton: FC = ({ const handleResourceSelect = useCallback( (resource: MCPResource) => { - setTimeout(async () => { - const server = enabledMCPs.find((s) => s.id === resource.serverId) - if (server) { - try { - // Fetch the resource data - const response = await window.api.mcp.getResource({ - server, - uri: resource.uri - }) - console.log('Resource Data:', response) + const server = activedMcpServers.find((s) => s.id === resource.serverId) + if (!server) return - // Check if the response has the expected structure - if (response && response.contents && Array.isArray(response.contents)) { - // Process each resource in the contents array - for (const resourceData of response.contents) { - // Determine how to handle the resource based on its MIME type - if (resourceData.blob) { - // Handle binary data (images, etc.) - if (resourceData.mimeType?.startsWith('image/')) { - // Insert image as markdown - const imageMarkdown = `![${resourceData.name || 'Image'}](data:${resourceData.mimeType};base64,${resourceData.blob})` - insertPromptIntoTextArea(imageMarkdown) - } else { - // For other binary types, just mention it's available - const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.mimeType || t('settings.mcp.resources.blobInvisible')}]` - insertPromptIntoTextArea(resourceInfo) - } - } else if (resourceData.text) { - // Handle text data - insertPromptIntoTextArea(resourceData.text) - } else { - // Fallback for resources without content - const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.uri || resource.uri}]` - insertPromptIntoTextArea(resourceInfo) - } - } - } else { - // Handle legacy format or direct resource data - const resourceData = response - - // Determine how to handle the resource based on its MIME type - if (resourceData.blob) { - // Handle binary data (images, etc.) - if (resourceData.mimeType?.startsWith('image/')) { - // Insert image as markdown - const imageMarkdown = `![${resourceData.name || resource.name}](data:${resourceData.mimeType};base64,${resourceData.blob})` - insertPromptIntoTextArea(imageMarkdown) - } else { - // For other binary types, just mention it's available - const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.mimeType || t('settings.mcp.resources.blobInvisible')}]` - insertPromptIntoTextArea(resourceInfo) - } - } else if (resourceData.text) { - // Handle text data - insertPromptIntoTextArea(resourceData.text) - } else { - // Fallback for resources without content - const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.uri || resource.uri}]` - insertPromptIntoTextArea(resourceInfo) - } - } - } catch (error: Error | any) { - Modal.error({ - title: t('common.error'), - content: error.message || t('settings.mcp.resources.genericError') - }) + const processResourceContent = (resourceData: ResourceData) => { + if (resourceData.blob) { + if (resourceData.mimeType?.startsWith('image/')) { + const imageMarkdown = `![${resourceData.name || 'Image'}](data:${resourceData.mimeType};base64,${resourceData.blob})` + insertPromptIntoTextArea(imageMarkdown) + } else { + const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.mimeType || t('settings.mcp.resources.blobInvisible')}]` + insertPromptIntoTextArea(resourceInfo) } + } else if (resourceData.text) { + insertPromptIntoTextArea(resourceData.text) + } else { + const resourceInfo = `[${resourceData.name || resource.name} - ${resourceData.uri || resource.uri}]` + insertPromptIntoTextArea(resourceInfo) } - }, 10) + } + + requestAnimationFrame(async () => { + try { + const response = await window.api.mcp.getResource({ + server, + uri: resource.uri + }) + + if (response?.contents && Array.isArray(response.contents)) { + response.contents.forEach((content: ResourceData) => processResourceContent(content)) + } else { + processResourceContent(response as ResourceData) + } + } catch (error: Error | any) { + window.modal.error({ + title: t('common.error'), + content: error.message || t('settings.mcp.resources.genericError') + }) + } + }) }, - [enabledMCPs, t, insertPromptIntoTextArea] + [activedMcpServers, t, insertPromptIntoTextArea] ) + + // 优化 resourcesList 的状态更新 const [resourcesList, setResourcesList] = useState([]) useEffect(() => { + let isMounted = true + const fetchResources = async () => { const resources: MCPResource[] = [] - for (const server of enabledMCPs) { + for (const server of activedMcpServers) { const serverResources = await window.api.mcp.listResources(server) resources.push(...serverResources) } - setResourcesList( - resources.map((resource) => ({ - label: resource.name, - description: resource.description, - icon: , - action: () => handleResourceSelect(resource) - })) - ) + + if (isMounted) { + setResourcesList( + resources.map((resource) => ({ + label: resource.name, + description: resource.description, + icon: , + action: () => handleResourceSelect(resource) + })) + ) + } } fetchResources() - }, [handleResourceSelect, enabledMCPs]) + + return () => { + isMounted = false + } + }, [activedMcpServers, handleResourceSelect]) const openResourcesList = useCallback(async () => { const resources = resourcesList @@ -418,4 +445,5 @@ const MCPToolsButton: FC = ({ ) } -export default MCPToolsButton +// 使用 React.memo 包装组件 +export default React.memo(MCPToolsButton) diff --git a/src/renderer/src/pages/home/Messages/MessageCitations.tsx b/src/renderer/src/pages/home/Messages/MessageCitations.tsx new file mode 100644 index 00000000..54a37423 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/MessageCitations.tsx @@ -0,0 +1,113 @@ +import { isOpenAIWebSearch } from '@renderer/config/models' +import { Message, Model } from '@renderer/types' +import { FC, useMemo } from 'react' +import styled from 'styled-components' + +import CitationsList from './CitationsList' + +type Citation = { + number: number + url: string + hostname: string +} + +interface Props { + message: Message + formattedCitations: Citation[] | null + model?: Model +} + +const MessageCitations: FC = ({ message, formattedCitations, model }) => { + const isWebCitation = model && (isOpenAIWebSearch(model) || model.provider === 'openrouter') + + // 判断是否有引用内容 + const hasCitations = useMemo(() => { + return !!( + (formattedCitations && formattedCitations.length > 0) || + (message?.metadata?.webSearch && message.status === 'success') || + (message?.metadata?.webSearchInfo && message.status === 'success') || + (message?.metadata?.groundingMetadata && message.status === 'success') || + (message?.metadata?.knowledge && message.status === 'success') + ) + }, [formattedCitations, message]) + + if (!hasCitations) { + return null + } + + return ( + + {message?.metadata?.groundingMetadata && message.status === 'success' && ( + <> + ({ + number: index + 1, + url: chunk?.web?.uri || '', + title: chunk?.web?.title, + showFavicon: false + })) || [] + } + /> + + + )} + {formattedCitations && ( + ({ + number: citation.number, + url: citation.url, + hostname: citation.hostname, + showFavicon: isWebCitation + }))} + /> + )} + {(message?.metadata?.webSearch || message.metadata?.knowledge) && message.status === 'success' && ( + ({ + number: index + 1, + url: result.url, + title: result.title, + showFavicon: true, + type: 'websearch' + })) || []), + ...(message.metadata.knowledge?.map((result, index) => ({ + number: (message.metadata?.webSearch?.results?.length || 0) + index + 1, + url: result.sourceUrl, + title: result.sourceUrl, + showFavicon: true, + type: 'knowledge' + })) || []) + ]} + /> + )} + {message?.metadata?.webSearchInfo && message.status === 'success' && ( + ({ + number: index + 1, + url: result.link || result.url, + title: result.title, + showFavicon: true + }))} + /> + )} + + ) +} + +const Container = styled.div`` + +const SearchEntryPoint = styled.div` + margin: 10px 2px; +` + +export default MessageCitations diff --git a/src/renderer/src/pages/home/Messages/MessageContent.tsx b/src/renderer/src/pages/home/Messages/MessageContent.tsx index 2e630192..e6a28522 100644 --- a/src/renderer/src/pages/home/Messages/MessageContent.tsx +++ b/src/renderer/src/pages/home/Messages/MessageContent.tsx @@ -1,97 +1,65 @@ -import { SyncOutlined, TranslationOutlined } from '@ant-design/icons' -import { isOpenAIWebSearch } from '@renderer/config/models' +import { SyncOutlined } from '@ant-design/icons' import { getModelUniqId } from '@renderer/services/ModelService' import { Message, Model } from '@renderer/types' import { getBriefInfo } from '@renderer/utils' -import { withMessageThought } from '@renderer/utils/formats' -import { Divider, Flex } from 'antd' +import { formatCitations, withMessageThought } from '@renderer/utils/formats' +import { encodeHTML } from '@renderer/utils/markdown' +import { Flex } from 'antd' import { clone } from 'lodash' import { Search } from 'lucide-react' import React, { Fragment, useMemo } from 'react' import { useTranslation } from 'react-i18next' import BarLoader from 'react-spinners/BarLoader' -import BeatLoader from 'react-spinners/BeatLoader' -import styled from 'styled-components' +import styled, { css } from 'styled-components' import Markdown from '../Markdown/Markdown' -import CitationsList from './CitationsList' import MessageAttachments from './MessageAttachments' +import MessageCitations from './MessageCitations' import MessageError from './MessageError' import MessageImage from './MessageImage' import MessageThought from './MessageThought' import MessageTools from './MessageTools' +import MessageTranslate from './MessageTranslate' interface Props { - message: Message - model?: Model + readonly message: Readonly + readonly model?: Readonly } +const toolUseRegex = /([\s\S]*?)<\/tool_use>/g + const MessageContent: React.FC = ({ message: _message, model }) => { const { t } = useTranslation() const message = withMessageThought(clone(_message)) - const isWebCitation = model && (isOpenAIWebSearch(model) || model.provider === 'openrouter') - // HTML实体编码辅助函数 - const encodeHTML = (str: string) => { - return str.replace(/[&<>"']/g, (match) => { - const entities: { [key: string]: string } = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - } - return entities[match] - }) - } + // Memoize message status checks + const messageStatus = useMemo( + () => ({ + isSending: message.status === 'sending', + isSearching: message.status === 'searching', + isError: message.status === 'error', + isMention: message.type === '@' + }), + [message.status, message.type] + ) + + // Memoize mentions rendering data + const mentionsData = useMemo(() => { + if (!message.mentions?.length) return null + return message.mentions.map((model) => ({ + key: getModelUniqId(model), + name: model.name + })) + }, [message.mentions]) + + // 预先缓存 URL 对象,避免重复创建 + const urlCache = useMemo(() => new Map(), []) // Format citations for display - const formattedCitations = useMemo(() => { - if (!message.metadata?.citations?.length && !message.metadata?.annotations?.length) return null - - let citations: any[] = [] - - if (model && isOpenAIWebSearch(model)) { - citations = - message.metadata.annotations?.map((url, index) => { - return { number: index + 1, url: url.url_citation?.url, hostname: url.url_citation.title } - }) || [] - } else { - citations = - message.metadata?.citations?.map((url, index) => { - try { - const hostname = new URL(url).hostname - return { number: index + 1, url, hostname } - } catch { - return { number: index + 1, url, hostname: url } - } - }) || [] - } - - // Deduplicate by URL - const urlSet = new Set() - return citations - .filter((citation) => { - if (!citation.url || urlSet.has(citation.url)) return false - urlSet.add(citation.url) - return true - }) - .map((citation, index) => ({ - ...citation, - number: index + 1 // Renumber citations sequentially after deduplication - })) - }, [message.metadata?.citations, message.metadata?.annotations, model]) - - // 判断是否有引用内容 - const hasCitations = useMemo(() => { - return !!( - (formattedCitations && formattedCitations.length > 0) || - (message?.metadata?.webSearch && message.status === 'success') || - (message?.metadata?.webSearchInfo && message.status === 'success') || - (message?.metadata?.groundingMetadata && message.status === 'success') || - (message?.metadata?.knowledge && message.status === 'success') - ) - }, [formattedCitations, message]) + const formattedCitations = useMemo( + () => formatCitations(message.metadata, model, urlCache), + [message.metadata, model, urlCache] + ) // 获取引用数据 const citationsData = useMemo(() => { @@ -101,38 +69,43 @@ const MessageContent: React.FC = ({ message: _message, model }) => { message?.metadata?.groundingMetadata?.groundingChunks?.map((chunk) => chunk?.web) || message?.metadata?.annotations?.map((annotation) => annotation.url_citation) || [] - const citationsUrls = formattedCitations || [] - // 合并引用数据 - const data = new Map() + // 使用对象而不是 Map 来提高性能 + const data = {} - // 添加webSearch结果 + // 批量处理 webSearch 结果 searchResults.forEach((result) => { - data.set(result.url || result.uri || result.link, { - url: result.url || result.uri || result.link, - title: result.title || result.hostname, - content: result.content - }) + const url = result.url || result.uri || result.link + if (url && !data[url]) { + data[url] = { + url, + title: result.title || result.hostname, + content: result.content + } + } }) - // 添加knowledge结果 - const knowledgeResults = message.metadata?.knowledge - knowledgeResults?.forEach((result) => { - data.set(result.sourceUrl, { - url: result.sourceUrl, - title: result.id, - content: result.content - }) + // 批量处理 knowledge 结果 + message.metadata?.knowledge?.forEach((result) => { + const { sourceUrl } = result + if (sourceUrl && !data[sourceUrl]) { + data[sourceUrl] = { + url: sourceUrl, + title: result.id, + content: result.content + } + } }) - // 添加citations - citationsUrls.forEach((result) => { - if (!data.has(result.url)) { - data.set(result.url, { - url: result.url, - title: result.title || result.hostname || undefined, - content: result.content || undefined - }) + // 批量处理 citations + formattedCitations?.forEach((result) => { + const { url } = result + if (url && !data[url]) { + data[url] = { + url, + title: result.title || result.hostname, + content: result.content + } } }) @@ -148,61 +121,62 @@ const MessageContent: React.FC = ({ message: _message, model }) => { // Process content to make citation numbers clickable const processedContent = useMemo(() => { - if ( - !( - message.metadata?.citations || - message.metadata?.webSearch || - message.metadata?.webSearchInfo || - message.metadata?.annotations || - message.metadata?.knowledge - ) - ) { - return message.content + const metadataFields = ['citations', 'webSearch', 'webSearchInfo', 'annotations', 'knowledge'] + const hasMetadata = metadataFields.some((field) => message.metadata?.[field]) + let content = message.content.replace(toolUseRegex, '') + + if (!hasMetadata) { + return content } - let content = message.content + // 预先计算citations数组,避免重复计算 + const websearchResults = message?.metadata?.webSearch?.results?.map((result) => result.url) || [] + const knowledgeResults = message?.metadata?.knowledge?.map((result) => result.sourceUrl) || [] + const citations = message?.metadata?.citations || [...websearchResults, ...knowledgeResults] - const websearchResultsCitations = message?.metadata?.webSearch?.results?.map((result) => result.url) || [] - const knowledgeResultsCitations = message?.metadata?.knowledge?.map((result) => result.sourceUrl) || [] - - const searchResultsCitations = [...websearchResultsCitations, ...knowledgeResultsCitations] - - const citations = message?.metadata?.citations || searchResultsCitations - - // Convert [n] format to superscript numbers and make them clickable - // Use tag for superscript and make it a link with citation data + // 优化正则表达式匹配 if (message.metadata?.webSearch || message.metadata?.knowledge) { + // 合并两个正则为一个,减少遍历次数 content = content.replace(/\[\[(\d+)\]\]|\[(\d+)\]/g, (match, num1, num2) => { const num = num1 || num2 const index = parseInt(num) - 1 - if (index >= 0 && index < citations.length) { - const link = citations[index] - const isWebLink = link && (link.startsWith('http://') || link.startsWith('https://')) - const citationData = link ? encodeHTML(JSON.stringify(citationsData.get(link) || { url: link })) : null - return link && isWebLink - ? `[${num}](${link})` - : `${num}` + + if (index < 0 || index >= citations.length) { + return match } - return match + + const link = citations[index] + + if (!link) { + return match + } + + const isWebLink = link.startsWith('http://') || link.startsWith('https://') + if (!isWebLink) { + return `${num}` + } + + const citation = citationsData[link] || { url: link } + if (citation.content) { + citation.content = citation.content.substring(0, 200) + } + + return `[${num}](${link})` }) } else { - content = content.replace(/\[(\d+)<\/sup>\]\(([^)]+)\)/g, (_, num, url) => { - const citationData = url ? encodeHTML(JSON.stringify(citationsData.get(url) || { url })) : null + // 使用预编译的正则表达式 + const citationRegex = /\[(\d+)<\/sup>\]\(([^)]+)\)/g + content = content.replace(citationRegex, (_, num, url) => { + const citation = citationsData[url] || { url } + const citationData = url ? encodeHTML(JSON.stringify(citation)) : null return `[${num}](${url})` }) } - return content - }, [ - message.metadata?.citations, - message.metadata?.webSearch, - message.metadata?.knowledge, - message.metadata?.webSearchInfo, - message.metadata?.annotations, - message.content, - citationsData - ]) - if (message.status === 'sending') { + return content + }, [message.content, message.metadata, citationsData]) + + if (messageStatus.isSending) { return ( @@ -210,7 +184,7 @@ const MessageContent: React.FC = ({ message: _message, model }) => { ) } - if (message.status === 'searching') { + if (messageStatus.isSearching) { return ( @@ -220,104 +194,30 @@ const MessageContent: React.FC = ({ message: _message, model }) => { ) } - if (message.status === 'error') { + if (messageStatus.isError) { return } - if (message.type === '@' && model) { + if (messageStatus.isMention && model) { const content = `[@${model.name}](#) ${getBriefInfo(message.content)}` return } - const toolUseRegex = /([\s\S]*?)<\/tool_use>/g + return ( - - {message.mentions?.map((model) => {'@' + model.name})} - + {mentionsData && ( + + {mentionsData.map(({ key, name }) => ( + {'@' + name} + ))} + + )} - - {message.metadata?.generateImage && } - {message.translatedContent && ( - - - - - {message.translatedContent === t('translate.processing') ? ( - - ) : ( - - )} - - )} - {hasCitations && ( - <> - {message?.metadata?.groundingMetadata && message.status === 'success' && ( - <> - ({ - number: index + 1, - url: chunk?.web?.uri || '', - title: chunk?.web?.title, - showFavicon: false - })) || [] - } - /> - - - )} - {formattedCitations && ( - ({ - number: citation.number, - url: citation.url, - hostname: citation.hostname, - showFavicon: isWebCitation - }))} - /> - )} - {(message?.metadata?.webSearch || message.metadata?.knowledge) && message.status === 'success' && ( - ({ - number: index + 1, - url: result.url, - title: result.title, - showFavicon: true, - type: 'websearch' - })) || []), - ...(message.metadata.knowledge?.map((result, index) => ({ - number: (message.metadata?.webSearch?.results?.length || 0) + index + 1, - url: result.sourceUrl, - title: result.sourceUrl, - showFavicon: true, - type: 'knowledge' - })) || []) - ]} - /> - )} - {message?.metadata?.webSearchInfo && message.status === 'success' && ( - ({ - number: index + 1, - url: result.link || result.url, - title: result.title, - showFavicon: true - }))} - /> - )} - - )} - + + + + ) @@ -332,10 +232,14 @@ const MessageContentLoading = styled.div` margin-bottom: 5px; ` -const SearchingContainer = styled.div` +const baseContainer = css` display: flex; flex-direction: row; align-items: center; +` + +const SearchingContainer = styled.div` + ${baseContainer} background-color: var(--color-background-mute); padding: 10px; border-radius: 10px; @@ -354,8 +258,4 @@ const SearchingText = styled.div` color: var(--color-text-1); ` -const SearchEntryPoint = styled.div` - margin: 10px 2px; -` - export default React.memo(MessageContent) diff --git a/src/renderer/src/pages/home/Messages/MessageImage.tsx b/src/renderer/src/pages/home/Messages/MessageImage.tsx index 21550d21..2141d77a 100644 --- a/src/renderer/src/pages/home/Messages/MessageImage.tsx +++ b/src/renderer/src/pages/home/Messages/MessageImage.tsx @@ -8,10 +8,10 @@ import { ZoomInOutlined, ZoomOutOutlined } from '@ant-design/icons' +import i18n from '@renderer/i18n' import { Message } from '@renderer/types' import { Image as AntdImage, Space } from 'antd' import { FC } from 'react' -import { useTranslation } from 'react-i18next' import styled from 'styled-components' interface Props { @@ -19,73 +19,8 @@ interface Props { } const MessageImage: FC = ({ message }) => { - const { t } = useTranslation() - - const onDownload = (imageBase64: string, index: number) => { - try { - const link = document.createElement('a') - link.href = imageBase64 - link.download = `image-${Date.now()}-${index}.png` - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - window.message.success(t('message.download.success')) - } catch (error) { - console.error('下载图片失败:', error) - window.message.error(t('message.download.failed')) - } - } - - // 复制图片到剪贴板 - const onCopy = async (type: string, image: string) => { - try { - switch (type) { - case 'base64': { - // 处理 base64 格式的图片 - const parts = image.split(';base64,') - if (parts.length === 2) { - const mimeType = parts[0].replace('data:', '') - const base64Data = parts[1] - const byteCharacters = atob(base64Data) - const byteArrays: Uint8Array[] = [] - - for (let offset = 0; offset < byteCharacters.length; offset += 512) { - const slice = byteCharacters.slice(offset, offset + 512) - const byteNumbers = new Array(slice.length) - for (let i = 0; i < slice.length; i++) { - byteNumbers[i] = slice.charCodeAt(i) - } - const byteArray = new Uint8Array(byteNumbers) - byteArrays.push(byteArray) - } - - const blob = new Blob(byteArrays, { type: mimeType }) - await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })]) - } else { - throw new Error('无效的 base64 图片格式') - } - break - } - case 'url': - { - // 处理 URL 格式的图片 - const response = await fetch(image) - const blob = await response.blob() - - await navigator.clipboard.write([ - new ClipboardItem({ - [blob.type]: blob - }) - ]) - } - break - } - - window.message.success(t('message.copy.success')) - } catch (error) { - console.error('复制图片失败:', error) - window.message.error(t('message.copy.failed')) - } + if (!message.metadata?.generateImage) { + return null } return ( @@ -145,4 +80,71 @@ const ToobarWrapper = styled(Space)` } ` +const onDownload = (imageBase64: string, index: number) => { + try { + const link = document.createElement('a') + link.href = imageBase64 + link.download = `image-${Date.now()}-${index}.png` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.message.success(i18n.t('message.download.success')) + } catch (error) { + console.error('下载图片失败:', error) + window.message.error(i18n.t('message.download.failed')) + } +} + +// 复制图片到剪贴板 +const onCopy = async (type: string, image: string) => { + try { + switch (type) { + case 'base64': { + // 处理 base64 格式的图片 + const parts = image.split(';base64,') + if (parts.length === 2) { + const mimeType = parts[0].replace('data:', '') + const base64Data = parts[1] + const byteCharacters = atob(base64Data) + const byteArrays: Uint8Array[] = [] + + for (let offset = 0; offset < byteCharacters.length; offset += 512) { + const slice = byteCharacters.slice(offset, offset + 512) + const byteNumbers = new Array(slice.length) + for (let i = 0; i < slice.length; i++) { + byteNumbers[i] = slice.charCodeAt(i) + } + const byteArray = new Uint8Array(byteNumbers) + byteArrays.push(byteArray) + } + + const blob = new Blob(byteArrays, { type: mimeType }) + await navigator.clipboard.write([new ClipboardItem({ [mimeType]: blob })]) + } else { + throw new Error('无效的 base64 图片格式') + } + break + } + case 'url': + { + // 处理 URL 格式的图片 + const response = await fetch(image) + const blob = await response.blob() + + await navigator.clipboard.write([ + new ClipboardItem({ + [blob.type]: blob + }) + ]) + } + break + } + + window.message.success(i18n.t('message.copy.success')) + } catch (error) { + console.error('复制图片失败:', error) + window.message.error(i18n.t('message.copy.failed')) + } +} + export default MessageImage diff --git a/src/renderer/src/pages/home/Messages/MessageTranslate.tsx b/src/renderer/src/pages/home/Messages/MessageTranslate.tsx new file mode 100644 index 00000000..58ce4c22 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/MessageTranslate.tsx @@ -0,0 +1,35 @@ +import { TranslationOutlined } from '@ant-design/icons' +import { Message } from '@renderer/types' +import { Divider } from 'antd' +import { FC, Fragment } from 'react' +import { useTranslation } from 'react-i18next' +import BeatLoader from 'react-spinners/BeatLoader' + +import Markdown from '../Markdown/Markdown' + +interface Props { + message: Message +} + +const MessageTranslate: FC = ({ message }) => { + const { t } = useTranslation() + + if (!message.translatedContent) { + return null + } + + return ( + + + + + {message.translatedContent === t('translate.processing') ? ( + + ) : ( + + )} + + ) +} + +export default MessageTranslate diff --git a/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx new file mode 100644 index 00000000..9cc9c837 --- /dev/null +++ b/src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx @@ -0,0 +1,181 @@ +import { CodeOutlined, PlusOutlined } from '@ant-design/icons' +import { nanoid } from '@reduxjs/toolkit' +import DragableList from '@renderer/components/DragableList' +import IndicatorLight from '@renderer/components/IndicatorLight' +import { HStack, VStack } from '@renderer/components/Layout' +import Scrollbar from '@renderer/components/Scrollbar' +import { useMCPServers } from '@renderer/hooks/useMCPServers' +import { MCPServer } from '@renderer/types' +import { FC, useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +import { SettingTitle } from '..' +import McpSettings from './McpSettings' + +interface Props { + selectedMcpServer: MCPServer | null + setSelectedMcpServer: (server: MCPServer | null) => void +} + +const McpServersList: FC = ({ selectedMcpServer, setSelectedMcpServer }) => { + const { mcpServers, addMCPServer, updateMcpServers } = useMCPServers() + const { t } = useTranslation() + + const onAddMcpServer = useCallback(async () => { + const newServer = { + id: nanoid(), + name: t('settings.mcp.newServer'), + description: '', + baseUrl: '', + command: '', + args: [], + env: {}, + isActive: false + } + addMCPServer(newServer) + window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-list' }) + setSelectedMcpServer(newServer) + }, [addMCPServer, setSelectedMcpServer, t]) + + return ( + + + + {t('settings.mcp.newServer')} + + + + {t('settings.mcp.addServer')} + + + {(server) => ( + setSelectedMcpServer(server)} + className={selectedMcpServer?.id === server.id ? 'active' : ''}> + + + + + {server.name} + + + + + {server.description} + + )} + + + {selectedMcpServer && } + + ) +} + +const Container = styled(HStack)` + flex: 1; + width: 350px; + height: calc(100vh - var(--navbar-height)); + overflow: hidden; +` + +const ServersList = styled(Scrollbar)` + gap: 16px; + display: flex; + flex-direction: column; + height: calc(100vh - var(--navbar-height)); + width: 350px; + padding: 15px; + border-right: 0.5px solid var(--color-border); +` + +const ServerSettings = styled(VStack)` + flex: 1; + height: calc(100vh - var(--navbar-height)); +` + +const ListHeader = styled.div` + width: 100%; + + h2 { + font-size: 20px; + margin: 0; + } +` + +const ServerCard = styled.div` + display: flex; + flex-direction: column; + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 10px 16px; + cursor: pointer; + transition: all 0.2s ease; + height: 120px; + background-color: var(--color-background); + + &:hover, + &.active { + border-color: var(--color-primary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transform: translateY(-2px); + } +` + +const ServerHeader = styled.div` + display: flex; + align-items: center; + margin-bottom: 5px; +` + +const ServerIcon = styled.div` + font-size: 18px; + color: var(--color-primary); + margin-right: 8px; +` + +const ServerName = styled.div` + font-weight: 500; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +` + +const StatusIndicator = styled.div` + margin-left: 8px; +` + +const ServerDescription = styled.div` + font-size: 12px; + color: var(--color-text-2); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + width: 100%; + word-break: break-word; +` + +const AddServerCard = styled(ServerCard)` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + border-style: dashed; + background-color: transparent; + color: var(--color-text-2); +` + +const AddServerText = styled.div` + margin-top: 12px; + font-weight: 500; +` + +export default McpServersList diff --git a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx index 569a487b..3800be55 100644 --- a/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/McpSettings.tsx @@ -538,7 +538,7 @@ const McpSettings: React.FC = ({ server }) => { } return ( - + diff --git a/src/renderer/src/pages/settings/MCPSettings/index.tsx b/src/renderer/src/pages/settings/MCPSettings/index.tsx index e94d583a..0e7f98a9 100644 --- a/src/renderer/src/pages/settings/MCPSettings/index.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/index.tsx @@ -1,108 +1,43 @@ -import { ArrowLeftOutlined, CodeOutlined, PlusOutlined } from '@ant-design/icons' -import { nanoid } from '@reduxjs/toolkit' -import IndicatorLight from '@renderer/components/IndicatorLight' +import { ArrowLeftOutlined } from '@ant-design/icons' import { VStack } from '@renderer/components/Layout' import { useTheme } from '@renderer/context/ThemeProvider' import { useMCPServers } from '@renderer/hooks/useMCPServers' import { MCPServer } from '@renderer/types' -import { FC, useCallback, useEffect, useState } from 'react' +import { FC, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Route, Routes, useLocation, useNavigate } from 'react-router' +import { Route, Routes, useLocation } from 'react-router' import { Link } from 'react-router-dom' import styled from 'styled-components' -import { SettingContainer, SettingTitle } from '..' +import { SettingContainer } from '..' import InstallNpxUv from './InstallNpxUv' -import McpSettings from './McpSettings' +import McpServersList from './McpServersList' import NpxSearch from './NpxSearch' const MCPSettings: FC = () => { const { t } = useTranslation() - const { mcpServers, addMCPServer } = useMCPServers() - const [selectedMcpServer, setSelectedMcpServer] = useState(null) + const { mcpServers } = useMCPServers() + const [selectedMcpServer, setSelectedMcpServer] = useState(mcpServers[0]) const { theme } = useTheme() - const navigate = useNavigate() const location = useLocation() const pathname = location.pathname - const onAddMcpServer = useCallback(async () => { - const newServer = { - id: nanoid(), - name: t('settings.mcp.newServer'), - description: '', - baseUrl: '', - command: '', - args: [], - env: {}, - isActive: false - } - addMCPServer(newServer) - window.message.success({ content: t('settings.mcp.addSuccess'), key: 'mcp-list' }) - setSelectedMcpServer(newServer) - }, [addMCPServer, t]) - useEffect(() => { const _selectedMcpServer = mcpServers.find((server) => server.id === selectedMcpServer?.id) setSelectedMcpServer(_selectedMcpServer || mcpServers[0]) }, [mcpServers, selectedMcpServer]) + // Check if the selected server still exists in the updated mcpServers list useEffect(() => { - // Check if the selected server still exists in the updated mcpServers list if (selectedMcpServer) { const serverExists = mcpServers.some((server) => server.id === selectedMcpServer.id) if (!serverExists) { - setSelectedMcpServer(null) + setSelectedMcpServer(mcpServers[0]) } - } else { - setSelectedMcpServer(null) } }, [mcpServers, selectedMcpServer]) - const McpServersList = useCallback( - () => ( - - - {t('settings.mcp.newServer')} - - - - - {t('settings.mcp.addServer')} - - {mcpServers.map((server) => ( - { - setSelectedMcpServer(server) - navigate(`/settings/mcp/server/${server.id}`) - }}> - - - - - {server.name} - - - - - - {server.description && - server.description.substring(0, 60) + (server.description.length > 60 ? '...' : '')} - - - ))} - - - ), - [mcpServers, navigate, onAddMcpServer, t] - ) - const isHome = pathname === '/settings/mcp' return ( @@ -118,8 +53,12 @@ const MCPSettings: FC = () => { )} - } /> - : null} /> + + } + /> (message.id === lastAssistantMessage.id ? updatedAssistantMessage : message)) } + +/** + * 格式化 citations + * @param metadata 消息的 metadata + * @param model 模型 + * @param urlCache url 缓存 + * @returns citations + */ +export const formatCitations = ( + metadata: Message['metadata'], + model: Model | undefined, + urlCache: Map +): Citation[] | null => { + if (!metadata?.citations?.length && !metadata?.annotations?.length) { + return null + } + + interface UrlInfo { + hostname: string + url: string + } + + // 提取 URL 处理函数到组件外 + const getUrlInfo = (url: string, urlCache: Map): UrlInfo => { + try { + let urlObj = urlCache.get(url) + if (!urlObj) { + urlObj = new URL(url) + urlCache.set(url, urlObj) + } + return { hostname: urlObj.hostname, url } + } catch { + return { hostname: url, url } + } + } + + // 使用 Set 提前去重,减少后续处理 + const uniqueUrls = new Set() + let citations: Citation[] = [] + + if (model && isOpenAIWebSearch(model)) { + citations = + metadata.annotations + ?.filter((annotation) => { + const url = annotation.url_citation?.url + if (!url || uniqueUrls.has(url)) return false + uniqueUrls.add(url) + return true + }) + .map((annotation, index) => ({ + number: index + 1, + url: annotation.url_citation.url, + hostname: annotation.url_citation.title, + title: annotation.url_citation.title + })) || [] + } else { + citations = (metadata?.citations || []) + .filter((url) => { + if (!url || uniqueUrls.has(url)) return false + uniqueUrls.add(url) + return true + }) + .map((url, index) => { + const { hostname } = getUrlInfo(url, urlCache) + return { + number: index + 1, + url, + hostname + } + }) + } + + return citations +}