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:
parent
98f2c8a0b6
commit
bf8baedfcf
@ -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 {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,63 +17,34 @@ 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
|
||||
}) => {
|
||||
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
|
||||
// 添加类型定义
|
||||
interface PromptArgument {
|
||||
name: string
|
||||
description?: string
|
||||
required?: boolean
|
||||
}
|
||||
})
|
||||
}, [menuItems, quickPanel, t])
|
||||
// Extract and format all content from the prompt response
|
||||
const extractPromptContent = useCallback((response: any): string | null => {
|
||||
|
||||
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
|
||||
@ -89,30 +63,23 @@ const MCPToolsButton: FC<Props> = ({
|
||||
// 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 += `\n\n`
|
||||
formattedContent += `\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 {
|
||||
@ -121,7 +88,6 @@ const MCPToolsButton: FC<Props> = ({
|
||||
break
|
||||
|
||||
default:
|
||||
// Add text content if available with role, otherwise show placeholder
|
||||
if (message.content.text) {
|
||||
formattedContent += `${rolePrefix}${message.content.text}\n\n`
|
||||
}
|
||||
@ -141,25 +107,103 @@ const MCPToolsButton: FC<Props> = ({
|
||||
}
|
||||
|
||||
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(
|
||||
(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,22 +211,29 @@ 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) {
|
||||
(prompt: MCPPromptWithArgs) => {
|
||||
const server = activedMcpServers.find((s) => s.id === prompt.serverId)
|
||||
if (!server) return
|
||||
|
||||
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 {
|
||||
// Check if the prompt has arguments
|
||||
if (prompt.arguments && prompt.arguments.length > 0) {
|
||||
// Reset form when opening a new modal
|
||||
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}`,
|
||||
content: (
|
||||
<Form form={form} layout="vertical">
|
||||
{prompt.arguments.map((arg, index) => (
|
||||
{prompt.arguments?.map((arg, index) => (
|
||||
<Form.Item
|
||||
key={index}
|
||||
name={arg.name}
|
||||
@ -198,71 +249,66 @@ const MCPToolsButton: FC<Props> = ({
|
||||
),
|
||||
onOk: async () => {
|
||||
try {
|
||||
// Validate and get form values
|
||||
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({
|
||||
server,
|
||||
name: prompt.name,
|
||||
args: values
|
||||
args: result
|
||||
})
|
||||
|
||||
// Extract and format prompt content from the response
|
||||
const promptContent = extractPromptContent(response)
|
||||
if (promptContent) {
|
||||
insertPromptIntoTextArea(promptContent)
|
||||
} else {
|
||||
throw new Error('Invalid prompt response format')
|
||||
}
|
||||
|
||||
return Promise.resolve()
|
||||
await handlePromptResponse(response)
|
||||
} catch (error: Error | any) {
|
||||
if (error.errorFields) {
|
||||
// This is a form validation error, handled by Ant Design
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
Modal.error({
|
||||
if (error.message !== 'cancelled') {
|
||||
window.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 handlePromptWithoutArgs = async () => {
|
||||
try {
|
||||
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')
|
||||
}
|
||||
}
|
||||
await handlePromptResponse(response)
|
||||
} catch (error: Error | any) {
|
||||
Modal.error({
|
||||
window.modal.error({
|
||||
title: t('common.error'),
|
||||
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 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,87 +333,63 @@ const MCPToolsButton: FC<Props> = ({
|
||||
|
||||
const handleResourceSelect = useCallback(
|
||||
(resource: MCPResource) => {
|
||||
setTimeout(async () => {
|
||||
const server = enabledMCPs.find((s) => s.id === resource.serverId)
|
||||
if (server) {
|
||||
const server = activedMcpServers.find((s) => s.id === resource.serverId)
|
||||
if (!server) return
|
||||
|
||||
const processResourceContent = (resourceData: ResourceData) => {
|
||||
if (resourceData.blob) {
|
||||
if (resourceData.mimeType?.startsWith('image/')) {
|
||||
const imageMarkdown = ``
|
||||
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 {
|
||||
// Fetch the resource data
|
||||
const response = await window.api.mcp.getResource({
|
||||
server,
|
||||
uri: resource.uri
|
||||
})
|
||||
console.log('Resource Data:', response)
|
||||
|
||||
// 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 = ``
|
||||
insertPromptIntoTextArea(imageMarkdown)
|
||||
if (response?.contents && Array.isArray(response.contents)) {
|
||||
response.contents.forEach((content: ResourceData) => processResourceContent(content))
|
||||
} 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 = ``
|
||||
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)
|
||||
}
|
||||
processResourceContent(response as ResourceData)
|
||||
}
|
||||
} catch (error: Error | any) {
|
||||
Modal.error({
|
||||
window.modal.error({
|
||||
title: t('common.error'),
|
||||
content: error.message || t('settings.mcp.resources.genericError')
|
||||
})
|
||||
}
|
||||
}
|
||||
}, 10)
|
||||
})
|
||||
},
|
||||
[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)
|
||||
}
|
||||
|
||||
if (isMounted) {
|
||||
setResourcesList(
|
||||
resources.map((resource) => ({
|
||||
label: resource.name,
|
||||
@ -377,9 +399,14 @@ const MCPToolsButton: FC<Props> = ({
|
||||
}))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
113
src/renderer/src/pages/home/Messages/MessageCitations.tsx
Normal file
113
src/renderer/src/pages/home/Messages/MessageCitations.tsx
Normal 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
|
||||
@ -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 } = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}
|
||||
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')
|
||||
const formattedCitations = useMemo(
|
||||
() => formatCitations(message.metadata, model, urlCache),
|
||||
[message.metadata, model, urlCache]
|
||||
)
|
||||
}, [formattedCitations, message])
|
||||
|
||||
// 获取引用数据
|
||||
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,
|
||||
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,
|
||||
// 批量处理 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
|
||||
}
|
||||
|
||||
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>
|
||||
{mentionsData && (
|
||||
<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>
|
||||
)}
|
||||
<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)
|
||||
|
||||
@ -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
|
||||
|
||||
35
src/renderer/src/pages/home/Messages/MessageTranslate.tsx
Normal file
35
src/renderer/src/pages/home/Messages/MessageTranslate.tsx
Normal 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
|
||||
181
src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx
Normal file
181
src/renderer/src/pages/settings/MCPSettings/McpServersList.tsx
Normal 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
|
||||
@ -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 }}>
|
||||
|
||||
@ -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])
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the selected server still exists in the updated mcpServers list
|
||||
useEffect(() => {
|
||||
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%;
|
||||
|
||||
@ -502,3 +502,11 @@ export interface QuickPhrase {
|
||||
updatedAt: number
|
||||
order?: number
|
||||
}
|
||||
|
||||
export interface Citation {
|
||||
number: number
|
||||
url: string
|
||||
hostname: string
|
||||
title?: string
|
||||
content?: string
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user