Merge branch 'develop' into release/v0.2.7

This commit is contained in:
Ke Sun
2026-03-16 15:47:00 +08:00
committed by GitHub
155 changed files with 13164 additions and 1796 deletions

View File

@@ -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-13 17:07:54
*/
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; }) => {
@@ -143,4 +143,26 @@ export const appExport = (app_id: string, appName: string, data?: { release_vers
// 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}`)
}

View File

@@ -2,9 +2,10 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 14:00:06
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-12 18:25:06
* @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) => {

View File

@@ -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) => {

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -1,13 +1,23 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-06 21:11:51
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 17:11:14
*/
import { type FC, useRef, useState } from 'react'
import RecordRTC from 'recordrtc'
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>;
}
@@ -17,9 +27,12 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
action = fileUploadUrlWithoutApiPrefix,
requestConfig = {}
}) => {
// 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 () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
@@ -34,6 +47,7 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
}
}
/** Stop recording, upload the audio blob, then invoke the completion callback */
const stopRecording = () => {
if (recorderRef.current) {
recorderRef.current.stopRecording(() => {
@@ -49,6 +63,7 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
type: blob.type,
url
}, blob)
// Release recorder resources after upload
recorderRef.current?.destroy()
recorderRef.current = null
})
@@ -57,12 +72,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} ${
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}
/>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:01:59
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-02 15:46:05
* @Last Modified time: 2026-03-12 14:59:38
*/
/**
@@ -15,7 +15,7 @@
*/
import { type FC, type ReactNode, useEffect } from 'react';
import { type RadioGroupProps } from 'antd';
import { type RadioGroupProps, Flex } from 'antd';
import clsx from 'clsx'
// Button checkbox component props
@@ -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,7 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
icon,
checkedIcon,
children,
cicle = false
}) => {
// Listen to value changes and trigger side effects via onValueChange callback
useEffect(() => {
@@ -57,21 +59,26 @@ const ButtonCheckbox: FC<ButtonCheckboxProps> = ({
}
return (
<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]", {
<Flex
align="center"
justify={cicle ? 'center' : 'start'}
gap={4}
className={clsx("rb:flex rb:items-center rb:cursor-pointer rb:border rb:hover:bg-[#F6F6F6]", {
'rb:size-7 rb:rounded-[14px] rb:border-[0.5px] rb:border-[#EBEBEB]': cicle,
'rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6': !cicle,
// Checked state: blue background and border
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[#155EEF] rb:text-[#155EEF]": checked,
"rb:bg-[rgba(21,94,239,0.06)] rb:border-[rgba(21,94,239,0.25)] rb:hover:bg-[rgba(21,94,239,0.06)] rb:text-[#155EEF]": checked,
// Unchecked state: gray border and dark text
"rb:border-[#DFE4ED] rb:text-[#212332]": !checked,
})}
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}
</div>
</Flex>
);
};

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2025-12-10 16:46:09
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-06 21:05:09
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-12 13:57:49
*/
import { type FC } from 'react'
import ChatInput from './ChatInput'
@@ -25,7 +25,8 @@ const Chat: FC<ChatProps> = ({
labelFormat,
errorDesc,
fileList,
fileChange
fileChange,
renderRuntime
}) => {
return (
<div className="rb:h-full rb:relative rb:pt-2">
@@ -37,6 +38,7 @@ const Chat: FC<ChatProps> = ({
empty={empty}
labelFormat={labelFormat}
errorDesc={errorDesc}
renderRuntime={renderRuntime}
/>
{/* Chat input area */}

View File

@@ -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-12 13:57:51
*/
import { type ReactNode } from 'react'
@@ -53,6 +53,7 @@ export interface ChatProps {
fileList?: any[];
/** Attachment update */
fileChange?: (fileList: any[]) => void;
renderRuntime?: (item: ChatItem, index: number) => ReactNode;
}
/**

View 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)

View 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
}

View 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>
}

View 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)
})
})
}

View File

@@ -458,6 +458,7 @@ export const en = {
imageSquareRequired: 'Please upload a square image',
nameInvalid: 'Name cannot start or end with a space',
notAllSpaces: 'Cannot be all spaces',
view: 'View',
},
model: {
searchPlaceholder: 'search model…',
@@ -1371,6 +1372,59 @@ export const en = {
gotoDetail: 'View Details',
dify: 'Dify',
pleaseUploadFile: 'Please upload file',
setting: 'Settings',
funConfig: 'Features',
fileUpload: 'File Upload',
fileUploadDesc: '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',
textTranfer: 'Text to Speech',
textTranferDesc: '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',
@@ -1482,6 +1536,33 @@ 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',
},
space: {
createSpace: 'Create Space',

View File

@@ -755,6 +755,59 @@ export const zh = {
gotoDetail: '查看详情',
dify: 'Dify',
pleaseUploadFile: '请上传文件',
setting: '设置',
funConfig: '功能',
fileUpload: '文件上传',
fileUploadDesc: '聊天输入框支持上传文件。类型包括图片、文档以及其它类型',
settings: '文件上传设置',
uploadType: '上传类型',
local: '本地上传',
both: '两者皆可',
maxSize: '最大上传大小',
maxSizeDesc: '文档 < 200.00MB,图片 < 10.00MB,音频 < 50.00MB,视频 < 100.00MB',
supportedTypes: '支持的文件类型',
document: '文档',
image: '图片',
audio: '音频',
video: '视频',
other: '其他文件类型',
otherFormats: '指定其他文件类型',
maxCount: '最大文件数',
singleMaxSize: '单文件最大大小',
unix: '个',
textTranfer: '文字转语音',
textTranferDesc: '文本可以转换成语言',
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}} 条记录'
@@ -1038,6 +1091,7 @@ export const zh = {
imageSquareRequired: '请上传正方形比例图片',
nameInvalid: '不能是空格开头或结尾',
notAllSpaces: '不能是纯空格',
view: '查看',
},
model: {
searchPlaceholder: '搜索模型…',
@@ -1480,6 +1534,33 @@ 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: '点击左侧图表中的社区查看详情',
},
space: {
createSpace: '创建空间',
@@ -2546,7 +2627,7 @@ export const zh = {
memoryHealthVisualization: '记忆健康可视化',
activationValueDistribution: '激活值分布',
forgettingTrend: '遗忘趋势近7天',
nodes_without_activation: '观察区',
low_activation_nodes: '遗忘区',
health_nodes: '健康区',

View File

@@ -45,6 +45,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" },

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:21
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-03 14:24:34
* @Last Modified time: 2026-03-13 16:58:15
*/
import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
import clsx from 'clsx'
@@ -23,7 +23,8 @@ import type {
MemoryConfig,
AiPromptModalRef,
Source,
ChatVariableConfigModalRef
ChatVariableConfigModalRef,
FunConfigForm
} from './types'
import type { Variable } from './components/VariableList/types'
import type { KnowledgeConfig } from './components/Knowledge/types'
@@ -41,6 +42,7 @@ import ToolList from './components/ToolList/ToolList'
import SkillList from './components/Skill'
import ChatVariableConfigModal from './components/ChatVariableConfigModal';
import type { Skill } from '@/views/Skills/types'
import FunConfig from './components/FunConfig'
/**
* Description wrapper component
@@ -99,7 +101,7 @@ const SwitchWrapper: FC<{ title: string, desc?: string, name: string | string[];
* @param name - Form field name
* @param url - API URL for options
*/
const SelectWrapper: FC<{ title: string, desc: string, name: string | string[], url: string }> = ({ title, desc, name, url }) => {
const SelectWrapper: FC<{ title: string, desc: string, name: string | string[], url: string; disabled?: boolean }> = ({ title, desc, name, url, disabled }) => {
const { t } = useTranslation();
return (
<>
@@ -115,6 +117,7 @@ const SelectWrapper: FC<{ title: string, desc: string, name: string | string[],
hasAll={false}
valueKey='config_id'
labelKey="config_name"
disabled={disabled}
/>
</Form.Item>
<DescWrapper desc={t(`application.${desc}`)} className="rb:mt-2" />
@@ -352,7 +355,8 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
}, [modelList, values?.default_model_config_id])
useImperativeHandle(ref, () => ({
handleSave
handleSave,
funConfig: values?.funConfig
}))
const aiPromptModalRef = useRef<AiPromptModalRef>(null)
@@ -406,7 +410,11 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
useEffect(() => {
setChatVariables(values?.variables || [])
}, [values?.variables])
console.log('values', values)
const handleSaveFunConfig = (value: FunConfigForm) => {
form.setFieldValue('funConfig', value)
}
console.log('agent', values)
return (
<>
{loading && <Spin fullscreen></Spin>}
@@ -418,6 +426,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
{defaultModel?.name ? <div className="rb:w-4 rb:h-4 rb:bg-[url('@/assets/images/application/model.svg')] rb:group-hover:bg-[url('@/assets/images/application/model_hover.svg')]"></div> : null}
{defaultModel?.name || t('application.chooseModel')}
</Button>
{/* <FunConfig value={values?.funConfig as FunConfigForm} refresh={handleSaveFunConfig} /> */}
<Button type="primary" onClick={() => handleSave()}>
{t('common.save')}
</Button>
@@ -426,6 +435,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
<Form form={form}>
<Form.Item name="default_model_config_id" hidden noStyle></Form.Item>
<Form.Item name="model_parameters" hidden noStyle></Form.Item>
<Form.Item name="funConfig" hidden noStyle></Form.Item>
<Space size={16} direction="vertical" style={{ width: '100%' }}>
<Card title={t('application.promptConfiguration')}>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-2.75">
@@ -464,11 +474,12 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
<Card title={t('application.memoryConfiguration')}>
<Space size={24} direction='vertical' style={{ width: '100%' }}>
<SwitchWrapper title="dialogueHistoricalMemory" desc="dialogueHistoricalMemoryDesc" name={['memory', 'enabled']} />
<SelectWrapper
title="selectMemoryContent"
desc="selectMemoryContentDesc"
<SelectWrapper
title="selectMemoryContent"
desc="selectMemoryContentDesc"
name={['memory', 'memory_config_id']}
url={memoryConfigListUrl}
disabled={!values?.memory?.enabled}
/>
</Space>
</Card>
@@ -493,11 +504,6 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
{t('application.debuggingAndPreview')}
<Space size={10}>
{chatVariables.length > 0 &&
<Button type="primary" ghost onClick={handleOpenVariableConfig}>
{t('application.variableConfig')}
</Button>
}
<Button type="primary" ghost onClick={handleAddModel}>
+ {t('application.addModel')}
</Button>
@@ -511,6 +517,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
updateChatList={setChatList}
handleSave={handleSave}
chatVariables={chatVariables}
handleEditVariables={handleOpenVariableConfig}
/>
</RbCard>
</Col>

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:33
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 16:29:33
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-05 13:47:23
*/
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next'
@@ -165,7 +165,8 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
setSubAgents(prev => prev.filter(item => item.agent_id !== agent.agent_id))
}
useImperativeHandle(ref, () => ({
handleSave
handleSave,
funConfig: data?.funConfig
}))
const modelConfigModalRef = useRef<ModelConfigModalRef>(null)

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:41
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 16:29:41
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-11 17:44:24
*/
import { type FC, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
@@ -14,7 +14,8 @@ import RbCard from '@/components/RbCard/Card'
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';
@@ -39,6 +40,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[]>([])
@@ -129,6 +131,7 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
{data?.type !== 'multi_agent' && <Button onClick={handleExport}>{t('common.export')}</Button>}
{data.current_release_id !== selectedVersion.id && <Button onClick={handleRollback}>{t('application.willRollToThisVersion')}</Button>}
<Button type="primary" ghost onClick={() => releaseShareModalRef.current?.handleOpen()}>{t('application.share')}</Button>
<Button type="primary" ghost onClick={() => appSharingModalRef.current?.handleOpen()}>{t('application.sharing')}</Button>
</>}
<Button type="primary" onClick={() => releaseModalRef.current?.handleOpen()}>{t('application.release')}</Button>
</Space>
@@ -178,6 +181,11 @@ const ReleasePage: FC<{data: Application; refresh: () => void}> = ({data, refres
ref={releaseShareModalRef}
version={selectedVersion}
/>
<AppSharingModal
ref={appSharingModalRef}
appId={data.id}
version={selectedVersion}
/>
</div>
);
}

View File

@@ -0,0 +1,642 @@
import { type FC, useState, useRef, useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { App, Flex, Dropdown, type MenuProps, Divider, Form, Space } from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import clsx from 'clsx'
import dayjs from 'dayjs'
import ChatIcon from '@/assets/images/application/chat.png'
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal'
import { draftRun } from '@/api/application';
import Empty from '@/components/Empty'
import Chat from '@/components/Chat'
import AudioRecorder from '@/components/AudioRecorder'
import RbCard from '@/components/RbCard/Card'
import UploadFiles from '@/views/Conversation/components/FileUpload'
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
import Runtime from '@/views/Workflow/components/Chat/Runtime';
import { nodeLibrary } from '@/views/Workflow/constant'
// import ButtonCheckbox from '@/components/ButtonCheckbox';
// import MemoryFunctionIcon from '@/assets/images/conversation/memoryFunction.svg'
// 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 { ChatItem } from '@/components/Chat/types'
import type { VariableConfigModalRef, WorkflowConfig } from '@/views/Workflow/types'
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
import type { TestChatProps } from './type';
import type { UploadFileListModalRef } from '@/views/Conversation/types'
import type { SSEMessage } from '@/utils/stream'
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'
}
interface FormData {
files: any[];
variables: Variable[]
}
const TestChat: FC<TestChatProps> = ({
application,
config
}) => {
const { t } = useTranslation()
const { message: messageApi } = App.useApp()
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
const [loading, setLoading] = useState(false) // Send button loading state
const [chatList, setChatList] = useState<ChatItem[]>([]) // Chat message history
const [streamLoading, setStreamLoading] = useState(false) // SSE streaming state
const [conversationId, setConversationId] = useState<string | null>(null) // Current conversation ID
const [message, setMessage] = useState<string | undefined>(undefined) // Current input message
const [form] = Form.useForm<FormData>()
const queryValues = Form.useWatch([], form)
useEffect(() => {
getVariables()
}, [application, config])
const getVariables = () => {
if (!application || !config) return
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
}
form.setFieldValue('variables', [...initVariables])
}
/**
* Opens the variable configuration modal
*/
const handleEditVariables = () => {
variableConfigModalRef.current?.handleOpen(queryValues.variables)
}
/**
* Saves updated variable values from the modal
*/
const handleSave = (values: Variable[]) => {
form.setFieldValue('variables', [...values])
}
/**
* Handles file upload from local device
*/
const fileChange = (file?: any) => {
form.setFieldValue('files', [...(queryValues.files || []), file])
}
const handleRecordingComplete = async (file: any) => {
form.setFieldValue('files', [...(queryValues.files || []), file])
}
/**
* 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
form.setFieldValue('files', [...(queryValues.files || []), ...(list || [])])
}
/**
* Updates the entire file list (used when removing files)
*/
const updateFileList = (list?: any[]) => {
form.setFieldValue('files', [...list || []])
}
const isNeedVariableConfig = useMemo(() => {
return queryValues?.variables.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
}, [queryValues?.variables])
const addUserMessage = (message: string, files: any[]) => {
const newUserMessage: ChatItem = {
role: 'user',
content: message,
created_at: Date.now(),
files
};
setChatList(prev => [...prev, newUserMessage])
}
const addAssistantMessage = () => {
const { type } = application || {}
setChatList(prev => [...prev, {
role: 'assistant',
content: '',
created_at: Date.now(),
subContent: type === 'workflow' ? [] : undefined,
}])
}
const updateAssistantMessage = (content: string) => {
setChatList(prev => {
let newList = [...prev]
const lastMsg = newList[newList.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.content += content
}
return newList
})
}
const updateErrorAssistantMessage = (message_length: number) => {
if (message_length > 0) return
setChatList(prev => {
let newList = [...prev]
const lastMsg = newList[newList.length - 1]
if (lastMsg.role === 'assistant') {
lastMsg.content = null
}
return newList
})
}
const handleSend = () => {
if (loading || !application || !message || !message?.trim()) return
// Validate required variables before sending
const { variables, files } = queryValues;
let isCanSend = true
const params: Record<string, any> = {}
if (variables && variables.length > 0) {
const needRequired: string[] = []
variables.forEach(vo => {
params[vo.name] = vo.value
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
isCanSend = false
needRequired.push(vo.name)
}
})
if (needRequired.length) {
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
}
}
if (!isCanSend) {
setLoading(false)
return
}
addUserMessage(message, files)
setMessage(undefined)
form.setFieldValue('files', [])
addAssistantMessage()
setStreamLoading(true)
setLoading(true)
draftRun(
application.id,
formatParams(message, conversationId, files, params),
handleStreamMessage
)
.catch(() => {
setLoading(false)
})
.finally(() => {
setLoading(false)
setStreamLoading(false)
})
}
const handleStreamMessage = (data: SSEMessage[]) => {
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) {
setConversationId(conversation_id);
}
break
case 'message':
updateAssistantMessage(content)
if (conversation_id && conversationId !== conversation_id) {
setConversationId(conversation_id);
}
break;
case 'end':
updateErrorAssistantMessage(message_length)
setStreamLoading(false)
break;
}
})
};
const handleWorkflowSend = () => {
if (loading || !application || !message || !message?.trim()) return
// Validate required variables before sending
const { variables, files } = queryValues;
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')}`)
}
}
if (!isCanSend) {
return
}
setLoading(true)
addUserMessage(message, files)
addAssistantMessage()
form.setFieldsValue({
files: [],
})
setMessage(undefined)
setStreamLoading(true)
draftRun(
application.id,
formatParams(message, conversationId, files, params),
handleWorkflowStreamMessage
)
.catch((error) => {
console.log('draftRun error', error)
setChatList(prev => {
const newList = [...prev]
const lastIndex = newList.length - 1
if (lastIndex >= 0) {
newList[lastIndex] = {
...newList[lastIndex],
status: 'failed',
content: null,
subContent: error.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 } = data as {
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'
};
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,
}
}
return newList
})
}
console.log('queryValues', queryValues)
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)]': !queryValues?.files?.length,
'rb:h-[calc(100%-208px)]': !!queryValues?.files?.length,
})}
data={chatList}
streamLoading={streamLoading}
loading={loading}
onChange={setMessage}
onSend={application?.type === 'workflow' ? handleWorkflowSend : handleSend}
fileList={queryValues?.files || []}
fileChange={updateFileList}
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) => {
return <Runtime item={item} index={index} />
} : undefined}
>
<Form form={form}>
<Flex justify="space-between" className="rb:flex-1">
<Space size={8} align="center">
<Form.Item name="files" noStyle>
<Dropdown
menu={{
items: [
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
{
key: 'upload', label: (
<UploadFiles
onChange={fileChange}
/>
)
},
],
onClick: handleShowUpload
}}
>
<Flex align="center" justify="center" className="rb:size-7 rb:cursor-pointer rb:rounded-[14px] rb:border rb:border-[#EBEBEB] rb:hover:bg-[#F6F6F6]">
<div
className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')]"
></div>
</Flex>
</Dropdown>
</Form.Item>
{/* <Form.Item name="web_search" valuePropName="checked" className="rb:mb-0!">
<ButtonCheckbox
icon={OnlineIcon}
checkedIcon={OnlineCheckedIcon}
>
{t(`memoryConversation.web_search`)}
</ButtonCheckbox>
</Form.Item>
<Tooltip title={t(`memoryConversation.memory`)}></Tooltip>
<Form.Item name="memory" valuePropName="checked" className="rb:mb-0!">
<ButtonCheckbox
icon={MemoryFunctionIcon}
checkedIcon={MemoryFunctionCheckedIcon}
cicle={true}
>
</ButtonCheckbox>
</Form.Item> */}
<Form.Item name="variables" className="rb:mb-0!" hidden={!queryValues?.variables?.length}>
<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>
</Space>
<Space size={8} align="center">
<AudioRecorder
onRecordingComplete={handleRecordingComplete}
/>
<Divider type="vertical" className="rb:ml-0! rb:mr-2!" />
</Space>
</Flex>
</Form>
</Chat>
<VariableConfigModal
ref={variableConfigModalRef}
refresh={handleSave}
/>
<UploadFileListModal
ref={uploadFileListModalRef}
refresh={addFileList}
/>
</RbCard>
</div>
)
}
export default TestChat

View 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
}

View File

@@ -0,0 +1,183 @@
/*
* @Author: ZhaoYing
* @Date: 2026-03-13 17:19:13
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 17:26:57
*/
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 = form.getFieldValue('target_workspace_ids') as string[] ?? [];
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
name="target_workspace_ids"
label={t('application.selectTargetSpace')}
rules={[{ required: true, message: t('common.pleaseSelect') }]}
>
<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} // already-shared workspaces cannot be unselected
onChange={() => handleToggle(space.id, isShared)}
/>
<span className="rb:flex-1 rb:text-sm">{space.name}</span>
{/* Badge shown when the app is already shared with this workspace */}
{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;

View File

@@ -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-13 15:20:32
*/
/**
* Chat debugging component for application testing
@@ -13,7 +13,8 @@
import { type FC, useEffect, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx'
import { Flex, Dropdown, type MenuProps, App, Divider } from 'antd'
import { Flex, Dropdown, type MenuProps, App, Divider } from 'antd';
import { SettingOutlined } from '@ant-design/icons'
import ChatIcon from '@/assets/images/application/chat.png'
import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png'
@@ -45,13 +46,17 @@ interface ChatProps {
/** Source type: multi-agent cluster or single agent */
source?: 'multi_agent' | 'agent';
chatVariables?: Variable[]; // Add chatVariables prop
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 { message: messageApi } = App.useApp()
const [loading, setLoading] = useState(false)
@@ -434,6 +439,7 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
const updateFileList = (list?: any[]) => {
setFileList([...list || []])
}
const isNeedVariableConfig = chatVariables?.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
return (
<div className="rb:relative rb:h-full rb:flex rb:flex-col">
@@ -521,6 +527,18 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
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>
{chatVariables && chatVariables.length > 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>
)}
</Flex>
<Flex align="center">
<AudioRecorder onRecordingComplete={handleRecordingComplete} />

View File

@@ -2,9 +2,9 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:27:52
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-28 16:48:52
* @Last Modified time: 2026-03-13 17:12:59
*/
import { type FC, useRef, useMemo } from 'react';
import { type FC, useRef, useMemo, useCallback } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Layout, Tabs, Dropdown, Button, Flex } from 'antd';
import type { MenuProps } from 'antd';
@@ -18,9 +18,10 @@ 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 type { CopyModalRef, AgentRef, ClusterRef, WorkflowRef, FunConfigForm } from '../types'
import { deleteApplication, appExport } from '@/api/application'
import CopyModal from './CopyModal'
import FunConfig from './FunConfig'
const { Header } = Layout;
@@ -28,6 +29,11 @@ const { Header } = Layout;
* Tab keys for application configuration
*/
const tabKeys = ['arrangement', 'api', 'release', 'statistics']
const sharingTabKeys = [
'test',
// 'log',
'api'
]
/**
* Menu icon mapping
@@ -64,22 +70,23 @@ interface ConfigHeaderProps {
const ConfigHeader: FC<ConfigHeaderProps> = ({
application, activeTab, handleChangeTab, refresh,
workflowRef,
appRef,
}) => {
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
*/
@@ -160,6 +167,13 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
return items
}, [t, handleClick, application])
const funConfig = useMemo(() => {
return (appRef?.current?.funConfig || { file_type: [] }) as FunConfigForm
}, [appRef])
const handleSaveFunConfig = useCallback((value: FunConfigForm) => {
appRef?.current?.handleSaveFunConfig?.(value)
}, [appRef])
console.log('formatMenuItems', formatMenuItems)
return (
<>
@@ -170,7 +184,7 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
</div>
<div className="rb:max-w-[100%-80px] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">{application?.name}</div>
<Dropdown
{source !== 'sharing' && <Dropdown
menu={{ items: formatMenuItems, onClick: handleClick }}
trigger={['click']}
placement="bottomRight"
@@ -178,19 +192,20 @@ const ConfigHeader: FC<ConfigHeaderProps> = ({
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/edit.svg')] rb:hover:bg-[url('@/assets/images/edit_hover.svg')]"
></div>
</Dropdown>
</Dropdown>}
</div>
<div className="rb:flex rb:justify-center">
<Tabs
activeKey={activeTab}
items={formatTabItems()}
items={formatTabItems}
onChange={handleChangeTab}
className={styles.tabs}
/>
</div>
{application?.type === 'workflow'
? <div className="rb:h-8 rb:flex rb:items-center rb:justify-end rb:gap-2.5">
? <div className="rb:h-8 rb:flex rb:items-center rb:justify-end rb:gap-2.5">
{/* <FunConfig value={funConfig} refresh={handleSaveFunConfig} /> */}
<Button onClick={clear}>{t('workflow.clear')}</Button>
<Button onClick={addvariable}>{t('workflow.addvariable')}</Button>
<Button onClick={run}>{t('workflow.run')}</Button>

View File

@@ -0,0 +1,182 @@
/*
* @Author: ZhaoYing
* @Date: 2026-03-05
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-11 15:42:13
*/
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Radio, InputNumber, Flex, Switch, Row, Col } from 'antd';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import RbModal from '@/components/RbModal';
import type { FunConfigForm } from '../../types'
interface FileUploadSettingModalRef {
handleOpen: (values?: FileUploadSettings) => void;
handleClose: () => void;
}
interface FileUploadSettings extends Omit<FunConfigForm, 'enabled'> {}
interface FileUploadSettingModalProps {
onSave: (values: FileUploadSettings) => void;
}
const fileTypeOptions = [
{
type: 'document',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/txt.svg')]"></div>,
formats: 'TXT, MD, MDX, MARKDOWN, PDF, DOC, DOCX',
defaultMaxCount: 1,
defaultMaxSize: 2
},
{
type: 'image',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/image.svg')]"></div>,
formats: 'JPG, JPEG, PNG, GIF, WEBP, SVG',
defaultMaxCount: 1,
defaultMaxSize: 2
},
{
type: 'audio',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/audio.svg')]"></div>,
formats: 'MP3, M4A, WAV, AMR, MPGA',
defaultMaxCount: 1,
defaultMaxSize: 2
},
{
type: 'video',
icon: <div className="rb:size-9 rb:bg-cover rb:bg-[url('@/assets/images/file/video.svg')]"></div>,
formats: 'MP4, MOV, MPEG, WEBM',
defaultMaxCount: 1,
defaultMaxSize: 2
},
];
const FileUploadSettingModal = forwardRef<FileUploadSettingModalRef, FileUploadSettingModalProps>(({
onSave,
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm();
const values = Form.useWatch([], form)
const handleClose = () => {
setVisible(false);
form.resetFields();
};
const handleOpen = (values?: FileUploadSettings) => {
setVisible(true);
// if (values) {
// form.setFieldsValue(values);
// }
};
const handleSave = async () => {
const values = await form.validateFields();
onSave(values);
handleClose();
};
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t('application.settings')}
open={visible}
onCancel={handleClose}
onOk={handleSave}
width={600}
>
<Form
form={form}
layout="vertical"
initialValues={{
uploadType: 'both',
fileTypes: fileTypeOptions.map(opt => ({
type: opt.type,
enabled: false,
maxCount: opt.defaultMaxCount,
maxSize: opt.defaultMaxSize
}))
}}
>
<Form.Item
label={t('application.uploadType')}
name="uploadType"
>
<Radio.Group block buttonStyle="solid">
<Radio.Button value="local">{t('application.local')}</Radio.Button>
<Radio.Button value="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
name="maxCount"
label={t('application.maxCount')}
>
<InputNumber min={1} max={100} className="rb:w-full!" placeholder={t('common.pleaseEnter')} />
</Form.Item>
<Form.Item label={t('application.supportedTypes')}>
<Form.List name="fileTypes">
{(fields) => (
<Flex vertical gap={12}>
{fields.map((field, index) => {
const option = fileTypeOptions[index];
const isEnabled = values?.fileTypes?.[index]?.enabled;
return (
<div
key={field.key}
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}</div>
</Flex>
<Form.Item name={[field.name, 'enabled']} 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={[field.name, 'maxSize']} noStyle>
<InputNumber min={1} max={500} suffix="MB" className="rb:flex-1" />
</Form.Item>
</Flex>
)}
<Form.Item name={[field.name, 'type']} hidden>
<input type="hidden" />
</Form.Item>
</div>
);
})}
</Flex>
)}
</Form.List>
</Form.Item>
</Form>
</RbModal>
);
});
export default FileUploadSettingModal;

View File

@@ -0,0 +1,140 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:27:56
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 17:20:30
*/
/**
* 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 type { FunConfigModalRef } from '../../types'
import RbModal from '@/components/RbModal'
import type { FunConfigForm } from '../../types'
import SwitchFormItem from '@/components/FormItem/SwitchFormItem'
import FileUploadSettingModal from './FileUploadSettingModal'
const FormItem = Form.Item;
interface FunConfigModalProps {
refresh: (value: FunConfigForm) => void;
}
/**
* Modal for copying applications
*/
const FunConfigModal = forwardRef<FunConfigModalRef, FunConfigModalProps>(({
refresh,
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm<FunConfigForm>();
const [loading, setLoading] = useState(false)
const values = Form.useWatch([], form)
const fileUploadSettingModalRef = useRef<any>(null)
/** Close modal and reset form */
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
};
/** Open modal */
const handleOpen = (initValue: FunConfigForm) => {
setVisible(true);
form.setFieldsValue(initValue)
};
/** Copy application with new name */
const handleSave = () => {
setVisible(false);
setLoading(true)
const values = form.getFieldsValue()
refresh(values)
}
const handleOpenSettings = () => {
fileUploadSettingModalRef.current?.handleOpen(values)
}
const handleSaveSettings = (settings: any) => {
form.setFieldsValue(settings)
}
/** Expose methods to parent component */
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<>
<RbModal
title={t('application.funConfig')}
open={visible}
onCancel={handleClose}
okText={t('common.copy')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<Flex vertical gap={12}>
<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.textTranfer')}
name={['textTranfer', "enabled"]}
desc={t('application.textTranferDesc')}
/>
</div>
<div className="rb:relative rb:border rb:border-[#DFE4ED] rb:p-3 rb:rounded-lg rb:bg-[#f5f7fc]">
<SwitchFormItem
title={t('application.fileUpload')}
name={['fileUpload', "enabled"]}
desc={values?.fileUpload?.enabled ? undefined : t('application.fileUploadDesc')}
/>
{values?.fileUpload?.enabled && values?.fileTypes?.length > 0 ? <>
<div className="rb:grid rb:grid-cols-3 rb:gap-2 rb:text-[12px] rb:text-[#5B6167]">
<div>{t(`application.supportedTypes`)}</div>
<div>{t('application.maxCount')}</div>
<div>{t('application.singleMaxSize')}</div>
</div>
{values?.fileTypes?.filter(item => item.enabled).map(item => (
<div key={item.type} className="rb:grid rb:grid-cols-3 rb:gap-2">
<div>{t(`application.${item.type}`)}</div>
<div>{item.maxCount} {t('application.unix')}</div>
<div>{item.maxSize} MB</div>
</div>
))}
<Button block onClick={handleOpenSettings}>{t('application.setting')}</Button>
</> : null}
<FormItem name="fileTypes" noStyle hidden></FormItem>
<FormItem name="uploadType" noStyle hidden></FormItem>
</div>
</Flex>
</Form>
</RbModal>
<FileUploadSettingModal
ref={fileUploadSettingModalRef}
onSave={handleSaveSettings}
/>
</>
);
});
export default FunConfigModal;

View File

@@ -0,0 +1,50 @@
/*
* @Author: ZhaoYing
* @Date: 2026-03-13 17:20:21
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 17:20:21
*/
import { type FC, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from 'antd';
import FunConfigModal from './FunConfigModal'
import type { FunConfigModalRef, FunConfigForm } from '../../types'
/** Props for the FunConfig component */
interface FunConfigProps {
/** Current feature configuration values */
value: FunConfigForm;
/** Callback to propagate updated config back to the parent */
refresh: (value: FunConfigForm) => void;
}
const FunConfig: FC<FunConfigProps> = ({
value,
refresh
}) => {
const { t } = useTranslation();
// Ref used to imperatively open the config modal
const funConfigModalRef = useRef<FunConfigModalRef>(null)
/** Open the feature config modal pre-populated with the current values */
const handleFunConfig = () => {
console.log('funConfig', value)
funConfigModalRef.current?.handleOpen(value)
}
return (
<>
{/* Button that triggers the feature configuration modal */}
<Button onClick={handleFunConfig}>{t('application.funConfig')}</Button>
{/* Modal for editing feature settings; calls refresh on save */}
<FunConfigModal
ref={funConfigModalRef}
refresh={refresh}
/>
</>
)
}
export default FunConfig

View File

@@ -1,22 +1,24 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:37
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 16:29:37
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-12 10:23:18
*/
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)
@@ -36,6 +38,31 @@ const ApplicationConfig: React.FC = () => {
const [application, setApplication] = useState<Application | null>(null);
const [activeTab, setActiveTab] = useState('arrangement');
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
* @param key - New tab key
@@ -94,6 +121,7 @@ const ApplicationConfig: React.FC = () => {
{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} />}
</>
);
};

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:29:49
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-28 16:40:30
* @Last Modified time: 2026-03-13 17:01:04
*/
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;
funConfig?: FunConfigForm;
}
/**
@@ -127,6 +129,8 @@ export interface AgentRef {
* @param flag - Whether to show success message
*/
handleSave: (flag?: boolean) => Promise<unknown>;
funConfig: Config['funConfig'];
handleSaveFunConfig?: (value: FunConfigForm) => void;
}
/**
@@ -138,6 +142,8 @@ export interface ClusterRef {
* @param flag - Whether to show success message
*/
handleSave: (flag?: boolean) => Promise<unknown>;
funConfig: Config['funConfig'];
handleSaveFunConfig?: (value: FunConfigForm) => void;
}
/**
@@ -156,6 +162,8 @@ export interface WorkflowRef {
/** Add variable */
addVariable: () => void;
config: WorkflowConfig | null;
funConfig: WorkflowConfig['funConfig'];
handleSaveFunConfig?: (value: FunConfigForm) => void;
}
/**
@@ -400,4 +408,34 @@ export interface StatisticsData {
total_api_calls: number;
/** Total tokens used */
total_tokens: number;
}
export interface FileTypeConfig {
type: string;
enabled: boolean;
maxCount: number;
maxSize: number;
}
export interface FunConfigForm {
enabled: boolean;
fileTypes: FileTypeConfig[]
uploadType: 'local' | 'url' | 'both';
}
/**
* Function config modal ref methods
*/
export interface FunConfigModalRef {
/** Open function config modal */
handleOpen: (value: FunConfigForm) => void;
}
/**
* App sharing modal ref methods
*/
export interface AppSharingModalRef {
handleOpen: () => void;
}
export interface AppSharingForm {
target_workspace_ids: string[];
permission: 'readonly' | 'editable'
}

View File

@@ -0,0 +1,158 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 16:34:12
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 17:36:16
*/
import React, { useState, useEffect, useMemo } 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'
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) => {
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();
})
}
});
};
return (
<Flex vertical gap={12}>
{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">
<div
className="rb:absolute rb:top-3 rb:right-3 rb:cursor-pointer rb:size-4 rb:bg-cover rb:bg-[url('src/assets/images/close.svg')]"
onClick={() => handleCancelOne(item)}
/>
<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>
),
}]}
/>
))}
</Flex>
);
};
export default MySharing;

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:34:12
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-02 17:48:51
* @Last Modified time: 2026-03-16 09:56:02
*/
/**
* Application Management Page
@@ -10,9 +10,9 @@
* Supports creating, editing, and deleting applications
*/
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Row, Col, App, Select, Space, Dropdown } from 'antd';
import { Button, App, Select, Space, Dropdown, type SegmentedProps, Flex, Form } from 'antd';
import clsx from 'clsx';
import { DeleteOutlined } from '@ant-design/icons';
import { useSearchParams } from 'react-router-dom'
@@ -21,12 +21,16 @@ import ApplicationModal, { types } from './components/ApplicationModal';
import type { Application, ApplicationModalRef, Query, UploadWorkflowModalRef } from './types';
import SearchInput from '@/components/SearchInput'
import RbCard from '@/components/RbCard/Card'
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'
const tabKeys = ['apps', 'sharing', 'myShare']
/**
* Application management main component
*/
@@ -34,20 +38,19 @@ const ApplicationManagement: React.FC = () => {
const { t } = useTranslation();
const { modal } = App.useApp();
const [searchParams] = useSearchParams()
const [query, setQuery] = useState<Query>({} as Query);
const applicationModalRef = useRef<ApplicationModalRef>(null);
const scrollListRef = useRef<PageScrollListRef>(null)
const uploadWorkflowModalRef = useRef<UploadWorkflowModalRef>(null);
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
setQuery(prev => ({
...prev,
type: type || undefined
}))
form.setFieldValue('type', type || undefined)
}, [searchParams])
/** Refresh application list */
@@ -61,7 +64,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) => {
@@ -81,9 +88,6 @@ const ApplicationManagement: React.FC = () => {
}
})
}
const handleChangeType = (value?: string) => {
setQuery(prev => ({...prev, type: value}))
}
const handleImport = () => {
uploadWorkflowModalRef.current?.handleOpen()
@@ -97,90 +101,137 @@ const ApplicationManagement: React.FC = () => {
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 (
<>
<Row gutter={16} className="rb:mb-4">
<Col span={4}>
<Select
value={query.type}
placeholder={t('application.applicationType')}
options={types.map((type) => ({
value: type,
label: t(`application.${type}`),
}))}
allowClear
className="rb:w-full"
onChange={handleChangeType}
/>
</Col>
<Col span={8}>
<SearchInput
placeholder={t('application.searchPlaceholder')}
onSearch={(value) => setQuery({ search: value })}
style={{width: '100%'}}
/>
</Col>
<Col span={12} className="rb:text-right">
<Space size={12}>
<Dropdown
menu={{ items: [
{ key: 'thirdParty', label: t('application.importWorkflow') },
{ key: 'import', label: t('application.import') },
], onClick: handleClick }}
placement="bottomRight"
<Flex justify="space-between" className="rb:mb-3!">
<PageTabs
value={activeTab}
options={formatTabItems}
onChange={handleChangeTab}
/>
<Form
form={form}
initialValues={{}}
>
{activeTab !== 'myShare' &&
<Space size={8}>
<Form.Item name="type" noStyle>
<Select
placeholder={t('application.applicationType')}
options={types.map((type) => ({
value: type,
label: t(`application.${type}`),
}))}
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' && <>
<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>
<Button type="primary" onClick={handleCreate}>
{t('application.createApplication')}
</Button>
</>}
</Space>
}
</Form>
</Flex>
{(activeTab === 'apps' || activeTab === 'sharing') &&
<PageScrollList<Application, Query>
ref={scrollListRef}
url={getApplicationListUrl}
query={{ ...query, shared_only: activeTab === 'sharing' }}
renderItem={(item) => (
<RbCard
title={item.name}
avatar={
<div className="rb:w-12 rb:h-12 rb:rounded-lg rb:mr-3.25 rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[28px] rb:text-[#ffffff]">
{item.name[0]}
</div>
}
>
<Button>
{t('application.import')}
</Button>
</Dropdown>
<Button type="primary" onClick={handleCreate}>
{t('application.createApplication')}
</Button>
</Space>
</Col>
</Row>
<PageScrollList<Application, Query>
ref={scrollListRef}
url={getApplicationListUrl}
query={query}
renderItem={(item) => (
<RbCard
title={item.name}
avatar={
<div className="rb:w-12 rb:h-12 rb:rounded-lg rb:mr-3.25 rb:bg-[#155eef] rb:flex rb:items-center rb:justify-center rb:text-[28px] rb:text-[#ffffff]">
{item.name[0]}
</div>
}
>
{['type', 'source', 'created_at'].map((key, index) => (
<div key={key} className={clsx("rb:flex rb:justify-between rb:gap-5 rb:font-regular rb:text-[14px]", {
'rb:mt-3': index !== 0
})}>
<span className="rb:text-[#5B6167]">{t(`application.${key}`)}</span>
<span className={clsx({
'rb:text-[#155EEF] rb:font-medium': key === 'type' && item[key] === 'agent',
'rb:text-[#369F21] rb:font-medium': key === 'type' && item[key] === 'multi_agent',
{['type', 'source', 'created_at'].map((key, index) => (
<div key={key} className={clsx("rb:flex rb:justify-between rb:gap-5 rb:font-regular rb:text-[14px]", {
'rb:mt-3': index !== 0
})}>
{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>
</div>
))}
<span className="rb:text-[#5B6167]">{t(`application.${key}`)}</span>
<span className={clsx({
'rb:text-[#155EEF] rb:font-medium': key === 'type' && item[key] === 'agent',
'rb:text-[#369F21] rb:font-medium': key === 'type' && item[key] === 'multi_agent',
})}>
{key === 'source' && item.is_shared
? item.source_workspace_name
: 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>
</div>
))}
<div className="rb:mt-5 rb:flex rb:justify-between rb:gap-2.5">
<Button type="primary" ghost className="rb:w-[calc(100%-46px)]" onClick={() => handleEdit(item)}>{t('application.configuration')}</Button>
<Button icon={<DeleteOutlined />} onClick={() => handleDelete(item)}></Button>
</div>
</RbCard>
)}
/>
{item.is_shared
? <div className="rb:mt-5 rb:flex rb:justify-between rb:gap-2.5">
<Button type="primary" ghost block onClick={() => handleEdit(item)}>{t('common.view')}</Button>
{item.share_permission === 'editable' && <Button type="primary" className="rb:w-[calc(100%-46px)]" onClick={() => handleCopy(item)}>{t('common.copy')}</Button>}
</div>
: <div className="rb:mt-5 rb:flex rb:justify-between rb:gap-2.5">
<Button type="primary" ghost className="rb:w-[calc(100%-46px)]" onClick={() => handleEdit(item)}>{t('application.configuration')}</Button>
<Button icon={<DeleteOutlined />} onClick={() => handleDelete(item)}></Button>
</div>
}
</RbCard>
)}
/>
}
{activeTab === 'myShare' && <MySharing />}
<ApplicationModal
ref={applicationModalRef}

View File

@@ -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-16 09:55:52
*/
/**
* Type definitions for Application Management
@@ -15,6 +15,7 @@ export interface Query {
/** Search keyword */
search: string;
type?: string;
shared_only?: boolean;
}
/**
@@ -53,6 +54,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;
}
/**
@@ -241,4 +247,20 @@ export interface UploadWorkflowModalRef {
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;
}

View File

@@ -0,0 +1,72 @@
import React, { useState, type FC, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
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 [graphData, setGraphData] = useState<CommunityGraphData | null>(null)
const [empty, setEmpty] = useState(false)
useEffect(() => {
if (!id) return
const controller = new AbortController()
setEmpty(false)
setGraphData(null)
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) })
return () => controller.abort()
}, [id])
return (
<CommunityGraph
data={graphData}
empty={empty}
showLegend={false}
onCommunityClick={onSelectCommunity}
renderTooltip={node => <NodeTooltip node={node} />}
/>
)
}
export default React.memo(CommunityNetwork)

View File

@@ -1,8 +1,8 @@
/*
* @Author: ZhaoYing
* @Date: 2026-02-03 18:32:00
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-02-03 18:32:00
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-13 14:51:17
*/
/**
* Relationship Network Component
@@ -13,18 +13,20 @@
import React, { type FC, useEffect, useState, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, useNavigate } from 'react-router-dom'
import { Col, Row, Space, Button } from 'antd'
import { Col, Row, Space, Button, Tabs, Flex, Divider } from 'antd'
import dayjs from 'dayjs'
import ReactEcharts from 'echarts-for-react'
import RbCard from '@/components/RbCard/Card'
import detailEmpty from '@/assets/images/userMemory/detail_empty.png'
import type { Node, Edge, GraphData, StatementNodeProperties, ExtractedEntityNodeProperties } from '../types'
import type { RawCommunityNode } from '@/components/D3Graph/types'
import {
getMemorySearchEdges,
} from '@/api/memory'
import Empty from '@/components/Empty'
import Tag from '@/components/Tag'
import CommunityNetwork from './CommunityNetwork'
/** Node color palette */
const colors = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048']
@@ -36,16 +38,21 @@ const RelationshipNetwork:FC = () => {
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[] = []
@@ -123,6 +130,7 @@ const RelationshipNetwork:FC = () => {
useEffect(() => {
if (!id) return
getEdgeData()
return () => { edgeAbortRef.current?.abort() }
}, [id])
useEffect(() => {
@@ -153,34 +161,36 @@ 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 (
<Row gutter={16}>
{/* Relationship Network */}
<Col span={16}>
<RbCard
title={t('userMemory.relationshipNetwork')}
headerType="borderless"
headerClassName="rb:min-h-[46px]!"
// extra={
// <div
// onClick={handleFullScreen}
// className="rb:group rb:cursor-pointer rb:hover:text-[#212332] rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:flex rb:items-center rb:gap-1"
// >
// <div className="rb:size-4 rb:bg-cover rb:bg-[url('@/assets/images/fullScreen.svg')] rb:hover:bg-[url('@/assets/images/fullScreen_hover.svg')]"></div>
// {t('userMemory.fullScreen')}
// </div>
// }
>
<RbCard bodyClassName="rb:pt-0!">
<Tabs
items={['relationshipNetwork', 'communityNetwork'].map(key => ({ key, label: t(`userMemory.${key}`) }))}
activeKey={activeTab}
onChange={handleChangeTab}
/>
<div className="rb:h-129.5 rb:bg-[#F6F8FC] rb:border rb:border-[#DFE4ED] rb:rounded-sm">
{nodes.length === 0 ? (
<Empty className="rb:h-full" />
) : (
<ReactEcharts
{activeTab === 'communityNetwork'
? <CommunityNetwork onSelectCommunity={community => setSelectedNode(community)} />
: nodes.length === 0
? <Empty className="rb:h-full" />
: <ReactEcharts
option={{
colors: colors,
tooltip: {
@@ -253,103 +263,121 @@ const RelationshipNetwork:FC = () => {
}
}}
/>
)}
}
</div>
</RbCard>
</Col>
{/* Memory Details */}
<Col span={8}>
<RbCard
<RbCard
title={t('userMemory.memoryDetails')}
headerType="borderless"
headerClassName="rb:min-h-[46px]!"
bodyClassName='rb:p-0!'
extra={selectedNode && <Button type="text" onClick={handleViewAll}>
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/view.svg')] rb:hover:bg-[url('@/assets/images/userMemory/view_hover.svg')]"
></div>
{t('userMemory.completeMemory')}
</Button>}
bodyClassName="rb:p-0!"
extra={selectedNode && !(selectedNode as RawCommunityNode).properties.community_id && (
<Button type="text" onClick={handleViewAll}>
<div className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/view.svg')] rb:hover:bg-[url('@/assets/images/userMemory/view_hover.svg')]" />
{t('userMemory.completeMemory')}
</Button>
)}
>
<div className="rb:h-133.5 rb:overflow-y-auto">
{!selectedNode
? <Empty
url={detailEmpty}
subTitle={t('userMemory.memoryDetailEmptyDesc')}
className="rb:h-full rb:mx-10 rb:text-center"
size={[197.81, 150]}
/>
: <>
{selectedNode.name && <div className="rb:bg-[#F6F8FC] rb:border-t rb:border-b rb:border-[#DFE4ED] rb:font-medium rb:py-2 rb:px-4 rb:h-10">{selectedNode.name}</div>}
<div className="rb:p-4">
<>
? <Empty url={detailEmpty} subTitle={activeTab === 'relationshipNetwork' ? t('userMemory.memoryDetailEmptyDesc') : t('userMemory.communityDetailEmptyDesc')} className="rb:h-full rb:mx-10 rb:text-center" size={[197.81, 150]} />
: (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 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:bg-[#F6F8FC] rb:border-t rb:border-b rb:border-[#DFE4ED] rb:font-medium rb:py-2 rb:px-4 rb:h-10">
{(selectedNode as Node).name}
</div>
)}
<div className="rb:p-4">
<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-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{['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 className="rb:font-medium rb:mb-2 rb:mt-4">
<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-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{dayjs(selectedNode?.properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
? selectedNode.properties.description
: selectedNode.label === 'Statement' && 'statement' in selectedNode.properties
? selectedNode.properties.statement
: ''}
</div>
{selectedNode?.properties.associative_memory > 0 && <div className="rb:mt-4">
<div className="rb:font-medium rb:leading-5">{t('userMemory.associative_memory')}</div>
<div className="rb:font-medium rb:mb-2 rb:mt-4">
<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-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
<span className="rb:text-[#155EEF] rb:font-medium">{selectedNode?.properties.associative_memory}</span> {t('userMemory.unix')}{t('userMemory.associative_memory')}
{dayjs((selectedNode as Node).properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
</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') {
console.log('statementProps[key]', statementProps[key])
return (
<div className="rb:mt-4" key={key}>
{t(`userMemory.Statement_${key}`)}
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{key === 'emotion_keywords'
? <Space>{statementProps.emotion_keywords.map((vo, index) => <Tag key={index}>{vo}</Tag>)}</Space>
: statementProps[key]
}
{(selectedNode as Node).properties.associative_memory > 0 && (
<div className="rb:mt-4">
<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 p = selectedNode.properties as StatementNodeProperties
if ((key === 'emotion_keywords' && p[key]?.length > 0) || typeof p[key] === 'string') {
return (
<div className="rb:mt-4" key={key}>
{t(`userMemory.Statement_${key}`)}
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{key === 'emotion_keywords'
? <Space>{p.emotion_keywords.map((v, i) => <Tag key={i}>{v}</Tag>)}</Space>
: p[key]}
</div>
</div>
</div>
)
}
return null
})}
</>}
{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 className="rb:mt-4" key={key}>
{t(`userMemory.ExtractedEntity_${key}`)}
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{Array.isArray(entityProps[key]) && entityProps[key].length > 0
? entityProps[key].map((vo, index) => <div key={index}>- {vo}</div>)
: entityProps[key]
}
)
}
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 className="rb:mt-4" key={key}>
{t(`userMemory.ExtractedEntity_${key}`)}
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{Array.isArray(p[key]) && p[key].length > 0
? p[key].map((v, i) => <div key={i}>- {v}</div>)
: p[key]}
</div>
</div>
</div>
)
}
return null
})}
</>}
)
}
return null
})
)}
</div>
</div>
</div>
</>
</>
}
</div>
</RbCard>

View File

@@ -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;
}

View File

@@ -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)}
</>
)
}]}
/>

View File

@@ -8,7 +8,6 @@ import RbModal from '@/components/RbModal'
interface VariableEditModalProps {
refresh: (values: Variable[]) => void;
variables: Variable[]
}
const VariableConfigModal = forwardRef<VariableConfigModalRef, VariableEditModalProps>(({

View File

@@ -60,7 +60,8 @@ const Workflow = forwardRef<WorkflowRef>((_props, ref) => {
handleRun,
graphRef,
addVariable,
config
config,
funConfig: config?.funConfig
}))
return (
<div className="rb:h-[calc(100vh-64px)] rb:relative">

View File

@@ -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 { FunConfigForm } 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;
funConfig?: FunConfigForm;
}
export interface ChatRef {