Merge branch 'feature/memory_zy' of github.com:SuanmoSuanyangTechnology/MemoryBear into feature/memory_zy
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { request } from '@/utils/request'
|
||||
import type { Query, CustomToolItem, ExecuteData, MCPToolItem, InnerToolItem } from '@/views/ToolManagement/types'
|
||||
import type { Query, MarketQuery, CustomToolItem, ExecuteData, MCPToolItem, InnerToolItem } from '@/views/ToolManagement/types'
|
||||
|
||||
// 工具列表
|
||||
export const getTools = (data: Query) => {
|
||||
@@ -33,4 +33,44 @@ export const getToolDetail = (tool_id: string) => {
|
||||
}
|
||||
export const getToolMethods = (tool_id: string) => {
|
||||
return request.get(`/tools/${tool_id}/methods`)
|
||||
}
|
||||
|
||||
// MCP市场列表
|
||||
export const getMarketTools = (data: Query) => {
|
||||
return request.get('/mcp_markets/mcp_markets', data)
|
||||
}
|
||||
// 市场配置创建
|
||||
export const createMarketConfig = (values: {
|
||||
mcp_market_id: string;
|
||||
token: string;
|
||||
status: number;
|
||||
}) => {
|
||||
return request.post('/mcp_market_configs/mcp_market_config', values)
|
||||
}
|
||||
// 市场配置更新
|
||||
export const updateMarketConfig = (values: {
|
||||
mcp_market_config_id: string;
|
||||
token: string;
|
||||
status: number;
|
||||
}) => {
|
||||
return request.put(`/mcp_market_configs/${values.mcp_market_config_id}`, values)
|
||||
}
|
||||
// 市场根据id获取配置
|
||||
export const getMarketConfig = (mcp_market_id: string) => {
|
||||
return request.get(`/mcp_market_configs/mcp_market_id/${mcp_market_id}`)
|
||||
}
|
||||
// 市场MCP列表
|
||||
export const getMarketMCPs = (data: MarketQuery) => {
|
||||
return request.get('/mcp_market_configs/mcp_servers', data)
|
||||
}
|
||||
// 根据配置ID serverId 获取MCP服务详情
|
||||
export const getMarketMCPDetail = (data:{
|
||||
mcp_market_config_id: string;
|
||||
server_id: string;
|
||||
}) => {
|
||||
return request.get(`/mcp_market_configs/mcp_server`,data)
|
||||
}
|
||||
// 市场已激活MCP列表
|
||||
export const getMarketMCPsActivated = (data: MarketQuery) => {
|
||||
return request.get('/mcp_market_configs/operational_mcp_servers', data)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 18:42:49
|
||||
* @Last Modified time: 2026-03-06 13:36:20
|
||||
*/
|
||||
import { type FC, useEffect, useMemo } from 'react'
|
||||
import { Flex, Input, Form } from 'antd'
|
||||
@@ -50,13 +50,17 @@ const ChatInput: FC<ChatInputProps> = ({
|
||||
|
||||
|
||||
const handleDelete = (file: any) => {
|
||||
fileChange?.(fileList?.filter(item => item.uid !== file.uid) || [])
|
||||
fileChange?.(fileList?.filter(item => {
|
||||
return item.thumbUrl && file.thumbUrl ? item.thumbUrl !== file.thumbUrl
|
||||
: item.url && file.url ? item.url !== file.url
|
||||
: item.uid !== file.uid
|
||||
}) || [])
|
||||
}
|
||||
// Convert file object to preview URL
|
||||
const previewFileList = useMemo(() => {
|
||||
return fileList?.map(file => ({
|
||||
...file,
|
||||
url: file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : file.thumbUrl)
|
||||
url: file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined)
|
||||
})) || []
|
||||
}, [fileList])
|
||||
|
||||
@@ -72,7 +76,7 @@ const ChatInput: FC<ChatInputProps> = ({
|
||||
{previewFileList.map((file) => {
|
||||
if (file.type.includes('image')) {
|
||||
return (
|
||||
<div key={file.uid} className="rb:inline-block rb:group rb:relative rb:rounded-lg">
|
||||
<div key={file.url || file.uid} className="rb:inline-block rb:group rb:relative rb:rounded-lg">
|
||||
<img src={file.url} alt={file.name} className="rb:size-12! rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||
@@ -83,7 +87,7 @@ const ChatInput: FC<ChatInputProps> = ({
|
||||
}
|
||||
if (file.type.includes('video')) {
|
||||
return (
|
||||
<div key={file.uid} className="rb:w-45 rb:h-16 rb:inline-block rb:group rb:relative rb:rounded-lg">
|
||||
<div key={file.url || file.uid} className="rb:w-45 rb:h-16 rb:inline-block rb:group rb:relative rb:rounded-lg">
|
||||
<video src={file.url} controls className="rb:w-45 rb:h-16 rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||
@@ -94,7 +98,7 @@ const ChatInput: FC<ChatInputProps> = ({
|
||||
}
|
||||
if (file.type.includes('audio')) {
|
||||
return (
|
||||
<div key={file.uid} className="rb:w-45 rb:h-16 rb:inline-flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5 rb:gap-2">
|
||||
<div key={file.url || file.uid} className="rb:w-45 rb:h-16 rb:inline-flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5 rb:gap-2">
|
||||
<audio src={file.url} controls className="rb:w-45 rb:h-16" />
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||
@@ -104,7 +108,7 @@ const ChatInput: FC<ChatInputProps> = ({
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={file.uid} className="rb:w-45 rb:text-[12px] rb:gap-2.5 rb:flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5">
|
||||
<div key={file.url || file.uid} className="rb:w-45 rb:text-[12px] rb:gap-2.5 rb:flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5">
|
||||
{(file.type.includes('doc') || file.type.includes('docx') || file.type.includes('word') || file.type.includes('wordprocessingml.document')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/word_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/word.svg')]"
|
||||
></div>}
|
||||
|
||||
@@ -440,7 +440,6 @@ export const en = {
|
||||
logoutApiCannotRefreshToken: 'Logout API cannot refresh token',
|
||||
publicApiCannotRefreshToken: 'Public API cannot refresh token',
|
||||
refreshTokenNotExist: 'Refresh token does not exist',
|
||||
SYSTEM_DEFAULT_SCENE_CANNOT_DELETE: 'This is a system preset scene and cannot be deleted',
|
||||
reset: 'Reset',
|
||||
refresh: 'Refresh',
|
||||
return: 'Return',
|
||||
@@ -1362,6 +1361,7 @@ export const en = {
|
||||
complex: 'Compatibility Analysis',
|
||||
sureInfo: 'Information Confirmation',
|
||||
completed: 'Import Completed',
|
||||
baseInfo: 'Basic Information',
|
||||
workflowName: 'Workflow Name',
|
||||
fileName: 'File Name',
|
||||
fileSize: 'File Size',
|
||||
@@ -1573,7 +1573,7 @@ export const en = {
|
||||
intelligentSemanticPruningFunction: 'Intelligent Semantic Pruning Function',
|
||||
intelligentSemanticPruningFunctionDesc: 'Whether to activate intelligent semantic pruning (true/false).',
|
||||
intelligentSemanticPruningScene: 'Intelligent Semantic Pruning Scene',
|
||||
intelligentSemanticPruningSceneDesc: 'Select intelligent semantic pruning scene (education, online_service, outbound).',
|
||||
intelligentSemanticPruningSceneDesc: 'Semantic pruning scenarios are consistent with ontology engineering scenarios',
|
||||
intelligentSemanticPruningThreshold: 'Intelligent Semantic Pruning Threshold',
|
||||
intelligentSemanticPruningThresholdDesc: 'Set intelligent semantic pruning threshold (0-0.9).',
|
||||
reflectionEngine: 'Self-Reflexion Engine',
|
||||
@@ -1807,6 +1807,25 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
error_desc: 'API is configured but connection error',
|
||||
|
||||
testConnectionSuccess: 'Test Connection Successful',
|
||||
refreshSuccess: 'Refresh Successful',
|
||||
refreshFailed: 'Refresh Failed',
|
||||
|
||||
// Market related
|
||||
marketSelectTitle: 'Select an MCP Market',
|
||||
marketSelectDesc: 'Choose a market source from the left, configure the connection to browse MCP services',
|
||||
marketRefreshSuccess: 'List refreshed',
|
||||
marketActivated: 'Activated',
|
||||
marketInDatabase: 'In Database',
|
||||
marketAdd: 'Add',
|
||||
marketRefresh: 'Refresh',
|
||||
marketConfig: 'Configure',
|
||||
marketConfigConnection: 'Configure Connection',
|
||||
marketNoServices: 'No MCP Services Available',
|
||||
marketNotConnected: 'Not Connected to This Market',
|
||||
marketNoServicesDesc: 'This market currently has no available services',
|
||||
marketNotConnectedDesc: 'Click the "Configure" button in the upper right corner to set connection information',
|
||||
marketSearchPlaceholder: 'Search services...',
|
||||
marketVisit: 'Visit Market',
|
||||
serviceEndpoint: 'Service Endpoint URL',
|
||||
serviceEndpointPlaceholder: 'URL of the service endpoint',
|
||||
serviceEndpointExtra: 'Complete access address of the MCP service',
|
||||
@@ -1960,6 +1979,19 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
viewDetail: 'View Details',
|
||||
textLink: 'Test Connection',
|
||||
noResult: 'Processing results will be displayed here',
|
||||
|
||||
marketConfig: 'Configure {{name}}',
|
||||
marketSaveAndConnect: 'Save & Connect',
|
||||
marketUrl: 'Market URL',
|
||||
marketUrlPlaceholder: 'Market URL',
|
||||
marketCopy: 'Copy',
|
||||
marketApiKeyOptional: 'Optional',
|
||||
marketApiKeyExtra: 'Some markets require an API Key to access the full service list',
|
||||
marketApiKeyPlaceholder: 'Enter API Key to access more services',
|
||||
marketConnectionStatus: 'Connection Status',
|
||||
marketConnected: '● Connected',
|
||||
marketDisconnected: '○ Disconnected',
|
||||
marketConnecting: 'Connecting to {{name}}...',
|
||||
serverUrlInvalid: 'Must start with http:// or https://, and cannot have leading or trailing spaces',
|
||||
requestHeaderKeyInvalid: 'Only English letters, numbers, hyphens (-), and underscores (_) are allowed, and cannot start or end with a hyphen or underscore',
|
||||
},
|
||||
@@ -2008,6 +2040,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
self_optimization: 'Self Optimization',
|
||||
process_evolution: 'Process Evolution',
|
||||
unknown: 'Unknown Node',
|
||||
notes: 'Sticky Note',
|
||||
|
||||
clickToConfigure: 'Click to configure node parameters',
|
||||
nodeProperties: 'Node Properties',
|
||||
@@ -2195,6 +2228,12 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
output_variables: 'Output Variables',
|
||||
refreshTip: 'Sync function signature to code',
|
||||
},
|
||||
notes: {
|
||||
showAuth: 'Show Author',
|
||||
enterLink: 'Enter Link URL',
|
||||
placeholder: 'Enter note...',
|
||||
removeLink: 'Remove Link',
|
||||
},
|
||||
name: 'Key',
|
||||
type: 'Type',
|
||||
value: 'Value',
|
||||
@@ -2617,6 +2656,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
updated_at: 'Updated At',
|
||||
entityTypes: 'Entity Types',
|
||||
|
||||
classSearchPlaceholder: 'Search types',
|
||||
addClass: 'Add Type',
|
||||
class_name: 'Type Name',
|
||||
class_description: 'Type Definition',
|
||||
|
||||
@@ -96,7 +96,7 @@ export const zh = {
|
||||
createMemorySummary: '创建记忆摘要',
|
||||
memoryManagement: '记忆管理',
|
||||
spaceManagement: '空间管理',
|
||||
memoryExtractionEngine: '记忆提取引擎',
|
||||
memoryExtractionEngine: '记忆萃取引擎',
|
||||
forgettingEngine: '遗忘引擎',
|
||||
apiKeyManagement: 'API KEY管理',
|
||||
knowledgePrivate: '详情',
|
||||
@@ -1020,7 +1020,6 @@ export const zh = {
|
||||
logoutApiCannotRefreshToken: '退出登录接口不能刷新token',
|
||||
publicApiCannotRefreshToken: '公共接口不能刷新token',
|
||||
refreshTokenNotExist: '刷新token不存在',
|
||||
SYSTEM_DEFAULT_SCENE_CANNOT_DELETE: '该场景为系统预设场景,不允许删除',
|
||||
reset: '重置',
|
||||
refresh: '刷新',
|
||||
return: '返回',
|
||||
@@ -1284,7 +1283,7 @@ export const zh = {
|
||||
createConfiguration: '创建配置',
|
||||
editConfiguration: '编辑配置',
|
||||
desc: '描述',
|
||||
memoryExtractionEngine: '记忆提取引擎',
|
||||
memoryExtractionEngine: '记忆萃取引擎',
|
||||
forgottenEngine: '遗忘引擎',
|
||||
active: '活跃',
|
||||
inactive: '不活跃',
|
||||
@@ -1572,7 +1571,7 @@ export const zh = {
|
||||
intelligentSemanticPruningFunction: '智能语义修剪功能',
|
||||
intelligentSemanticPruningFunctionDesc: '是否激活智能语义修剪(true/false)。',
|
||||
intelligentSemanticPruningScene: '智能语义修剪场景',
|
||||
intelligentSemanticPruningSceneDesc: '选择智能语义修剪场景(education、online_service、outbound)。',
|
||||
intelligentSemanticPruningSceneDesc: '语义剪枝场景与本体工程场景一致',
|
||||
intelligentSemanticPruningThreshold: '智能语义修剪阈值',
|
||||
intelligentSemanticPruningThresholdDesc: '设置智能语义修剪阈值(0-0.9)。',
|
||||
reflectionEngine: '自我反思引擎',
|
||||
@@ -1804,6 +1803,25 @@ export const zh = {
|
||||
error_desc: 'API 已配置但链接异常',
|
||||
|
||||
testConnectionSuccess: '测试连接成功',
|
||||
refreshSuccess: '刷新成功',
|
||||
refreshFailed: '刷新失败',
|
||||
|
||||
// Market 相关
|
||||
marketSelectTitle: '选择一个 MCP 市场',
|
||||
marketSelectDesc: '从左侧选择一个市场源,配置连接后即可浏览该市场的 MCP 服务',
|
||||
marketRefreshSuccess: '列表已刷新',
|
||||
marketActivated: '已激活',
|
||||
marketInDatabase: '已入库',
|
||||
marketAdd: '添加',
|
||||
marketRefresh: '刷新',
|
||||
marketConfig: '配置',
|
||||
marketConfigConnection: '配置连接',
|
||||
marketNoServices: '暂无可用的 MCP 服务',
|
||||
marketNotConnected: '尚未连接此市场',
|
||||
marketNoServicesDesc: '该市场暂时没有可用的服务',
|
||||
marketNotConnectedDesc: '点击右上角"配置"按钮设置连接信息',
|
||||
marketSearchPlaceholder: '搜索服务...',
|
||||
marketVisit: '前往市场',
|
||||
serviceEndpoint: '服务端点 URL',
|
||||
serviceEndpointPlaceholder: '服务端点的 URL',
|
||||
serviceEndpointExtra: 'MCP服务的完整访问地址',
|
||||
@@ -1957,6 +1975,19 @@ export const zh = {
|
||||
viewDetail: '查看详情',
|
||||
textLink: '测试连接',
|
||||
noResult: '处理结果将显示在这里',
|
||||
|
||||
marketConfig: '配置 {{name}}',
|
||||
marketSaveAndConnect: '保存并连接',
|
||||
marketUrl: '市场地址',
|
||||
marketUrlPlaceholder: '市场地址',
|
||||
marketCopy: '复制',
|
||||
marketApiKeyOptional: '可选',
|
||||
marketApiKeyExtra: '部分市场需要 API Key 才能获取完整的服务列表',
|
||||
marketApiKeyPlaceholder: '输入 API Key 以获取更多服务',
|
||||
marketConnectionStatus: '连接状态',
|
||||
marketConnected: '● 已连接',
|
||||
marketDisconnected: '○ 未连接',
|
||||
marketConnecting: '正在连接 {{name}}...',
|
||||
serverUrlInvalid: '必须以 http:// 或 https:// 开头,且不能有前后空格',
|
||||
requestHeaderKeyInvalid: '只支持英文、数字、连字符(-)、下划线(_),不能以连字符或下划线开头结尾',
|
||||
},
|
||||
@@ -2005,6 +2036,7 @@ export const zh = {
|
||||
self_optimization: '自我优化',
|
||||
process_evolution: '流程演化',
|
||||
unknown: '未知节点',
|
||||
notes: '便签',
|
||||
|
||||
clickToConfigure: '点击配置节点参数',
|
||||
nodeProperties: '节点属性',
|
||||
@@ -2195,6 +2227,12 @@ export const zh = {
|
||||
unknown: {
|
||||
replaceNodeType: '替换节点'
|
||||
},
|
||||
notes: {
|
||||
showAuth: '显示作者',
|
||||
enterLink: '输入链接 URL',
|
||||
placeholder: '输入注释...',
|
||||
removeLink: '取消链接',
|
||||
},
|
||||
name: '键',
|
||||
type: '类型',
|
||||
value: '值',
|
||||
@@ -2618,6 +2656,7 @@ export const zh = {
|
||||
updated_at: '更新时间',
|
||||
entityTypes: '实体类型',
|
||||
|
||||
classSearchPlaceholder: '搜索类型',
|
||||
addClass: '添加类型',
|
||||
class_name: '类型名称',
|
||||
class_description: '类型定义',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:35:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:35:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-06 10:39:00
|
||||
*/
|
||||
/**
|
||||
* HTTP Request Utility Module
|
||||
@@ -183,7 +183,7 @@ service.interceptors.response.use(
|
||||
msg = msg || i18n.t('common.serverError');
|
||||
break;
|
||||
default:
|
||||
if (msg === 'SYSTEM_DEFAULT_SCENE_CANNOT_DELETE') {
|
||||
if (['SYSTEM_DEFAULT_SCENE_CANNOT_DELETE', 'SYSTEM_DEFAULT_CLASS_CANNOT_DELETE', 'SYSTEM_DEFAULT_SCENE_CANNOT_UPDATE'].includes(msg)) {
|
||||
msg = i18n.t(`common.${msg}`)
|
||||
} else if (!msg && Array.isArray(error.response?.data?.detail)) {
|
||||
msg = error.response?.data?.detail?.map((item: { msg: string }) => item.msg).join(';')
|
||||
@@ -356,12 +356,11 @@ export const request = {
|
||||
* Get parent domain for cookie setting
|
||||
* @returns Parent domain or IP address
|
||||
*/
|
||||
const isIp = (hostname: string) => /^\d+\.\d+\.\d+\.\d+$/.test(hostname)
|
||||
|
||||
const getParentDomain = () => {
|
||||
const hostname = window.location.hostname
|
||||
// Check if it's an IP address
|
||||
if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
|
||||
return hostname
|
||||
}
|
||||
if (isIp(hostname)) return hostname
|
||||
const parts = hostname.split('.')
|
||||
return parts.length > 2 ? `.${parts.slice(-2).join('.')}` : hostname
|
||||
}
|
||||
@@ -371,7 +370,10 @@ const getParentDomain = () => {
|
||||
*/
|
||||
export const cookieUtils = {
|
||||
set: (name: string, value: string, domain = getParentDomain()) => {
|
||||
document.cookie = `${name}=${value}; domain=${domain}; path=/; secure; samesite=strict`
|
||||
const ip = isIp(window.location.hostname)
|
||||
const domainPart = ip ? '' : `; domain=${domain}`
|
||||
const securePart = window.location.protocol === 'https:' ? '; secure' : ''
|
||||
document.cookie = `${name}=${value}${domainPart}; path=/${securePart}; samesite=strict`
|
||||
},
|
||||
get: (name: string) => {
|
||||
const value = `; ${document.cookie}`
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:39
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 18:51:20
|
||||
* @Last Modified time: 2026-03-05 17:03:46
|
||||
*/
|
||||
/**
|
||||
* Chat debugging component for application testing
|
||||
@@ -171,6 +171,29 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
.then(() => {
|
||||
const message = msg
|
||||
if (!message?.trim()) return
|
||||
// Validate required variables before sending
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (chatVariables && chatVariables.length > 0) {
|
||||
const needRequired: string[] = []
|
||||
chatVariables.forEach(vo => {
|
||||
params[vo.name] = vo.value
|
||||
|
||||
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
|
||||
isCanSend = false
|
||||
needRequired.push(vo.name)
|
||||
}
|
||||
})
|
||||
|
||||
if (needRequired.length) {
|
||||
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
|
||||
}
|
||||
}
|
||||
if (!isCanSend) {
|
||||
setLoading(false)
|
||||
setCompareLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
addUserMessage(message, fileList)
|
||||
setMessage(message)
|
||||
@@ -198,29 +221,6 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
// Validate required variables before sending
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (chatVariables && chatVariables.length > 0) {
|
||||
const needRequired: string[] = []
|
||||
chatVariables.forEach(vo => {
|
||||
params[vo.name] = vo.value
|
||||
|
||||
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
|
||||
isCanSend = false
|
||||
needRequired.push(vo.name)
|
||||
}
|
||||
})
|
||||
|
||||
if (needRequired.length) {
|
||||
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
|
||||
}
|
||||
}
|
||||
if (!isCanSend) {
|
||||
setLoading(false)
|
||||
setCompareLoading(false)
|
||||
return
|
||||
}
|
||||
runCompare(data.app_id, {
|
||||
message,
|
||||
files: fileList.map(file => {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-28 14:08:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-02 17:39:49
|
||||
* @Last Modified time: 2026-03-06 12:05:46
|
||||
*/
|
||||
/**
|
||||
* UploadWorkflowModal Component
|
||||
@@ -101,6 +101,7 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
formData.append('platform', values.platform);
|
||||
formData.append('file', values.file[0]);
|
||||
|
||||
setLoading(true)
|
||||
// Call import workflow API
|
||||
importWorkflow(formData)
|
||||
.then(res => {
|
||||
@@ -114,21 +115,24 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
} else {
|
||||
setCurrent(2);
|
||||
// Pre-fill form with file information
|
||||
const fileNameSplit = values.file[0].name.split('.')
|
||||
form.setFieldsValue({
|
||||
name: values.file[0].name.split('.')[0],
|
||||
name: fileNameSplit.slice(0, fileNameSplit.length - 1).join('.'),
|
||||
platform: values.platform,
|
||||
fileName: values.file[0].name,
|
||||
fileSize: values.file[0].size,
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
break;
|
||||
case 1: // Step 2: Error/warning display
|
||||
if (firstFormData) {
|
||||
const { file, platform } = firstFormData;
|
||||
const fileNameSplit = firstFormData.file[0].name.split('.')
|
||||
// Pre-fill form with file information
|
||||
form.setFieldsValue({
|
||||
name: file[0].name.split('.')[0],
|
||||
name: fileNameSplit.slice(0, fileNameSplit.length - 1).join('.'),
|
||||
platform: platform,
|
||||
fileName: file[0].name,
|
||||
fileSize: file[0].size,
|
||||
@@ -138,6 +142,7 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
break;
|
||||
case 2: // Step 3: Confirm information
|
||||
if (data) {
|
||||
setLoading(true);
|
||||
// Complete import workflow
|
||||
completeImportWorkflow({
|
||||
temp_id: data.temp_id,
|
||||
@@ -148,7 +153,8 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
const response = res as { id: string };
|
||||
setCurrent(3);
|
||||
setAppId(response.id);
|
||||
});
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
break;
|
||||
default:
|
||||
@@ -175,7 +181,9 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
}
|
||||
|
||||
// Reset form if not going back to error/warning step
|
||||
if (newStep !== 1) {
|
||||
if (newStep === 0) {
|
||||
form.setFieldsValue(firstFormData || {})
|
||||
} else if (newStep !== 1) {
|
||||
form.resetFields();
|
||||
}
|
||||
setCurrent(newStep);
|
||||
@@ -186,14 +194,16 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
* @param {string} type - Navigation type ('detail' or 'list')
|
||||
*/
|
||||
const handleJump = (type: string) => {
|
||||
switch(type) {
|
||||
case 'detail':
|
||||
// Open application detail page in new tab
|
||||
window.open(`/#/application/config/${appId}`, '_blank');
|
||||
break;
|
||||
}
|
||||
refresh();
|
||||
handleClose();
|
||||
refresh();
|
||||
setTimeout(() => {
|
||||
switch (type) {
|
||||
case 'detail':
|
||||
// Open application detail page in new tab
|
||||
window.open(`/#/application/config/${appId}`, '_blank');
|
||||
break;
|
||||
}
|
||||
}, 100)
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -235,7 +245,7 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
</Button>
|
||||
];
|
||||
}
|
||||
}, [current]);
|
||||
}, [current, loading]);
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
@@ -350,7 +360,7 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
title={t('application.importSuccess')}
|
||||
subTitle={t('application.importSuccessDesc')}
|
||||
extra={[
|
||||
<Button key="back" onClick={() => handleJump('list')}>
|
||||
<Button key="back" onClick={() => handleJump('list')}>
|
||||
{t('application.gotoList')}
|
||||
</Button>,
|
||||
<Button
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:42
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-05 15:09:22
|
||||
* @Last Modified time: 2026-03-06 12:20:43
|
||||
*/
|
||||
/**
|
||||
* File Upload Component
|
||||
@@ -208,6 +208,7 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
newFileList.map(file => {
|
||||
const type = (file.type && transform_file_type[file.type as keyof typeof transform_file_type]) || file.type || 'document'
|
||||
file.type = type
|
||||
file.thumbUrl = file.thumbUrl || URL.createObjectURL(file.originFileObj as Blob)
|
||||
})
|
||||
setFileList(newFileList);
|
||||
if (onChange) {
|
||||
|
||||
@@ -82,6 +82,7 @@ const CreateDataset = () => {
|
||||
const [form] = Form.useForm<ContentFormData>();
|
||||
const [data, setData] = useState<KnowledgeBaseDocumentData[]>([]);
|
||||
const [rechunkFileIds, setRechunkFileIds] = useState<string[]>(initialFileIds);
|
||||
const [textFormValid, setTextFormValid] = useState<boolean>(false);
|
||||
|
||||
const [pollingLoading, setPollingLoading] = useState<boolean>(false);
|
||||
const pollingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
@@ -624,7 +625,16 @@ const CreateDataset = () => {
|
||||
)}
|
||||
{source && source === 'text' && (
|
||||
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={() => {
|
||||
// 检查表单字段是否都已填写
|
||||
const values = form.getFieldsValue();
|
||||
const isValid = !!(values.title?.trim() && values.content?.trim());
|
||||
setTextFormValid(isValid);
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="title"
|
||||
label={t('knowledgeBase.title')}
|
||||
@@ -845,7 +855,11 @@ const CreateDataset = () => {
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={current === 2 ? handleStartUpload : handleNext}
|
||||
disabled={pollingLoading || (current === 0 && rechunkFileIds.length === 0)}
|
||||
disabled={
|
||||
pollingLoading ||
|
||||
(current === 0 && source === 'local' && rechunkFileIds.length === 0) ||
|
||||
(current === 0 && source === 'text' && !textFormValid)
|
||||
}
|
||||
>
|
||||
{current === 2 ? t('knowledgeBase.startUploading') || 'Start Upload' : t('common.next') || 'Next'}
|
||||
</Button>
|
||||
|
||||
@@ -672,9 +672,17 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
{currentType !== 'Folder' && dynamicTypeList.map((tp) => {
|
||||
const fieldKey = typeToFieldKey(tp);
|
||||
// When tp is 'llm', merge llm and chat options
|
||||
const options = tp.toLowerCase() === 'llm' || tp.toLowerCase() === 'image2text'
|
||||
let options = tp.toLowerCase() === 'llm' || tp.toLowerCase() === 'image2text'
|
||||
? [...(modelOptionsByType['llm'] || []), ...(modelOptionsByType['chat'] || [])]
|
||||
: modelOptionsByType[tp] || [];
|
||||
|
||||
// When tp is 'image2text', filter to only include models with 'vision' capability
|
||||
if (tp.toLowerCase() === 'image2text') {
|
||||
options = options.filter((opt: any) => {
|
||||
const model = models?.items?.find((m: any) => m.id === opt.value);
|
||||
return model?.capability?.includes('vision');
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Form.Item
|
||||
key={tp}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-10 18:52:55
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2026-03-03 14:46:08
|
||||
* @LastEditTime: 2026-03-09 16:39:07
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
@@ -58,16 +58,21 @@ const ShareModal = forwardRef<ShareModalRef,ShareModalRefProps>(({ handleShare:
|
||||
}
|
||||
|
||||
const handleShare = async() => {
|
||||
const workspaceIds = spaceList
|
||||
.map(item => item.target_kb?.workspace_id)
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
|
||||
console.log('Workspace IDs:', workspaceIds);
|
||||
shareSpaceModalRef?.current?.handleOpen(kbId,knowledgeBase,workspaceIds);
|
||||
|
||||
// Close modal after sharing
|
||||
handleClose();
|
||||
setLoading(true);
|
||||
try {
|
||||
const workspaceIds = spaceList
|
||||
.map(item => item.target_kb?.workspace_id)
|
||||
.filter(Boolean)
|
||||
.join(',');
|
||||
|
||||
console.log('Workspace IDs:', workspaceIds);
|
||||
shareSpaceModalRef?.current?.handleOpen(kbId,knowledgeBase,workspaceIds);
|
||||
|
||||
// Close modal after sharing
|
||||
handleClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const handleChange = (checked: boolean, item: any) => {
|
||||
// Toggle shared knowledge base status
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-10 18:52:55
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2025-12-03 18:44:58
|
||||
* @LastEditTime: 2026-03-09 16:34:51
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
@@ -50,34 +50,38 @@ const ShareModal = forwardRef<ShareModalRef,ShareModalRefProps>(({ handleShare:
|
||||
setSpaceList(filteredItems as SpaceItem[]);
|
||||
}
|
||||
const handleShare = async() => {
|
||||
|
||||
// Get all data with checked = true
|
||||
const checkedItems = spaceList.filter(item => item.is_active);
|
||||
debugger
|
||||
// Get currently selected item (corresponding to curIndex)
|
||||
const selectedItem = curIndex !== -1 ? spaceList[curIndex] : null;
|
||||
if(!selectedItem){
|
||||
messageApi.error(t('knowledgeBase.selectSpace'));
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
source_kb_id: kbId ?? '',
|
||||
target_workspace_id: selectedItem?.id ?? '',
|
||||
}
|
||||
const respose = await shareKnowledgeBase(payload)
|
||||
if(respose){
|
||||
messageApi.success(t('knowledgeBase.shareSuccess'));
|
||||
}else{
|
||||
messageApi.error(t('knowledgeBase.shareFailed'));
|
||||
}
|
||||
// Call parent component's callback function with selected data
|
||||
onShare?.({
|
||||
checkedItems,
|
||||
selectedItem
|
||||
});
|
||||
|
||||
// Close modal after sharing
|
||||
handleClose();
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
source_kb_id: kbId ?? '',
|
||||
target_workspace_id: selectedItem?.id ?? '',
|
||||
}
|
||||
const respose = await shareKnowledgeBase(payload)
|
||||
if(respose){
|
||||
messageApi.success(t('knowledgeBase.shareSuccess'));
|
||||
}else{
|
||||
messageApi.error(t('knowledgeBase.shareFailed'));
|
||||
}
|
||||
// Call parent component's callback function with selected data
|
||||
onShare?.({
|
||||
checkedItems,
|
||||
selectedItem
|
||||
});
|
||||
|
||||
// Close modal after sharing
|
||||
handleClose();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
const handleClick = (index: number, checked: boolean) => {
|
||||
if (!checked) return;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:30:06
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-04 10:09:45
|
||||
* @Last Modified time: 2026-03-06 13:49:00
|
||||
*/
|
||||
/**
|
||||
* Memory Extraction Engine Configuration Constants
|
||||
@@ -140,13 +140,8 @@ export const configList: ConfigVo[] = [
|
||||
{
|
||||
label: 'intelligentSemanticPruningScene',
|
||||
variableName: 'pruning_scene',
|
||||
control: 'select',
|
||||
control: 'text',
|
||||
type: 'enum',
|
||||
options: [
|
||||
{ label: 'education', value: 'education' },
|
||||
{ label: 'online_service', value: 'online_service' },
|
||||
{ label: 'outbound', value: 'outbound' },
|
||||
],
|
||||
meaning: 'intelligentSemanticPruningSceneDesc',
|
||||
},
|
||||
// Intelligent semantic pruning阈值
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:30:02
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 17:30:02
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-06 13:50:05
|
||||
*/
|
||||
/**
|
||||
* Memory Extraction Engine Configuration Page
|
||||
@@ -13,7 +13,7 @@
|
||||
import { type FC, useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Row, Col, Space, Select, InputNumber, Slider, App, Form } from 'antd'
|
||||
import { Row, Col, Space, Select, InputNumber, Slider, App, Form, Input } from 'antd'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import Card from './components/Card'
|
||||
@@ -35,15 +35,15 @@ const keys = [
|
||||
/**
|
||||
* Configuration description component
|
||||
*/
|
||||
const ConfigDesc: FC<{ config: Variable, className?: string }> = ({config, className}) => {
|
||||
const ConfigDesc: FC<{ config: Variable, className?: string; onlyMeaning?: boolean; }> = ({ config, className, onlyMeaning = false}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={className}>
|
||||
<Space size={8} className={clsx("rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4 ")}>
|
||||
{!onlyMeaning && <Space size={8} className={clsx("rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4 ")}>
|
||||
{config.variableName && <span className="rb:font-regular">{t('memoryExtractionEngine.variableName')}: {config.variableName}</span>}
|
||||
{config.control && <span className="rb:font-regular">{t('memoryExtractionEngine.control')}: {t(`memoryExtractionEngine.${config.control}`)}</span>}
|
||||
{config.type && <span className="rb:font-regular">{t('memoryExtractionEngine.type')}: {config.type}</span>}
|
||||
</Space>
|
||||
</Space>}
|
||||
{config.meaning && <div className={clsx("rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4 ")}>{t('memoryExtractionEngine.Meaning')}: {t(`memoryExtractionEngine.${config.meaning}`)}</div>}
|
||||
</div>
|
||||
)
|
||||
@@ -253,6 +253,21 @@ const MemoryExtractionEngine: FC = () => {
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
{config.control === 'text' &&
|
||||
<>
|
||||
<div className="rb:text-[14px] rb:font-medium rb:leading-5 rb:mt-6 rb:mb-2">
|
||||
-{t(`memoryExtractionEngine.${config.label}`)}
|
||||
</div>
|
||||
<div className="rb:pl-2">
|
||||
<Form.Item
|
||||
name={config.variableName}
|
||||
>
|
||||
<Input placeholder={t('common.pleaseEnter')} disabled />
|
||||
</Form.Item>
|
||||
<ConfigDesc config={config} onlyMeaning={true} className="rb:-mt-4!" />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:33:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-05 16:28:58
|
||||
* @Last Modified time: 2026-03-06 13:53:53
|
||||
*/
|
||||
/**
|
||||
* Memory Management Page
|
||||
@@ -154,10 +154,10 @@ const MemoryManagement: React.FC = () => {
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
|
||||
onClick={() => handleEdit(item)}
|
||||
></div>
|
||||
<div
|
||||
{!item.is_system_default && <div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/delete.svg')] rb:hover:bg-[url('@/assets/images/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(item)}
|
||||
></div>
|
||||
></div>}
|
||||
</Space>
|
||||
</div>
|
||||
</RbCard>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:49:45
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 11:50:47
|
||||
* @Last Modified time: 2026-03-06 12:26:12
|
||||
*/
|
||||
/**
|
||||
* Model List Detail Drawer
|
||||
@@ -144,7 +144,7 @@ const ModelListDetail = forwardRef<ModelListDetailRef, ModelListDetailProps>(({
|
||||
{item.name[0]}
|
||||
</div>
|
||||
}
|
||||
extra={<Switch defaultChecked={item.is_active} disabled={loading} onChange={() => handleChange(item)} />}
|
||||
extra={<Switch checked={item.is_active} disabled={loading} onChange={() => handleChange(item)} />}
|
||||
bodyClassName="rb:relative rb:pb-[64px]! rb:h-[calc(100%-64px)]!"
|
||||
>
|
||||
<Tooltip title={item.description}>
|
||||
@@ -153,7 +153,7 @@ const ModelListDetail = forwardRef<ModelListDetailRef, ModelListDetailProps>(({
|
||||
<div className="rb:absolute rb:bottom-4 rb:left-6 rb:right-6">
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Button block onClick={() => handleEdit(item)}>{t('modelNew.modelConfiguration')}</Button>
|
||||
{!item.model_id && <Button block onClick={() => handleEdit(item)}>{t('modelNew.modelConfiguration')}</Button>}
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Button type="primary" ghost block onClick={() => handleKeyConfig(item)}>{t('modelNew.keyConfig')}</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:50:18
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 11:39:20
|
||||
* @Last Modified time: 2026-03-06 12:26:11
|
||||
*/
|
||||
/**
|
||||
* Type definitions for Model Management
|
||||
@@ -121,6 +121,7 @@ export interface ModelApiKey {
|
||||
* Model list item data structure
|
||||
*/
|
||||
export interface ModelListItem {
|
||||
model_id?: string;
|
||||
/** Model name */
|
||||
model_name?: string;
|
||||
/** Associated model config IDs */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 14:10:24
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 18:02:13
|
||||
* @Last Modified time: 2026-03-06 11:25:59
|
||||
*/
|
||||
import { type FC, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -17,7 +17,7 @@ const { Header } = Layout;
|
||||
*/
|
||||
interface ConfigHeaderProps {
|
||||
/** Page title/name */
|
||||
name?: string;
|
||||
name?: string | ReactNode;
|
||||
/** Subtitle content displayed below the title */
|
||||
subTitle?: ReactNode | string;
|
||||
/** Extra content displayed on the right side */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 14:10:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-05 16:28:53
|
||||
* @Last Modified time: 2026-03-06 10:56:44
|
||||
*/
|
||||
import { type FC, useState, useRef, type MouseEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -181,8 +181,8 @@ const Ontology: FC = () => {
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<div className="rb:mt-4 rb:text-[12px] rb:leading-4 rb:font-regular rb:text-[#5B6167] rb:flex rb:items-center rb:justify-end">
|
||||
<Space size={16}>
|
||||
<div className="rb:mt-4 rb:h-5 rb:text-[12px] rb:leading-4 rb:font-regular rb:text-[#5B6167] rb:flex rb:items-center rb:justify-end">
|
||||
{!item.is_system_default && <Space size={16}>
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
|
||||
onClick={(e) => handleEdit(item, e)}
|
||||
@@ -191,7 +191,7 @@ const Ontology: FC = () => {
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/delete.svg')] rb:hover:bg-[url('@/assets/images/delete_hover.svg')]"
|
||||
onClick={(e) => handleDelete(item, e)}
|
||||
></div>
|
||||
</Space>
|
||||
</Space>}
|
||||
</div>
|
||||
</RbCard>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 14:10:20
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 17:56:35
|
||||
* @Last Modified time: 2026-03-06 11:26:49
|
||||
*/
|
||||
import { type FC, useEffect, useState, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom';
|
||||
@@ -17,6 +17,7 @@ import OntologyClassModal from '../components/OntologyClassModal'
|
||||
import SearchInput from '@/components/SearchInput';
|
||||
import OntologyClassExtractModal from '../components/OntologyClassExtractModal'
|
||||
import BodyWrapper from '@/components/Empty/BodyWrapper'
|
||||
import Tag from '@/components/Tag'
|
||||
|
||||
/**
|
||||
* Ontology detail page component
|
||||
@@ -99,19 +100,22 @@ const Detail: FC = () => {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
name={data.scene_name}
|
||||
name={<Space>
|
||||
{data.scene_name}
|
||||
{data.is_system_default ? <Tag color="warning">{t('common.default')}</Tag> : undefined}
|
||||
</Space>}
|
||||
subTitle={<Tooltip title={data.scene_description}><div className="rb:h-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{data.scene_description}</div></Tooltip>}
|
||||
extra={<Space>
|
||||
extra={data.is_system_default ? undefined : (<Space>
|
||||
<Button type="primary" ghost className="rb:h-6! rb:px-2! rb:leading-5.5!" onClick={handleAdd}>+ {t('ontology.addClass')}</Button>
|
||||
<Button className="rb:h-6! rb:px-2! rb:leading-5.5!" type="primary" onClick={handleExtract}>+ {t('ontology.extract')}</Button>
|
||||
</Space>}
|
||||
</Space>)}
|
||||
/>
|
||||
|
||||
<div className="rb:h-[calc(100vh-64px)] rb:overflow-y-auto rb:py-3 rb:px-4">
|
||||
<Row gutter={16} className="rb:mb-4">
|
||||
<Col span={6} offset={18}>
|
||||
<SearchInput
|
||||
placeholder={t('ontology.searchPlaceholder')}
|
||||
placeholder={t('ontology.classSearchPlaceholder')}
|
||||
onSearch={(value) => setQuery({ class_name: value })}
|
||||
className="rb:w-full!"
|
||||
/>
|
||||
@@ -123,10 +127,10 @@ const Detail: FC = () => {
|
||||
<Col key={item.class_id} span={6}>
|
||||
<RbCard
|
||||
title={item.class_name}
|
||||
extra={<div
|
||||
extra={data.is_system_default ? undefined : (<div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/delete.svg')] rb:hover:bg-[url('@/assets/images/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(item)}
|
||||
></div>}
|
||||
></div>)}
|
||||
className="rb:bg-transparent!"
|
||||
>
|
||||
<Tooltip title={item.class_description}>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 14:10:10
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-05 16:18:56
|
||||
* @Last Modified time: 2026-03-06 10:55:23
|
||||
*/
|
||||
/**
|
||||
* Query parameters for ontology list pagination and filtering
|
||||
@@ -94,6 +94,7 @@ export interface OntologyClassData {
|
||||
scene_description: string;
|
||||
/** Array of class items */
|
||||
items: OntologyClassItem[];
|
||||
is_system_default: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,131 +1,296 @@
|
||||
import React, { useState, useRef, type ReactNode } from 'react';
|
||||
import { Input, Button, Spin, App } from 'antd';
|
||||
import React, { useState, useRef, useEffect, useCallback, type ReactNode } from 'react';
|
||||
import { Input, Button, App, Card, Space, Skeleton, Tag } from 'antd';
|
||||
import { SearchOutlined, SettingOutlined, GlobalOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
import MarketConfigModal, { type MarketConfigModalRef } from './components/MarketConfigModal';
|
||||
|
||||
import McpServiceModal from './components/McpServiceModal';
|
||||
import type { McpServiceModalRef } from './types';
|
||||
import pageEmptyIcon from '@/assets/images/empty/pageEmpty.png'
|
||||
import Empty from '@/components/Empty/index'
|
||||
import { getMarketTools, getMarketConfig, getMarketMCPs, getMarketMCPDetail, getMarketMCPsActivated, getTools } from '@/api/tools';
|
||||
import BodyWrapper from '@/components/Empty/BodyWrapper';
|
||||
interface MarketSource {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
icon: string;
|
||||
logo_url: string;
|
||||
url: string;
|
||||
desc: string;
|
||||
apiKey: string;
|
||||
description: string;
|
||||
api_key?: string;
|
||||
connected: boolean;
|
||||
mcpCount: number;
|
||||
mcp_count: number;
|
||||
created_at?: number;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
interface MarketMcp {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
type: string;
|
||||
desc: string;
|
||||
downloads?: string;
|
||||
stars?: string;
|
||||
icon: string;
|
||||
configTemplate: any;
|
||||
chinese_name?: string;
|
||||
description: string;
|
||||
logo_url: string;
|
||||
publisher: string;
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
view_count?: number;
|
||||
activated?: boolean;
|
||||
inDatabase?: boolean;
|
||||
locales?: {
|
||||
[lang: string]: {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface MarketCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface MarketApiResponse {
|
||||
items: MarketSource[];
|
||||
}
|
||||
|
||||
const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const getLocaleField = (mcp: MarketMcp, field: 'name' | 'description') => {
|
||||
const lang = i18n.language?.startsWith('zh') ? 'zh' : 'en';
|
||||
return mcp.locales?.[lang]?.[field] || mcp[field] || '';
|
||||
};
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
const marketConfigModalRef = useRef<MarketConfigModalRef>(null);
|
||||
const [marketSources, setMarketSources] = useState<MarketSource[]>([
|
||||
{ id: 'smithery', name: 'Smithery', category: 'official', icon: '🔧', url: 'https://mcp.smithery.ai', desc: '官方 MCP 服务市场,提供丰富的 MCP 服务', apiKey: '', connected: false, mcpCount: 2847 },
|
||||
{ id: 'mcpmarket', name: 'MCP Market', category: 'official', icon: '🏪', url: 'https://mcpmarket.com', desc: '综合性 MCP 市场平台', apiKey: '', connected: false, mcpCount: 1523 },
|
||||
{ id: 'glama', name: 'Glama.ai MCP', category: 'official', icon: '✨', url: 'https://glama.ai/mcp', desc: 'Glama AI 提供的 MCP 服务集合', apiKey: '', connected: false, mcpCount: 892 },
|
||||
{ id: 'github-mcp', name: 'modelcontextprotocol/servers', category: 'official', icon: '🐙', url: 'https://github.com/modelcontextprotocol/servers', desc: 'GitHub 官方 MCP 服务器仓库', apiKey: '', connected: true, mcpCount: 156 },
|
||||
{ id: 'aliyun-bailian', name: '阿里云百炼 MCP', category: 'china-cloud', icon: '☁️', url: 'https://bailian.console.aliyun.com/mcp', desc: '阿里云百炼平台 MCP 市场', apiKey: '', connected: false, mcpCount: 423 },
|
||||
{ id: 'modelscope', name: '魔搭社区 MCP', category: 'china-cloud', icon: '🎭', url: 'https://modelscope.cn/mcp', desc: '阿里达摩院魔搭社区 MCP 市场', apiKey: '', connected: false, mcpCount: 312 },
|
||||
]);
|
||||
|
||||
const [categories] = useState<MarketCategory[]>([
|
||||
{ id: 'official', name: '官方/综合', icon: '🌐' },
|
||||
{ id: 'china-cloud', name: '国内云', icon: '☁️' },
|
||||
{ id: 'community', name: '社区/垂直', icon: '👥' }
|
||||
]);
|
||||
|
||||
const [mcpCache, setMcpCache] = useState<Record<string, MarketMcp[]>>({
|
||||
'github-mcp': [
|
||||
{ id: 'gh-1', name: 'Fetch', provider: 'modelcontextprotocol', type: 'Hosted', desc: '使用浏览器模拟大型语言模型检索和处理网页内容', downloads: '203.7m', stars: '308.2k', icon: '🌐', configTemplate: {} },
|
||||
{ id: 'gh-2', name: 'Filesystem', provider: 'modelcontextprotocol', type: 'Local', desc: '安全的文件系统操作,支持读写文件和目录管理', downloads: '156.2m', stars: '245.1k', icon: '📁', configTemplate: {} },
|
||||
{ id: 'gh-3', name: 'GitHub', provider: 'modelcontextprotocol', type: 'Hosted', desc: 'GitHub API 集成,支持仓库、Issue、PR 等操作', downloads: '89.4m', stars: '178.3k', icon: '🐙', configTemplate: {} },
|
||||
]
|
||||
});
|
||||
|
||||
const mcpServiceModalRef = useRef<McpServiceModalRef>(null);
|
||||
const [marketSources, setMarketSources] = useState<MarketSource[]>([]);
|
||||
const [categories, setCategories] = useState<MarketCategory[]>([]);
|
||||
const [mcpCache, setMcpCache] = useState<Record<string, MarketMcp[]>>({});
|
||||
const [mcpTotal, setMcpTotal] = useState(0);
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [configIdMap, setConfigIdMap] = useState<Record<string, string>>({});
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [activatedMcps, setActivatedMcps] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 20;
|
||||
|
||||
const handleSelectSource = (sourceId: string) => {
|
||||
setSelectedSource(sourceId);
|
||||
};
|
||||
|
||||
const handleRefresh = (sourceId: string) => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
// 模拟刷新数据
|
||||
const source = marketSources.find(s => s.id === sourceId);
|
||||
if (source) {
|
||||
message.success(`${source.name} 列表已刷新`);
|
||||
// 获取市场数据
|
||||
useEffect(() => {
|
||||
const fetchMarketData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getMarketTools({}) as MarketApiResponse;
|
||||
if (response?.items && Array.isArray(response.items)) {
|
||||
setMarketSources(response.items);
|
||||
|
||||
// 根据 category 字段分组
|
||||
const categoryMap = new Map<string, MarketCategory>();
|
||||
response.items.forEach(item => {
|
||||
if (item.category && !categoryMap.has(item.category)) {
|
||||
categoryMap.set(item.category, {
|
||||
id: item.category,
|
||||
name: item.category
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setCategories(Array.from(categoryMap.values()));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取市场数据失败:', error);
|
||||
message.error('获取市场数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchMarketData();
|
||||
}, [message]);
|
||||
|
||||
const fetchMcpList = async (sourceId: string, page = 1, append = false) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let configId = configIdMap[sourceId];
|
||||
|
||||
// 如果没有缓存 configId,先获取配置
|
||||
if (!configId) {
|
||||
const config: any = await getMarketConfig(sourceId);
|
||||
if (config?.id) {
|
||||
configId = config.id;
|
||||
setConfigIdMap(prev => ({ ...prev, [sourceId]: configId }));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 第一次加载时获取已激活列表
|
||||
let activatedIds: string[] = activatedMcps;
|
||||
if (page === 1 && !append) {
|
||||
const activatedRes: any = await getMarketMCPsActivated({ mcp_market_config_id: configId });
|
||||
if (activatedRes && Array.isArray(activatedRes)) {
|
||||
activatedIds = activatedRes.map((item: any) => item.id);
|
||||
setActivatedMcps(activatedIds);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取全量工具列表,用于标记已入库的 MCP
|
||||
const allTools: any = await getTools({ tool_type: 'mcp' });
|
||||
const toolsList = Array.isArray(allTools) ? allTools : [];
|
||||
|
||||
const res: any = await getMarketMCPs({ mcp_market_config_id: configId, page, pagesize: pageSize });
|
||||
if (res?.items && Array.isArray(res.items)) {
|
||||
// 标记已激活和已入库的 MCP
|
||||
const mcpsWithActivated = res.items.map((item: MarketMcp) => {
|
||||
// 检查是否已入库:market_id = sourceId, market_config_id = configId, mcp_service_id = item.id
|
||||
const isInDatabase = toolsList.some((tool: any) =>
|
||||
tool.config_data?.market_id === sourceId &&
|
||||
tool.config_data?.market_config_id === configId &&
|
||||
tool.config_data?.mcp_service_id === item.id
|
||||
);
|
||||
|
||||
return {
|
||||
...item,
|
||||
activated: activatedIds.includes(item.id),
|
||||
inDatabase: isInDatabase
|
||||
};
|
||||
});
|
||||
|
||||
setMcpCache(prev => ({
|
||||
...prev,
|
||||
[sourceId]: append ? [...(prev[sourceId] || []), ...mcpsWithActivated] : mcpsWithActivated
|
||||
}));
|
||||
}
|
||||
if (res?.page) {
|
||||
setMcpTotal(res.page.total || 0);
|
||||
setHasMore(!!res.page.has_next);
|
||||
setCurrentPage(res.page.page || page);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取 MCP 列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}, 600);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenConfig = (sourceId: string) => {
|
||||
const loadMore = useCallback(() => {
|
||||
if (!selectedSource || loading) return;
|
||||
fetchMcpList(selectedSource, currentPage + 1, true);
|
||||
}, [selectedSource, currentPage, loading]);
|
||||
|
||||
const handleSelectSource = async (sourceId: string) => {
|
||||
setSelectedSource(sourceId);
|
||||
setSearchKeyword('');
|
||||
setCurrentPage(1);
|
||||
setHasMore(false);
|
||||
setMcpTotal(0);
|
||||
|
||||
// 如果缓存中已有数据,直接使用
|
||||
if (mcpCache[sourceId]) return;
|
||||
|
||||
await fetchMcpList(sourceId, 1);
|
||||
};
|
||||
|
||||
const handleRefresh = async (sourceId: string) => {
|
||||
// 清除缓存,重新从第一页加载
|
||||
setMcpCache(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[sourceId];
|
||||
return next;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
await fetchMcpList(sourceId, 1);
|
||||
const source = marketSources.find(s => s.id === sourceId);
|
||||
if (source) {
|
||||
message.success(`${source.name} ${t('tool.marketRefreshSuccess')}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenConfig = async (sourceId: string) => {
|
||||
const source = marketSources.find(s => s.id === sourceId);
|
||||
if (!source) return;
|
||||
try {
|
||||
const config: any = await getMarketConfig(sourceId);
|
||||
marketConfigModalRef.current?.handleOpen({
|
||||
...source,
|
||||
connected: config?.status === 1,
|
||||
token: config?.token || '',
|
||||
configId: config?.id || '',
|
||||
});
|
||||
} catch {
|
||||
marketConfigModalRef.current?.handleOpen(source);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = (sourceId: string, apiKey: string) => {
|
||||
// 更新市场源状态
|
||||
const handleOpenMcpServiceModal = async (mcp: MarketMcp) => {
|
||||
if (!selectedSource || !configIdMap[selectedSource]) return;
|
||||
try {
|
||||
const detail: any = await getMarketMCPDetail({
|
||||
mcp_market_config_id: configIdMap[selectedSource],
|
||||
server_id: mcp.id,
|
||||
});
|
||||
const source = marketSources.find(s => s.id === selectedSource);
|
||||
const toolItem = {
|
||||
name: detail.name,
|
||||
description: detail.description,
|
||||
source_channel: source?.name || '',
|
||||
market_id: selectedSource,
|
||||
market_config_id: configIdMap[selectedSource],
|
||||
mcp_service_id: mcp.id,
|
||||
config_data: {
|
||||
server_url: detail.servers?.[0]?.url || '',
|
||||
connection_config: {
|
||||
auth_type: 'none',
|
||||
timeout: 30,
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
mcpServiceModalRef.current?.handleOpen(toolItem as any);
|
||||
} catch (error) {
|
||||
console.error('获取 MCP 服务详情失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = async (sourceId: string, configId: string) => {
|
||||
// 更新市场源状态,缓存 configId
|
||||
setMarketSources(prev => prev.map(source => {
|
||||
if (source.id === sourceId) {
|
||||
return {
|
||||
...source,
|
||||
apiKey,
|
||||
connected: true
|
||||
};
|
||||
return { ...source, connected: true };
|
||||
}
|
||||
return source;
|
||||
}));
|
||||
setConfigIdMap(prev => ({ ...prev, [sourceId]: configId }));
|
||||
|
||||
// 模拟获取MCP列表
|
||||
setTimeout(() => {
|
||||
const source = marketSources.find(s => s.id === sourceId);
|
||||
if (source && !mcpCache[sourceId]) {
|
||||
// 生成模拟数据
|
||||
const mockData: MarketMcp[] = [
|
||||
{ id: `${sourceId}-1`, name: `${source.name} 服务 1`, provider: source.name, type: 'Hosted', desc: `来自 ${source.name} 的 MCP 服务`, downloads: '10.2m', stars: '23.4k', icon: '🔧', configTemplate: {} },
|
||||
{ id: `${sourceId}-2`, name: `${source.name} 服务 2`, provider: source.name, type: 'Local', desc: `来自 ${source.name} 的本地 MCP 服务`, downloads: '8.5m', stars: '18.7k', icon: '⚙️', configTemplate: {} }
|
||||
];
|
||||
setMcpCache(prev => ({
|
||||
...prev,
|
||||
[sourceId]: mockData
|
||||
}));
|
||||
// 用 configId 获取第一页 MCP 列表
|
||||
try {
|
||||
const res: any = await getMarketMCPs({ mcp_market_config_id: configId, page: 1, pagesize: pageSize });
|
||||
if (res?.items && Array.isArray(res.items)) {
|
||||
setMcpCache(prev => ({ ...prev, [sourceId]: res.items }));
|
||||
}
|
||||
message.success(`已连接 ${source?.name}`);
|
||||
}, 800);
|
||||
if (res?.page) {
|
||||
setMcpTotal(res.page.total || 0);
|
||||
setHasMore(!!res.page.has_next);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取 MCP 列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const renderSourceDetail = () => {
|
||||
if (!selectedSource) {
|
||||
return (
|
||||
<div className="rb:flex rb:flex-col rb:items-center rb:justify-center rb:h-full rb:text-center">
|
||||
<div className="rb:text-6xl rb:mb-4">🏪</div>
|
||||
<h3 className="rb:text-lg rb:font-semibold rb:text-gray-900 rb:mb-2">选择一个 MCP 市场</h3>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:max-w-md">从左侧选择一个市场源,配置连接后即可浏览该市场的 MCP 服务</p>
|
||||
<Empty
|
||||
url={pageEmptyIcon}
|
||||
title={t('tool.marketSelectTitle')}
|
||||
subTitle={t('tool.marketSelectDesc')}
|
||||
size={200}
|
||||
className="rb:h-full"
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -134,170 +299,218 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
if (!source) return null;
|
||||
|
||||
const mcpList = mcpCache[selectedSource] || [];
|
||||
const filteredList = mcpList.filter(mcp =>
|
||||
mcp.name.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
||||
mcp.desc.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
);
|
||||
const filteredList = mcpList.filter(mcp => {
|
||||
const name = getLocaleField(mcp, 'name');
|
||||
const desc = getLocaleField(mcp, 'description');
|
||||
return name.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
||||
desc.toLowerCase().includes(searchKeyword.toLowerCase());
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rb:flex rb:justify-between rb:items-start rb:pb-6 rb:border-b rb:border-gray-200 rb:mb-6">
|
||||
<div className="rb:flex rb:gap-4">
|
||||
<div className="rb:text-5xl rb:w-16 rb:h-16 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded-xl rb:flex-shrink-0">
|
||||
{source.icon}
|
||||
<div className="rb:flex rb:justify-between rb:items-center rb:pb-0">
|
||||
<div className="rb:flex rb:items-center rb:gap-4">
|
||||
<div className="rb:w-10 rb:h-10 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded-xl rb:flex-shrink-0 rb:overflow-hidden">
|
||||
{source.logo_url ? (
|
||||
<img
|
||||
src={source.logo_url}
|
||||
alt={source.name}
|
||||
className="rb:w-full rb:h-full rb:object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
const parent = e.currentTarget.parentElement;
|
||||
if (parent) {
|
||||
parent.innerHTML = '🏪';
|
||||
parent.style.fontSize = '48px';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="rb:text-5xl">🏪</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rb:flex-1">
|
||||
<h2 className="rb:text-xl rb:font-semibold rb:text-gray-900 rb:mb-2">{source.name}</h2>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed">{source.desc}</p>
|
||||
<div className="rb:flex rb:items-center rb:flex-1">
|
||||
<h2 className="rb:text-xl rb:font-semibold rb:text-gray-900 rb:mb-2 rb:mr-2">{source.name}</h2>
|
||||
可用 MCP 服务 <span className="rb:text-gray-600 rb:font-normal">({mcpTotal})</span>
|
||||
{/* <p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed">{source.description}</p> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rb:flex rb:gap-3">
|
||||
<Button icon={<SettingOutlined />} onClick={() => handleOpenConfig(selectedSource)}>
|
||||
配置
|
||||
</Button>
|
||||
<Button type="primary" icon={<GlobalOutlined />} onClick={() => window.open(source.url, '_blank')}>
|
||||
前往市场
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rb:mt-6">
|
||||
<div className="rb:flex rb:justify-between rb:items-center rb:mb-5">
|
||||
<h3 className="rb:text-base rb:font-semibold rb:text-gray-900 rb:m-0">
|
||||
可用 MCP 服务 <span className="rb:text-gray-600 rb:font-normal">({mcpList.length})</span>
|
||||
</h3>
|
||||
<div className="rb:flex rb:gap-3">
|
||||
<div className="rb:flex rb:gap-3 rb:items-center">
|
||||
{source.connected && (
|
||||
<Button size="small" icon={<SyncOutlined />} onClick={() => handleRefresh(selectedSource)}>
|
||||
刷新
|
||||
{t('tool.marketRefresh')}
|
||||
</Button>
|
||||
)}
|
||||
{mcpList.length > 0 && (
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索服务..."
|
||||
placeholder={t('tool.marketSearchPlaceholder')}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Button icon={<SettingOutlined />} onClick={() => handleOpenConfig(selectedSource)}>
|
||||
{t('tool.marketConfig')}
|
||||
</Button>
|
||||
<Button type="primary" icon={<GlobalOutlined />} onClick={() => window.open(source.url, '_blank')}>
|
||||
{t('tool.marketVisit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpList.length > 0 ? (
|
||||
<Spin spinning={loading}>
|
||||
<div className="rb:grid rb:grid-cols-1 md:rb:grid-cols-2 lg:rb:grid-cols-3 rb:gap-4">
|
||||
{filteredList.map(mcp => (
|
||||
<div className="rb:mt-6">
|
||||
<BodyWrapper loading={loading} empty={mcpList.length === 0}>
|
||||
<div id="mcpScrollableDiv" className="rb:overflow-y-auto rb:h-[calc(100vh-260px)]">
|
||||
<InfiniteScroll
|
||||
dataLength={filteredList.length}
|
||||
next={loadMore}
|
||||
hasMore={hasMore}
|
||||
loader={<Skeleton active paragraph={{ rows: 2 }} className="rb:mt-4" />}
|
||||
scrollableTarget="mcpScrollableDiv"
|
||||
>
|
||||
<div className="rb:grid rb:grid-cols-3 rb:gap-4">
|
||||
{filteredList.map(mcp => (
|
||||
<div
|
||||
key={mcp.id}
|
||||
className="rb:bg-white rb:border rb:border-gray-200 rb:rounded-lg rb:p-4 rb:transition-all rb:duration-200 hover:rb:shadow-lg hover:rb:border-gray-300"
|
||||
className="rb:bg-white rb:border rb:border-gray-200 rb:rounded-lg rb:p-4 rb:pb-2 rb:transition-all rb:duration-200 hover:rb:shadow-lg hover:rb:border-gray-300"
|
||||
>
|
||||
<div className="rb:flex rb:justify-between rb:items-center rb:mb-3">
|
||||
<div className="rb:text-3xl rb:w-12 rb:h-12 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded-lg">
|
||||
{mcp.icon}
|
||||
<div className="rb:w-12 rb:h-12 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded-lg rb:overflow-hidden">
|
||||
{mcp.logo_url ? (
|
||||
<img
|
||||
src={mcp.logo_url}
|
||||
alt={getLocaleField(mcp, 'name')}
|
||||
className="rb:w-full rb:h-full rb:object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
const parent = e.currentTarget.parentElement;
|
||||
if (parent) {
|
||||
parent.innerHTML = '🔧';
|
||||
parent.style.fontSize = '24px';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="rb:text-3xl">🔧</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`rb:px-2 rb:py-1 rb:rounded rb:text-xs rb:font-medium ${
|
||||
mcp.type === 'Hosted'
|
||||
? 'rb:bg-blue-50 rb:text-blue-700'
|
||||
: 'rb:bg-gray-100 rb:text-gray-600'
|
||||
}`}>
|
||||
{mcp.type}
|
||||
</span>
|
||||
{mcp.categories?.[0] && (
|
||||
<span className="rb:px-2 rb:py-1 rb:rounded rb:text-xs rb:font-medium rb:bg-blue-50 rb:text-blue-700">
|
||||
{mcp.categories[0]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="rb:text-base rb:font-semibold rb:text-gray-900 rb:mb-1">{mcp.name}</h3>
|
||||
{mcp.provider && (
|
||||
<h3 className="rb:text-base rb:font-semibold rb:text-gray-900 rb:mb-1">{getLocaleField(mcp, 'name')}</h3>
|
||||
{mcp.publisher && (
|
||||
<div className="rb:mb-2">
|
||||
<span className="rb:text-xs rb:text-gray-500">@ {mcp.provider}</span>
|
||||
<span className="rb:text-xs rb:text-gray-500">{mcp.publisher.startsWith('@') ? mcp.publisher : `@${mcp.publisher}`}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed rb:mb-3 rb:min-h-[42px]">{mcp.desc}</p>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:line-clamp-2 rb:mb-3 rb:min-h-10">{getLocaleField(mcp, 'description')}</p>
|
||||
<div className="rb:flex rb:gap-4 rb:mb-3 rb:pt-3 rb:border-t rb:border-gray-100">
|
||||
{mcp.downloads && (
|
||||
{mcp.view_count != null && (
|
||||
<span className="rb:flex rb:items-center rb:gap-1 rb:text-xs rb:text-gray-500">
|
||||
<GlobalOutlined /> {mcp.downloads}
|
||||
</span>
|
||||
)}
|
||||
{mcp.stars && (
|
||||
<span className="rb:flex rb:items-center rb:gap-1 rb:text-xs rb:text-gray-500">
|
||||
⭐ {mcp.stars}
|
||||
<GlobalOutlined /> {mcp.view_count.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rb:flex rb:justify-end">
|
||||
<Button type="primary" size="small">
|
||||
+ 添加
|
||||
<div className={`rb:flex rb:items-center ${mcp.activated || mcp.inDatabase ? 'rb:justify-between' : 'rb:justify-end'}`}>
|
||||
<div className="rb:flex rb:gap-2">
|
||||
{mcp.activated && <Tag color="success">{t('tool.marketActivated')}</Tag>}
|
||||
{mcp.inDatabase && <Tag color="blue">{t('tool.marketInDatabase')}</Tag>}
|
||||
</div>
|
||||
<Button type="primary" size="small" onClick={() => handleOpenMcpServiceModal(mcp)}>
|
||||
+ {t('tool.marketAdd')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Spin>
|
||||
) : (
|
||||
<div className="rb:flex rb:flex-col rb:items-center rb:justify-center rb:py-16 rb:text-center">
|
||||
<div className="rb:text-6xl rb:mb-4">{source.connected ? '📭' : '🔌'}</div>
|
||||
<h4 className="rb:text-base rb:font-semibold rb:text-gray-900 rb:mb-2">
|
||||
{source.connected ? '暂无可用的 MCP 服务' : '尚未连接此市场'}
|
||||
</h4>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:mb-4">
|
||||
{source.connected ? '该市场暂时没有可用的服务' : '点击右上角"配置"按钮设置连接信息'}
|
||||
</p>
|
||||
{!source.connected && (
|
||||
<Button type="primary" onClick={() => handleOpenConfig(selectedSource)}>
|
||||
配置连接
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
)}
|
||||
</BodyWrapper>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rb:flex rb:gap-4 rb:h-[calc(100vh-178px)]">
|
||||
<div className="rb:flex rb:gap-4 rb:h-[calc(100vh-138px)]">
|
||||
{/* 左侧市场源列表 */}
|
||||
<div className="rb:w-70 rb:bg-white rb:rounded-lg rb:border rb:border-gray-200 rb:overflow-y-auto rb:flex-shrink-0">
|
||||
<div className="rb:p-4 rb:border-b rb:border-gray-200">
|
||||
<span className="rb:text-base rb:font-semibold rb:text-gray-900">MCP 市场</span>
|
||||
</div>
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id} className="rb:py-3 rb:border-b rb:border-gray-100 last:rb:border-b-0">
|
||||
<div className="rb:flex rb:items-center rb:gap-2 rb:px-4 rb:py-2 rb:text-xs rb:font-medium rb:text-gray-500 rb:uppercase">
|
||||
<span className="rb:text-sm">{cat.icon}</span>
|
||||
<span>{cat.name}</span>
|
||||
</div>
|
||||
<div className="rb:px-2 rb:py-1">
|
||||
{marketSources
|
||||
.filter(s => s.category === cat.id)
|
||||
.map(source => (
|
||||
<div
|
||||
key={source.id}
|
||||
className={`rb:flex rb:items-center rb:gap-2 rb:px-3 rb:py-2.5 rb:rounded-md rb:cursor-pointer rb:transition-all rb:relative ${
|
||||
selectedSource === source.id
|
||||
? 'rb:bg-blue-50 rb:text-blue-600'
|
||||
: 'hover:rb:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => handleSelectSource(source.id)}
|
||||
>
|
||||
<span className="rb:text-lg rb:flex-shrink-0">{source.icon}</span>
|
||||
<span className="rb:flex-1 rb:text-sm rb:font-medium rb:overflow-hidden rb:text-ellipsis rb:whitespace-nowrap">
|
||||
{source.name}
|
||||
</span>
|
||||
<span className="rb:text-xs rb:text-gray-500 rb:px-1.5 rb:py-0.5 rb:bg-gray-100 rb:rounded-full">
|
||||
{source.mcpCount}
|
||||
</span>
|
||||
{source.connected && (
|
||||
<span className="rb:text-green-500 rb:text-[8px] rb:ml-1">●</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="rb:w-80 rb:h-full rb:overflow-y-auto">
|
||||
<Space size={12} direction="vertical" className="rb:w-full">
|
||||
{categories.map(cat => (
|
||||
<Card
|
||||
key={cat.id}
|
||||
type="inner"
|
||||
title={
|
||||
<div className="rb:flex rb:items-center rb:gap-2">
|
||||
<span>{cat.name}</span>
|
||||
</div>
|
||||
}
|
||||
classNames={{
|
||||
body: "rb:p-[10px]!",
|
||||
header: "rb:bg-[#F6F8FC]!"
|
||||
}}
|
||||
>
|
||||
<Space size={8} direction="vertical" className="rb:w-full">
|
||||
{marketSources
|
||||
.filter(s => s.category === cat.id)
|
||||
.map(source => (
|
||||
<div
|
||||
key={source.id}
|
||||
className={`rb:bg-white rb:rounded-lg rb:p-2 rb:border rb:cursor-pointer rb:flex rb:items-center rb:gap-2 rb:transition-all ${
|
||||
selectedSource === source.id
|
||||
? 'rb:border-[#155EEF] rb:shadow-[0px_2px_4px_0px_rgba(33,35,50,0.15)]'
|
||||
: 'rb:border-[#DFE4ED] rb:hover:border-[#155EEF] rb:hover:shadow-[0px_2px_4px_0px_rgba(33,35,50,0.15)]'
|
||||
}`}
|
||||
onClick={() => handleSelectSource(source.id)}
|
||||
>
|
||||
<div className="rb:w-5 rb:h-5 rb:flex-shrink-0 rb:flex rb:items-center rb:justify-center rb:overflow-hidden rb:rounded rb:bg-gray-100">
|
||||
{source.logo_url ? (
|
||||
<img
|
||||
src={source.logo_url}
|
||||
alt={source.name}
|
||||
className="rb:w-full rb:h-full rb:object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
const parent = e.currentTarget.parentElement;
|
||||
if (parent) {
|
||||
parent.innerHTML = '🏪';
|
||||
parent.style.fontSize = '16px';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="rb:text-base">🏪</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="rb:flex-1 rb:font-medium rb:text-[12px] rb:overflow-hidden rb:text-ellipsis rb:whitespace-nowrap">
|
||||
{source.name}
|
||||
</span>
|
||||
<span className="rb:text-xs rb:text-gray-500 rb:px-1.5 rb:py-0.5 rb:bg-gray-100 rb:rounded-full rb:flex-shrink-0">
|
||||
{source.mcp_count}
|
||||
</span>
|
||||
{source.connected && (
|
||||
<span className="rb:text-green-500 rb:text-[8px] rb:flex-shrink-0">●</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 右侧内容区 */}
|
||||
<div className="rb:flex-1 rb:bg-white rb:rounded-lg rb:border rb:border-gray-200 rb:overflow-hidden">
|
||||
<div className="rb:flex-1 rb:border-l rb:border-gray-200 rb:overflow-hidden">
|
||||
<div className="rb:h-full rb:overflow-y-auto rb:p-6">
|
||||
{renderSourceDetail()}
|
||||
</div>
|
||||
@@ -308,6 +521,10 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
ref={marketConfigModalRef}
|
||||
onConnect={handleConnect}
|
||||
/>
|
||||
<McpServiceModal
|
||||
ref={mcpServiceModalRef}
|
||||
refresh={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,7 +61,6 @@ const Mcp: React.FC<{ getStatusTag: (status: string) => ReactNode }> = ({ getSta
|
||||
getData()
|
||||
})
|
||||
};
|
||||
|
||||
// 删除服务
|
||||
const handleDeleteService = (item: ToolItem) => {
|
||||
if (!item.id) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, Button, App, Space } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { createMarketConfig,updateMarketConfig } from '@/api/tools';
|
||||
import RbModal from '@/components/RbModal';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
@@ -9,15 +10,16 @@ const FormItem = Form.Item;
|
||||
interface MarketSource {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
logo_url: string;
|
||||
url: string;
|
||||
desc: string;
|
||||
apiKey: string;
|
||||
description: string;
|
||||
token?: string;
|
||||
connected: boolean;
|
||||
configId?: string;
|
||||
}
|
||||
|
||||
interface MarketConfigModalProps {
|
||||
onConnect: (sourceId: string, apiKey: string) => void;
|
||||
onConnect: (sourceId: string, configId: string) => void;
|
||||
}
|
||||
|
||||
export interface MarketConfigModalRef {
|
||||
@@ -47,8 +49,7 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
const handleOpen = (source: MarketSource) => {
|
||||
setCurrentSource(source);
|
||||
form.setFieldsValue({
|
||||
url: source.url,
|
||||
apiKey: source.apiKey,
|
||||
token: source.token || '',
|
||||
});
|
||||
setVisible(true);
|
||||
};
|
||||
@@ -56,18 +57,36 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
const handleSave = () => {
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
.then(async (values) => {
|
||||
if (!currentSource) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// 模拟连接延迟
|
||||
setTimeout(() => {
|
||||
onConnect(currentSource.id, values.apiKey || '');
|
||||
message.success(`正在连接 ${currentSource.name}...`);
|
||||
setLoading(false);
|
||||
try {
|
||||
let res: any;
|
||||
if (currentSource.configId) {
|
||||
// 更新配置
|
||||
res = await updateMarketConfig({
|
||||
mcp_market_config_id: currentSource.configId,
|
||||
token: values.token || '',
|
||||
status: 1,
|
||||
});
|
||||
message.success(t('tool.marketConfigUpdated', { name: currentSource.name }));
|
||||
} else {
|
||||
// 创建配置
|
||||
res = await createMarketConfig({
|
||||
mcp_market_id: currentSource.id || '',
|
||||
token: values.token || '',
|
||||
status: 1,
|
||||
});
|
||||
message.success(t('tool.marketConnecting', { name: currentSource.name }));
|
||||
}
|
||||
onConnect(currentSource.id, res.id || currentSource.configId);
|
||||
handleClose();
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('表单验证失败:', err);
|
||||
@@ -91,10 +110,10 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={`配置 ${currentSource.name}`}
|
||||
title={t('tool.marketConfig', { name: currentSource.name })}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText="保存并连接"
|
||||
okText={t('tool.marketSaveAndConnect')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
width={600}
|
||||
@@ -102,12 +121,28 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
<div>
|
||||
{/* 市场源信息头部 */}
|
||||
<div className="rb:flex rb:gap-4 rb:mb-6 rb:p-4 rb:bg-gray-50 rb:rounded-lg">
|
||||
<div className="rb:text-4xl rb:w-16 rb:h-16 rb:flex rb:items-center rb:justify-center rb:bg-white rb:rounded-lg rb:flex-shrink-0">
|
||||
{currentSource.icon}
|
||||
<div className="rb:w-16 rb:h-16 rb:flex rb:items-center rb:justify-center rb:bg-white rb:rounded-lg rb:flex-shrink-0 rb:overflow-hidden">
|
||||
{currentSource.logo_url ? (
|
||||
<img
|
||||
src={currentSource.logo_url}
|
||||
alt={currentSource.name}
|
||||
className="rb:w-full rb:h-full rb:object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none';
|
||||
const parent = e.currentTarget.parentElement;
|
||||
if (parent) {
|
||||
parent.innerHTML = '🏪';
|
||||
parent.style.fontSize = '32px';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className="rb:text-4xl">🏪</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rb:flex-1">
|
||||
<h3 className="rb:text-base rb:font-semibold rb:mb-1 rb:text-gray-900">{currentSource.name}</h3>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed">{currentSource.desc}</p>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed">{currentSource.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,39 +150,34 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
{/* 市场地址 */}
|
||||
<FormItem
|
||||
name="url"
|
||||
label="市场地址"
|
||||
>
|
||||
<FormItem label={t('tool.marketUrl')}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
readOnly
|
||||
placeholder="市场地址"
|
||||
value={currentSource.url}
|
||||
/>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={handleCopyUrl}
|
||||
>
|
||||
复制
|
||||
{t('tool.marketCopy')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</FormItem>
|
||||
|
||||
{/* API Key */}
|
||||
<FormItem
|
||||
name="apiKey"
|
||||
name="token"
|
||||
label={
|
||||
<span>
|
||||
API Key <span className="rb:text-gray-400 rb:font-normal">(可选)</span>
|
||||
API Key <span className="rb:text-gray-400 rb:font-normal">({t('tool.marketApiKeyOptional')})</span>
|
||||
</span>
|
||||
}
|
||||
extra="部分市场需要 API Key 才能获取完整的服务列表"
|
||||
extra={<span style={{ display: 'inline-block', marginTop: 8 }}>{t('tool.marketApiKeyExtra')}</span>}
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="输入 API Key 以获取更多服务"
|
||||
placeholder={t('tool.marketApiKeyPlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
@@ -157,11 +187,10 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
</Space.Compact>
|
||||
</FormItem>
|
||||
|
||||
{/* 连接状态 */}
|
||||
<div className="rb:flex rb:items-center rb:gap-2 rb:p-3 rb:bg-gray-50 rb:rounded rb:text-sm">
|
||||
<span className="rb:text-gray-600">连接状态:</span>
|
||||
<span className="rb:text-gray-600">{t('tool.marketConnectionStatus')}:</span>
|
||||
<span className={`rb:font-medium ${currentSource.connected ? 'rb:text-green-600' : 'rb:text-gray-400'}`}>
|
||||
{currentSource.connected ? '● 已连接' : '○ 未连接'}
|
||||
{currentSource.connected ? t('tool.marketConnected') : t('tool.marketDisconnected')}
|
||||
</span>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -70,7 +70,7 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
config: { ...config_data }
|
||||
})
|
||||
|
||||
if (config_data.connection_config.headers) {
|
||||
if (config_data?.connection_config?.headers) {
|
||||
console.log(Object.keys(config_data.connection_config.headers).map(key => ({
|
||||
key,
|
||||
value: config_data.connection_config.headers[key]
|
||||
@@ -81,6 +81,16 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
})))
|
||||
}
|
||||
setEditVo(data)
|
||||
} else if (data) {
|
||||
const { config_data, name, description, icon } = data
|
||||
form.setFieldsValue({
|
||||
name, description, icon,
|
||||
...(config_data ? { config: { ...config_data } } : {})
|
||||
})
|
||||
// 如果是从 Market 组件传来的数据(包含 market_id),保存完整的 data 用于后续提交
|
||||
if ((data as any).market_id) {
|
||||
setEditVo(data)
|
||||
}
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
@@ -110,6 +120,15 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是从 Market 组件传来的数据,添加市场相关字段
|
||||
if ((editVo as any)?.market_id) {
|
||||
(newService.config as any).source_channel = (editVo as any).source_channel;
|
||||
(newService.config as any).market_id = (editVo as any).market_id;
|
||||
(newService.config as any).market_config_id = (editVo as any).market_config_id;
|
||||
(newService.config as any).mcp_service_id = (editVo as any).mcp_service_id;
|
||||
}
|
||||
|
||||
const request = editVo?.id ? updateTool(editVo.id, newService) : addTool(newService)
|
||||
request.then((res: any) => {
|
||||
message.success(t('common.saveSuccess'));
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @Author: yujiangping
|
||||
* @Date: 2026-01-05 17:22:23
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2026-03-04 15:12:48
|
||||
* @LastEditTime: 2026-03-06 15:11:31
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Tabs } from 'antd';
|
||||
@@ -16,7 +16,7 @@ import Custom from './Custom';
|
||||
import Market from './Market';
|
||||
import Tag from '@/components/Tag'
|
||||
|
||||
const tabKeys = ['mcp', 'inner', 'custom', 'market']
|
||||
const tabKeys = ['mcp', 'inner', 'custom', 'market'] //
|
||||
const ToolManagement: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('mcp');
|
||||
@@ -54,7 +54,7 @@ const ToolManagement: React.FC = () => {
|
||||
{activeTab === 'mcp' && <Mcp getStatusTag={getStatusTag} />}
|
||||
{activeTab === 'inner' && <Inner getStatusTag={getStatusTag} />}
|
||||
{activeTab === 'custom' && <Custom getStatusTag={getStatusTag} />}
|
||||
{/* {activeTab === 'market' && <Market getStatusTag={getStatusTag} />} */}
|
||||
{activeTab === 'market' && <Market getStatusTag={getStatusTag} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -75,6 +75,10 @@ export interface ToolItem {
|
||||
tool_class: string;
|
||||
|
||||
schema_content: string;
|
||||
source_channel?: string;
|
||||
market_id?: string;
|
||||
market_config_id?: string;
|
||||
mcp_service_id?: string;
|
||||
};
|
||||
status: 'available' | 'unavailable';
|
||||
tags: string[];
|
||||
@@ -136,4 +140,10 @@ export interface ExecuteData {
|
||||
export interface CustomToolModalRef {
|
||||
handleOpen: (data?: ToolItem) => void;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
export interface MarketQuery {
|
||||
mcp_market_config_id?: string;
|
||||
page?: number;
|
||||
pagesize?: number;
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { FC } from 'react';
|
||||
import { Select } from 'antd';
|
||||
import { Select, Divider } from 'antd';
|
||||
// import { Node } from '@antv/x6';
|
||||
import type { GraphRef } from '../types'
|
||||
import { PlusOutlined, MinusOutlined } from '@ant-design/icons'
|
||||
import { PlusOutlined, MinusOutlined, FileAddOutlined } from '@ant-design/icons'
|
||||
|
||||
interface CanvasToolbarProps {
|
||||
miniMapRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -14,6 +14,7 @@ interface CanvasToolbarProps {
|
||||
canRedo: boolean;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
addNotes: () => void;
|
||||
}
|
||||
|
||||
const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
@@ -26,6 +27,7 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
// canRedo,
|
||||
// onUndo,
|
||||
// onRedo,
|
||||
addNotes,
|
||||
}) => {
|
||||
// 整理布局函数
|
||||
/*
|
||||
@@ -152,7 +154,7 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
{/* 小地图 */}
|
||||
<div ref={miniMapRef} className="rb:absolute rb:bottom-15 rb:right-8 rb:z-1000 rb:rounded-lg rb:overflow-hidden"></div>
|
||||
{/* 缩放控制按钮 */}
|
||||
<div className="rb:h-8.5 rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.15)] rb:px-3 rb:py-2 rb:absolute rb:bottom-5 rb:right-8 rb:flex rb:flex-row rb:gap-4 rb:z-1000">
|
||||
<div className="rb:h-8.5 rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.15)] rb:px-3 rb:py-2 rb:absolute rb:bottom-5 rb:right-8 rb:flex rb:flex-row rb:items-center rb:gap-4 rb:z-1000">
|
||||
<MinusOutlined className="rb:text-[16px] rb:cursor-pointer" onClick={() => graphRef.current?.zoom(-0.1)} />
|
||||
<Select
|
||||
value={Math.round(zoomLevel * 100)}
|
||||
@@ -182,6 +184,8 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
size="small"
|
||||
/>
|
||||
<PlusOutlined className="rb:text-[16px] rb:cursor-pointer" onClick={() => graphRef.current?.zoom(0.1)} />
|
||||
<Divider type="vertical" className="rb:h-4" />
|
||||
<FileAddOutlined onClick={addNotes} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
||||
const textContent = root.getTextContent();
|
||||
if (textContent !== prevValueRef.current) {
|
||||
isUserInputRef.current = true;
|
||||
prevValueRef.current = textContent;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -33,7 +34,13 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((value !== prevValueRef.current || enableLineNumbers !== prevEnableLineNumbersRef.current) && !isUserInputRef.current) {
|
||||
if (value !== prevValueRef.current || enableLineNumbers !== prevEnableLineNumbersRef.current) {
|
||||
// Skip reset if the change was triggered by user input (avoid cursor jump)
|
||||
if (isUserInputRef.current && enableLineNumbers === prevEnableLineNumbersRef.current) {
|
||||
prevValueRef.current = value;
|
||||
isUserInputRef.current = false;
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
editor.update(() => {
|
||||
const root = $getRoot();
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { FORMAT_TEXT_COMMAND, $getSelection, $isRangeSelection, $setSelection, $isTextNode, type BaseSelection } from 'lexical';
|
||||
import { $patchStyleText } from '@lexical/selection';
|
||||
import { INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, ListNode } from '@lexical/list';
|
||||
import { TOGGLE_LINK_COMMAND, LinkNode } from '@lexical/link';
|
||||
import { $getNearestNodeOfType } from '@lexical/utils';
|
||||
|
||||
export const NOTE_FORMAT_EVENT = 'note:format';
|
||||
|
||||
export interface FormatState {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
strikethrough: boolean;
|
||||
list: boolean;
|
||||
fontSize?: number;
|
||||
linkUrl?: string | null;
|
||||
}
|
||||
|
||||
const NoteFormatPlugin = ({ nodeId, onFormatChange, fontSize = 12 }: { nodeId: string; fontSize?: number; onFormatChange?: (state: FormatState) => void }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const savedSelection = useRef<BaseSelection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const selection = $getSelection();
|
||||
if (!$isRangeSelection(selection)) return;
|
||||
savedSelection.current = selection.clone();
|
||||
const anchorNode = selection.anchor.getNode();
|
||||
const style = 'getStyle' in anchorNode ? (anchorNode as { getStyle(): string }).getStyle() : '';
|
||||
const match = style.match(/font-size:\s*([\d.]+)px/);
|
||||
const nodeFontSize = match ? Number(match[1]) : fontSize;
|
||||
const linkNode = $getNearestNodeOfType(anchorNode, LinkNode);
|
||||
onFormatChange?.({
|
||||
bold: selection.hasFormat('bold'),
|
||||
italic: selection.hasFormat('italic'),
|
||||
strikethrough: selection.hasFormat('strikethrough'),
|
||||
list: !!$getNearestNodeOfType(anchorNode, ListNode),
|
||||
...(nodeFontSize ? { fontSize: nodeFontSize } : {}),
|
||||
linkUrl: linkNode ? linkNode.getURL() : null,
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [editor, onFormatChange]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { id, format, value } = (e as CustomEvent).detail;
|
||||
if (id !== nodeId) return;
|
||||
const sel = savedSelection.current;
|
||||
const hasSelection = $isRangeSelection(sel) && !sel.isCollapsed();
|
||||
if (format === 'link' && value === null) {
|
||||
// remove link: select the entire LinkNode first
|
||||
editor.focus(() => {
|
||||
editor.update(() => {
|
||||
const s = $getSelection();
|
||||
const anchorNode = $isRangeSelection(s)
|
||||
? s.anchor.getNode()
|
||||
: savedSelection.current && $isRangeSelection(savedSelection.current)
|
||||
? savedSelection.current.anchor.getNode()
|
||||
: null;
|
||||
const linkNode = anchorNode ? $getNearestNodeOfType(anchorNode, LinkNode) : null;
|
||||
if (linkNode) {
|
||||
const children = linkNode.getChildren();
|
||||
if (children.length > 0) {
|
||||
const first = children[0];
|
||||
const last = children[children.length - 1];
|
||||
if ($isTextNode(first) && $isTextNode(last)) {
|
||||
const range = first.select(0, 0);
|
||||
range.focus.set(last.getKey(), last.getTextContentSize(), 'text');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
||||
});
|
||||
} else if (format === 'list') {
|
||||
editor.focus(() => {
|
||||
if (sel) editor.update(() => $setSelection(sel));
|
||||
editor.dispatchCommand(value ? INSERT_UNORDERED_LIST_COMMAND : REMOVE_LIST_COMMAND, undefined);
|
||||
editor.update(() => $setSelection(null));
|
||||
});
|
||||
} else if (hasSelection) {
|
||||
editor.focus(() => {
|
||||
editor.update(() => $setSelection(sel));
|
||||
if (format === 'bold' || format === 'italic' || format === 'strikethrough') {
|
||||
editor.dispatchCommand(FORMAT_TEXT_COMMAND, format);
|
||||
} else if (format === 'link') {
|
||||
editor.dispatchCommand(TOGGLE_LINK_COMMAND, value as string | null);
|
||||
} else if (format === 'fontSize') {
|
||||
editor.update(() => {
|
||||
$setSelection(sel);
|
||||
$patchStyleText(sel!, { 'font-size': `${value}px` });
|
||||
});
|
||||
}
|
||||
editor.update(() => $setSelection(null));
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener(NOTE_FORMAT_EVENT, handler);
|
||||
return () => window.removeEventListener(NOTE_FORMAT_EVENT, handler);
|
||||
}, [editor, nodeId]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default NoteFormatPlugin;
|
||||
@@ -0,0 +1,74 @@
|
||||
import { type FC, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Flex, Button, Input } from 'antd';
|
||||
import { EditOutlined, DisconnectOutlined } from '@ant-design/icons';
|
||||
|
||||
const POPOVER_STYLE: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
zIndex: 1000,
|
||||
background: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
|
||||
whiteSpace: 'nowrap',
|
||||
};
|
||||
|
||||
interface LinkPopoverProps {
|
||||
url: string;
|
||||
rect: DOMRect;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
export const LinkPopover: FC<LinkPopoverProps> = ({ url, rect, onEdit, onRemove }) => {
|
||||
const { t } = useTranslation();
|
||||
return createPortal(
|
||||
<div
|
||||
style={{ ...POPOVER_STYLE, left: rect.left, top: rect.bottom + 4, padding: '4px 10px', fontSize: 12 }}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<Flex align="center" gap={8}>
|
||||
<a href={url} target="_blank" rel="noreferrer" style={{ color: '#2563eb', maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', display: 'inline-block' }}>
|
||||
{url}
|
||||
</a>
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit}>{t('common.edit')}</Button>
|
||||
<Button size="small" type="text" icon={<DisconnectOutlined />} onClick={onRemove}>{t('workflow.config.notes.removeLink')}</Button>
|
||||
</Flex>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
interface EditLinkPopoverProps {
|
||||
rect: DOMRect;
|
||||
initialUrl: string;
|
||||
onConfirm: (url: string) => void;
|
||||
}
|
||||
|
||||
export const EditLinkPopover: FC<EditLinkPopoverProps> = ({ rect, initialUrl, onConfirm }) => {
|
||||
const { t } = useTranslation();
|
||||
const [url, setUrl] = useState(initialUrl);
|
||||
const confirm = () => onConfirm(url);
|
||||
return createPortal(
|
||||
<div
|
||||
style={{ ...POPOVER_STYLE, left: rect.left, top: rect.bottom + 4, padding: '8px' }}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<Flex gap={8}>
|
||||
<Input
|
||||
size="small"
|
||||
className="rb:w-60!"
|
||||
placeholder={t('workflow.config.notes.enterLink')}
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
onPressEnter={confirm}
|
||||
autoFocus
|
||||
/>
|
||||
<Button size="small" type="primary" onClick={confirm}>{t('common.confirm')}</Button>
|
||||
</Flex>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import { type FC, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||
import { ListPlugin } from '@lexical/react/LexicalListPlugin';
|
||||
import { LinkPlugin } from '@lexical/react/LexicalLinkPlugin';
|
||||
import { ListNode, ListItemNode } from '@lexical/list';
|
||||
import { LinkNode } from '@lexical/link';
|
||||
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import NoteFormatPlugin from './NoteFormatPlugin';
|
||||
import type { FormatState } from './NoteFormatPlugin';
|
||||
import { LinkPopover, EditLinkPopover } from './NoteLinkPopovers';
|
||||
|
||||
const theme = {
|
||||
paragraph: 'editor-paragraph',
|
||||
text: {
|
||||
bold: 'editor-text-bold',
|
||||
italic: 'editor-text-italic',
|
||||
strikethrough: 'note-text-strikethrough',
|
||||
},
|
||||
list: { ul: 'note-list-ul', listitem: 'note-list-item' },
|
||||
link: 'note-link',
|
||||
};
|
||||
|
||||
const NOTE_NODES = [ListNode, ListItemNode, LinkNode];
|
||||
|
||||
const NOTE_STYLES = `
|
||||
.editor-text-bold { font-weight: bold; }
|
||||
.editor-text-italic { font-style: italic; }
|
||||
.note-text-strikethrough { text-decoration: line-through; }
|
||||
.note-list-ul { list-style-type: disc; padding-left: 1.2em; margin: 0; }
|
||||
.note-list-item { margin: 2px 0; }
|
||||
.note-link { color: #2563eb; text-decoration: underline; cursor: pointer; }
|
||||
`;
|
||||
|
||||
const NoteInitPlugin: FC<{ value: string }> = ({ value }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const initialized = useRef(false);
|
||||
useEffect(() => {
|
||||
if (initialized.current || !value) return;
|
||||
initialized.current = true;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed?.root) {
|
||||
const state = editor.parseEditorState(JSON.stringify(parsed));
|
||||
editor.setEditorState(state);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
}, [editor, value]);
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
interface NoteEditorProps {
|
||||
nodeId: string;
|
||||
value: string;
|
||||
fontSize?: number;
|
||||
onChange: (val: string) => void;
|
||||
onFormatChange?: (state: FormatState) => void;
|
||||
}
|
||||
|
||||
const NoteEditor: FC<NoteEditorProps> = ({ nodeId, value, fontSize = 12, onChange, onFormatChange }) => {
|
||||
const { t } = useTranslation();
|
||||
const [linkState, setLinkState] = useState<{ url: string; rect: DOMRect } | null>(null);
|
||||
const [editLinkRect, setEditLinkRect] = useState<{ url: string; rect: DOMRect } | null>(null);
|
||||
const removingLink = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!linkState) return;
|
||||
const handler = () => setLinkState(null);
|
||||
window.addEventListener('mousedown', handler);
|
||||
return () => window.removeEventListener('mousedown', handler);
|
||||
}, [!!linkState]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const { id, url, rect: passedRect } = (e as CustomEvent).detail;
|
||||
if (id !== nodeId) return;
|
||||
if (passedRect) {
|
||||
setEditLinkRect({ url: url || '', rect: passedRect });
|
||||
return;
|
||||
}
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const r = sel.getRangeAt(0).getBoundingClientRect();
|
||||
if (r.width > 0 || r.height > 0) { setEditLinkRect({ url: url || '', rect: r }); return; }
|
||||
}
|
||||
const linkEl = document.querySelector(`[data-note-id="${nodeId}"] a.note-link`) as HTMLElement;
|
||||
const rect = linkEl?.getBoundingClientRect() ?? new DOMRect(window.innerWidth / 2, 200, 0, 0);
|
||||
setEditLinkRect({ url: url || '', rect });
|
||||
};
|
||||
window.addEventListener('note:edit-link', handler);
|
||||
return () => window.removeEventListener('note:edit-link', handler);
|
||||
}, [nodeId]);
|
||||
|
||||
const handleFormatChange = useCallback((state: FormatState) => {
|
||||
onFormatChange?.(state);
|
||||
if (state.linkUrl) {
|
||||
requestAnimationFrame(() => {
|
||||
if (removingLink.current) { removingLink.current = false; return; }
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
if (rect.width > 0 || rect.height > 0) {
|
||||
setLinkState({ url: state.linkUrl!, rect });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// fallback: find the link element in the correct editor
|
||||
const editorEl = document.querySelector(`[data-note-id="${nodeId}"] a.note-link`) as HTMLElement;
|
||||
if (editorEl) {
|
||||
setLinkState({ url: state.linkUrl!, rect: editorEl.getBoundingClientRect() });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setLinkState(null);
|
||||
}
|
||||
}, [onFormatChange]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{NOTE_STYLES}</style>
|
||||
<LexicalComposer initialConfig={{ namespace: `note-${nodeId}`, theme, nodes: NOTE_NODES, onError: console.error }}>
|
||||
<div style={{ position: 'relative' }} data-note-id={nodeId}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
<ContentEditable
|
||||
style={{ minHeight: 60, outline: 'none', resize: 'none', fontSize: '12px', lineHeight: '18px', color: '#374151', overflow: 'auto', cursor: 'auto' }}
|
||||
/>
|
||||
}
|
||||
placeholder={
|
||||
<div style={{ position: 'absolute', top: 0, left: 0, color: '#9CA3AF', lineHeight: '18px', pointerEvents: 'none' }}>
|
||||
{t('workflow.config.notes.placeholder')}
|
||||
</div>
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<HistoryPlugin />
|
||||
<ListPlugin />
|
||||
<LinkPlugin />
|
||||
<OnChangePlugin onChange={(editorState) => onChange(JSON.stringify(editorState.toJSON()))} />
|
||||
<NoteInitPlugin value={value} />
|
||||
<NoteFormatPlugin nodeId={nodeId} fontSize={fontSize} onFormatChange={handleFormatChange} />
|
||||
{editLinkRect && (
|
||||
<EditLinkPopover
|
||||
rect={editLinkRect.rect}
|
||||
initialUrl={editLinkRect.url}
|
||||
onConfirm={(url) => {
|
||||
removingLink.current = true;
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'link', value: url || null } }));
|
||||
setEditLinkRect(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{linkState && (
|
||||
<LinkPopover
|
||||
url={linkState.url}
|
||||
rect={linkState.rect}
|
||||
onEdit={() => {
|
||||
removingLink.current = true;
|
||||
const { rect, url } = linkState;
|
||||
setLinkState(null);
|
||||
setEditLinkRect({ url, rect });
|
||||
}}
|
||||
onRemove={() => {
|
||||
removingLink.current = true;
|
||||
setLinkState(null);
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'link', value: null } }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteEditor;
|
||||
@@ -0,0 +1,163 @@
|
||||
import { type FC } from 'react';
|
||||
import { Flex, Dropdown, type MenuProps, Switch, Button, Divider } from 'antd';
|
||||
import { UnorderedListOutlined, BoldOutlined, ItalicOutlined, StrikethroughOutlined, LinkOutlined, DashOutlined } from '@ant-design/icons';
|
||||
import { Node } from '@antv/x6';
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { THEME_MAP } from '../../../constant';
|
||||
const FONT_SIZES = [
|
||||
{ label: '小', value: 12 },
|
||||
{ label: '中', value: 14 },
|
||||
{ label: '大', value: 16 },
|
||||
];
|
||||
|
||||
interface NoteNodeToolbarProps {
|
||||
node: Node;
|
||||
onFormat: (type: string, value?: unknown) => void;
|
||||
toolConfig: Record<string, number | boolean>;
|
||||
nodeId: string;
|
||||
}
|
||||
|
||||
const NoteNodeToolbar: FC<NoteNodeToolbarProps> = ({ node, onFormat, toolConfig, nodeId }) => {
|
||||
const data = node?.getData() || {};
|
||||
const { t } = useTranslation();
|
||||
|
||||
const colorItems: MenuProps['items'] = Object.entries(THEME_MAP).map(([key, theme]) => ({
|
||||
key,
|
||||
label: (
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:rounded-full rb:cursor-pointer rb:border rb:border-gray-200"
|
||||
style={{ background: theme.bg }}
|
||||
onClick={() => onFormat('color', key)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const fontSizeItems: MenuProps['items'] = FONT_SIZES.map(({ label, value }) => ({
|
||||
key: value,
|
||||
label: <span onClick={() => onFormat('fontSize', value)}>{label}</span>,
|
||||
}));
|
||||
|
||||
const currentFontSize = FONT_SIZES.find(f => f.value === toolConfig.fontSize)?.label ?? '小';
|
||||
|
||||
const handleClick: MenuProps['onClick'] = (e) => {
|
||||
switch (e.key) {
|
||||
case 'delete':
|
||||
node.remove()
|
||||
break;
|
||||
case 'copy':
|
||||
break;
|
||||
}
|
||||
}
|
||||
const handleChange = (type: string) => {
|
||||
let show_author = data.config.show_author.defaultValue
|
||||
if(type === 'showAuth'){
|
||||
show_author = !show_author
|
||||
}
|
||||
node.setData({
|
||||
...data,
|
||||
config: {
|
||||
...data.config,
|
||||
show_author: {
|
||||
...data.config.show_author,
|
||||
defaultValue: show_author
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
gap={8}
|
||||
className="rb:absolute rb:-top-11 rb:left-1/2 rb:-translate-x-1/2 rb:bg-white rb:z-10 rb:whitespace-nowrap rb:rounded-lg rb:py-1! rb:px-3!"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Color picker */}
|
||||
<Dropdown menu={{ items: colorItems }} trigger={['click']}>
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:rounded-full rb:cursor-pointer rb:border rb:border-gray-200"
|
||||
style={{ background: THEME_MAP[data.bgColor]?.bg || THEME_MAP.blue.bg }}
|
||||
/>
|
||||
</Dropdown>
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
{/* Font size */}
|
||||
<Dropdown menu={{ items: fontSizeItems }} trigger={['click']}>
|
||||
<Flex align="center" gap={4} className="rb:cursor-pointer rb:text-xs rb:text-gray-600 rb:select-none">
|
||||
<span className="rb:text-xs">Aa</span>
|
||||
<span className="rb:text-xs">{currentFontSize}</span>
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
{/* Bold */}
|
||||
<Button
|
||||
type={toolConfig.bold ? 'primary' : 'text'}
|
||||
icon={<BoldOutlined />}
|
||||
onClick={() => onFormat('bold')}
|
||||
/>
|
||||
|
||||
{/* Italic */}
|
||||
<Button
|
||||
type={toolConfig.italic ? 'primary' : 'text'}
|
||||
icon={<ItalicOutlined />}
|
||||
onClick={() => onFormat('italic')}
|
||||
/>
|
||||
|
||||
{/* Strikethrough */}
|
||||
<Button
|
||||
type={toolConfig.strikethrough ? 'primary' : 'text'}
|
||||
icon={<StrikethroughOutlined />}
|
||||
onClick={() => onFormat('strikethrough')}
|
||||
/>
|
||||
|
||||
{/* Link */}
|
||||
<Button
|
||||
type={toolConfig.link ? 'primary' : 'text'}
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => {
|
||||
const sel = window.getSelection();
|
||||
const rect = sel && sel.rangeCount > 0 ? sel.getRangeAt(0).getBoundingClientRect() : undefined;
|
||||
window.dispatchEvent(new CustomEvent('note:edit-link', { detail: { id: nodeId, url: '', rect } }));
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* List */}
|
||||
<Button
|
||||
type={toolConfig.list ? 'primary' : 'text'}
|
||||
icon={<UnorderedListOutlined />}
|
||||
onClick={() => onFormat('list')}
|
||||
/>
|
||||
|
||||
<Divider type="vertical" />
|
||||
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
// { key: 'copy', label: t('common.copy') },
|
||||
{
|
||||
key: 'showAuth',
|
||||
label: <Flex align="center" gap={24}>
|
||||
{t('workflow.config.notes.showAuth')}
|
||||
<Switch
|
||||
size="small"
|
||||
checked={data.config.show_author.defaultValue}
|
||||
onChange={() => handleChange('showAuth')}
|
||||
/>
|
||||
</Flex>
|
||||
},
|
||||
{ key: 'delete', label: <Flex>{t('common.delete')}</Flex> },
|
||||
],
|
||||
onClick: handleClick
|
||||
}}
|
||||
>
|
||||
<DashOutlined />
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteNodeToolbar;
|
||||
155
web/src/views/Workflow/components/Nodes/NoteNode/index.tsx
Normal file
155
web/src/views/Workflow/components/Nodes/NoteNode/index.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||
import { Flex } from 'antd';
|
||||
|
||||
import NoteEditor from './NoteEditor';
|
||||
import NoteNodeToolbar from './NoteNodeToolbar';
|
||||
import { THEME_MAP } from '../../../constant'
|
||||
|
||||
const MIN_W = 240;
|
||||
const MIN_H = 120;
|
||||
|
||||
const NoteNode: ReactShapeConfig['component'] = ({ node }) => {
|
||||
const data = node?.getData() || {};
|
||||
const nodeId = node?.id || '';
|
||||
const startRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null);
|
||||
const [toolConfig, setToolConfig] = useState({
|
||||
fontSize: 12,
|
||||
bold: false,
|
||||
italic: false,
|
||||
strikethrough: false,
|
||||
list: false,
|
||||
})
|
||||
|
||||
const handleFormat = (type: string, value?: unknown) => {
|
||||
console.log('handleFormat', type, value)
|
||||
if (type === 'color') {
|
||||
node?.setData({
|
||||
...data,
|
||||
config: {
|
||||
...data.config,
|
||||
theme: {
|
||||
...data.config.theme,
|
||||
defaultValue: value
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (type === 'fontSize') {
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'fontSize', value } }));
|
||||
} else if (type === 'link') {
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'link', value: value || null } }));
|
||||
} else if (type === 'list') {
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: 'list', value: !toolConfig.list } }));
|
||||
} else {
|
||||
window.dispatchEvent(new CustomEvent('note:format', { detail: { id: nodeId, format: type } }));
|
||||
}
|
||||
|
||||
setToolConfig(prev => ({ ...prev, [type]: value || !prev[type as unknown as keyof typeof toolConfig] }))
|
||||
};
|
||||
|
||||
const onResizeMouseDown = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const size = node?.getSize();
|
||||
if (!size) return;
|
||||
startRef.current = { x: e.clientX, y: e.clientY, w: size.width, h: size.height };
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!startRef.current) return;
|
||||
const w = Math.max(MIN_W, startRef.current.w + ev.clientX - startRef.current.x);
|
||||
const h = Math.max(MIN_H, startRef.current.h + ev.clientY - startRef.current.y);
|
||||
|
||||
node?.setData({
|
||||
...data,
|
||||
config: {
|
||||
...data.config,
|
||||
width: {
|
||||
...data.config.width,
|
||||
defaultValue: w
|
||||
},
|
||||
height: {
|
||||
...data.config.height,
|
||||
defaultValue: h
|
||||
}
|
||||
}
|
||||
});
|
||||
node?.prop('size', { width: w, height: h });
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
startRef.current = null;
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const updateText = (value: string) => {
|
||||
node.setData({
|
||||
...data,
|
||||
config: {
|
||||
...data.config,
|
||||
text: {
|
||||
...data.config.text,
|
||||
defaultValue: value
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const theme = THEME_MAP[data.config?.theme?.defaultValue || 'blue'] || THEME_MAP['blue']
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rb:relative rb:h-full rb:w-full rb:rounded-2xl rb:border"
|
||||
style={{
|
||||
background: theme.bg,
|
||||
borderColor: data.isSelected ? theme.outer : theme.border,
|
||||
}}
|
||||
>
|
||||
<div className="rb:h-4 rb:rounded-tl-2xl rb:rounded-tr-2xl"
|
||||
style={{
|
||||
background: theme.title
|
||||
}}
|
||||
></div>
|
||||
{data.isSelected && <NoteNodeToolbar node={node!} nodeId={nodeId} toolConfig={toolConfig} onFormat={handleFormat} />}
|
||||
|
||||
<div
|
||||
className="rb:w-full rb:h-[calc(100%-36px)] rb:p-2.5 rb:overflow-auto"
|
||||
onMouseDown={e => {
|
||||
e.stopPropagation()
|
||||
node?.setData({ ...node.getData(), isSelected: true })
|
||||
}}
|
||||
onWheel={e => e.stopPropagation()}
|
||||
>
|
||||
<NoteEditor
|
||||
nodeId={nodeId}
|
||||
value={data.config.text.defaultValue || ''}
|
||||
fontSize={toolConfig.fontSize}
|
||||
onChange={updateText}
|
||||
onFormatChange={(state) => setToolConfig(prev => ({ ...prev, ...state }))}
|
||||
/>
|
||||
</div>
|
||||
<Flex align="center" justify="space-between" className="rb:pl-2.5! rb:pr-1!">
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167]">
|
||||
{data.config.show_author.defaultValue
|
||||
? data.config.author.defaultValue
|
||||
: undefined
|
||||
}
|
||||
</div>
|
||||
|
||||
|
||||
{/* <div className="rb:size-4 rb:border-b-[4px] rb:border-r-[4px] rb:border-[#EBEBEB] rb:rounded-2xl"></div> */}
|
||||
<div
|
||||
onMouseDown={onResizeMouseDown}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M12 9.75V6H13.5V9.75C13.5 11.8211 11.8211 13.5 9.75 13.5H6V12H9.75C10.9926 12 12 10.9926 12 9.75Z" fill="black" fillOpacity="0.16"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteNode;
|
||||
@@ -35,7 +35,8 @@ const NODE_VARIABLES = {
|
||||
],
|
||||
'http-request': [
|
||||
{ label: 'body', dataType: 'string', field: 'body' },
|
||||
{ label: 'status_code', dataType: 'number', field: 'status_code' }
|
||||
{ label: 'status_code', dataType: 'number', field: 'status_code' },
|
||||
{ label: 'headers', dataType: 'object', field: 'headers' },
|
||||
],
|
||||
'question-classifier': [{ label: 'class_name', dataType: 'string', field: 'class_name' }],
|
||||
'memory-read': [
|
||||
@@ -390,11 +391,6 @@ export const useVariableList = (
|
||||
addVariable(list, keys, `${pid}_item`, 'item', itemType, `${pid}.item`, pd);
|
||||
addVariable(list, keys, `${pid}_index`, 'index', 'number', `${pid}.index`, pd);
|
||||
} else if (pd.type === 'iteration' && !pd.config.input.defaultValue) {
|
||||
let itemType = 'object';
|
||||
const iv = list.find(v => `{{${v.value}}}` === pd.config.input.defaultValue);
|
||||
if (iv?.dataType.startsWith('array[')) {
|
||||
itemType = iv.dataType.replace(/^array\[(.+)\]$/, '$1');
|
||||
}
|
||||
addVariable(list, keys, `${pid}_item`, 'item', 'string', `${pid}.item`, pd);
|
||||
addVariable(list, keys, `${pid}_index`, 'index', 'number', `${pid}.index`, pd);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
initialValue[key] = config[key].defaultValue
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
form.setFieldsValue({
|
||||
type,
|
||||
id: selectedNode.id,
|
||||
@@ -114,16 +114,16 @@ const Properties: FC<PropertiesProps> = ({
|
||||
*/
|
||||
const updateNodeLabel = (newLabel: string) => {
|
||||
if (selectedNode && form) {
|
||||
const nodeData = selectedNode.data as NodeProperties;
|
||||
const nodeData = selectedNode.getData() as NodeProperties;
|
||||
selectedNode.setAttrByPath('text/text', `${nodeData.icon} ${newLabel}`);
|
||||
selectedNode.setData({ ...selectedNode.data, name: newLabel });
|
||||
selectedNode.setData({ ...selectedNode.getData(), name: newLabel });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (values && selectedNode) {
|
||||
const { id, knowledge_retrieval, group, group_variables, ...rest } = values
|
||||
const { knowledge_bases = [], ...restKnowledgeConfig } = (knowledge_retrieval as any) || {}
|
||||
const { knowledge_bases = [], name: _name, description: _description, ...restKnowledgeConfig } = (knowledge_retrieval as any) || {}
|
||||
|
||||
let allRest = {
|
||||
...rest,
|
||||
@@ -136,21 +136,23 @@ const Properties: FC<PropertiesProps> = ({
|
||||
}))
|
||||
}
|
||||
|
||||
const nodeData = selectedNode.getData()
|
||||
|
||||
Object.keys(values).forEach(key => {
|
||||
if (selectedNode.data?.config?.[key]) {
|
||||
if (nodeData?.config?.[key]) {
|
||||
// Create a deep copy to avoid reference sharing between nodes
|
||||
if (!selectedNode.data.config[key]) {
|
||||
selectedNode.data.config[key] = {};
|
||||
if (!nodeData.config[key]) {
|
||||
nodeData.config[key] = {};
|
||||
}
|
||||
selectedNode.data.config[key] = {
|
||||
...selectedNode.data.config[key],
|
||||
nodeData.config[key] = {
|
||||
...nodeData.config[key],
|
||||
defaultValue: values[key]
|
||||
};
|
||||
}
|
||||
})
|
||||
|
||||
selectedNode?.setData({
|
||||
...selectedNode.data,
|
||||
...nodeData,
|
||||
...allRest,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:06:18
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-11 12:07:20
|
||||
* @Last Modified time: 2026-03-09 13:41:19
|
||||
*/
|
||||
import LoopNode from './components/Nodes/LoopNode';
|
||||
import NormalNode from './components/Nodes/NormalNode';
|
||||
import ConditionNode from './components/Nodes/ConditionNode';
|
||||
import GroupStartNode from './components/Nodes/GroupStartNode';
|
||||
import AddNode from './components/Nodes/AddNode'
|
||||
import NoteNode from './components/Nodes/NoteNode';
|
||||
import type { PortMetadata, GroupMetadata } from '@antv/x6/lib/model/port';
|
||||
import type { ReactShapeConfig } from '@antv/x6-react-shape';
|
||||
|
||||
@@ -525,10 +526,81 @@ export const nodeLibrary: NodeLibrary[] = [
|
||||
// ]
|
||||
// },
|
||||
];
|
||||
|
||||
export const THEME_MAP: Record<string, { outer: string; title: string; bg: string; border: string }> = {
|
||||
blue: {
|
||||
outer: '#2E90FA',
|
||||
title: '#D1E9FF',
|
||||
bg: '#EFF8FF',
|
||||
border: '#84CAFF',
|
||||
},
|
||||
cyan: {
|
||||
outer: '#06AED4',
|
||||
title: '#CFF9FE',
|
||||
bg: '#ECFDFF',
|
||||
border: '#67E3F9',
|
||||
},
|
||||
green: {
|
||||
outer: '#16B364',
|
||||
title: '#D3F8DF',
|
||||
bg: '#EDFCF2',
|
||||
border: '#73E2A3',
|
||||
},
|
||||
yellow: {
|
||||
outer: '#EAAA08',
|
||||
title: '#FEF7C3',
|
||||
bg: '#FEFBE8',
|
||||
border: '#FDE272',
|
||||
},
|
||||
pink: {
|
||||
outer: '#EE46BC',
|
||||
title: '#FCE7F6',
|
||||
bg: '#FDF2FA',
|
||||
border: '#FAA7E0',
|
||||
},
|
||||
violet: {
|
||||
outer: '#875BF7',
|
||||
title: '#ECE9FE',
|
||||
bg: '#F5F3FF',
|
||||
border: '#C3B5FD',
|
||||
},
|
||||
}
|
||||
|
||||
export const notesConfig = {
|
||||
type: "notes", icon: templateRenderingIcon,
|
||||
config: {
|
||||
text: {
|
||||
type: 'define',
|
||||
},
|
||||
theme: {
|
||||
type: 'define',
|
||||
defaultValue: 'blue',
|
||||
},
|
||||
width: {
|
||||
type: 'define',
|
||||
width: 240,
|
||||
},
|
||||
height: {
|
||||
type: 'define',
|
||||
height: 120,
|
||||
},
|
||||
author: {
|
||||
type: 'define',
|
||||
},
|
||||
show_author: {
|
||||
type: 'define',
|
||||
defaultValue: true
|
||||
}
|
||||
}
|
||||
}
|
||||
export const unknownNode = {
|
||||
type: 'unknown',
|
||||
icon: unknownIcon
|
||||
}
|
||||
export const noteNode = {
|
||||
type: 'notes',
|
||||
icon: unknownIcon
|
||||
}
|
||||
|
||||
export const nodeWidth = 240;
|
||||
/**
|
||||
@@ -572,6 +644,12 @@ export const nodeRegisterLibrary: ReactShapeConfig[] = [
|
||||
height: 44,
|
||||
component: AddNode,
|
||||
},
|
||||
{
|
||||
shape: 'notes-node',
|
||||
width: nodeWidth,
|
||||
height: 120,
|
||||
component: NoteNode,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -797,6 +875,11 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
|
||||
groups: {left: { position: 'left', markup: portMarkup, attrs: portAttrs }},
|
||||
items: [{ group: 'left' }],
|
||||
},
|
||||
},
|
||||
notes: {
|
||||
width: nodeWidth,
|
||||
height: 120,
|
||||
shape: 'notes-node',
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:17:48
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 17:59:34
|
||||
* @Last Modified time: 2026-03-07 15:23:39
|
||||
*/
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
@@ -12,9 +12,10 @@ import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '
|
||||
import { register } from '@antv/x6-react-shape';
|
||||
import type { PortMetadata } from '@antv/x6/lib/model/port';
|
||||
|
||||
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode } from '../constant';
|
||||
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, noteNode, notesConfig } from '../constant';
|
||||
import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types';
|
||||
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
|
||||
import { useUser } from '@/store/user';
|
||||
|
||||
/**
|
||||
* Props for useWorkflowGraph hook
|
||||
@@ -64,6 +65,8 @@ export interface UseWorkflowGraphReturn {
|
||||
chatVariables: ChatVariable[];
|
||||
/** Function to update chat variables */
|
||||
setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>;
|
||||
|
||||
handleAddNotes: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,6 +83,7 @@ export const useWorkflowGraph = ({
|
||||
const { id } = useParams();
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation()
|
||||
const { user } = useUser();
|
||||
|
||||
// Refs
|
||||
const graphRef = useRef<Graph>();
|
||||
@@ -128,7 +132,7 @@ export const useWorkflowGraph = ({
|
||||
if (nodes.length) {
|
||||
const nodeList = nodes.map(node => {
|
||||
const { id, type, name, position, config = {} } = node
|
||||
let nodeLibraryConfig = [...nodeLibrary, { nodes: [unknownNode] }]
|
||||
let nodeLibraryConfig = [...nodeLibrary, { nodes: [unknownNode, notesConfig] }]
|
||||
.flatMap(category => category.nodes)
|
||||
.find(n => n.type === type)
|
||||
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
|
||||
@@ -197,6 +201,13 @@ export const useWorkflowGraph = ({
|
||||
data: { ...node, ...nodeLibraryConfig},
|
||||
...position,
|
||||
}
|
||||
|
||||
if (type === 'notes') {
|
||||
const w = config.width;
|
||||
const h = config.height;
|
||||
if (w) nodeConfig.width = w as number;
|
||||
if (h) nodeConfig.height = h as number;
|
||||
}
|
||||
|
||||
// Generate ports dynamically for if-else node based on cases
|
||||
if (type === 'if-else' && config.cases && Array.isArray(config.cases)) {
|
||||
@@ -461,11 +472,12 @@ export const useWorkflowGraph = ({
|
||||
*/
|
||||
const nodeClick = ({ node }: { node: Node }) => {
|
||||
// Ignore add-node type node clicks
|
||||
if (node.getData()?.type === 'add-node' || node.getData().type === 'break' || node.getData().type === 'cycle-start') {
|
||||
const nodeData = node.getData()
|
||||
if (nodeData?.type === 'add-node' || nodeData.type === 'break' || nodeData.type === 'cycle-start') {
|
||||
setSelectedNode(null)
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const nodes = graphRef.current?.getNodes();
|
||||
|
||||
nodes?.forEach(vo => {
|
||||
@@ -478,10 +490,12 @@ export const useWorkflowGraph = ({
|
||||
}
|
||||
});
|
||||
node.setData({
|
||||
...node.getData(),
|
||||
...nodeData,
|
||||
isSelected: true,
|
||||
});
|
||||
setSelectedNode(node);
|
||||
if (nodeData.type !== 'notes') {
|
||||
setSelectedNode(node);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Handle edge click event
|
||||
@@ -715,6 +729,8 @@ export const useWorkflowGraph = ({
|
||||
panning: isHandMode,
|
||||
mousewheel: {
|
||||
enabled: true,
|
||||
factor: 0.1,
|
||||
modifiers: null,
|
||||
},
|
||||
connecting: {
|
||||
connector: {
|
||||
@@ -857,8 +873,31 @@ export const useWorkflowGraph = ({
|
||||
init();
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
const handleNoteKeydown = (e: KeyboardEvent) => {
|
||||
if (!graphRef.current) return;
|
||||
const selectedNote = graphRef.current.getNodes().find(n => n.getData()?.isSelected && n.getData()?.type === 'notes');
|
||||
if (!selectedNote) return;
|
||||
const isMeta = e.ctrlKey || e.metaKey;
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
// Only delete node when editor is not focused on text
|
||||
const active = document.activeElement;
|
||||
if (active && (active as HTMLElement).isContentEditable) return;
|
||||
deleteEvent();
|
||||
} else if (isMeta && e.key === 'c') {
|
||||
copyEvent();
|
||||
} else if (isMeta && e.key === 'v') {
|
||||
parseEvent();
|
||||
} else if (isMeta && e.key === 'd') {
|
||||
e.preventDefault();
|
||||
deleteEvent();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleNoteKeydown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('keydown', handleNoteKeydown);
|
||||
graphRef.current?.dispose();
|
||||
};
|
||||
}, []);
|
||||
@@ -882,7 +921,7 @@ export const useWorkflowGraph = ({
|
||||
.flatMap(category => category.nodes)
|
||||
.find(n => n.type === dragData.type);
|
||||
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
|
||||
|
||||
|
||||
// Create clean node data, only keep necessary fields
|
||||
const cleanNodeData = {
|
||||
id: `${dragData.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
@@ -1101,6 +1140,32 @@ export const useWorkflowGraph = ({
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddNotes = () => {
|
||||
if (!graphRef.current) return;
|
||||
const nodeConfig: NodeProperties = JSON.parse(JSON.stringify(notesConfig));
|
||||
nodeConfig.config = {
|
||||
...nodeConfig.config,
|
||||
author: { type: 'define', defaultValue: user?.username || '' },
|
||||
};
|
||||
const cleanNodeData = {
|
||||
id: `notes_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: t('workflow.notes'),
|
||||
...nodeConfig,
|
||||
};
|
||||
const container = graphRef.current.container;
|
||||
const nodeW = graphNodeLibrary.notes?.width || nodeWidth;
|
||||
const nodeH = graphNodeLibrary.notes?.height || 100;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const center = graphRef.current.clientToLocal(rect.left + rect.width / 2, rect.top + rect.height / 2);
|
||||
graphRef.current.addNode({
|
||||
...(graphNodeLibrary.notes || graphNodeLibrary.default),
|
||||
x: center.x - nodeW / 2,
|
||||
y: center.y - nodeH / 2,
|
||||
id: cleanNodeData.id,
|
||||
data: { ...cleanNodeData },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
setConfig,
|
||||
@@ -1118,6 +1183,7 @@ export const useWorkflowGraph = ({
|
||||
parseEvent,
|
||||
handleSave,
|
||||
chatVariables,
|
||||
setChatVariables
|
||||
setChatVariables,
|
||||
handleAddNotes
|
||||
};
|
||||
};
|
||||
|
||||
@@ -38,7 +38,8 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
parseEvent,
|
||||
handleSave,
|
||||
chatVariables,
|
||||
setChatVariables
|
||||
setChatVariables,
|
||||
handleAddNotes
|
||||
} = useWorkflowGraph({ containerRef, miniMapRef });
|
||||
|
||||
const onDragOver = (event: React.DragEvent) => {
|
||||
@@ -95,6 +96,7 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
canRedo={canRedo}
|
||||
onUndo={onUndo}
|
||||
onRedo={onRedo}
|
||||
addNotes={handleAddNotes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user