feat(Messages): add MessageCitations and MessageTranslate components for citation and translation display

- Introduced MessageCitations component to handle and display citations from messages.
- Added MessageTranslate component to show translated content with loading state.
- Updated MessageContent to integrate new components and streamline citation formatting.
- Refactored citation handling logic in formats utility for improved performance and clarity.
- Enhanced MessageImage component to manage image download and clipboard copy functionality.

refactor(MCP): optimize MCP server handling in Inputbar and MCPToolsButton

wip

refactor(MCPSettings): streamline MCP server management and enhance UI components

- Removed unused imports and optimized state management for selected MCP servers.
- Introduced McpServersList component to encapsulate server listing and management logic.
- Updated routing to accommodate the new component structure.
- Adjusted styles for better layout and user experience in MCP settings.
This commit is contained in:
kangfenmao 2025-04-22 11:01:25 +08:00
parent 98f2c8a0b6
commit bf8baedfcf
12 changed files with 923 additions and 754 deletions

View File

@ -2,6 +2,7 @@ import store, { useAppDispatch, useAppSelector } from '@renderer/store'
import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp' import { addMCPServer, deleteMCPServer, setMCPServers, updateMCPServer } from '@renderer/store/mcp'
import { MCPServer } from '@renderer/types' import { MCPServer } from '@renderer/types'
import { IpcChannel } from '@shared/IpcChannel' import { IpcChannel } from '@shared/IpcChannel'
import { useMemo } from 'react'
const ipcRenderer = window.electron.ipcRenderer const ipcRenderer = window.electron.ipcRenderer
@ -12,7 +13,7 @@ ipcRenderer.on(IpcChannel.Mcp_ServersChanged, (_event, servers) => {
export const useMCPServers = () => { export const useMCPServers = () => {
const mcpServers = useAppSelector((state) => state.mcp.servers) 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() const dispatch = useAppDispatch()
return { return {

View File

@ -22,7 +22,7 @@ import WebSearchService from '@renderer/services/WebSearchService'
import { useAppDispatch } from '@renderer/store' import { useAppDispatch } from '@renderer/store'
import { sendMessage as _sendMessage } from '@renderer/store/messages' import { sendMessage as _sendMessage } from '@renderer/store/messages'
import { setSearching } from '@renderer/store/runtime' 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 { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils'
import { getFilesFromDropEvent } from '@renderer/utils/input' import { getFilesFromDropEvent } from '@renderer/utils/input'
import { documentExts, imageExts, textExts } from '@shared/config/constant' import { documentExts, imageExts, textExts } from '@shared/config/constant'
@ -107,7 +107,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const [isTranslating, setIsTranslating] = useState(false) const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([]) const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
const [mentionModels, setMentionModels] = useState<Model[]>([]) const [mentionModels, setMentionModels] = useState<Model[]>([])
const [enabledMCPs, setEnabledMCPs] = useState<MCPServer[]>(assistant.mcpServers || [])
const [isDragging, setIsDragging] = useState(false) const [isDragging, setIsDragging] = useState(false)
const [textareaHeight, setTextareaHeight] = useState<number>() const [textareaHeight, setTextareaHeight] = useState<number>()
const startDragY = useRef<number>(0) const startDragY = useRef<number>(0)
@ -122,7 +121,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const quickPanel = useQuickPanel() const quickPanel = useQuickPanel()
const showKnowledgeIcon = useSidebarIconShow('knowledge') const showKnowledgeIcon = useSidebarIconShow('knowledge')
// const showMCPToolsIcon = isFunctionCallingModel(model)
const [tokenCount, setTokenCount] = useState(0) const [tokenCount, setTokenCount] = useState(0)
@ -168,11 +166,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
} }
}, [textareaHeight]) }, [textareaHeight])
// Reset to assistant knowledge mcp servers
useEffect(() => {
setEnabledMCPs(assistant.mcpServers || [])
}, [assistant.mcpServers])
const sendMessage = useCallback(async () => { const sendMessage = useCallback(async () => {
if (inputEmpty || loading) { if (inputEmpty || loading) {
return return
@ -202,8 +195,10 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
userMessage.mentions = mentionModels userMessage.mentions = mentionModels
} }
if (!isEmpty(enabledMCPs) && !isEmpty(activedMcpServers)) { if (!isEmpty(assistant.mcpServers) && !isEmpty(activedMcpServers)) {
userMessage.enabledMCPs = activedMcpServers.filter((server) => enabledMCPs?.some((s) => s.id === server.id)) userMessage.enabledMCPs = activedMcpServers.filter((server) =>
assistant.mcpServers?.some((s) => s.id === server.id)
)
} }
userMessage.usage = await estimateMessageUsage(userMessage) userMessage.usage = await estimateMessageUsage(userMessage)
@ -225,9 +220,9 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
console.error('Failed to send message:', error) console.error('Failed to send message:', error)
} }
}, [ }, [
activedMcpServers,
assistant, assistant,
dispatch, dispatch,
enabledMCPs,
files, files,
inputEmpty, inputEmpty,
loading, loading,
@ -235,8 +230,7 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
resizeTextArea, resizeTextArea,
selectedKnowledgeBases, selectedKnowledgeBases,
text, text,
topic, topic
activedMcpServers
]) ])
const translate = useCallback(async () => { const translate = useCallback(async () => {
@ -507,9 +501,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
// Reset to assistant default model // Reset to assistant default model
assistant.defaultModel && setModel(assistant.defaultModel) assistant.defaultModel && setModel(assistant.defaultModel)
// Reset to assistant knowledge mcp servers
!isEmpty(assistant.mcpServers) && setEnabledMCPs(assistant.mcpServers || [])
addTopic(topic) addTopic(topic)
setActiveTopic(topic) setActiveTopic(topic)
@ -773,17 +764,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
setSelectedKnowledgeBases(newKnowledgeBases ?? []) 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 = () => { const showWebSearchEnableModal = () => {
window.modal.confirm({ window.modal.confirm({
title: t('chat.input.web_search.enable'), title: t('chat.input.web_search.enable'),
@ -967,9 +947,8 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
/> />
)} )}
<MCPToolsButton <MCPToolsButton
assistant={assistant}
ref={mcpToolsButtonRef} ref={mcpToolsButtonRef}
enabledMCPs={enabledMCPs}
toggelEnableMCP={toggelEnableMCP}
ToolbarButton={ToolbarButton} ToolbarButton={ToolbarButton}
setInputValue={setText} setInputValue={setText}
resizeTextArea={resizeTextArea} resizeTextArea={resizeTextArea}

View File

@ -1,9 +1,12 @@
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel' import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { MCPPrompt, MCPResource, MCPServer } from '@renderer/types' import { EventEmitter } from '@renderer/services/EventService'
import { Form, Input, Modal, Tooltip } from 'antd' import { Assistant, MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
import { Form, Input, Tooltip } from 'antd'
import { Plus, SquareTerminal } from 'lucide-react' import { Plus, SquareTerminal } from 'lucide-react'
import { FC, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react' import { FC, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router' import { useNavigate } from 'react-router'
@ -14,63 +17,34 @@ export interface MCPToolsButtonRef {
} }
interface Props { interface Props {
assistant: Assistant
ref?: React.RefObject<MCPToolsButtonRef | null> ref?: React.RefObject<MCPToolsButtonRef | null>
enabledMCPs: MCPServer[]
setInputValue: React.Dispatch<React.SetStateAction<string>> setInputValue: React.Dispatch<React.SetStateAction<string>>
resizeTextArea: () => void resizeTextArea: () => void
toggelEnableMCP: (server: MCPServer) => void
ToolbarButton: any ToolbarButton: any
} }
const MCPToolsButton: FC<Props> = ({ // 添加类型定义
ref, interface PromptArgument {
setInputValue, name: string
resizeTextArea, description?: string
enabledMCPs, required?: boolean
toggelEnableMCP,
ToolbarButton
}) => {
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 buttonEnabled = availableMCPs.length > 0
const menuItems = useMemo(() => {
const newList: QuickPanelListItem[] = activedMcpServers.map((server) => ({
label: server.name,
description: server.description || server.baseUrl,
icon: <SquareTerminal />,
action: () => toggelEnableMCP(server),
isSelected: enabledMCPs.some((s) => s.id === server.id)
}))
newList.push({
label: t('settings.mcp.addServer') + '...',
icon: <Plus />,
action: () => navigate('/settings/mcp')
})
return newList
}, [activedMcpServers, t, enabledMCPs, toggelEnableMCP, navigate])
const openQuickPanel = useCallback(() => {
quickPanel.open({
title: t('settings.mcp.title'),
list: menuItems,
symbol: 'mcp',
multiple: true,
afterAction({ item }) {
item.isSelected = !item.isSelected
} }
})
}, [menuItems, quickPanel, t]) interface MCPPromptWithArgs extends MCPPrompt {
// Extract and format all content from the prompt response arguments?: PromptArgument[]
const extractPromptContent = useCallback((response: any): string | null => { }
interface ResourceData {
blob?: string
mimeType?: string
name?: string
text?: string
uri?: string
}
// 提取到组件外的工具函数
const extractPromptContent = (response: any): string | null => {
// Handle string response (backward compatibility) // Handle string response (backward compatibility)
if (typeof response === 'string') { if (typeof response === 'string') {
return response return response
@ -89,30 +63,23 @@ const MCPToolsButton: FC<Props> = ({
// Process different content types // Process different content types
switch (message.content.type) { switch (message.content.type) {
case 'text': case 'text':
// Add formatted text content with role
formattedContent += `${rolePrefix}${message.content.text}\n\n` formattedContent += `${rolePrefix}${message.content.text}\n\n`
break break
case 'image': case 'image':
// Format image as markdown with proper attribution
if (message.content.data && message.content.mimeType) { if (message.content.data && message.content.mimeType) {
const imageData = message.content.data
const mimeType = message.content.mimeType
// Include role if available
if (rolePrefix) { if (rolePrefix) {
formattedContent += `${rolePrefix}\n` formattedContent += `${rolePrefix}\n`
} }
formattedContent += `![Image](data:${mimeType};base64,${imageData})\n\n` formattedContent += `![Image](data:${message.content.mimeType};base64,${message.content.data})\n\n`
} }
break break
case 'audio': case 'audio':
// Add indicator for audio content with role
formattedContent += `${rolePrefix}[Audio content available]\n\n` formattedContent += `${rolePrefix}[Audio content available]\n\n`
break break
case 'resource': case 'resource':
// Add indicator for resource content with role
if (message.content.text) { if (message.content.text) {
formattedContent += `${rolePrefix}${message.content.text}\n\n` formattedContent += `${rolePrefix}${message.content.text}\n\n`
} else { } else {
@ -121,7 +88,6 @@ const MCPToolsButton: FC<Props> = ({
break break
default: default:
// Add text content if available with role, otherwise show placeholder
if (message.content.text) { if (message.content.text) {
formattedContent += `${rolePrefix}${message.content.text}\n\n` formattedContent += `${rolePrefix}${message.content.text}\n\n`
} }
@ -141,25 +107,103 @@ const MCPToolsButton: FC<Props> = ({
} }
return null return null
}
const MCPToolsButton: FC<Props> = ({ ref, setInputValue, resizeTextArea, ToolbarButton, ...props }) => {
const { activedMcpServers } = useMCPServers()
const { t } = useTranslation()
const quickPanel = useQuickPanel()
const navigate = useNavigate()
const [form] = Form.useForm()
const { updateAssistant, assistant } = useAssistant(props.assistant.id)
// 使用 useRef 存储不需要触发重渲染的值
const isMountedRef = useRef(true)
useEffect(() => {
return () => {
isMountedRef.current = false
}
}, []) }, [])
// Helper function to insert prompt into text area 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: <SquareTerminal />,
action: () => EventEmitter.emit('mcp-server-select', server),
isSelected: assistantMcpServers.some((s) => s.id === server.id)
}))
newList.push({
label: t('settings.mcp.addServer') + '...',
icon: <Plus />,
action: () => navigate('/settings/mcp')
})
return newList
}, [activedMcpServers, t, assistantMcpServers, navigate])
const openQuickPanel = useCallback(() => {
quickPanel.open({
title: t('settings.mcp.title'),
list: menuItems,
symbol: 'mcp',
multiple: true,
afterAction({ item }) {
item.isSelected = !item.isSelected
}
})
}, [menuItems, quickPanel, t])
// 使用 useCallback 优化 insertPromptIntoTextArea
const insertPromptIntoTextArea = useCallback( const insertPromptIntoTextArea = useCallback(
(promptText: string) => { (promptText: string) => {
setInputValue((prev) => { setInputValue((prev) => {
const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement 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 cursorPosition = textArea.selectionStart
const selectionStart = cursorPosition const selectionStart = cursorPosition
const selectionEndPosition = cursorPosition + promptText.length const selectionEndPosition = cursorPosition + promptText.length
const newText = prev.slice(0, cursorPosition) + promptText + prev.slice(cursorPosition) const newText = prev.slice(0, cursorPosition) + promptText + prev.slice(cursorPosition)
setTimeout(() => { // 使用 requestAnimationFrame 优化 DOM 操作
requestAnimationFrame(() => {
textArea.focus() textArea.focus()
textArea.setSelectionRange(selectionStart, selectionEndPosition) textArea.setSelectionRange(selectionStart, selectionEndPosition)
resizeTextArea() resizeTextArea()
}, 10) })
return newText return newText
}) })
}, },
@ -167,22 +211,29 @@ const MCPToolsButton: FC<Props> = ({
) )
const handlePromptSelect = useCallback( const handlePromptSelect = useCallback(
(prompt: MCPPrompt) => { (prompt: MCPPromptWithArgs) => {
// Using a 10ms delay to ensure the modal or UI updates are fully rendered before executing the logic. const server = activedMcpServers.find((s) => s.id === prompt.serverId)
setTimeout(async () => { if (!server) return
const server = enabledMCPs.find((s) => s.id === prompt.serverId)
if (server) { const handlePromptResponse = async (response: any) => {
const promptContent = extractPromptContent(response)
if (promptContent) {
insertPromptIntoTextArea(promptContent)
} else {
throw new Error('Invalid prompt response format')
}
}
const handlePromptWithArgs = async () => {
try { try {
// Check if the prompt has arguments
if (prompt.arguments && prompt.arguments.length > 0) {
// Reset form when opening a new modal
form.resetFields() form.resetFields()
Modal.confirm({ const result = await new Promise<Record<string, string>>((resolve, reject) => {
window.modal.confirm({
title: `${t('settings.mcp.prompts.arguments')}: ${prompt.name}`, title: `${t('settings.mcp.prompts.arguments')}: ${prompt.name}`,
content: ( content: (
<Form form={form} layout="vertical"> <Form form={form} layout="vertical">
{prompt.arguments.map((arg, index) => ( {prompt.arguments?.map((arg, index) => (
<Form.Item <Form.Item
key={index} key={index}
name={arg.name} name={arg.name}
@ -198,71 +249,66 @@ const MCPToolsButton: FC<Props> = ({
), ),
onOk: async () => { onOk: async () => {
try { try {
// Validate and get form values
const values = await form.validateFields() const values = await form.validateFields()
resolve(values)
} catch (error) {
reject(error)
}
},
onCancel: () => reject(new Error('cancelled')),
okText: t('common.confirm'),
cancelText: t('common.cancel')
})
})
const response = await window.api.mcp.getPrompt({ const response = await window.api.mcp.getPrompt({
server, server,
name: prompt.name, name: prompt.name,
args: values args: result
}) })
// Extract and format prompt content from the response await handlePromptResponse(response)
const promptContent = extractPromptContent(response)
if (promptContent) {
insertPromptIntoTextArea(promptContent)
} else {
throw new Error('Invalid prompt response format')
}
return Promise.resolve()
} catch (error: Error | any) { } catch (error: Error | any) {
if (error.errorFields) { if (error.message !== 'cancelled') {
// This is a form validation error, handled by Ant Design window.modal.error({
return Promise.reject(error)
}
Modal.error({
title: t('common.error'), title: t('common.error'),
content: error.message || t('settings.mcp.prompts.genericError') content: error.message || t('settings.mcp.prompts.genericError')
}) })
return Promise.reject(error)
} }
}, }
okText: t('common.confirm'), }
cancelText: t('common.cancel')
}) const handlePromptWithoutArgs = async () => {
} else { try {
// If no arguments, get the prompt directly
const response = await window.api.mcp.getPrompt({ const response = await window.api.mcp.getPrompt({
server, server,
name: prompt.name name: prompt.name
}) })
await handlePromptResponse(response)
// 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) { } catch (error: Error | any) {
Modal.error({ window.modal.error({
title: t('common.error'), title: t('common.error'),
content: error.message || t('settings.mcp.prompt.genericError') content: error.message || t('settings.mcp.prompt.genericError')
}) })
} }
} }
}, 10)
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 promptList = useMemo(async () => {
const prompts: MCPPrompt[] = [] const prompts: MCPPrompt[] = []
for (const server of enabledMCPs) { for (const server of activedMcpServers) {
const serverPrompts = await window.api.mcp.listPrompts(server) const serverPrompts = await window.api.mcp.listPrompts(server)
prompts.push(...serverPrompts) prompts.push(...serverPrompts)
} }
@ -271,9 +317,9 @@ const MCPToolsButton: FC<Props> = ({
label: prompt.name, label: prompt.name,
description: prompt.description, description: prompt.description,
icon: <SquareTerminal />, icon: <SquareTerminal />,
action: () => handlePromptSelect(prompt) action: () => handlePromptSelect(prompt as MCPPromptWithArgs)
})) }))
}, [handlePromptSelect, enabledMCPs]) }, [handlePromptSelect, activedMcpServers])
const openPromptList = useCallback(async () => { const openPromptList = useCallback(async () => {
const prompts = await promptList const prompts = await promptList
@ -287,87 +333,63 @@ const MCPToolsButton: FC<Props> = ({
const handleResourceSelect = useCallback( const handleResourceSelect = useCallback(
(resource: MCPResource) => { (resource: MCPResource) => {
setTimeout(async () => { const server = activedMcpServers.find((s) => s.id === resource.serverId)
const server = enabledMCPs.find((s) => s.id === resource.serverId) if (!server) return
if (server) {
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)
}
}
requestAnimationFrame(async () => {
try { try {
// Fetch the resource data
const response = await window.api.mcp.getResource({ const response = await window.api.mcp.getResource({
server, server,
uri: resource.uri uri: resource.uri
}) })
console.log('Resource Data:', response)
// Check if the response has the expected structure if (response?.contents && Array.isArray(response.contents)) {
if (response && response.contents && Array.isArray(response.contents)) { response.contents.forEach((content: ResourceData) => processResourceContent(content))
// 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 { } else {
// For other binary types, just mention it's available processResourceContent(response as ResourceData)
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) { } catch (error: Error | any) {
Modal.error({ window.modal.error({
title: t('common.error'), title: t('common.error'),
content: error.message || t('settings.mcp.resources.genericError') content: error.message || t('settings.mcp.resources.genericError')
}) })
} }
} })
}, 10)
}, },
[enabledMCPs, t, insertPromptIntoTextArea] [activedMcpServers, t, insertPromptIntoTextArea]
) )
// 优化 resourcesList 的状态更新
const [resourcesList, setResourcesList] = useState<QuickPanelListItem[]>([]) const [resourcesList, setResourcesList] = useState<QuickPanelListItem[]>([])
useEffect(() => { useEffect(() => {
let isMounted = true
const fetchResources = async () => { const fetchResources = async () => {
const resources: MCPResource[] = [] const resources: MCPResource[] = []
for (const server of enabledMCPs) { for (const server of activedMcpServers) {
const serverResources = await window.api.mcp.listResources(server) const serverResources = await window.api.mcp.listResources(server)
resources.push(...serverResources) resources.push(...serverResources)
} }
if (isMounted) {
setResourcesList( setResourcesList(
resources.map((resource) => ({ resources.map((resource) => ({
label: resource.name, label: resource.name,
@ -377,9 +399,14 @@ const MCPToolsButton: FC<Props> = ({
})) }))
) )
} }
}
fetchResources() fetchResources()
}, [handleResourceSelect, enabledMCPs])
return () => {
isMounted = false
}
}, [activedMcpServers, handleResourceSelect])
const openResourcesList = useCallback(async () => { const openResourcesList = useCallback(async () => {
const resources = resourcesList const resources = resourcesList
@ -418,4 +445,5 @@ const MCPToolsButton: FC<Props> = ({
) )
} }
export default MCPToolsButton // 使用 React.memo 包装组件
export default React.memo(MCPToolsButton)

View File

@ -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<Props> = ({ 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 (
<Container>
{message?.metadata?.groundingMetadata && message.status === 'success' && (
<>
<CitationsList
citations={
message.metadata.groundingMetadata?.groundingChunks?.map((chunk, index) => ({
number: index + 1,
url: chunk?.web?.uri || '',
title: chunk?.web?.title,
showFavicon: false
})) || []
}
/>
<SearchEntryPoint
dangerouslySetInnerHTML={{
__html: message.metadata.groundingMetadata?.searchEntryPoint?.renderedContent
? message.metadata.groundingMetadata.searchEntryPoint.renderedContent
.replace(/@media \(prefers-color-scheme: light\)/g, 'body[theme-mode="light"]')
.replace(/@media \(prefers-color-scheme: dark\)/g, 'body[theme-mode="dark"]')
: ''
}}
/>
</>
)}
{formattedCitations && (
<CitationsList
citations={formattedCitations.map((citation) => ({
number: citation.number,
url: citation.url,
hostname: citation.hostname,
showFavicon: isWebCitation
}))}
/>
)}
{(message?.metadata?.webSearch || message.metadata?.knowledge) && message.status === 'success' && (
<CitationsList
citations={[
...(message.metadata.webSearch?.results.map((result, index) => ({
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' && (
<CitationsList
citations={message.metadata.webSearchInfo.map((result, index) => ({
number: index + 1,
url: result.link || result.url,
title: result.title,
showFavicon: true
}))}
/>
)}
</Container>
)
}
const Container = styled.div``
const SearchEntryPoint = styled.div`
margin: 10px 2px;
`
export default MessageCitations

View File

@ -1,97 +1,65 @@
import { SyncOutlined, TranslationOutlined } from '@ant-design/icons' import { SyncOutlined } from '@ant-design/icons'
import { isOpenAIWebSearch } from '@renderer/config/models'
import { getModelUniqId } from '@renderer/services/ModelService' import { getModelUniqId } from '@renderer/services/ModelService'
import { Message, Model } from '@renderer/types' import { Message, Model } from '@renderer/types'
import { getBriefInfo } from '@renderer/utils' import { getBriefInfo } from '@renderer/utils'
import { withMessageThought } from '@renderer/utils/formats' import { formatCitations, withMessageThought } from '@renderer/utils/formats'
import { Divider, Flex } from 'antd' import { encodeHTML } from '@renderer/utils/markdown'
import { Flex } from 'antd'
import { clone } from 'lodash' import { clone } from 'lodash'
import { Search } from 'lucide-react' import { Search } from 'lucide-react'
import React, { Fragment, useMemo } from 'react' import React, { Fragment, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import BarLoader from 'react-spinners/BarLoader' import BarLoader from 'react-spinners/BarLoader'
import BeatLoader from 'react-spinners/BeatLoader' import styled, { css } from 'styled-components'
import styled from 'styled-components'
import Markdown from '../Markdown/Markdown' import Markdown from '../Markdown/Markdown'
import CitationsList from './CitationsList'
import MessageAttachments from './MessageAttachments' import MessageAttachments from './MessageAttachments'
import MessageCitations from './MessageCitations'
import MessageError from './MessageError' import MessageError from './MessageError'
import MessageImage from './MessageImage' import MessageImage from './MessageImage'
import MessageThought from './MessageThought' import MessageThought from './MessageThought'
import MessageTools from './MessageTools' import MessageTools from './MessageTools'
import MessageTranslate from './MessageTranslate'
interface Props { interface Props {
message: Message readonly message: Readonly<Message>
model?: Model readonly model?: Readonly<Model>
} }
const toolUseRegex = /<tool_use>([\s\S]*?)<\/tool_use>/g
const MessageContent: React.FC<Props> = ({ message: _message, model }) => { const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
const { t } = useTranslation() const { t } = useTranslation()
const message = withMessageThought(clone(_message)) const message = withMessageThought(clone(_message))
const isWebCitation = model && (isOpenAIWebSearch(model) || model.provider === 'openrouter')
// HTML实体编码辅助函数 // Memoize message status checks
const encodeHTML = (str: string) => { const messageStatus = useMemo(
return str.replace(/[&<>"']/g, (match) => { () => ({
const entities: { [key: string]: string } = { isSending: message.status === 'sending',
'&': '&amp;', isSearching: message.status === 'searching',
'<': '&lt;', isError: message.status === 'error',
'>': '&gt;', isMention: message.type === '@'
'"': '&quot;', }),
"'": '&apos;' [message.status, message.type]
} )
return entities[match]
}) // 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<string, URL>(), [])
// Format citations for display // Format citations for display
const formattedCitations = useMemo(() => { const formattedCitations = useMemo(
if (!message.metadata?.citations?.length && !message.metadata?.annotations?.length) return null () => formatCitations(message.metadata, model, urlCache),
[message.metadata, model, urlCache]
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 citationsData = useMemo(() => { const citationsData = useMemo(() => {
@ -101,38 +69,43 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
message?.metadata?.groundingMetadata?.groundingChunks?.map((chunk) => chunk?.web) || message?.metadata?.groundingMetadata?.groundingChunks?.map((chunk) => chunk?.web) ||
message?.metadata?.annotations?.map((annotation) => annotation.url_citation) || message?.metadata?.annotations?.map((annotation) => annotation.url_citation) ||
[] []
const citationsUrls = formattedCitations || []
// 合并引用数据 // 使用对象而不是 Map 来提高性能
const data = new Map() const data = {}
// 添加webSearch结果 // 批量处理 webSearch 结果
searchResults.forEach((result) => { searchResults.forEach((result) => {
data.set(result.url || result.uri || result.link, { const url = result.url || result.uri || result.link
url: result.url || result.uri || result.link, if (url && !data[url]) {
data[url] = {
url,
title: result.title || result.hostname, title: result.title || result.hostname,
content: result.content content: result.content
}) }
}
}) })
// 添加knowledge结果 // 批量处理 knowledge 结果
const knowledgeResults = message.metadata?.knowledge message.metadata?.knowledge?.forEach((result) => {
knowledgeResults?.forEach((result) => { const { sourceUrl } = result
data.set(result.sourceUrl, { if (sourceUrl && !data[sourceUrl]) {
url: result.sourceUrl, data[sourceUrl] = {
url: sourceUrl,
title: result.id, title: result.id,
content: result.content content: result.content
}) }
}
}) })
// 添加citations // 批量处理 citations
citationsUrls.forEach((result) => { formattedCitations?.forEach((result) => {
if (!data.has(result.url)) { const { url } = result
data.set(result.url, { if (url && !data[url]) {
url: result.url, data[url] = {
title: result.title || result.hostname || undefined, url,
content: result.content || undefined title: result.title || result.hostname,
}) content: result.content
}
} }
}) })
@ -148,61 +121,62 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
// Process content to make citation numbers clickable // Process content to make citation numbers clickable
const processedContent = useMemo(() => { const processedContent = useMemo(() => {
if ( const metadataFields = ['citations', 'webSearch', 'webSearchInfo', 'annotations', 'knowledge']
!( const hasMetadata = metadataFields.some((field) => message.metadata?.[field])
message.metadata?.citations || let content = message.content.replace(toolUseRegex, '')
message.metadata?.webSearch ||
message.metadata?.webSearchInfo || if (!hasMetadata) {
message.metadata?.annotations || return content
message.metadata?.knowledge
)
) {
return message.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 <sup> tag for superscript and make it a link with citation data
if (message.metadata?.webSearch || message.metadata?.knowledge) { if (message.metadata?.webSearch || message.metadata?.knowledge) {
// 合并两个正则为一个,减少遍历次数
content = content.replace(/\[\[(\d+)\]\]|\[(\d+)\]/g, (match, num1, num2) => { content = content.replace(/\[\[(\d+)\]\]|\[(\d+)\]/g, (match, num1, num2) => {
const num = num1 || num2 const num = num1 || num2
const index = parseInt(num) - 1 const index = parseInt(num) - 1
if (index >= 0 && index < citations.length) {
const link = citations[index] if (index < 0 || index >= citations.length) {
const isWebLink = link && (link.startsWith('http://') || link.startsWith('https://'))
const citationData = link ? encodeHTML(JSON.stringify(citationsData.get(link) || { url: link })) : null
return link && isWebLink
? `[<sup data-citation='${citationData}'>${num}</sup>](${link})`
: `<sup>${num}</sup>`
}
return match return match
}
const link = citations[index]
if (!link) {
return match
}
const isWebLink = link.startsWith('http://') || link.startsWith('https://')
if (!isWebLink) {
return `<sup>${num}</sup>`
}
const citation = citationsData[link] || { url: link }
if (citation.content) {
citation.content = citation.content.substring(0, 200)
}
return `[<sup data-citation='${encodeHTML(JSON.stringify(citation))}'>${num}</sup>](${link})`
}) })
} else { } else {
content = content.replace(/\[<sup>(\d+)<\/sup>\]\(([^)]+)\)/g, (_, num, url) => { // 使用预编译的正则表达式
const citationData = url ? encodeHTML(JSON.stringify(citationsData.get(url) || { url })) : null const citationRegex = /\[<sup>(\d+)<\/sup>\]\(([^)]+)\)/g
content = content.replace(citationRegex, (_, num, url) => {
const citation = citationsData[url] || { url }
const citationData = url ? encodeHTML(JSON.stringify(citation)) : null
return `[<sup data-citation='${citationData}'>${num}</sup>](${url})` return `[<sup data-citation='${citationData}'>${num}</sup>](${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 ( return (
<MessageContentLoading> <MessageContentLoading>
<SyncOutlined spin size={24} /> <SyncOutlined spin size={24} />
@ -210,7 +184,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
) )
} }
if (message.status === 'searching') { if (messageStatus.isSearching) {
return ( return (
<SearchingContainer> <SearchingContainer>
<Search size={24} /> <Search size={24} />
@ -220,104 +194,30 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
) )
} }
if (message.status === 'error') { if (messageStatus.isError) {
return <MessageError message={message} /> return <MessageError message={message} />
} }
if (message.type === '@' && model) { if (messageStatus.isMention && model) {
const content = `[@${model.name}](#) ${getBriefInfo(message.content)}` const content = `[@${model.name}](#) ${getBriefInfo(message.content)}`
return <Markdown message={{ ...message, content }} /> return <Markdown message={{ ...message, content }} />
} }
const toolUseRegex = /<tool_use>([\s\S]*?)<\/tool_use>/g
return ( return (
<Fragment> <Fragment>
{mentionsData && (
<Flex gap="8px" wrap style={{ marginBottom: 10 }}> <Flex gap="8px" wrap style={{ marginBottom: 10 }}>
{message.mentions?.map((model) => <MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>)} {mentionsData.map(({ key, name }) => (
<MentionTag key={key}>{'@' + name}</MentionTag>
))}
</Flex> </Flex>
)}
<MessageThought message={message} /> <MessageThought message={message} />
<MessageTools message={message} /> <MessageTools message={message} />
<Markdown message={{ ...message, content: processedContent.replace(toolUseRegex, '') }} /> <Markdown message={{ ...message, content: processedContent }} />
{message.metadata?.generateImage && <MessageImage message={message} />} <MessageImage message={message} />
{message.translatedContent && ( <MessageTranslate message={message} />
<Fragment> <MessageCitations message={message} formattedCitations={formattedCitations} model={model} />
<Divider style={{ margin: 0, marginBottom: 10 }}>
<TranslationOutlined />
</Divider>
{message.translatedContent === t('translate.processing') ? (
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginBottom: 15 }} />
) : (
<Markdown message={{ ...message, content: message.translatedContent }} />
)}
</Fragment>
)}
{hasCitations && (
<>
{message?.metadata?.groundingMetadata && message.status === 'success' && (
<>
<CitationsList
citations={
message.metadata.groundingMetadata?.groundingChunks?.map((chunk, index) => ({
number: index + 1,
url: chunk?.web?.uri || '',
title: chunk?.web?.title,
showFavicon: false
})) || []
}
/>
<SearchEntryPoint
dangerouslySetInnerHTML={{
__html: message.metadata.groundingMetadata?.searchEntryPoint?.renderedContent
? message.metadata.groundingMetadata.searchEntryPoint.renderedContent
.replace(/@media \(prefers-color-scheme: light\)/g, 'body[theme-mode="light"]')
.replace(/@media \(prefers-color-scheme: dark\)/g, 'body[theme-mode="dark"]')
: ''
}}
/>
</>
)}
{formattedCitations && (
<CitationsList
citations={formattedCitations.map((citation) => ({
number: citation.number,
url: citation.url,
hostname: citation.hostname,
showFavicon: isWebCitation
}))}
/>
)}
{(message?.metadata?.webSearch || message.metadata?.knowledge) && message.status === 'success' && (
<CitationsList
citations={[
...(message.metadata.webSearch?.results.map((result, index) => ({
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' && (
<CitationsList
citations={message.metadata.webSearchInfo.map((result, index) => ({
number: index + 1,
url: result.link || result.url,
title: result.title,
showFavicon: true
}))}
/>
)}
</>
)}
<MessageAttachments message={message} /> <MessageAttachments message={message} />
</Fragment> </Fragment>
) )
@ -332,10 +232,14 @@ const MessageContentLoading = styled.div`
margin-bottom: 5px; margin-bottom: 5px;
` `
const SearchingContainer = styled.div` const baseContainer = css`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
`
const SearchingContainer = styled.div`
${baseContainer}
background-color: var(--color-background-mute); background-color: var(--color-background-mute);
padding: 10px; padding: 10px;
border-radius: 10px; border-radius: 10px;
@ -354,8 +258,4 @@ const SearchingText = styled.div`
color: var(--color-text-1); color: var(--color-text-1);
` `
const SearchEntryPoint = styled.div`
margin: 10px 2px;
`
export default React.memo(MessageContent) export default React.memo(MessageContent)

View File

@ -8,10 +8,10 @@ import {
ZoomInOutlined, ZoomInOutlined,
ZoomOutOutlined ZoomOutOutlined
} from '@ant-design/icons' } from '@ant-design/icons'
import i18n from '@renderer/i18n'
import { Message } from '@renderer/types' import { Message } from '@renderer/types'
import { Image as AntdImage, Space } from 'antd' import { Image as AntdImage, Space } from 'antd'
import { FC } from 'react' import { FC } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components' import styled from 'styled-components'
interface Props { interface Props {
@ -19,73 +19,8 @@ interface Props {
} }
const MessageImage: FC<Props> = ({ message }) => { const MessageImage: FC<Props> = ({ message }) => {
const { t } = useTranslation() if (!message.metadata?.generateImage) {
return null
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'))
}
} }
return ( 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 export default MessageImage

View File

@ -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<Props> = ({ message }) => {
const { t } = useTranslation()
if (!message.translatedContent) {
return null
}
return (
<Fragment>
<Divider style={{ margin: 0, marginBottom: 10 }}>
<TranslationOutlined />
</Divider>
{message.translatedContent === t('translate.processing') ? (
<BeatLoader color="var(--color-text-2)" size="10" style={{ marginBottom: 15 }} />
) : (
<Markdown message={{ ...message, content: message.translatedContent }} />
)}
</Fragment>
)
}
export default MessageTranslate

View File

@ -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<Props> = ({ 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 (
<Container>
<ServersList>
<ListHeader>
<SettingTitle>{t('settings.mcp.newServer')}</SettingTitle>
</ListHeader>
<AddServerCard onClick={onAddMcpServer}>
<PlusOutlined style={{ fontSize: 24 }} />
<AddServerText>{t('settings.mcp.addServer')}</AddServerText>
</AddServerCard>
<DragableList list={mcpServers} onUpdate={updateMcpServers}>
{(server) => (
<ServerCard
key={server.id}
onClick={() => setSelectedMcpServer(server)}
className={selectedMcpServer?.id === server.id ? 'active' : ''}>
<ServerHeader>
<ServerIcon>
<CodeOutlined />
</ServerIcon>
<ServerName>{server.name}</ServerName>
<StatusIndicator>
<IndicatorLight
size={6}
color={server.isActive ? 'green' : 'var(--color-text-3)'}
animation={server.isActive}
shadow={false}
/>
</StatusIndicator>
</ServerHeader>
<ServerDescription>{server.description}</ServerDescription>
</ServerCard>
)}
</DragableList>
</ServersList>
<ServerSettings>{selectedMcpServer && <McpSettings server={selectedMcpServer} />}</ServerSettings>
</Container>
)
}
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

View File

@ -538,7 +538,7 @@ const McpSettings: React.FC<Props> = ({ server }) => {
} }
return ( return (
<SettingContainer> <SettingContainer style={{ width: '100%' }}>
<SettingGroup style={{ marginBottom: 0 }}> <SettingGroup style={{ marginBottom: 0 }}>
<SettingTitle> <SettingTitle>
<Flex justify="space-between" align="center" gap={5} style={{ marginRight: 10 }}> <Flex justify="space-between" align="center" gap={5} style={{ marginRight: 10 }}>

View File

@ -1,108 +1,43 @@
import { ArrowLeftOutlined, CodeOutlined, PlusOutlined } from '@ant-design/icons' import { ArrowLeftOutlined } from '@ant-design/icons'
import { nanoid } from '@reduxjs/toolkit'
import IndicatorLight from '@renderer/components/IndicatorLight'
import { VStack } from '@renderer/components/Layout' import { VStack } from '@renderer/components/Layout'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { MCPServer } from '@renderer/types' import { MCPServer } from '@renderer/types'
import { FC, useCallback, useEffect, useState } from 'react' import { FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 { Link } from 'react-router-dom'
import styled from 'styled-components' import styled from 'styled-components'
import { SettingContainer, SettingTitle } from '..' import { SettingContainer } from '..'
import InstallNpxUv from './InstallNpxUv' import InstallNpxUv from './InstallNpxUv'
import McpSettings from './McpSettings' import McpServersList from './McpServersList'
import NpxSearch from './NpxSearch' import NpxSearch from './NpxSearch'
const MCPSettings: FC = () => { const MCPSettings: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { mcpServers, addMCPServer } = useMCPServers() const { mcpServers } = useMCPServers()
const [selectedMcpServer, setSelectedMcpServer] = useState<MCPServer | null>(null) const [selectedMcpServer, setSelectedMcpServer] = useState<MCPServer | null>(mcpServers[0])
const { theme } = useTheme() const { theme } = useTheme()
const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const pathname = location.pathname 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(() => { useEffect(() => {
const _selectedMcpServer = mcpServers.find((server) => server.id === selectedMcpServer?.id) const _selectedMcpServer = mcpServers.find((server) => server.id === selectedMcpServer?.id)
setSelectedMcpServer(_selectedMcpServer || mcpServers[0]) setSelectedMcpServer(_selectedMcpServer || mcpServers[0])
}, [mcpServers, selectedMcpServer]) }, [mcpServers, selectedMcpServer])
useEffect(() => {
// Check if the selected server still exists in the updated mcpServers list // Check if the selected server still exists in the updated mcpServers list
useEffect(() => {
if (selectedMcpServer) { if (selectedMcpServer) {
const serverExists = mcpServers.some((server) => server.id === selectedMcpServer.id) const serverExists = mcpServers.some((server) => server.id === selectedMcpServer.id)
if (!serverExists) { if (!serverExists) {
setSelectedMcpServer(null) setSelectedMcpServer(mcpServers[0])
} }
} else {
setSelectedMcpServer(null)
} }
}, [mcpServers, selectedMcpServer]) }, [mcpServers, selectedMcpServer])
const McpServersList = useCallback(
() => (
<GridContainer>
<GridHeader>
<SettingTitle>{t('settings.mcp.newServer')}</SettingTitle>
</GridHeader>
<ServersGrid>
<AddServerCard onClick={onAddMcpServer}>
<PlusOutlined style={{ fontSize: 24 }} />
<AddServerText>{t('settings.mcp.addServer')}</AddServerText>
</AddServerCard>
{mcpServers.map((server) => (
<ServerCard
key={server.id}
onClick={() => {
setSelectedMcpServer(server)
navigate(`/settings/mcp/server/${server.id}`)
}}>
<ServerHeader>
<ServerIcon>
<CodeOutlined />
</ServerIcon>
<ServerName>{server.name}</ServerName>
<StatusIndicator>
<IndicatorLight
size={6}
color={server.isActive ? 'green' : 'var(--color-text-3)'}
animation={server.isActive}
shadow={false}
/>
</StatusIndicator>
</ServerHeader>
<ServerDescription>
{server.description &&
server.description.substring(0, 60) + (server.description.length > 60 ? '...' : '')}
</ServerDescription>
</ServerCard>
))}
</ServersGrid>
</GridContainer>
),
[mcpServers, navigate, onAddMcpServer, t]
)
const isHome = pathname === '/settings/mcp' const isHome = pathname === '/settings/mcp'
return ( return (
@ -118,8 +53,12 @@ const MCPSettings: FC = () => {
)} )}
<MainContainer> <MainContainer>
<Routes> <Routes>
<Route path="/" element={<McpServersList />} /> <Route
<Route path="server/:id" element={selectedMcpServer ? <McpSettings server={selectedMcpServer} /> : null} /> path="/"
element={
<McpServersList selectedMcpServer={selectedMcpServer} setSelectedMcpServer={setSelectedMcpServer} />
}
/>
<Route <Route
path="npx-search" path="npx-search"
element={ element={
@ -146,97 +85,6 @@ const Container = styled(VStack)`
flex: 1; flex: 1;
` `
const GridContainer = styled(VStack)`
width: 100%;
height: calc(100vh - var(--navbar-height));
padding: 20px;
`
const GridHeader = styled.div`
width: 100%;
padding-bottom: 16px;
h2 {
font-size: 20px;
margin: 0;
}
`
const ServersGrid = styled.div`
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 16px;
width: 100%;
overflow-y: auto;
padding: 2px;
`
const ServerCard = styled.div`
display: flex;
flex-direction: column;
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s ease;
height: 140px;
background-color: var(--color-bg-1);
&:hover {
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: 12px;
`
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;
`
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;
`
const BackButtonContainer = styled.div` const BackButtonContainer = styled.div`
padding: 12px 0 0 12px; padding: 12px 0 0 12px;
width: 100%; width: 100%;

View File

@ -502,3 +502,11 @@ export interface QuickPhrase {
updatedAt: number updatedAt: number
order?: number order?: number
} }
export interface Citation {
number: number
url: string
hostname: string
title?: string
content?: string
}

View File

@ -1,6 +1,6 @@
import { isReasoningModel } from '@renderer/config/models' import { isOpenAIWebSearch, isReasoningModel } from '@renderer/config/models'
import { getAssistantById } from '@renderer/services/AssistantService' import { getAssistantById } from '@renderer/services/AssistantService'
import { Message } from '@renderer/types' import { Citation, Message, Model } from '@renderer/types'
export function escapeDollarNumber(text: string) { export function escapeDollarNumber(text: string) {
let escapedText = '' let escapedText = ''
@ -241,3 +241,77 @@ export function addImageFileToContents(messages: Message[]) {
return messages.map((message) => (message.id === lastAssistantMessage.id ? updatedAssistantMessage : message)) return messages.map((message) => (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<string, URL>
): Citation[] | null => {
if (!metadata?.citations?.length && !metadata?.annotations?.length) {
return null
}
interface UrlInfo {
hostname: string
url: string
}
// 提取 URL 处理函数到组件外
const getUrlInfo = (url: string, urlCache: Map<string, URL>): 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<string>()
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
}