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 { 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 {

View File

@ -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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
const [isTranslating, setIsTranslating] = useState(false)
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<KnowledgeBase[]>([])
const [mentionModels, setMentionModels] = useState<Model[]>([])
const [enabledMCPs, setEnabledMCPs] = useState<MCPServer[]>(assistant.mcpServers || [])
const [isDragging, setIsDragging] = useState(false)
const [textareaHeight, setTextareaHeight] = useState<number>()
const startDragY = useRef<number>(0)
@ -122,7 +121,6 @@ const Inputbar: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
resizeTextArea,
selectedKnowledgeBases,
text,
topic,
activedMcpServers
topic
])
const translate = useCallback(async () => {
@ -507,9 +501,6 @@ const Inputbar: FC<Props> = ({ 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<Props> = ({ 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<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
/>
)}
<MCPToolsButton
assistant={assistant}
ref={mcpToolsButtonRef}
enabledMCPs={enabledMCPs}
toggelEnableMCP={toggelEnableMCP}
ToolbarButton={ToolbarButton}
setInputValue={setText}
resizeTextArea={resizeTextArea}

View File

@ -1,9 +1,12 @@
import { QuickPanelListItem, useQuickPanel } from '@renderer/components/QuickPanel'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useMCPServers } from '@renderer/hooks/useMCPServers'
import { MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
import { Form, Input, Modal, Tooltip } from 'antd'
import { EventEmitter } from '@renderer/services/EventService'
import { Assistant, MCPPrompt, MCPResource, MCPServer } from '@renderer/types'
import { Form, Input, Tooltip } from 'antd'
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 { useNavigate } from 'react-router'
@ -14,40 +17,152 @@ export interface MCPToolsButtonRef {
}
interface Props {
assistant: Assistant
ref?: React.RefObject<MCPToolsButtonRef | null>
enabledMCPs: MCPServer[]
setInputValue: React.Dispatch<React.SetStateAction<string>>
resizeTextArea: () => void
toggelEnableMCP: (server: MCPServer) => void
ToolbarButton: any
}
const MCPToolsButton: FC<Props> = ({
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<Props> = ({ 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: <SquareTerminal />,
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<Props> = ({
icon: <Plus />,
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<Props> = ({
}
})
}, [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<Props> = ({
)
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: (
<Form form={form} layout="vertical">
{prompt.arguments.map((arg, index) => (
<Form.Item
key={index}
name={arg.name}
label={`${arg.name}${arg.required ? ' *' : ''}`}
tooltip={arg.description}
rules={
arg.required ? [{ required: true, message: t('settings.mcp.prompts.requiredField') }] : []
}>
<Input placeholder={arg.description || arg.name} />
</Form.Item>
))}
</Form>
),
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<Record<string, string>>((resolve, reject) => {
window.modal.confirm({
title: `${t('settings.mcp.prompts.arguments')}: ${prompt.name}`,
content: (
<Form form={form} layout="vertical">
{prompt.arguments?.map((arg, index) => (
<Form.Item
key={index}
name={arg.name}
label={`${arg.name}${arg.required ? ' *' : ''}`}
tooltip={arg.description}
rules={
arg.required ? [{ required: true, message: t('settings.mcp.prompts.requiredField') }] : []
}>
<Input placeholder={arg.description || arg.name} />
</Form.Item>
))}
</Form>
),
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<Props> = ({
label: prompt.name,
description: prompt.description,
icon: <SquareTerminal />,
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<Props> = ({
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<QuickPanelListItem[]>([])
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: <SquareTerminal />,
action: () => handleResourceSelect(resource)
}))
)
if (isMounted) {
setResourcesList(
resources.map((resource) => ({
label: resource.name,
description: resource.description,
icon: <SquareTerminal />,
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<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 { 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<Message>
readonly model?: Readonly<Model>
}
const toolUseRegex = /<tool_use>([\s\S]*?)<\/tool_use>/g
const MessageContent: React.FC<Props> = ({ 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 } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&apos;'
}
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<string, URL>(), [])
// 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<Props> = ({ 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<Props> = ({ 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 <sup> 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
? `[<sup data-citation='${citationData}'>${num}</sup>](${link})`
: `<sup>${num}</sup>`
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 `<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 {
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 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 (
<MessageContentLoading>
<SyncOutlined spin size={24} />
@ -210,7 +184,7 @@ const MessageContent: React.FC<Props> = ({ message: _message, model }) => {
)
}
if (message.status === 'searching') {
if (messageStatus.isSearching) {
return (
<SearchingContainer>
<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} />
}
if (message.type === '@' && model) {
if (messageStatus.isMention && model) {
const content = `[@${model.name}](#) ${getBriefInfo(message.content)}`
return <Markdown message={{ ...message, content }} />
}
const toolUseRegex = /<tool_use>([\s\S]*?)<\/tool_use>/g
return (
<Fragment>
<Flex gap="8px" wrap style={{ marginBottom: 10 }}>
{message.mentions?.map((model) => <MentionTag key={getModelUniqId(model)}>{'@' + model.name}</MentionTag>)}
</Flex>
{mentionsData && (
<Flex gap="8px" wrap style={{ marginBottom: 10 }}>
{mentionsData.map(({ key, name }) => (
<MentionTag key={key}>{'@' + name}</MentionTag>
))}
</Flex>
)}
<MessageThought message={message} />
<MessageTools message={message} />
<Markdown message={{ ...message, content: processedContent.replace(toolUseRegex, '') }} />
{message.metadata?.generateImage && <MessageImage message={message} />}
{message.translatedContent && (
<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>
)}
{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
}))}
/>
)}
</>
)}
<Markdown message={{ ...message, content: processedContent }} />
<MessageImage message={message} />
<MessageTranslate message={message} />
<MessageCitations message={message} formattedCitations={formattedCitations} model={model} />
<MessageAttachments message={message} />
</Fragment>
)
@ -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)

View File

@ -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<Props> = ({ 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

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 (
<SettingContainer>
<SettingContainer style={{ width: '100%' }}>
<SettingGroup style={{ marginBottom: 0 }}>
<SettingTitle>
<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 { 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<MCPServer | null>(null)
const { mcpServers } = useMCPServers()
const [selectedMcpServer, setSelectedMcpServer] = useState<MCPServer | null>(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(
() => (
<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'
return (
@ -118,8 +53,12 @@ const MCPSettings: FC = () => {
)}
<MainContainer>
<Routes>
<Route path="/" element={<McpServersList />} />
<Route path="server/:id" element={selectedMcpServer ? <McpSettings server={selectedMcpServer} /> : null} />
<Route
path="/"
element={
<McpServersList selectedMcpServer={selectedMcpServer} setSelectedMcpServer={setSelectedMcpServer} />
}
/>
<Route
path="npx-search"
element={
@ -146,97 +85,6 @@ const Container = styled(VStack)`
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`
padding: 12px 0 0 12px;
width: 100%;

View File

@ -502,3 +502,11 @@ export interface QuickPhrase {
updatedAt: 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 { Message } from '@renderer/types'
import { Citation, Message, Model } from '@renderer/types'
export function escapeDollarNumber(text: string) {
let escapedText = ''
@ -241,3 +241,77 @@ export function addImageFileToContents(messages: 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
}