Merge branch 'develop' into feature/ui_upgrade_zy
@@ -2,11 +2,11 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 13:59:45
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 12:08:42
|
||||
* @Last Modified time: 2026-03-19 20:42:23
|
||||
*/
|
||||
import { request } from '@/utils/request'
|
||||
import type { ApplicationModalData } from '@/views/ApplicationManagement/types'
|
||||
import type { Config } from '@/views/ApplicationConfig/types'
|
||||
import type { Config, AppSharingForm } from '@/views/ApplicationConfig/types'
|
||||
import { handleSSE, type SSEMessage } from '@/utils/stream'
|
||||
import type { QueryParams } from '@/views/Conversation/types'
|
||||
import type { WorkflowConfig } from '@/views/Workflow/types'
|
||||
@@ -113,8 +113,8 @@ export const getShareToken = (share_token: string, user_id: string) => {
|
||||
return request.post(`/public/share/${share_token}/token`, { user_id })
|
||||
}
|
||||
// Copy application
|
||||
export const copyApplication = (app_id: string, new_name: string) => {
|
||||
return request.post(`/apps/${app_id}/copy?new_name=${new_name}`)
|
||||
export const copyApplication = (app_id: string, new_name?: string) => {
|
||||
return request.post(`/apps/${app_id}/copy`, { new_name })
|
||||
}
|
||||
// Data statistics
|
||||
export const getAppStatistics = (app_id: string, data: { start_date: number; end_date: number; }) => {
|
||||
@@ -139,4 +139,34 @@ export const getExperienceConfig = (share_token: string) => {
|
||||
// Get workspace API call statistics
|
||||
export const getWorkspaceApiStatistics = (data: { start_date: number; end_date: number; }) => {
|
||||
return request.get(`/apps/workspace/api-statistics`, data)
|
||||
}
|
||||
}
|
||||
// Export application
|
||||
export const appExport = (app_id: string, appName: string, data?: { release_id: string }) => {
|
||||
return request.getDownloadFile(`/apps/${app_id}/export`, `${appName}.yml`, data)
|
||||
}
|
||||
// Import application
|
||||
export const appImport = (formData: FormData) => {
|
||||
return request.uploadFile(`/apps/import`, formData)
|
||||
}
|
||||
|
||||
// Share application
|
||||
export const appSharing = (app_id: string, data: AppSharingForm) => {
|
||||
return request.post(`/apps/${app_id}/share`, data)
|
||||
}
|
||||
// Get my shared application records
|
||||
export const mySharedOutList = () => {
|
||||
return request.get(`/apps/my-shared-out`)
|
||||
}
|
||||
// Get sharing records for a specific application
|
||||
export const getAppShares = (app_id: string) => {
|
||||
return request.get(`/apps/${app_id}/shares`)
|
||||
}
|
||||
// Cancel a single share (source side operation)
|
||||
export const cancelShare = (app_id: string, target_workspace_id?: string) => {
|
||||
return request.delete(`/apps/${app_id}/share/${target_workspace_id}`)
|
||||
}
|
||||
// Cancel all shares under a workspace (source side operation)
|
||||
export const cancelSpaceShare = (target_workspace_id?: string) => {
|
||||
return request.delete(`/apps/share/${target_workspace_id}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 14:00:06
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 10:58:41
|
||||
* @Last Modified time: 2026-03-13 10:48:41
|
||||
*/
|
||||
import { request } from '@/utils/request'
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type {
|
||||
MemoryFormData,
|
||||
} from '@/views/MemoryManagement/types'
|
||||
@@ -94,8 +95,12 @@ export const updatedEndUserProfile = (values: EndUser) => {
|
||||
return request.post(`/memory-storage/updated_end_user/profile`, values)
|
||||
}
|
||||
// User Memory - Relationship network
|
||||
export const getMemorySearchEdges = (end_user_id: string) => {
|
||||
return request.get(`/memory-storage/analytics/graph_data`, { end_user_id })
|
||||
export const getMemorySearchEdges = (end_user_id: string, config?: AxiosRequestConfig) => {
|
||||
return request.get(`/memory-storage/analytics/graph_data`, { end_user_id }, config)
|
||||
}
|
||||
// User Memory - Community graph
|
||||
export const getMemoryCommunityGraph = (end_user_id: string, config?: AxiosRequestConfig) => {
|
||||
return request.get(`/memory-storage/analytics/community_graph`, { end_user_id }, config)
|
||||
}
|
||||
// User Memory - User interest distribution
|
||||
export const getInterestDistributionByUser = (end_user_id: string) => {
|
||||
@@ -118,8 +123,9 @@ export const getChunkInsight = (end_user_id: string) => {
|
||||
return request.get(`/dashboard/chunk_insight`, { end_user_id })
|
||||
}
|
||||
// RAG User Memory - Storage content
|
||||
export const getRagContent = (end_user_id: string) => {
|
||||
return request.get(`/dashboard/rag_content`, { end_user_id, limit: 20 })
|
||||
export const getRagContentUrl = '/dashboard/rag_content'
|
||||
export const getRagContent = (end_user_id: string, page = 1, pagesize = 20) => {
|
||||
return request.get(getRagContentUrl, { end_user_id, page, pagesize })
|
||||
}
|
||||
// Emotion distribution analysis
|
||||
export const getWordCloud = (end_user_id: string) => {
|
||||
@@ -224,6 +230,10 @@ export const getConversationDetail = (end_user_id: string, conversation_id: stri
|
||||
export const forgetTrigger = (data: { max_merge_batch_size: number; min_days_since_access: number; end_user_id: string;}) => {
|
||||
return request.post(`/memory/forget-memory/trigger`, data)
|
||||
}
|
||||
// RAG type - Refresh RAG user summary and memory insight
|
||||
export const generateRagProfile = (end_user_id: string) => {
|
||||
return request.post(`/dashboard/generate_rag_profile`, { end_user_id })
|
||||
}
|
||||
/*************** end User Memory APIs ******************************/
|
||||
|
||||
/****************** Memory Management APIs *******************************/
|
||||
|
||||
@@ -41,12 +41,12 @@ export const deleteCompositeModel = (model_id: string) => {
|
||||
return request.delete(`/models/composite/${model_id}`)
|
||||
}
|
||||
// Create API keys for all matching models by provider
|
||||
export const updateProviderApiKeys = (data: KeyConfigModalForm) => {
|
||||
return request.post('/models/provider/apikeys', data)
|
||||
export const updateProviderApiKeys = (data: KeyConfigModalForm, signal?: AbortSignal) => {
|
||||
return request.post('/models/provider/apikeys', data, { signal })
|
||||
}
|
||||
// Create model API key
|
||||
export const addModelApiKey = (model_id: string, data: MultiKeyForm) => {
|
||||
return request.post(`/models/${model_id}/apikeys`, data)
|
||||
export const addModelApiKey = (model_id: string, data: MultiKeyForm, signal?: AbortSignal) => {
|
||||
return request.post(`/models/${model_id}/apikeys`, data, { signal })
|
||||
}
|
||||
// Delete model API key
|
||||
export const deleteModelApiKey = (api_key_id: string) => {
|
||||
@@ -65,10 +65,10 @@ export const addModelPlaza = (model_base_id: string) => {
|
||||
return request.post(`/models/model_plaza/${model_base_id}/add`)
|
||||
}
|
||||
// Create custom model
|
||||
export const addCustomModel = (data: CustomModelForm) => {
|
||||
return request.post('/models', data)
|
||||
export const addCustomModel = (data: CustomModelForm, signal?: AbortSignal) => {
|
||||
return request.post('/models', data, { signal })
|
||||
}
|
||||
// Update custom model
|
||||
export const updateCustomModel = (model_base_id: string, data: CustomModelForm) => {
|
||||
return request.put(`/models/${model_base_id}`, data)
|
||||
export const updateCustomModel = (model_base_id: string, data: CustomModelForm, signal?: AbortSignal) => {
|
||||
return request.put(`/models/${model_base_id}`, data, { signal })
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
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) => {
|
||||
return request.get('/tools', data)
|
||||
}
|
||||
// 创建MCP工具
|
||||
export const addTool = (values: MCPToolItem | CustomToolItem) => {
|
||||
return request.post('/tools', values)
|
||||
export const addTool = (values: MCPToolItem | CustomToolItem, config?: { signal?: AbortSignal }) => {
|
||||
return request.post('/tools', values, config)
|
||||
}
|
||||
// 更新工具
|
||||
export const updateTool = (tool_id: string, data: MCPToolItem | InnerToolItem | CustomToolItem) => {
|
||||
return request.put(`/tools/${tool_id}`, data)
|
||||
export const updateTool = (tool_id: string, data: MCPToolItem | InnerToolItem | CustomToolItem, config?: { signal?: AbortSignal }) => {
|
||||
return request.put(`/tools/${tool_id}`, data, config)
|
||||
}
|
||||
// 删除工具
|
||||
export const deleteTool = (tool_id: string) => {
|
||||
@@ -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?: Record<string, any>) => {
|
||||
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)
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 14:00:26
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 14:00:26
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 15:29:03
|
||||
*/
|
||||
import { request } from '@/utils/request'
|
||||
import type { SpaceModalData } from '@/views/SpaceManagement/types'
|
||||
import type { SpaceConfigData } from '@/views/SpaceConfig/types'
|
||||
|
||||
// Workspace list
|
||||
export const getWorkspaces = () => {
|
||||
return request.get('/workspaces')
|
||||
export const getWorkspaces = (data?: { include_current?: boolean }) => {
|
||||
return request.get('/workspaces', data)
|
||||
}
|
||||
// Create workspace
|
||||
export const createWorkspace = (values: SpaceModalData) => {
|
||||
|
||||
11
web/src/assets/images/file/audio.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>音乐</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" opacity="0.99">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-949, -851)" fill="#369F21" fill-rule="nonzero">
|
||||
<g id="音乐" transform="translate(949, 851)">
|
||||
<path d="M20,16.455738 C19.9998747,17.6766961 19.0342058,18.9480203 17.5433445,19.5353482 C15.7283829,20.2503561 13.8223534,19.6808938 13.2861085,18.2634188 C12.9392237,17.34645 13.2450501,16.2524025 14.0883859,15.3933709 C14.9317216,14.5343394 16.184444,14.0408311 17.3746646,14.0987459 C17.7820128,14.118567 18.1608598,14.201947 18.4969068,14.3398172 L18.4963476,7.04615138 C18.4963476,6.79692321 18.2897002,6.63396892 18.0924319,6.63396892 C18.0548497,6.63396892 18.0172674,6.64353993 17.9797072,6.65313341 L10.1453372,9.20299846 C9.96684901,9.25094338 9.85412426,9.41389767 9.85412426,9.59603891 L9.85412426,18.6260387 C9.84879171,19.3368367 9.52006796,20.0801486 8.91077204,20.700786 C8.06744003,21.5598158 6.81472186,22.0533241 5.62450435,21.995411 C4.43428684,21.9374978 3.48739235,21.3369617 3.14050687,20.4200187 C2.79362136,19.5030757 3.09944501,18.4090313 3.94277702,17.5500015 C4.78610903,16.6909716 6.0388272,16.1974633 7.22904471,16.2553765 C7.63671921,16.2752129 8.0158469,16.3587103 8.35209443,16.496778 L8.35111987,7.48711428 C8.35056786,6.63056391 8.90023435,5.87488803 9.70381726,5.62744028 L17.5288082,3.08714624 C18.7593794,2.70369927 19.9993516,3.6335475 19.9993516,4.93724922 Z" id="形状结合"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
16
web/src/assets/images/file/csv.svg
Normal file
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>编组 57</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-443, -851)">
|
||||
<g id="编组-57" transform="translate(443, 851)">
|
||||
<path d="M6,1 L15.1715729,1 C15.7020059,1 16.2107137,1.21071368 16.5857864,1.58578644 L20.4142136,5.41421356 C20.7892863,5.78928632 21,6.29799415 21,6.82842712 L21,20 C21,21.6568542 19.6568542,23 18,23 L6,23 C4.34314575,23 3,21.6568542 3,20 L3,4 C3,2.34314575 4.34314575,1 6,1 Z" id="矩形" fill="#155EEF"></path>
|
||||
<g id="编组-11" transform="translate(4.5, 10)" fill="#FFFFFF" fill-rule="nonzero">
|
||||
<path d="M3.74998106,1.31509018 C3.74735762,1.4334406 3.69649081,1.54592661 3.60857717,1.62778835 C3.52066353,1.70965009 3.40290911,1.75417757 3.28123343,1.75156962 L2.8124858,1.75156962 C2.30551317,1.74138936 1.88592101,2.13264132 1.87499054,2.62574433 L1.87499054,4.37409375 C1.88592099,4.86719677 2.30551316,5.25844875 2.8124858,5.24826849 L3.28123343,5.24826849 C3.40290911,5.24566054 3.52066353,5.29018802 3.60857717,5.37204975 C3.69649081,5.45391149 3.74735762,5.56639751 3.74998106,5.68474793 L3.74998106,6.55892264 C3.74735763,6.67727307 3.69649082,6.78975909 3.60857718,6.87162083 C3.52066354,6.95348258 3.40290911,6.99801006 3.28123343,6.99540211 L2.8124858,6.99540211 C2.08181824,7.01169692 1.37453656,6.74441594 0.846835274,6.25258491 C0.319133983,5.76075387 0.0144251536,5.08483394 0,4.37409375 L0,2.62574433 C0.0144251609,1.91500414 0.319133993,1.23908422 0.846835283,0.747253188 C1.37453657,0.255422158 2.08181824,-0.0118588152 2.8124858,0.00443599745 L3.28123343,0.00443599745 C3.40290911,0.00182804652 3.52066353,0.0463555261 3.60857717,0.128217263 C3.69649081,0.210078999 3.74735762,0.322565017 3.74998106,0.440915438 L3.74998106,1.31509018 Z" id="路径"></path>
|
||||
<path d="M6.34371799,6.99540211 L5.6249716,6.99540211 C5.50329592,6.99801006 5.38554149,6.95348258 5.29762785,6.87162083 C5.20971421,6.78975909 5.1588474,6.67727307 5.15622397,6.55892264 L5.15622397,5.68474793 C5.15884741,5.56639751 5.20971422,5.45391149 5.29762786,5.37204975 C5.3855415,5.29018802 5.50329592,5.24566054 5.6249716,5.24826849 L6.34371799,5.24826849 C6.69246623,5.24826849 6.95371491,5.05738471 6.95371488,4.88352243 C6.94629613,4.7988134 6.90047953,4.72171762 6.82871553,4.67318553 L5.547472,3.65189656 C5.0625209,3.2801153 4.77555777,2.71562733 4.76622595,2.11509985 C4.85967193,0.868433381 5.96731214,-0.0728320292 7.24996338,0.00443599745 L7.96745977,0.00443599745 C8.08913545,0.00182804652 8.20688987,0.0463555261 8.29480351,0.128217263 C8.38271714,0.210078999 8.43358396,0.322565017 8.4362074,0.440915438 L8.4362074,1.31509018 C8.43358396,1.4334406 8.38271714,1.54592661 8.29480351,1.62778835 C8.20688987,1.70965009 8.08913545,1.75417757 7.96745977,1.75156962 L7.24996338,1.75156962 C6.90121515,1.75156962 6.63996646,1.94245339 6.63996649,2.11631568 C6.6477439,2.20058626 6.69352092,2.27717018 6.76496584,2.32543675 L8.04745938,3.35037315 C8.53241048,3.72215442 8.81937361,4.2866424 8.82870543,4.88716989 C8.73462185,6.13368729 7.62617195,7.0740823 6.34371799,6.99540211 L6.34371799,6.99540211 Z" id="路径"></path>
|
||||
<path d="M11.2499432,0.440915438 L11.2499432,1.5764915 C11.2512026,2.67777506 11.5765122,3.75569874 12.1874385,4.68291209 C12.7984289,3.75572719 13.1237441,2.67778486 13.1249337,1.5764915 L13.1249337,0.440915438 C13.1275572,0.322565017 13.178424,0.210078999 13.2663376,0.128217263 C13.3542513,0.0463555261 13.4720057,0.00182804652 13.5936814,0.00443599745 L14.5311766,0.00443599745 C14.6528523,0.00182804652 14.7706067,0.0463555261 14.8585204,0.128217263 C14.946434,0.210078999 14.9973008,0.322565017 14.9999243,0.440915438 L14.9999243,1.5764915 C15.0087494,3.4937602 14.2460649,5.33830752 12.874935,6.71576346 C12.6935617,6.89486746 12.4459015,6.99571622 12.1874385,6.99571622 C11.9289755,6.99571622 11.6813152,6.89486746 11.4999419,6.71576346 C10.128812,5.33830752 9.36612749,3.4937602 9.37495266,1.5764915 L9.37495266,0.440915438 C9.37757609,0.322565012 9.42844291,0.21007899 9.51635656,0.128217253 C9.6042702,0.0463555155 9.72202463,0.00182803917 9.84370031,0.00443599745 L10.7811956,0.00443599745 C10.9028712,0.00182804652 11.0206257,0.0463555261 11.1085393,0.128217263 C11.1964529,0.210078999 11.2473198,0.322565017 11.2499432,0.440915438 L11.2499432,0.440915438 Z" id="路径"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
15
web/src/assets/images/file/excel.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Excel</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-296, -851)">
|
||||
<g id="Excel" transform="translate(296, 851)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M3,0 L12.5983161,0 L12.5983161,0 L18,5.5 L18,19 C18,20.6568542 16.6568542,22 15,22 L3,22 C1.34314575,22 -4.4408921e-16,20.6568542 -4.4408921e-16,19 L0,3 C0,1.34314575 1.34314575,0 3,0 Z" id="矩形" fill="#369F21"></path>
|
||||
<path d="M4.47756884,8.31184687 L7.89908257,12.834 L7.89908257,12.834 L4.23221368,17.6923338 C4.16833301,17.776971 4.18515952,17.8973686 4.26979675,17.9612493 C4.30311465,17.9863962 4.34372024,18 4.38546294,18 L5.94228393,18 C6.09575976,18 6.23998192,17.9266088 6.33032081,17.8025374 L9,14.136 L9,14.136 L11.6696792,17.8025374 C11.7600181,17.9266088 11.9042402,18 12.0577161,18 L13.6127141,18 C13.7187527,18 13.8047141,17.9140387 13.8047141,17.808 C13.8047141,17.7660102 13.790949,17.7251781 13.7655273,17.6917583 L10.0703364,12.834 L10.0703364,12.834 L13.5200138,8.31246056 C13.5843332,8.22815626 13.5681322,8.10767294 13.4838279,8.04335355 C13.4503697,8.01782685 13.4094514,8.004 13.3673674,8.004 L11.8130677,8.004 C11.6595919,8.004 11.5153698,8.07739117 11.4250309,8.20146259 L9,11.532 L9,11.532 L6.57496912,8.20146259 C6.48463024,8.07739117 6.34040808,8.004 6.18693225,8.004 L4.63068156,8.004 C4.52464289,8.004 4.43868156,8.08996133 4.43868156,8.196 C4.43868156,8.2378202 4.45233583,8.27849685 4.47756884,8.31184687 Z" id="路径" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||
<path d="M12.6,0 L18,5.5 L14.328,5.5 C13.373652,5.5 12.6,4.72634805 12.6,3.772 L12.6,0 L12.6,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
15
web/src/assets/images/file/html.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Word</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-555, -851)">
|
||||
<g id="Word" transform="translate(555, 851)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M3,0 L12.5983161,0 L12.5983161,0 L18,5.5 L18,19 C18,20.6568542 16.6568542,22 15,22 L3,22 C1.34314575,22 -4.4408921e-16,20.6568542 -4.4408921e-16,19 L0,3 C0,1.34314575 1.34314575,0 3,0 Z" id="矩形" fill="#155EEF"></path>
|
||||
<path d="M12.6,0 L18,5.5 L14.76,5.5 C13.5670649,5.5 12.6,4.53293506 12.6,3.34 L12.6,0 L12.6,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
|
||||
<path d="M6.30572751,10.1239093 L4.42963674,12 L6.30572751,13.8760908 C6.61579533,14.1971281 6.61136076,14.7074326 6.29576054,15.0230328 C5.98016031,15.338633 5.4698558,15.3430676 5.14881851,15.0329998 L2.69427324,12.5784545 C2.37486988,12.2589547 2.37486988,11.7410454 2.69427324,11.4215455 L5.14881851,8.96700027 C5.4698558,8.65693244 5.98016031,8.661367 6.29576054,8.97696722 C6.61136076,9.29256744 6.61579533,9.80287195 6.30572751,10.1239093 L6.30572751,10.1239093 Z M11.6942725,13.8760908 L13.5703633,12 L11.6942725,10.1239093 C11.3842047,9.80287195 11.3886393,9.29256744 11.7042395,8.97696722 C12.0198397,8.661367 12.5301442,8.65693244 12.8511815,8.96700027 L15.3057268,11.4215455 C15.6251302,11.7410454 15.6251302,12.2589547 15.3057268,12.5784545 L12.8511815,15.0329998 C12.5301442,15.3430676 12.0198397,15.338633 11.7042395,15.0230328 C11.3886393,14.7074326 11.3842047,14.1971281 11.6942725,13.8760908 L11.6942725,13.8760908 Z M9.47536361,8.81400027 C9.65746679,8.40542152 10.1339436,8.21882948 10.5450755,8.39509277 C10.9562074,8.57135605 11.1495693,9.04512595 10.9791817,9.45872749 L8.52463643,15.1859998 C8.34253325,15.5945785 7.86605643,15.7811706 7.45492455,15.6049073 C7.04379267,15.428644 6.85043075,14.9548741 7.02081835,14.5412726 L9.47536361,8.81400027 Z" id="形状结合" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
15
web/src/assets/images/file/image.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>编组 58</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-823, -851)">
|
||||
<g id="编组-58" transform="translate(823, 851)">
|
||||
<g id="编组-14" transform="translate(2, 2)">
|
||||
<rect id="矩形" fill="#9C6FFF" x="0" y="0" width="20" height="20" rx="4"></rect>
|
||||
<circle id="椭圆形" fill="#FFFFFF" cx="6.7" cy="6.15" r="2.75"></circle>
|
||||
<path d="M5.0161215,17.5 L12.9367104,17.5 C18.5091262,17.5 18.6712744,12.5070581 17.2413994,11.2815463 C16.8361052,10.9341783 16.4185445,10.5833222 15.9887171,10.2289779 L15.5545124,9.87379582 C14.8635955,9.31203668 13.8276109,9.39262388 13.2405761,10.0537924 C13.2084868,10.0899341 13.1781242,10.1274478 13.1495818,10.1662183 L11.2529579,12.7408093 C10.4310905,13.8557427 8.85224477,14.1797794 7.62487827,13.4854236 L6.18377361,12.6710243 C5.61174537,12.348034 4.87992638,12.4649012 4.44849452,12.9481383 L3.22751176,14.5162069 C2.11457779,15.8777759 2.497689,17.5 5.0161215,17.5 Z" id="路径-27" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
12
web/src/assets/images/file/json.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>JSON</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-676, -851)" fill-rule="nonzero">
|
||||
<g id="JSON" transform="translate(676, 851)">
|
||||
<path d="M3,5.69333333 C3,4.05066667 3,3.22933333 3.30184615,2.6016 C3.56733763,2.04968249 3.990959,1.60095763 4.512,1.31973333 C5.10461538,1 5.88,1 7.43076923,1 L13.8595385,1 C14.7062308,1 15.1299231,1 15.528,1.1012 C15.8811528,1.19112104 16.2187447,1.33935687 16.5283846,1.54046667 C16.878,1.76706667 17.1770769,2.08386667 17.7759231,2.7182 L19.3779231,4.41513333 C19.9767692,5.04946667 20.2758462,5.36626667 20.4897692,5.7366 C20.6794615,6.0644 20.8193077,6.42226667 20.9044615,6.79626667 C21,7.21793333 21,7.66673333 21,8.5636 L21,18.3066667 C21,19.9493333 21,20.7706667 20.6981538,21.3984 C20.4326624,21.9503175 20.009041,22.3990424 19.488,22.6802667 C18.8953846,23 18.12,23 16.5692308,23 L7.43076923,23 C5.88,23 5.10461538,23 4.512,22.6802667 C3.990959,22.3990424 3.56733763,21.9503175 3.30184615,21.3984 C3,20.7706667 3,19.9493333 3,18.3066667 L3,5.69333333 Z" id="路径" fill="#369F21"></path>
|
||||
<path d="M8.84341209,9 C6.94273128,9 6.56627954,9.55244338 6.56627954,10.5255406 L6.56627954,11.8664226 C6.56627954,12.3951132 6.46455747,12.6065895 6,12.6065895 L6,14.0179636 C6.46455747,14.0179636 6.56627954,14.2294398 6.56627954,14.7581304 L6.56627954,16.0990124 C6.56627954,17.0667461 6.94273128,17.6268517 8.84421306,17.6268517 L8.84421306,16.4553039 C8.34120945,16.4553039 8.2659191,16.3181509 8.2659191,15.9771837 L8.2659191,14.6286395 C8.2659191,13.7750724 7.83259912,13.433339 7.14457349,13.3199387 C7.8494193,13.1743572 8.25790949,12.8655713 8.25790949,11.9951473 L8.25790949,10.6466031 C8.25790949,10.3064022 8.33239888,10.1684829 8.84421306,10.1684829 L8.84421306,9 L8.84341209,9 Z M12.0552663,11.8426698 C11.5274329,11.8426698 11.1045254,12.2487655 11.1045254,12.7437426 C11.1102202,13.2425271 11.5338357,13.6443537 12.0552663,13.6455815 C12.5735717,13.6401939 12.9923657,13.2395657 12.9979976,12.7437426 C12.9979976,12.2495318 12.5718863,11.8419036 12.0552663,11.8419036 L12.0552663,11.8426698 Z M12.0552663,14.7504682 C11.5106127,14.7504682 11.1045254,15.1389409 11.1045254,15.6438788 C11.1045254,15.9610931 11.256708,16.2285033 11.5274329,16.392474 L11.1293552,18 L12.0632759,18 L12.6327593,16.7732845 C12.8786544,16.2369317 12.9979976,15.9610931 12.9979976,15.6523072 C12.9979976,15.1389409 12.5887064,14.7504682 12.0552663,14.7504682 Z M15.1565879,9 L15.1565879,10.1684829 C15.6643973,10.1684829 15.7420905,10.3064022 15.7420905,10.6473693 L15.7420905,11.9959135 C15.7420905,12.8655713 16.1481778,13.1743572 16.8530236,13.3199387 C16.164998,13.4341052 15.7340809,13.7750724 15.7340809,14.6286395 L15.7340809,15.9771837 C15.7340809,16.3181509 15.6563877,16.4553039 15.1565879,16.4553039 L15.1565879,17.6276179 C17.0572687,17.6276179 17.4313176,17.0667461 17.4313176,16.0990124 L17.4313176,14.7581304 C17.4313176,14.2294398 17.5330396,14.0179636 18,14.0179636 L18,12.6065895 C17.5330396,12.6065895 17.4313176,12.394347 17.4313176,11.8656564 L17.4313176,10.5255406 C17.4313176,9.55244338 17.0572687,9 15.1565879,9 L15.1565879,9 Z" id="形状" fill="#FFFFFF" opacity="0.98"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
17
web/src/assets/images/file/md.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>PDF</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-554, -811)">
|
||||
<g id="PDF" transform="translate(554, 811)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M2.88,0 L12.5983161,0 L12.5983161,0 L18,5.5 L18,19.12 C18,20.7105801 16.7105801,22 15.12,22 L2.88,22 C1.28941992,22 1.33226763e-15,20.7105801 1.33226763e-15,19.12 L0,2.88 C0,1.28941992 1.28941992,-8.8817842e-16 2.88,-8.8817842e-16 Z" id="矩形" fill="#9C6FFF"></path>
|
||||
<path d="M12.6,0 L18,5.5 L14.328,5.5 C13.373652,5.5 12.6,4.72634805 12.6,3.772 L12.6,0 L12.6,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
<text id="MD" font-family="Rubik-Medium, Rubik" font-size="10" font-weight="400" fill="#FFFFFF">
|
||||
<tspan x="4.5" y="17">MD</tspan>
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
18
web/src/assets/images/file/pdf.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>PDF</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-443, -811)">
|
||||
<g id="PDF" transform="translate(443, 811)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M2.88,0 L12.5983161,0 L12.5983161,0 L18,5.5 L18,19.12 C18,20.7105801 16.7105801,22 15.12,22 L2.88,22 C1.28941992,22 1.33226763e-15,20.7105801 1.33226763e-15,19.12 L0,2.88 C0,1.28941992 1.28941992,-8.8817842e-16 2.88,-8.8817842e-16 Z" id="矩形" fill="#FF5D34"></path>
|
||||
<path d="M12.6,0 L18,5.5 L14.328,5.5 C13.373652,5.5 12.6,4.72634805 12.6,3.772 L12.6,0 L12.6,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
|
||||
<g id="pdf" transform="translate(2.7, 5.5)" fill="#FFFFFF" fill-rule="nonzero">
|
||||
<rect id="矩形" opacity="0" x="0" y="0" width="12.5969524" height="12.8333333"></rect>
|
||||
<path d="M10.8473784,9.58129971 C9.90260697,9.50981236 8.9928216,9.152451 8.25799118,8.50919049 C6.82335331,8.83082074 5.45867526,9.29540057 4.09400952,9.86719885 C3.00926922,11.8326989 1.99451336,12.8333333 1.11972637,12.8333333 C0.944771429,12.8333333 0.734818122,12.7976022 0.594861552,12.6903838 C0.209941005,12.5117031 0,12.1186106 0,11.725493 C0,11.4038628 0.0699844356,10.5104594 3.39417747,9.0452451 C4.16399396,7.61577452 4.75884321,6.15057284 5.24873426,4.61389636 C4.82882765,3.7562115 3.91904228,1.64773675 4.5488899,0.575652663 C4.75884321,0.182535062 5.17873752,-0.0318767295 5.6336425,0.00385438127 C5.98355238,0.00385438127 6.33346226,0.182547626 6.54341556,0.468434203 C6.99830824,1.11169471 6.96330988,2.46970306 6.36846063,4.47094679 C6.92832381,5.54304344 7.66314193,6.5079342 8.53792892,7.32988795 C9.27275935,7.18693838 10.0075775,7.07971992 10.7423956,7.07971992 C12.3870114,7.11546359 12.6319384,7.90167366 12.5969524,8.36624092 C12.5969524,9.58129971 11.4422276,9.58129971 10.8473784,9.58129971 L10.8473784,9.58129971 Z M1.04974193,11.7969552 L1.15472474,11.7612241 C1.64460348,11.5825309 2.02949943,11.2251695 2.30943717,10.7605897 C1.78457235,10.975014 1.36466574,11.3323754 1.04974193,11.7969678 L1.04974193,11.7969552 Z M5.70361464,1.0759636 L5.59864414,1.0759636 C5.56365807,1.0759636 5.49367363,1.0759636 5.45867526,1.11169471 C5.31871869,1.7192241 5.4236892,2.3624846 5.66862857,2.93427032 C5.87858188,2.32674093 5.87858188,1.68348042 5.70361464,1.0759636 Z M5.94856631,6.25777873 L5.91356795,6.32926608 L5.87858188,6.29352241 C5.56365807,7.11546359 5.21373589,7.93740477 4.82882765,8.72361485 L4.89882438,8.68787117 L4.89882438,8.75935852 C5.66862857,8.47345938 6.5084295,8.22330392 7.27823369,8.04461067 L7.24324762,8.00887956 L7.34821812,8.00887956 C6.82335331,7.47282495 6.33346226,6.86530812 5.94856631,6.25777873 Z M10.7074095,8.15182913 C10.3924857,8.15182913 10.112548,8.15182913 9.79762416,8.22330392 C10.1475463,8.4019846 10.4974685,8.47344682 10.8473784,8.50919049 C11.0923055,8.54493417 11.3372571,8.50919049 11.5472104,8.43771571 C11.5472104,8.33050981 11.4072416,8.15182913 10.7074095,8.15182913 L10.7074095,8.15182913 Z" id="形状"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
12
web/src/assets/images/file/ppt.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>file-ppt-2-fill</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-676, -811)" fill-rule="nonzero">
|
||||
<g id="file-ppt-2-fill" transform="translate(676, 811)">
|
||||
<path d="M16.7502375,3.34859818 L20.5500475,3.34859818 C21.0746918,3.34859818 21.5,3.77897542 21.5,4.30987266 L21.5,19.6902643 C21.5,20.2211615 21.0746918,20.6515387 20.5500475,20.6515387 L16.7502375,20.6515387 L16.7502375,3.34859818 L16.7502375,3.34859818 Z M3.31695914,3.23036142 L15.2578621,1.50487373 C15.3940533,1.4851035 15.5320247,1.52606362 15.6360746,1.61715516 C15.7401245,1.7082467 15.8000282,1.84051837 15.800285,1.97974333 L15.800285,22.0203936 C15.7999894,22.1594252 15.7402137,22.2915157 15.6363863,22.382572 C15.5325589,22.4736282 15.3948533,22.514728 15.2588121,22.4952632 L3.31600918,20.7697755 C2.84783928,20.7023027 2.5,20.2966443 2.5,19.8181138 L2.5,4.18202315 C2.5,3.70349267 2.84783928,3.29783423 3.31600918,3.23036142 L3.31695914,3.23036142 Z" id="形状结合" fill="#FF5D34"></path>
|
||||
<path d="M12.4504275,8.15497055 C12.7265699,8.15497055 12.9504275,8.37882818 12.9504275,8.65497055 L12.9504275,13.4226174 C12.9504275,13.6987598 12.7265699,13.9226174 12.4504275,13.9226174 L7.25071246,13.9226174 L7.25071246,15.5 C7.25071246,15.7761424 7.02685484,16 6.75071246,16 L5.85080744,16 C5.57466507,16 5.35080744,15.7761424 5.35080744,15.5 L5.35080744,8.65497055 C5.35080744,8.37882818 5.57466507,8.15497055 5.85080744,8.15497055 Z M11.0505225,10.0775195 L7.25071246,10.0775195 L7.25071246,12.0000685 L11.0505225,12.0000685 L11.0505225,10.0775195 Z" id="形状结合" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
12
web/src/assets/images/file/txt.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>txt</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-821, -811)">
|
||||
<g id="txt" transform="translate(821, 811)">
|
||||
<rect id="矩形" fill="#4DA8FF" x="2" y="2" width="20" height="20" rx="4"></rect>
|
||||
<path d="M16,8 C16.5522847,8 17,8.44771525 17,9 C17,9.55228475 16.5522847,10 16,10 L13,10 L13,17 C13,17.5522847 12.5522847,18 12,18 C11.4477153,18 11,17.5522847 11,17 L11,10 L8,10 C7.44771525,10 7,9.55228475 7,9 C7,8.44771525 7.44771525,8 8,8 L16,8 Z" id="形状结合" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 920 B |
14
web/src/assets/images/file/video.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>编组 59</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-949, -811)">
|
||||
<g id="编组-59" transform="translate(949, 811)">
|
||||
<g id="编组-15" transform="translate(1, 4.5)">
|
||||
<path d="M3.6,0 L12.9,0 C14.8882251,-4.4408921e-16 16.5,1.6117749 16.5,3.6 L16.5,11.4 C16.5,13.3882251 14.8882251,15 12.9,15 L3.6,15 C1.6117749,15 -4.4408921e-16,13.3882251 -4.4408921e-16,11.4 L0,3.6 C-4.4408921e-16,1.6117749 1.6117749,0 3.6,-4.4408921e-16 Z M17.879231,4.05647049 L20.3282054,2.69447403 C20.8712,2.39248768 21.5561925,2.58786354 21.8581788,3.13085821 C21.9511875,3.29809499 22,3.48629259 22,3.67765286 L22,5.5744503 L22,5.5744503 L22,11.3223471 C22,11.9436675 21.4963203,12.4473471 20.875,12.4473471 C20.6836397,12.4473471 20.4954421,12.3985347 20.3282054,12.305526 L17.879231,10.9435295 C17.5937122,10.7847383 17.4166667,10.4836906 17.4166667,10.1569864 L17.4166667,4.84301355 C17.4166667,4.51630942 17.5937122,4.21526166 17.879231,4.05647049 Z" id="形状结合" fill="#4DA8FF"></path>
|
||||
<path d="M7.56036738,10.1956474 L10.0614601,8.64798876 C10.6954768,8.25566369 10.8914066,7.42364931 10.4990815,6.78963262 C10.389064,6.61183886 10.2392539,6.46202877 10.0614601,6.35201124 L7.56036738,4.80435262 C6.92635069,4.41202755 6.09433631,4.60795731 5.70201124,5.241974 C5.56995501,5.45538338 5.5,5.7013784 5.5,5.95234138 L5.5,9.04765862 C5.5,9.79324304 6.10441559,10.3976586 6.85,10.3976586 C7.10096297,10.3976586 7.34695799,10.3277036 7.56036738,10.1956474 Z" id="路径-30" fill="#FFFFFF"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
15
web/src/assets/images/file/word.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="24px" height="24px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Word</title>
|
||||
<g id="空间里层页面优化" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="工作台-知识库-创建数据集-本地文件-2" transform="translate(-296, -811)">
|
||||
<g id="Word" transform="translate(296, 811)">
|
||||
<g id="编组-9" transform="translate(3, 1)">
|
||||
<path d="M3,0 L12.5983161,0 L12.5983161,0 L18,5.5 L18,19 C18,20.6568542 16.6568542,22 15,22 L3,22 C1.34314575,22 -4.4408921e-16,20.6568542 -4.4408921e-16,19 L0,3 C0,1.34314575 1.34314575,0 3,0 Z" id="矩形" fill="#155EEF"></path>
|
||||
<path d="M12.6,0 L18,5.5 L14.76,5.5 C13.5670649,5.5 12.6,4.53293506 12.6,3.34 L12.6,0 L12.6,0 Z" id="矩形" fill-opacity="0.5" fill="#FFFFFF"></path>
|
||||
<path d="M9.00012556,10.5092 L10.4465805,15.7628533 C10.4850891,15.902729 10.6152746,16 10.7640042,16 L11.6343858,16 C11.7829678,16 11.9130713,15.9029416 11.9517273,15.7632533 L13.9886889,8.40325334 C13.996199,8.37610652 14,8.34811442 14,8.32 C14,8.14328 13.8528458,8 13.6713474,8 L12.6970019,8 C12.5438715,8 12.4110293,8.10296221 12.3767026,8.24826667 L11.1226189,13.5577333 L9.75980609,8.24250667 C9.72327861,8.10001319 9.59182177,8 9.44098566,8 L8.55932024,8 C8.40846371,8 8.27697746,8.0999939 8.24044504,8.24250667 L6.88037096,13.5472267 L5.6179067,8.24770667 C5.5833622,8.10267332 5.45065244,8 5.29774428,8 L4.32873942,8 C4.29993141,8 4.27124802,8.00368186 4.24342668,8.01096 C4.06814528,8.05682667 3.96423628,8.23237334 4.01134316,8.40304 L6.04277237,15.76304 C6.08134559,15.9028129 6.21146947,16 6.36011386,16 L7.23624697,16 C7.38497656,16 7.51516205,15.902729 7.55367062,15.7628533 L9.00012556,10.5092 Z" id="路径" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -1,26 +1,48 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:11:51
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-17 18:39:09
|
||||
*/
|
||||
import { type FC, useRef, useState } from 'react'
|
||||
import RecordRTC from 'recordrtc'
|
||||
import { App } from 'antd'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
/** Props for the AudioRecorder component */
|
||||
interface AudioRecorderProps {
|
||||
/** Callback fired when recording is complete, receives uploaded file info and raw blob */
|
||||
onRecordingComplete?: (file: { file_id: string; file_key: string; url: string; type?: string; }, blob?: Blob) => void
|
||||
className?: string;
|
||||
/** Upload endpoint URL, defaults to fileUploadUrlWithoutApiPrefix */
|
||||
action?: string;
|
||||
/** Additional config passed to the upload request */
|
||||
requestConfig?: Record<string, any>;
|
||||
disabled?: boolean;
|
||||
maxSize?: number;
|
||||
}
|
||||
|
||||
const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
onRecordingComplete,
|
||||
className = '',
|
||||
action = fileUploadUrlWithoutApiPrefix,
|
||||
requestConfig = {}
|
||||
requestConfig = {},
|
||||
disabled = false,
|
||||
maxSize,
|
||||
}) => {
|
||||
const { message } = App.useApp()
|
||||
const { t } = useTranslation();
|
||||
// Whether the recorder is currently capturing audio
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
// Holds the RecordRTC instance across renders
|
||||
const recorderRef = useRef<RecordRTC | null>(null)
|
||||
|
||||
/** Request microphone access and start recording */
|
||||
const startRecording = async () => {
|
||||
if (disabled) return
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
recorderRef.current = new RecordRTC(stream, {
|
||||
@@ -34,11 +56,19 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
/** Stop recording, upload the audio blob, then invoke the completion callback */
|
||||
const stopRecording = () => {
|
||||
if (disabled) return
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.stopRecording(() => {
|
||||
const blob = recorderRef.current!.getBlob()
|
||||
const url = recorderRef.current!.toURL()
|
||||
|
||||
if (maxSize && blob.size > maxSize * 1024 * 1024) {
|
||||
message.error(t('common.fileSizeTip', { size: maxSize }));
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, `recording_${Date.now()}.webm`)
|
||||
request
|
||||
@@ -49,6 +79,7 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
type: blob.type,
|
||||
url
|
||||
}, blob)
|
||||
// Release recorder resources after upload
|
||||
recorderRef.current?.destroy()
|
||||
recorderRef.current = null
|
||||
})
|
||||
@@ -57,12 +88,14 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle between recording/idle states on click;
|
||||
// swap background image to reflect current state
|
||||
return (
|
||||
<div
|
||||
className={`rb:size-5.5 rb:cursor-pointer rb:bg-cover ${className} ${
|
||||
className={`rb:size-5.5 rb:bg-cover ${disabled ? 'rb:opacity-65 rb:cursor-not-allowed' : 'rb:cursor-pointer'} ${className} ${
|
||||
isRecording
|
||||
? `rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]`
|
||||
: `rb:bg-[url('@/assets/images/conversation/audio.svg')] rb:hover:bg-[url('@/assets/images/conversation/audio_hover.svg')]`
|
||||
: `rb:bg-[url('@/assets/images/conversation/audio.svg')]`
|
||||
}`}
|
||||
onClick={isRecording ? stopRecording : startRecording}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:01:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-11 10:53:27
|
||||
* @Last Modified time: 2026-03-19 20:45:13
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -32,6 +32,7 @@ interface ButtonCheckboxProps extends Omit<RadioGroupProps, 'onChange'> {
|
||||
checkedIcon?: string;
|
||||
/** Button content */
|
||||
children?: ReactNode
|
||||
cicle?: boolean;
|
||||
}
|
||||
|
||||
const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
@@ -41,6 +42,8 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
icon,
|
||||
checkedIcon,
|
||||
children,
|
||||
cicle = false,
|
||||
disabled,
|
||||
}) => {
|
||||
// Listen to value changes and trigger side effects via onValueChange callback
|
||||
useEffect(() => {
|
||||
@@ -57,18 +60,21 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
<Flex
|
||||
align="center"
|
||||
justify={cicle ? 'center' : 'start'}
|
||||
gap={4}
|
||||
className={clsx("rb:border rb:rounded-lg rb:px-2! rb:text-[12px] rb:h-6 rb:cursor-pointer", {
|
||||
// Checked state: blue background and border
|
||||
"rb:bg-[#FAFAFA] rb:border-[#171719]": checked,
|
||||
// Unchecked state: gray border and dark text
|
||||
"rb:border-[#EBEBEB] rb:text-[#212332] rb:hover:bg-[#F0F3F8]": !checked,
|
||||
"rb:opacity-65 rb:cursor-not-allowed!": disabled
|
||||
})}
|
||||
onClick={handleChange}
|
||||
>
|
||||
{/* Display unchecked icon when not checked */}
|
||||
{icon && !checked && <img src={icon} className="rb:w-4 rb:h-4 rb:mr-1" />}
|
||||
{icon && !checked && <img src={icon} className="rb:size-4" />}
|
||||
{/* Display checked icon when checked */}
|
||||
{checkedIcon && checked && <img src={checkedIcon} className="rb:w-4 rb:h-4 rb:mr-1" />}
|
||||
{children}
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:17
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-25 19:04:55
|
||||
* @Last Modified time: 2026-03-19 20:45:39
|
||||
*/
|
||||
import { type FC, useRef, useEffect } from 'react'
|
||||
import { type FC, useRef, useEffect, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import Markdown from '@/components/Markdown'
|
||||
import type { ChatContentProps } from './types'
|
||||
import { Spin } from 'antd'
|
||||
import { Spin, Divider, Space, Image, Flex } from 'antd'
|
||||
import { SoundOutlined } from '@ant-design/icons'
|
||||
|
||||
|
||||
const getFileUrl = (file: any) => {
|
||||
return file.thumbUrl || file.url || (file.originFileObj ? URL.createObjectURL(file.originFileObj) : undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat Content Display Component
|
||||
@@ -28,15 +34,33 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
// Scroll container reference for controlling auto-scroll to bottom
|
||||
const scrollContainerRef = useRef<(HTMLDivElement | null)>(null)
|
||||
const prevDataLengthRef = useRef(data.length);
|
||||
const isScrolledToBottomRef = useRef(true); // Track if user is scrolled to bottom
|
||||
const isScrolledToBottomRef = useRef(true);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null)
|
||||
|
||||
const handlePlay = (index: number, audio_url: string) => {
|
||||
if (playingIndex === index) {
|
||||
audioRef.current?.pause()
|
||||
setPlayingIndex(null)
|
||||
return
|
||||
}
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
}
|
||||
const audio = new Audio(audio_url)
|
||||
audioRef.current = audio
|
||||
audio.play()
|
||||
setPlayingIndex(index)
|
||||
audio.onended = () => setPlayingIndex(null)
|
||||
}
|
||||
|
||||
// Track scroll position to determine if user is at bottom
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||||
// Consider user is at bottom if within 20px of the bottom
|
||||
isScrolledToBottomRef.current = scrollHeight - scrollTop - clientHeight < 20;
|
||||
// Consider user is at bottom if within 100px of the bottom
|
||||
isScrolledToBottomRef.current = scrollHeight - scrollTop - clientHeight < 100;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -64,11 +88,16 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
// Auto-scroll if data length changed OR user is currently at bottom
|
||||
if (data.length !== prevDataLengthRef.current || isScrolledToBottomRef.current) {
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
isScrolledToBottomRef.current = true;
|
||||
}
|
||||
prevDataLengthRef.current = data.length;
|
||||
}
|
||||
}, 0);
|
||||
}, [data])
|
||||
|
||||
const handleDownload = (file: any) => {
|
||||
window.open(getFileUrl(file), '_blank')
|
||||
}
|
||||
return (
|
||||
<div ref={scrollContainerRef} className={clsx("rb:relative rb:overflow-y-auto", classNames)}>
|
||||
{data.length === 0
|
||||
@@ -89,6 +118,44 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
{labelFormat(item)}
|
||||
</div>
|
||||
}
|
||||
{item.meta_data?.files && item.meta_data?.files.length > 0 && <Flex gap={8} vertical align="end">
|
||||
{item.meta_data?.files?.map((file) => {
|
||||
if (file.type.includes('image')) {
|
||||
return (
|
||||
<div key={file.url || file.uid} className={`rb:inline-block rb:group rb:relative rb:rounded-lg ${contentClassNames}`}>
|
||||
<Image src={getFileUrl(file)} alt={file.name} className="rb:w-full rb:max-w-80 rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (file.type.includes('video')) {
|
||||
return (
|
||||
<div key={file.url || file.uid} className="rb:inline-block rb:group rb:relative rb:rounded-lg">
|
||||
<video src={getFileUrl(file)} controls className="rb:max-w-80 rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (file.type.includes('audio')) {
|
||||
return (
|
||||
<div key={file.url || file.uid} className="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={getFileUrl(file)} controls className="rb:max-w-80" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={file.url || file.uid} className="rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:p-1! rb:cursor-pointer" onClick={() => handleDownload(file)}>
|
||||
{(file.type.includes('doc') || file.type.includes('docx') || file.type.includes('word') || file.type.includes('wordprocessingml.document')) && <div
|
||||
className="rb:size-10 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/word.svg')]"
|
||||
></div>}
|
||||
{(file.type.includes('pdf')) && <div
|
||||
className="rb:size-10 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf.svg')]"
|
||||
></div>}
|
||||
{(file.type.includes('excel') || file.type.includes('spreadsheetml.sheet') || file.type.includes('csv')) && <div
|
||||
className="rb:size-10 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/excel.svg')]"
|
||||
></div>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Flex>}
|
||||
{/* Message bubble */}
|
||||
<div className={clsx('rb:text-left rb:rounded-lg rb:leading-5 rb:p-[10px_12px_2px_12px] rb:inline-block rb:max-w-130 rb:wrap-break-word rb:relative', contentClassNames, {
|
||||
// Error message style (content is null and not assistant message)
|
||||
@@ -104,6 +171,19 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
{item.subContent && renderRuntime && renderRuntime(item, index)}
|
||||
{/* Render message content using Markdown component */}
|
||||
<Markdown content={renderRuntime ? item.content ?? '' : item.content ?? errorDesc ?? ''} />
|
||||
|
||||
{item.meta_data?.audio_url && <>
|
||||
<Divider className="rb:my-3!" />
|
||||
<Space size={12} className="rb:pb-2 rb:pl-1">
|
||||
{playingIndex !== index
|
||||
? <SoundOutlined className="rb:cursor-pointer rb:hover:text-[#155EEF]! rb:size-5.5" onClick={() => handlePlay(index, item.meta_data?.audio_url!)} />
|
||||
: <div
|
||||
className="rb:size-5.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/audio_ing.gif')]"
|
||||
onClick={() => handlePlay(index, item.meta_data?.audio_url!)}
|
||||
/>
|
||||
}
|
||||
</Space>
|
||||
</>}
|
||||
</div>
|
||||
{/* Bottom label (such as timestamp, username, etc.) */}
|
||||
{labelPosition === 'bottom' &&
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 17:35:14
|
||||
* @Last Modified time: 2026-03-19 20:46:45
|
||||
*/
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import { Flex, Input, Form } from 'antd'
|
||||
@@ -122,15 +122,20 @@ const ChatInput: FC<ChatInputProps> = ({
|
||||
gap={10}
|
||||
className="rb:w-45 rb:text-[12px] 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>}
|
||||
{(file.type.includes('pdf')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/pdf.svg')]"
|
||||
></div>}
|
||||
{(file.type.includes('excel') || file.type.includes('spreadsheetml.sheet') || file.type.includes('csv')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/excel_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/excel.svg')]"
|
||||
></div>}
|
||||
{file.type.includes('pdf')
|
||||
? <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/pdf_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/pdf.svg')]"
|
||||
></div>
|
||||
: (file.type.includes('excel') || file.type.includes('spreadsheetml.sheet') || file.type.includes('csv'))
|
||||
? <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/excel_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/excel.svg')]"
|
||||
></div>
|
||||
: (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>
|
||||
: null
|
||||
}
|
||||
<div className="rb:flex-1 rb:w-32.5">
|
||||
<div className="rb:leading-4 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.name}</div>
|
||||
<div className="rb:leading-3.5 rb:mt-0.5 rb:text-[#5B6167] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{file.type} · {file.size}</div>
|
||||
|
||||
204
web/src/components/Chat/ChatToolbar.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-17 14:22:25
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 15:55:13
|
||||
*/
|
||||
// Toolbar component for chat input area, supporting file upload, audio recording, and variable configuration
|
||||
import { useRef, forwardRef, useImperativeHandle, type ReactNode, useEffect } from 'react'
|
||||
import { Flex, Dropdown, Divider, App, Form, type MenuProps } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import clsx from 'clsx'
|
||||
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
import type { VariableConfigModalRef } from '@/views/Workflow/types'
|
||||
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
|
||||
|
||||
// Exposed methods via ref for parent components to access/set form state
|
||||
export interface ChatToolbarRef {
|
||||
getFiles: () => any[]
|
||||
getVariables: () => Variable[]
|
||||
setFiles: (files: any[]) => void
|
||||
setVariables: (variables: Variable[]) => void
|
||||
}
|
||||
|
||||
// Props for configuring toolbar features, upload settings, and event callbacks
|
||||
export interface ChatToolbarProps {
|
||||
features: FeaturesConfigForm
|
||||
extra?: ReactNode
|
||||
uploadAction?: string
|
||||
uploadRequestConfig?: {
|
||||
data?: Record<string, string | number | boolean>
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
onFilesChange?: (files: any[]) => void
|
||||
onVariablesChange?: (variables: Variable[]) => void
|
||||
onRecordingComplete?: (file: any) => void;
|
||||
defaultValue?: { memory: boolean }
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
files: any[]
|
||||
variables: Variable[];
|
||||
memory?: boolean;
|
||||
}
|
||||
|
||||
const ChatToolbar = forwardRef<ChatToolbarRef, ChatToolbarProps>(({
|
||||
features,
|
||||
extra,
|
||||
uploadAction,
|
||||
uploadRequestConfig,
|
||||
onFilesChange,
|
||||
onVariablesChange,
|
||||
onRecordingComplete,
|
||||
defaultValue,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
const [form] = Form.useForm<FormValues>()
|
||||
const queryValues = Form.useWatch([], form)
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultValue) return
|
||||
form.setFieldsValue(defaultValue)
|
||||
}, [defaultValue])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getFiles: () => form.getFieldValue('files') || [],
|
||||
getVariables: () => form.getFieldValue('variables') || [],
|
||||
setFiles: (files) => form.setFieldValue('files', files),
|
||||
setVariables: (variables) => {
|
||||
console.log('variables', variables)
|
||||
form.setFieldValue('variables', variables)
|
||||
},
|
||||
}))
|
||||
|
||||
const { file_upload } = features || {}
|
||||
|
||||
// Append newly uploaded file to the file list when upload is complete
|
||||
const fileChange = (file?: any) => {
|
||||
if (file?.status !== 'done') return
|
||||
const files = [...(queryValues?.files || []), file]
|
||||
form.setFieldValue('files', files)
|
||||
onFilesChange?.(files)
|
||||
}
|
||||
|
||||
// Append recorded audio file to the file list and notify parent
|
||||
const handleRecordingComplete = (file: any) => {
|
||||
const files = [...(queryValues?.files || []), file]
|
||||
form.setFieldValue('files', files)
|
||||
onFilesChange?.(files)
|
||||
onRecordingComplete?.(file)
|
||||
}
|
||||
|
||||
// Merge a batch of files (e.g. from remote URL modal) into the file list
|
||||
const addFileList = (list?: any[]) => {
|
||||
if (!list?.length) return
|
||||
const files = [...(queryValues?.files || []), ...list]
|
||||
form.setFieldValue('files', files)
|
||||
onFilesChange?.(files)
|
||||
}
|
||||
|
||||
// Persist variable values from the config modal and notify parent
|
||||
const handleVariablesSave = (values: Variable[]) => {
|
||||
form.setFieldValue('variables', values)
|
||||
onVariablesChange?.(values)
|
||||
}
|
||||
|
||||
// True when any required variable is missing a value, used to highlight the config button
|
||||
const isNeedVariableConfig = queryValues?.variables?.some(
|
||||
vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === '')
|
||||
)
|
||||
|
||||
// Build dropdown menu items based on allowed transfer methods
|
||||
const fileMenus: MenuProps['items'] = []
|
||||
const enabledTypes = ['image', 'document', 'video', 'audio'].filter(
|
||||
type => file_upload?.[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]
|
||||
)
|
||||
if (file_upload?.allowed_transfer_methods?.includes('remote_url') && enabledTypes.length > 0) {
|
||||
fileMenus.push({
|
||||
key: 'url',
|
||||
label: t('memoryConversation.addRemoteFile'),
|
||||
onClick: () => {
|
||||
if ((queryValues?.files?.length || 0) >= file_upload.max_file_count) {
|
||||
messageApi.warning(t('common.fileNumTip', { num: file_upload.max_file_count }))
|
||||
return
|
||||
}
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
}
|
||||
})
|
||||
}
|
||||
if (file_upload?.allowed_transfer_methods?.includes('local_file') && enabledTypes.length > 0) {
|
||||
fileMenus.push({
|
||||
key: 'upload',
|
||||
label: (
|
||||
<UploadFiles
|
||||
action={uploadAction}
|
||||
onChange={fileChange}
|
||||
requestConfig={uploadRequestConfig}
|
||||
featureConfig={file_upload}
|
||||
disabled={(queryValues?.files?.length || 0) >= file_upload.max_file_count}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Form form={form} initialValues={{ files: [], variables: [] }}>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Form.Item name="files" noStyle hidden={!file_upload?.enabled || fileMenus.length === 0}>
|
||||
<Dropdown menu={{ items: fileMenus }}>
|
||||
<div className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]" />
|
||||
</Dropdown>
|
||||
</Form.Item>
|
||||
{extra}
|
||||
<Form.Item name="variables" className="rb:mb-0!" hidden={queryValues?.variables?.length < 1}>
|
||||
<div
|
||||
className={clsx('rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]', {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
|
||||
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
|
||||
})}
|
||||
onClick={() => variableConfigModalRef.current?.handleOpen(queryValues.variables)}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t('memoryConversation.variableConfig')}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
{file_upload?.audio_enabled && file_upload?.allowed_transfer_methods?.includes('local_file') && (
|
||||
<Flex align="center">
|
||||
<AudioRecorder
|
||||
disabled={(queryValues?.files?.length || 0) >= file_upload.max_file_count}
|
||||
action={uploadAction}
|
||||
requestConfig={uploadRequestConfig}
|
||||
onRecordingComplete={handleRecordingComplete}
|
||||
maxSize={file_upload?.audio_max_size_mb}
|
||||
/>
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
featureConfig={file_upload}
|
||||
/>
|
||||
<VariableConfigModal
|
||||
ref={variableConfigModalRef}
|
||||
refresh={handleVariablesSave}
|
||||
/>
|
||||
</Form>
|
||||
)
|
||||
})
|
||||
|
||||
export default ChatToolbar
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:09
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 14:57:56
|
||||
* @Last Modified time: 2026-03-19 20:47:27
|
||||
*/
|
||||
import { type FC } from 'react'
|
||||
import ChatInput from './ChatInput'
|
||||
@@ -26,7 +26,8 @@ const Chat: FC<ChatProps> = ({
|
||||
errorDesc,
|
||||
fileList,
|
||||
fileChange,
|
||||
className
|
||||
className,
|
||||
renderRuntime
|
||||
}) => {
|
||||
return (
|
||||
<div className={`rb:h-full rb:relative rb:pt-2 ${className}`}>
|
||||
@@ -38,6 +39,7 @@ const Chat: FC<ChatProps> = ({
|
||||
empty={empty}
|
||||
labelFormat={labelFormat}
|
||||
errorDesc={errorDesc}
|
||||
renderRuntime={renderRuntime}
|
||||
/>
|
||||
|
||||
{/* Chat input area */}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:45:54
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:05:09
|
||||
* @Last Modified time: 2026-03-19 20:47:12
|
||||
*/
|
||||
import { type ReactNode } from 'react'
|
||||
|
||||
@@ -22,8 +22,11 @@ export interface ChatItem {
|
||||
created_at?: number | string;
|
||||
status?: string;
|
||||
subContent?: Record<string, any>[];
|
||||
files?: any[];
|
||||
error?: string;
|
||||
meta_data?: {
|
||||
audio_url?: string;
|
||||
files?: any[];
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -54,6 +57,7 @@ export interface ChatProps {
|
||||
/** Attachment update */
|
||||
fileChange?: (fileList: any[]) => void;
|
||||
className?: string;
|
||||
renderRuntime?: (item: ChatItem, index: number) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
67
web/src/components/D3Graph/CommunityGraph.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { useState, useRef, useMemo, useEffect, type FC } from 'react'
|
||||
import Empty from '@/components/Empty'
|
||||
import { GRAPH_COLORS, initCommunityGraph } from './utils'
|
||||
import { useD3Graph } from './hooks'
|
||||
import type { CommunityD3Node, D3Link, CommunityGraphProps } from './types'
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
// Renders a D3-powered community graph with optional tooltip and legend.
|
||||
|
||||
const CommunityGraph: FC<CommunityGraphProps> = ({
|
||||
data,
|
||||
empty: emptyProp,
|
||||
colors = GRAPH_COLORS,
|
||||
renderTooltip,
|
||||
showLegend = true,
|
||||
onCommunityClick,
|
||||
onNodeClick,
|
||||
defaultZoom = 1,
|
||||
}) => {
|
||||
// Tooltip position and hovered node state
|
||||
const [tooltip, setTooltip] = useState<{ x: number; y: number; node: CommunityD3Node } | null>(null)
|
||||
|
||||
// Keep callback refs stable to avoid re-initializing the graph on every render
|
||||
const onCommunityClickRef = useRef(onCommunityClick)
|
||||
const onNodeClickRef = useRef(onNodeClick)
|
||||
const renderTooltipRef = useRef(renderTooltip)
|
||||
useEffect(() => { onCommunityClickRef.current = onCommunityClick }, [onCommunityClick])
|
||||
useEffect(() => { onNodeClickRef.current = onNodeClick }, [onNodeClick])
|
||||
useEffect(() => { renderTooltipRef.current = renderTooltip }, [renderTooltip])
|
||||
|
||||
const graphState = useMemo(() => data, [data])
|
||||
// Show empty state when explicitly flagged or when there are no nodes
|
||||
const isEmpty = emptyProp ?? !data?.nodes.length
|
||||
|
||||
// Initialize (or re-initialize) the D3 graph whenever relevant state changes
|
||||
const containerRef = useD3Graph((container) => {
|
||||
if (!graphState) return
|
||||
return initCommunityGraph(
|
||||
container,
|
||||
graphState.nodes,
|
||||
graphState.links as D3Link[],
|
||||
graphState.communityMap,
|
||||
graphState.communityCaption,
|
||||
graphState.communityNodeMap,
|
||||
{ colors, showLegend, defaultZoom, setTooltip: renderTooltip ? setTooltip : () => {}, onCommunityClickRef, onNodeClickRef }
|
||||
)
|
||||
}, [graphState, showLegend, defaultZoom])
|
||||
|
||||
// Resolve tooltip content: use custom renderer if provided, otherwise fall back to DefaultTooltip
|
||||
const tooltipNode = tooltip && renderTooltipRef.current
|
||||
? renderTooltipRef.current(tooltip.node)
|
||||
: null
|
||||
|
||||
if (isEmpty) return <Empty className="rb:h-full" />
|
||||
return (
|
||||
<div className="rb:w-full rb:h-full rb:relative">
|
||||
<div ref={containerRef} className="rb:w-full rb:h-full" />
|
||||
{tooltipNode ? (
|
||||
<div style={{ position: 'absolute', left: tooltip!.x + 14, top: tooltip!.y - 10, pointerEvents: 'none', zIndex: 20 }}>
|
||||
{tooltipNode}
|
||||
</div>
|
||||
) : undefined}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CommunityGraph)
|
||||
24
web/src/components/D3Graph/hooks.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
|
||||
/**
|
||||
* Generic hook that mounts a D3 graph inside a div container.
|
||||
* Clears any existing SVG before calling initFn, and runs cleanup on unmount or dep change.
|
||||
*/
|
||||
export function useD3Graph<T>(
|
||||
initFn: (container: HTMLDivElement) => (() => void) | void,
|
||||
deps: T[]
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
d3.select(container).selectAll('svg').remove()
|
||||
const cleanup = initFn(container)
|
||||
return () => {
|
||||
cleanup?.()
|
||||
d3.select(container).selectAll('svg').remove()
|
||||
}
|
||||
}, deps)
|
||||
return containerRef
|
||||
}
|
||||
102
web/src/components/D3Graph/types.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { ReactNode, RefObject } from 'react'
|
||||
import type * as d3 from 'd3'
|
||||
|
||||
// ─── Raw input types (mirror of API response, no external dependency) ─────────
|
||||
// These interfaces map 1-to-1 with the graph API response shape.
|
||||
|
||||
export interface RawCommunityNode {
|
||||
id: string
|
||||
label: 'Community'
|
||||
properties: {
|
||||
name: string
|
||||
summary: string
|
||||
member_entity_ids: string[]
|
||||
member_count: number
|
||||
core_entities: string[]
|
||||
community_id: string
|
||||
end_user_id?: string
|
||||
updated_at?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RawEntityNode {
|
||||
id: string
|
||||
label: 'ExtractedEntity'
|
||||
properties: {
|
||||
name: string
|
||||
description: string
|
||||
entity_type: string
|
||||
community_name?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export interface RawEdge {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
export interface RawCommunityGraphData {
|
||||
nodes: (RawCommunityNode | RawEntityNode)[]
|
||||
edges: RawEdge[]
|
||||
}
|
||||
|
||||
// ─── D3 graph types ───────────────────────────────────────────────────────────
|
||||
// Runtime node shape used by D3 simulations; extends SimulationNodeDatum for x/y/vx/vy.
|
||||
|
||||
export interface CommunityD3Node extends d3.SimulationNodeDatum {
|
||||
id: string
|
||||
name: string
|
||||
community: string
|
||||
label: string
|
||||
symbolSize: number
|
||||
color: string
|
||||
properties?: RawEntityNode['properties']
|
||||
}
|
||||
|
||||
export interface D3Link extends d3.SimulationLinkDatum<CommunityD3Node> {
|
||||
isCross: boolean
|
||||
}
|
||||
|
||||
// Convex-hull shape rendered behind each community cluster.
|
||||
export interface HullDatum {
|
||||
id: string
|
||||
path: string
|
||||
color: string
|
||||
labelX: number
|
||||
labelY: number
|
||||
dashed: boolean
|
||||
caption: string
|
||||
}
|
||||
|
||||
// Fully transformed graph data ready to be passed into initCommunityGraph.
|
||||
export interface CommunityGraphData {
|
||||
nodes: CommunityD3Node[]
|
||||
links: Array<{ source: string; target: string; isCross: boolean }>
|
||||
communityMap: Map<string, string[]>
|
||||
communityCaption: Map<string, string>
|
||||
communityNodeMap: Map<string, RawCommunityNode>
|
||||
}
|
||||
|
||||
// Props accepted by the CommunityGraph React component.
|
||||
export interface CommunityGraphProps {
|
||||
data: CommunityGraphData | null
|
||||
empty?: boolean
|
||||
colors?: string[]
|
||||
renderTooltip?: (node: CommunityD3Node) => ReactNode
|
||||
showLegend?: boolean
|
||||
onCommunityClick?: (node: RawCommunityNode) => void
|
||||
onNodeClick?: (node: CommunityD3Node) => void
|
||||
defaultZoom?: number
|
||||
}
|
||||
|
||||
// Options forwarded from the React component into the D3 initializer.
|
||||
export interface InitOptions {
|
||||
colors: string[]
|
||||
showLegend: boolean
|
||||
defaultZoom: number
|
||||
setTooltip: (s: { x: number; y: number; node: CommunityD3Node } | null) => void
|
||||
onCommunityClickRef: RefObject<((node: RawCommunityNode) => void) | undefined>
|
||||
onNodeClickRef: RefObject<((node: CommunityD3Node) => void) | undefined>
|
||||
}
|
||||
547
web/src/components/D3Graph/utils.ts
Normal file
@@ -0,0 +1,547 @@
|
||||
import * as d3 from 'd3'
|
||||
import type { CommunityD3Node, D3Link, HullDatum, CommunityGraphData, RawCommunityGraphData, RawCommunityNode, RawEntityNode, InitOptions } from './types'
|
||||
|
||||
// ─── Colors ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const GRAPH_COLORS = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048']
|
||||
export const colorAt = (i: number) => GRAPH_COLORS[i % GRAPH_COLORS.length]
|
||||
|
||||
export function connectionToRadius(connections: number): number {
|
||||
if (connections <= 1) return 5
|
||||
if (connections <= 10) return 8
|
||||
if (connections <= 15) return 11
|
||||
if (connections <= 20) return 16
|
||||
return 22
|
||||
}
|
||||
|
||||
// ─── Arrow markers ────────────────────────────────────────────────────────────
|
||||
|
||||
export function addArrowMarkers(
|
||||
defs: d3.Selection<SVGDefsElement, unknown, null, undefined>,
|
||||
markers: { id: string; color: string }[]
|
||||
) {
|
||||
markers.forEach(({ id, color }) => {
|
||||
defs.append('marker')
|
||||
.attr('id', id)
|
||||
.attr('viewBox', '0 -4 8 8')
|
||||
.attr('refX', 8).attr('refY', 0)
|
||||
.attr('markerWidth', 6).attr('markerHeight', 6)
|
||||
.attr('orient', 'auto')
|
||||
.append('path').attr('d', 'M0,-4L8,0L0,4').attr('fill', color)
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Zoom ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function addZoom(
|
||||
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
|
||||
g: d3.Selection<SVGGElement, unknown, null, undefined>
|
||||
) {
|
||||
svg.call(
|
||||
d3.zoom<SVGSVGElement, unknown>().scaleExtent([0.2, 4])
|
||||
.on('zoom', e => g.attr('transform', e.transform))
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Node drag ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function makeNodeDrag<N extends d3.SimulationNodeDatum>(
|
||||
simulation: d3.Simulation<N, d3.SimulationLinkDatum<N>>
|
||||
) {
|
||||
return d3.drag<SVGGElement, N>()
|
||||
.on('start', (e, d) => { if (!e.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y })
|
||||
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y })
|
||||
.on('end', (e, d) => { if (!e.active) simulation.alphaTarget(0); d.fx = e.x; d.fy = e.y })
|
||||
}
|
||||
|
||||
// ─── Cluster force ────────────────────────────────────────────────────────────
|
||||
// Works for both string and number group keys.
|
||||
|
||||
export function makeClusterForce<N extends d3.SimulationNodeDatum & { x?: number; y?: number; vx?: number; vy?: number }>(
|
||||
nodes: N[],
|
||||
getGroup: (d: N) => string | number,
|
||||
centers: Record<string | number, { x: number; y: number }>,
|
||||
width: number,
|
||||
height: number,
|
||||
opts: { pullStrength?: number; minSepRatio?: number; pushStrength?: number } = {}
|
||||
) {
|
||||
const { pullStrength = 0.45, minSepRatio = 0.68, pushStrength = 1.0 } = opts
|
||||
return (alpha: number) => {
|
||||
// pre-group nodes by key to avoid repeated filter() in hot path
|
||||
const groups = new Map<string, N[]>()
|
||||
nodes.forEach(d => {
|
||||
const k = String(getGroup(d))
|
||||
if (!groups.has(k)) groups.set(k, [])
|
||||
groups.get(k)!.push(d)
|
||||
})
|
||||
// pull toward group center
|
||||
nodes.forEach(d => {
|
||||
const c = centers[getGroup(d)]
|
||||
if (!c) return
|
||||
d.vx = (d.vx ?? 0) + (c.x - (d.x ?? 0)) * pullStrength * alpha
|
||||
d.vy = (d.vy ?? 0) + (c.y - (d.y ?? 0)) * pullStrength * alpha
|
||||
})
|
||||
// live centroids
|
||||
const centroids: Record<string, { x: number; y: number; n: number }> = {}
|
||||
nodes.forEach(d => {
|
||||
const g = String(getGroup(d))
|
||||
if (!centroids[g]) centroids[g] = { x: 0, y: 0, n: 0 }
|
||||
centroids[g].x += d.x ?? 0
|
||||
centroids[g].y += d.y ?? 0
|
||||
centroids[g].n++
|
||||
})
|
||||
Object.values(centroids).forEach(c => { c.x /= c.n; c.y /= c.n })
|
||||
// push groups apart
|
||||
const keys = Object.keys(centroids)
|
||||
const minSep = Math.min(width, height) * minSepRatio
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
for (let j = i + 1; j < keys.length; j++) {
|
||||
const ci = centroids[keys[i]], cj = centroids[keys[j]]
|
||||
const dx = cj.x - ci.x, dy = cj.y - ci.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
if (dist >= minSep) continue
|
||||
const push = ((minSep - dist) / dist) * pushStrength * alpha
|
||||
const fx = dx * push, fy = dy * push
|
||||
groups.get(keys[i])?.forEach(d => { d.vx = (d.vx ?? 0) - fx; d.vy = (d.vy ?? 0) - fy })
|
||||
groups.get(keys[j])?.forEach(d => { d.vx = (d.vx ?? 0) + fx; d.vy = (d.vy ?? 0) + fy })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Group centers ────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildGroupCenters(
|
||||
keys: (string | number)[],
|
||||
width: number,
|
||||
height: number,
|
||||
radiusRatio = 0.4
|
||||
): Record<string | number, { x: number; y: number }> {
|
||||
const centers: Record<string | number, { x: number; y: number }> = {}
|
||||
const r = Math.min(width, height) * radiusRatio
|
||||
keys.forEach((key, i) => {
|
||||
const angle = (i / keys.length) * 2 * Math.PI - Math.PI / 2
|
||||
centers[key] = { x: width / 2 + r * Math.cos(angle), y: height / 2 + r * Math.sin(angle) }
|
||||
})
|
||||
return centers
|
||||
}
|
||||
|
||||
// ─── Community graph data transform ─────────────────────────────────────────
|
||||
|
||||
export function buildCommunityGraphData(raw: RawCommunityGraphData, colors: string[] = GRAPH_COLORS): CommunityGraphData | null {
|
||||
const getColor = (i: number) => colors[i % colors.length]
|
||||
|
||||
const communityNodes = raw.nodes.filter(n => n.label === 'Community') as RawCommunityNode[]
|
||||
const communityCaption = new Map<string, string>()
|
||||
const communityMap = new Map<string, string[]>()
|
||||
|
||||
communityNodes.forEach(n => {
|
||||
communityCaption.set(n.id, n.properties.name)
|
||||
communityMap.set(n.id, n.properties.member_entity_ids)
|
||||
})
|
||||
|
||||
const entityToCommunity = new Map<string, string>()
|
||||
communityMap.forEach((members, commId) => members.forEach(eid => entityToCommunity.set(eid, commId)))
|
||||
|
||||
const commKeys = Array.from(communityMap.keys())
|
||||
const commIndex = new Map(commKeys.map((k, i) => [k, i]))
|
||||
|
||||
const entityNodes = raw.nodes.filter(n => n.label === 'ExtractedEntity') as RawEntityNode[]
|
||||
const entityNodeSet = new Set(entityNodes.map(n => n.id))
|
||||
|
||||
const connectionCount: Record<string, number> = {}
|
||||
raw.edges.forEach(e => {
|
||||
if (entityNodeSet.has(e.source)) connectionCount[e.source] = (connectionCount[e.source] || 0) + 1
|
||||
if (entityNodeSet.has(e.target)) connectionCount[e.target] = (connectionCount[e.target] || 0) + 1
|
||||
})
|
||||
|
||||
const nodes: CommunityD3Node[] = entityNodes.map(n => {
|
||||
const commId = entityToCommunity.get(n.id) ?? commKeys[0]
|
||||
return {
|
||||
id: n.id,
|
||||
name: n.properties.name,
|
||||
community: commId,
|
||||
label: n.label,
|
||||
symbolSize: connectionToRadius(connectionCount[n.id] || 0),
|
||||
color: getColor(commIndex.get(commId) ?? 0),
|
||||
properties: n.properties,
|
||||
}
|
||||
})
|
||||
|
||||
if (!nodes.length) return null
|
||||
|
||||
const links = raw.edges
|
||||
.filter(e => entityNodeSet.has(e.source) && entityNodeSet.has(e.target))
|
||||
.map(e => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
isCross: entityToCommunity.get(e.source) !== entityToCommunity.get(e.target),
|
||||
}))
|
||||
|
||||
const communityNodeMap = new Map<string, RawCommunityNode>(
|
||||
communityNodes.map(n => [n.id, n])
|
||||
)
|
||||
return { nodes, links, communityMap, communityCaption, communityNodeMap }
|
||||
}
|
||||
|
||||
// ─── Hull helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const smoothLine = d3.line<[number, number]>()
|
||||
.x(d => d[0]).y(d => d[1])
|
||||
.curve(d3.curveCatmullRomClosed.alpha(0.5))
|
||||
|
||||
function expandPoints(pts: [number, number][], pad: number): [number, number][] {
|
||||
const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length
|
||||
const cy = pts.reduce((s, p) => s + p[1], 0) / pts.length
|
||||
return pts.map(([x, y]) => {
|
||||
const dx = x - cx, dy = y - cy
|
||||
const len = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
return [x + (dx / len) * pad, y + (dy / len) * pad]
|
||||
})
|
||||
}
|
||||
|
||||
function toHullPoints(pts: [number, number][]): [number, number][] {
|
||||
if (pts.length === 1) {
|
||||
const [x, y] = pts[0]
|
||||
return [[x - 1, y - 1], [x + 1, y - 1], [x, y + 1]]
|
||||
}
|
||||
if (pts.length === 2) {
|
||||
const [[x1, y1], [x2, y2]] = pts
|
||||
return [[x1, y1], [x2, y2], [(x1 + x2) / 2, (y1 + y2) / 2 - 1]]
|
||||
}
|
||||
return d3.polygonHull(pts) ?? pts
|
||||
}
|
||||
|
||||
const CIRCLE_THRESHOLD = 4 // 节点数 < 此值时使用圆形
|
||||
const CIRCLE_SEGMENTS = 32
|
||||
|
||||
function circlePoints(cx: number, cy: number, r: number): [number, number][] {
|
||||
return Array.from({ length: CIRCLE_SEGMENTS }, (_, i) => {
|
||||
const a = (i / CIRCLE_SEGMENTS) * 2 * Math.PI
|
||||
return [cx + r * Math.cos(a), cy + r * Math.sin(a)] as [number, number]
|
||||
})
|
||||
}
|
||||
|
||||
export function buildHullData(
|
||||
nodes: CommunityD3Node[],
|
||||
communityMap: Map<string, string[]>,
|
||||
communityCaption: Map<string, string>,
|
||||
colors: string[]
|
||||
): HullDatum[] {
|
||||
const getColor = (i: number) => colors[i % colors.length]
|
||||
const byComm = new Map<string, [number, number][]>()
|
||||
communityMap.forEach((_, id) => byComm.set(id, []))
|
||||
nodes.forEach(d => {
|
||||
if (d.x != null && d.y != null) byComm.get(d.community)?.push([d.x, d.y])
|
||||
})
|
||||
|
||||
const hulls: HullDatum[] = []
|
||||
let ci = 0
|
||||
byComm.forEach((pts, id) => {
|
||||
const color = getColor(ci++)
|
||||
if (!pts.length) return
|
||||
let pathPoints: [number, number][]
|
||||
if (pts.length < CIRCLE_THRESHOLD) {
|
||||
const cx = pts.reduce((s, p) => s + p[0], 0) / pts.length
|
||||
const cy = pts.reduce((s, p) => s + p[1], 0) / pts.length
|
||||
pathPoints = circlePoints(cx, cy, 60)
|
||||
} else {
|
||||
pathPoints = expandPoints(toHullPoints(pts), 60) as [number, number][]
|
||||
}
|
||||
const path = smoothLine(pathPoints)
|
||||
if (!path) return
|
||||
hulls.push({
|
||||
id, path, color,
|
||||
labelX: pathPoints.reduce((s, p) => s + p[0], 0) / pathPoints.length,
|
||||
labelY: Math.min(...pathPoints.map(p => p[1])) - 10,
|
||||
dashed: pts.length <= 2,
|
||||
caption: communityCaption.get(id) ?? id,
|
||||
})
|
||||
})
|
||||
return hulls
|
||||
}
|
||||
|
||||
// ─── Hull render ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function renderHulls(
|
||||
hullG: d3.Selection<SVGGElement, unknown, null, undefined>,
|
||||
hulls: HullDatum[],
|
||||
hiddenCommunities: Set<string>,
|
||||
nodes: CommunityD3Node[],
|
||||
simulation: d3.Simulation<CommunityD3Node, D3Link>,
|
||||
onCommunityClick?: (node: RawCommunityNode) => void,
|
||||
communityNodeMap?: Map<string, RawCommunityNode>
|
||||
) {
|
||||
let dragNodes: CommunityD3Node[] = []
|
||||
let dragStart = { x: 0, y: 0 }
|
||||
const communityDrag = d3.drag<SVGPathElement, HullDatum>()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart()
|
||||
dragNodes = nodes.filter(n => n.community === d.id)
|
||||
dragStart = { x: event.x, y: event.y }
|
||||
dragNodes.forEach(n => { n.fx = n.x; n.fy = n.y })
|
||||
})
|
||||
.on('drag', (event) => {
|
||||
const dx = event.x - dragStart.x, dy = event.y - dragStart.y
|
||||
dragStart = { x: event.x, y: event.y }
|
||||
dragNodes.forEach(n => { n.fx = (n.fx ?? n.x ?? 0) + dx; n.fy = (n.fy ?? n.y ?? 0) + dy })
|
||||
})
|
||||
.on('end', (event) => { if (!event.active) simulation.alphaTarget(0) })
|
||||
|
||||
const pathSel = hullG.selectAll<SVGPathElement, HullDatum>('path.hull').data(hulls, d => d.id)
|
||||
pathSel.enter().append('path').attr('class', 'hull').style('cursor', 'grab')
|
||||
.merge(pathSel)
|
||||
.call(communityDrag)
|
||||
.attr('d', d => d.path)
|
||||
.attr('fill', d => d.color).attr('fill-opacity', 0.08)
|
||||
.attr('stroke', d => d.color).attr('stroke-opacity', 0.5).attr('stroke-width', 1.5)
|
||||
.attr('stroke-dasharray', 'none')
|
||||
.style('display', d => hiddenCommunities.has(d.id) ? 'none' : null)
|
||||
.on('click', (event, d) => {
|
||||
if ((event as MouseEvent).defaultPrevented) return
|
||||
const node = communityNodeMap?.get(d.id)
|
||||
if (node) onCommunityClick?.(node)
|
||||
})
|
||||
pathSel.exit().remove()
|
||||
|
||||
const labelSel = hullG.selectAll<SVGTextElement, HullDatum>('text.hull-label').data(hulls, d => d.id)
|
||||
labelSel.enter().append('text').attr('class', 'hull-label')
|
||||
.attr('text-anchor', 'middle').attr('font-size', '12px').attr('font-weight', '500')
|
||||
.style('pointer-events', 'none')
|
||||
.merge(labelSel)
|
||||
.attr('x', d => d.labelX).attr('y', d => d.labelY)
|
||||
.attr('fill', d => d.color)
|
||||
.style('display', d => hiddenCommunities.has(d.id) ? 'none' : null)
|
||||
.text(d => d.caption)
|
||||
labelSel.exit().remove()
|
||||
}
|
||||
|
||||
// ─── Community graph init ─────────────────────────────────────────────────────
|
||||
|
||||
export function initCommunityGraph(
|
||||
container: HTMLDivElement,
|
||||
nodes: CommunityD3Node[],
|
||||
links: D3Link[],
|
||||
communityMap: Map<string, string[]>,
|
||||
communityCaption: Map<string, string>,
|
||||
communityNodeMap: Map<string, RawCommunityNode>,
|
||||
opts: InitOptions
|
||||
) {
|
||||
const { colors, showLegend, defaultZoom, setTooltip, onCommunityClickRef, onNodeClickRef } = opts
|
||||
const getColor = (i: number) => colors[i % colors.length]
|
||||
|
||||
const width = container.clientWidth || 600
|
||||
const height = container.clientHeight || 518
|
||||
|
||||
const svg = d3.select(container).append('svg')
|
||||
.attr('width', width).attr('height', height)
|
||||
.style('width', '100%').style('height', '100%')
|
||||
.style('background', '#F6F8FC')
|
||||
|
||||
const g = svg.append('g')
|
||||
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.2, 4])
|
||||
.on('zoom', e => g.attr('transform', e.transform))
|
||||
svg.call(zoom)
|
||||
if (defaultZoom !== 1) {
|
||||
svg.call(zoom.transform, d3.zoomIdentity
|
||||
.translate(width / 2 * (1 - defaultZoom), height / 2 * (1 - defaultZoom))
|
||||
.scale(defaultZoom)
|
||||
)
|
||||
}
|
||||
|
||||
const defs = svg.append('defs')
|
||||
addArrowMarkers(defs, [{ id: 'arrow', color: 'rgba(91, 97, 103, 0.7)' }])
|
||||
|
||||
const commKeys = Array.from(communityMap.keys())
|
||||
const centers = buildGroupCenters(commKeys, width, height, 0.45)
|
||||
const linkedIds = new Set(links.flatMap(l => [l.source as string, l.target as string]))
|
||||
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink<CommunityD3Node, D3Link>(links).id(d => d.id).distance(60))
|
||||
.force('charge', d3.forceManyBody().strength(-120))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2).strength(0.02))
|
||||
.force('collision', d3.forceCollide<CommunityD3Node>(d => d.symbolSize + 16))
|
||||
.force('cluster', makeClusterForce(nodes, d => d.community, centers, width, height, {
|
||||
pullStrength: 0.45, minSepRatio: 0.68, pushStrength: 1.0,
|
||||
}))
|
||||
.force('isolatedPull', (alpha: number) => {
|
||||
nodes.forEach(d => {
|
||||
if (linkedIds.has(d.id)) return
|
||||
const c = centers[d.community]
|
||||
if (!c) return
|
||||
d.vx = (d.vx ?? 0) + (c.x - (d.x ?? 0)) * 0.4 * alpha
|
||||
d.vy = (d.vy ?? 0) + (c.y - (d.y ?? 0)) * 0.4 * alpha
|
||||
})
|
||||
})
|
||||
|
||||
const hullG = g.append('g').attr('class', 'hulls')
|
||||
const hiddenCommunities = new Set<string>()
|
||||
|
||||
const linkSel = g.append('g').selectAll<SVGLineElement, D3Link>('line')
|
||||
.data(links).enter().append('line')
|
||||
.attr('stroke', '#5B6167')
|
||||
.attr('stroke-opacity', d => d.isCross ? 0.3 : 0.5)
|
||||
.attr('stroke-width', d => d.isCross ? 1 : 1.2)
|
||||
.attr('marker-end', 'url(#arrow)')
|
||||
|
||||
const nodeSel = g.append('g').selectAll<SVGGElement, CommunityD3Node>('g')
|
||||
.data(nodes).enter().append('g')
|
||||
.call(makeNodeDrag(simulation))
|
||||
|
||||
nodeSel.append('circle')
|
||||
.attr('r', d => d.symbolSize)
|
||||
.attr('fill', d => d.color).attr('fill-opacity', 0.85)
|
||||
.attr('stroke', '#fff').attr('stroke-width', 1.5)
|
||||
.style('cursor', 'pointer')
|
||||
.on('mouseenter', (event: MouseEvent, d: CommunityD3Node) => {
|
||||
const { left, top } = container.getBoundingClientRect()
|
||||
setTooltip({ x: event.clientX - left, y: event.clientY - top, node: d })
|
||||
})
|
||||
.on('mousemove', (event: MouseEvent) => {
|
||||
const { left, top } = container.getBoundingClientRect()
|
||||
const nd = d3.select<SVGCircleElement, CommunityD3Node>(event.target as SVGCircleElement).datum()
|
||||
setTooltip({ x: event.clientX - left, y: event.clientY - top, node: nd })
|
||||
})
|
||||
.on('mouseleave', () => setTooltip(null))
|
||||
.on('click', (_event: MouseEvent, d: CommunityD3Node) => onNodeClickRef.current?.(d))
|
||||
|
||||
nodeSel.append('text')
|
||||
.text(d => d.name)
|
||||
.attr('x', 0).attr('dy', d => -(d.symbolSize + 5))
|
||||
.attr('text-anchor', 'middle').attr('font-size', '11px').attr('fill', '#444')
|
||||
.style('pointer-events', 'none')
|
||||
|
||||
if (showLegend) {
|
||||
renderLegend(
|
||||
svg,
|
||||
commKeys.map((cid, i) => ({ key: cid, label: communityCaption.get(cid) ?? cid, color: getColor(i) })),
|
||||
width, height,
|
||||
(key, hidden) => {
|
||||
const cid = key as string
|
||||
if (hidden) hiddenCommunities.add(cid)
|
||||
else hiddenCommunities.delete(cid)
|
||||
nodeSel.style('display', d => hiddenCommunities.has(d.community) ? 'none' : null)
|
||||
linkSel.style('display', d => {
|
||||
const s = d.source as CommunityD3Node, t = d.target as CommunityD3Node
|
||||
return hiddenCommunities.has(s.community) || hiddenCommunities.has(t.community) ? 'none' : null
|
||||
})
|
||||
hullG.selectAll<SVGPathElement, HullDatum>('path.hull').style('display', d => hiddenCommunities.has(d.id) ? 'none' : null)
|
||||
hullG.selectAll<SVGTextElement, HullDatum>('text.hull-label').style('display', d => hiddenCommunities.has(d.id) ? 'none' : null)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
simulation.on('tick', () => {
|
||||
linkSel
|
||||
.attr('x1', d => (d.source as CommunityD3Node).x ?? 0)
|
||||
.attr('y1', d => (d.source as CommunityD3Node).y ?? 0)
|
||||
.attr('x2', d => {
|
||||
const s = d.source as CommunityD3Node, t = d.target as CommunityD3Node
|
||||
const dx = (t.x ?? 0) - (s.x ?? 0), dy = (t.y ?? 0) - (s.y ?? 0)
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
return (t.x ?? 0) - (dx / dist) * (t.symbolSize + 2)
|
||||
})
|
||||
.attr('y2', d => {
|
||||
const s = d.source as CommunityD3Node, t = d.target as CommunityD3Node
|
||||
const dx = (t.x ?? 0) - (s.x ?? 0), dy = (t.y ?? 0) - (s.y ?? 0)
|
||||
const dist = Math.sqrt(dx * dx + dy * dy) || 1
|
||||
return (t.y ?? 0) - (dy / dist) * (t.symbolSize + 2)
|
||||
})
|
||||
nodeSel.attr('transform', d => `translate(${d.x ?? 0},${d.y ?? 0})`)
|
||||
renderHulls(hullG, buildHullData(nodes, communityMap, communityCaption, colors), hiddenCommunities, nodes, simulation, (n) => onCommunityClickRef.current?.(n), communityNodeMap)
|
||||
})
|
||||
|
||||
return () => { simulation.stop(); d3.select(container).selectAll('svg').remove() }
|
||||
}
|
||||
|
||||
// ─── Legend ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LegendItem {
|
||||
key: string | number
|
||||
label: string
|
||||
color: string
|
||||
}
|
||||
|
||||
const LEGEND_GAP = 12
|
||||
const LEGEND_RECT_W = 20
|
||||
const LEGEND_RECT_H = 10
|
||||
const LEGEND_TEXT_OFFSET = 24
|
||||
const LEGEND_FONT_SIZE = 11
|
||||
const LEGEND_ROW_H = 24
|
||||
const LEGEND_BOTTOM_PAD = 8
|
||||
|
||||
// Approximate text width using canvas measureText if available, else char-based estimate
|
||||
function measureText(text: string, fontSize: number): number {
|
||||
try {
|
||||
const ctx = document.createElement('canvas').getContext('2d')
|
||||
if (ctx) { ctx.font = `${fontSize}px sans-serif`; return ctx.measureText(text).width }
|
||||
} catch { /* noop */ }
|
||||
return text.length * fontSize * 0.6
|
||||
}
|
||||
|
||||
export function renderLegend(
|
||||
svg: d3.Selection<SVGSVGElement, unknown, null, undefined>,
|
||||
items: LegendItem[],
|
||||
width: number,
|
||||
height: number,
|
||||
onToggle: (key: string | number, hidden: boolean) => void
|
||||
) {
|
||||
// Compute per-item width: rect + text-offset + textW
|
||||
const itemWidths = items.map(item =>
|
||||
LEGEND_RECT_W + LEGEND_TEXT_OFFSET + measureText(item.label, LEGEND_FONT_SIZE)
|
||||
)
|
||||
|
||||
// Layout items into rows
|
||||
const rows: { item: LegendItem; w: number; x: number; row: number }[] = []
|
||||
let rowIdx = 0, curX = 0
|
||||
itemWidths.forEach((w, i) => {
|
||||
const slotW = w + LEGEND_GAP
|
||||
if (curX > 0 && curX + w > width - LEGEND_GAP * 2) { rowIdx++; curX = 0 }
|
||||
rows.push({ item: items[i], w, x: curX, row: rowIdx })
|
||||
curX += slotW
|
||||
})
|
||||
|
||||
const totalRows = rowIdx + 1
|
||||
const totalH = totalRows * LEGEND_ROW_H
|
||||
const baseY = height - totalH - LEGEND_BOTTOM_PAD
|
||||
|
||||
// Center each row
|
||||
const rowWidths: number[] = Array(totalRows).fill(0)
|
||||
rows.forEach(({ w, row }, i) => {
|
||||
rowWidths[row] += w + (i > 0 && rows[i - 1].row === row ? LEGEND_GAP : 0)
|
||||
})
|
||||
// Recalculate row widths properly
|
||||
const rowTotals: number[] = Array(totalRows).fill(0)
|
||||
const rowCounts: number[] = Array(totalRows).fill(0)
|
||||
rows.forEach(r => { rowCounts[r.row]++; rowTotals[r.row] += r.w })
|
||||
rowTotals.forEach((_, ri) => { rowTotals[ri] += Math.max(0, rowCounts[ri] - 1) * LEGEND_GAP })
|
||||
|
||||
const legendG = svg.append('g')
|
||||
|
||||
rows.forEach(({ item, x, row }) => {
|
||||
const rowOffsetX = (width - rowTotals[row]) / 2
|
||||
const g = legendG.append('g')
|
||||
.attr('transform', `translate(${rowOffsetX + x},${baseY + row * LEGEND_ROW_H + LEGEND_ROW_H / 2})`)
|
||||
.style('cursor', 'pointer')
|
||||
|
||||
const rect = g.append('rect')
|
||||
.attr('x', 0).attr('y', -LEGEND_RECT_H / 2)
|
||||
.attr('width', LEGEND_RECT_W).attr('height', LEGEND_RECT_H).attr('rx', 2)
|
||||
.attr('fill', item.color)
|
||||
|
||||
const text = g.append('text')
|
||||
.text(item.label)
|
||||
.attr('x', LEGEND_TEXT_OFFSET).attr('dy', '0.35em')
|
||||
.attr('font-size', `${LEGEND_FONT_SIZE}px`).attr('fill', '#5B6167')
|
||||
|
||||
let hidden = false
|
||||
g.on('click', () => {
|
||||
hidden = !hidden
|
||||
rect.attr('fill', hidden ? '#ccc' : item.color)
|
||||
text.attr('fill', hidden ? '#bbb' : '#5B6167')
|
||||
onToggle(item.key, hidden)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,20 +1,37 @@
|
||||
import { useState, useEffect, type FC } from 'react';
|
||||
import { Spin, Alert, Button } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
/*
|
||||
* @Description:
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2026-03-16 19:01:12
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2026-03-18 18:35:53
|
||||
*/
|
||||
import { useState, useEffect, useRef, useCallback, type FC } from 'react';
|
||||
import { Spin, Alert, Button, Table, InputNumber, Image } from 'antd';
|
||||
import {
|
||||
ReloadOutlined,
|
||||
DownloadOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
ZoomInOutlined,
|
||||
ZoomOutOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import RbMarkdown from '../Markdown';
|
||||
import { cookieUtils } from '@/utils/request'
|
||||
import { cookieUtils } from '@/utils/request';
|
||||
import mammoth from 'mammoth';
|
||||
import * as XLSX from 'xlsx';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
type PreviewMode = 'office' | 'google';
|
||||
// 设置 pdf.js worker - 使用 CDN 避免 Vite 打包动态 import 问题
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.10.38/pdf.worker.min.mjs';
|
||||
|
||||
interface DocumentPreviewProps {
|
||||
fileUrl: string;
|
||||
fileName?: string;
|
||||
fileExt?: string; // 文件扩展名(优先使用)
|
||||
fileExt?: string;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
className?: string;
|
||||
mode?: PreviewMode; // 预览模式
|
||||
showModeSwitch?: boolean; // 是否显示模式切换按钮
|
||||
}
|
||||
|
||||
const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
@@ -24,18 +41,38 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
width = '100%',
|
||||
height = '600px',
|
||||
className = '',
|
||||
mode = 'office',
|
||||
showModeSwitch = true,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [currentMode, setCurrentMode] = useState<PreviewMode>(mode);
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [textContent, setTextContent] = useState<string>('');
|
||||
const [htmlContent, setHtmlContent] = useState<string>('');
|
||||
const [excelData, setExcelData] = useState<{ sheetName: string; data: any[][] }[]>([]);
|
||||
|
||||
// PDF 状态
|
||||
const [pdfDoc, setPdfDoc] = useState<pdfjsLib.PDFDocumentProxy | null>(null);
|
||||
const [pdfCurrentPage, setPdfCurrentPage] = useState(1);
|
||||
const [pdfTotalPages, setPdfTotalPages] = useState(0);
|
||||
const [pdfScale, setPdfScale] = useState(1.5);
|
||||
const pdfCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pdfRenderingRef = useRef(false);
|
||||
|
||||
// PPT 状态
|
||||
const [pptSlides, setPptSlides] = useState<string[]>([]);
|
||||
const [pptCurrentPage, setPptCurrentPage] = useState(1);
|
||||
const [pptTotalPages, setPptTotalPages] = useState(0);
|
||||
|
||||
// 图片状态
|
||||
const [imageBlobUrl, setImageBlobUrl] = useState<string>('');
|
||||
|
||||
// 支持预览的文件类型
|
||||
const previewableTypes = [
|
||||
'.pdf', '.txt', '.md', '.csv',
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp',
|
||||
'.doc', '.docx', '.xls', '.xlsx',
|
||||
'.ppt', '.pptx',
|
||||
];
|
||||
|
||||
// 支持的文件类型
|
||||
const supportedTypes = ['.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.pdf', '.txt', '.md', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'];
|
||||
|
||||
// 获取文件扩展名(优先使用 fileExt prop)
|
||||
const getFileExtension = () => {
|
||||
if (fileExt) {
|
||||
return fileExt.toLowerCase().startsWith('.') ? fileExt.toLowerCase() : `.${fileExt.toLowerCase()}`;
|
||||
@@ -44,172 +81,356 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
const match = name.match(/\.([^.]+)$/);
|
||||
return match ? `.${match[1].toLowerCase()}` : '';
|
||||
};
|
||||
|
||||
// 检查是否为文本文件
|
||||
const isTextFile = () => {
|
||||
const ext = getFileExtension();
|
||||
return ext === '.txt';
|
||||
};
|
||||
|
||||
// 检查是否为 Markdown 文件
|
||||
const isMarkdownFile = () => {
|
||||
const ext = getFileExtension();
|
||||
return ext === '.md';
|
||||
};
|
||||
|
||||
// 检查是否为图片文件
|
||||
|
||||
const isTextFile = () => getFileExtension() === '.txt';
|
||||
const isMarkdownFile = () => getFileExtension() === '.md';
|
||||
const isImageFile = () => {
|
||||
const ext = getFileExtension();
|
||||
const imageExts = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'];
|
||||
return imageExts.includes(ext);
|
||||
return imageExts.includes(getFileExtension());
|
||||
};
|
||||
|
||||
// 检查文件类型是否支持
|
||||
const isSupportedFile = () => {
|
||||
const ext = getFileExtension();
|
||||
return ext && supportedTypes.includes(ext);
|
||||
const isPdfFile = () => getFileExtension() === '.pdf';
|
||||
const isWordFile = () => ['.doc', '.docx'].includes(getFileExtension());
|
||||
const isExcelFile = () => ['.xls', '.xlsx', '.csv'].includes(getFileExtension());
|
||||
const isPptFile = () => ['.ppt', '.pptx'].includes(getFileExtension());
|
||||
const isPreviewable = () => previewableTypes.includes(getFileExtension());
|
||||
|
||||
const getRequestUrl = (url: string) => {
|
||||
if (url.includes('devapi.mem.redbearai.com')) {
|
||||
const parsed = new URL(url);
|
||||
return parsed.pathname;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
// 检查是否为 PDF 文件
|
||||
const isPdfFile = () => {
|
||||
const ext = getFileExtension();
|
||||
return ext === '.pdf';
|
||||
const fetchFileBuffer = async (url: string): Promise<ArrayBuffer> => {
|
||||
const requestUrl = getRequestUrl(url);
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return response.arrayBuffer();
|
||||
};
|
||||
|
||||
// 构建预览 URL
|
||||
const getPreviewUrl = () => {
|
||||
// 处理文件 URL,如果是完整的 URL,转换为代理路径
|
||||
let requestUrl = fileUrl;
|
||||
|
||||
// 如果是完整的 https://devapi.mem.redbearai.com 开头的 URL,提取路径部分
|
||||
// 这样可以通过代理访问,避免 CORS 问题
|
||||
if (fileUrl.includes('devapi.mem.redbearai.com')) {
|
||||
const url = new URL(fileUrl);
|
||||
requestUrl = url.pathname; // 只取路径部分,例如 /api/files/xxx
|
||||
}
|
||||
|
||||
// 对于 PDF 文件,直接使用浏览器内置预览
|
||||
if (isPdfFile()) {
|
||||
return requestUrl;
|
||||
}
|
||||
|
||||
// 确保 fileUrl 是完整的 URL(用于第三方预览服务)
|
||||
let fullUrl = fileUrl;
|
||||
if (!fileUrl.startsWith('http')) {
|
||||
fullUrl = `${window.location.origin}${fileUrl.startsWith('/') ? '' : '/'}${fileUrl}`;
|
||||
}
|
||||
console.log('预览 URL:', fullUrl);
|
||||
// 根据模式选择预览服务
|
||||
if (currentMode === 'google') {
|
||||
return `https://docs.google.com/viewer?url=${encodeURIComponent(fullUrl)}&embedded=true`;
|
||||
}
|
||||
|
||||
// 默认使用 Microsoft Office Online Viewer
|
||||
return `https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fullUrl)}`;
|
||||
const handleDownload = () => {
|
||||
const link = document.createElement('a');
|
||||
link.href = fileUrl;
|
||||
link.download = fileName || 'document';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleLoad = () => {
|
||||
setLoading(false);
|
||||
setError(false);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
const handleError = (msg?: string) => {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
if (msg) setErrorMessage(msg);
|
||||
};
|
||||
|
||||
// ========== PDF 渲染逻辑 ==========
|
||||
const renderPdfPage = useCallback(async (doc: pdfjsLib.PDFDocumentProxy, pageNum: number, scale: number) => {
|
||||
if (pdfRenderingRef.current || !pdfCanvasRef.current) return;
|
||||
pdfRenderingRef.current = true;
|
||||
try {
|
||||
const page = await doc.getPage(pageNum);
|
||||
const viewport = page.getViewport({ scale });
|
||||
const canvas = pdfCanvasRef.current;
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) return;
|
||||
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = viewport.width * dpr;
|
||||
canvas.height = viewport.height * dpr;
|
||||
canvas.style.width = `${viewport.width}px`;
|
||||
canvas.style.height = `${viewport.height}px`;
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
} finally {
|
||||
pdfRenderingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadPdfFile = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
setPdfDoc(doc);
|
||||
setPdfTotalPages(doc.numPages);
|
||||
setPdfCurrentPage(1);
|
||||
await renderPdfPage(doc, 1, pdfScale);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载 PDF 文件失败:', err);
|
||||
handleError(err.message || '加载 PDF 文件失败');
|
||||
}
|
||||
}, [fileUrl, pdfScale, renderPdfPage]);
|
||||
|
||||
const handlePdfPageChange = async (page: number) => {
|
||||
if (!pdfDoc || page < 1 || page > pdfTotalPages) return;
|
||||
setPdfCurrentPage(page);
|
||||
await renderPdfPage(pdfDoc, page, pdfScale);
|
||||
};
|
||||
|
||||
const handlePdfZoom = async (delta: number) => {
|
||||
const newScale = Math.max(0.5, Math.min(3, pdfScale + delta));
|
||||
setPdfScale(newScale);
|
||||
if (pdfDoc) {
|
||||
await renderPdfPage(pdfDoc, pdfCurrentPage, newScale);
|
||||
}
|
||||
};
|
||||
|
||||
// ========== PPT/PPTX 预览逻辑(转 PDF 后用 pdfjs 渲染每页为图片) ==========
|
||||
const loadPptFile = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
// 尝试用 pdfjs 直接加载(某些服务端会返回转换后的 PDF)
|
||||
// 如果失败,则使用 Office Online Viewer 作为 fallback
|
||||
try {
|
||||
const doc = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
|
||||
// 成功解析为 PDF,逐页渲染为图片
|
||||
const slides: string[] = [];
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) continue;
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
await page.render({ canvasContext: context, viewport }).promise;
|
||||
slides.push(canvas.toDataURL('image/png'));
|
||||
}
|
||||
setPptSlides(slides);
|
||||
setPptTotalPages(slides.length);
|
||||
setPptCurrentPage(1);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
// 不是 PDF 格式,使用 Office Online Viewer
|
||||
setPptSlides([]);
|
||||
setPptTotalPages(0);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('加载 PPT 文件失败:', err);
|
||||
handleError(err.message || '加载 PPT 文件失败');
|
||||
}
|
||||
}, [fileUrl]);
|
||||
|
||||
// ========== 图片加载逻辑 ==========
|
||||
const loadImageFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
const ext = getFileExtension().replace('.', '');
|
||||
const mimeMap: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
||||
gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp', svg: 'image/svg+xml',
|
||||
};
|
||||
const blob = new Blob([arrayBuffer], { type: mimeMap[ext] || 'image/png' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setImageBlobUrl(url);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载图片文件失败:', err);
|
||||
handleError(err.message || '图片加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
// ========== 文本/Word/Excel 加载逻辑 ==========
|
||||
const loadTextFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const requestUrl = getRequestUrl(fileUrl);
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
if (contentType.startsWith('image/')) {
|
||||
handleError('文件实际是图片类型,但被标记为文本文件');
|
||||
return;
|
||||
}
|
||||
const text = await response.text();
|
||||
if (text.startsWith('\x89PNG') || text.startsWith('<27>PNG')) {
|
||||
handleError('文件内容是图片,但扩展名是文本');
|
||||
return;
|
||||
}
|
||||
setTextContent(text);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载文本文件失败:', err);
|
||||
handleError(err.message || '加载文本文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
const loadWordFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
// .doc 旧格式 mammoth 不支持,使用 Office Online Viewer
|
||||
if (getFileExtension() === '.doc') {
|
||||
setHtmlContent('');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
// 校验是否为有效的 docx(ZIP 格式,前两字节为 PK)
|
||||
const header = new Uint8Array(arrayBuffer.slice(0, 4));
|
||||
if (header[0] !== 0x50 || header[1] !== 0x4B) {
|
||||
// 不是 ZIP/docx 格式,可能是 HTML 错误页或 JSON 响应
|
||||
const text = new TextDecoder().decode(arrayBuffer.slice(0, 200));
|
||||
throw new Error(`文件内容不是有效的 docx 格式: ${text.substring(0, 100)}`);
|
||||
}
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer });
|
||||
setHtmlContent(result.value);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载 Word 文件失败:', err);
|
||||
handleError(err.message || '加载 Word 文件失败,文件可能已损坏');
|
||||
}
|
||||
};
|
||||
|
||||
const isCsvFile = () => getFileExtension() === '.csv';
|
||||
|
||||
const loadExcelFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const arrayBuffer = await fetchFileBuffer(fileUrl);
|
||||
|
||||
// CSV 文件需要处理编码问题(可能是 GBK/GB2312)
|
||||
if (isCsvFile()) {
|
||||
let csvText: string;
|
||||
// 先尝试 UTF-8 解码
|
||||
const utf8Text = new TextDecoder('utf-8').decode(arrayBuffer);
|
||||
// 检测是否有乱码特征(常见的 GBK 被错误解析为 UTF-8 的替换字符)
|
||||
if (utf8Text.includes('\uFFFD') || /[\x80-\xff]/.test(utf8Text.slice(0, 200))) {
|
||||
// 尝试 GBK 解码
|
||||
try {
|
||||
csvText = new TextDecoder('gbk').decode(arrayBuffer);
|
||||
} catch {
|
||||
csvText = utf8Text;
|
||||
}
|
||||
} else {
|
||||
csvText = utf8Text;
|
||||
}
|
||||
const workbook = XLSX.read(csvText, { type: 'string' });
|
||||
const sheets = workbook.SheetNames.map(sheetName => {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
||||
return { sheetName, data };
|
||||
});
|
||||
setExcelData(sheets);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
const sheets = workbook.SheetNames.map(sheetName => {
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json(worksheet, { header: 1 }) as any[][];
|
||||
return { sheetName, data };
|
||||
});
|
||||
setExcelData(sheets);
|
||||
setLoading(false);
|
||||
} catch (err: any) {
|
||||
console.error('加载 Excel 文件失败:', err);
|
||||
handleError(err.message || '加载 Excel 文件失败,文件可能已损坏');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
|
||||
if (isTextFile() || isMarkdownFile()) {
|
||||
// 重新加载文本文件
|
||||
loadTextFile();
|
||||
} else {
|
||||
// 强制重新加载 iframe
|
||||
const iframe = document.querySelector(`iframe[title="${fileName || '文档预览'}"]`) as HTMLIFrameElement;
|
||||
if (iframe) {
|
||||
iframe.src = iframe.src;
|
||||
}
|
||||
}
|
||||
setErrorMessage('');
|
||||
if (isTextFile() || isMarkdownFile()) loadTextFile();
|
||||
else if (isWordFile()) loadWordFile();
|
||||
else if (isExcelFile()) loadExcelFile();
|
||||
else if (isPdfFile()) loadPdfFile();
|
||||
else if (isPptFile()) loadPptFile();
|
||||
};
|
||||
|
||||
const handleSwitchMode = () => {
|
||||
setCurrentMode(prev => prev === 'office' ? 'google' : 'office');
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
};
|
||||
|
||||
// 加载文本文件内容
|
||||
const loadTextFile = async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
// 处理文件 URL,如果是完整的 URL,转换为代理路径
|
||||
let requestUrl = fileUrl;
|
||||
|
||||
// 如果是完整的 https://devapi.mem.redbearai.com 开头的 URL,提取路径部分
|
||||
if (fileUrl.includes('devapi.mem.redbearai.com')) {
|
||||
const url = new URL(fileUrl);
|
||||
requestUrl = url.pathname; // 只取路径部分,例如 /api/files/xxx
|
||||
}
|
||||
|
||||
const response = await fetch(requestUrl, {
|
||||
credentials: 'include', // 包含认证信息
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cookieUtils.get('authToken') || ''}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load file');
|
||||
}
|
||||
|
||||
// 检查响应的 Content-Type
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
console.log('文件 Content-Type:', contentType);
|
||||
|
||||
// 如果是图片类型,显示错误提示
|
||||
if (contentType.startsWith('image/')) {
|
||||
setError(true);
|
||||
setTextContent('');
|
||||
setLoading(false);
|
||||
console.error('文件实际是图片类型,但被标记为 txt');
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
// 检查是否是二进制数据(如 PNG 文件头)
|
||||
if (text.startsWith('\x89PNG') || text.startsWith('<27>PNG')) {
|
||||
setError(true);
|
||||
setTextContent('');
|
||||
setLoading(false);
|
||||
console.error('文件内容是 PNG 图片,但扩展名是 txt');
|
||||
return;
|
||||
}
|
||||
|
||||
setTextContent(text);
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
console.error('加载文本文件失败:', err);
|
||||
setError(true);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 当文件是 txt 或 md 时,加载文本内容
|
||||
useEffect(() => {
|
||||
if (isTextFile() || isMarkdownFile()) {
|
||||
loadTextFile();
|
||||
}
|
||||
if (isTextFile() || isMarkdownFile()) loadTextFile();
|
||||
else if (isWordFile()) loadWordFile();
|
||||
else if (isExcelFile()) loadExcelFile();
|
||||
else if (isPdfFile()) loadPdfFile();
|
||||
else if (isPptFile()) loadPptFile();
|
||||
else if (isImageFile()) loadImageFile();
|
||||
}, [fileUrl]);
|
||||
|
||||
if (!isSupportedFile()) {
|
||||
// PDF 翻页/缩放后重新渲染
|
||||
useEffect(() => {
|
||||
if (pdfDoc && isPdfFile()) {
|
||||
renderPdfPage(pdfDoc, pdfCurrentPage, pdfScale);
|
||||
}
|
||||
}, [pdfCurrentPage, pdfScale, pdfDoc]);
|
||||
|
||||
// ========== 分页控制栏组件 ==========
|
||||
const PaginationBar = ({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
extraControls,
|
||||
}: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
onPageChange: (page: number) => void;
|
||||
extraControls?: React.ReactNode;
|
||||
}) => (
|
||||
<div className="rb:flex rb:items-center rb:justify-center rb:gap-3 rb:py-2 rb:px-4 rb:bg-white rb:border-t rb:border-gray-200 rb:select-none">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LeftOutlined />}
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
/>
|
||||
<span className="rb:text-sm rb:text-gray-600 rb:flex rb:items-center rb:gap-1">
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
max={totalPages}
|
||||
value={currentPage}
|
||||
onChange={(val) => val && onPageChange(val)}
|
||||
style={{ width: 56 }}
|
||||
/>
|
||||
<span>/ {totalPages}</span>
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<RightOutlined />}
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
/>
|
||||
{extraControls}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!isPreviewable()) {
|
||||
return (
|
||||
<Alert
|
||||
message="不支持的文件类型"
|
||||
description={`仅支持以下文件类型:${supportedTypes.join(', ')}`}
|
||||
description={`仅支持预览:${previewableTypes.join(', ')}`}
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
@@ -217,36 +438,33 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rb:relative ${className}`} style={{ width, height }}>
|
||||
<div className={`rb:relative rb:flex rb:flex-col ${className}`} style={{ width, height }}>
|
||||
{loading && (
|
||||
<div className="rb:absolute rb:inset-0 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:z-10">
|
||||
<Spin size="large" tip="加载文档预览中..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{error && (
|
||||
<div className="rb:absolute rb:inset-0 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:z-10">
|
||||
<Alert
|
||||
message="预览失败"
|
||||
description={
|
||||
<div>
|
||||
<p>无法加载文档预览,可能的原因:</p>
|
||||
<ul className="rb:list-disc rb:pl-5 rb:mt-2">
|
||||
<li>文件需要认证访问,Office 预览服务无法访问</li>
|
||||
<li>文件 URL 无法公开访问(需要配置公开访问或临时签名 URL)</li>
|
||||
<li>文件大小超过限制(Office 预览通常限制 10MB)</li>
|
||||
<li>预览服务暂时不可用</li>
|
||||
<p className="rb:mb-2">无法加载文档预览</p>
|
||||
{errorMessage && (
|
||||
<p className="rb:text-sm rb:text-red-600 rb:mb-3">错误详情:{errorMessage}</p>
|
||||
)}
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:mb-3">可能的原因:</p>
|
||||
<ul className="rb:list-disc rb:pl-5 rb:text-sm rb:text-gray-600 rb:mb-3">
|
||||
<li>文件 URL 无法访问(401/403/404)</li>
|
||||
<li>认证 token 已过期</li>
|
||||
<li>文件格式损坏或不匹配</li>
|
||||
<li>网络连接问题</li>
|
||||
</ul>
|
||||
<p className="rb:mt-2 rb:text-gray-600">建议:请下载文件到本地查看</p>
|
||||
<div className="rb:mt-4 rb:flex rb:gap-2">
|
||||
<Button icon={<ReloadOutlined />} onClick={handleRetry}>
|
||||
重试
|
||||
</Button>
|
||||
{showModeSwitch && !isPdfFile() && (
|
||||
<Button onClick={handleSwitchMode}>
|
||||
切换到 {currentMode === 'office' ? 'Google' : 'Office'} 预览
|
||||
</Button>
|
||||
)}
|
||||
<Button icon={<ReloadOutlined />} onClick={handleRetry}>重试</Button>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleDownload}>下载文件</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -255,73 +473,160 @@ const DocumentPreview: FC<DocumentPreviewProps> = ({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 图片文件预览 */}
|
||||
|
||||
{/* 图片预览 */}
|
||||
{isImageFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-gray-50 rb:flex rb:items-center rb:justify-center">
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={fileName || '图片预览'}
|
||||
className="rb:max-w-full rb:max-h-full rb:object-contain"
|
||||
onError={() => setError(true)}
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-50 rb:flex rb:items-center rb:justify-center">
|
||||
<Image
|
||||
src={imageBlobUrl}
|
||||
alt={fileName || '图片预览'}
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
||||
onError={() => handleError('图片渲染失败')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Markdown 文件预览 */}
|
||||
{/* Markdown 预览 */}
|
||||
{isMarkdownFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
|
||||
<RbMarkdown content={textContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文本文件预览 */}
|
||||
{/* 文本预览 */}
|
||||
{isTextFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:h-full rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
|
||||
<pre className="rb:whitespace-pre-wrap rb:text-sm rb:text-gray-800 rb:font-mono">
|
||||
{textContent}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF 文件预览(使用浏览器内置预览) */}
|
||||
{isPdfFile() && !error && !loading && (
|
||||
<iframe
|
||||
src={getPreviewUrl()}
|
||||
width="100%"
|
||||
height="100%"
|
||||
title={fileName || 'PDF 预览'}
|
||||
className="rb:border-0"
|
||||
style={{ border: 'none' }}
|
||||
/>
|
||||
{/* Word 预览 */}
|
||||
{isWordFile() && !error && !loading && (
|
||||
getFileExtension() === '.doc' ? (
|
||||
/* .doc 旧格式前端无法解析,提示下载 */
|
||||
<div className="rb:w-full rb:flex-1 rb:flex rb:items-center rb:justify-center rb:bg-gray-50">
|
||||
<div className="rb:text-center">
|
||||
<p className="rb:text-gray-600 rb:mb-4">.doc 格式暂不支持在线预览,请下载后查看</p>
|
||||
<Button icon={<DownloadOutlined />} type="primary" onClick={handleDownload}>下载文件</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-6 rb:rounded rb:border rb:border-gray-200">
|
||||
<div
|
||||
className="rb:prose rb:max-w-none"
|
||||
dangerouslySetInnerHTML={{ __html: htmlContent }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Office 文件预览 */}
|
||||
{!isTextFile() && !isMarkdownFile() && !isImageFile() && !isPdfFile() && (
|
||||
<>
|
||||
{showModeSwitch && !loading && !error && (
|
||||
<div className="rb:absolute rb:top-2 rb:right-2 rb:z-20">
|
||||
<Button size="small" onClick={handleSwitchMode}>
|
||||
切换到 {currentMode === 'office' ? 'Google' : 'Office'} 预览
|
||||
</Button>
|
||||
{/* Excel 预览 */}
|
||||
{isExcelFile() && !error && !loading && (
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-white rb:p-4 rb:rounded rb:border rb:border-gray-200">
|
||||
{excelData.map((sheet, index) => (
|
||||
<div key={index} className="rb:mb-6">
|
||||
<h3 className="rb:text-lg rb:font-semibold rb:mb-3">{sheet.sheetName}</h3>
|
||||
{sheet.data.length > 0 && (
|
||||
<Table
|
||||
dataSource={sheet.data.slice(1).map((row, idx) => ({ key: idx, ...row }))}
|
||||
columns={sheet.data[0]?.map((header: any, colIdx: number) => ({
|
||||
title: header || `列 ${colIdx + 1}`,
|
||||
dataIndex: colIdx,
|
||||
key: colIdx,
|
||||
width: 150,
|
||||
})) || []}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="small"
|
||||
bordered
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && (
|
||||
<iframe
|
||||
src={getPreviewUrl()}
|
||||
width="100%"
|
||||
height="100%"
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
title={fileName || '文档预览'}
|
||||
className="rb:border-0"
|
||||
style={{ display: loading ? 'none' : 'block', border: 'none' }}
|
||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF 预览 - 带分页和缩放 */}
|
||||
{isPdfFile() && !error && !loading && (
|
||||
<>
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-100 rb:flex rb:justify-center rb:p-4">
|
||||
<canvas ref={pdfCanvasRef} className="rb:shadow-lg" />
|
||||
</div>
|
||||
{pdfTotalPages > 0 && (
|
||||
<PaginationBar
|
||||
currentPage={pdfCurrentPage}
|
||||
totalPages={pdfTotalPages}
|
||||
onPageChange={handlePdfPageChange}
|
||||
extraControls={
|
||||
<div className="rb:flex rb:items-center rb:gap-1 rb:ml-4">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ZoomOutOutlined />}
|
||||
disabled={pdfScale <= 0.5}
|
||||
onClick={() => handlePdfZoom(-0.25)}
|
||||
/>
|
||||
<span className="rb:text-sm rb:text-gray-600 rb:min-w-[48px] rb:text-center">
|
||||
{Math.round(pdfScale * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ZoomInOutlined />}
|
||||
disabled={pdfScale >= 3}
|
||||
onClick={() => handlePdfZoom(0.25)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* PPT/PPTX 预览 */}
|
||||
{isPptFile() && !error && !loading && (
|
||||
<>
|
||||
{pptSlides.length > 0 ? (
|
||||
/* 本地渲染模式(服务端返回了可解析的格式) */
|
||||
<>
|
||||
<div className="rb:w-full rb:flex-1 rb:overflow-auto rb:bg-gray-100 rb:flex rb:justify-center rb:items-center rb:p-4">
|
||||
<img
|
||||
src={pptSlides[pptCurrentPage - 1]}
|
||||
alt={`Slide ${pptCurrentPage}`}
|
||||
className="rb:max-w-full rb:max-h-full rb:object-contain rb:shadow-lg"
|
||||
/>
|
||||
</div>
|
||||
<PaginationBar
|
||||
currentPage={pptCurrentPage}
|
||||
totalPages={pptTotalPages}
|
||||
onPageChange={(page) => {
|
||||
if (page >= 1 && page <= pptTotalPages) setPptCurrentPage(page);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
/* Office Online Viewer fallback */
|
||||
<div className="rb:w-full rb:flex-1 rb:flex rb:flex-col">
|
||||
<iframe
|
||||
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(fileUrl)}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
title={fileName || 'PPT 预览'}
|
||||
className="rb:border-0 rb:flex-1"
|
||||
style={{ border: 'none' }}
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => handleError('PPT 在线预览加载失败')}
|
||||
/>
|
||||
<div className="rb:flex rb:items-center rb:justify-center rb:gap-3 rb:py-2 rb:px-4 rb:bg-white rb:border-t rb:border-gray-200">
|
||||
<span className="rb:text-sm rb:text-gray-500">使用 Office Online 预览</span>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={handleDownload}>
|
||||
下载文件
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-10 11:08:27
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-24 15:25:14
|
||||
* @Last Modified time: 2026-03-20 11:47:43
|
||||
*/
|
||||
/*
|
||||
* PageHeader Component
|
||||
@@ -43,7 +43,7 @@ const PageHeader: FC<ConfigHeaderProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
// Main header container: full width, 64px height, flex layout with space between
|
||||
<Header className="rb:w-full rb:h-16 rb:flex rb:items-center rb:justify-between rb:gap-6 rb:px-4! rb:bg-[#FFFFFF]!">
|
||||
<Header className={`"rb:w-full rb:h-16 rb:grid rb:grid-cols-${extra && centerContent ? '3' : ((extra && !centerContent) || (!extra && centerContent)) ? '2': 1} rb:gap-6 rb:px-4! rb:bg-[#FFFFFF]!"`}>
|
||||
<Flex align="center" gap={8}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} alt={avatarUrl} className="rb:size-8 rb:rounded-lg rb:mr-2" />
|
||||
@@ -58,9 +58,11 @@ const PageHeader: FC<ConfigHeaderProps> = ({
|
||||
{operation}
|
||||
</Flex>
|
||||
|
||||
{centerContent}
|
||||
{centerContent && <Flex align="center">
|
||||
{centerContent}
|
||||
</Flex>}
|
||||
{/* Right section: Extra content (buttons, filters, etc.) */}
|
||||
<Flex align="center" gap={12}>
|
||||
<Flex align="center" justify="end" gap={12}>
|
||||
{extra}
|
||||
</Flex>
|
||||
</Header>
|
||||
|
||||
@@ -136,7 +136,7 @@ const RbMarkdown: FC<RbMarkdownProps> = ({
|
||||
|
||||
/** Sync edit content when external content changes */
|
||||
useEffect(() => {
|
||||
setEditContent(content)
|
||||
setEditContent(prev => prev !== content ? content : prev)
|
||||
}, [content])
|
||||
|
||||
/** Handle textarea content changes and trigger callback */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 15:18:19
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 13:51:01
|
||||
* @Last Modified time: 2026-03-19 20:47:34
|
||||
*/
|
||||
/**
|
||||
* PageScrollList Component
|
||||
@@ -62,8 +62,8 @@ const heightClass = 'rb:h-[calc(100vh-124px)]!';
|
||||
|
||||
/** Infinite scroll list component with pagination support */
|
||||
const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
renderItem,
|
||||
query,
|
||||
renderItem,
|
||||
query,
|
||||
url,
|
||||
column = 4,
|
||||
className = '',
|
||||
@@ -71,68 +71,70 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
}: PageScrollListProps<T, Q>, ref: React.Ref<PageScrollListRef>) => {
|
||||
/** Expose refresh method to parent component */
|
||||
useImperativeHandle(ref, () => ({
|
||||
refresh,
|
||||
refresh: () => {
|
||||
pageRef.current = 1;
|
||||
loadingRef.current = false;
|
||||
setHasMore(true);
|
||||
setData([]);
|
||||
loadMoreData(true);
|
||||
},
|
||||
}));
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const pageRef = useRef(1);
|
||||
const loadingRef = useRef(false);
|
||||
const hasMoreRef = useRef(true);
|
||||
|
||||
/** Load more data from API with pagination */
|
||||
const loadMoreData = (flag?: boolean) => {
|
||||
if (!flag && (loading || !hasMore)) {
|
||||
return;
|
||||
}
|
||||
const loadMoreData = (reset?: boolean) => {
|
||||
if (loadingRef.current || (!reset && !hasMoreRef.current)) return;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
const currentPage = reset ? 1 : pageRef.current;
|
||||
request.get(url, {
|
||||
page: page,
|
||||
page: currentPage,
|
||||
pagesize: PAGE_SIZE,
|
||||
...(query||{}),
|
||||
...(query || {}),
|
||||
})
|
||||
.then((res) => {
|
||||
const response = res as ApiResponse<T>;
|
||||
const results = Array.isArray(response.items) ? response.items : Array.isArray(response) ? response as T[] : [];
|
||||
// Replace data if flag is true, otherwise append
|
||||
if (flag) {
|
||||
setData(results);
|
||||
} else {
|
||||
setData(data.concat(results));
|
||||
}
|
||||
setPage(response.page.page + 1);
|
||||
pageRef.current = response.page.page + 1;
|
||||
setData(prev => reset ? results : [...prev, ...results]);
|
||||
hasMoreRef.current = response.page?.hasnext;
|
||||
setHasMore(response.page?.hasnext);
|
||||
setLoading(false);
|
||||
console.log(`${results.length} more items loaded!`);
|
||||
})
|
||||
.catch(() => {
|
||||
setLoading(false);
|
||||
hasMoreRef.current = false;
|
||||
setHasMore(false);
|
||||
console.error('Failed to load data');
|
||||
})
|
||||
.finally(() => {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
// 内容不足以填满容器时,主动继续加载
|
||||
setTimeout(() => {
|
||||
const el = scrollRef.current;
|
||||
console.log(el, el?.scrollHeight, el?.clientHeight, hasMoreRef.current)
|
||||
if (el && hasMoreRef.current && el.scrollHeight <= el.clientHeight) {
|
||||
loadMoreData();
|
||||
}
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
|
||||
/** Reset list to initial state and reload data */
|
||||
const refresh = () => {
|
||||
setPage(1);
|
||||
/** Reset and reload when query parameters change */
|
||||
const queryKey = JSON.stringify(query);
|
||||
useEffect(() => {
|
||||
pageRef.current = 1;
|
||||
loadingRef.current = false;
|
||||
hasMoreRef.current = true;
|
||||
setHasMore(true);
|
||||
setData([]);
|
||||
}
|
||||
loadMoreData(true);
|
||||
}, [queryKey]);
|
||||
|
||||
/** Refresh when query parameters change */
|
||||
useEffect(() => {
|
||||
refresh()
|
||||
}, [query]);
|
||||
|
||||
/** Load initial data when list is reset */
|
||||
useEffect(() => {
|
||||
if (page === 1 && hasMore && data.length === 0) {
|
||||
loadMoreData(true);
|
||||
}
|
||||
}, [page, hasMore, data])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -142,7 +144,7 @@ const PageScrollList = forwardRef(<T, Q = Record<string, unknown>>({
|
||||
>
|
||||
<InfiniteScroll
|
||||
dataLength={data.length}
|
||||
next={loadMoreData}
|
||||
next={() => loadMoreData()}
|
||||
hasMore={hasMore}
|
||||
loader={loading && needLoading ? <PageLoading className={heightClass} /> : false}
|
||||
// endMessage={<Divider plain>It is all, nothing more 🤐</Divider>}
|
||||
|
||||
@@ -455,6 +455,7 @@ export const en = {
|
||||
|
||||
fileSizeTip: 'File size cannot exceed {{size}}MB',
|
||||
fileAcceptTip: 'Unsupported file type:',
|
||||
fileNumTip: 'File count cannot exceed {{num}}',
|
||||
nextStep: 'Next Step',
|
||||
prevStep: 'Previous Step',
|
||||
exportSuccess: 'Export successful',
|
||||
@@ -465,6 +466,7 @@ export const en = {
|
||||
nameInvalid: 'Name cannot start or end with a space',
|
||||
notAllSpaces: 'Cannot be all spaces',
|
||||
download: 'Download',
|
||||
view: 'View',
|
||||
},
|
||||
model: {
|
||||
searchPlaceholder: 'search model…',
|
||||
@@ -1380,8 +1382,61 @@ export const en = {
|
||||
gotoList: 'Return to Application List',
|
||||
gotoDetail: 'View Details',
|
||||
dify: 'Dify',
|
||||
pleaseUploadFile: 'Please upload workflow file',
|
||||
promptOptimizationEmpty: 'The prompt words for conversation optimization will be displayed here',
|
||||
pleaseUploadFile: 'Please upload file',
|
||||
setting: 'Settings',
|
||||
features: 'Conversation Features',
|
||||
file_upload: 'File Upload',
|
||||
file_upload_desc: 'The chat input box supports file uploads. Types include images, documents, and other types',
|
||||
settings: 'File Upload Settings',
|
||||
uploadType: 'Upload Type',
|
||||
local: 'Local Upload',
|
||||
both: 'Both',
|
||||
maxSize: 'Maximum Upload',
|
||||
maxSizeDesc: 'Documents < 200.00MB, Images < 10.00MB, Audio < 50.00MB, Video < 100.00MB',
|
||||
supportedTypes: 'Supported File Types',
|
||||
document: 'Document',
|
||||
image: 'Image',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
other: 'Other File Types',
|
||||
otherFormats: 'Specify other file types',
|
||||
maxCount: 'Max Files',
|
||||
singleMaxSize: 'Max Size',
|
||||
unix: 'items',
|
||||
text_to_speech: 'Text to Speech',
|
||||
text_to_speech_desc: 'Text can be converted to speech',
|
||||
|
||||
apps: 'My Apps',
|
||||
sharing: 'Sharing',
|
||||
sharingApp: 'Shared Apps',
|
||||
myShare: 'My Shares',
|
||||
selectTargetSpace: 'Select Target Space',
|
||||
alreadyShared: 'Already Shared',
|
||||
permissionMode: 'Permission Mode',
|
||||
readonlyMode: 'Use Shared',
|
||||
readonlyModeDesc: 'Can test and run, cannot view internals',
|
||||
editableMode: 'Copy Shared',
|
||||
editableModeDesc: 'Copy full replica, free to edit',
|
||||
confirmSharing: 'Confirm Sharing',
|
||||
selectAtLeastOneSpace: 'Please select at least one target space',
|
||||
test: 'Conversation Test',
|
||||
log: 'Logs',
|
||||
testChatEmpty: 'Send a message to test the shared app',
|
||||
allCancel: 'Cancel All',
|
||||
cancelShare: 'Cancel Sharing',
|
||||
confirmAppCancelShareDesc: 'Are you sure to cancel sharing of 【{{app}}】 app with 【{{workspace}}】 space? The other party will no longer have access after cancellation.',
|
||||
confirmWorkspaceCancelShareDesc: 'Are you sure to cancel all apps shared with 【{{workspace}}】? This action cannot be undone.',
|
||||
sourceActive: 'Active',
|
||||
sourceInactive: 'Inactive',
|
||||
readonly: 'Use Only',
|
||||
editable: 'Copyable',
|
||||
version: 'Version',
|
||||
permission: 'Permission',
|
||||
souceStatus: 'Source App Status',
|
||||
confirmCopyDesc: 'Are you sure to copy 【{{app}}】 app?',
|
||||
noShareAuth: 'No permission to share apps',
|
||||
appCount: '{{count}} apps shared to this space',
|
||||
},
|
||||
userMemory: {
|
||||
userMemory: 'User Memory',
|
||||
@@ -1493,6 +1548,34 @@ export const en = {
|
||||
memoryNum: 'memories',
|
||||
memory_config_name: 'Memory Engine',
|
||||
searchPlaceholder: 'Search memory store name',
|
||||
|
||||
communityNetwork: 'Community Graph',
|
||||
community: 'Community',
|
||||
"Person": "Person Entity Node",
|
||||
"Organization": "Organization Entity Node",
|
||||
"ORG": "Organization Entity Node",
|
||||
"Location": "Location Entity Node",
|
||||
"LOC": "Location Entity Node",
|
||||
"Event": "Event Entity Node",
|
||||
"Concept": "Concept Entity Node",
|
||||
"Time": "Time Entity Node",
|
||||
"Position": "Position Entity Node",
|
||||
"WorkRole": "Work Role Entity Node",
|
||||
"System": "System Entity Node",
|
||||
"Policy": "Policy Entity Node",
|
||||
"HistoricalPeriod": "Historical Period Entity Node",
|
||||
"HistoricalState": "Historical State Entity Node",
|
||||
"HistoricalEvent": "Historical Event Entity Node",
|
||||
"EconomicFactor": "Economic Factor Entity Node",
|
||||
"Condition": "Condition Entity Node",
|
||||
"Numeric": "Numeric Entity Node",
|
||||
"Work": "Work / Output",
|
||||
member_count: 'Member Count',
|
||||
member_count_desc: 'entities',
|
||||
summary: 'Summary',
|
||||
core_entities: 'Core Entities',
|
||||
communityDetailEmptyDesc: 'Click on a community in the chart on the left to view details',
|
||||
communityLoadingTip: 'Generating community graph',
|
||||
},
|
||||
space: {
|
||||
createSpace: 'Create Space',
|
||||
@@ -1706,6 +1789,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
memoryConversationAnalysisEmpty: 'No conversation analysis available.',
|
||||
memoryConversationAnalysisEmptySubTitle: 'Conversation analysis will appear here.',
|
||||
|
||||
communities: 'Cluster Communities',
|
||||
summaries: 'Memory Summaries',
|
||||
uploadFile: 'Upload File',
|
||||
fileType: 'File Type',
|
||||
image: 'Image',
|
||||
@@ -1716,6 +1801,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
variableConfig: 'Variable Configuration',
|
||||
|
||||
chatTitle: 'Red Bear Space',
|
||||
memoryCancelTipTitle: 'Are you sure you want to disable conversation memory? Conversations will no longer be saved to the memory store.',
|
||||
memoryTipTitle: 'Are you sure you want to enable conversation memory? Conversations will be saved to the memory store.',
|
||||
},
|
||||
login: {
|
||||
title: 'Red Bear Memory Science',
|
||||
@@ -1825,6 +1912,29 @@ 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',
|
||||
marketConfigBtn: 'Configure',
|
||||
marketConfigConnection: 'Configure Connection',
|
||||
marketNoData: 'No Data',
|
||||
marketNoDataDesc: 'This market currently has no available services',
|
||||
marketNoSearchResult: 'No Search Results',
|
||||
marketNoSearchResultDesc: 'No matching services found, please try other keywords',
|
||||
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',
|
||||
@@ -1978,6 +2088,21 @@ 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',
|
||||
marketApiKeyRequired: 'API Key is required',
|
||||
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}}...',
|
||||
marketConfigUpdated: '{{name}} configuration updated',
|
||||
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 +2133,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
'memory-read': 'Memory Retrieval',
|
||||
'memory-write': 'Memory Storage',
|
||||
unknown: 'Unknown Node',
|
||||
notes: 'Sticky Note',
|
||||
|
||||
clickToConfigure: 'Click to configure node parameters',
|
||||
nodeProperties: 'Node Properties',
|
||||
@@ -2200,6 +2326,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',
|
||||
|
||||
@@ -763,8 +763,61 @@ export const zh = {
|
||||
gotoList: '返回应用列表',
|
||||
gotoDetail: '查看详情',
|
||||
dify: 'Dify',
|
||||
pleaseUploadFile: '请上传工作流文件',
|
||||
promptOptimizationEmpty: '对话优化提示词将显示在这里',
|
||||
pleaseUploadFile: '请上传文件',
|
||||
setting: '设置',
|
||||
features: '对话功能',
|
||||
file_upload: '文件上传',
|
||||
file_upload_desc: '聊天输入框支持上传文件。类型包括图片、文档以及其它类型',
|
||||
settings: '文件上传设置',
|
||||
uploadType: '上传类型',
|
||||
local: '本地上传',
|
||||
both: '两者皆可',
|
||||
maxSize: '最大上传大小',
|
||||
maxSizeDesc: '文档 < 200.00MB,图片 < 10.00MB,音频 < 50.00MB,视频 < 100.00MB',
|
||||
supportedTypes: '支持的文件类型',
|
||||
document: '文档',
|
||||
image: '图片',
|
||||
audio: '音频',
|
||||
video: '视频',
|
||||
other: '其他文件类型',
|
||||
otherFormats: '指定其他文件类型',
|
||||
maxCount: '最大文件数',
|
||||
singleMaxSize: '单文件最大大小',
|
||||
unix: '个',
|
||||
text_to_speech: '文字转语音',
|
||||
text_to_speech_desc: '文本可以转换成语音',
|
||||
|
||||
apps: '我的应用',
|
||||
sharing: '共享',
|
||||
sharingApp: '共享应用',
|
||||
myShare: '我的共享',
|
||||
selectTargetSpace: '选择目标空间',
|
||||
alreadyShared: '已共享',
|
||||
permissionMode: '权限模式',
|
||||
readonlyMode: '使用共享',
|
||||
readonlyModeDesc: '可测试运行,不可查看内部',
|
||||
editableMode: '复制共享',
|
||||
editableModeDesc: '复制完整副本,自由编辑',
|
||||
confirmSharing: '确认共享',
|
||||
selectAtLeastOneSpace: '请选择至少一个目标空间',
|
||||
test: '对话测试',
|
||||
log: '日志',
|
||||
testChatEmpty: '发送消息测试共享应用效果',
|
||||
allCancel: '全部取消',
|
||||
cancelShare: '取消共享',
|
||||
confirmAppCancelShareDesc: '确定取消该【{{app}}】应用对【{{workspace}}】空间的共享?取消后对方将无法访问。',
|
||||
confirmWorkspaceCancelShareDesc: '确定取消所有共享给【{{workspace}}】的应用?此操作不可恢复。',
|
||||
sourceActive: '生效中',
|
||||
sourceInactive: '已失效',
|
||||
readonly: '仅使用',
|
||||
editable: '可复制',
|
||||
version: '版本号',
|
||||
permission: '权限',
|
||||
souceStatus: '源应用状态',
|
||||
confirmCopyDesc: '确定复制【{{app}}】应用?',
|
||||
noShareAuth: '无共享应用的权限',
|
||||
appCount: '{{count}}个应用共享到此空间',
|
||||
},
|
||||
table: {
|
||||
totalRecords: '共 {{total}} 条记录'
|
||||
@@ -1039,6 +1092,7 @@ export const zh = {
|
||||
|
||||
fileSizeTip: '文件大小不能超过 {{size}}MB',
|
||||
fileAcceptTip: '不支持的文件类型:',
|
||||
fileNumTip: '文件数量不能超过{{num}}个',
|
||||
nextStep: '下一步',
|
||||
prevStep: '上一步',
|
||||
exportSuccess: '导出成功',
|
||||
@@ -1049,6 +1103,7 @@ export const zh = {
|
||||
nameInvalid: '不能是空格开头或结尾',
|
||||
notAllSpaces: '不能是纯空格',
|
||||
download: '下载',
|
||||
view: '查看',
|
||||
},
|
||||
model: {
|
||||
searchPlaceholder: '搜索模型…',
|
||||
@@ -1491,6 +1546,34 @@ export const zh = {
|
||||
memoryNum: '条记忆',
|
||||
memory_config_name: '记忆引擎',
|
||||
searchPlaceholder: '搜索记忆库名称',
|
||||
|
||||
communityNetwork: '社区图谱',
|
||||
community: '社区',
|
||||
"Person": "人物实体节点",
|
||||
"Organization": "组织实体节点",
|
||||
"ORG": "组织实体节点",
|
||||
"Location": "地点实体节点",
|
||||
"LOC": "地点实体节点",
|
||||
"Event": "事件实体节点",
|
||||
"Concept": "概念实体节点",
|
||||
"Time": "时间实体节点",
|
||||
"Position": "职位实体节点",
|
||||
"WorkRole": "职业实体节点",
|
||||
"System": "系统实体节点",
|
||||
"Policy": "政策实体节点",
|
||||
"HistoricalPeriod": "历史时期实体节点",
|
||||
"HistoricalState": "历史国家实体节点",
|
||||
"HistoricalEvent": "历史事件实体节点",
|
||||
"EconomicFactor": "经济因素实体节点",
|
||||
"Condition": "条件实体节点",
|
||||
"Numeric": "数值实体节点",
|
||||
"Work": "作品/工作成果",
|
||||
member_count: '成员数',
|
||||
member_count_desc: '个实体',
|
||||
summary: '摘要',
|
||||
core_entities: '核心实体',
|
||||
communityDetailEmptyDesc: '点击左侧图表中的社区查看详情',
|
||||
communityLoadingTip: '社区图谱生成中',
|
||||
},
|
||||
space: {
|
||||
createSpace: '创建空间',
|
||||
@@ -1702,6 +1785,8 @@ export const zh = {
|
||||
memoryConversationAnalysisEmpty: '目前没有可用的对话分析内容',
|
||||
memoryConversationAnalysisEmptySubTitle: '输入您的用户ID后,点击"测试记忆"查看对话记忆',
|
||||
|
||||
communities: '聚类社区',
|
||||
summaries: '记忆摘要',
|
||||
uploadFile: '上传文件',
|
||||
fileType: '文件类型',
|
||||
image: '图片',
|
||||
@@ -1712,6 +1797,8 @@ export const zh = {
|
||||
variableConfig: '变量配置',
|
||||
|
||||
chatTitle: '记忆熊空间',
|
||||
memoryCancelTipTitle: '确定关闭对话记忆功能吗?关闭后对话将不会保存到记忆库中',
|
||||
memoryTipTitle: '确定打开对话记忆功能吗?打开后对话将会保存到记忆库中',
|
||||
},
|
||||
login: {
|
||||
title: '红熊记忆科学',
|
||||
@@ -1821,6 +1908,29 @@ export const zh = {
|
||||
error_desc: 'API 已配置但链接异常',
|
||||
|
||||
testConnectionSuccess: '测试连接成功',
|
||||
refreshSuccess: '刷新成功',
|
||||
refreshFailed: '刷新失败',
|
||||
|
||||
// Market 相关
|
||||
marketSelectTitle: '选择一个 MCP 市场',
|
||||
marketSelectDesc: '从左侧选择一个市场源,配置连接后即可浏览该市场的 MCP 服务',
|
||||
marketRefreshSuccess: '列表已刷新',
|
||||
marketActivated: '已激活',
|
||||
marketInDatabase: '已入库',
|
||||
marketAdd: '添加',
|
||||
marketRefresh: '刷新',
|
||||
marketConfigBtn: '配置',
|
||||
marketConfigConnection: '配置连接',
|
||||
marketNoData: '暂无数据',
|
||||
marketNoDataDesc: '该市场暂时没有可用的服务',
|
||||
marketNoSearchResult: '无搜索结果',
|
||||
marketNoSearchResultDesc: '未找到匹配的服务,请尝试其他关键词',
|
||||
marketNoServices: '暂无可用的 MCP 服务',
|
||||
marketNotConnected: '尚未连接此市场',
|
||||
marketNoServicesDesc: '该市场暂时没有可用的服务',
|
||||
marketNotConnectedDesc: '点击右上角"配置"按钮设置连接信息',
|
||||
marketSearchPlaceholder: '搜索服务...',
|
||||
marketVisit: '前往市场',
|
||||
serviceEndpoint: '服务端点 URL',
|
||||
serviceEndpointPlaceholder: '服务端点的 URL',
|
||||
serviceEndpointExtra: 'MCP服务的完整访问地址',
|
||||
@@ -1974,6 +2084,21 @@ export const zh = {
|
||||
viewDetail: '查看详情',
|
||||
textLink: '测试连接',
|
||||
noResult: '处理结果将显示在这里',
|
||||
|
||||
marketConfig: '配置 {{name}}',
|
||||
marketSaveAndConnect: '保存并连接',
|
||||
marketUrl: '市场地址',
|
||||
marketUrlPlaceholder: '市场地址',
|
||||
marketCopy: '复制',
|
||||
marketApiKeyOptional: '可选',
|
||||
marketApiKeyRequired: '请输入 API Key',
|
||||
marketApiKeyExtra: '部分市场需要 API Key 才能获取完整的服务列表',
|
||||
marketApiKeyPlaceholder: '输入 API Key 以获取更多服务',
|
||||
marketConnectionStatus: '连接状态',
|
||||
marketConnected: '● 已连接',
|
||||
marketDisconnected: '○ 未连接',
|
||||
marketConnecting: '正在连接 {{name}}...',
|
||||
marketConfigUpdated: '{{name}} 配置已更新',
|
||||
serverUrlInvalid: '必须以 http:// 或 https:// 开头,且不能有前后空格',
|
||||
requestHeaderKeyInvalid: '只支持英文、数字、连字符(-)、下划线(_),不能以连字符或下划线开头结尾',
|
||||
},
|
||||
@@ -2004,6 +2129,7 @@ export const zh = {
|
||||
'memory-read': '记忆提取',
|
||||
'memory-write': '记忆储存',
|
||||
unknown: '未知节点',
|
||||
notes: '便签',
|
||||
|
||||
clickToConfigure: '点击配置节点参数',
|
||||
nodeProperties: '节点属性',
|
||||
@@ -2199,6 +2325,12 @@ export const zh = {
|
||||
unknown: {
|
||||
replaceNodeType: '替换节点'
|
||||
},
|
||||
notes: {
|
||||
showAuth: '显示作者',
|
||||
enterLink: '输入链接 URL',
|
||||
placeholder: '输入注释...',
|
||||
removeLink: '取消链接',
|
||||
},
|
||||
name: '键',
|
||||
type: '类型',
|
||||
value: '值',
|
||||
@@ -2506,7 +2638,7 @@ export const zh = {
|
||||
memoryHealthVisualization: '记忆健康可视化',
|
||||
activationValueDistribution: '激活值分布',
|
||||
forgettingTrend: '遗忘趋势(近7天)',
|
||||
|
||||
|
||||
nodes_without_activation: '观察区',
|
||||
low_activation_nodes: '遗忘区',
|
||||
health_nodes: '健康区',
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"element": "BasicLayout",
|
||||
"children": [
|
||||
{ "path": "/application/config/:id", "element": "ApplicationConfig" },
|
||||
{ "path": "/application/config/:id/:source", "element": "ApplicationConfig" },
|
||||
{ "path": "/user-memory/neo4j/:id", "element": "Neo4jUserMemoryDetail" },
|
||||
{ "path": "/statement/:id", "element": "StatementDetail" },
|
||||
{ "path": "/user-memory/detail/:id/:type", "element": "MemoryNodeDetail" },
|
||||
|
||||
@@ -347,6 +347,23 @@ export const request = {
|
||||
document.body.removeChild(link);
|
||||
callback?.()
|
||||
});
|
||||
},
|
||||
getDownloadFile(url: string, fileName: string, data?: unknown, callback?: () => void) {
|
||||
service.get(url, {
|
||||
params: paramFilter(data as Record<string, string | number | boolean | ObjectWithPush | null | undefined>),
|
||||
responseType: "blob",
|
||||
})
|
||||
.then(res => {
|
||||
const link = document.createElement("a");
|
||||
const blob = new Blob([res as unknown as BlobPart]);
|
||||
link.style.display = "none";
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.setAttribute("download", decodeURI(fileName || fileName));
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
callback?.()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:35:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 18:19:24
|
||||
* @Last Modified time: 2026-03-18 14:32:40
|
||||
*/
|
||||
/**
|
||||
* Server-Sent Events (SSE) Stream Utility Module
|
||||
@@ -176,17 +176,23 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
|
||||
case 500:
|
||||
case 502:
|
||||
const errorData = await response.json();
|
||||
let errorInfo = errorData.error || i18n.t('common.serviceUpgrading')
|
||||
const errorInfo = errorData.error || i18n.t('common.serviceUpgrading');
|
||||
message.warning(errorInfo);
|
||||
throw errorInfo;
|
||||
throw new Error(errorData);
|
||||
case 400:
|
||||
const error = await response.json();
|
||||
message.warning(error.error);
|
||||
throw error.error || 'Bad Request';
|
||||
const error400 = error.error || 'Bad Request';
|
||||
message.warning(error400);
|
||||
throw new Error(error);
|
||||
case 403:
|
||||
const errors = await response.json();
|
||||
message.warning(i18n.t('common.permissionDenied'));
|
||||
throw new Error(errors);
|
||||
case 504:
|
||||
const errorJson = await response.json();
|
||||
message.warning(errorJson.error || i18n.t('common.serverError'));
|
||||
throw errorData.error;
|
||||
const errorMsg = errorJson.error || i18n.t('common.serverError');
|
||||
message.warning(errorMsg);
|
||||
throw new Error(errorJson);
|
||||
case 401:
|
||||
if (url?.includes('/public')) {
|
||||
return message.warning(i18n.t('common.publicApiCannotRefreshToken'));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 10:28:59
|
||||
* @Last Modified time: 2026-03-20 11:36:49
|
||||
*/
|
||||
import { useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -22,7 +22,8 @@ import type {
|
||||
MemoryConfig,
|
||||
AiPromptModalRef,
|
||||
Source,
|
||||
ChatVariableConfigModalRef
|
||||
ChatVariableConfigModalRef,
|
||||
FeaturesConfigForm
|
||||
} from './types'
|
||||
import type { Variable } from './components/VariableList/types'
|
||||
import type { KnowledgeConfig } from './components/Knowledge/types'
|
||||
@@ -41,12 +42,13 @@ import ChatVariableConfigModal from './components/ChatVariableConfigModal';
|
||||
import type { Skill } from '@/views/Skills/types'
|
||||
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
|
||||
import DescWrapper from '@/components/FormItem/DescWrapper'
|
||||
import FeaturesConfig from './components/FeaturesConfig'
|
||||
|
||||
/**
|
||||
* Agent configuration component
|
||||
* Manages single agent configuration including prompts, knowledge, memory, variables, and tools
|
||||
*/
|
||||
const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
const Agent = forwardRef<AgentRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams();
|
||||
const { message } = App.useApp()
|
||||
@@ -117,6 +119,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
...response,
|
||||
tools: allTools
|
||||
})
|
||||
onFeaturesLoad?.(response.features)
|
||||
}).finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
@@ -272,7 +275,8 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
}, [modelList, values?.default_model_config_id])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleSave
|
||||
handleSave,
|
||||
features: values?.features
|
||||
}))
|
||||
|
||||
const aiPromptModalRef = useRef<AiPromptModalRef>(null)
|
||||
@@ -326,6 +330,10 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
useEffect(() => {
|
||||
setChatVariables(values?.variables || [])
|
||||
}, [values?.variables])
|
||||
|
||||
const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
|
||||
form.setFieldValue('features', value)
|
||||
}
|
||||
console.log('values', values)
|
||||
return (
|
||||
<>
|
||||
@@ -339,13 +347,16 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
{defaultModel?.name ? <div className="rb:size-4 rb:bg-[url('@/assets/images/application/model.svg')]"></div> : null}
|
||||
{defaultModel?.name || t('application.chooseModel')}
|
||||
</Button>
|
||||
<Button type="primary" onClick={() => handleSave()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Space size={12}>
|
||||
<FeaturesConfig value={values?.features as FeaturesConfigForm} refresh={handleSaveFeaturesConfig} />
|
||||
<Button type="primary" onClick={() => handleSave()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
<Form.Item name="default_model_config_id" hidden noStyle></Form.Item>
|
||||
<Form.Item name="model_parameters" hidden noStyle></Form.Item>
|
||||
|
||||
<Form.Item name="features" hidden noStyle></Form.Item>
|
||||
<Card
|
||||
title={t('application.promptConfiguration')}
|
||||
extra={
|
||||
@@ -364,25 +375,25 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
<span className="rb:font-regular rb:text-[#5B6167]"> ({t('application.configurationDesc')})</span>
|
||||
</div>
|
||||
|
||||
<Form.Item name="system_prompt" className="rb:mb-0!">
|
||||
<Input.TextArea
|
||||
placeholder={t('application.promptPlaceholder')}
|
||||
styles={{
|
||||
textarea: {
|
||||
minHeight: '200px',
|
||||
borderRadius: '8px',
|
||||
padding: '12px'
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Form.Item name="system_prompt" className="rb:mb-0!">
|
||||
<Input.TextArea
|
||||
placeholder={t('application.promptPlaceholder')}
|
||||
styles={{
|
||||
textarea: {
|
||||
minHeight: '200px',
|
||||
borderRadius: '8px',
|
||||
padding: '12px'
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Form.Item name="knowledge_retrieval" noStyle>
|
||||
<Knowledge />
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Form.Item name="knowledge_retrieval" noStyle>
|
||||
<Knowledge />
|
||||
</Form.Item>
|
||||
|
||||
{/* Memory Configuration */}
|
||||
{/* Memory Configuration */}
|
||||
<Card title={t('application.memoryConfiguration')}>
|
||||
<Flex gap={16} vertical className="rb:bg-[#FAFAFA] rb:rounded-xl rb:p-3!">
|
||||
<SwitchFormItem
|
||||
@@ -403,6 +414,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
hasAll={false}
|
||||
valueKey='config_id'
|
||||
labelKey="config_name"
|
||||
disabled={!values?.memory?.enabled}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
@@ -428,9 +440,6 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
title={t('application.debuggingAndPreview')}
|
||||
extra={
|
||||
<Space size={10}>
|
||||
<Button type="primary" ghost onClick={handleOpenVariableConfig}>
|
||||
{t('application.variableConfig')}
|
||||
</Button>
|
||||
<Button type="primary" ghost onClick={handleAddModel}>
|
||||
+ {t('application.addModel')}
|
||||
</Button>
|
||||
@@ -441,14 +450,15 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
headerClassName="rb:h-[56px]! rb:leading-[22px]!"
|
||||
titleClassName="rb:font-[MiSans-Bold] rb:font-bold"
|
||||
bodyClassName="rb:p-4! rb:pt-0!"
|
||||
className="rb:h-full"
|
||||
className="rb:h-full!"
|
||||
>
|
||||
<Chat
|
||||
data={data as Config}
|
||||
data={values as Config}
|
||||
chatList={chatList}
|
||||
updateChatList={setChatList}
|
||||
handleSave={handleSave}
|
||||
chatVariables={chatVariables}
|
||||
handleEditVariables={handleOpenVariableConfig}
|
||||
/>
|
||||
</RbCard>
|
||||
</Col>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:33
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-07 17:11:54
|
||||
* @Last Modified time: 2026-03-19 21:03:01
|
||||
*/
|
||||
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -18,7 +18,8 @@ import type {
|
||||
ChatData,
|
||||
SubAgentItem,
|
||||
ClusterRef,
|
||||
ModelConfigModalRef
|
||||
ModelConfigModalRef,
|
||||
FeaturesConfigForm
|
||||
} from './types'
|
||||
import Chat from './components/Chat'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
@@ -28,13 +29,14 @@ import RadioGroupCard from '@/components/RadioGroupCard'
|
||||
import ModelSelect from '@/components/ModelSelect'
|
||||
import ModelConfigModal from './components/ModelConfigModal'
|
||||
import type { Application } from '@/views/ApplicationManagement/types'
|
||||
// import FeaturesConfig from './components/FeaturesConfig'
|
||||
|
||||
const MAX_LENGTH = 5;
|
||||
/**
|
||||
* Multi-agent cluster configuration component
|
||||
* Manages multi-agent orchestration, sub-agents, and collaboration modes
|
||||
*/
|
||||
const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
const Cluster = forwardRef<ClusterRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { message } = App.useApp()
|
||||
const [form] = Form.useForm()
|
||||
@@ -129,6 +131,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
} else {
|
||||
setSubAgents(sub_agents)
|
||||
}
|
||||
onFeaturesLoad?.(response.features)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
@@ -167,7 +170,8 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
setSubAgents(prev => prev.filter(item => item.agent_id !== agent.agent_id))
|
||||
}
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleSave
|
||||
handleSave,
|
||||
features: data?.features
|
||||
}))
|
||||
|
||||
const modelConfigModalRef = useRef<ModelConfigModalRef>(null)
|
||||
@@ -186,6 +190,9 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
model_parameters: values
|
||||
})
|
||||
}
|
||||
// const handleSaveFeaturesConfig = (value: FeaturesConfigForm) => {
|
||||
// form.setFieldValue('features', value)
|
||||
// }
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -195,10 +202,12 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
<Form form={form} layout="vertical">
|
||||
<Flex gap={16} vertical>
|
||||
<Flex align="center" justify="end" className="rb:p-3! rb:bg-white rb:rounded-xl">
|
||||
{/* <FeaturesConfig value={values?.features as FeaturesConfigForm} refresh={handleSaveFeaturesConfig} /> */}
|
||||
<Button type="primary" onClick={() => handleSave()}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</Flex>
|
||||
<Form.Item name="features" hidden noStyle></Form.Item>
|
||||
<Card title={t('application.collaboration')}>
|
||||
<Form.Item
|
||||
name="orchestration_mode"
|
||||
@@ -216,73 +225,73 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
</Form.Item>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<>
|
||||
{t('application.subAgentsManagement')}
|
||||
<span className="rb:font-medium rb:font-[PingFangSC,PingFang_SC]! rb:text-[14px]!"> ({subAgents.length}/{MAX_LENGTH})</span>
|
||||
</>}
|
||||
extra={<Button className="rb:py-0! rb:px-2! rb:h-6!" disabled={subAgents.length >= MAX_LENGTH} onClick={() => handleSubAgentModal()}>+ {t('application.addSubAgent')}</Button>}
|
||||
>
|
||||
{subAgents.length === 0
|
||||
? <div className="rb-border rb:rounded-xl rb:pt-4 rb:pb-6"><Empty size={88} /></div>
|
||||
: <Flex vertical gap={12}>
|
||||
{subAgents.map((agent, index) => (
|
||||
<Flex key={index} align="center" justify="space-between"
|
||||
className="rb:w-full! rb-border rb:rounded-xl rb:py-2.5! rb:pl-4! rb:pr-3!"
|
||||
>
|
||||
<Flex justify="center" vertical className="rb:max-w-[calc(100%-60px)]">
|
||||
<div>
|
||||
<span className="rb:text-[#212332] rb:leading-5">{agent.name}</span>
|
||||
<Tag color={agent.is_active ? 'success' : 'warning'} className="rb:ml-2">
|
||||
{agent.is_active ? t('common.enable') : t('common.deleted')}
|
||||
</Tag>
|
||||
</div>
|
||||
{agent.role && <div className="rb:font-regular rb:leading-5 rb:text-[#5B6167] rb:text-[12px] rb:mt-1">{agent.role || '-'}</div>}
|
||||
{agent.capabilities && <Flex wrap gap={8} className="rb:mt-2.5!">
|
||||
{agent.capabilities.map((tag, tagIndex) => <Tag key={tagIndex} color="dark" className="rb:py-0!">{tag}</Tag>)}
|
||||
</Flex>}
|
||||
</Flex>
|
||||
|
||||
<Space>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
|
||||
onClick={() => handleSubAgentModal(agent)}
|
||||
></div>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
|
||||
onClick={() => handleDeleteSubAgent(agent)}
|
||||
></div>
|
||||
</Space>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
}
|
||||
</Card>
|
||||
|
||||
{values?.orchestration_mode !== 'collaboration' && <Card title={t('application.masterConfig')}>
|
||||
<Form.Item
|
||||
label={<span className="rb:text-[#5B6167]">{t('application.model')}</span>}
|
||||
required={true}
|
||||
className="rb:mb-4!"
|
||||
<Card
|
||||
title={<>
|
||||
{t('application.subAgentsManagement')}
|
||||
<span className="rb:font-medium rb:font-[PingFangSC,PingFang_SC]! rb:text-[14px]!"> ({subAgents.length}/{MAX_LENGTH})</span>
|
||||
</>}
|
||||
extra={<Button className="rb:py-0! rb:px-2! rb:h-6!" disabled={subAgents.length >= MAX_LENGTH} onClick={() => handleSubAgentModal()}>+ {t('application.addSubAgent')}</Button>}
|
||||
>
|
||||
<Flex align="center" gap={12}>
|
||||
<Form.Item name="default_model_config_id" noStyle>
|
||||
<ModelSelect
|
||||
params={{ type: 'llm,chat' }}
|
||||
className="rb:w-full!"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="model_parameters" noStyle>
|
||||
<Button
|
||||
className="rb:w-33"
|
||||
icon={<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/application/set.svg')]"></div>}
|
||||
onClick={handleEditModelConfig}
|
||||
>{t('application.modelConfig')}</Button>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
{subAgents.length === 0
|
||||
? <div className="rb-border rb:rounded-xl rb:pt-4 rb:pb-6"><Empty size={88} /></div>
|
||||
: <Flex vertical gap={12}>
|
||||
{subAgents.map((agent, index) => (
|
||||
<Flex key={index} align="center" justify="space-between"
|
||||
className="rb:w-full! rb-border rb:rounded-xl rb:py-2.5! rb:pl-4! rb:pr-3!"
|
||||
>
|
||||
<Flex justify="center" vertical className="rb:max-w-[calc(100%-60px)]">
|
||||
<div>
|
||||
<span className="rb:text-[#212332] rb:leading-5">{agent.name}</span>
|
||||
<Tag color={agent.is_active ? 'success' : 'warning'} className="rb:ml-2">
|
||||
{agent.is_active ? t('common.enable') : t('common.deleted')}
|
||||
</Tag>
|
||||
</div>
|
||||
{agent.role && <div className="rb:font-regular rb:leading-5 rb:text-[#5B6167] rb:text-[12px] rb:mt-1">{agent.role || '-'}</div>}
|
||||
{agent.capabilities && <Flex wrap gap={8} className="rb:mt-2.5!">
|
||||
{agent.capabilities.map((tag, tagIndex) => <Tag key={tagIndex} color="dark" className="rb:py-0!">{tag}</Tag>)}
|
||||
</Flex>}
|
||||
</Flex>
|
||||
|
||||
<Space>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
|
||||
onClick={() => handleSubAgentModal(agent)}
|
||||
></div>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
|
||||
onClick={() => handleDeleteSubAgent(agent)}
|
||||
></div>
|
||||
</Space>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
}
|
||||
</Card>
|
||||
|
||||
{values?.orchestration_mode !== 'collaboration' && <Card title={t('application.masterConfig')}>
|
||||
<Form.Item
|
||||
label={<span className="rb:text-[#5B6167]">{t('application.model')}</span>}
|
||||
required={true}
|
||||
className="rb:mb-4!"
|
||||
>
|
||||
<Flex align="center" gap={12}>
|
||||
<Form.Item name="default_model_config_id" noStyle>
|
||||
<ModelSelect
|
||||
params={{ type: 'llm,chat' }}
|
||||
className="rb:w-full!"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="model_parameters" noStyle>
|
||||
<Button
|
||||
className="rb:w-33"
|
||||
icon={<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/application/set.svg')]"></div>}
|
||||
onClick={handleEditModelConfig}
|
||||
>{t('application.modelConfig')}</Button>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['execution_config',"sub_agent_execution_mode"]}
|
||||
name={['execution_config', "sub_agent_execution_mode"]}
|
||||
label={<span className="rb:text-[#5B6167]">{t('application.orchestrationMode')}</span>}
|
||||
className="rb:mb-4!"
|
||||
>
|
||||
@@ -291,6 +300,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
value: type,
|
||||
label: t(`application.${type}`),
|
||||
}))}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
@@ -303,6 +313,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
|
||||
value: type,
|
||||
label: t(`application.${type}`),
|
||||
}))}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Card>}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:41
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 19:02:43
|
||||
* @Last Modified time: 2026-03-19 21:10:38
|
||||
*/
|
||||
import { type FC, useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -11,10 +11,11 @@ import { Space, Input, Form, App, Flex } from 'antd';
|
||||
|
||||
import Tag, { type TagProps } from './components/Tag'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import { getReleaseList, rollbackRelease } from '@/api/application'
|
||||
import { getReleaseList, rollbackRelease, appExport } from '@/api/application'
|
||||
import ReleaseModal from './components/ReleaseModal'
|
||||
import ReleaseShareModal from './components/ReleaseShareModal'
|
||||
import type { Release, ReleaseModalRef, ReleaseShareModalRef } from './types'
|
||||
import AppSharingModal from './components/AppSharingModal'
|
||||
import type { Release, ReleaseModalRef, ReleaseShareModalRef, AppSharingModalRef } from './types'
|
||||
import type { Application } from '@/views/ApplicationManagement/types'
|
||||
import Empty from '@/components/Empty'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
@@ -41,6 +42,7 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
const { message } = App.useApp()
|
||||
const releaseModalRef = useRef<ReleaseModalRef>(null)
|
||||
const releaseShareModalRef = useRef<ReleaseShareModalRef>(null)
|
||||
const appSharingModalRef = useRef<AppSharingModalRef>(null)
|
||||
const [selectedVersion, setSelectedVersion] = useState<Release | null>(null);
|
||||
const [releaseList, setReleaseList] = useState<Release[]>([])
|
||||
|
||||
@@ -69,6 +71,10 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
message.success(t('common.operateSuccess'))
|
||||
})
|
||||
}
|
||||
const handleExport = () => {
|
||||
if (!selectedVersion) return
|
||||
appExport(data.id, data.name, { release_id: selectedVersion.id})
|
||||
}
|
||||
return (
|
||||
<Flex gap={12}>
|
||||
<div className={`rb:overflow-y-auto rb:w-101 rb:flex-[0_0_auto] ${heightClass}`}>
|
||||
@@ -125,9 +131,10 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
|
||||
<Space size={10}>
|
||||
{selectedVersion && <>
|
||||
{/* <RbButton>{t('application.exportDSLFile')}</RbButton> */}
|
||||
{data?.type !== 'multi_agent' && <RbButton onClick={handleExport}>{t('common.export')}</RbButton>}
|
||||
{data.current_release_id !== selectedVersion.id && <RbButton onClick={handleRollback}>{t('application.willRollToThisVersion')}</RbButton>}
|
||||
<RbButton onClick={() => releaseShareModalRef.current?.handleOpen()}>{t('application.share')}</RbButton>
|
||||
<RbButton type="primary" ghost onClick={() => releaseShareModalRef.current?.handleOpen()}>{t('application.share')}</RbButton>
|
||||
{data?.type !== 'multi_agent' && <RbButton type="primary" ghost onClick={() => appSharingModalRef.current?.handleOpen()}>{t('application.sharing')}</RbButton>}
|
||||
</>}
|
||||
<RbButton type="primary" onClick={() => releaseModalRef.current?.handleOpen()}>{t('application.release')}</RbButton>
|
||||
</Space>
|
||||
@@ -184,6 +191,11 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
|
||||
ref={releaseShareModalRef}
|
||||
version={selectedVersion}
|
||||
/>
|
||||
<AppSharingModal
|
||||
ref={appSharingModalRef}
|
||||
appId={data.id}
|
||||
version={selectedVersion}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
485
web/src/views/ApplicationConfig/TestChat/index.tsx
Normal file
@@ -0,0 +1,485 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-13 17:27:52
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 20:54:35
|
||||
*/
|
||||
import { type FC, useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { App } from 'antd'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import { draftRun } from '@/api/application'
|
||||
|
||||
import Empty from '@/components/Empty'
|
||||
import Chat from '@/components/Chat'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar'
|
||||
import Runtime from '@/views/Workflow/components/Chat/Runtime'
|
||||
import { nodeLibrary } from '@/views/Workflow/constant'
|
||||
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import type { WorkflowConfig } from '@/views/Workflow/types'
|
||||
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
|
||||
import type { TestChatProps } from './type'
|
||||
import type { SSEMessage } from '@/utils/stream'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
|
||||
const formatParams = (message: string, conversation_id: string | null, files: any[] = [], variables: Record<string, any>) => {
|
||||
return {
|
||||
message,
|
||||
conversation_id,
|
||||
stream: true,
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
}),
|
||||
variables: Object.keys(variables).length > 0 ? variables : undefined
|
||||
}
|
||||
}
|
||||
|
||||
interface NodeData {
|
||||
content: string;
|
||||
conversation_id: string | null;
|
||||
cycle_id: string;
|
||||
cycle_idx: number;
|
||||
node_id: string;
|
||||
node_name?: string;
|
||||
node_type?: string;
|
||||
input?: any;
|
||||
output?: any;
|
||||
elapsed_time?: string;
|
||||
error?: any;
|
||||
state: Record<string, any>;
|
||||
status?: 'completed' | 'failed';
|
||||
audio_url?: string;
|
||||
}
|
||||
|
||||
const TestChat: FC<TestChatProps> = ({
|
||||
application,
|
||||
config
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const toolbarRef = useRef<ChatToolbarRef>(null)
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([])
|
||||
const [streamLoading, setStreamLoading] = useState(false)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | undefined>(undefined)
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
|
||||
|
||||
useEffect(() => {
|
||||
getVariables()
|
||||
}, [application, config])
|
||||
|
||||
const getVariables = () => {
|
||||
if (!application || !config) return
|
||||
|
||||
setFeatures(config?.features || {} as FeaturesConfigForm)
|
||||
|
||||
let initVariables: Variable[] = []
|
||||
|
||||
switch (application.type) {
|
||||
case 'workflow':
|
||||
const { nodes } = config as WorkflowConfig;
|
||||
const startNodes = nodes.filter(vo => vo.type === 'start')
|
||||
if (startNodes.length) {
|
||||
const curVariables = startNodes[0].config.variables as Variable[]
|
||||
curVariables.forEach((vo) => {
|
||||
if (typeof vo.default !== 'undefined') {
|
||||
vo.value = vo.default
|
||||
}
|
||||
const lastVo = curVariables.find(item => item.name === vo.name)
|
||||
if (lastVo?.value) {
|
||||
vo.value = lastVo.value
|
||||
}
|
||||
})
|
||||
initVariables = curVariables
|
||||
}
|
||||
break
|
||||
case 'agent':
|
||||
initVariables = config.variables as Variable[]
|
||||
break
|
||||
}
|
||||
|
||||
toolbarRef.current?.setVariables([...initVariables])
|
||||
}
|
||||
|
||||
const addUserMessage = (message: string, files: any[]) => {
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
meta_data: {
|
||||
files
|
||||
},
|
||||
}])
|
||||
}
|
||||
|
||||
const addAssistantMessage = () => {
|
||||
const { type } = application || {}
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
created_at: Date.now(),
|
||||
subContent: type === 'workflow' ? [] : undefined,
|
||||
}])
|
||||
}
|
||||
|
||||
const updateAssistantMessage = (content: string, audio_url?: string) => {
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastMsg = newList[newList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content += content;
|
||||
lastMsg.meta_data = {audio_url}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const updateErrorAssistantMessage = (message_length: number) => {
|
||||
if (message_length > 0) return
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastMsg = newList[newList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
lastMsg.content = null
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const buildVariableParams = (variables: Variable[]) => {
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (variables?.length > 0) {
|
||||
const needRequired: string[] = []
|
||||
variables.forEach(vo => {
|
||||
params[vo.name] = vo.value ?? vo.defaultValue
|
||||
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')}`)
|
||||
}
|
||||
}
|
||||
return { isCanSend, params }
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
if (loading || !application || !message || !message?.trim()) return
|
||||
const files = toolbarRef.current?.getFiles() || []
|
||||
const variables = toolbarRef.current?.getVariables() || []
|
||||
const { isCanSend, params } = buildVariableParams(variables)
|
||||
if (!isCanSend) return
|
||||
|
||||
addUserMessage(message, files)
|
||||
setMessage(undefined)
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
addAssistantMessage()
|
||||
setStreamLoading(true)
|
||||
setLoading(true)
|
||||
|
||||
draftRun(
|
||||
application.id,
|
||||
formatParams(message, conversationId, files, params),
|
||||
handleStreamMessage
|
||||
)
|
||||
.catch(() => {
|
||||
updateErrorAssistantMessage(0)
|
||||
setLoading(false)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
data.map(item => {
|
||||
const { conversation_id, content, message_length, audio_url } = item.data as { conversation_id: string, content: string, message_length: number; audio_url?: string; };
|
||||
switch (item.event) {
|
||||
case 'start':
|
||||
if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
|
||||
break
|
||||
case 'message':
|
||||
updateAssistantMessage(content)
|
||||
if (conversation_id && conversationId !== conversation_id) setConversationId(conversation_id)
|
||||
break
|
||||
case 'end':
|
||||
if (audio_url) {
|
||||
updateAssistantMessage(content, audio_url)
|
||||
}
|
||||
updateErrorAssistantMessage(message_length)
|
||||
setStreamLoading(false)
|
||||
break
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleWorkflowSend = () => {
|
||||
if (loading || !application || !message || !message?.trim()) return
|
||||
const files = toolbarRef.current?.getFiles() || []
|
||||
const variables = toolbarRef.current?.getVariables() || []
|
||||
const { isCanSend, params } = buildVariableParams(variables)
|
||||
if (!isCanSend) return
|
||||
|
||||
setLoading(true)
|
||||
addUserMessage(message, files)
|
||||
addAssistantMessage()
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
setMessage(undefined)
|
||||
setStreamLoading(true)
|
||||
|
||||
draftRun(
|
||||
application.id,
|
||||
formatParams(message, conversationId, files, params),
|
||||
handleWorkflowStreamMessage
|
||||
)
|
||||
.catch((error) => {
|
||||
const errorInfo = JSON.parse(error.message)
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
newList[lastIndex] = { ...newList[lastIndex], status: 'failed', content: null, subContent: errorInfo.error }
|
||||
}
|
||||
return newList
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const handleWorkflowStreamMessage = (data: SSEMessage[]) => {
|
||||
data.forEach(item => {
|
||||
const { content, conversation_id } = item.data as NodeData;
|
||||
switch (item.event) {
|
||||
// Append streaming text chunks to assistant message
|
||||
case 'message':
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
newList[lastIndex] = { ...newList[lastIndex], content: newList[lastIndex].content + content }
|
||||
}
|
||||
return newList
|
||||
})
|
||||
break
|
||||
// Track node execution start
|
||||
case 'node_start':
|
||||
addWorkflowNodeStartMessage(item.data as NodeData)
|
||||
break
|
||||
// Update node with execution results or errors
|
||||
case 'node_end':
|
||||
case 'node_error':
|
||||
updateWorkflowNodeEndMessage(item.data as NodeData)
|
||||
break
|
||||
// Update node with subContent
|
||||
case 'cycle_item':
|
||||
updateWorkflowCycleMessage(item.data as NodeData)
|
||||
break
|
||||
// Mark workflow as complete
|
||||
case 'workflow_end':
|
||||
updateWorkflowEndMessage(item.data as NodeData)
|
||||
setStreamLoading(false)
|
||||
setLoading(false)
|
||||
break
|
||||
}
|
||||
|
||||
if (conversation_id && conversationId !== conversation_id) {
|
||||
setConversationId(conversation_id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const addWorkflowNodeStartMessage = (data: NodeData) => {
|
||||
const { node_id } = data;
|
||||
const { nodes } = config as WorkflowConfig
|
||||
const node = nodes.find(n => n.id === node_id);
|
||||
const { name, type } = node || {}
|
||||
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
const newSubContent = newList[lastIndex].subContent || []
|
||||
const filterIndex = newSubContent.findIndex(vo => vo.id === node_id)
|
||||
if (filterIndex > -1) {
|
||||
newSubContent[filterIndex] = {
|
||||
...newSubContent[filterIndex],
|
||||
node_id: node_id,
|
||||
node_name: name,
|
||||
node_type: type,
|
||||
icon,
|
||||
content: {},
|
||||
}
|
||||
} else {
|
||||
newSubContent.push({
|
||||
id: node_id,
|
||||
node_id: node_id,
|
||||
node_name: name,
|
||||
node_type: type,
|
||||
icon,
|
||||
content: {},
|
||||
})
|
||||
}
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
subContent: newSubContent
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const updateWorkflowNodeEndMessage = (data: NodeData) => {
|
||||
const { node_id, input, output, error, elapsed_time, status } = data;
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
const newSubContent = newList[lastIndex].subContent || []
|
||||
const filterIndex = newSubContent.findIndex(vo => vo.node_id === node_id)
|
||||
if (filterIndex > -1 && newSubContent[filterIndex].content) {
|
||||
newSubContent[filterIndex] = {
|
||||
...newSubContent[filterIndex],
|
||||
content: {
|
||||
input,
|
||||
output,
|
||||
error,
|
||||
},
|
||||
status: status || 'completed',
|
||||
elapsed_time
|
||||
}
|
||||
}
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
subContent: newSubContent
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const updateWorkflowCycleMessage = (data: NodeData) => {
|
||||
const { node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = data;
|
||||
const { nodes } = config as WorkflowConfig
|
||||
const node = nodes.find(n => n.id === node_id);
|
||||
const { name, type } = node || {}
|
||||
const icon = nodeLibrary.flatMap(g => g.nodes).find(n => n.type === type)?.icon
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
const newSubContent = newList[lastIndex].subContent || []
|
||||
const filterIndex = newSubContent.findIndex(vo => vo.id === cycle_id)
|
||||
if (filterIndex > -1) {
|
||||
const items = newSubContent[filterIndex].subContent || []
|
||||
items.push({
|
||||
cycle_id,
|
||||
cycle_idx,
|
||||
node_id,
|
||||
node_name: name,
|
||||
node_type: type,
|
||||
icon,
|
||||
content: {
|
||||
cycle_idx,
|
||||
input,
|
||||
output,
|
||||
error,
|
||||
},
|
||||
status: status || 'completed',
|
||||
elapsed_time
|
||||
})
|
||||
newSubContent[filterIndex] = {
|
||||
...newSubContent[filterIndex],
|
||||
subContent: [...items]
|
||||
}
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
subContent: newSubContent
|
||||
}
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
const updateWorkflowEndMessage = (data: NodeData) => {
|
||||
const { error, status, audio_url } = data;
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
if (lastIndex >= 0) {
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
status,
|
||||
error,
|
||||
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
|
||||
meta_data: { audio_url: audio_url }
|
||||
}
|
||||
}
|
||||
return newList
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rb:w-250 rb:p-3 rb:mx-auto">
|
||||
<RbCard
|
||||
title={t('application.test')}
|
||||
headerClassName="rb:min-h-[56px]!"
|
||||
className="rb:h-[calc(100vh-88px)]!"
|
||||
bodyClassName="rb:h-[calc(100%-56px)]! rb:overflow-y-auto rb:px-3! rb:py-0!"
|
||||
>
|
||||
<Chat
|
||||
empty={<Empty url={ChatIcon} title={t('application.testChatEmpty')} isNeedSubTitle={false} size={[240, 200]} />}
|
||||
contentClassName={clsx(`rb:mx-[16px] rb:pt-[24px]`, {
|
||||
'rb:h-[calc(100%-140px)]': !fileList.length,
|
||||
'rb:h-[calc(100%-208px)]': !!fileList.length,
|
||||
})}
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
loading={loading}
|
||||
onChange={setMessage}
|
||||
onSend={application?.type === 'workflow' ? handleWorkflowSend : handleSend}
|
||||
fileList={fileList}
|
||||
fileChange={(list) => {
|
||||
setFileList(list || [])
|
||||
toolbarRef.current?.setFiles(list || [])
|
||||
}}
|
||||
labelFormat={(item) => item.role === 'user' ? t('application.you') : dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
renderRuntime={application?.type === 'workflow' ? (item, index) => <Runtime item={item} index={index} /> : undefined}
|
||||
>
|
||||
<ChatToolbar
|
||||
ref={toolbarRef}
|
||||
features={features}
|
||||
onFilesChange={setFileList}
|
||||
/>
|
||||
</Chat>
|
||||
</RbCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TestChat
|
||||
8
web/src/views/ApplicationConfig/TestChat/type.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Application } from '@/views/ApplicationManagement/types'
|
||||
import type { Config } from '../types';
|
||||
import type { WorkflowConfig } from '@/views/Workflow/types';
|
||||
|
||||
export interface TestChatProps {
|
||||
application?: Application | null;
|
||||
config: Config | WorkflowConfig | null
|
||||
}
|
||||
189
web/src/views/ApplicationConfig/components/AppSharingModal.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-13 17:19:13
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 16:03:46
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Checkbox, App, Form } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import RbModal from '@/components/RbModal';
|
||||
import { appSharing, getAppShares } from '@/api/application';
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import type { AppSharingModalRef, Release } from '../types';
|
||||
import type { SpaceItem } from '@/views/KnowledgeBase/types';
|
||||
import { getWorkspaces } from '@/api/workspaces';
|
||||
import RadioGroupCard from '@/components/RadioGroupCard';
|
||||
|
||||
/** Props for the AppSharingModal component */
|
||||
interface AppSharingModalProps {
|
||||
/** ID of the application being shared */
|
||||
appId: string;
|
||||
/** The release version to share */
|
||||
version: Release | null;
|
||||
}
|
||||
|
||||
const AppSharingModal = forwardRef<AppSharingModalRef, AppSharingModalProps>(({ appId, version }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// All workspaces available to share with (excluding the current one)
|
||||
const [spaceList, setSpaceList] = useState<SpaceItem[]>([]);
|
||||
// IDs of workspaces that already have access to this app
|
||||
const [sharedIds, setSharedIds] = useState<string[]>([]);
|
||||
|
||||
const [form] = Form.useForm<{ target_workspace_ids: string[]; permission: 'readonly' | 'editable' }>();
|
||||
// Reactively track the currently selected workspace IDs in the form
|
||||
const selectedIds: string[] = Form.useWatch('target_workspace_ids', form) ?? [];
|
||||
|
||||
/**
|
||||
* Fetch workspaces and existing share records in parallel,
|
||||
* sort already-shared spaces to the top, then open the modal.
|
||||
* Shows a warning if the user has no shareable workspaces.
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
Promise.all([getWorkspaces({ include_current: false }), getAppShares(appId)]).then(([spaces, shared]) => {
|
||||
// Normalise the shared workspace ID field across different API response shapes
|
||||
const ids = ((shared as any[]) || []).map((s: any) => s.workspace_id || s.target_workspace_id || s.id);
|
||||
// Sort: already-shared workspaces appear first
|
||||
const sorted = (spaces as SpaceItem[]).sort((a, b) =>
|
||||
ids.includes(b.id) ? 1 : ids.includes(a.id) ? -1 : 0
|
||||
);
|
||||
setSpaceList(sorted);
|
||||
setSharedIds(ids);
|
||||
|
||||
if (sorted.length > 0) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
message.warning(t('application.noShareAuth'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/** Close the modal and reset form fields */
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
// Expose open/close handlers to the parent via ref
|
||||
useImperativeHandle(ref, () => ({ handleOpen, handleClose }));
|
||||
|
||||
/**
|
||||
* Toggle a workspace in the selected list.
|
||||
* Already-shared workspaces are read-only and cannot be toggled.
|
||||
*/
|
||||
const handleToggle = (id: string, isShared: boolean) => {
|
||||
if (isShared) return;
|
||||
const prev: string[] = form.getFieldValue('target_workspace_ids') ?? [];
|
||||
form.setFieldValue(
|
||||
'target_workspace_ids',
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
/** Validate the form then submit the sharing request */
|
||||
const handleConfirm = () => {
|
||||
form.validateFields().then(values => {
|
||||
setLoading(true);
|
||||
appSharing(appId, values)
|
||||
.then(() => {
|
||||
message.success(t('common.operateSuccess'));
|
||||
handleClose();
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
});
|
||||
};
|
||||
|
||||
// Normalise the version label to always start with "v"
|
||||
const versionLabel = version?.version_name
|
||||
? (version.version_name[0].toLowerCase() === 'v' ? version.version_name : `v${version.version_name}`)
|
||||
: `v${version?.version}`;
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.sharingApp')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={<>{t('application.confirmSharing')}({selectedIds.length})</>}
|
||||
onOk={handleConfirm}
|
||||
confirmLoading={loading}
|
||||
width={600}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ target_workspace_ids: [], permission: 'readonly' }}>
|
||||
{/* Version info: displays version number, release time and publisher */}
|
||||
<div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:bg-[#FBFDFF] rb:p-4 rb:mb-4">
|
||||
<div className="rb:text-sm rb:font-medium rb:mb-3">{t('application.VersionInformation')}</div>
|
||||
<div className="rb:grid rb:grid-cols-3 rb:gap-4 rb:text-sm">
|
||||
<div>
|
||||
<div className="rb:text-[#5B6167] rb:mb-1">{t('application.versionList').replace('列表', '号')}</div>
|
||||
<div className="rb:font-medium">{versionLabel}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[#5B6167] rb:mb-1">{t('application.releaseTime')}</div>
|
||||
<div className="rb:font-medium">{formatDateTime(version?.published_at || 0, 'YYYY-MM-DD HH:mm:ss')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[#5B6167] rb:mb-1">{t('application.publisher')}</div>
|
||||
<div className="rb:font-medium">{version?.publisher_name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Target space: scrollable list of workspaces with checkbox selection */}
|
||||
<Form.Item
|
||||
label={t('application.selectTargetSpace')}
|
||||
required
|
||||
>
|
||||
<Form.Item
|
||||
name="target_workspace_ids"
|
||||
noStyle
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
>
|
||||
<input type="hidden" />
|
||||
</Form.Item>
|
||||
<div className="rb:rounded-lg rb:border rb:border-[#EBEBEB] rb:divide-y rb:divide-[#EBEBEB] rb:max-h-50 rb:overflow-y-auto">
|
||||
{spaceList.map(space => {
|
||||
const isShared = sharedIds.includes(space.id);
|
||||
return (
|
||||
<div key={space.id} className="rb:flex rb:items-center rb:gap-2 rb:px-4 rb:py-3 rb:cursor-pointer" onClick={() => handleToggle(space.id, isShared)}>
|
||||
<Checkbox
|
||||
checked={isShared || selectedIds.includes(space.id)}
|
||||
disabled={isShared}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onChange={() => handleToggle(space.id, isShared)}
|
||||
/>
|
||||
<span className="rb:flex-1 rb:text-sm">{space.name}</span>
|
||||
{isShared && (
|
||||
<span className="rb:text-xs rb:text-[#5B6167]">{t('application.alreadyShared')}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
{/* Permission mode: readonly (use only) or editable (full copy) */}
|
||||
<Form.Item
|
||||
name="permission"
|
||||
label={t('application.permissionMode')}
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
className="rb:mb-0!"
|
||||
>
|
||||
<RadioGroupCard
|
||||
options={['readonly', 'editable'].map((type) => ({
|
||||
value: type,
|
||||
label: t(`application.${type}Mode`),
|
||||
labelDesc: t(`application.${type}ModeDesc`),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default AppSharingModal;
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:39
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-05 17:03:46
|
||||
* @Last Modified time: 2026-03-20 11:38:45
|
||||
*/
|
||||
/**
|
||||
* Chat debugging component for application testing
|
||||
@@ -12,24 +12,25 @@
|
||||
|
||||
import { type FC, useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom'
|
||||
import clsx from 'clsx'
|
||||
import { Flex, Dropdown, type MenuProps, App, Divider } from 'antd'
|
||||
import { App, Flex } from 'antd';
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png'
|
||||
import type { ChatData, Config } from '../types'
|
||||
import type { ChatData, Config, FeaturesConfigForm } from '../types'
|
||||
import { runCompare, draftRun } from '@/api/application'
|
||||
import Empty from '@/components/Empty'
|
||||
import ChatContent from '@/components/Chat/ChatContent'
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import ChatInput from '@/components/Chat/ChatInput'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
import ChatToolbar from '@/components/Chat/ChatToolbar'
|
||||
import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
|
||||
import type { Variable } from './VariableList/types'
|
||||
|
||||
|
||||
/**
|
||||
* Component props
|
||||
*/
|
||||
@@ -44,27 +45,44 @@ interface ChatProps {
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
/** Source type: multi-agent cluster or single agent */
|
||||
source?: 'multi_agent' | 'agent';
|
||||
chatVariables?: Variable[]; // Add chatVariables prop
|
||||
/** chatVariables prop */
|
||||
chatVariables?: Variable[];
|
||||
handleEditVariables?: () => void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Chat debugging component
|
||||
* Allows testing application with different model configurations side-by-side
|
||||
*/
|
||||
const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, source = 'agent', chatVariables }) => {
|
||||
const Chat: FC<ChatProps> = ({
|
||||
chatList, data, updateChatList, handleSave, source = 'agent', chatVariables,
|
||||
handleEditVariables
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { id } = useParams()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const toolbarRef = useRef<ChatToolbarRef>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isCluster, setIsCluster] = useState(source === 'multi_agent')
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [compareLoading, setCompareLoading] = useState(false)
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [message, setMessage] = useState<string | undefined>(undefined)
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
|
||||
|
||||
useEffect(() => {
|
||||
setCompareLoading(false)
|
||||
setLoading(false)
|
||||
}, [chatList.map(item => item.label).join(',')])
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.features) setFeatures(data.features)
|
||||
}, [data?.features])
|
||||
|
||||
useEffect(() => {
|
||||
setIsCluster(source === 'multi_agent')
|
||||
setFileList([])
|
||||
toolbarRef.current?.setFiles([])
|
||||
setMessage(undefined)
|
||||
}, [source])
|
||||
|
||||
@@ -74,7 +92,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
files
|
||||
meta_data: {
|
||||
files
|
||||
},
|
||||
};
|
||||
updateChatList(prev => prev.map(item => ({
|
||||
...item,
|
||||
@@ -106,8 +126,8 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
}
|
||||
}
|
||||
/** Update assistant message with streaming content */
|
||||
const updateAssistantMessage = (content?: string, model_config_id?: string, conversation_id?: string) => {
|
||||
if (!content || !model_config_id) return
|
||||
const updateAssistantMessage = (content?: string, model_config_id?: string, conversation_id?: string, audio_url?: string) => {
|
||||
if ((!content && !audio_url) || !model_config_id) return
|
||||
updateChatList(prev => {
|
||||
const targetIndex = prev.findIndex(item => item.model_config_id === model_config_id);
|
||||
if (targetIndex !== -1) {
|
||||
@@ -118,12 +138,13 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
modelChatList[targetIndex] = {
|
||||
...modelChatList[targetIndex],
|
||||
conversation_id: conversation_id,
|
||||
conversation_id,
|
||||
list: [
|
||||
...curChatMsgList.slice(0, curChatMsgList.length - 1),
|
||||
{
|
||||
...lastMsg,
|
||||
content: lastMsg.content + content
|
||||
content: lastMsg.content + (content || ''),
|
||||
meta_data: { audio_url }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -162,13 +183,14 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
}
|
||||
/** Send message for agent comparison mode */
|
||||
const handleSend = (msg?: string) => {
|
||||
if (loading) return
|
||||
if (loading || !id) return
|
||||
setLoading(true)
|
||||
setCompareLoading(true)
|
||||
handleSave(false)
|
||||
.then(() => {
|
||||
const message = msg
|
||||
if (!message?.trim()) return
|
||||
const files = toolbarRef.current?.getFiles() || []
|
||||
// Validate required variables before sending
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
@@ -193,8 +215,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
return
|
||||
}
|
||||
|
||||
addUserMessage(message, fileList)
|
||||
addUserMessage(message, files)
|
||||
setMessage(message)
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
addAssistantMessage()
|
||||
|
||||
@@ -202,13 +225,16 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
setCompareLoading(false)
|
||||
|
||||
data.map(item => {
|
||||
const { model_config_id, conversation_id, content, message_length } = item.data as { model_config_id: string; conversation_id: string; content: string; message_length: number };
|
||||
|
||||
const { model_config_id, conversation_id, content, message_length, audio_url } = item.data as { model_config_id: string; conversation_id: string; content: string; message_length: number; audio_url: string };
|
||||
|
||||
switch (item.event) {
|
||||
case 'model_message':
|
||||
updateAssistantMessage(content, model_config_id, conversation_id)
|
||||
updateAssistantMessage(content, model_config_id, conversation_id, audio_url)
|
||||
break;
|
||||
case 'model_end':
|
||||
if (audio_url) {
|
||||
updateAssistantMessage(content, model_config_id, conversation_id, audio_url)
|
||||
}
|
||||
updateErrorAssistantMessage(message_length, model_config_id)
|
||||
break;
|
||||
case 'compare_end':
|
||||
@@ -219,9 +245,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
runCompare(data.app_id, {
|
||||
runCompare(id, {
|
||||
message,
|
||||
files: fileList.map(file => {
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
@@ -239,9 +265,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
conversation_id: item.conversation_id
|
||||
})),
|
||||
variables: params,
|
||||
"parallel": true,
|
||||
"stream": true,
|
||||
"timeout": 60,
|
||||
parallel: true,
|
||||
stream: true,
|
||||
timeout: 60,
|
||||
}, handleStreamMessage)
|
||||
.catch(() => {
|
||||
setLoading(false)
|
||||
@@ -265,7 +291,7 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
const assistantMessage: ChatItem = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
created_at: Date.now(),
|
||||
created_at: Date.now()
|
||||
};
|
||||
updateChatList(prev => prev.map(item => ({
|
||||
...item,
|
||||
@@ -277,8 +303,7 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
if (!content) return
|
||||
updateChatList(prev => {
|
||||
const modelChatList = [...prev]
|
||||
const curModelChat = modelChatList[0]
|
||||
const curChatMsgList = curModelChat.list || []
|
||||
const curChatMsgList = modelChatList[0].list || []
|
||||
const lastMsg = curChatMsgList[curChatMsgList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
modelChatList[0] = {
|
||||
@@ -298,11 +323,9 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
/** Update cluster message when error occurs */
|
||||
const updateClusterErrorAssistantMessage = (message_length: number) => {
|
||||
if (message_length > 0) return
|
||||
|
||||
updateChatList(prev => {
|
||||
const modelChatList = [...prev]
|
||||
const curModelChat = modelChatList[0]
|
||||
const curChatMsgList = curModelChat.list || []
|
||||
const curChatMsgList = modelChatList[0].list || []
|
||||
const lastMsg = curChatMsgList[curChatMsgList.length - 1]
|
||||
if (lastMsg.role === 'assistant') {
|
||||
modelChatList[0] = {
|
||||
@@ -321,15 +344,17 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
}
|
||||
/** Send message for cluster mode */
|
||||
const handleClusterSend = (msg?: string) => {
|
||||
if (loading) return
|
||||
if (loading || !id) return
|
||||
setLoading(true)
|
||||
setCompareLoading(true)
|
||||
handleSave(false)
|
||||
.then(() => {
|
||||
const message = msg
|
||||
if (!message || message.trim() === '') return
|
||||
addUserMessage(message, fileList)
|
||||
const files = toolbarRef.current?.getFiles() || []
|
||||
addUserMessage(message, files)
|
||||
setMessage(undefined)
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
addClusterAssistantMessage()
|
||||
|
||||
@@ -338,7 +363,7 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
|
||||
data.map(item => {
|
||||
const { conversation_id, content, message_length } = item.data as { conversation_id: string, content: string, message_length: number };
|
||||
|
||||
|
||||
switch (item.event) {
|
||||
case 'start':
|
||||
if (conversation_id && conversationId !== conversation_id) {
|
||||
@@ -362,13 +387,12 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
draftRun(
|
||||
data.app_id,
|
||||
draftRun(id,
|
||||
{
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
stream: true,
|
||||
files: fileList.map(file => {
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
@@ -403,35 +427,6 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
const handleDelete = (index: number) => {
|
||||
updateChatList(chatList.filter((_, voIndex) => voIndex !== index))
|
||||
}
|
||||
const handleMessageChange = (message: string) => {
|
||||
setMessage(message)
|
||||
}
|
||||
const fileChange = (file?: any) => {
|
||||
setFileList([...fileList, file])
|
||||
}
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
setFileList([...fileList, {
|
||||
uid: file.file_id,
|
||||
response: { data: file },
|
||||
thumbUrl: file.url,
|
||||
type: file.type
|
||||
}])
|
||||
}
|
||||
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch (key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
const addFileList = (list?: any[]) => {
|
||||
if (!list || list.length <= 0) return
|
||||
setFileList([...fileList, ...(list || [])])
|
||||
}
|
||||
const updateFileList = (list?: any[]) => {
|
||||
setFileList([...list || []])
|
||||
}
|
||||
const isHasLabel = useMemo(() => chatList.some(item => item.label), [chatList])
|
||||
|
||||
return (
|
||||
@@ -444,105 +439,95 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
subTitle={t('application.debuggingEmptyDesc')}
|
||||
className="rb:h-[calc(100vh-159px)]"
|
||||
/>
|
||||
: <>
|
||||
<div className={clsx(`rb:relative rb:grid rb:grid-cols-${chatList.length} rb:overflow-hidden rb:w-full rb:flex-1 rb:min-h-0`)}>
|
||||
{chatList.map((chat, index) => (
|
||||
<Flex key={index} vertical className={clsx({
|
||||
"rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1,
|
||||
})}>
|
||||
{chat.label &&
|
||||
<div className={clsx(
|
||||
"rb:grid rb:bg-[#F6F6F6] rb:text-center rb:flex-[0_0_auto]"
|
||||
)}>
|
||||
<div className='rb:relative rb:py-2.5 rb:px-3 rb:overflow-hidden'>
|
||||
<div className="rb:text-[#212332] rb:font-medium rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:w-[calc(100%-24px)]">{chat.label}</div>
|
||||
<div
|
||||
className="rb:w-4 rb:h-4 rb:cursor-pointer rb:absolute rb:top-3 rb:right-3 rb:bg-cover rb:bg-[url('@/assets/images/close.svg')] rb:hover:bg-[url('@/assets/images/close_hover.svg')]"
|
||||
onClick={() => handleDelete(index)}
|
||||
></div>
|
||||
: <>
|
||||
<div className={clsx(`rb:relative rb:grid rb:grid-cols-${chatList.length} rb:overflow-hidden rb:w-full rb:flex-1 rb:min-h-0`)}>
|
||||
{chatList.map((chat, index) => (
|
||||
<Flex key={index} vertical className={clsx({
|
||||
"rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1,
|
||||
})}>
|
||||
{chat.label &&
|
||||
<div className={clsx(
|
||||
"rb:grid rb:bg-[#F6F6F6] rb:text-center rb:flex-[0_0_auto]"
|
||||
)}>
|
||||
<div className='rb:relative rb:py-2.5 rb:px-3 rb:overflow-hidden'>
|
||||
<div className="rb:text-[#212332] rb:font-medium rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:w-[calc(100%-24px)]">{chat.label}</div>
|
||||
<div
|
||||
className="rb:w-4 rb:h-4 rb:cursor-pointer rb:absolute rb:top-3 rb:right-3 rb:bg-cover rb:bg-[url('@/assets/images/close.svg')] rb:hover:bg-[url('@/assets/images/close_hover.svg')]"
|
||||
onClick={() => handleDelete(index)}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<ChatContent
|
||||
classNames={{
|
||||
'rb:mb-3 rb:mt-5': isHasLabel,
|
||||
'rb:mb-3': !isHasLabel,
|
||||
'rb:h-[calc(100vh-292px)]': isCluster,
|
||||
'rb:h-[calc(100vh-353px)]': !isCluster,
|
||||
"rb:pr-4": index !== chatList.length - 1 && chatList.length > 1,
|
||||
"rb:pl-4": index !== 0 && chatList.length > 1,
|
||||
}}
|
||||
contentClassNames={{
|
||||
'rb:max-w-100!': chatList.length === 1,
|
||||
'rb:max-w-70!': chatList.length === 2,
|
||||
'rb:max-w-45!': chatList.length === 3,
|
||||
'rb:max-w-24!': chatList.length === 4,
|
||||
}}
|
||||
empty={<Empty
|
||||
url={ChatIcon}
|
||||
title={t('application.chatEmpty')}
|
||||
isNeedSubTitle={false}
|
||||
size={[240, 200]}
|
||||
className={clsx({
|
||||
"rb:h-[calc(100vh-353px)]": isHasLabel,
|
||||
"rb:h-[calc(100vh-292px)]": !isHasLabel,
|
||||
})}
|
||||
/>}
|
||||
data={chat.list || []}
|
||||
streamLoading={compareLoading}
|
||||
labelPosition="top"
|
||||
labelFormat={(item) => item.role === 'user' ? t('application.you') : chat.label || t(`application.ai`)}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
/>
|
||||
</Flex>
|
||||
))}
|
||||
</div>
|
||||
<div className="rb:relative rb:flex rb:items-center rb:gap-2.5 rb:m-4 rb:mb-1">
|
||||
}
|
||||
<ChatContent
|
||||
classNames={{
|
||||
'rb:mb-3 rb:mt-5': isHasLabel,
|
||||
'rb:mb-3': !isHasLabel,
|
||||
'rb:h-[calc(100vh-292px)]': isCluster,
|
||||
'rb:h-[calc(100vh-353px)]': !isCluster,
|
||||
"rb:pr-4": index !== chatList.length - 1 && chatList.length > 1,
|
||||
"rb:pl-4": index !== 0 && chatList.length > 1,
|
||||
}}
|
||||
contentClassNames={{
|
||||
'rb:max-w-100!': chatList.length === 1,
|
||||
'rb:max-w-70!': chatList.length === 2,
|
||||
'rb:max-w-45!': chatList.length === 3,
|
||||
'rb:max-w-24!': chatList.length === 4,
|
||||
}}
|
||||
empty={<Empty
|
||||
url={ChatIcon}
|
||||
title={t('application.chatEmpty')}
|
||||
isNeedSubTitle={false}
|
||||
size={[240, 200]}
|
||||
className={clsx({
|
||||
"rb:h-[calc(100vh-353px)]": isHasLabel,
|
||||
"rb:h-[calc(100vh-292px)]": !isHasLabel,
|
||||
})}
|
||||
/>}
|
||||
data={chat.list || []}
|
||||
streamLoading={compareLoading}
|
||||
labelPosition="top"
|
||||
labelFormat={(item) => item.role === 'user' ? t('application.you') : chat.label || t(`application.ai`)}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
/>
|
||||
</Flex>
|
||||
))}
|
||||
</div>
|
||||
<div className="rb:relative rb:flex rb:items-center rb:gap-2.5 rb:mt-4 rb:mb-1">
|
||||
<ChatInput
|
||||
message={message}
|
||||
className="rb:relative!"
|
||||
loading={loading}
|
||||
fileChange={updateFileList}
|
||||
fileChange={(list) => {
|
||||
setFileList(list || [])
|
||||
toolbarRef.current?.setFiles(list || [])
|
||||
}}
|
||||
fileList={fileList}
|
||||
onSend={isCluster ? handleClusterSend : handleSend}
|
||||
onChange={handleMessageChange}
|
||||
onChange={setMessage}
|
||||
>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
onChange={fileChange}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<ChatToolbar
|
||||
ref={toolbarRef}
|
||||
features={features}
|
||||
onFilesChange={setFileList}
|
||||
extra={
|
||||
chatVariables && chatVariables.length > 0 ? (
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ChatInput>
|
||||
className={clsx('rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]', {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': chatVariables.some(vo => vo.required && !vo.value),
|
||||
'rb:border-[#DFE4ED]': !chatVariables.some(vo => vo.required && !vo.value),
|
||||
})}
|
||||
onClick={handleEditVariables}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t('memoryConversation.variableConfig')}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</ChatInput>
|
||||
</div>
|
||||
</>
|
||||
</>
|
||||
}
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:52
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 10:31:08
|
||||
* @Last Modified time: 2026-03-19 21:21:28
|
||||
*/
|
||||
import { type FC, useRef, useMemo } from 'react';
|
||||
import { type FC, useRef, useMemo, useCallback } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Tabs, Dropdown, Button, Flex } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
@@ -18,16 +18,21 @@ import exportIcon from '@/assets/images/export_hover.svg'
|
||||
import deleteIcon from '@/assets/images/delete_hover.svg'
|
||||
import type { Application, ApplicationModalRef } from '@/views/ApplicationManagement/types';
|
||||
import ApplicationModal from '@/views/ApplicationManagement/components/ApplicationModal'
|
||||
import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef } from '../types'
|
||||
import { deleteApplication } from '@/api/application'
|
||||
import type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FeaturesConfigForm } from '../types'
|
||||
import { deleteApplication, appExport } from '@/api/application'
|
||||
import CopyModal from './CopyModal'
|
||||
import PageHeader from '@/components/Layout/PageHeader'
|
||||
import { exportToYaml } from '@/utils/yamlExport';
|
||||
import FeaturesConfig from './FeaturesConfig'
|
||||
|
||||
/**
|
||||
* Tab keys for application configuration
|
||||
*/
|
||||
const tabKeys = ['arrangement', 'api', 'release', 'statistics']
|
||||
const sharingTabKeys = [
|
||||
'test',
|
||||
// 'log',
|
||||
'api'
|
||||
]
|
||||
|
||||
/**
|
||||
* Menu icon mapping
|
||||
@@ -55,6 +60,10 @@ interface ConfigHeaderProps {
|
||||
workflowRef: React.RefObject<WorkflowRef>
|
||||
/** App component ref (Agent/Cluster/Workflow) */
|
||||
appRef?: React.RefObject<AgentRef | ClusterRef | WorkflowRef>
|
||||
/** Features config from parent state */
|
||||
features?: FeaturesConfigForm;
|
||||
/** Callback to update features in parent */
|
||||
onFeaturesChange?: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,35 +73,45 @@ interface ConfigHeaderProps {
|
||||
const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
application, activeTab, handleChangeTab, refresh,
|
||||
workflowRef,
|
||||
appRef,
|
||||
features,
|
||||
onFeaturesChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const { id, source } = useParams();
|
||||
const applicationModalRef = useRef<ApplicationModalRef>(null);
|
||||
const copyModalRef = useRef<CopyModalRef>(null);
|
||||
|
||||
/**
|
||||
* Format tab items for display
|
||||
*/
|
||||
const formatTabItems = () => {
|
||||
return tabKeys.map(key => ({
|
||||
const formatTabItems = useMemo(() => {
|
||||
return (source === 'sharing' ? sharingTabKeys : tabKeys).map(key => ({
|
||||
key,
|
||||
label: t(`application.${key}`),
|
||||
}))
|
||||
}
|
||||
}, [source, sharingTabKeys, tabKeys])
|
||||
/**
|
||||
* Handle menu item click
|
||||
*/
|
||||
const handleClick: MenuProps['onClick'] = ({ key }) => {
|
||||
if (!application) return
|
||||
switch (key) {
|
||||
case 'edit':
|
||||
applicationModalRef.current?.handleOpen(application as Application)
|
||||
applicationModalRef.current?.handleOpen(application)
|
||||
break;
|
||||
case 'copy':
|
||||
copyModalRef.current?.handleOpen()
|
||||
appRef?.current?.handleSave(false)
|
||||
.then(() => {
|
||||
copyModalRef.current?.handleOpen()
|
||||
})
|
||||
break;
|
||||
case 'export':
|
||||
exportToYaml(workflowRef?.current?.config, application?.name ? `${application?.name}.yml` : undefined)
|
||||
appRef?.current?.handleSave(false)
|
||||
.then(() => {
|
||||
appExport(application.id, application.name)
|
||||
})
|
||||
break;
|
||||
case 'delete':
|
||||
handleDelete()
|
||||
@@ -151,7 +170,7 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
* Format dropdown menu items
|
||||
*/
|
||||
const formatMenuItems = useMemo(() => {
|
||||
const items = (application?.type === 'workflow' ? ['edit', 'copy', 'export', 'delete'] : ['edit', 'copy', 'delete']).map(key => ({
|
||||
const items = (application?.type !== 'multi_agent' ? ['edit', 'copy', 'export', 'delete'] : ['edit', 'copy', 'delete']).map(key => ({
|
||||
key,
|
||||
icon: <img src={menuIcons[key]} className="rb:w-4 rb:h-4 rb:mr-2" />,
|
||||
label: t(`common.${key}`),
|
||||
@@ -159,7 +178,11 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
return items
|
||||
}, [t, handleClick, application])
|
||||
|
||||
console.log('formatMenuItems', formatMenuItems)
|
||||
const handleSaveFeaturesConfig = useCallback((value: FeaturesConfigForm) => {
|
||||
appRef?.current?.handleSaveFeaturesConfig?.(value)
|
||||
onFeaturesChange?.(value)
|
||||
}, [appRef, onFeaturesChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
@@ -170,8 +193,8 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
'rb:bg-[#171719]': application?.type === 'workflow',
|
||||
})}
|
||||
title={application?.name || ''}
|
||||
operation={<Dropdown
|
||||
menu={{ items: formatMenuItems, onClick: handleClick }}
|
||||
operation={source !== 'sharing' && <Dropdown
|
||||
menu={{ items: formatMenuItems, onClick: handleClick }}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
>
|
||||
@@ -182,13 +205,14 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
|
||||
centerContent={<Flex justify="center" className="rb:h-16!">
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
items={formatTabItems()}
|
||||
items={formatTabItems}
|
||||
onChange={handleChangeTab}
|
||||
className={styles.tabs}
|
||||
/>
|
||||
</Flex>}
|
||||
extra={application?.type === 'workflow'
|
||||
extra={application?.type === 'workflow' && source !== 'sharing' && activeTab === 'arrangement'
|
||||
? <Flex align="center" justify="end" gap={10} className="rb:h-8">
|
||||
<FeaturesConfig source={application?.type} value={features as FeaturesConfigForm} refresh={handleSaveFeaturesConfig} />
|
||||
<Button onClick={clear}>{t('workflow.clear')}</Button>
|
||||
<Button onClick={addvariable}>{t('workflow.addvariable')}</Button>
|
||||
<Button onClick={run}>{t('workflow.run')}</Button>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 15:38:14
|
||||
*/
|
||||
/**
|
||||
* Copy Application Modal
|
||||
* Allows users to duplicate an existing application with a new name
|
||||
*/
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||
import { Form, Button, Flex } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx'
|
||||
|
||||
import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
|
||||
import FileUploadSettingModal from './FileUploadSettingModal'
|
||||
import type { Application } from '@/views/ApplicationManagement/types';
|
||||
|
||||
interface FeaturesConfigModalProps {
|
||||
refresh: (value: FeaturesConfigForm) => void;
|
||||
source?: Application['type'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for copying applications
|
||||
*/
|
||||
const FeaturesConfigModal = forwardRef<FeaturesConfigModalRef, FeaturesConfigModalProps>(({
|
||||
refresh,
|
||||
source,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<FeaturesConfigForm>();
|
||||
const values = Form.useWatch([], form)
|
||||
const fileUploadSettingModalRef = useRef<any>(null)
|
||||
|
||||
/** Close modal and reset form */
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
/** Open modal */
|
||||
const handleOpen = (initValue: FeaturesConfigForm) => {
|
||||
setVisible(true);
|
||||
console.log('initValue', initValue)
|
||||
form.setFieldsValue(initValue)
|
||||
};
|
||||
/** Copy application with new name */
|
||||
const handleSave = () => {
|
||||
setVisible(false);
|
||||
refresh(form.getFieldsValue())
|
||||
}
|
||||
|
||||
const handleOpenSettings = () => {
|
||||
fileUploadSettingModalRef.current?.handleOpen(values?.file_upload)
|
||||
}
|
||||
|
||||
const handleSaveSettings = (settings: FeaturesConfigForm['file_upload']) => {
|
||||
form.setFieldValue('file_upload', { ...settings, enabled: values?.file_upload?.enabled ?? false })
|
||||
}
|
||||
|
||||
/** Expose methods to parent component */
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<RbModal
|
||||
title={t('application.features')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('common.confirm')}
|
||||
onOk={handleSave}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
{source !== 'workflow' && <>
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t(`memoryConversation.web_search`)}
|
||||
name={['web_search', "enabled"]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t('application.text_to_speech')}
|
||||
name={['text_to_speech', "enabled"]}
|
||||
desc={t('application.text_to_speech_desc')}
|
||||
/>
|
||||
</div>
|
||||
</>}
|
||||
|
||||
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
|
||||
<SwitchFormItem
|
||||
title={t('application.file_upload')}
|
||||
name={['file_upload', "enabled"]}
|
||||
desc={values?.file_upload?.enabled ? undefined : t('application.file_upload_desc')}
|
||||
/>
|
||||
{values?.file_upload?.enabled && (() => {
|
||||
const fu = values.file_upload
|
||||
const types = [
|
||||
{ type: 'image', enabled: fu.image_enabled, maxSize: fu.image_max_size_mb },
|
||||
{ type: 'audio', enabled: fu.audio_enabled, maxSize: fu.audio_max_size_mb },
|
||||
{ type: 'document', enabled: fu.document_enabled, maxSize: fu.document_max_size_mb },
|
||||
{ type: 'video', enabled: fu.video_enabled, maxSize: fu.video_max_size_mb },
|
||||
].filter(item => item.enabled)
|
||||
return types.length > 0 ? <>
|
||||
<Flex gap={12} className="rb:py-2!">
|
||||
<div className="rb:flex-1 rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:bg-white rb:text-[12px]">
|
||||
<div className="rb:grid rb:grid-cols-2 rb:gap-2 rb:text-[12px] rb:text-[#5B6167] rb:border-b rb:border-b-[#DFE4ED]">
|
||||
<div className="rb:px-3 rb:py-1">{t(`application.supportedTypes`)}</div>
|
||||
<div className="rb:px-3 rb:py-1">{t('application.singleMaxSize')}</div>
|
||||
</div>
|
||||
{types.map((item, index) => (
|
||||
<div key={item.type} className={clsx('rb:grid rb:grid-cols-2 rb:gap-2', {
|
||||
'rb:border-b rb:border-b-[#DFE4ED]': index !== types.length - 1
|
||||
})}>
|
||||
<div className="rb:px-3 rb:py-1">{t(`application.${item.type}`)}</div>
|
||||
<div className="rb:px-3 rb:py-1">{item.maxSize} MB</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:py-1">{t('application.maxCount')}</div>
|
||||
{fu.max_file_count} {t('application.unix')}
|
||||
</div>
|
||||
</Flex>
|
||||
<Button block onClick={handleOpenSettings}>{t('application.setting')}</Button>
|
||||
</> : <Button block onClick={handleOpenSettings}>{t('application.setting')}</Button>
|
||||
})()}
|
||||
<Form.Item name="file_upload" hidden />
|
||||
</div>
|
||||
</Flex>
|
||||
</Form>
|
||||
</RbModal>
|
||||
|
||||
<FileUploadSettingModal
|
||||
ref={fileUploadSettingModalRef}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default FeaturesConfigModal;
|
||||
@@ -0,0 +1,222 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-05
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 15:18:20
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, InputNumber, Flex, Switch, Row, Col, Radio } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import RbModal from '@/components/RbModal';
|
||||
import type { FeaturesConfigForm } from '../../types'
|
||||
|
||||
type FileUpload = Omit<FeaturesConfigForm['file_upload'], 'settings'>
|
||||
|
||||
interface FileUploadSettingModalRef {
|
||||
handleOpen: (values?: FileUpload) => void;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
interface FileUploadSettingModalProps {
|
||||
onSave: (values: FileUpload) => void;
|
||||
}
|
||||
|
||||
const fileTypeOptions = [
|
||||
{
|
||||
type: 'document',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/txt.svg')]"></div>,
|
||||
formats: [
|
||||
"pdf",
|
||||
"docx",
|
||||
"doc",
|
||||
"xlsx",
|
||||
"xls",
|
||||
"txt",
|
||||
"csv",
|
||||
"json",
|
||||
"md",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/image.svg')]"></div>,
|
||||
formats: [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg"
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'audio',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/audio.svg')]"></div>,
|
||||
formats: [
|
||||
"mp3",
|
||||
"wav",
|
||||
"m4a",
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'video',
|
||||
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/video.svg')]"></div>,
|
||||
formats: [
|
||||
"mp4",
|
||||
"mov",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultValues: FileUpload = {
|
||||
enabled: false,
|
||||
image_enabled: false,
|
||||
image_max_size_mb: 20,
|
||||
image_allowed_extensions: [
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg"
|
||||
],
|
||||
audio_enabled: false,
|
||||
audio_max_size_mb: 50,
|
||||
audio_allowed_extensions: [
|
||||
"mp3",
|
||||
"wav",
|
||||
"m4a",
|
||||
"ogg",
|
||||
"flac"
|
||||
],
|
||||
document_enabled: false,
|
||||
document_max_size_mb: 100,
|
||||
document_allowed_extensions: [
|
||||
"pdf",
|
||||
"docx",
|
||||
"xlsx",
|
||||
"txt",
|
||||
"csv",
|
||||
"json"
|
||||
],
|
||||
video_enabled: false,
|
||||
video_max_size_mb: 100,
|
||||
video_allowed_extensions: [
|
||||
"mp4",
|
||||
"mov",
|
||||
"avi",
|
||||
"webm"
|
||||
],
|
||||
max_file_count: 5,
|
||||
allowed_transfer_methods: 'both'
|
||||
}
|
||||
|
||||
const FileUploadSettingModal = forwardRef<FileUploadSettingModalRef, FileUploadSettingModalProps>(({
|
||||
onSave,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm<FileUpload>();
|
||||
const values = Form.useWatch([], form)
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleOpen = (values?: FileUpload) => {
|
||||
setVisible(true);
|
||||
if (values) {
|
||||
const methods = values.allowed_transfer_methods || ['local_file', 'remote_url']
|
||||
const transferMethod = Array.isArray(methods)
|
||||
? methods.length === 2 ? 'both' : methods[0]
|
||||
: methods
|
||||
form.setFieldsValue({ ...values, allowed_transfer_methods: transferMethod as any })
|
||||
} else {
|
||||
form.setFieldsValue(defaultValues)
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const vals = await form.validateFields();
|
||||
const methodMap: Record<string, string[]> = {
|
||||
local_file: ['local_file'],
|
||||
remote_url: ['remote_url'],
|
||||
both: ['local_file', 'remote_url'],
|
||||
}
|
||||
onSave({ ...vals, allowed_transfer_methods: methodMap[vals.allowed_transfer_methods as unknown as string] ?? [] });
|
||||
handleClose();
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.settings')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
onOk={handleSave}
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={defaultValues}>
|
||||
<Form.Item
|
||||
label={t('application.uploadType')}
|
||||
name="allowed_transfer_methods"
|
||||
>
|
||||
<Radio.Group block buttonStyle="solid">
|
||||
<Radio.Button value="local_file">{t('application.local')}</Radio.Button>
|
||||
<Radio.Button value="remote_url">URL</Radio.Button>
|
||||
<Radio.Button value="both">{t('application.both')}</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mb-1">{t('application.maxCount')}</div>
|
||||
<Form.Item label={t('application.maxCount')} name="max_file_count">
|
||||
<InputNumber min={1} max={20} precision={0} className="rb:w-full!" placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('application.supportedTypes')}>
|
||||
<Flex vertical gap={12}>
|
||||
{fileTypeOptions.map((option) => {
|
||||
const enabledKey = `${option.type}_enabled` as keyof FileUpload
|
||||
const sizeKey = `${option.type}_max_size_mb` as keyof FileUpload
|
||||
const isEnabled = values?.[enabledKey]
|
||||
return (
|
||||
<div
|
||||
key={option.type}
|
||||
className={clsx('rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:p-3', {
|
||||
'rb:bg-[#f5f7fc]': isEnabled
|
||||
})}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col flex="36px" className="rb:self-center">{option.icon}</Col>
|
||||
<Col flex="1">
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex vertical>
|
||||
<div className="rb:font-medium">{t(`application.${option.type}`)}</div>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167]">{option.formats.map(item => item.toUpperCase()).join(', ')}</div>
|
||||
</Flex>
|
||||
<Form.Item name={enabledKey} valuePropName="checked" noStyle>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Col>
|
||||
</Row>
|
||||
{isEnabled && (
|
||||
<Flex align="center" gap={12} className="rb:mt-3! rb:pt-3! rb:border-t rb:border-[#DFE4ED]">
|
||||
<div>{t('application.singleMaxSize')}: </div>
|
||||
<Form.Item name={sizeKey} noStyle>
|
||||
<InputNumber min={1} max={100} suffix="MB" className="rb:flex-1" />
|
||||
</Form.Item>
|
||||
<Form.Item name={`${option.type}_allowed_extensions`} hidden />
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default FileUploadSettingModal;
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-13 17:20:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 15:38:59
|
||||
*/
|
||||
import { type FC, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from 'antd';
|
||||
|
||||
import FeaturesConfigModal from './FeaturesConfigModal'
|
||||
import type { FeaturesConfigModalRef, FeaturesConfigForm } from '../../types'
|
||||
import type { Application } from '@/views/ApplicationManagement/types';
|
||||
|
||||
/** Props for the FeaturesConfig component */
|
||||
interface FeaturesConfigProps {
|
||||
/** Current feature configuration values */
|
||||
value: FeaturesConfigForm;
|
||||
/** Callback to propagate updated config back to the parent */
|
||||
refresh: (value: FeaturesConfigForm) => void;
|
||||
source?: Application['type'];
|
||||
}
|
||||
|
||||
const FeaturesConfig: FC<FeaturesConfigProps> = ({
|
||||
value,
|
||||
refresh,
|
||||
source
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
// Ref used to imperatively open the config modal
|
||||
const funConfigModalRef = useRef<FeaturesConfigModalRef>(null)
|
||||
|
||||
/** Open the feature config modal pre-populated with the current values */
|
||||
const handleFeaturesConfig = () => {
|
||||
console.log('handleFeaturesConfig', value)
|
||||
funConfigModalRef.current?.handleOpen(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Button that triggers the feature configuration modal */}
|
||||
<Button onClick={handleFeaturesConfig}>{t('application.features')}</Button>
|
||||
|
||||
{/* Modal for editing feature settings; calls refresh on save */}
|
||||
<FeaturesConfigModal
|
||||
ref={funConfigModalRef}
|
||||
refresh={refresh}
|
||||
source={source}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeaturesConfig
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:26:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 10:15:39
|
||||
* @Last Modified time: 2026-03-19 21:22:53
|
||||
*/
|
||||
/**
|
||||
* Tool List Component
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import Empty from '@/components/Empty'
|
||||
import ToolModal from './ToolModal'
|
||||
import { getToolMethods, getToolDetail } from '@/api/tools'
|
||||
import Tag from '@/components/Tag'
|
||||
|
||||
/**
|
||||
* Tool list management component
|
||||
@@ -47,18 +48,19 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
const mcpFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
|
||||
return {
|
||||
...item,
|
||||
is_active: (toolDetail as any).is_active,
|
||||
label: mcpFilterItem?.description,
|
||||
method_id: mcpFilterItem?.method_id,
|
||||
value: mcpFilterItem?.name,
|
||||
description: mcpFilterItem?.description,
|
||||
parameters: mcpFilterItem?.parameters
|
||||
}
|
||||
break
|
||||
case 'builtin':
|
||||
if ((methods as any[]).length > 1) {
|
||||
const builtinFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
|
||||
return {
|
||||
...item,
|
||||
is_active: (toolDetail as any).is_active,
|
||||
label: builtinFilterItem?.description,
|
||||
method_id: builtinFilterItem?.method_id,
|
||||
value: builtinFilterItem?.name,
|
||||
@@ -68,17 +70,18 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
is_active: (toolDetail as any).is_active,
|
||||
label: (methods as any[])[0]?.description,
|
||||
method_id: (methods as any[])[0]?.method_id,
|
||||
value: (methods as any[])[0]?.name,
|
||||
description: (methods as any[])[0]?.description,
|
||||
parameters: (methods as any[])[0]?.parameters
|
||||
}
|
||||
break
|
||||
default:
|
||||
const customFilterItem = (methods as any[]).find(vo => vo.method_id === item.operation)
|
||||
return {
|
||||
...item,
|
||||
is_active: (toolDetail as any).is_active,
|
||||
label: customFilterItem?.name,
|
||||
method_id: customFilterItem?.method_id,
|
||||
value: customFilterItem?.name,
|
||||
@@ -103,7 +106,10 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
}
|
||||
/** Add new tool to list */
|
||||
const updateTools = (tool: ToolOption) => {
|
||||
const list = [...toolList, tool]
|
||||
const list = [...toolList, {
|
||||
...tool,
|
||||
is_active: true,
|
||||
}]
|
||||
setToolList(list)
|
||||
onChange && onChange(list)
|
||||
}
|
||||
@@ -142,8 +148,13 @@ const ToolList: FC<{ value?: ToolOption[]; onChange?: (config: ToolOption[]) =>
|
||||
: <Flex vertical gap={12}>
|
||||
{toolList.map((item, index) => (
|
||||
<Flex key={index} align="center" justify="space-between" className="rb:py-2.5! rb:pl-4! rb:pr-3! rb-border rb:rounded-lg">
|
||||
<div className="rb:font-medium rb:leading-4">
|
||||
{item.label}
|
||||
<div>
|
||||
<div className="rb:font-medium rb:leading-4">
|
||||
{item.label}
|
||||
</div>
|
||||
<Tag color={item.is_active ? 'success' : 'error'} className="rb:mt-1">
|
||||
{item.is_active ? t('common.enable') : t('common.deleted')}
|
||||
</Tag>
|
||||
</div>
|
||||
<Space size={12}>
|
||||
<Switch size="small" checked={item.enabled} onChange={() => handleChangeEnabled(index)} />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:26:10
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:26:10
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-17 15:50:48
|
||||
*/
|
||||
/**
|
||||
* Type definitions for tool configuration in application settings
|
||||
@@ -32,6 +32,7 @@ export interface ToolOption {
|
||||
tool_id?: string;
|
||||
/** Whether tool is enabled */
|
||||
enabled?: boolean;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,21 +2,23 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:37
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 18:57:36
|
||||
* @Last Modified time: 2026-03-19 21:09:32
|
||||
*/
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import ConfigHeader from './components/ConfigHeader'
|
||||
import type { AgentRef, ClusterRef, WorkflowRef } from './types'
|
||||
import type { AgentRef, ClusterRef, WorkflowRef, Config } from './types'
|
||||
import type { Application } from '@/views/ApplicationManagement/types'
|
||||
import Agent from './Agent'
|
||||
import Api from './Api'
|
||||
import ReleasePage from './ReleasePage'
|
||||
import Cluster from './Cluster'
|
||||
import { getApplication } from '@/api/application'
|
||||
import { getApplication, getApplicationConfig, getMultiAgentConfig, getWorkflowConfig } from '@/api/application'
|
||||
import Workflow from '@/views/Workflow';
|
||||
import Statistics from './Statistics'
|
||||
import TestChat from './TestChat'
|
||||
import type { WorkflowConfig } from '@/views/Workflow/types';
|
||||
|
||||
/**
|
||||
* Application configuration page component
|
||||
@@ -25,7 +27,7 @@ import Statistics from './Statistics'
|
||||
*/
|
||||
const ApplicationConfig: React.FC = () => {
|
||||
// Hooks
|
||||
const { id } = useParams();
|
||||
const { id, source } = useParams();
|
||||
|
||||
// Refs for different application types
|
||||
const agentRef = useRef<AgentRef>(null)
|
||||
@@ -35,6 +37,32 @@ const ApplicationConfig: React.FC = () => {
|
||||
// State
|
||||
const [application, setApplication] = useState<Application | null>(null);
|
||||
const [activeTab, setActiveTab] = useState('arrangement');
|
||||
const [features, setFeatures] = useState<import('./types').FeaturesConfigForm | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab(source === 'sharing' ? 'test' : 'arrangement')
|
||||
}, [source])
|
||||
|
||||
const [config, setConfig] = useState<Config | WorkflowConfig | null>(null)
|
||||
useEffect(() => {
|
||||
if (source === 'sharing' && application?.type) {
|
||||
getAppConfig()
|
||||
}
|
||||
}, [source, application?.type])
|
||||
|
||||
const getAppConfig = () => {
|
||||
if (!id || !source || !application?.type) {
|
||||
return
|
||||
}
|
||||
const request = application?.type === 'agent'
|
||||
? getApplicationConfig
|
||||
: application?.type === 'multi_agent'
|
||||
? getMultiAgentConfig
|
||||
: getWorkflowConfig
|
||||
request(id as string).then(res => {
|
||||
setConfig(res as Config | WorkflowConfig | null)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tab change with auto-save for arrangement tab
|
||||
@@ -87,14 +115,17 @@ const ApplicationConfig: React.FC = () => {
|
||||
refresh={getApplicationInfo}
|
||||
appRef={application?.type === 'agent' ? agentRef : application?.type === 'multi_agent' ? clusterRef : application?.type === 'workflow' ? workflowRef : undefined}
|
||||
workflowRef={workflowRef}
|
||||
features={features}
|
||||
onFeaturesChange={setFeatures}
|
||||
/>
|
||||
<div className="rb:p-3 rb:max-h-[calc(100vh-65px)] rb:overflow-auto">
|
||||
{activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster ref={clusterRef} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'workflow' && <Workflow ref={workflowRef} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'agent' && <Agent ref={agentRef} onFeaturesLoad={setFeatures} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'multi_agent' && <Cluster ref={clusterRef} onFeaturesLoad={setFeatures} />}
|
||||
{activeTab === 'arrangement' && application?.type === 'workflow' && <Workflow ref={workflowRef} onFeaturesLoad={setFeatures} />}
|
||||
{activeTab === 'api' && <Api application={application} />}
|
||||
{activeTab === 'release' && <ReleasePage data={application as Application} refresh={getApplicationInfo} />}
|
||||
{activeTab === 'statistics' && <Statistics application={application} />}
|
||||
{activeTab === 'test' && <TestChat application={application} config={config} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:49
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 18:55:57
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 21:10:53
|
||||
*/
|
||||
import type { KnowledgeConfig } from './components/Knowledge/types'
|
||||
import type { Variable } from './components/VariableList/types'
|
||||
@@ -77,6 +77,8 @@ export interface Config extends MultiAgentConfig {
|
||||
/** Last update timestamp */
|
||||
updated_at: number;
|
||||
skills?: SkillConfigForm | null;
|
||||
|
||||
features?: FeaturesConfigForm;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -127,6 +129,8 @@ export interface AgentRef {
|
||||
* @param flag - Whether to show success message
|
||||
*/
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
features: Config['features'];
|
||||
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,6 +142,8 @@ export interface ClusterRef {
|
||||
* @param flag - Whether to show success message
|
||||
*/
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
features: Config['features'];
|
||||
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,6 +162,8 @@ export interface WorkflowRef {
|
||||
/** Add variable */
|
||||
addVariable: () => void;
|
||||
config: WorkflowConfig | null;
|
||||
features: WorkflowConfig['features'];
|
||||
handleSaveFeaturesConfig?: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -402,4 +410,72 @@ export interface StatisticsData {
|
||||
total_api_calls: number;
|
||||
/** Total tokens used */
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
export interface FileTypeConfig {
|
||||
type: string;
|
||||
enabled: boolean;
|
||||
maxCount: number;
|
||||
maxSize: number;
|
||||
}
|
||||
interface FileSetttings {
|
||||
image_enabled: boolean;
|
||||
image_max_size_mb: number;
|
||||
image_allowed_extensions: string[];
|
||||
audio_enabled: boolean;
|
||||
audio_max_size_mb: number;
|
||||
audio_allowed_extensions: string[];
|
||||
document_enabled: boolean;
|
||||
document_max_size_mb: number;
|
||||
document_allowed_extensions: string[];
|
||||
video_enabled: boolean;
|
||||
video_max_size_mb: number;
|
||||
video_allowed_extensions: string[];
|
||||
max_file_count: number;
|
||||
allowed_transfer_methods: string[] | string;
|
||||
}
|
||||
export type FeaturesConfigForm = {
|
||||
file_upload: FileSetttings & {
|
||||
enabled: boolean;
|
||||
settings?: FileSetttings
|
||||
};
|
||||
opening_statement: {
|
||||
enabled: boolean;
|
||||
statement: string | null;
|
||||
suggested_questions: string[];
|
||||
};
|
||||
suggested_questions_after_answer: {
|
||||
enabled: boolean;
|
||||
};
|
||||
text_to_speech: {
|
||||
enabled: boolean;
|
||||
voice: string | null;
|
||||
language: string | null;
|
||||
autoplay: boolean;
|
||||
};
|
||||
citation: {
|
||||
enabled: boolean;
|
||||
};
|
||||
web_search: {
|
||||
enabled: boolean;
|
||||
search_engine: string | null;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Function config modal ref methods
|
||||
*/
|
||||
export interface FeaturesConfigModalRef {
|
||||
/** Open function config modal */
|
||||
handleOpen: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* App sharing modal ref methods
|
||||
*/
|
||||
export interface AppSharingModalRef {
|
||||
handleOpen: () => void;
|
||||
}
|
||||
export interface AppSharingForm {
|
||||
target_workspace_ids: string[];
|
||||
permission: 'readonly' | 'editable'
|
||||
}
|
||||
168
web/src/views/ApplicationManagement/MySharing.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:12
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-18 16:15:43
|
||||
*/
|
||||
import React, { useState, useEffect, useMemo, type MouseEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, App, Flex, Row, Col, Collapse } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import type { MySharedOutItem } from './types';
|
||||
import { mySharedOutList, cancelShare, cancelSpaceShare } from '@/api/application'
|
||||
import BodyWrapper from '@/components/Empty/BodyWrapper'
|
||||
|
||||
const MySharing: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { modal } = App.useApp();
|
||||
const [data, setData] = useState<MySharedOutItem[]>([])
|
||||
|
||||
useEffect(() => { getList() }, [])
|
||||
|
||||
const getList = () => {
|
||||
mySharedOutList()
|
||||
.then(res => setData(res as MySharedOutItem[]))
|
||||
}
|
||||
|
||||
/** Group items by target_workspace_id */
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, { workspace: Pick<MySharedOutItem, 'target_workspace_id' | 'target_workspace_name' | 'target_workspace_icon'>, items: MySharedOutItem[] }>();
|
||||
data.forEach(item => {
|
||||
if (!map.has(item.target_workspace_id)) {
|
||||
map.set(item.target_workspace_id, {
|
||||
workspace: {
|
||||
target_workspace_id: item.target_workspace_id,
|
||||
target_workspace_name: item.target_workspace_name,
|
||||
target_workspace_icon: item.target_workspace_icon,
|
||||
},
|
||||
items: [],
|
||||
});
|
||||
}
|
||||
map.get(item.target_workspace_id)!.items.push(item);
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}, [data]);
|
||||
|
||||
const handleAllCancel = (workspace: { target_workspace_name: string; target_workspace_id: string; }) => {
|
||||
modal.confirm({
|
||||
title: t('application.confirmWorkspaceCancelShareDesc', { workspace: workspace.target_workspace_name }),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
cancelSpaceShare(workspace.target_workspace_id)
|
||||
.then(() => {
|
||||
getList();
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCancelOne = (item: MySharedOutItem, e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
modal.confirm({
|
||||
title: t('application.confirmAppCancelShareDesc', { app: item.source_app_name, workspace: item.target_workspace_name }),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
okType: 'danger',
|
||||
onOk: () => {
|
||||
cancelShare(item.source_app_id, item.target_workspace_id)
|
||||
.then(() => {
|
||||
getList();
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
/** Navigate to application configuration page */
|
||||
const handleEdit = (item: MySharedOutItem) => {
|
||||
let url = `/#/application/config/${item.source_app_id}`
|
||||
window.open(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex vertical gap={12} className="rb:h-[calc(100vh-148px)]! rb:overflow-y-auto!">
|
||||
<BodyWrapper loading={false} empty={data.length === 0}>
|
||||
{grouped.map(({ workspace, items }) => (
|
||||
<Collapse
|
||||
key={workspace.target_workspace_id}
|
||||
defaultActiveKey={[workspace.target_workspace_id]}
|
||||
items={[{
|
||||
key: workspace.target_workspace_id,
|
||||
label: (
|
||||
<Flex align="center" gap={12}>
|
||||
{workspace.target_workspace_icon
|
||||
? <img src={workspace.target_workspace_icon} className="rb:w-8 rb:h-8 rb:rounded-lg rb:object-cover" />
|
||||
: <div className="rb:w-8 rb:h-8 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||
{workspace.target_workspace_name[0]}
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
<span className="rb:font-medium">{workspace.target_workspace_name}</span>
|
||||
<div className="rb:text-[#5B6167] rb:text-[12px]">{t('application.appCount', { count: items.length })}</div>
|
||||
</div>
|
||||
</Flex>
|
||||
),
|
||||
extra: (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={e => { e.stopPropagation(); handleAllCancel(workspace); }}
|
||||
>
|
||||
{t('application.allCancel')}
|
||||
</Button>
|
||||
),
|
||||
children: (
|
||||
<Row gutter={[12, 12]}>
|
||||
{items.map(item => (
|
||||
<Col key={item.id} span={6} className="rb:bg-[#F6F6F6] rb:rounded-lg rb:py-3! rb:px-4! rb:relative rb:cursor-pointer" onClick={() => handleEdit(item)}>
|
||||
<div
|
||||
className="rb:absolute rb:top-3 rb:right-3 rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/close.svg')]"
|
||||
onClick={(e) => handleCancelOne(item, e)}
|
||||
/>
|
||||
<Flex gap={8} align="center">
|
||||
<div className="rb:size-7 rb:rounded-lg rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[14px] rb:text-white">
|
||||
{item.source_app_name[0]}
|
||||
</div>
|
||||
<div className="rb:font-medium">{item.source_app_name}</div>
|
||||
</Flex>
|
||||
<Flex vertical gap={4} className="rb:mt-3! rb:text-[12px]!">
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.type')}</span>
|
||||
<span className={clsx({
|
||||
'rb:text-[#155EEF] rb:font-medium': item.source_app_type === 'agent',
|
||||
'rb:text-[#369F21] rb:font-medium': item.source_app_type === 'multi_agent',
|
||||
})}>
|
||||
{t(`application.${item.source_app_type}`)}
|
||||
</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.version')}</span>
|
||||
<span>{item.source_app_version}</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.permission')}</span>
|
||||
<span className={clsx({
|
||||
'rb:text-[#369F21] rb:font-medium': item.permission === 'editable',
|
||||
'rb:text-[#5B6167] rb:font-medium': item.permission === 'readonly',
|
||||
})}>
|
||||
{t(`application.${item.permission}`)}
|
||||
</span>
|
||||
</Flex>
|
||||
<Flex gap={5} justify="space-between">
|
||||
<span className="rb:text-[#5B6167]">{t('application.souceStatus')}</span>
|
||||
<span>{item.source_app_is_active ? t('application.sourceActive') : t('application.sourceInactive')}</span>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
),
|
||||
}]}
|
||||
/>
|
||||
))}
|
||||
</BodyWrapper>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default MySharing;
|
||||
256
web/src/views/ApplicationManagement/components/UploadModal.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-28 14:08:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-12 17:19:46
|
||||
*/
|
||||
/**
|
||||
* UploadModal Component
|
||||
*
|
||||
* This component provides a modal for uploading workflow files with a multi-step process:
|
||||
* 1. Upload - Select platform and file
|
||||
* 2. Complex - Show warnings and errors if any
|
||||
* 3. SureInfo - Confirm and edit workflow information
|
||||
* 4. Completed - Show success message and options
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useMemo } from 'react';
|
||||
import { Form, Steps, Flex, Alert, Button, Result, message } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { Application, UploadModalRef } from '../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import UploadFiles from '@/components/Upload/UploadFiles'
|
||||
import { appImport } from '@/api/application'
|
||||
|
||||
/**
|
||||
* Props for UploadModal component
|
||||
*/
|
||||
interface UploadModalProps {
|
||||
/** Function to refresh the parent component after workflow import */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Steps definition for the upload process
|
||||
*/
|
||||
const steps = [
|
||||
'upload', // Step 1: File upload
|
||||
'complex', // Step 2: Error/warning display
|
||||
'completed' // Step 4: Success message
|
||||
]
|
||||
/**
|
||||
* UploadModal component
|
||||
*
|
||||
* @param {UploadModalProps} props - Component props
|
||||
* @param {React.Ref<UploadModalRef>} ref - Ref for imperative methods
|
||||
*/
|
||||
const UploadModal = forwardRef<UploadModalRef, UploadModalProps>(({
|
||||
refresh
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// State management
|
||||
const [visible, setVisible] = useState(false); // Modal visibility
|
||||
const [form] = Form.useForm<{ file: File[] }>(); // Form instance
|
||||
const [loading, setLoading] = useState(false); // Loading state
|
||||
const [current, setCurrent] = useState<number>(0); // Current step
|
||||
const [appId, setAppId] = useState<string | null>(null); // Imported application ID
|
||||
const [warnings, setWarnings] = useState<string[]>([])
|
||||
|
||||
/**
|
||||
* Handle modal close
|
||||
* Resets all states and form fields
|
||||
*/
|
||||
const handleClose = () => {
|
||||
refresh()
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setCurrent(0);
|
||||
setAppId(null);
|
||||
setLoading(false);
|
||||
setWarnings([])
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle modal open
|
||||
* Resets form fields and shows modal
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
form.resetFields();
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle save/submit action
|
||||
* Processes different logic based on current step
|
||||
*/
|
||||
const handleSave = () => {
|
||||
const values = form.getFieldsValue();
|
||||
|
||||
switch(current) {
|
||||
case 0: // Step 1: Upload file
|
||||
if (!values.file || values.file.length === 0) {
|
||||
message.warning(t('application.pleaseUploadFile'));
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append('file', values.file[0]);
|
||||
|
||||
setLoading(true)
|
||||
// Call import API
|
||||
appImport(formData)
|
||||
.then(res => {
|
||||
const { warnings, app } = res as { warnings: string[]; app: Application };
|
||||
|
||||
setAppId(app?.id)
|
||||
if (warnings.length) {
|
||||
setCurrent(1)
|
||||
setWarnings(warnings)
|
||||
} else {
|
||||
setCurrent(2)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
break;
|
||||
case 2:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Expose methods to parent component via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
/**
|
||||
* Handle navigation after successful import
|
||||
* @param {string} type - Navigation type ('detail' or 'list')
|
||||
*/
|
||||
const handleJump = (type: string) => {
|
||||
handleClose();
|
||||
refresh();
|
||||
setTimeout(() => {
|
||||
switch (type) {
|
||||
case 'detail':
|
||||
// Open application detail page in new tab
|
||||
window.open(`/#/application/config/${appId}`, '_blank');
|
||||
break;
|
||||
}
|
||||
}, 100)
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate modal footer based on current step
|
||||
*/
|
||||
const getFooter = useMemo(() => {
|
||||
switch (current) {
|
||||
case 0: // Step 1: Upload
|
||||
return [
|
||||
<Button key="back" onClick={handleClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="confirm"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
];
|
||||
case 1:
|
||||
return [
|
||||
<Button key="back" onClick={() => handleJump('list')}>
|
||||
{t('application.gotoList')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => handleJump('detail')}
|
||||
>
|
||||
{t('application.gotoDetail')}
|
||||
</Button>
|
||||
]
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [current, loading]);
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.import')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('common.confirm')}
|
||||
onOk={handleSave}
|
||||
footer={getFooter}
|
||||
>
|
||||
{/* Steps indicator */}
|
||||
<div className='rb:p-3 rb:bg-[#FBFDFF] rb:rounded-lg rb:border rb:border-[#DFE4ED] rb:mb-3'>
|
||||
<Steps
|
||||
labelPlacement="vertical"
|
||||
size="small"
|
||||
current={current}
|
||||
items={steps.map(key => ({ title: t(`application.${key}`) }))}
|
||||
/>
|
||||
</div>
|
||||
{current === 0 &&
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
<Form.Item
|
||||
name="file"
|
||||
valuePropName="fileList"
|
||||
noStyle
|
||||
>
|
||||
<UploadFiles
|
||||
isAutoUpload={false}
|
||||
isCanDrag={true}
|
||||
fileSize={100}
|
||||
maxCount={1}
|
||||
fileType={['yml']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
}
|
||||
{/* Step 2: Error/warning display */}
|
||||
{current === 1 &&
|
||||
<Flex vertical gap={12}>
|
||||
{warnings.map((vo, index) => (
|
||||
<Alert
|
||||
key={index}
|
||||
message={<div>{vo}</div>}
|
||||
type="warning"
|
||||
showIcon
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
}
|
||||
{current === 2 &&
|
||||
<Result
|
||||
status="success"
|
||||
title={t('application.importSuccess')}
|
||||
subTitle={t('application.importSuccessDesc')}
|
||||
extra={[
|
||||
<Button key="back" onClick={() => handleJump('list')}>
|
||||
{t('application.gotoList')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => handleJump('detail')}
|
||||
>
|
||||
{t('application.gotoDetail')}
|
||||
</Button>
|
||||
]}
|
||||
/>
|
||||
}
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default UploadModal;
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-28 14:08:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-06 12:05:46
|
||||
* @Last Modified time: 2026-03-12 17:19:33
|
||||
*/
|
||||
/**
|
||||
* UploadWorkflowModal Component
|
||||
@@ -72,6 +72,7 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
setFirstFormData(null);
|
||||
setAppId(null);
|
||||
setLoading(false);
|
||||
refresh()
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:12
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 10:44:29
|
||||
* @Last Modified time: 2026-03-19 21:29:45
|
||||
*/
|
||||
/**
|
||||
* Application Management Page
|
||||
@@ -10,23 +10,28 @@
|
||||
* Supports creating, editing, and deleting applications
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { App, Select, Space, Form, Flex, Dropdown, Button } from 'antd';
|
||||
import { Button, App, Select, Space, Dropdown, type SegmentedProps, Flex, Form } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
import ApplicationModal, { types } from './components/ApplicationModal';
|
||||
import type { Application, ApplicationModalRef, Query, UploadWorkflowModalRef } from './types';
|
||||
import SearchInput from '@/components/SearchInput'
|
||||
import { getApplicationListUrl, deleteApplication } from '@/api/application'
|
||||
import { getApplicationListUrl, deleteApplication, copyApplication } from '@/api/application'
|
||||
import PageScrollList, { type PageScrollListRef } from '@/components/PageScrollList'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import UploadWorkflowModal from './components/UploadWorkflowModal'
|
||||
import UploadModal from './components/UploadModal'
|
||||
import PageTabs from '@/components/PageTabs'
|
||||
import MySharing from './MySharing'
|
||||
import RbCard from '@/components/RbCard'
|
||||
import RbButton from '@/components/RbButton'
|
||||
import RbDescriptions from '@/components/RbDescriptions'
|
||||
|
||||
|
||||
const tabKeys = ['apps', 'sharing', 'myShare']
|
||||
/**
|
||||
* Application management main component
|
||||
*/
|
||||
@@ -37,16 +42,16 @@ const ApplicationManagement: React.FC = () => {
|
||||
const applicationModalRef = useRef<ApplicationModalRef>(null);
|
||||
const scrollListRef = useRef<PageScrollListRef>(null)
|
||||
const uploadWorkflowModalRef = useRef<UploadWorkflowModalRef>(null);
|
||||
|
||||
const [form] = Form.useForm()
|
||||
const uploadModalRef = useRef<UploadWorkflowModalRef>(null);
|
||||
const [form] = Form.useForm<Query>()
|
||||
const query = Form.useWatch([], form)
|
||||
const [activeTab, setActiveTab] = useState('apps');
|
||||
|
||||
useEffect(() => {
|
||||
// Convert URLSearchParams to a plain object for easier access
|
||||
const data = Object.fromEntries(searchParams)
|
||||
const { type } = data
|
||||
|
||||
form.setFieldValue('type', type || null)
|
||||
form.setFieldValue('type', type || undefined)
|
||||
}, [searchParams])
|
||||
|
||||
/** Refresh application list */
|
||||
@@ -60,7 +65,11 @@ const ApplicationManagement: React.FC = () => {
|
||||
}
|
||||
/** Navigate to application configuration page */
|
||||
const handleEdit = (item: Application) => {
|
||||
window.open(`/#/application/config/${item.id}`);
|
||||
let url = `/#/application/config/${item.id}`
|
||||
if (item.is_shared) {
|
||||
url += `/${activeTab}`
|
||||
}
|
||||
window.open(url);
|
||||
}
|
||||
/** Delete application with confirmation */
|
||||
const handleDelete = (item: Application) => {
|
||||
@@ -89,92 +98,139 @@ const ApplicationManagement: React.FC = () => {
|
||||
case 'thirdParty':
|
||||
handleImport()
|
||||
break;
|
||||
case 'import':
|
||||
uploadModalRef.current?.handleOpen()
|
||||
}
|
||||
}
|
||||
const formatTabItems = useMemo(() => {
|
||||
return tabKeys.map(value => ({
|
||||
value,
|
||||
label: t(`application.${value}`),
|
||||
}))
|
||||
}, [tabKeys, t])
|
||||
/** Handle tab change */
|
||||
const handleChangeTab = (value: SegmentedProps['value']) => {
|
||||
setActiveTab(value as string);
|
||||
form.resetFields()
|
||||
}
|
||||
const handleCopy = (item: Application) => {
|
||||
modal.confirm({
|
||||
title: t('application.confirmCopyDesc', { app: item.name }),
|
||||
okText: t('common.copy'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: () => {
|
||||
copyApplication(item.id)
|
||||
.then(() => {
|
||||
setActiveTab('apps')
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Form form={form} className="rb:mb-4!">
|
||||
<Flex justify="space-between">
|
||||
<Space size={10}>
|
||||
<Form.Item name="type" noStyle>
|
||||
<Select
|
||||
placeholder={t('application.applicationType')}
|
||||
options={[
|
||||
{ value: null, label: t('application.allType') },
|
||||
...types.map((type) => ({
|
||||
<Flex justify="space-between" className="rb:mb-4!">
|
||||
<PageTabs
|
||||
value={activeTab}
|
||||
options={formatTabItems}
|
||||
onChange={handleChangeTab}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
>
|
||||
{activeTab !== 'myShare' &&
|
||||
<Space size={8}>
|
||||
<Form.Item name="type" noStyle>
|
||||
<Select
|
||||
placeholder={t('application.applicationType')}
|
||||
options={(activeTab === 'sharing' ? types.filter(type => type !== 'multi_agent') : types).map((type) => ({
|
||||
value: type,
|
||||
label: t(`application.${type}`),
|
||||
}))
|
||||
]}
|
||||
className="rb:w-30!"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="search" noStyle>
|
||||
<SearchInput
|
||||
placeholder={t('application.searchPlaceholder')}
|
||||
className="rb:w-75!"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Space size={10}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'thirdParty', label: t('application.importWorkflow') },
|
||||
], onClick: handleClick
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button>
|
||||
{t('application.import')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<RbButton type="primary" icon={<div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/plus.svg')]"></div>} onClick={handleCreate}>
|
||||
{t('application.createApplication')}
|
||||
</RbButton>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Form>
|
||||
}))}
|
||||
allowClear
|
||||
className="rb:w-30!"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="search" noStyle>
|
||||
<SearchInput
|
||||
placeholder={t('application.searchPlaceholder')}
|
||||
className="rb:w-75!"
|
||||
/>
|
||||
</Form.Item>
|
||||
{activeTab === 'apps' && <Space size={10}>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'thirdParty', label: t('application.importWorkflow') },
|
||||
{ key: 'import', label: t('application.import') },
|
||||
], onClick: handleClick
|
||||
}}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button>
|
||||
{t('application.import')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<RbButton type="primary" icon={<div className="rb:size-3 rb:bg-cover rb:bg-[url('@/assets/images/common/plus.svg')]"></div>} onClick={handleCreate}>
|
||||
{t('application.createApplication')}
|
||||
</RbButton>
|
||||
</Space>}
|
||||
</Space>
|
||||
}
|
||||
</Form>
|
||||
</Flex>
|
||||
|
||||
<PageScrollList<Application, Query>
|
||||
ref={scrollListRef}
|
||||
url={getApplicationListUrl}
|
||||
query={query}
|
||||
renderItem={(item) => (
|
||||
<RbCard
|
||||
title={item.name}
|
||||
avatarText={item.name.trim()[0]}
|
||||
avatarClassName={clsx({
|
||||
'rb:bg-[#155EEF]': item.type === 'agent',
|
||||
'rb:bg-[#9C6FFF]!': item.type === 'multi_agent',
|
||||
'rb:bg-[#171719]': item.type === 'workflow',
|
||||
})}
|
||||
footer={<Flex justify="space-between" gap={12}>
|
||||
<RbButton danger className="rb:w-22.25" onClick={() => handleDelete(item)}>{t('common.delete')}</RbButton>
|
||||
<RbButton type="primary" ghost className="rb:flex-1" onClick={() => handleEdit(item)}>{t('application.configuration')}</RbButton>
|
||||
</Flex>}
|
||||
>
|
||||
<RbDescriptions
|
||||
items={['type', 'source', 'created_at'].map(key => ({
|
||||
key,
|
||||
label: t(`application.${key}`),
|
||||
children: <span className={clsx('rb:font-medium', {
|
||||
'rb:text-[#155EEF]': key === 'type',
|
||||
})}>
|
||||
{key === 'source' && item.is_shared
|
||||
? t('application.shared')
|
||||
: key === 'source' && !item.is_shared
|
||||
? t('application.configuration')
|
||||
: key === 'created_at'
|
||||
? formatDateTime(item.created_at, 'YYYY-MM-DD HH:mm:ss')
|
||||
: t(`application.${item[key as keyof Application]}`)
|
||||
}
|
||||
</span>
|
||||
}))}
|
||||
/>
|
||||
</RbCard>
|
||||
)}
|
||||
/>
|
||||
{(activeTab === 'apps' || activeTab === 'sharing') &&
|
||||
<PageScrollList<Application, Query>
|
||||
ref={scrollListRef}
|
||||
url={getApplicationListUrl}
|
||||
needLoading={false}
|
||||
query={{ ...query, shared_only: activeTab === 'sharing', include_shared: activeTab !== 'apps' }}
|
||||
renderItem={(item) => (
|
||||
<RbCard
|
||||
title={item.name}
|
||||
avatarText={item.name.trim()[0]}
|
||||
avatarClassName={clsx({
|
||||
'rb:bg-[#155EEF]': item.type === 'agent',
|
||||
'rb:bg-[#9C6FFF]!': item.type === 'multi_agent',
|
||||
'rb:bg-[#171719]': item.type === 'workflow',
|
||||
})}
|
||||
footer={
|
||||
item.is_shared
|
||||
? <Flex justify="space-between" gap={12}>
|
||||
<RbButton type="primary" ghost block onClick={() => handleEdit(item)}>{t('common.view')}</RbButton>
|
||||
{item.share_permission === 'editable' && <RbButton type="primary" className="rb:w-[calc(100%-46px)]" onClick={() => handleCopy(item)}>{t('common.copy')}</RbButton>}
|
||||
</Flex>
|
||||
: <Flex justify="space-between" gap={12}>
|
||||
<RbButton danger className="rb:w-22.25" onClick={() => handleDelete(item)}>{t('common.delete')}</RbButton>
|
||||
<RbButton type="primary" ghost className="rb:flex-1" onClick={() => handleEdit(item)}>{t('application.configuration')}</RbButton>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<RbDescriptions
|
||||
items={['type', 'source', 'created_at'].map(key => ({
|
||||
key,
|
||||
label: t(`application.${key}`),
|
||||
children: <span className={clsx('rb:font-medium', {
|
||||
'rb:text-[#155EEF]': key === 'type',
|
||||
})}>
|
||||
{key === 'source' && item.is_shared
|
||||
? t('application.shared')
|
||||
: key === 'source' && !item.is_shared
|
||||
? t('application.configuration')
|
||||
: key === 'created_at'
|
||||
? formatDateTime(item.created_at, 'YYYY-MM-DD HH:mm:ss')
|
||||
: t(`application.${item[key as keyof Application]}`)
|
||||
}
|
||||
</span>
|
||||
}))}
|
||||
/>
|
||||
</RbCard>
|
||||
)}
|
||||
/>
|
||||
}
|
||||
{activeTab === 'myShare' && <MySharing />}
|
||||
|
||||
|
||||
<ApplicationModal
|
||||
ref={applicationModalRef}
|
||||
@@ -185,6 +241,10 @@ const ApplicationManagement: React.FC = () => {
|
||||
ref={uploadWorkflowModalRef}
|
||||
refresh={refresh}
|
||||
/>
|
||||
<UploadModal
|
||||
ref={uploadModalRef}
|
||||
refresh={refresh}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 16:16:03
|
||||
* @Last Modified time: 2026-03-18 10:50:27
|
||||
*/
|
||||
/**
|
||||
* Type definitions for Application Management
|
||||
@@ -15,6 +15,8 @@ export interface Query {
|
||||
/** Search keyword */
|
||||
search: string;
|
||||
type?: string;
|
||||
shared_only?: boolean;
|
||||
include_shared?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +55,11 @@ export interface Application {
|
||||
created_at: number;
|
||||
/** Last update timestamp */
|
||||
updated_at: number;
|
||||
share_permission?: string;
|
||||
source_workspace_name?: string;
|
||||
source_workspace_icon?: string;
|
||||
source_app_version?: string;
|
||||
source_app_is_active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -233,4 +240,28 @@ export interface UploadData extends WorkflowConfig {
|
||||
export interface UploadWorkflowModalRef {
|
||||
/** Open the upload workflow modal */
|
||||
handleOpen: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload app modal ref interface
|
||||
*/
|
||||
export interface UploadModalRef {
|
||||
/** Open the upload workflow modal */
|
||||
handleOpen: () => void;
|
||||
}
|
||||
export interface MySharedOutItem {
|
||||
id: string;
|
||||
source_app_id: string;
|
||||
source_workspace_id: string;
|
||||
target_workspace_id: string;
|
||||
shared_by: string;
|
||||
permission: 'readonly' | 'editable';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
source_app_name: string;
|
||||
source_app_type: string;
|
||||
source_app_version: string;
|
||||
source_app_is_active: boolean;
|
||||
target_workspace_name: string;
|
||||
target_workspace_icon: string;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:42
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-07 15:03:31
|
||||
* @Last Modified time: 2026-03-19 21:32:34
|
||||
*/
|
||||
/**
|
||||
* File Upload Component
|
||||
@@ -19,8 +19,8 @@
|
||||
* - File list management
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle } from 'react';
|
||||
*/
|
||||
import { useState, useEffect, forwardRef, useImperativeHandle, useMemo } from 'react';
|
||||
import { Upload, Progress, App, Flex } from 'antd';
|
||||
import type { UploadProps, UploadFile } from 'antd';
|
||||
import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface';
|
||||
@@ -28,6 +28,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||
|
||||
interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
|
||||
/** Upload API endpoint */
|
||||
@@ -48,14 +49,14 @@ interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
|
||||
disabled?: boolean;
|
||||
/** File size limit in MB */
|
||||
fileSize?: number;
|
||||
/** Allowed file types ['doc', 'xls', 'ppt', 'pdf'] */
|
||||
fileType?: string[];
|
||||
/** Auto-upload on file selection, default is true */
|
||||
isAutoUpload?: boolean;
|
||||
/** Maximum number of files allowed */
|
||||
maxCount?: number;
|
||||
/** Custom file removal callback */
|
||||
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
|
||||
|
||||
featureConfig: FeaturesConfigForm['file_upload']
|
||||
}
|
||||
|
||||
const transform_file_type = {
|
||||
@@ -70,6 +71,12 @@ const transform_file_type = {
|
||||
|
||||
'application/vnd.ms-powerpoint': 'document/ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document/pptx',
|
||||
|
||||
'application/vnd.ms-excel': 'document/xls',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document/xlsx',
|
||||
'text/csv': 'document/csv',
|
||||
|
||||
'application/json': 'document/json'
|
||||
}
|
||||
// Mapping of file extensions to MIME types
|
||||
const ALL_FILE_TYPE: {
|
||||
@@ -87,6 +94,13 @@ const ALL_FILE_TYPE: {
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
|
||||
csv: 'text/csv',
|
||||
|
||||
json: 'application/json',
|
||||
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
@@ -130,11 +144,11 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
onChange,
|
||||
disabled = false,
|
||||
fileSize = 5,
|
||||
fileType = Object.entries(ALL_FILE_TYPE).map(([key]) => key),
|
||||
isAutoUpload = true,
|
||||
maxCount = 1,
|
||||
onRemove: customOnRemove,
|
||||
requestConfig,
|
||||
featureConfig,
|
||||
...props
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -142,18 +156,37 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
const [fileList, setFileList] = useState<UploadFile[]>(propFileList);
|
||||
const [accept, setAccept] = useState<string | undefined>();
|
||||
|
||||
const fileType = useMemo(() => {
|
||||
let types: string[] = [];
|
||||
['image', 'document', 'video', 'audio'].forEach(type => {
|
||||
if (featureConfig[`${type}_enabled` as keyof FeaturesConfigForm['file_upload']]) {
|
||||
types = types.concat(featureConfig[`${type}_allowed_extensions` as keyof FeaturesConfigForm['file_upload']] as string[])
|
||||
}
|
||||
})
|
||||
|
||||
return types
|
||||
}, [featureConfig])
|
||||
|
||||
/**
|
||||
* Validates file type and size before upload
|
||||
* @returns Upload.LIST_IGNORE to prevent upload, or true to proceed
|
||||
*/
|
||||
const beforeUpload: RcUploadProps['beforeUpload'] = (file) => {
|
||||
// Validate file size
|
||||
if (fileSize) {
|
||||
const isLtMaxSize = (file.size / 1024 / 1024) < fileSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(t('common.fileSizeTip', { size: fileSize }));
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
// Determine file category and get max size from featureConfig
|
||||
const mimePrefix = file.type?.split('/')[0]
|
||||
const categoryMap: Record<string, keyof FeaturesConfigForm['file_upload']> = {
|
||||
image: 'image_max_size_mb',
|
||||
video: 'video_max_size_mb',
|
||||
audio: 'audio_max_size_mb',
|
||||
}
|
||||
const maxSizeKey = categoryMap[mimePrefix] ?? 'document_max_size_mb'
|
||||
const maxSize = (featureConfig[maxSizeKey] as number) ?? fileSize
|
||||
|
||||
const fileSizeMB = file.size / 1024 / 1024
|
||||
const isLtMaxSize = fileSizeMB < maxSize;
|
||||
if (!isLtMaxSize) {
|
||||
message.error(t('common.fileSizeTip', { size: maxSize }));
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
// Validate file type
|
||||
if (fileType && fileType.length > 0) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:47
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 17:47:09
|
||||
* @Last Modified time: 2026-03-18 21:10:01
|
||||
*/
|
||||
/**
|
||||
* Upload File List Modal Component
|
||||
@@ -18,25 +18,28 @@
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { forwardRef, useImperativeHandle, useState, useMemo } from 'react';
|
||||
import { Form, Input, Select, Button, Flex } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { UploadFileListModalRef } from '../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
interface UploadFileListModalProps {
|
||||
/** Callback to refresh parent component with new file list */
|
||||
refresh: (fileList?: any[]) => void;
|
||||
featureConfig: FeaturesConfigForm['file_upload']
|
||||
}
|
||||
|
||||
/**
|
||||
* Modal for adding remote files via URL
|
||||
*/
|
||||
const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListModalProps>(({
|
||||
refresh
|
||||
refresh,
|
||||
featureConfig
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const [visible, setVisible] = useState(false);
|
||||
@@ -79,6 +82,20 @@ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListMod
|
||||
handleOpen
|
||||
}));
|
||||
|
||||
const fileTypeOptions = useMemo(() => {
|
||||
const options = [];
|
||||
if (featureConfig?.image_enabled) {
|
||||
options.push({ label: t('memoryConversation.image'), value: 'image' });
|
||||
}
|
||||
if (featureConfig?.audio_enabled) {
|
||||
options.push({ label: t('memoryConversation.audio'), value: 'audio' });
|
||||
}
|
||||
if (featureConfig?.video_enabled) {
|
||||
options.push({ label: t('memoryConversation.video'), value: 'video' });
|
||||
}
|
||||
return options;
|
||||
}, [featureConfig, t])
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('memoryConversation.addRemoteFile')}
|
||||
@@ -98,26 +115,21 @@ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListMod
|
||||
<FormItem
|
||||
{...restField}
|
||||
name={[name, 'type']}
|
||||
initialValue="image"
|
||||
className="rb:mb-0!"
|
||||
>
|
||||
<Select
|
||||
placeholder={t('memoryConversation.fileType')}
|
||||
options={[
|
||||
{ label: t('memoryConversation.image'), value: 'image' },
|
||||
{ label: t('memoryConversation.audio'), value: 'audio' },
|
||||
{ label: t('memoryConversation.video'), value: 'video' },
|
||||
]}
|
||||
className="rb:w-30"
|
||||
options={fileTypeOptions}
|
||||
className="rb:w-30!"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem
|
||||
{...restField}
|
||||
name={[name, 'url']}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
className="rb:mb-0!"
|
||||
className="rb:mb-0! rb:flex-1!"
|
||||
>
|
||||
<Input placeholder={t('memoryConversation.fileUrl')} className="rb:w-82.5!" />
|
||||
<Input placeholder={t('memoryConversation.fileUrl')} />
|
||||
</FormItem>
|
||||
<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')]"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:58:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 17:19:20
|
||||
* @Last Modified time: 2026-03-19 21:32:05
|
||||
*/
|
||||
/**
|
||||
* Conversation Page
|
||||
@@ -14,13 +14,12 @@ import { type FC, useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
import { Flex, Skeleton, Form, Dropdown, type MenuProps, App, Divider } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import { Flex, Skeleton, App } from 'antd'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { getConversationHistory, sendConversation, getConversationDetail, getShareToken, getExperienceConfig } from '@/api/application'
|
||||
import type { HistoryItem, QueryParams, UploadFileListModalRef } from './types'
|
||||
import type { HistoryItem } from './types'
|
||||
import Empty from '@/components/Empty'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
import { randomString } from '@/utils/common'
|
||||
@@ -33,20 +32,14 @@ import OnlineIcon from '@/assets/images/conversation/online.svg'
|
||||
import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg'
|
||||
import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import UploadFiles from './components/FileUpload'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import { shareFileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import UploadFileListModal from './components/UploadFileListModal'
|
||||
import type { VariableConfigModalRef } from '@/views/Workflow/types'
|
||||
import ChatToolbar, { type ChatToolbarRef } from '@/components/Chat/ChatToolbar'
|
||||
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
|
||||
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal';
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||
|
||||
/**
|
||||
* Conversation component for shared applications
|
||||
*/
|
||||
const Conversation: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const { message: messageApi, modal } = App.useApp()
|
||||
const { token } = useParams()
|
||||
const location = useLocation()
|
||||
const searchParams = new URLSearchParams(location.search)
|
||||
@@ -62,35 +55,22 @@ const Conversation: FC = () => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const toolbarRef = useRef<ChatToolbarRef>(null)
|
||||
const [shareToken, setShareToken] = useState<string | null>(localStorage.getItem(`shareToken_${token}`))
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [webSearch, setWebSearch] = useState(false)
|
||||
const [isHasMemory, setIsHasMemory] = useState(false)
|
||||
const [memory, setMemory] = useState(true)
|
||||
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
|
||||
const [config, setConfig] = useState<Record<string, any>>({})
|
||||
|
||||
const [form] = Form.useForm<QueryParams>()
|
||||
const queryValues = Form.useWatch<QueryParams>([], form)
|
||||
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
const [variables, setVariables] = useState<Variable[]>([]) // Workflow input variables
|
||||
|
||||
/**
|
||||
* Opens the variable configuration modal
|
||||
*/
|
||||
const handleEditVariables = () => {
|
||||
variableConfigModalRef.current?.handleOpen(variables)
|
||||
}
|
||||
/**
|
||||
* Saves updated variable values from the modal
|
||||
*/
|
||||
const handleSave = (values: Variable[]) => {
|
||||
setVariables([...values])
|
||||
}
|
||||
useEffect(() => {
|
||||
const shareToken = localStorage.getItem(`shareToken_${token}`)
|
||||
setShareToken(shareToken)
|
||||
if (shareToken && shareToken !== '') return
|
||||
getShareToken(token as string, userId || randomString(12, false))
|
||||
.then(res => {
|
||||
const response = res as { access_token: string } || {}
|
||||
const response = res as { access_token: string } || {}
|
||||
localStorage.setItem(`shareToken_${token}`, response.access_token ?? '')
|
||||
setShareToken(response.access_token ?? '')
|
||||
})
|
||||
@@ -101,12 +81,16 @@ const Conversation: FC = () => {
|
||||
getHistory()
|
||||
}
|
||||
}, [token, shareToken, page, hasMore, historyList])
|
||||
|
||||
useEffect(() => {
|
||||
if (shareToken && token) {
|
||||
getExperienceConfig(token)
|
||||
.then(res => {
|
||||
const response = res as { variables: Variable[] }
|
||||
setVariables(response.variables || [])
|
||||
const response = res as { variables: Variable[]; features: FeaturesConfigForm; app_type: string; memory?: boolean; }
|
||||
toolbarRef.current?.setVariables(response.variables || [])
|
||||
setConfig(response)
|
||||
setFeatures(response.features)
|
||||
setIsHasMemory((response.app_type === 'workflow' && response.memory) || (response.app_type !== 'workflow'))
|
||||
})
|
||||
} else {
|
||||
setChatList([])
|
||||
@@ -117,7 +101,7 @@ const Conversation: FC = () => {
|
||||
const groupHistoryByDate = (items: HistoryItem[]): Record<string, HistoryItem[]> => {
|
||||
return items.reduce((groups: Record<string, HistoryItem[]>, item) => {
|
||||
const date = formatDateTime(item.created_at, 'YYYY-MM-DD')
|
||||
|
||||
|
||||
if (!groups[date]) {
|
||||
groups[date] = [];
|
||||
}
|
||||
@@ -128,9 +112,7 @@ const Conversation: FC = () => {
|
||||
|
||||
/** Fetch conversation history with pagination */
|
||||
const getHistory = (flag: boolean = false) => {
|
||||
if (!token || (pageLoading || !hasMore) && !flag) {
|
||||
return
|
||||
}
|
||||
if (!token || (pageLoading || !hasMore) && !flag) return
|
||||
setPageLoading(true);
|
||||
getConversationHistory(token, { page: flag ? 1 : page, pagesize: 20 })
|
||||
.then(res => {
|
||||
@@ -153,19 +135,14 @@ const Conversation: FC = () => {
|
||||
setHasMore(response.page.hasnext);
|
||||
setLoading(false);
|
||||
})
|
||||
.finally(() => {
|
||||
setPageLoading(false);
|
||||
})
|
||||
.finally(() => setPageLoading(false))
|
||||
}
|
||||
/** Switch to different conversation or start new one */
|
||||
const handleChangeHistory = (id: string | null) => {
|
||||
if (id !== conversation_id) {
|
||||
setConversationId(id)
|
||||
}
|
||||
if (!id) {
|
||||
setMessage('')
|
||||
}
|
||||
if (id !== conversation_id) setConversationId(id)
|
||||
if (!id) setMessage('')
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (conversation_id) {
|
||||
getConversationDetail(token as string, conversation_id)
|
||||
@@ -178,43 +155,40 @@ const Conversation: FC = () => {
|
||||
}
|
||||
}, [conversation_id])
|
||||
|
||||
/** Add user message to chat */
|
||||
const addUserMessage = (message: string = '', files?: any[]) => {
|
||||
const newUserMessage: ChatItem = {
|
||||
setChatList(prev => [...prev, {
|
||||
conversation_id,
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
files
|
||||
};
|
||||
setChatList(prev => [...prev, newUserMessage])
|
||||
meta_data: {
|
||||
files
|
||||
},
|
||||
}])
|
||||
}
|
||||
/** Add empty assistant message placeholder */
|
||||
|
||||
const addAssistantMessage = () => {
|
||||
const newAssistantMessage: ChatItem = {
|
||||
setChatList(prev => [...prev, {
|
||||
created_at: Date.now(),
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
}
|
||||
setChatList(prev => [...prev, newAssistantMessage])
|
||||
content: ''
|
||||
}])
|
||||
}
|
||||
/** Update assistant message with streaming content */
|
||||
const updateAssistantMessage = (content: string = '') => {
|
||||
if (!content) return
|
||||
if (streamLoading) {
|
||||
setStreamLoading(false)
|
||||
}
|
||||
|
||||
const updateAssistantMessage = (content: string = '', audio_url?: string) => {
|
||||
if (!content && !audio_url) return
|
||||
if (streamLoading) setStreamLoading(false)
|
||||
setChatList(prev => {
|
||||
const lastList = [...prev]
|
||||
const lastIndex = lastList.length - 1
|
||||
const lastMsg = lastList[lastIndex]
|
||||
if (lastMsg?.role === 'assistant') {
|
||||
return [
|
||||
...lastList.slice(0, lastList.length - 1),
|
||||
...lastList.slice(0, lastIndex),
|
||||
{
|
||||
...lastMsg,
|
||||
content: lastMsg.content + content
|
||||
content: lastMsg.content + content,
|
||||
meta_data: { audio_url }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -222,22 +196,17 @@ const Conversation: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const isNeedVariableConfig = variables.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
|
||||
|
||||
/** Send message and handle streaming response */
|
||||
const handleSend = () => {
|
||||
if (!token || !shareToken) {
|
||||
return
|
||||
}
|
||||
const { files = [], ...rest } = queryValues || {}
|
||||
// Validate required variables before sending
|
||||
if (!token || !shareToken) return
|
||||
const files = toolbarRef.current?.getFiles() || []
|
||||
const variables = toolbarRef.current?.getVariables() || []
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (variables.length > 0) {
|
||||
const needRequired: string[] = []
|
||||
variables.forEach(vo => {
|
||||
params[vo.name] = vo.value ?? vo.defaultValue
|
||||
|
||||
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
|
||||
isCanSend = false
|
||||
needRequired.push(vo.name)
|
||||
@@ -248,33 +217,34 @@ const Conversation: FC = () => {
|
||||
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
|
||||
}
|
||||
}
|
||||
if (!isCanSend) {
|
||||
return
|
||||
}
|
||||
if (!isCanSend) return
|
||||
|
||||
setLoading(true)
|
||||
setStreamLoading(true)
|
||||
addUserMessage(message, files)
|
||||
addAssistantMessage()
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
|
||||
let currentConversationId: string | null = null
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
data.forEach((item) => {
|
||||
switch(item.event) {
|
||||
const { content, conversation_id: curId, audio_url } = item.data as { content: string; conversation_id: string; audio_url?: string; }
|
||||
switch (item.event) {
|
||||
case 'start':
|
||||
case 'node_start':
|
||||
const { conversation_id: newId } = item.data as { conversation_id: string }
|
||||
const { conversation_id: newId } = item.data as { conversation_id: string }
|
||||
currentConversationId = newId
|
||||
break
|
||||
case 'message':
|
||||
const { content, conversation_id: curId } = item.data as { content: string; conversation_id: string; }
|
||||
updateAssistantMessage(content)
|
||||
|
||||
if (curId) {
|
||||
currentConversationId = curId;
|
||||
}
|
||||
updateAssistantMessage(content, audio_url)
|
||||
if (curId) currentConversationId = curId;
|
||||
break
|
||||
case 'end':
|
||||
case 'workflow_end':
|
||||
if (audio_url) {
|
||||
updateAssistantMessage(content, audio_url)
|
||||
}
|
||||
setLoading(false)
|
||||
if (currentConversationId && currentConversationId !== conversation_id) {
|
||||
setConversationId(currentConversationId)
|
||||
@@ -285,9 +255,9 @@ const Conversation: FC = () => {
|
||||
})
|
||||
};
|
||||
|
||||
form.setFieldValue('files', [])
|
||||
sendConversation({
|
||||
...rest,
|
||||
web_search: webSearch,
|
||||
memory,
|
||||
message: message || '',
|
||||
stream: true,
|
||||
conversation_id: conversation_id || null,
|
||||
@@ -314,32 +284,19 @@ const Conversation: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const fileChange = (file?: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), file])
|
||||
}
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), {
|
||||
uid: file.file_id,
|
||||
response: { data: file },
|
||||
thumbUrl: file.url,
|
||||
type: file.type
|
||||
}])
|
||||
}
|
||||
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch(key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
const addFileList = (fileList?: any[]) => {
|
||||
if (!fileList || fileList.length <= 0) return
|
||||
form.setFieldValue('files', [...(queryValues.files || []), ...fileList])
|
||||
}
|
||||
const updateFileList = (fileList?: any[]) => {
|
||||
console.log('fileList', fileList)
|
||||
form.setFieldValue('files', [...(fileList || [])])
|
||||
const handleChangeMemory = (value: boolean) => {
|
||||
if (config.app_type === 'workflow') return;
|
||||
modal.confirm({
|
||||
title: value ? t('memoryConversation.memoryTipTitle') : t('memoryConversation.memoryCancelTipTitle'),
|
||||
okText: t('common.confirm'),
|
||||
cancelText: t('common.cancel'),
|
||||
onOk: () => {
|
||||
setMemory(value)
|
||||
},
|
||||
onCancel: () => {
|
||||
setMemory(!value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -354,8 +311,8 @@ const Conversation: FC = () => {
|
||||
className="rb:cursor-pointer rb:border rb:border-[#155EEF] rb:rounded-xl rb:p-3! rb:mx-4! rb:text-[16px] rb:font-medium rb:text-[#155EEF] rb:h-12! rb:mb-5!"
|
||||
onClick={() => handleChangeHistory(null)}
|
||||
>
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:mr-2 rb:bg-cover rb:bg-[url('@/assets/images/conversation/conversation.svg')] rb:group-hover:bg-[url('@/assets/images/conversation/conversation_hover.svg')]"
|
||||
<div
|
||||
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:mr-2 rb:bg-cover rb:bg-[url('@/assets/images/conversation/conversation.svg')] rb:group-hover:bg-[url('@/assets/images/conversation/conversation_hover.svg')]"
|
||||
></div>
|
||||
{t('memoryConversation.startANewConversation')}
|
||||
</Flex>
|
||||
@@ -370,7 +327,6 @@ const Conversation: FC = () => {
|
||||
next={getHistory}
|
||||
hasMore={hasMore}
|
||||
loader={<Skeleton active />}
|
||||
// endMessage={<Divider plain>It is all, nothing more 🤐</Divider>}
|
||||
scrollableTarget="scrollableDiv"
|
||||
>
|
||||
{Object.entries(groupHistoryList).map(([date, items]) => (
|
||||
@@ -379,16 +335,16 @@ const Conversation: FC = () => {
|
||||
|
||||
<Flex vertical gap={4}>
|
||||
{items.map(item => (
|
||||
<div key={item.updated_at} className="rb:mb-3">
|
||||
<div className={clsx("rb:p-[8px_13px] rb:rounded-lg rb:leading-5 rb:cursor-pointer rb:hover:bg-[#F0F3F8]", {
|
||||
'rb:bg-[#FFFFFF] rb:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)] rb:font-medium rb:hover:bg-[#FFFFFF]!': item.id === conversation_id,
|
||||
})}
|
||||
onClick={() => handleChangeHistory(item.id)}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div key={item.updated_at} className="rb:mb-3">
|
||||
<div className={clsx("rb:p-[8px_13px] rb:rounded-lg rb:leading-5 rb:cursor-pointer rb:hover:bg-[#F0F3F8]", {
|
||||
'rb:bg-[#FFFFFF] rb:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)] rb:font-medium rb:hover:bg-[#FFFFFF]!': item.id === conversation_id,
|
||||
})}
|
||||
onClick={() => handleChangeHistory(item.id)}
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Flex>
|
||||
</div>
|
||||
))}
|
||||
@@ -399,108 +355,62 @@ const Conversation: FC = () => {
|
||||
|
||||
<div className="rb:relative rb:h-screen rb:px-4 rb:flex-[1_1_auto]">
|
||||
<div className='rb:w-190 rb:h-screen rb:mx-auto rb:pt-10'>
|
||||
<Chat
|
||||
empty={<Empty url={ChatEmpty} className="rb:h-full" size={[320,180]} title={t('memoryConversation.chatEmpty')} subTitle={t('memoryConversation.emptyDesc')} />}
|
||||
contentClassName={!queryValues?.files?.length ? "rb:h-[calc(100%-144px)]" : "rb:h-[calc(100%-208px)]"}
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
loading={loading}
|
||||
onChange={setMessage}
|
||||
onSend={handleSend}
|
||||
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||
fileList={queryValues?.files || []}
|
||||
fileChange={updateFileList}
|
||||
>
|
||||
<Form form={form} initialValues={{ memory: false, web_search: false}}>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Form.Item name="files" noStyle>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
action={shareFileUploadUrlWithoutApiPrefix}
|
||||
onChange={fileChange}
|
||||
requestConfig={{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${shareToken || ''}`,
|
||||
}}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Form.Item>
|
||||
<Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
|
||||
<Chat
|
||||
empty={<Empty url={ChatEmpty} className="rb:h-full" size={[320,180]} title={t('memoryConversation.chatEmpty')} subTitle={t('memoryConversation.emptyDesc')} />}
|
||||
contentClassName={!fileList.length ? "rb:h-[calc(100%-144px)]" : "rb:h-[calc(100%-208px)]"}
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
loading={loading}
|
||||
onChange={setMessage}
|
||||
onSend={handleSend}
|
||||
labelFormat={(item) => dayjs(item.created_at).locale('en').format('MMMM D, YYYY [at] h:mm A')}
|
||||
fileList={fileList}
|
||||
fileChange={(list) => {
|
||||
setFileList(list || [])
|
||||
toolbarRef.current?.setFiles(list || [])
|
||||
}}
|
||||
>
|
||||
<ChatToolbar
|
||||
ref={toolbarRef}
|
||||
features={features}
|
||||
onFilesChange={setFileList}
|
||||
uploadAction={shareFileUploadUrlWithoutApiPrefix}
|
||||
uploadRequestConfig={{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${shareToken || ''}`,
|
||||
}
|
||||
}}
|
||||
extra={
|
||||
<>
|
||||
{features?.web_search?.enabled &&
|
||||
<ButtonCheckbox
|
||||
icon={OnlineIcon}
|
||||
checkedIcon={OnlineCheckedIcon}
|
||||
checked={webSearch}
|
||||
onChange={setWebSearch}
|
||||
>
|
||||
{t(`memoryConversation.web_search`)}
|
||||
{t('memoryConversation.web_search')}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="memory" valuePropName="checked" className="rb:mb-0!">
|
||||
}
|
||||
{isHasMemory &&
|
||||
<ButtonCheckbox
|
||||
icon={MemoryFunctionIcon}
|
||||
checkedIcon={MemoryFunctionCheckedIcon}
|
||||
checked={memory}
|
||||
disabled={config.app_type === 'workflow'}
|
||||
onChange={handleChangeMemory}
|
||||
>
|
||||
{t(`memoryConversation.memory`)}
|
||||
{t('memoryConversation.memory')}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
{variables.length > 0 && (
|
||||
<Form.Item name="variables" className="rb:mb-0!">
|
||||
<div
|
||||
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]", {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
|
||||
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
|
||||
})}
|
||||
onClick={handleEditVariables}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t(`memoryConversation.variableConfig`)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<AudioRecorder
|
||||
action={shareFileUploadUrlWithoutApiPrefix}
|
||||
requestConfig={{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${shareToken || ''}`,
|
||||
}
|
||||
}}
|
||||
onRecordingComplete={handleRecordingComplete}
|
||||
/>
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Form>
|
||||
</Chat>
|
||||
}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Chat>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
<VariableConfigModal
|
||||
ref={variableConfigModalRef}
|
||||
refresh={handleSave}
|
||||
variables={variables}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
export default Conversation
|
||||
export default Conversation
|
||||
|
||||
@@ -83,6 +83,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);
|
||||
@@ -546,8 +547,7 @@ const CreateDataset = () => {
|
||||
};
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<>
|
||||
return (<>
|
||||
{contextHolder}
|
||||
|
||||
<div className='rb:p-3 rb:pt-2 rb:h-full rb:flex rb:flex-col'>
|
||||
@@ -610,58 +610,67 @@ const CreateDataset = () => {
|
||||
{source && source === 'link' && (
|
||||
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-40'>
|
||||
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.webLink')}
|
||||
</div>
|
||||
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} />
|
||||
<div className='rb:text-sm rb:text-gray-500 rb:mt-3'>
|
||||
{t('knowledgeBase.webLinkDesc',{count: 5})}
|
||||
</div>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
|
||||
{t('knowledgeBase.selectorTutorial')}
|
||||
</div>
|
||||
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
|
||||
</div>
|
||||
)}
|
||||
{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.Item
|
||||
name="title"
|
||||
label={t('knowledgeBase.title')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterTitle') }]}
|
||||
>
|
||||
<Input placeholder={t('knowledgeBase.pleaseEnterTitle')} />
|
||||
</Form.Item>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.webLink')}
|
||||
</div>
|
||||
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} />
|
||||
<div className='rb:text-sm rb:text-gray-500 rb:mt-3'>
|
||||
{t('knowledgeBase.webLinkDesc',{count: 5})}
|
||||
</div>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
|
||||
{t('knowledgeBase.selectorTutorial')}
|
||||
</div>
|
||||
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
|
||||
</div>
|
||||
)}
|
||||
{source && source === 'text' && (
|
||||
<div className='rb:flex rb:w-full rb:flex-col rb:mt-10 rb:px-20'>
|
||||
<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')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterTitle') }]}
|
||||
>
|
||||
<Input placeholder={t('knowledgeBase.pleaseEnterTitle')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label={t('knowledgeBase.customContent')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterContent') }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={t('knowledgeBase.pleaseEnterContent')}
|
||||
rows={8}
|
||||
showCount
|
||||
maxLength={5000}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{/* <div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.customText')}
|
||||
</div>
|
||||
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
|
||||
{t('knowledgeBase.customContent')}
|
||||
</div>
|
||||
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} /> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Form.Item
|
||||
name="content"
|
||||
label={t('knowledgeBase.customContent')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.pleaseEnterContent') }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={t('knowledgeBase.pleaseEnterContent')}
|
||||
rows={8}
|
||||
showCount
|
||||
maxLength={5000}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
{/* <div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mb-3'>
|
||||
{t('knowledgeBase.customText')}
|
||||
</div>
|
||||
<Input className='rb:w-full' placeholder={t('knowledgeBase.webLinkPlaceholder')}/>
|
||||
<div className='rb:text-sm rb:font-medium rb:text-gray-800 rb:mt-10 rb:mb-3'>
|
||||
{t('knowledgeBase.customContent')}
|
||||
</div>
|
||||
<TextArea rows={6} placeholder={t('knowledgeBase.webLinkPlaceholder')} /> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{current === 1 && (
|
||||
<div className='rb:flex rb:flex-col rb:py-6 rb:px-40'>
|
||||
<div className='rb:flex rb:flex-col rb:mt-10 rb:px-40'>
|
||||
{rechunkFileIds.length > 0 && (
|
||||
<div className='rb:bg-[#F0F3F8] rb:border rb:border-[#DFE4ED] rb:rounded rb:px-3 rb:py-2 rb:mb-4 rb:text-xs rb:text-gray-600 rb:flex rb:flex-wrap rb:gap-2'>
|
||||
<span className='rb:text-gray-700 rb:font-medium'>{t('knowledgeBase.rechunking')}:</span>
|
||||
@@ -676,7 +685,7 @@ const CreateDataset = () => {
|
||||
<div className='rb:mt-4'>
|
||||
<div
|
||||
className={`rb:flex rb:items-center rb:w-full rb:border rb:rounded-lg rb:p-4 rb:cursor-pointer ${
|
||||
pdfEnhancementEnabled ? 'rb:border-gray-900' : 'rb:border-gray-300'
|
||||
pdfEnhancementEnabled ? 'rb:border-blue-500' : 'rb:border-gray-300'
|
||||
}`}
|
||||
// onClick={() => setPdfEnhancementEnabled(!pdfEnhancementEnabled)}
|
||||
>
|
||||
@@ -836,8 +845,7 @@ const CreateDataset = () => {
|
||||
</div>
|
||||
// </Spin>
|
||||
)}
|
||||
|
||||
<div className={`rb:flex rb:p-6 rb:gap-3 rb:mt-6 ${current === 1 || (source == 'link' && current === 0) || (source == 'text' && current === 0) ? 'rb:pl-40 rb:mt-10' : ''}`}>
|
||||
<div className={`rb:flex rb:p-6 rb:gap-3 rb:mt-6 ${current === 1 || (source == 'link' && current === 0) || (source == 'text' && current === 0) ? 'rb:pl-28 rb:mt-10' : ''}`}>
|
||||
{current !== 0 && (
|
||||
<Button onClick={handlePrev} disabled={current === 0 || pollingLoading}>
|
||||
{t('common.previous') || 'Prev'}
|
||||
@@ -846,7 +854,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>
|
||||
|
||||
@@ -566,6 +566,7 @@ const Private: FC = () => {
|
||||
title: t('knowledgeBase.status'),
|
||||
dataIndex: 'progress',
|
||||
key: 'progress',
|
||||
width: 160,
|
||||
render: (value: string | number) => {
|
||||
return (
|
||||
<span className="rb:text-xs rb:border rb:border-[#DFE4ED] rb:bg-[#FBFDFF] rb:rounded rb:items-center rb:text-[#212332] rb:py-1 rb:px-2">
|
||||
|
||||
@@ -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:09:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 16:00:39
|
||||
* @Last Modified time: 2026-03-20 10:22:08
|
||||
*/
|
||||
/**
|
||||
* Memory Conversation Page
|
||||
@@ -83,7 +83,7 @@ export interface LogItem {
|
||||
type: string;
|
||||
title: string;
|
||||
data?: DataItem[] | AnyObject;
|
||||
raw_results?: string | AnyObject;
|
||||
raw_results?: string | Record<string, AnyObject>;
|
||||
summary?: string;
|
||||
query?: string;
|
||||
reason?: string;
|
||||
@@ -236,47 +236,50 @@ const MemoryConversation: FC = () => {
|
||||
handleExpand={() => setExpandedLogs(prev => ({ ...prev, [logIndex]: !prev[logIndex] }))}
|
||||
extra={log.type === 'verification' && <div className="rb-border rb:rounded-lg rb:py-1 rb:px-2 rb:text-[12px] rb:font-medium rb:leading-4.5 rb:text-[#FF5D34]">{log.result}</div>}
|
||||
>
|
||||
{log.type === 'problem_split' && Array.isArray(log.data) && log.data.length > 0
|
||||
? <Flex gap={12} vertical>
|
||||
{log.data.map(vo => (
|
||||
<ContentWrapper key={vo.id}>
|
||||
<>
|
||||
<div className="rb:font-medium rb:text-[#212332]">{vo.id}. {vo.question}</div>
|
||||
<div className="rb:mt-2 rb:text-[12px] rb:text-[#5B6167]">{vo.reason}</div>
|
||||
</>
|
||||
</ContentWrapper>
|
||||
))}
|
||||
</Flex>
|
||||
: log.type === 'problem_extension' && log.data && Object.keys(log.data).length > 0
|
||||
? <Flex gap={12} vertical>
|
||||
{Object.keys(log.data).map((key: string) => (
|
||||
<ContentWrapper key={key}>
|
||||
<>
|
||||
<div className="rb:font-medium rb:text-[#212332]">{key}</div>
|
||||
{(log.data as Record<string, string[]>)[key].map((item, index) => (
|
||||
<div key={index} className="rb:mt-2 rb:text-[#5B6167] rb:text-[12px]">{item}</div>
|
||||
))}
|
||||
</>
|
||||
</ContentWrapper>
|
||||
))}
|
||||
{log.type === 'problem_split' && Array.isArray(log.data) && log.data.length > 0
|
||||
? <Flex gap={12} vertical>
|
||||
{log.data.map(vo => (
|
||||
<ContentWrapper key={vo.id}>
|
||||
<>
|
||||
<div className="rb:font-medium rb:text-[#212332]">{vo.id}. {vo.question}</div>
|
||||
<div className="rb:mt-2 rb:text-[12px] rb:text-[#5B6167]">{vo.reason}</div>
|
||||
</>
|
||||
</ContentWrapper>
|
||||
))}
|
||||
</Flex>
|
||||
: log.type === 'search_result' && log.raw_results
|
||||
? <ContentWrapper>
|
||||
<div className="rb:font-medium rb:text-[#212332] rb:mb-2">{log.query}</div>
|
||||
<div className='rb:mt-2 rb:text-[12px] rb:text-[#5B6167]'>
|
||||
{typeof log.raw_results === 'string'
|
||||
? <Markdown content={log.raw_results} />
|
||||
: <>
|
||||
{log.raw_results.reranked_results?.statements.length > 0 && log.raw_results.reranked_results?.statements.map((item: { statement: string }, index: number) => (
|
||||
<div key={index}>{item.statement}</div>
|
||||
: log.type === 'problem_extension' && log.data && Object.keys(log.data).length > 0
|
||||
? <Flex gap={12} vertical>
|
||||
{Object.keys(log.data).map((key: string) => (
|
||||
<ContentWrapper key={key}>
|
||||
<>
|
||||
<div className="rb:font-medium rb:text-[#212332]">{key}</div>
|
||||
{(log.data as Record<string, string[]>)[key].map((item, index) => (
|
||||
<div key={index} className="rb:mt-2 rb:text-[#5B6167] rb:text-[12px]">{item}</div>
|
||||
))}
|
||||
{log.raw_results.reranked_results?.summaries.length > 0 && log.raw_results.reranked_results?.summaries.map((item: { content: string }, index: number) => (
|
||||
<div key={index}>{item.content}</div>
|
||||
</>
|
||||
</ContentWrapper>
|
||||
))}
|
||||
</Flex>
|
||||
: log.type === 'search_result' && log.raw_results && typeof log.raw_results !== 'string'
|
||||
? <ContentWrapper>
|
||||
<div className="rb:font-medium rb:text-[#212332] rb:mb-2">{log.query}</div>
|
||||
{(log.raw_results.reranked_results as AnyObject)?.communities?.length > 0 && <>
|
||||
<div className="rb:font-medium rb:text-[#212332] rb:text-[12px]">{t('memoryConversation.communities')}</div>
|
||||
<ul className='rb:mt-2 rb:text-[12px] rb:text-[#5B6167] rb:list-disc rb:pl-4'>
|
||||
{((log.raw_results.reranked_results as AnyObject)?.communities as { content: string }[]).map((item, index: number) => (
|
||||
<li key={index}>{item.content}</li>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
</ul>
|
||||
</>}
|
||||
{(log.raw_results.reranked_results as AnyObject)?.summaries?.length > 0 && <>
|
||||
<div className="rb:font-medium rb:text-[#212332] rb:text-[12px]">{t('memoryConversation.summaries')}</div>
|
||||
<ul className='rb:mt-2 rb:text-[12px] rb:text-[#5B6167] rb:list-disc rb:pl-4'>
|
||||
{((log.raw_results.reranked_results as AnyObject)?.summaries as { content: string }[]).map((item, index: number) => (
|
||||
<li key={index}>{item.content}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>}
|
||||
</ContentWrapper>
|
||||
: log.type === 'retrieval_summary' && log.summary
|
||||
? <ContentWrapper>
|
||||
<div className="rb:text-[12px] rb:text-[#5B6167]">{log.summary}</div>
|
||||
@@ -310,9 +313,9 @@ const MemoryConversation: FC = () => {
|
||||
}
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
: null
|
||||
}
|
||||
</ResultCard>
|
||||
: null
|
||||
}
|
||||
</ResultCard>
|
||||
))}
|
||||
</Flex>
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:49:28
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 11:31:43
|
||||
* @Last Modified time: 2026-03-11 15:08:24
|
||||
*/
|
||||
/**
|
||||
* Custom Model Modal
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, App, Checkbox } from 'antd';
|
||||
import { Form, Input, App, Checkbox, Button } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { CustomModelForm, ModelListItem, CustomModelModalRef, CustomModelModalProps } from '../types';
|
||||
@@ -35,6 +35,7 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [form] = Form.useForm<CustomModelForm>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||
const modelType = Form.useWatch(['type'], form);
|
||||
const isOmni = Form.useWatch(['is_omni'], form);
|
||||
|
||||
@@ -46,6 +47,8 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
|
||||
/** Close modal and reset state */
|
||||
const handleClose = () => {
|
||||
abortController?.abort()
|
||||
setAbortController(null)
|
||||
setModel({} as ModelListItem);
|
||||
form.resetFields();
|
||||
setLoading(false)
|
||||
@@ -73,8 +76,10 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
/** Update or create custom model */
|
||||
const handleUpdate = (data: CustomModelForm) => {
|
||||
setLoading(true)
|
||||
const controller = new AbortController()
|
||||
setAbortController(controller)
|
||||
const { type, provider, ...rest} = data
|
||||
const res = isEdit ? updateCustomModel(model.id, rest) : addCustomModel(data)
|
||||
const res = isEdit ? updateCustomModel(model.id, rest, controller.signal) : addCustomModel(data, controller.signal)
|
||||
|
||||
res.then(() => {
|
||||
refresh?.(isEdit)
|
||||
@@ -124,15 +129,15 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
}));
|
||||
console.log('modelType', modelType)
|
||||
return (
|
||||
<RbModal
|
||||
title={isEdit ? `${model.name} - ${t('modelNew.modelConfiguration')}` : t('modelNew.createCustomModel')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t(`common.${isEdit ? 'save' : 'create'}`)}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleClose}>{t('common.cancel')}</Button>,
|
||||
<Button key="confirm" type="primary" loading={loading} onClick={handleSave}>{t(`common.${isEdit ? 'save' : 'create'}`)}</Button>,
|
||||
]}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:49:40
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:49:40
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-11 15:12:17
|
||||
*/
|
||||
/**
|
||||
* Key Configuration Modal
|
||||
@@ -11,7 +11,7 @@
|
||||
*/
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, App } from 'antd';
|
||||
import { Form, Input, App, Button } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { KeyConfigModalForm, ProviderModelItem, KeyConfigModalRef, KeyConfigModalProps } from '../types';
|
||||
@@ -30,9 +30,12 @@ const KeyConfigModal = forwardRef<KeyConfigModalRef, KeyConfigModalProps>(({
|
||||
const [model, setModel] = useState<ProviderModelItem>({} as ProviderModelItem);
|
||||
const [form] = Form.useForm<KeyConfigModalForm>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||
|
||||
/** Close modal and reset state */
|
||||
const handleClose = () => {
|
||||
abortController?.abort()
|
||||
setAbortController(null)
|
||||
setModel({} as ProviderModelItem);
|
||||
form.resetFields();
|
||||
setLoading(false)
|
||||
@@ -51,10 +54,13 @@ const KeyConfigModal = forwardRef<KeyConfigModalRef, KeyConfigModalProps>(({
|
||||
.then((values) => {
|
||||
setLoading(true)
|
||||
|
||||
const controller = new AbortController()
|
||||
setAbortController(controller)
|
||||
|
||||
updateProviderApiKeys({
|
||||
...values,
|
||||
provider: model.provider
|
||||
}).then((res) => {
|
||||
}, controller.signal).then((res) => {
|
||||
if (refresh) {
|
||||
refresh();
|
||||
}
|
||||
@@ -81,9 +87,10 @@ const KeyConfigModal = forwardRef<KeyConfigModalRef, KeyConfigModalProps>(({
|
||||
title={`${model.provider} - ${t('modelNew.keyConfig')}`}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t(`common.save`)}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleClose}>{t('common.cancel')}</Button>,
|
||||
<Button key="confirm" type="primary" loading={loading} onClick={handleSave}>{t(`common.save`)}</Button>,
|
||||
]}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:49:55
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:49:55
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-11 15:11:06
|
||||
*/
|
||||
/**
|
||||
* Multi-Key Configuration Modal
|
||||
@@ -28,9 +28,12 @@ const MultiKeyConfigModal = forwardRef<MultiKeyConfigModalRef, MultiKeyConfigMod
|
||||
const [model, setModel] = useState<ModelListItem>({} as ModelListItem);
|
||||
const [form] = Form.useForm<MultiKeyForm>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [abortController, setAbortController] = useState<AbortController | null>(null)
|
||||
|
||||
/** Close modal and refresh parent */
|
||||
const handleClose = () => {
|
||||
abortController?.abort()
|
||||
setAbortController(null)
|
||||
setModel({} as ModelListItem);
|
||||
refresh?.()
|
||||
|
||||
@@ -60,12 +63,14 @@ const MultiKeyConfigModal = forwardRef<MultiKeyConfigModalRef, MultiKeyConfigMod
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
setLoading(true)
|
||||
const controller = new AbortController()
|
||||
setAbortController(controller)
|
||||
addModelApiKey(model.id, {
|
||||
...values,
|
||||
model_config_id: model.id,
|
||||
model_name: model.name,
|
||||
provider: model.provider,
|
||||
}).then(() => {
|
||||
}, controller.signal).then(() => {
|
||||
message.success(t('common.saveSuccess'))
|
||||
form.resetFields();
|
||||
getData(model)
|
||||
@@ -98,7 +103,6 @@ const MultiKeyConfigModal = forwardRef<MultiKeyConfigModalRef, MultiKeyConfigMod
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
confirmLoading={loading}
|
||||
>
|
||||
{model.api_keys && model.api_keys.length > 0 && (
|
||||
<div className="rb:mb-4">
|
||||
|
||||
@@ -1,131 +1,342 @@
|
||||
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';
|
||||
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 searchTimerRef = useRef<number | null>(null);
|
||||
|
||||
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, keywords = '') => {
|
||||
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,
|
||||
...(keywords ? { keywords } : {})
|
||||
});
|
||||
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, searchKeyword);
|
||||
}, [selectedSource, currentPage, loading, searchKeyword]);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchKeyword(value);
|
||||
|
||||
// 清除之前的定时器
|
||||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
}
|
||||
|
||||
// 如果清空搜索框,恢复原始列表
|
||||
if (!value.trim()) {
|
||||
if (selectedSource) {
|
||||
// 清除缓存,重新加载原始列表
|
||||
setMcpCache(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedSource];
|
||||
return next;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchMcpList(selectedSource, 1, false, '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置新的定时器,500ms 后执行搜索
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
if (selectedSource) {
|
||||
// 清除缓存,重新搜索
|
||||
setMcpCache(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedSource];
|
||||
return next;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchMcpList(selectedSource, 1, false, value);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
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);
|
||||
console.log('获取到的配置数据:', config);
|
||||
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
|
||||
}));
|
||||
}
|
||||
message.success(`已连接 ${source?.name}`);
|
||||
}, 800);
|
||||
// 使用 fetchMcpList 获取完整的 MCP 列表(包含激活状态和入库状态)
|
||||
await fetchMcpList(sourceId, 1);
|
||||
};
|
||||
|
||||
const handleRefreshAfterAdd = async () => {
|
||||
// 添加成功后,刷新当前选中的市场源的 MCP 列表
|
||||
if (!selectedSource) return;
|
||||
|
||||
// 清除缓存并重新加载,这样会重新获取工具列表并更新 inDatabase 标记
|
||||
setMcpCache(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedSource];
|
||||
return next;
|
||||
});
|
||||
setCurrentPage(1);
|
||||
await fetchMcpList(selectedSource, 1);
|
||||
};
|
||||
|
||||
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 +345,228 @@ 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())
|
||||
);
|
||||
|
||||
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">
|
||||
<div className="rb:flex rb:gap-3 rb:items-center">
|
||||
{source.connected && (
|
||||
<Button size="small" icon={<SyncOutlined />} onClick={() => handleRefresh(selectedSource)}>
|
||||
{t('tool.marketRefresh')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder={t('tool.marketSearchPlaceholder')}
|
||||
value={searchKeyword}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
|
||||
/>
|
||||
|
||||
</div>
|
||||
<Button icon={<SettingOutlined />} onClick={() => handleOpenConfig(selectedSource)}>
|
||||
配置
|
||||
{t('tool.marketConfigBtn')}
|
||||
</Button>
|
||||
<Button type="primary" icon={<GlobalOutlined />} onClick={() => window.open(source.url, '_blank')}>
|
||||
前往市场
|
||||
{t('tool.marketVisit')}
|
||||
</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 rb:items-center">
|
||||
{source.connected && (
|
||||
<Button size="small" icon={<SyncOutlined />} onClick={() => handleRefresh(selectedSource)}>
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
{mcpList.length > 0 && (
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索服务..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
<div id="mcpScrollableDiv" className="rb:overflow-y-auto rb:h-[calc(100vh-260px)]">
|
||||
{!loading && mcpList.length === 0 ? (
|
||||
<Empty
|
||||
url={pageEmptyIcon}
|
||||
title={searchKeyword ? t('tool.marketNoSearchResult') : t('tool.marketNoData')}
|
||||
subTitle={searchKeyword ? t('tool.marketNoSearchResultDesc') : t('tool.marketNoDataDesc')}
|
||||
size={200}
|
||||
className="rb:h-full"
|
||||
/>
|
||||
)}
|
||||
</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 => (
|
||||
) : (
|
||||
<InfiniteScroll
|
||||
dataLength={mcpList.length}
|
||||
next={loadMore}
|
||||
hasMore={hasMore}
|
||||
loader={null}
|
||||
scrollableTarget="mcpScrollableDiv"
|
||||
>
|
||||
<div
|
||||
className="rb:gap-4"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))'
|
||||
}}
|
||||
>
|
||||
{mcpList.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 disabled={mcp.inDatabase} 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>
|
||||
)}
|
||||
</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 +577,10 @@ const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () =>
|
||||
ref={marketConfigModalRef}
|
||||
onConnect={handleConnect}
|
||||
/>
|
||||
<McpServiceModal
|
||||
ref={mcpServiceModalRef}
|
||||
refresh={handleRefreshAfterAdd}
|
||||
/>
|
||||
</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 {
|
||||
@@ -35,6 +37,8 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentSource, setCurrentSource] = useState<MarketSource | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [initialValues, setInitialValues] = useState<{ token: string }>({ token: '' });
|
||||
const formValues = Form.useWatch([], form);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
@@ -42,32 +46,62 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
setLoading(false);
|
||||
setCurrentSource(null);
|
||||
setShowApiKey(false);
|
||||
setInitialValues({ token: '' });
|
||||
};
|
||||
|
||||
const handleOpen = (source: MarketSource) => {
|
||||
console.log('Modal 接收到的数据:', source);
|
||||
setCurrentSource(source);
|
||||
form.setFieldsValue({
|
||||
url: source.url,
|
||||
apiKey: source.apiKey,
|
||||
});
|
||||
setInitialValues({ token: source.token || '' });
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleAfterOpenChange = (open: boolean) => {
|
||||
if (open && currentSource) {
|
||||
// Modal 完全打开后再设置表单值,使用 setTimeout 确保在下一个事件循环
|
||||
setTimeout(() => {
|
||||
form.setFieldsValue({
|
||||
token: currentSource.token || '',
|
||||
});
|
||||
console.log('Modal 打开后设置表单值:', { token: currentSource.token || '' });
|
||||
console.log('当前表单所有值:', form.getFieldsValue());
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -82,6 +116,9 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
}
|
||||
};
|
||||
|
||||
// 检查是否可以保存:token 字段必须有值
|
||||
const canSave = formValues?.token?.trim().length > 0;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
@@ -91,77 +128,97 @@ const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProp
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={`配置 ${currentSource.name}`}
|
||||
title={t('tool.marketConfig', { name: currentSource.name })}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText="保存并连接"
|
||||
afterOpenChange={handleAfterOpenChange}
|
||||
okText={t('tool.marketSaveAndConnect')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{ disabled: !canSave }}
|
||||
width={600}
|
||||
>
|
||||
<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>
|
||||
|
||||
<Form
|
||||
key={currentSource?.id || 'new'}
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
>
|
||||
{/* 市场地址 */}
|
||||
<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>
|
||||
}
|
||||
extra="部分市场需要 API Key 才能获取完整的服务列表"
|
||||
rules={[
|
||||
{ required: true, message: t('tool.marketApiKeyRequired') },
|
||||
{ whitespace: true, message: t('tool.marketApiKeyRequired') }
|
||||
]}
|
||||
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 以获取更多服务"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
icon={showApiKey ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder={t('tool.marketApiKeyPlaceholder')}
|
||||
autoComplete="off"
|
||||
suffix={
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={showApiKey ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -41,6 +41,7 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
const values = Form.useWatch<MCPToolItem>([], form)
|
||||
const requestHeaderModalRef = useRef<RequestHeaderModalRef>(null)
|
||||
const [requestHeaderList, setRequestHeaderList] = useState<RequestHeader[]>([])
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const formatTabItems = () => {
|
||||
return tabKeys.map(key => ({
|
||||
@@ -54,6 +55,12 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
|
||||
// 封装取消方法,添加关闭弹窗逻辑
|
||||
const handleClose = () => {
|
||||
// 如果有正在进行的请求,取消它
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setLoading(false);
|
||||
@@ -70,7 +77,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 +88,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();
|
||||
}
|
||||
@@ -93,6 +110,10 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
.validateFields()
|
||||
.then(() => {
|
||||
setLoading(true);
|
||||
|
||||
// 创建 AbortController 用于取消请求
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
// 创建新服务对象
|
||||
const { config, ...rest } = values
|
||||
|
||||
@@ -110,17 +131,42 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
}
|
||||
}
|
||||
}
|
||||
const request = editVo?.id ? updateTool(editVo.id, newService) : addTool(newService)
|
||||
|
||||
// 如果是从 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, { signal: abortControllerRef.current.signal })
|
||||
: addTool(newService, { signal: abortControllerRef.current.signal })
|
||||
request.then((res: any) => {
|
||||
// 清除 AbortController
|
||||
abortControllerRef.current = null;
|
||||
|
||||
message.success(t('common.saveSuccess'));
|
||||
testConnection(res.tool_id || editVo?.id)
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
handleClose();
|
||||
refresh()
|
||||
})
|
||||
setLoading(false);
|
||||
handleClose();
|
||||
refresh();
|
||||
|
||||
// 在后台测试连接,不阻塞用户操作
|
||||
testConnection(res.tool_id || editVo?.id).catch((err) => {
|
||||
console.error('测试连接失败:', err);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
.catch((error) => {
|
||||
// 清除 AbortController
|
||||
abortControllerRef.current = null;
|
||||
|
||||
// 如果是用户主动取消,不显示错误提示
|
||||
if (error.name === 'AbortError' || error.code === 'ERR_CANCELED') {
|
||||
console.log('请求已取消');
|
||||
} else {
|
||||
message.error(t('common.saveFailed'));
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
})
|
||||
@@ -150,7 +196,13 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
onCancel={handleClose}
|
||||
okText={t('tool.saveAndTest')}
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
okButtonProps={{ loading: loading }}
|
||||
footer={(_, { OkBtn, CancelBtn }) => (
|
||||
<>
|
||||
<CancelBtn />
|
||||
<OkBtn />
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @Author: yujiangping
|
||||
* @Date: 2026-01-05 17:22:23
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2026-03-06 15:08:38
|
||||
* @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,11 @@ export interface ExecuteData {
|
||||
export interface CustomToolModalRef {
|
||||
handleOpen: (data?: ToolItem) => void;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
export interface MarketQuery {
|
||||
mcp_market_config_id?: string;
|
||||
page?: number;
|
||||
pagesize?: number;
|
||||
keywords?: string;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:57:11
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-19 11:38:17
|
||||
* @Last Modified time: 2026-03-20 11:27:53
|
||||
*/
|
||||
/**
|
||||
* RAG User Memory Detail View
|
||||
@@ -12,7 +12,8 @@
|
||||
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Row, Col, Skeleton, Flex } from 'antd'
|
||||
import { Row, Col, Skeleton, Spin, Flex, Tooltip } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import aboutUs from '@/assets/images/userMemory/aboutUs.svg'
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
getChunkSummaryTag,
|
||||
getUserProfile,
|
||||
getChunkInsight,
|
||||
generateRagProfile
|
||||
} from '@/api/memory'
|
||||
import Empty from '@/components/Empty'
|
||||
import ConversationMemory from './components/ConversationMemory'
|
||||
@@ -96,6 +98,20 @@ const Rag: FC = () => {
|
||||
})
|
||||
}
|
||||
const name = loading.detail ? '' : data?.name && data?.name !== '' ? data.name : id
|
||||
|
||||
const [refreshLoading, setRefreshLoading] = useState(false)
|
||||
const handleRefresh = () => {
|
||||
if (refreshLoading || !id) return
|
||||
setRefreshLoading(true)
|
||||
generateRagProfile(id as string)
|
||||
.then(() => {
|
||||
getSummary()
|
||||
getInsightReport()
|
||||
})
|
||||
.finally(() => {
|
||||
setRefreshLoading(false)
|
||||
})
|
||||
}
|
||||
return (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={8}>
|
||||
@@ -104,9 +120,22 @@ const Rag: FC = () => {
|
||||
>
|
||||
<Flex align="center" gap={12} className="rb:mb-6!">
|
||||
<div className="rb:size-12 rb:text-center rb:font-semibold rb:text-[28px] rb:leading-12 rb:rounded-xl rb:text-white rb:bg-[#155EEF]">{name?.[0]}</div>
|
||||
<div className="rb:text-[16px] rb:font-semibold rb:leading-6 rb:line-clamp-2 rb:flex-1">
|
||||
{name}
|
||||
</div>
|
||||
<Flex justify="space-between">
|
||||
<div className="rb:text-[16px] rb:font-semibold rb:leading-6 rb:line-clamp-2 rb:flex-1">
|
||||
{name}
|
||||
</div>
|
||||
<Tooltip title={t('common.refresh')}>
|
||||
{refreshLoading
|
||||
? <Spin indicator={<LoadingOutlined spin />} />
|
||||
: (
|
||||
<div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/refresh.svg')]"
|
||||
onClick={handleRefresh}
|
||||
></div>
|
||||
)
|
||||
}
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* About Me */}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import React, { useState, type FC, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Spin, Flex } from 'antd';
|
||||
|
||||
import type { CommunityD3Node, CommunityGraphData, RawCommunityGraphData, RawCommunityNode } from '@/components/D3Graph/types'
|
||||
import { buildCommunityGraphData } from '@/components/D3Graph/utils'
|
||||
import CommunityGraph from '@/components/D3Graph/CommunityGraph'
|
||||
import { getMemoryCommunityGraph } from '@/api/memory'
|
||||
|
||||
// ─── Tooltip ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const NodeTooltip: FC<{ node: CommunityD3Node }> = ({ node }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div style={{
|
||||
background: '#fff', border: '1px solid #DFE4ED', borderRadius: 8,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: '10px 14px',
|
||||
minWidth: 180, maxWidth: 260, fontSize: 13,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 6, color: '#1a1a1a', fontSize: 14 }}>
|
||||
{node.properties?.name ?? node.name}
|
||||
</div>
|
||||
{node.properties?.description && (
|
||||
<div style={{ color: '#5B6167', lineHeight: '20px', marginBottom: 4 }}>
|
||||
{node.properties.description}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#5B6167', lineHeight: '22px' }}>
|
||||
{t('userMemory.type')}:
|
||||
<span style={{ color: '#1a1a1a' }}>{t(`userMemory.${node.properties?.entity_type}`)}</span>
|
||||
</div>
|
||||
<div style={{ color: '#5B6167', lineHeight: '22px' }}>
|
||||
{t('userMemory.community')}:
|
||||
<span style={{ color: node.color, fontWeight: 500 }}>{node.properties?.community_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
const CommunityNetwork: FC<{ onSelectCommunity?: (node: RawCommunityNode) => void }> = ({ onSelectCommunity }) => {
|
||||
const { id } = useParams()
|
||||
const { t } = useTranslation()
|
||||
const [graphData, setGraphData] = useState<CommunityGraphData | null>(null)
|
||||
const [empty, setEmpty] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
const controller = new AbortController()
|
||||
setEmpty(false)
|
||||
setGraphData(null)
|
||||
setLoading(true)
|
||||
getMemoryCommunityGraph(id, { signal: controller.signal }).then(res => {
|
||||
const raw = res as RawCommunityGraphData
|
||||
if (!raw.nodes?.length) { setEmpty(true); return }
|
||||
const built = buildCommunityGraphData(raw)
|
||||
if (!built) { setEmpty(true); return }
|
||||
setGraphData(built)
|
||||
}).catch((e) => { if (e?.code !== 'ERR_CANCELED') setEmpty(true) })
|
||||
.finally(() => setLoading(false))
|
||||
return () => controller.abort()
|
||||
}, [id])
|
||||
|
||||
if (loading) {
|
||||
return <Flex align="center" justify="center" className="rb:w-full rb:h-full">
|
||||
<Spin tip={t('userMemory.communityLoadingTip')} size="large">
|
||||
<div className="rb:w-64 rb:h-64" />
|
||||
</Spin>
|
||||
</Flex>
|
||||
}
|
||||
|
||||
return (
|
||||
<CommunityGraph
|
||||
data={graphData}
|
||||
empty={empty}
|
||||
showLegend={false}
|
||||
onCommunityClick={onSelectCommunity}
|
||||
renderTooltip={node => <NodeTooltip node={node} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CommunityNetwork)
|
||||
@@ -1,74 +1,43 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 18:34:04
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 18:34:04
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-20 11:04:52
|
||||
*/
|
||||
/**
|
||||
* Conversation Memory Component
|
||||
* Displays RAG conversation memory content list
|
||||
*/
|
||||
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { type FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Skeleton, List } from 'antd';
|
||||
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import Empty from '@/components/Empty';
|
||||
import PageScrollList from '@/components/PageScrollList'
|
||||
import Markdown from '@/components/Markdown'
|
||||
import {
|
||||
getRagContent
|
||||
} from '@/api/memory'
|
||||
import { getRagContentUrl } from '@/api/memory'
|
||||
|
||||
const ConversationMemory:FC = () => {
|
||||
const ConversationMemory: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [list, setList] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
getList()
|
||||
}, [id])
|
||||
/** Fetch conversation memory list */
|
||||
const getList = () => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
getRagContent(id).then((res) => {
|
||||
setList((res as { contents?: [] }).contents || [])
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<RbCard
|
||||
<RbCard
|
||||
title={t('userMemory.conversationMemory')}
|
||||
headerClassName="rb:text-[18px]! rb:leading-[24px]"
|
||||
bodyClassName="rb:h-[100%]! rb:overflow-hidden rb:py-0!"
|
||||
>
|
||||
{loading
|
||||
? <Skeleton />
|
||||
: list.length > 0
|
||||
? <List
|
||||
dataSource={list}
|
||||
grid={{ gutter: 12, column: 1 }}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item>
|
||||
<div
|
||||
key={index}
|
||||
className="rb:rounded-lg rb-border rb:px-4 rb:py-3 rb:bg-[#F0F3F8] rb:mt-2 rb:text-[#212332] rb:text-sm"
|
||||
>
|
||||
<Markdown content={item} />
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
: <Empty className="rb:h-full" />
|
||||
}
|
||||
<PageScrollList<string>
|
||||
url={getRagContentUrl}
|
||||
query={{ end_user_id: id }}
|
||||
column={1}
|
||||
renderItem={(item: string) => (
|
||||
<div
|
||||
className="rb:rounded-lg rb-border rb:px-4 rb:py-3 rb:bg-[#F0F3F8] rb:mt-2 rb:text-[#212332] rb:text-sm"
|
||||
>
|
||||
<Markdown content={item} />
|
||||
</div>
|
||||
)}
|
||||
className="rb:h-full!"
|
||||
/>
|
||||
</RbCard>
|
||||
)
|
||||
}
|
||||
export default ConversationMemory
|
||||
|
||||
export default ConversationMemory
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 18:32:23
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-16 15:01:50
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-20 11:07:02
|
||||
*/
|
||||
import { type FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -107,7 +107,7 @@ const PerceptualLastInfo: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
const handleDownload = async () => {
|
||||
if (!data.file_path) return
|
||||
window.open(data.file_path, '_blank')
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 18:32:00
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-11 15:06:05
|
||||
* @Last Modified time: 2026-03-20 11:45:16
|
||||
*/
|
||||
/**
|
||||
* Relationship Network Component
|
||||
@@ -10,51 +10,60 @@
|
||||
* Interactive force-directed graph visualization
|
||||
*/
|
||||
|
||||
import React, { type FC, useEffect, useState, useCallback } from 'react'
|
||||
import React, { type FC, useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Space, Flex } from 'antd'
|
||||
import { Space, Tabs, Flex, Divider } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
import type { GraphData, StatementNodeProperties, ExtractedEntityNodeProperties } from '../types'
|
||||
import type { RawCommunityNode } from '@/components/D3Graph/types'
|
||||
import {
|
||||
getMemorySearchEdges,
|
||||
} from '@/api/memory'
|
||||
import Tag from '@/components/Tag'
|
||||
import GraphNetworkChart, { type Node, type Edge } from '@/components/Charts/GraphNetworkChart'
|
||||
import CommunityNetwork from './CommunityNetwork'
|
||||
|
||||
const RelationshipNetwork:FC = () => {
|
||||
const RelationshipNetwork: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const [nodes, setNodes] = useState<Node[]>([])
|
||||
const [links, setLinks] = useState<Edge[]>([])
|
||||
const [categories, setCategories] = useState<{ name: string }[]>([])
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<Node | RawCommunityNode | null>(null)
|
||||
// const [fullScreen, setFullScreen] = useState<boolean>(false)
|
||||
const navigate = useNavigate()
|
||||
const [activeTab, setActiveTab] = useState('relationshipNetwork')
|
||||
|
||||
console.log('categories', categories)
|
||||
const edgeAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
/** Fetch relationship network data */
|
||||
const getEdgeData = useCallback(() => {
|
||||
if (!id) return
|
||||
edgeAbortRef.current?.abort()
|
||||
edgeAbortRef.current = new AbortController()
|
||||
setSelectedNode(null)
|
||||
getMemorySearchEdges(id).then((res) => {
|
||||
getMemorySearchEdges(id, { signal: edgeAbortRef.current.signal }).then((res) => {
|
||||
const { nodes, edges, statistics } = res as GraphData
|
||||
const curNodes: Node[] = []
|
||||
const curEdges: Edge[] = []
|
||||
const curNodeTypes = Object.keys(statistics.node_types).filter(vo => vo !== 'Dialogue')
|
||||
|
||||
|
||||
// Calculate connection count for each node
|
||||
const connectionCount: Record<string, number> = {}
|
||||
edges.forEach(edge => {
|
||||
connectionCount[edge.source] = (connectionCount[edge.source] || 0) + 1
|
||||
connectionCount[edge.target] = (connectionCount[edge.target] || 0) + 1
|
||||
})
|
||||
|
||||
|
||||
// Process node data
|
||||
nodes.filter(vo => vo.label !== 'Dialogue').forEach(node => {
|
||||
const connections = connectionCount[node.id] || 0
|
||||
const categoryIndex = curNodeTypes.indexOf(node.label)
|
||||
|
||||
|
||||
// Get display name based on node type
|
||||
let displayName = ''
|
||||
switch (node.label) {
|
||||
@@ -80,7 +89,7 @@ const RelationshipNetwork:FC = () => {
|
||||
} else {
|
||||
symbolSize = 35
|
||||
}
|
||||
|
||||
|
||||
curNodes.push({
|
||||
...node,
|
||||
name: displayName,
|
||||
@@ -88,7 +97,7 @@ const RelationshipNetwork:FC = () => {
|
||||
symbolSize: symbolSize, // Adjust node size based on connection count
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// Create mapping from node ID to label
|
||||
const nodeIdToLabel: Record<string, string> = {}
|
||||
nodes.forEach(node => {
|
||||
@@ -103,10 +112,10 @@ const RelationshipNetwork:FC = () => {
|
||||
value: edge.weight || 1
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
// Set categories
|
||||
const curCategories = curNodeTypes.map(type => ({ name: type }))
|
||||
|
||||
|
||||
setNodes(curNodes)
|
||||
setLinks(curEdges)
|
||||
setCategories(curCategories)
|
||||
@@ -115,6 +124,7 @@ const RelationshipNetwork:FC = () => {
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
getEdgeData()
|
||||
return () => { edgeAbortRef.current?.abort() }
|
||||
}, [id])
|
||||
|
||||
/** Navigate to full graph view */
|
||||
@@ -123,21 +133,41 @@ const RelationshipNetwork:FC = () => {
|
||||
const params = new URLSearchParams({
|
||||
nodeId: selectedNode.id,
|
||||
nodeLabel: selectedNode.label,
|
||||
nodeName: selectedNode.name || ''
|
||||
nodeName: (selectedNode as Node).name || ''
|
||||
})
|
||||
navigate(`/user-memory/detail/${id}/GRAPH?${params.toString()}`)
|
||||
}
|
||||
const handleChangeTab = (tab: string) => {
|
||||
if (tab === 'communityNetwork') {
|
||||
edgeAbortRef.current?.abort()
|
||||
} else {
|
||||
getEdgeData()
|
||||
}
|
||||
setActiveTab(tab)
|
||||
setSelectedNode(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rb:flex-1 rb:relative">
|
||||
<GraphNetworkChart
|
||||
nodes={nodes}
|
||||
links={links}
|
||||
categories={categories.map(vo => ({
|
||||
name: t(`userMemory.${vo.name}`)
|
||||
})) || []}
|
||||
onNodeClick={setSelectedNode}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={handleChangeTab}
|
||||
items={['relationshipNetwork', 'communityNetwork'].map(key => ({
|
||||
key,
|
||||
label: t(`userMemory.${key}`)
|
||||
}))}
|
||||
/>
|
||||
|
||||
{activeTab === 'communityNetwork'
|
||||
? <CommunityNetwork onSelectCommunity={community => setSelectedNode(community)} />
|
||||
: <GraphNetworkChart
|
||||
nodes={nodes}
|
||||
links={links}
|
||||
categories={categories.map(vo => ({
|
||||
name: t(`userMemory.${vo.name}`)
|
||||
})) || []}
|
||||
onNodeClick={(node) => setSelectedNode(node as Node)}
|
||||
/>
|
||||
}
|
||||
{selectedNode &&
|
||||
<RbCard
|
||||
title={t('userMemory.memoryDetails')}
|
||||
@@ -148,82 +178,100 @@ const RelationshipNetwork:FC = () => {
|
||||
extra={<div className="rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/userMemory/close.svg')]" onClick={() => setSelectedNode(null)}></div>}
|
||||
>
|
||||
<div className="rb:max-h-[calc(100vh-269px)] rb:overflow-auto">
|
||||
{selectedNode.name &&
|
||||
<div className="rb:font-medium rb:text-[16px] rb:text-[#212332] rb:leading-5.5 rb:mb-3">
|
||||
{selectedNode.name}
|
||||
</div>
|
||||
}
|
||||
<Flex vertical gap={24}>
|
||||
<div>
|
||||
<div className="rb:font-medium rb:leading-5">{t('userMemory.memoryContent')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{['Chunk', 'Dialogue', 'MemorySummary'].includes(selectedNode.label) && 'content' in selectedNode.properties
|
||||
? selectedNode.properties.content
|
||||
: selectedNode.label === 'ExtractedEntity' && 'description' in selectedNode.properties
|
||||
? selectedNode.properties.description
|
||||
: selectedNode.label === 'Statement' && 'statement' in selectedNode.properties
|
||||
? selectedNode.properties.statement
|
||||
: ''
|
||||
}
|
||||
{(selectedNode as RawCommunityNode).properties.community_id
|
||||
? <div className="rb:p-3 rb:pt-0">
|
||||
<div className="rb:font-medium rb:text-[#212332] rb:text-[16px] rb:leading-5.5 rb:pl-1">
|
||||
{(selectedNode as RawCommunityNode).properties.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="rb:font-medium rb:leading-5">{t('userMemory.created_at')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{dayjs(selectedNode?.properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
<div className="rb:mt-3 rb:font-medium rb:leading-5 rb:pl-1">{t('userMemory.summary')}</div>
|
||||
<div className="rb:bg-[#F6F6F6] rb:rounded-xl rb:px-3 rb:py-2.5 rb:mt-2">
|
||||
{(selectedNode as RawCommunityNode).properties.summary}
|
||||
</div>
|
||||
<Flex align="center" justify="space-between" className="rb:mt-5!">
|
||||
<span className="rb:text-[#5B6167] rb:font-regular rb:pl-1">{t('userMemory.member_count')}</span>
|
||||
<span className="rb:font-medium">{(selectedNode as RawCommunityNode).properties.member_count}{t('userMemory.member_count_desc')}</span>
|
||||
</Flex>
|
||||
|
||||
<Divider className='rb:my-2.5!' />
|
||||
<div className="rb:font-medium rb:leading-5 rb:pl-1">{t('userMemory.core_entities')}</div>
|
||||
<ul className="rb:list-disc rb:pl-4 rb:text-[#5B6167] rb:mt-2">
|
||||
{(selectedNode as RawCommunityNode).properties.core_entities.map((entity, index) => <li key={index}>{entity}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
: <>
|
||||
{(selectedNode as Node).name &&
|
||||
<div className="rb:font-medium rb:text-[16px] rb:text-[#212332] rb:leading-5.5 rb:mb-3">
|
||||
{(selectedNode as Node).name}
|
||||
</div>
|
||||
}
|
||||
<Flex vertical gap={24}>
|
||||
<div>
|
||||
<div className="rb:font-medium rb:leading-5">{t('userMemory.memoryContent')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{['Chunk', 'Dialogue', 'MemorySummary'].includes(selectedNode.label) && 'content' in selectedNode.properties
|
||||
? selectedNode.properties.content
|
||||
: selectedNode.label === 'ExtractedEntity' && 'description' in selectedNode.properties
|
||||
? selectedNode.properties.description
|
||||
: selectedNode.label === 'Statement' && 'statement' in selectedNode.properties
|
||||
? selectedNode.properties.statement
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedNode?.properties.associative_memory > 0 && <div>
|
||||
<div className="rb:font-medium rb:leading-5">{t('userMemory.associative_memory')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
<span className="rb:text-[#155EEF] rb:font-medium">{selectedNode?.properties.associative_memory}</span> {t('userMemory.unix')}{t('userMemory.associative_memory')}
|
||||
</div>
|
||||
</div>}
|
||||
<div>
|
||||
<div className="rb:font-medium rb:leading-5">{t('userMemory.created_at')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{dayjs((selectedNode as Node).properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(selectedNode as Node).properties.associative_memory > 0 && <div>
|
||||
<div className="rb:font-medium rb:leading-5">{t('userMemory.associative_memory')}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
|
||||
<span className="rb:text-[#155EEF] rb:font-medium">{(selectedNode as Node).properties.associative_memory}</span> {t('userMemory.unix')}{t('userMemory.associative_memory')}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{selectedNode.label === 'Statement' && <>
|
||||
{(['emotion_keywords', 'emotion_type', 'emotion_subject', 'importance_score'] as const).map(key => {
|
||||
const statementProps = selectedNode.properties as StatementNodeProperties;
|
||||
if ((key === 'emotion_keywords' && statementProps[key]?.length > 0) || typeof statementProps[key] === 'string') {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="rb:font-medium rb:leading-5">{t(`userMemory.Statement_${key}`)}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{key === 'emotion_keywords'
|
||||
? <Space>{statementProps.emotion_keywords.map((vo, index) => <Tag key={index}>{vo}</Tag>)}</Space>
|
||||
: statementProps[key]
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
{selectedNode.label === 'Statement' && (<>
|
||||
{(['emotion_keywords', 'emotion_type', 'emotion_subject', 'importance_score'] as const).map(key => {
|
||||
const p = selectedNode.properties as StatementNodeProperties
|
||||
if ((key === 'emotion_keywords' && p[key]?.length > 0) || typeof p[key] === 'string') {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="rb:font-medium rb:leading-5">{t(`userMemory.Statement_${key}`)}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{key === 'emotion_keywords'
|
||||
? <Space>{p.emotion_keywords.map((v, i) => <Tag key={i}>{v}</Tag>)}</Space>
|
||||
: p[key]}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</>)}
|
||||
|
||||
{selectedNode.label === 'ExtractedEntity' && <>
|
||||
{(['name', 'entity_type', 'aliases', 'connect_strngth', 'importance_score'] as const).map(key => {
|
||||
const p = selectedNode.properties as ExtractedEntityNodeProperties
|
||||
if (p[key]) {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="rb:font-medium rb:leading-5">{t(`userMemory.ExtractedEntity_${key}`)}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{Array.isArray(p[key]) && p[key].length > 0
|
||||
? p[key].map((v, i) => <div key={i}>- {v}</div>)
|
||||
: p[key]}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</>}
|
||||
</Flex>
|
||||
</>}
|
||||
|
||||
|
||||
{selectedNode.label === 'ExtractedEntity' && <>
|
||||
{(['name', 'entity_type', 'aliases', 'connect_strngth', 'importance_score'] as const).map(key => {
|
||||
const entityProps = selectedNode.properties as ExtractedEntityNodeProperties;
|
||||
if (entityProps[key]) {
|
||||
return (
|
||||
<div key={key}>
|
||||
<div className="rb:font-medium rb:leading-5">{t(`userMemory.ExtractedEntity_${key}`)}</div>
|
||||
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-2">
|
||||
{Array.isArray(entityProps[key]) && entityProps[key].length > 0
|
||||
? entityProps[key].map((vo, index) => <div key={index}>- {vo}</div>)
|
||||
: entityProps[key]
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</>}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<Flex align="center" justify="center" className="rb:absolute rb:bottom-3 rb:left-6 rb:right-6 rb:border rb:border-[#171719] rb:rounded-xl rb:h-11 rb:font-medium rb:leading-5 rb:cursor-pointer" onClick={handleViewAll}>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 17:57:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 17:57:15
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-13 11:49:52
|
||||
*/
|
||||
/**
|
||||
* User Memory Detail Types
|
||||
@@ -90,6 +90,7 @@ export interface ExtractedEntityNodeProperties {
|
||||
connect_strngth: string;
|
||||
importance_score: number;
|
||||
associative_memory: number;
|
||||
community_name?: string;
|
||||
}
|
||||
/**
|
||||
* Memory summary node
|
||||
@@ -246,4 +247,53 @@ export interface ForgetData {
|
||||
*/
|
||||
export interface GraphDetailRef {
|
||||
handleOpen: (vo: Node) => void
|
||||
}
|
||||
}
|
||||
// Community
|
||||
export type CommunityNodeType = 'Community' | 'ExtractedEntity';
|
||||
export type CommunityEdgeType = 'BELONGS_TO_COMMUNITY' | 'EXTRACTED_RELATIONSHIP';
|
||||
export type CommunityEntityType = "Person" | "Organization" | "ORG" | "Location" | "LOC" | "Event" | "Concept" | "Time" | "Position" | "WorkRole" | "System" | "Policy" | "HistoricalPeriod" | "HistoricalState" | "HistoricalEvent" | "EconomicFactor" | "Condition" | "Numeric" | "Work";
|
||||
// 社区节点
|
||||
export interface CommunityTypeNode {
|
||||
id: string;
|
||||
label: 'Community';
|
||||
properties: {
|
||||
community_id: string;
|
||||
end_user_id: string;
|
||||
member_count: number;
|
||||
updated_at: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
core_entities: string[];
|
||||
member_entity_ids: string[];
|
||||
};
|
||||
}
|
||||
// 核心实体
|
||||
export interface ExtractedEntityTypeNode {
|
||||
id: string;
|
||||
label: 'ExtractedEntity';
|
||||
properties: {
|
||||
name: string;
|
||||
end_user_id: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
entity_type: CommunityEntityType;
|
||||
community_name: string;
|
||||
};
|
||||
}
|
||||
// 社区图谱连线
|
||||
export interface CommunityEdge {
|
||||
id: string;
|
||||
target: string;
|
||||
source: string;
|
||||
}
|
||||
export interface CommunityStatistics {
|
||||
total_nodes: number;
|
||||
total_edges: number;
|
||||
node_types: Record<CommunityNodeType, number>;
|
||||
edge_types: Record<CommunityEdgeType, number>;
|
||||
}
|
||||
export interface CommunityGraphData {
|
||||
nodes: (CommunityTypeNode | ExtractedEntityTypeNode)[];
|
||||
edges: CommunityEdge[];
|
||||
statistics: CommunityStatistics;
|
||||
}
|
||||
|
||||
@@ -1,7 +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, FileAddOutlined } from '@ant-design/icons'
|
||||
|
||||
interface CanvasToolbarProps {
|
||||
miniMapRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -9,20 +10,26 @@ interface CanvasToolbarProps {
|
||||
isHandMode: boolean;
|
||||
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
zoomLevel: number;
|
||||
addNotes: () => void;
|
||||
}
|
||||
|
||||
const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
miniMapRef,
|
||||
graphRef,
|
||||
zoomLevel,
|
||||
// canUndo,
|
||||
// canRedo,
|
||||
// onUndo,
|
||||
// onRedo,
|
||||
addNotes,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{/* 小地图 */}
|
||||
<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:rounded-lg rb:shadow-[0px_2px_6px_0px_rgba(33,35,50,0.15)] rb:px-3 rb:py-2.25 rb:absolute rb:bottom-5 rb:right-8 rb:flex rb:flex-row rb:gap-2 rb:z-1000">
|
||||
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/workflow/minus.png')]" onClick={() => graphRef.current?.zoom(-0.1)}></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: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)}
|
||||
onChange={(value: number | string) => {
|
||||
@@ -50,7 +57,9 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
|
||||
variant='borderless'
|
||||
size="small"
|
||||
/>
|
||||
<div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/workflow/plus.png')]" onClick={() => graphRef.current?.zoom(0.1)}></div>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:10:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 18:51:48
|
||||
* @Last Modified time: 2026-03-20 11:25:51
|
||||
*/
|
||||
/**
|
||||
* Workflow Chat Component
|
||||
@@ -21,50 +21,59 @@
|
||||
*
|
||||
* @component
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react'
|
||||
import { forwardRef, useImperativeHandle, useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { App, Space, Button, Flex, Dropdown, type MenuProps, Divider } from 'antd'
|
||||
import { App, Flex } from 'antd'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import RbDrawer from '@/components/RbDrawer';
|
||||
import VariableConfigModal from './VariableConfigModal'
|
||||
import { draftRun } from '@/api/application';
|
||||
import Empty from '@/components/Empty'
|
||||
import ChatContent from '@/components/Chat/ChatContent'
|
||||
import type { ChatItem } from '@/components/Chat/types'
|
||||
import dayjs from 'dayjs'
|
||||
import type { ChatRef, VariableConfigModalRef, GraphRef } from '../../types'
|
||||
import type { ChatRef, GraphRef, WorkflowConfig } from '../../types'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import type { Variable } from '../Properties/VariableList/types'
|
||||
import ChatInput from '@/components/Chat/ChatInput'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
import ChatToolbar from '@/components/Chat/ChatToolbar'
|
||||
import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
|
||||
import Runtime from './Runtime';
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
|
||||
|
||||
const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId, graphRef }, ref) => {
|
||||
const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: WorkflowConfig | null }>(({ appId, graphRef, data }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
// State management
|
||||
const [open, setOpen] = useState(false) // Drawer visibility
|
||||
const [loading, setLoading] = useState(false) // Send button loading state
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([]) // Chat message history
|
||||
const [variables, setVariables] = useState<Variable[]>([]) // Workflow input variables
|
||||
const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state
|
||||
const [conversationId, setConversationId] = useState<string | null>(null) // Current conversation ID
|
||||
const [fileList, setFileList] = useState<any[]>([]) // Uploaded files
|
||||
const [message, setMessage] = useState<string | undefined>(undefined) // Current input message
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
const toolbarRef = useRef<ChatToolbarRef>(null)
|
||||
const toolbarCallbackRef = useCallback((node: ChatToolbarRef | null) => {
|
||||
(toolbarRef as React.MutableRefObject<ChatToolbarRef | null>).current = node
|
||||
}, [])
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [chatList, setChatList] = useState<ChatItem[]>([])
|
||||
const [variables, setVariables] = useState<Variable[]>([])
|
||||
const [streamLoading, setStreamLoading] = useState(false)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [fileList, setFileList] = useState<any[]>([])
|
||||
const [message, setMessage] = useState<string | undefined>(undefined)
|
||||
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
|
||||
|
||||
/**
|
||||
* Opens the chat drawer and loads workflow variables from the start node
|
||||
*/
|
||||
const handleOpen = () => {
|
||||
setOpen(true)
|
||||
getVariables()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.features && open) setFeatures(data.features)
|
||||
}, [open, data?.features])
|
||||
|
||||
useEffect(() => {
|
||||
if (open && graphRef.current && toolbarRef.current) {
|
||||
getVariables()
|
||||
}
|
||||
}, [open])
|
||||
/**
|
||||
* Extracts variables from the workflow's start node and merges with previous values
|
||||
*/
|
||||
@@ -84,7 +93,9 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
vo.value = lastVo.value
|
||||
}
|
||||
})
|
||||
setVariables(curVariables)
|
||||
console.log('curVariables', curVariables)
|
||||
setVariables([...curVariables])
|
||||
toolbarRef.current?.setVariables([...curVariables])
|
||||
}
|
||||
}
|
||||
/**
|
||||
@@ -96,22 +107,12 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
setVariables([])
|
||||
setConversationId(null)
|
||||
setMessage(undefined)
|
||||
toolbarRef.current?.setFiles([])
|
||||
toolbarRef.current?.setVariables([])
|
||||
setFileList([])
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
}
|
||||
/**
|
||||
* Opens the variable configuration modal
|
||||
*/
|
||||
const handleEditVariables = () => {
|
||||
variableConfigModalRef.current?.handleOpen(variables)
|
||||
}
|
||||
/**
|
||||
* Saves updated variable values from the modal
|
||||
*/
|
||||
const handleSave = (values: Variable[]) => {
|
||||
setVariables([...values])
|
||||
}
|
||||
/**
|
||||
* Sends a message to execute the workflow
|
||||
*
|
||||
@@ -150,10 +151,14 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
|
||||
setLoading(true)
|
||||
const message = msg
|
||||
const files = toolbarRef.current?.getFiles() || []
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'user',
|
||||
content: message,
|
||||
created_at: Date.now(),
|
||||
meta_data: {
|
||||
files
|
||||
},
|
||||
}])
|
||||
setChatList(prev => [...prev, {
|
||||
role: 'assistant',
|
||||
@@ -338,13 +343,14 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
}
|
||||
|
||||
setMessage(undefined)
|
||||
toolbarRef.current?.setFiles([])
|
||||
setFileList([])
|
||||
const data = {
|
||||
message: message,
|
||||
variables: params,
|
||||
stream: true,
|
||||
conversation_id: conversationId,
|
||||
files: fileList.map(file => {
|
||||
files: files.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
@@ -359,7 +365,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
setStreamLoading(true)
|
||||
draftRun(appId, data, handleStreamMessage)
|
||||
.catch((error) => {
|
||||
console.log('draftRun error', error)
|
||||
const errorInfo = JSON.parse(error.message)
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
@@ -368,7 +374,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
...newList[lastIndex],
|
||||
status: 'failed',
|
||||
content: null,
|
||||
subContent: error.error
|
||||
subContent: errorInfo.error
|
||||
}
|
||||
}
|
||||
return newList
|
||||
@@ -379,65 +385,20 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current input message
|
||||
*/
|
||||
const handleMessageChange = (message: string) => {
|
||||
setMessage(message)
|
||||
}
|
||||
/**
|
||||
* Handles file upload from local device
|
||||
*/
|
||||
const fileChange = (file?: any) => {
|
||||
setFileList([...fileList, file])
|
||||
}
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
setFileList([...fileList, {
|
||||
response: { data: file },
|
||||
thumbUrl: file.url,
|
||||
type: file.type
|
||||
}])
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles dropdown menu actions for file upload
|
||||
*/
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch(key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Adds files from remote URL modal
|
||||
*/
|
||||
const addFileList = (list?: any[]) => {
|
||||
if (!list || list.length <= 0) return
|
||||
setFileList([...fileList, ...(list || [])])
|
||||
}
|
||||
/**
|
||||
* Updates the entire file list (used when removing files)
|
||||
*/
|
||||
const updateFileList = (list?: any[]) => {
|
||||
setFileList([...list || []])
|
||||
toolbarRef.current?.setFiles([...list || []])
|
||||
}
|
||||
|
||||
// Expose methods to parent component via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
console.log('fileList', fileList)
|
||||
|
||||
return (
|
||||
<RbDrawer
|
||||
title={<Flex align="center" gap={10}>
|
||||
{t('workflow.run')}
|
||||
{variables.length > 0 && <Space>
|
||||
<Button size="small" onClick={handleEditVariables}>{t('application.variable')}</Button>
|
||||
</Space>}
|
||||
</Flex>}
|
||||
classNames={{
|
||||
body: 'rb:p-0!'
|
||||
@@ -466,48 +427,16 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
fileChange={updateFileList}
|
||||
fileList={fileList}
|
||||
onSend={handleSend}
|
||||
onChange={handleMessageChange}
|
||||
onChange={(msg) => setMessage(msg)}
|
||||
>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
onChange={fileChange}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
<ChatToolbar
|
||||
ref={toolbarCallbackRef}
|
||||
features={features}
|
||||
onFilesChange={setFileList}
|
||||
onVariablesChange={setVariables}
|
||||
/>
|
||||
</ChatInput>
|
||||
</Flex>
|
||||
|
||||
<VariableConfigModal
|
||||
ref={variableConfigModalRef}
|
||||
refresh={handleSave}
|
||||
variables={variables}
|
||||
/>
|
||||
|
||||
<UploadFileListModal
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
</RbDrawer>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-24 17:57:08
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 16:48:09
|
||||
* @Last Modified time: 2026-03-12 13:39:24
|
||||
*/
|
||||
/*
|
||||
* Runtime Component
|
||||
@@ -225,12 +225,13 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
|
||||
</div>
|
||||
)
|
||||
: <>
|
||||
{item.error
|
||||
? <div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 ", getStatus('failed'))}>
|
||||
{item.error &&
|
||||
<div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 rb:mb-2 rb:-mt-4", getStatus('failed'))}>
|
||||
<Markdown content={item.error} />
|
||||
</div>
|
||||
: renderChild(item.subContent)
|
||||
}</>
|
||||
</div>
|
||||
}
|
||||
{renderChild(item.subContent)}
|
||||
</>
|
||||
)
|
||||
}]}
|
||||
/>
|
||||
|
||||
@@ -8,7 +8,6 @@ import RbModal from '@/components/RbModal'
|
||||
|
||||
interface VariableEditModalProps {
|
||||
refresh: (values: Variable[]) => void;
|
||||
variables: Variable[]
|
||||
}
|
||||
|
||||
const VariableConfigModal = forwardRef<VariableConfigModalRef, VariableEditModalProps>(({
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-09 18:30:28
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-06 11:49:30
|
||||
* @Last Modified time: 2026-03-20 11:24:26
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Popover } from 'antd';
|
||||
@@ -148,18 +148,23 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
if (sourcePortGroup === 'left') {
|
||||
// Connect from left port to new node's right side
|
||||
targetPort = targetPorts.find((port: any) => port.group === 'right')?.id || 'right';
|
||||
graph.addEdge({
|
||||
source: { cell: newNode.id, port: targetPort },
|
||||
target: { cell: sourceNode.id, port: sourcePort },
|
||||
...edgeAttrs
|
||||
// zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
|
||||
});
|
||||
} else {
|
||||
// Connect from right port to new node's left side
|
||||
targetPort = targetPorts.find((port: any) => port.group === 'left')?.id || 'left';
|
||||
graph.addEdge({
|
||||
source: { cell: sourceNode.id, port: sourcePort },
|
||||
target: { cell: newNode.id, port: targetPort },
|
||||
...edgeAttrs
|
||||
// zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
|
||||
});
|
||||
}
|
||||
|
||||
graph.addEdge({
|
||||
source: { cell: sourceNode.id, port: sourcePort },
|
||||
target: { cell: newNode.id, port: targetPort },
|
||||
...edgeAttrs
|
||||
// zIndex: sourceNodeData.cycle && sourceNodeType == 'cycle-start' ? 1 : sourceNodeData.cycle ? 2 : 0
|
||||
});
|
||||
|
||||
// Adjust loop node size when child node is added via port within loop node
|
||||
const cycleId = sourceNodeData.cycle;
|
||||
if (cycleId) {
|
||||
@@ -230,20 +235,27 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
|
||||
const isChildOfLoop = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'loop');
|
||||
const isChildOfIteration = sourceNodeData?.cycle && graph?.getNodes().find((n: any) => n.getData()?.id === sourceNodeData.cycle && n.getData()?.type === 'iteration');
|
||||
|
||||
const sourcePortInfo = sourceNode?.getPorts().find((p: any) => p.id === sourcePort);
|
||||
const sourcePortGroup = sourcePortInfo?.group || sourcePort;
|
||||
const isLeftPort = sourcePortGroup === 'left';
|
||||
|
||||
let filteredNodes;
|
||||
if (isChildOfLoop) {
|
||||
// Use same filtering as AddNode for child nodes of loop, but allow break
|
||||
// Use same filtering as AddNode for child nodes of loop, but allow break
|
||||
filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type));
|
||||
} else if (isChildOfIteration) {
|
||||
// Filter out loop and iteration nodes for children of iteration nodes, but allow break
|
||||
filteredNodes = category.nodes.filter(nodeType => !['start', 'end', 'loop', 'cycle-start', 'iteration'].includes(nodeType.type));
|
||||
} else {
|
||||
// Original filtering for non-loop child nodes
|
||||
filteredNodes = category.nodes.filter(nodeType => !['start', 'break', 'cycle-start'].includes(nodeType.type));
|
||||
filteredNodes = category.nodes.filter(nodeType =>
|
||||
nodeType.type !== 'start' && nodeType.type !== 'cycle-start' && nodeType.type !== 'break'
|
||||
);
|
||||
}
|
||||
|
||||
if (isLeftPort) {
|
||||
filteredNodes = filteredNodes.filter(nodeType => nodeType.type !== 'end');
|
||||
}
|
||||
|
||||
if (filteredNodes.length === 0) return null;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-09 18:35:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 15:20:32
|
||||
* @Last Modified time: 2026-03-20 11:32:44
|
||||
*/
|
||||
import { type FC, useRef, useState } from "react";
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -211,7 +211,9 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
|
||||
</Form.Item>
|
||||
}
|
||||
{values?.body?.content_type === 'binary' &&
|
||||
<Form.Item name={['body', 'data']} noStyle>
|
||||
<Form.Item name={['body', 'data']}
|
||||
className="rb:bg-[#F6F6F6] rb:border-[#F6F6F6]! rb:hover:bg-white rb:hover:border-[#171719]! rb:border rb:rounded-lg rb:px-2! rb:py-1.5! rb:mb-0!"
|
||||
>
|
||||
<Editor
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={options.filter(vo => vo.dataType.includes('file'))}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:39:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-07 17:16:13
|
||||
* @Last Modified time: 2026-03-20 11:25:59
|
||||
*/
|
||||
import { type FC, useEffect, useState, useMemo } from "react";
|
||||
import clsx from 'clsx'
|
||||
@@ -86,7 +86,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedNode && form) {
|
||||
const { type = 'default', name = '', config } = selectedNode.getData() || {}
|
||||
const { type = 'default', name = '', config, id } = selectedNode.getData() || {}
|
||||
const initialValue: Record<string, any> = {}
|
||||
Object.keys(config || {}).forEach(key => {
|
||||
if (config && config[key] && 'defaultValue' in config[key]) {
|
||||
@@ -96,7 +96,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
|
||||
form.setFieldsValue({
|
||||
type,
|
||||
id: selectedNode.id,
|
||||
id,
|
||||
name,
|
||||
...initialValue,
|
||||
})
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:06:18
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-07 17:10:59
|
||||
* @Last Modified time: 2026-03-20 11:23:17
|
||||
*/
|
||||
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';
|
||||
|
||||
@@ -475,10 +476,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;
|
||||
|
||||
@@ -526,6 +598,12 @@ export const nodeRegisterLibrary: ReactShapeConfig[] = [
|
||||
height: 28,
|
||||
component: AddNode,
|
||||
},
|
||||
{
|
||||
shape: 'notes-node',
|
||||
width: nodeWidth,
|
||||
height: 120,
|
||||
component: NoteNode,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -658,7 +736,7 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
|
||||
defaultPortItems[0],
|
||||
...(['IF', 'ELSE'].map((_, index) => ({
|
||||
group: 'right',
|
||||
id: `CASE${index}`,
|
||||
id: `CASE${index + 1}`,
|
||||
args: {
|
||||
...portArgs,
|
||||
y: portItemArgsY * index + conditionNodePortItemArgsY,
|
||||
@@ -677,7 +755,7 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
|
||||
defaultPortItems[0],
|
||||
...(['分类1', '分类2'].map((_text, index) => ({
|
||||
group: 'right',
|
||||
id: `CASE${index}`,
|
||||
id: `CASE${index + 1}`,
|
||||
args: {
|
||||
...portArgs,
|
||||
y: portItemArgsY * index + conditionNodePortItemArgsY,
|
||||
@@ -758,6 +836,11 @@ export const graphNodeLibrary: Record<string, NodeConfig> = {
|
||||
items: [defaultPortItems[0]],
|
||||
},
|
||||
},
|
||||
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-03-06 14:49:17
|
||||
* @Last Modified time: 2026-03-20 11:26:43
|
||||
*/
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
@@ -12,9 +12,11 @@ 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, defaultPortItems, portItemArgsY, edge_width, conditionNodePortItemArgsY, conditionNodeItemHeight, conditionNodeHeight } from '../constant';
|
||||
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode, defaultPortItems, portItemArgsY, edge_width, conditionNodePortItemArgsY, conditionNodeItemHeight, conditionNodeHeight, notesConfig } from '../constant';
|
||||
import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types';
|
||||
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
|
||||
import { useUser } from '@/store/user';
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
|
||||
/**
|
||||
* Props for useWorkflowGraph hook
|
||||
@@ -24,6 +26,8 @@ export interface UseWorkflowGraphProps {
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
/** Reference to the minimap container element */
|
||||
miniMapRef: React.RefObject<HTMLDivElement>;
|
||||
/** Callback when features config is loaded */
|
||||
onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +68,9 @@ export interface UseWorkflowGraphReturn {
|
||||
chatVariables: ChatVariable[];
|
||||
/** Function to update chat variables */
|
||||
setChatVariables: React.Dispatch<React.SetStateAction<ChatVariable[]>>;
|
||||
|
||||
handleAddNotes: () => void;
|
||||
handleSaveFeaturesConfig: (value: FeaturesConfigForm) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,11 +82,13 @@ export interface UseWorkflowGraphReturn {
|
||||
export const useWorkflowGraph = ({
|
||||
containerRef,
|
||||
miniMapRef,
|
||||
onFeaturesLoad,
|
||||
}: UseWorkflowGraphProps): UseWorkflowGraphReturn => {
|
||||
// Hooks
|
||||
const { id } = useParams();
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation()
|
||||
const { user } = useUser();
|
||||
|
||||
// Refs
|
||||
const graphRef = useRef<Graph>();
|
||||
@@ -111,6 +120,7 @@ export const useWorkflowGraph = ({
|
||||
})
|
||||
setChatVariables(initChatVariables)
|
||||
setConfig({ ...rest, variables: initChatVariables })
|
||||
onFeaturesLoad?.(rest.features)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,7 +138,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: NodeProperties | undefined = [...nodeLibrary, { nodes: [unknownNode, notesConfig] }]
|
||||
.flatMap(category => category.nodes)
|
||||
.find(n => n.type === type) as NodeProperties
|
||||
nodeLibraryConfig = JSON.parse(JSON.stringify({ ...nodeLibraryConfig, config: nodeLibraryConfig.config || {} }))
|
||||
@@ -197,6 +207,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)) {
|
||||
@@ -471,11 +488,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 => {
|
||||
@@ -488,11 +506,13 @@ export const useWorkflowGraph = ({
|
||||
}
|
||||
});
|
||||
node.setData({
|
||||
...node.getData(),
|
||||
...nodeData,
|
||||
isSelected: true,
|
||||
});
|
||||
setSelectedNode(node);
|
||||
clearEdgeSelect()
|
||||
if (nodeData.type !== 'notes') {
|
||||
setSelectedNode(node);
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Handle edge click event
|
||||
@@ -601,7 +621,14 @@ export const useWorkflowGraph = ({
|
||||
*/
|
||||
const parseEvent = () => {
|
||||
if (!graphRef.current?.isClipboardEmpty()) {
|
||||
graphRef.current?.paste({ offset: 32 });
|
||||
const pastedNodes = graphRef.current?.paste({ offset: 32 }) ?? [];
|
||||
pastedNodes.forEach(cell => {
|
||||
if (cell.isNode()) {
|
||||
const data = cell.getData();
|
||||
const newId = `${(data.type as string).replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
cell.setData({ ...data, id: newId });
|
||||
}
|
||||
});
|
||||
blankClick();
|
||||
}
|
||||
return false;
|
||||
@@ -749,6 +776,8 @@ export const useWorkflowGraph = ({
|
||||
panning: isHandMode,
|
||||
mousewheel: {
|
||||
enabled: true,
|
||||
factor: 0.1,
|
||||
modifiers: null,
|
||||
},
|
||||
connecting: {
|
||||
connector: {
|
||||
@@ -772,8 +801,23 @@ export const useWorkflowGraph = ({
|
||||
createEdge() {
|
||||
return graphRef.current?.createEdge(edgeAttrs);
|
||||
},
|
||||
validateConnection({ sourceCell, targetCell, targetMagnet }) {
|
||||
validateConnection({ sourceCell, targetCell, sourceMagnet, targetMagnet }) {
|
||||
if (!targetMagnet) return false;
|
||||
|
||||
// Only allow right port → left port connections
|
||||
const getPortGroup = (magnet: Element) => {
|
||||
let el: Element | null = magnet;
|
||||
while (el) {
|
||||
const group = el.getAttribute('port-group');
|
||||
if (group) return group;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const sourceGroup = sourceMagnet ? getPortGroup(sourceMagnet) : null;
|
||||
const targetGroup = targetMagnet ? getPortGroup(targetMagnet) : null;
|
||||
|
||||
if (sourceGroup === 'left' || targetGroup === 'right') return false;
|
||||
|
||||
// Node cannot connect to itself
|
||||
if (sourceCell?.id === targetCell?.id) return false;
|
||||
@@ -872,8 +916,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();
|
||||
};
|
||||
}, []);
|
||||
@@ -897,7 +964,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)}`,
|
||||
@@ -948,6 +1015,9 @@ export const useWorkflowGraph = ({
|
||||
}) || [];
|
||||
const edges = graphRef.current?.getEdges() || []
|
||||
|
||||
|
||||
console.log('config', config)
|
||||
|
||||
const params = {
|
||||
...config,
|
||||
variables: chatVariables.map(v => {
|
||||
@@ -1116,6 +1186,35 @@ 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 },
|
||||
});
|
||||
}
|
||||
const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => {
|
||||
setConfig(prev => prev ? { ...prev, features: value } as WorkflowConfig : prev)
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
setConfig,
|
||||
@@ -1133,6 +1232,8 @@ export const useWorkflowGraph = ({
|
||||
parseEvent,
|
||||
handleSave,
|
||||
chatVariables,
|
||||
setChatVariables
|
||||
setChatVariables,
|
||||
handleAddNotes,
|
||||
handleSaveFeaturesConfig
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,12 +6,12 @@ import Properties from './components/Properties';
|
||||
import CanvasToolbar from './components/CanvasToolbar';
|
||||
import PortClickHandler from './components/PortClickHandler';
|
||||
import { useWorkflowGraph } from './hooks/useWorkflowGraph';
|
||||
import type { WorkflowRef } from '@/views/ApplicationConfig/types'
|
||||
import type { WorkflowRef, FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
import Chat from './components/Chat/Chat';
|
||||
import type { ChatRef, AddChatVariableRef } from './types'
|
||||
import AddChatVariable from './components/AddChatVariable';
|
||||
|
||||
const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesConfigForm | undefined) => void }>(({ onFeaturesLoad }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const miniMapRef = useRef<HTMLDivElement>(null);
|
||||
const addChatVariableRef = useRef<AddChatVariableRef>(null)
|
||||
@@ -32,8 +32,10 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
parseEvent,
|
||||
handleSave,
|
||||
chatVariables,
|
||||
setChatVariables
|
||||
} = useWorkflowGraph({ containerRef, miniMapRef });
|
||||
setChatVariables,
|
||||
handleAddNotes,
|
||||
handleSaveFeaturesConfig
|
||||
} = useWorkflowGraph({ containerRef, miniMapRef, onFeaturesLoad });
|
||||
|
||||
const onDragOver = (event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
@@ -53,7 +55,9 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
handleRun,
|
||||
graphRef,
|
||||
addVariable,
|
||||
config
|
||||
config,
|
||||
features: config?.features,
|
||||
handleSaveFeaturesConfig
|
||||
}))
|
||||
return (
|
||||
<div className="rb:h-[calc(100vh-64px)] rb:relative">
|
||||
@@ -74,6 +78,7 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
isHandMode={isHandMode}
|
||||
setIsHandMode={setIsHandMode}
|
||||
zoomLevel={zoomLevel}
|
||||
addNotes={handleAddNotes}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -92,6 +97,7 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
|
||||
}
|
||||
<Chat
|
||||
ref={chatRef}
|
||||
data={config}
|
||||
graphRef={graphRef}
|
||||
appId={config?.app_id as string}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Graph } from '@antv/x6';
|
||||
import type { KnowledgeConfig } from './components/Properties/Knowledge/types'
|
||||
import type { Variable } from './components/Properties/VariableList/types'
|
||||
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types'
|
||||
export interface NodeConfig {
|
||||
type: 'input' | 'textarea' | 'select' | 'inputNumber' | 'slider' | 'customSelect' | 'define' | 'knowledge' | 'variableList' | string;
|
||||
placeholder?: string;
|
||||
@@ -89,6 +90,8 @@ export interface WorkflowConfig {
|
||||
is_active: boolean;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
|
||||
features?: FeaturesConfigForm;
|
||||
}
|
||||
|
||||
export interface ChatRef {
|
||||
|
||||