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