Merge branch 'release/v0.2.6' into feature/memory_zy
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 13:59:45
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 16:34:15
|
||||
* @Last Modified time: 2026-03-03 12:08:42
|
||||
*/
|
||||
import { request } from '@/utils/request'
|
||||
import type { ApplicationModalData } from '@/views/ApplicationManagement/types'
|
||||
@@ -120,15 +120,19 @@ export const copyApplication = (app_id: string, new_name: string) => {
|
||||
export const getAppStatistics = (app_id: string, data: { start_date: number; end_date: number; }) => {
|
||||
return request.get(`/apps/${app_id}/statistics`, data)
|
||||
}
|
||||
// 导出工作流
|
||||
export const exportWorkflow = (app_id: string, fileName: string) => {
|
||||
return request.downloadFile(`/apps/${app_id}/workflow/export`, fileName, undefined, undefined, 'GET')
|
||||
}
|
||||
// 工作流上传+兼容性分析
|
||||
// Upload workflow and analyze compatibility
|
||||
export const importWorkflow = (formData: FormData) => {
|
||||
return request.uploadFile(`/apps/workflow/import`, formData)
|
||||
}
|
||||
// 完成工作流导入
|
||||
// Complete workflow import
|
||||
export const completeImportWorkflow = (data: { temp_id: string; name?: string; description?: string }) => {
|
||||
return request.post(`/apps/workflow/import/save`, data)
|
||||
}
|
||||
// Get experience config
|
||||
export const getExperienceConfig = (share_token: string) => {
|
||||
return request.get(`/public/share/config`, {}, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem(`shareToken_${share_token}`)}`
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -154,7 +154,7 @@ export const uploadFile = async (data: FormData, options?: UploadFileOptions) =>
|
||||
// 下载文件
|
||||
export const downloadFile = async (fileId: string, fileName?: string) => {
|
||||
const token = cookieUtils.get('authToken');
|
||||
const url = `${apiPrefix}/files/${fileId}`;
|
||||
const url = `/api/files/${fileId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
|
||||
@@ -163,9 +163,14 @@ export const getImplicitInterestAreas = (end_user_id: string) => {
|
||||
export const getImplicitHabits = (end_user_id: string) => {
|
||||
return request.get(`/memory/implicit-memory/habits/${end_user_id}`)
|
||||
}
|
||||
// Implicit Memory - Generate user portrait
|
||||
export const generateProfile = (end_user_id: string) => {
|
||||
return request.post(`/memory/implicit-memory/generate_profile`, { end_user_id })
|
||||
}
|
||||
// Implicit Memory - Check if data exists
|
||||
export const implicitCheckData = (end_user_id: string) => {
|
||||
return request.get(`/memory/implicit-memory/check-data/${end_user_id}`)
|
||||
}
|
||||
// Short-term memory
|
||||
export const getShortTerm = (end_user_id: string) => {
|
||||
return request.get(`/memory/short/short_term`, { end_user_id })
|
||||
|
||||
26
web/src/assets/images/workflow/unknown.svg
Normal file
26
web/src/assets/images/workflow/unknown.svg
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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">
|
||||
<g id="应用管理-工作流-配置-结束" transform="translate(-81, -222)">
|
||||
<g id="未知节点" transform="translate(81, 222)">
|
||||
<rect id="矩形" fill="#4DA8FF" x="0" y="0" width="24" height="24" rx="8"></rect>
|
||||
<g id="未知" transform="translate(4, 4)" stroke="#FFFFFF">
|
||||
<g id="编组-29" transform="translate(1.5, 1.5)">
|
||||
<g id="编组-32" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2">
|
||||
<rect id="矩形" x="0" y="0" width="2.78571429" height="2.78571429" rx="0.5"></rect>
|
||||
<rect id="矩形备份-2" x="10.2142857" y="0" width="2.78571429" height="2.78571429" rx="0.5"></rect>
|
||||
<rect id="矩形" x="0" y="10.2142857" width="2.78571429" height="2.78571429" rx="0.5"></rect>
|
||||
<rect id="矩形备份-2" x="10.2142857" y="10.2142857" width="2.78571429" height="2.78571429" rx="0.5"></rect>
|
||||
<line x1="1.39285714" y1="2.78571429" x2="1.39285714" y2="10.2142857" id="路径-10"></line>
|
||||
<line x1="11.6071429" y1="2.78571429" x2="11.6071429" y2="10.2142857" id="路径-10"></line>
|
||||
<line x1="2.78571429" y1="1.39285714" x2="10.2142857" y2="1.39285714" id="路径-11"></line>
|
||||
<line x1="2.78571429" y1="11.6071429" x2="10.2218696" y2="11.6071429" id="路径-12"></line>
|
||||
</g>
|
||||
<path d="M6.52432916,9.87401948 C6.13772983,9.87401948 5.82432916,9.56061881 5.82432916,9.17401948 C5.82432916,8.78742016 6.13772983,8.47401948 6.52432916,8.47401948 C6.91092848,8.47401948 7.22432916,8.78742016 7.22432916,9.17401948 C7.22432916,9.56061881 6.91092848,9.87401948 6.52432916,9.87401948 Z M8.31902118,5.62806989 C8.07271219,6.00231919 7.78239354,6.33608471 7.45624872,6.61995827 C7.28497017,6.76209568 7.1395834,6.94091014 7.02820004,7.14642499 C6.96128207,7.37469384 6.93624197,7.61622669 6.95463661,7.85600603 L5.97147432,7.85600603 L5.97147432,7.55843743 C5.96278552,7.25062126 6.0177749,6.94479831 6.131988,6.66574207 C6.29645546,6.367538 6.50993979,6.10844775 6.7606742,5.90275181 C6.96315112,5.72946983 7.15324854,5.53812192 7.32917462,5.33051172 C7.42118807,5.19526753 7.47065705,5.02865038 7.46962638,4.85745421 C7.46993348,4.61851645 7.37673645,4.39189307 7.21546905,4.23943033 C7.02808491,4.06206133 6.7899692,3.96970505 6.54664986,3.98002011 C6.30313821,3.97665394 6.06636072,4.07119573 5.87783981,4.24706618 C5.68380967,4.4710619 5.55131388,4.75479898 5.49660839,5.06346565 C5.4631688,5.29236377 4.49338418,5.39154635 4.50003404,4.92612469 C4.52964278,4.40848145 4.74202371,3.92666766 5.08863079,3.59089435 C5.47706668,3.20376734 5.97741851,2.99447314 6.49314835,3.003393 C7.03166324,2.97113454 7.56128279,3.16980909 7.97792275,3.56037182 C8.31228969,3.88267896 8.5034992,4.35809104 8.49960592,4.85745421 C8.50560934,5.12879049 8.44291047,5.3963476 8.31902118,5.62806989 L8.31902118,5.62806989 Z" id="形状结合" stroke-width="0.2" fill="#FFFFFF" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
@@ -1,16 +1,21 @@
|
||||
import { type FC, useRef, useState } from 'react'
|
||||
import RecordRTC from 'recordrtc'
|
||||
|
||||
import { fileUpload } from '@/api/fileStorage'
|
||||
import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import { request } from '@/utils/request'
|
||||
|
||||
interface AudioRecorderProps {
|
||||
onRecordingComplete?: (file: { file_id: string; file_key: string; }, blob: Blob) => void
|
||||
className?: string
|
||||
onRecordingComplete?: (file: { file_id: string; file_key: string; url: string; type?: string; }, blob?: Blob) => void
|
||||
className?: string;
|
||||
action?: string;
|
||||
requestConfig?: Record<string, any>;
|
||||
}
|
||||
|
||||
const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
onRecordingComplete,
|
||||
className = '',
|
||||
action = fileUploadUrlWithoutApiPrefix,
|
||||
requestConfig = {}
|
||||
}) => {
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const recorderRef = useRef<RecordRTC | null>(null)
|
||||
@@ -33,11 +38,17 @@ const AudioRecorder: FC<AudioRecorderProps> = ({
|
||||
if (recorderRef.current) {
|
||||
recorderRef.current.stopRecording(() => {
|
||||
const blob = recorderRef.current!.getBlob()
|
||||
const url = recorderRef.current!.toURL()
|
||||
const formData = new FormData()
|
||||
formData.append('file', blob, `recording_${Date.now()}.webm`)
|
||||
fileUpload(formData)
|
||||
request
|
||||
.uploadFile(action, formData, requestConfig)
|
||||
.then(res => {
|
||||
onRecordingComplete?.(res as { file_id: string; file_key: string; }, blob)
|
||||
onRecordingComplete?.({
|
||||
...(res as { file_id: string; file_key: string }),
|
||||
type: blob.type,
|
||||
url
|
||||
}, blob)
|
||||
recorderRef.current?.destroy()
|
||||
recorderRef.current = null
|
||||
})
|
||||
|
||||
@@ -27,12 +27,45 @@ const ChatContent: FC<ChatContentProps> = ({
|
||||
}) => {
|
||||
// Scroll container reference for controlling auto-scroll to bottom
|
||||
const scrollContainerRef = useRef<(HTMLDivElement | null)>(null)
|
||||
const prevDataLengthRef = useRef(data.length);
|
||||
const isScrolledToBottomRef = useRef(true); // Track if user is scrolled to bottom
|
||||
|
||||
// Track scroll position to determine if user is at bottom
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (scrollContainerRef.current) {
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainerRef.current;
|
||||
// Consider user is at bottom if within 20px of the bottom
|
||||
isScrolledToBottomRef.current = scrollHeight - scrollTop - clientHeight < 20;
|
||||
}
|
||||
};
|
||||
|
||||
const container = scrollContainerRef.current;
|
||||
if (container) {
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
// Initial check
|
||||
handleScroll();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (container) {
|
||||
container.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-scroll to bottom when data changes to show latest messages
|
||||
// When data array length remains unchanged, if data is updated and user manually scrolled up, don't auto-scroll to bottom
|
||||
// When data array length changes, auto-scroll to bottom
|
||||
// If already scrolled to bottom, will auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
// Auto-scroll if data length changed OR user is currently at bottom
|
||||
if (data.length !== prevDataLengthRef.current || isScrolledToBottomRef.current) {
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
}
|
||||
prevDataLengthRef.current = data.length;
|
||||
}
|
||||
}, 0);
|
||||
}, [data])
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-10 16:46:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-10 12:13:52
|
||||
* @Last Modified time: 2026-03-04 18:42:49
|
||||
*/
|
||||
import { type FC, useEffect, useMemo } from 'react'
|
||||
import { Flex, Input, Form } from 'antd'
|
||||
|
||||
import SendIcon from '@/assets/images/conversation/send.svg'
|
||||
import SendDisabledIcon from '@/assets/images/conversation/sendDisabled.svg'
|
||||
import LoadingIcon from '@/assets/images/conversation/loading.svg'
|
||||
@@ -80,9 +81,31 @@ const ChatInput: FC<ChatInputProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (file.type.includes('video')) {
|
||||
return (
|
||||
<div key={file.uid} className="rb:w-45 rb:h-16 rb:inline-block rb:group rb:relative rb:rounded-lg">
|
||||
<video src={file.url} controls className="rb:w-45 rb:h-16 rb:rounded-lg rb:object-cover rb:cursor-pointer" />
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(file)}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (file.type.includes('audio')) {
|
||||
return (
|
||||
<div key={file.uid} className="rb:w-45 rb:h-16 rb:inline-flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5 rb:gap-2">
|
||||
<audio src={file.url} controls className="rb:w-45 rb:h-16" />
|
||||
<div
|
||||
className="rb:hidden rb:group-hover:block rb:absolute rb:-right-1 rb:-top-1 rb:size-3.5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/delete.svg')] rb:hover:bg-[url('@/assets/images/conversation/delete_hover.svg')]"
|
||||
onClick={() => handleDelete(file)}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div key={file.uid} className="rb:w-45 rb:text-[12px] rb:gap-2.5 rb:flex rb:items-center rb:group rb:relative rb:rounded-lg rb:bg-[#F0F3F8] rb:py-2 rb:px-2.5">
|
||||
{(file.type.includes('word') || file.type.includes('wordprocessingml.document')) && <div
|
||||
{(file.type.includes('doc') || file.type.includes('docx') || file.type.includes('word') || file.type.includes('wordprocessingml.document')) && <div
|
||||
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/word_disabled.svg')] rb:hover:bg-[url('@/assets/images/conversation/word.svg')]"
|
||||
></div>}
|
||||
{(file.type.includes('pdf')) && <div
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface ChatItem {
|
||||
status?: string;
|
||||
subContent?: Record<string, any>[];
|
||||
files?: any[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,8 @@ interface SearchInputProps {
|
||||
className?: string;
|
||||
/** Input size */
|
||||
size?: InputProps['size']
|
||||
/** Maximum length of the input value */
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
/** Search input component with debounce and throttle support */
|
||||
|
||||
@@ -455,6 +455,10 @@ export const en = {
|
||||
exportSuccess: 'Export successful',
|
||||
recommend: 'Recommend',
|
||||
default: 'Default',
|
||||
logoTip: `Supported image formats: JPG, PNG \n Suggested size: square ratio \n Maximum size: ≤ 2MB`,
|
||||
imageSquareRequired: 'Please upload a square image',
|
||||
nameInvalid: 'Name cannot start or end with a space',
|
||||
notAllSpaces: 'Cannot be all spaces',
|
||||
},
|
||||
model: {
|
||||
searchPlaceholder: 'search model…',
|
||||
@@ -544,7 +548,7 @@ export const en = {
|
||||
ollama: "Ollama",
|
||||
xinference: "Xinference",
|
||||
gpustack: "Gpustack",
|
||||
bedrock: "Bedrock"
|
||||
bedrock: "Bedrock",
|
||||
},
|
||||
modelNew: {
|
||||
group: 'Model Group',
|
||||
@@ -602,7 +606,13 @@ export const en = {
|
||||
ollama: "Ollama",
|
||||
xinference: "Xinference",
|
||||
gpustack: "Gpustack",
|
||||
bedrock: "Bedrock"
|
||||
bedrock: "Bedrock",
|
||||
|
||||
is_vision: 'Vision Support',
|
||||
is_omni: 'Omni Support',
|
||||
vision: 'Vision',
|
||||
audio: 'Audio',
|
||||
video: 'Video',
|
||||
},
|
||||
knowledgeBase: {
|
||||
home: 'Home',
|
||||
@@ -610,6 +620,7 @@ export const en = {
|
||||
preview: 'Preview',
|
||||
pleaseUploadFileFirst: 'Please upload file first',
|
||||
shareSuccess: 'Share successfully',
|
||||
stopShareSuccess: 'Sharing is off. Access denied. ',
|
||||
shareFailed: 'Share failed',
|
||||
allModels: 'All Models',
|
||||
knowledgeBaseInfo: 'Knowledge base information',
|
||||
@@ -1343,7 +1354,9 @@ export const en = {
|
||||
dynamicMatchSkill: 'Dynamic Match Skill',
|
||||
executeTask: 'Execute Task',
|
||||
|
||||
importWorkflow: 'Import Workflow',
|
||||
import: 'Import Application',
|
||||
importWorkflow: 'Third-Party Workflow',
|
||||
importThirdParty: 'Import Workflow',
|
||||
platform: 'Source Platform',
|
||||
upload: 'Upload & Parse',
|
||||
complex: 'Compatibility Analysis',
|
||||
@@ -1357,6 +1370,7 @@ export const en = {
|
||||
gotoList: 'Return to Application List',
|
||||
gotoDetail: 'View Details',
|
||||
dify: 'Dify',
|
||||
pleaseUploadFile: 'Please upload workflow file',
|
||||
},
|
||||
userMemory: {
|
||||
userMemory: 'User Memory',
|
||||
@@ -1679,8 +1693,11 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
uploadFile: 'Upload File',
|
||||
fileType: 'File Type',
|
||||
image: 'Image',
|
||||
video: 'Video',
|
||||
audio: 'Audio',
|
||||
fileUrl: 'File URL',
|
||||
addRemoteFile: 'Add Remote File'
|
||||
addRemoteFile: 'Add Remote File',
|
||||
variableConfig: 'Variable Configuration',
|
||||
},
|
||||
login: {
|
||||
title: 'Red Bear Memory Science',
|
||||
@@ -1768,6 +1785,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
mcp: 'MCP Services',
|
||||
inner: 'Built-in Tools',
|
||||
custom: 'Custom Tools',
|
||||
market: 'Tool Market',
|
||||
mcpSearchPlaceholder: 'Search MCP Services...',
|
||||
innerSearchPlaceholder: 'Search Tools...',
|
||||
customSearchPlaceholder: 'Search Custom Tools...',
|
||||
@@ -1941,7 +1959,9 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
path: 'Path',
|
||||
viewDetail: 'View Details',
|
||||
textLink: 'Test Connection',
|
||||
noResult: 'Processing results will be displayed here'
|
||||
noResult: 'Processing results will be displayed here',
|
||||
serverUrlInvalid: 'Must start with http:// or https://, and cannot have leading or trailing spaces',
|
||||
requestHeaderKeyInvalid: 'Only English letters, numbers, hyphens (-), and underscores (_) are allowed, and cannot start or end with a hyphen or underscore',
|
||||
},
|
||||
workflow: {
|
||||
coreNode: 'Core Nodes',
|
||||
@@ -1987,6 +2007,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
evolutionAndGovernance: 'Evolution & Governance',
|
||||
self_optimization: 'Self Optimization',
|
||||
process_evolution: 'Process Evolution',
|
||||
unknown: 'Unknown Node',
|
||||
|
||||
clickToConfigure: 'Click to configure node parameters',
|
||||
nodeProperties: 'Node Properties',
|
||||
@@ -2187,7 +2208,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
save: 'Save',
|
||||
export: 'Export',
|
||||
variableConfig: 'Variable Configuration',
|
||||
variableRequired: 'Required',
|
||||
variableRequired: 'Required, please configure variable values',
|
||||
addMessage: 'Add Message',
|
||||
answerDesc: 'Reply',
|
||||
addNode: 'Add Node',
|
||||
@@ -2290,6 +2311,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
suggestions: 'Personalized Suggestions',
|
||||
suggestionLoading: 'Your personalized suggestions are being generated',
|
||||
item: 'item',
|
||||
noData: 'Emotion suggestion data does not exist, please click the refresh button to initialize',
|
||||
},
|
||||
reflectionEngine: {
|
||||
reflectionEngineConfig: 'Reflection Engine Configuration',
|
||||
@@ -2536,7 +2558,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
||||
context_details: 'Preference Details',
|
||||
supporting_evidence: 'Preference Source',
|
||||
specific_examples: 'Source',
|
||||
wordEmpty: 'Click on a node in the left chart to view preference details'
|
||||
wordEmpty: 'Click on a node in the left chart to view preference details',
|
||||
noData: 'Portrait data does not exist, please click the refresh button to initialize',
|
||||
},
|
||||
shortTermDetail: {
|
||||
title: 'Short-term memory is the "workbench" of the AI system, connecting instant conversations with long-term knowledge bases. Through real-time capture, deep retrieval, intelligent extraction and filtering transformation, temporary unstructured information is converted into valuable long-term knowledge.',
|
||||
|
||||
@@ -122,6 +122,7 @@ export const zh = {
|
||||
preview: '预览',
|
||||
pleaseUploadFileFirst: '请先上传文件',
|
||||
shareSuccess: '分享成功',
|
||||
stopShareSuccess: '已取消分享,对方将无法访问该知识库',
|
||||
shareFailed: '分享失败',
|
||||
allModels: '所有模型',
|
||||
knowledgeBaseInfo: '知识库信息',
|
||||
@@ -736,7 +737,9 @@ export const zh = {
|
||||
dynamicMatchSkill: '动态匹配技能',
|
||||
executeTask: '执行任务',
|
||||
|
||||
importWorkflow: '导入工作流',
|
||||
import: '导入应用',
|
||||
importWorkflow: '第三方工作流',
|
||||
importThirdParty: '导入工作流',
|
||||
platform: '来源平台',
|
||||
upload: '上传与解析',
|
||||
complex: '兼容性分析',
|
||||
@@ -751,6 +754,7 @@ export const zh = {
|
||||
gotoList: '返回应用列表',
|
||||
gotoDetail: '查看详情',
|
||||
dify: 'Dify',
|
||||
pleaseUploadFile: '请上传工作流文件',
|
||||
},
|
||||
table: {
|
||||
totalRecords: '共 {{total}} 条记录'
|
||||
@@ -1031,6 +1035,10 @@ export const zh = {
|
||||
exportSuccess: '导出成功',
|
||||
recommend: '推荐',
|
||||
default: '默认',
|
||||
logoTip: `支持图片格式(JPG、PNG)\n 尺寸:正方形比例 \n 文件大小限制:≤ 2MB`,
|
||||
imageSquareRequired: '请上传正方形比例图片',
|
||||
nameInvalid: '不能是空格开头或结尾',
|
||||
notAllSpaces: '不能是纯空格',
|
||||
},
|
||||
model: {
|
||||
searchPlaceholder: '搜索模型…',
|
||||
@@ -1178,7 +1186,13 @@ export const zh = {
|
||||
ollama: "Ollama",
|
||||
xinference: "Xinference",
|
||||
gpustack: "Gpustack",
|
||||
bedrock: "Bedrock"
|
||||
bedrock: "Bedrock",
|
||||
|
||||
is_vision: '支持视觉',
|
||||
is_omni: '支持全模态',
|
||||
vision: '视觉',
|
||||
audio: '音频',
|
||||
video: '视频',
|
||||
},
|
||||
timezones: {
|
||||
'Asia/Shanghai': '中国标准时间 (UTC+8)',
|
||||
@@ -1676,8 +1690,11 @@ export const zh = {
|
||||
uploadFile: '上传文件',
|
||||
fileType: '文件类型',
|
||||
image: '图片',
|
||||
video: '视频',
|
||||
audio: '音频',
|
||||
fileUrl: '文件链接',
|
||||
addRemoteFile: '添加远程文件'
|
||||
addRemoteFile: '添加远程文件',
|
||||
variableConfig: '变量配置',
|
||||
},
|
||||
login: {
|
||||
title: '红熊记忆科学',
|
||||
@@ -1765,6 +1782,7 @@ export const zh = {
|
||||
mcp: 'MCP 服务',
|
||||
inner: '内置工具',
|
||||
custom: '自定义工具',
|
||||
market: '工具市场',
|
||||
mcpSearchPlaceholder: '搜索MCP服务...',
|
||||
innerSearchPlaceholder: '搜索工具...',
|
||||
customSearchPlaceholder: '搜索自定义工具...',
|
||||
@@ -1938,7 +1956,9 @@ export const zh = {
|
||||
path: '路径',
|
||||
viewDetail: '查看详情',
|
||||
textLink: '测试连接',
|
||||
noResult: '处理结果将显示在这里'
|
||||
noResult: '处理结果将显示在这里',
|
||||
serverUrlInvalid: '必须以 http:// 或 https:// 开头,且不能有前后空格',
|
||||
requestHeaderKeyInvalid: '只支持英文、数字、连字符(-)、下划线(_),不能以连字符或下划线开头结尾',
|
||||
},
|
||||
workflow: {
|
||||
coreNode: '核心节点',
|
||||
@@ -1984,6 +2004,7 @@ export const zh = {
|
||||
evolutionAndGovernance: '演化与治理',
|
||||
self_optimization: '自我优化',
|
||||
process_evolution: '流程演化',
|
||||
unknown: '未知节点',
|
||||
|
||||
clickToConfigure: '点击配置节点参数',
|
||||
nodeProperties: '节点属性',
|
||||
@@ -2171,6 +2192,9 @@ export const zh = {
|
||||
output_variables: '输出变量',
|
||||
refreshTip: '同步函数签名至代码',
|
||||
},
|
||||
unknown: {
|
||||
replaceNodeType: '替换节点'
|
||||
},
|
||||
name: '键',
|
||||
type: '类型',
|
||||
value: '值',
|
||||
@@ -2184,7 +2208,7 @@ export const zh = {
|
||||
save: '保存',
|
||||
export: '导出',
|
||||
variableConfig: '变量配置',
|
||||
variableRequired: '必填',
|
||||
variableRequired: '必填,请配置变量值',
|
||||
addMessage: '添加消息',
|
||||
answerDesc: '回复',
|
||||
addNode: '添加节点',
|
||||
@@ -2202,7 +2226,8 @@ export const zh = {
|
||||
iteration: '迭代',
|
||||
input_cycle_vars: '初始循环变量',
|
||||
output_cycle_vars: '最终循环变量',
|
||||
}
|
||||
},
|
||||
sureReplace: '确认替换',
|
||||
},
|
||||
emotionEngine: {
|
||||
emotionEngineConfig: '情感引擎配置',
|
||||
@@ -2287,6 +2312,7 @@ export const zh = {
|
||||
suggestions: '个性化建议',
|
||||
suggestionLoading: '您的个性化建议正在生成中',
|
||||
item: '个',
|
||||
noData: '情绪建议数据不存在,请点击刷新按钮进行初始化',
|
||||
},
|
||||
reflectionEngine: {
|
||||
reflectionEngineConfig: '反思引擎配置',
|
||||
@@ -2533,7 +2559,8 @@ export const zh = {
|
||||
context_details: '偏好详情',
|
||||
supporting_evidence: '偏好来源',
|
||||
specific_examples: '来源',
|
||||
wordEmpty: '点击左侧图表中的节点查看偏好详情'
|
||||
wordEmpty: '点击左侧图表中的节点查看偏好详情',
|
||||
noData: '画像数据不存在,请点击刷新按钮进行初始化',
|
||||
},
|
||||
shortTermDetail: {
|
||||
title: '短期记忆是AI系统的"工作台",连接即时对话与长期知识库。通过实时捕获、深度检索、智能提取和筛选转化,将临时的非结构化信息转化为有价值的长期知识。',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-02 16:35:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-02 16:35:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 18:19:24
|
||||
*/
|
||||
/**
|
||||
* Server-Sent Events (SSE) Stream Utility Module
|
||||
@@ -176,17 +176,17 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
|
||||
case 500:
|
||||
case 502:
|
||||
const errorData = await response.json();
|
||||
errorData.error || i18n.t('common.serviceUpgrading');
|
||||
message.warning(errorData.error || i18n.t('common.serviceUpgrading'));
|
||||
return;
|
||||
let errorInfo = errorData.error || i18n.t('common.serviceUpgrading')
|
||||
message.warning(errorInfo);
|
||||
throw errorInfo;
|
||||
case 400:
|
||||
const error = await response.json();
|
||||
message.warning(error.error);
|
||||
throw error || 'Bad Request';
|
||||
throw error.error || 'Bad Request';
|
||||
case 504:
|
||||
const errorJson = await response.json();
|
||||
message.warning(errorJson.error || i18n.t('common.serverError'));
|
||||
return;
|
||||
throw errorData.error;
|
||||
case 401:
|
||||
if (url?.includes('/public')) {
|
||||
return message.warning(i18n.t('common.publicApiCannotRefreshToken'));
|
||||
|
||||
49
web/src/utils/validator.ts
Normal file
49
web/src/utils/validator.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-03-02 13:46:53
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-02 14:38:33
|
||||
*/
|
||||
/**
|
||||
* Form validation utilities
|
||||
*/
|
||||
|
||||
interface UploadFile {
|
||||
originFileObj: Blob;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if uploaded image is square (width === height)
|
||||
* @param errorMessage - Error message to display when validation fails
|
||||
* @returns Ant Design form validator
|
||||
*/
|
||||
export const validateSquareImage = (errorMessage: string = 'Image must be square') => {
|
||||
return (_: unknown, value: UploadFile | UploadFile[] | undefined) => {
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const file = Array.isArray(value) ? value[0] : value;
|
||||
|
||||
if (file?.originFileObj) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (img.width === img.height) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(errorMessage));
|
||||
}
|
||||
};
|
||||
img.onerror = () => reject(new Error('Failed to load image'));
|
||||
img.src = URL.createObjectURL(file.originFileObj);
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
};
|
||||
|
||||
// - Cannot start or end with a space
|
||||
export const stringRegExp = /^(?!\s).*(?<!\s)$/
|
||||
@@ -12,6 +12,7 @@ import dayjs from 'dayjs'
|
||||
import type { ApiKey, ApiKeyModalRef } from '../types';
|
||||
import RbModal from '@/components/RbModal'
|
||||
import { createApiKey, updateApiKey } from '@/api/apiKey';
|
||||
import { stringRegExp } from '@/utils/validator';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
@@ -78,7 +79,7 @@ const ApiKeyModal = forwardRef<ApiKeyModalRef, CreateModalProps>(({
|
||||
form.validateFields()
|
||||
.then((values) => {
|
||||
const { memory, rag, expires_at, ...rest } = values
|
||||
let scopes = []
|
||||
const scopes = []
|
||||
|
||||
if (memory) {
|
||||
scopes.push('memory')
|
||||
@@ -130,7 +131,11 @@ const ApiKeyModal = forwardRef<ApiKeyModalRef, CreateModalProps>(({
|
||||
<FormItem
|
||||
name="name"
|
||||
label={t('apiKey.name')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ max: 50 },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.enter')} />
|
||||
</FormItem>
|
||||
@@ -138,6 +143,7 @@ const ApiKeyModal = forwardRef<ApiKeyModalRef, CreateModalProps>(({
|
||||
<FormItem
|
||||
name="description"
|
||||
label={t('apiKey.description')}
|
||||
rules={[{ max: 500 }]}
|
||||
>
|
||||
<Input.TextArea placeholder={t('common.pleaseEnter')} rows={3} />
|
||||
</FormItem>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:29:21
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-25 18:11:49
|
||||
* @Last Modified time: 2026-03-03 14:24:34
|
||||
*/
|
||||
import { type FC, type ReactNode, useEffect, useRef, useState, forwardRef, useImperativeHandle } from 'react';
|
||||
import clsx from 'clsx'
|
||||
@@ -169,12 +169,16 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
getApplicationConfig(id as string).then(res => {
|
||||
const response = res as Config
|
||||
const { skills, variables } = response
|
||||
let allSkills = Array.isArray(skills?.skill_ids) ? skills?.skill_ids.map(vo => ({ id: vo })) : []
|
||||
let allTools = Array.isArray(response.tools) ? response.tools : []
|
||||
const allSkills = Array.isArray(skills?.skill_ids) ? skills?.skill_ids.map(vo => ({ id: vo })) : []
|
||||
const allTools = Array.isArray(response.tools) ? response.tools : []
|
||||
const memoryContent = response.memory?.memory_config_id
|
||||
const parsedMemoryContent = memoryContent === null || memoryContent === ''
|
||||
? undefined
|
||||
: !isNaN(Number(memoryContent)) ? Number(memoryContent) : memoryContent
|
||||
const variableList = variables?.map((item, index) => ({
|
||||
...item,
|
||||
index
|
||||
})) || []
|
||||
form.setFieldsValue({
|
||||
...response,
|
||||
tools: allTools,
|
||||
@@ -185,9 +189,10 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
skills: {
|
||||
...skills,
|
||||
skill_ids: allSkills
|
||||
}
|
||||
},
|
||||
variables: [...variableList]
|
||||
})
|
||||
updateVariableList([...variables])
|
||||
updateVariableList([...variableList])
|
||||
setData({
|
||||
...response,
|
||||
tools: allTools
|
||||
@@ -398,6 +403,9 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
const handleSaveChatVariable = (values: Variable[]) => {
|
||||
setChatVariables(values)
|
||||
}
|
||||
useEffect(() => {
|
||||
setChatVariables(values?.variables || [])
|
||||
}, [values?.variables])
|
||||
console.log('values', values)
|
||||
return (
|
||||
<>
|
||||
@@ -431,7 +439,11 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form.Item name="system_prompt" className="rb:mb-0!">
|
||||
<Form.Item
|
||||
name="system_prompt"
|
||||
className="rb:mb-0!"
|
||||
rules={[{ max: 10000 }]}
|
||||
>
|
||||
<Input.TextArea
|
||||
placeholder={t('application.promptPlaceholder')}
|
||||
styles={{
|
||||
@@ -498,6 +510,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
|
||||
chatList={chatList}
|
||||
updateChatList={setChatList}
|
||||
handleSave={handleSave}
|
||||
chatVariables={chatVariables}
|
||||
/>
|
||||
</RbCard>
|
||||
</Col>
|
||||
|
||||
@@ -29,7 +29,7 @@ const Api: FC<{ application: Application | null }> = ({ application }) => {
|
||||
const { t } = useTranslation();
|
||||
const activeMethods = ['POST'];
|
||||
const { message, modal } = App.useApp()
|
||||
const copyContent = window.location.origin + '/v1/chat'
|
||||
const copyContent = window.location.origin + '/v1/app/chat'
|
||||
const apiKeyModalRef = useRef<ApiKeyModalRef>(null);
|
||||
const apiKeyConfigModalRef = useRef<ApiKeyConfigModalRef>(null);
|
||||
const [apiKeyList, setApiKeyList] = useState<ApiKey[]>([])
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:27:39
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-10 17:40:15
|
||||
* @Last Modified time: 2026-03-04 18:51:20
|
||||
*/
|
||||
/**
|
||||
* Chat debugging component for application testing
|
||||
@@ -13,7 +13,7 @@
|
||||
import { type FC, useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import clsx from 'clsx'
|
||||
import { Flex, Dropdown, type MenuProps } from 'antd'
|
||||
import { Flex, Dropdown, type MenuProps, App, Divider } from 'antd'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import DebuggingEmpty from '@/assets/images/application/debuggingEmpty.png'
|
||||
@@ -25,9 +25,10 @@ import type { ChatItem } from '@/components/Chat/types'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import ChatInput from '@/components/Chat/ChatInput'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
// import AudioRecorder from '@/components/AudioRecorder'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
import type { Variable } from './VariableList/types'
|
||||
|
||||
/**
|
||||
* Component props
|
||||
@@ -43,14 +44,16 @@ interface ChatProps {
|
||||
handleSave: (flag?: boolean) => Promise<unknown>;
|
||||
/** Source type: multi-agent cluster or single agent */
|
||||
source?: 'multi_agent' | 'agent';
|
||||
chatVariables?: Variable[]; // Add chatVariables prop
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat debugging component
|
||||
* Allows testing application with different model configurations side-by-side
|
||||
*/
|
||||
const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, source = 'agent' }) => {
|
||||
const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, source = 'agent', chatVariables }) => {
|
||||
const { t } = useTranslation();
|
||||
const { message: messageApi } = App.useApp()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [isCluster, setIsCluster] = useState(source === 'multi_agent')
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
@@ -85,7 +88,7 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
content: '',
|
||||
created_at: Date.now(),
|
||||
};
|
||||
|
||||
|
||||
if (isCluster) {
|
||||
updateChatList(prev => prev.map(item => ({
|
||||
...item,
|
||||
@@ -131,7 +134,7 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
})
|
||||
}
|
||||
/** Update assistant message when error occurs */
|
||||
const updateErrorAssistantMessage = (message_length: number, model_config_id?: string) => {
|
||||
const updateErrorAssistantMessage = (message_length: number, model_config_id?: string) => {
|
||||
if (message_length > 0 || !model_config_id) return
|
||||
|
||||
updateChatList(prev => {
|
||||
@@ -195,6 +198,29 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
// Validate required variables before sending
|
||||
let isCanSend = true
|
||||
const params: Record<string, any> = {}
|
||||
if (chatVariables && chatVariables.length > 0) {
|
||||
const needRequired: string[] = []
|
||||
chatVariables.forEach(vo => {
|
||||
params[vo.name] = vo.value
|
||||
|
||||
if (vo.required && (params[vo.name] === null || params[vo.name] === undefined || params[vo.name] === '')) {
|
||||
isCanSend = false
|
||||
needRequired.push(vo.name)
|
||||
}
|
||||
})
|
||||
|
||||
if (needRequired.length) {
|
||||
messageApi.error(`${needRequired.join(',')} ${t('workflow.variableRequired')}`)
|
||||
}
|
||||
}
|
||||
if (!isCanSend) {
|
||||
setLoading(false)
|
||||
setCompareLoading(false)
|
||||
return
|
||||
}
|
||||
runCompare(data.app_id, {
|
||||
message,
|
||||
files: fileList.map(file => {
|
||||
@@ -214,12 +240,20 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
model_parameters: item.model_parameters,
|
||||
conversation_id: item.conversation_id
|
||||
})),
|
||||
variables: {},
|
||||
variables: params,
|
||||
"parallel": true,
|
||||
"stream": true,
|
||||
"timeout": 60,
|
||||
}, handleStreamMessage)
|
||||
.finally(() => setLoading(false));
|
||||
.catch(() => {
|
||||
setLoading(false)
|
||||
setCompareLoading(false)
|
||||
updateClusterErrorAssistantMessage(0)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
setCompareLoading(false)
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -264,7 +298,7 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
})
|
||||
}
|
||||
/** Update cluster message when error occurs */
|
||||
const updateClusterErrorAssistantMessage = (message_length: number) => {
|
||||
const updateClusterErrorAssistantMessage = (message_length: number) => {
|
||||
if (message_length > 0) return
|
||||
|
||||
updateChatList(prev => {
|
||||
@@ -307,7 +341,7 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
data.map(item => {
|
||||
const { conversation_id, content, message_length } = item.data as { conversation_id: string, content: string, message_length: number };
|
||||
|
||||
switch(item.event) {
|
||||
switch (item.event) {
|
||||
case 'start':
|
||||
if (conversation_id && conversationId !== conversation_id) {
|
||||
setConversationId(conversation_id);
|
||||
@@ -330,27 +364,35 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
draftRun(
|
||||
data.app_id,
|
||||
{
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
stream: true,
|
||||
files: fileList.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
draftRun(
|
||||
data.app_id,
|
||||
{
|
||||
message,
|
||||
conversation_id: conversationId,
|
||||
stream: true,
|
||||
files: fileList.map(file => {
|
||||
if (file.url) {
|
||||
return file
|
||||
} else {
|
||||
return {
|
||||
type: file.type,
|
||||
transfer_method: 'local_file',
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}),
|
||||
},
|
||||
handleStreamMessage
|
||||
)
|
||||
.finally(() => setLoading(false))
|
||||
}
|
||||
}),
|
||||
},
|
||||
handleStreamMessage
|
||||
)
|
||||
.catch(() => {
|
||||
setLoading(false)
|
||||
setCompareLoading(false)
|
||||
updateClusterErrorAssistantMessage(0)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
setCompareLoading(false)
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -369,12 +411,17 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
const fileChange = (file?: any) => {
|
||||
setFileList([...fileList, file])
|
||||
}
|
||||
// const handleRecordingComplete = async (file: any) => {
|
||||
// console.log('file', file)
|
||||
// }
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
setFileList([...fileList, {
|
||||
uid: file.file_id,
|
||||
response: { data: file },
|
||||
thumbUrl: file.url,
|
||||
type: file.type
|
||||
}])
|
||||
}
|
||||
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch(key) {
|
||||
switch (key) {
|
||||
case 'define':
|
||||
uploadFileListModalRef.current?.handleOpen()
|
||||
break
|
||||
@@ -391,99 +438,98 @@ const Chat: FC<ChatProps> = ({ chatList, data, updateChatList, handleSave, sourc
|
||||
return (
|
||||
<div className="rb:relative rb:h-full rb:flex rb:flex-col">
|
||||
{chatList.length === 0
|
||||
? <Empty
|
||||
url={DebuggingEmpty}
|
||||
? <Empty
|
||||
url={DebuggingEmpty}
|
||||
size={[300, 200]}
|
||||
title={t('application.debuggingEmpty')}
|
||||
subTitle={t('application.debuggingEmptyDesc')}
|
||||
title={t('application.debuggingEmpty')}
|
||||
subTitle={t('application.debuggingEmptyDesc')}
|
||||
className="rb:h-full"
|
||||
/>
|
||||
: <>
|
||||
<div className={clsx(`rb:relative rb:grid rb:grid-cols-${chatList.length} rb:overflow-hidden rb:w-full rb:flex-1 rb:min-h-0`)}>
|
||||
{chatList.map((chat, index) => (
|
||||
<div key={index} className={clsx('rb:flex rb:flex-col', {
|
||||
"rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1,
|
||||
})}>
|
||||
{chat.label &&
|
||||
<div className={clsx(
|
||||
"rb:grid rb:bg-[#F0F3F8] rb:text-center rb:flex-[0_0_auto]",
|
||||
{
|
||||
'rb:rounded-tr-xl': index === chatList.length - 1,
|
||||
'rb:rounded-tl-xl': index === 0,
|
||||
}
|
||||
)}>
|
||||
<div className='rb:relative rb:p-[10px_12px] rb:overflow-hidden'>
|
||||
<div className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:w-[calc(100%-24px)]">{chat.label}</div>
|
||||
<div
|
||||
className="rb:w-4 rb:h-4 rb:cursor-pointer rb:absolute rb:top-3 rb:right-3 rb:bg-cover rb:bg-[url('@/assets/images/close.svg')] rb:hover:bg-[url('@/assets/images/close_hover.svg')]"
|
||||
onClick={() => handleDelete(index)}
|
||||
></div>
|
||||
: <>
|
||||
<div className={clsx(`rb:relative rb:grid rb:grid-cols-${chatList.length} rb:overflow-hidden rb:w-full rb:flex-1 rb:min-h-0`)}>
|
||||
{chatList.map((chat, index) => (
|
||||
<div key={index} className={clsx('rb:flex rb:flex-col', {
|
||||
"rb:border-r rb:border-[#DFE4ED]": index !== chatList.length - 1 && chatList.length > 1,
|
||||
})}>
|
||||
{chat.label &&
|
||||
<div className={clsx(
|
||||
"rb:grid rb:bg-[#F0F3F8] rb:text-center rb:flex-[0_0_auto]",
|
||||
{
|
||||
'rb:rounded-tr-xl': index === chatList.length - 1,
|
||||
'rb:rounded-tl-xl': index === 0,
|
||||
}
|
||||
)}>
|
||||
<div className='rb:relative rb:p-[10px_12px] rb:overflow-hidden'>
|
||||
<div className="rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap rb:w-[calc(100%-24px)]">{chat.label}</div>
|
||||
<div
|
||||
className="rb:w-4 rb:h-4 rb:cursor-pointer rb:absolute rb:top-3 rb:right-3 rb:bg-cover rb:bg-[url('@/assets/images/close.svg')] rb:hover:bg-[url('@/assets/images/close_hover.svg')]"
|
||||
onClick={() => handleDelete(index)}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<ChatContent
|
||||
classNames={{
|
||||
'rb:mx-[16px] rb:mt-6': true,
|
||||
'rb:h-[calc(100vh-282px)]': isCluster,
|
||||
'rb:h-[calc(100vh-380px)]': !isCluster,
|
||||
}}
|
||||
contentClassNames={{
|
||||
'rb:max-w-[400px]!': chatList.length === 1,
|
||||
'rb:max-w-[260px]!': chatList.length === 2,
|
||||
'rb:max-w-[150px]!': chatList.length === 3,
|
||||
'rb:max-w-[108px]!': chatList.length === 4,
|
||||
}}
|
||||
empty={<Empty url={ChatIcon} title={t('application.chatEmpty')} isNeedSubTitle={false} size={[240, 200]} className="rb:h-full" />}
|
||||
data={chat.list || []}
|
||||
streamLoading={compareLoading}
|
||||
labelPosition="top"
|
||||
labelFormat={(item) => item.role === 'user' ? t('application.you') : chat.label}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rb:relative rb:flex rb:items-center rb:gap-2.5 rb:m-4 rb:mb-1">
|
||||
<ChatInput
|
||||
message={message}
|
||||
className="rb:relative!"
|
||||
loading={loading}
|
||||
fileChange={updateFileList}
|
||||
fileList={fileList}
|
||||
onSend={isCluster ? handleClusterSend : handleSend}
|
||||
onChange={handleMessageChange}
|
||||
>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']}
|
||||
onChange={fileChange}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}
|
||||
<ChatContent
|
||||
classNames={{
|
||||
'rb:mx-[16px] rb:mt-6': true,
|
||||
'rb:h-[calc(100vh-282px)]': isCluster,
|
||||
'rb:h-[calc(100vh-380px)]': !isCluster,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
contentClassNames={{
|
||||
'rb:max-w-[400px]!': chatList.length === 1,
|
||||
'rb:max-w-[260px]!': chatList.length === 2,
|
||||
'rb:max-w-[150px]!': chatList.length === 3,
|
||||
'rb:max-w-[108px]!': chatList.length === 4,
|
||||
}}
|
||||
empty={<Empty url={ChatIcon} title={t('application.chatEmpty')} isNeedSubTitle={false} size={[240, 200]} className="rb:h-full" />}
|
||||
data={chat.list || []}
|
||||
streamLoading={compareLoading}
|
||||
labelPosition="top"
|
||||
labelFormat={(item) => item.role === 'user' ? t('application.you') : chat.label}
|
||||
errorDesc={t('application.ReplyException')}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rb:relative rb:flex rb:items-center rb:gap-2.5 rb:m-4 rb:mb-1">
|
||||
<ChatInput
|
||||
message={message}
|
||||
className="rb:relative!"
|
||||
loading={loading}
|
||||
fileChange={updateFileList}
|
||||
fileList={fileList}
|
||||
onSend={isCluster ? handleClusterSend : handleSend}
|
||||
onChange={handleMessageChange}
|
||||
>
|
||||
<Flex justify="space-between" className="rb:flex-1">
|
||||
<Flex gap={8} align="center">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'define', label: t('memoryConversation.addRemoteFile') },
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
onChange={fileChange}
|
||||
/>
|
||||
)
|
||||
},
|
||||
],
|
||||
onClick: handleShowUpload
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rb:size-6 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/conversation/link.svg')] rb:hover:bg-[url('@/assets/images/conversation/link_hover.svg')]"
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
<Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
{/* <Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex> */}
|
||||
</Flex>
|
||||
</ChatInput>
|
||||
</div>
|
||||
</>
|
||||
</ChatInput>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
<UploadFileListModal
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:28:46
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:28:46
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 14:03:44
|
||||
*/
|
||||
/**
|
||||
* Release Share Modal
|
||||
@@ -79,7 +79,7 @@ const ReleaseShareModal = forwardRef<ReleaseShareModalRef, ReleaseShareModalProp
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={<>{t('application.shareVersion')} {version?.version}</>}
|
||||
title={<>{t('application.shareVersion')} ({version?.version_name && version.version_name[0].toLocaleLowerCase() === 'v' ? version.version_name : version?.version_name ? `v${version.version_name}` : `v${version?.version}`})</>}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
footer={false}
|
||||
|
||||
@@ -21,6 +21,7 @@ import WorkflowIcon from '@/assets/images/application/workflow.svg'
|
||||
import type { ApplicationModalData, ApplicationModalRef, Application } from '../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import { addApplication, updateApplication } from '@/api/application'
|
||||
import { stringRegExp } from '@/utils/validator';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
@@ -131,13 +132,18 @@ const ApplicationModal = forwardRef<ApplicationModalRef, ApplicationModalProps>(
|
||||
<FormItem
|
||||
name="name"
|
||||
label={t('application.applicationName')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ max: 50 },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.enter')} />
|
||||
</FormItem>
|
||||
<FormItem
|
||||
name="description"
|
||||
label={t('application.description')}
|
||||
rules={[{ max: 500 }]}
|
||||
>
|
||||
<Input.TextArea placeholder={t('common.enter')} />
|
||||
</FormItem>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-28 14:08:14
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 16:20:40
|
||||
* @Last Modified time: 2026-03-02 17:39:49
|
||||
*/
|
||||
/**
|
||||
* UploadWorkflowModal Component
|
||||
@@ -14,7 +14,7 @@
|
||||
* 4. Completed - Show success message and options
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useMemo } from 'react';
|
||||
import { Form, Select, Steps, Flex, Alert, Input, Button, Result } from 'antd';
|
||||
import { Form, Select, Steps, Flex, Alert, Input, Button, Result, message } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { UploadWorkflowModalData, UploadData, UploadWorkflowModalRef } from '../types'
|
||||
@@ -92,18 +92,22 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
|
||||
switch(current) {
|
||||
case 0: // Step 1: Upload file
|
||||
if (!values.file || values.file.length === 0) {
|
||||
message.warning(t('application.pleaseUploadFile'));
|
||||
return;
|
||||
}
|
||||
const formData = new FormData();
|
||||
setFirstFormData(values);
|
||||
formData.append('platform', values.platform);
|
||||
formData.append('file', values.file[0]);
|
||||
|
||||
|
||||
// Call import workflow API
|
||||
importWorkflow(formData)
|
||||
.then(res => {
|
||||
const response = res as UploadData;
|
||||
const { errors, warnings } = response;
|
||||
setData(response);
|
||||
|
||||
|
||||
// Navigate to error/warning step if any, otherwise go to confirmation
|
||||
if (errors.length || warnings.length) {
|
||||
setCurrent(1);
|
||||
@@ -203,7 +207,7 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
{t('common.cancel')}
|
||||
</Button>,
|
||||
<Button
|
||||
key="submit"
|
||||
key="nextStep"
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={handleSave}
|
||||
@@ -215,7 +219,7 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
return null;
|
||||
default: // Steps 1-2
|
||||
return [
|
||||
<Button onClick={handleClose}>
|
||||
<Button key="cancel" onClick={handleClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>,
|
||||
<Button key="back" onClick={handleLastStep}>
|
||||
@@ -235,7 +239,7 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={t('application.importWorkflow')}
|
||||
title={t('application.importThirdParty')}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText={t('application.nextStep')}
|
||||
@@ -262,7 +266,10 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
platform: 'dify'
|
||||
}}
|
||||
>
|
||||
<Form.Item name="platform" label={t('application.platform')}>
|
||||
<Form.Item
|
||||
name="platform" label={t('application.platform')}
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={['dify'].map(value => ({
|
||||
@@ -270,16 +277,17 @@ const UploadWorkflowModal = forwardRef<UploadWorkflowModalRef, UploadWorkflowMod
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="file" valuePropName="fileList" noStyle>
|
||||
<Form.Item
|
||||
name="file"
|
||||
valuePropName="fileList"
|
||||
noStyle
|
||||
>
|
||||
<UploadFiles
|
||||
isAutoUpload={false}
|
||||
isCanDrag={true}
|
||||
fileSize={100}
|
||||
maxCount={1}
|
||||
fileType={['yml', 'yaml', 'zip', 'json']}
|
||||
onChange={(fileList) => {
|
||||
console.log('文件列表变化:', fileList);
|
||||
}}
|
||||
fileType={['yml']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:34:12
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 13:52:22
|
||||
* @Last Modified time: 2026-03-02 17:48:51
|
||||
*/
|
||||
/**
|
||||
* Application Management Page
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Row, Col, App, Select, Space } from 'antd';
|
||||
import { Button, Row, Col, App, Select, Space, Dropdown } from 'antd';
|
||||
import clsx from 'clsx';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
@@ -86,6 +86,13 @@ const ApplicationManagement: React.FC = () => {
|
||||
const handleImport = () => {
|
||||
uploadWorkflowModalRef.current?.handleOpen()
|
||||
}
|
||||
const handleClick = ({ key }: { key: string } ) => {
|
||||
switch (key) {
|
||||
case 'thirdParty':
|
||||
handleImport()
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Row gutter={16} className="rb:mb-4">
|
||||
@@ -111,9 +118,16 @@ const ApplicationManagement: React.FC = () => {
|
||||
</Col>
|
||||
<Col span={12} className="rb:text-right">
|
||||
<Space size={12}>
|
||||
<Button onClick={handleImport}>
|
||||
{t('application.importWorkflow')}
|
||||
</Button>
|
||||
<Dropdown
|
||||
menu={{ items: [
|
||||
{ key: 'thirdParty', label: t('application.importWorkflow') },
|
||||
], onClick: handleClick }}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Button>
|
||||
{t('application.import')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Button type="primary" onClick={handleCreate}>
|
||||
{t('application.createApplication')}
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:42
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-11 11:32:48
|
||||
* @Last Modified time: 2026-03-05 15:09:22
|
||||
*/
|
||||
/**
|
||||
* File Upload Component
|
||||
@@ -25,6 +25,7 @@ import { Upload, Progress, App } from 'antd';
|
||||
import type { UploadProps, UploadFile } from 'antd';
|
||||
import type { UploadProps as RcUploadProps } from 'antd/es/upload/interface';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { request } from '@/utils/request'
|
||||
import { fileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
|
||||
@@ -56,27 +57,36 @@ interface UploadFilesProps extends Omit<UploadProps, 'onChange'> {
|
||||
/** Custom file removal callback */
|
||||
onRemove?: (file: UploadFile) => boolean | void | Promise<boolean | void>;
|
||||
}
|
||||
|
||||
const transform_file_type = {
|
||||
'text/plain': 'document/text',
|
||||
'text/markdown': 'document/markdown',
|
||||
'text/x-markdown': 'document/x-markdown',
|
||||
|
||||
'application/pdf': 'document/pdf',
|
||||
|
||||
'application/msword': 'document/doc',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document/docx',
|
||||
|
||||
'application/vnd.ms-powerpoint': 'document/ppt',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'document/pptx',
|
||||
}
|
||||
// Mapping of file extensions to MIME types
|
||||
const ALL_FILE_TYPE: {
|
||||
[key: string]: string;
|
||||
} = {
|
||||
// txt: 'text/plain',
|
||||
txt: 'text/plain',
|
||||
md: 'text/markdown',
|
||||
xmd: 'text/x-markdown',
|
||||
|
||||
pdf: 'application/pdf',
|
||||
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
csv: 'text/csv',
|
||||
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
|
||||
// md: 'text/markdown',
|
||||
// htm: 'text/html',
|
||||
// html: 'text/html',
|
||||
// json: 'application/json',
|
||||
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
@@ -84,6 +94,23 @@ const ALL_FILE_TYPE: {
|
||||
bmp: 'image/bmp',
|
||||
webp: 'image/webp',
|
||||
svg: 'image/svg+xml',
|
||||
|
||||
mp4: 'video/mp4',
|
||||
mov: 'video/quicktime',
|
||||
avi: 'video/x-msvideo',
|
||||
mkv: 'video/x-matroska',
|
||||
webm: 'video/webm',
|
||||
flv: 'video/x-flv',
|
||||
wmv: 'video/x-ms-wmv',
|
||||
|
||||
mp3: 'audio/mpeg',
|
||||
wav: 'audio/wav',
|
||||
ogg: 'audio/ogg',
|
||||
aac: 'audio/aac',
|
||||
flac: 'audio/flac',
|
||||
m4a: 'audio/mp4',
|
||||
wma: 'audio/x-ms-wma',
|
||||
xm4a: 'audio/x-m4a',
|
||||
}
|
||||
export interface UploadFilesRef {
|
||||
/** Current file list */
|
||||
@@ -178,6 +205,10 @@ const UploadFiles = forwardRef<UploadFilesRef, UploadFilesProps>(({
|
||||
* Handles upload state changes
|
||||
*/
|
||||
const handleChange: UploadProps['onChange'] = ({ fileList: newFileList }) => {
|
||||
newFileList.map(file => {
|
||||
const type = (file.type && transform_file_type[file.type as keyof typeof transform_file_type]) || file.type || 'document'
|
||||
file.type = type
|
||||
})
|
||||
setFileList(newFileList);
|
||||
if (onChange) {
|
||||
onChange(maxCount === 1 ? newFileList[newFileList.length - 1] : newFileList);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:09:47
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 10:17:54
|
||||
* @Last Modified time: 2026-03-04 17:47:09
|
||||
*/
|
||||
/**
|
||||
* Upload File List Modal Component
|
||||
@@ -104,7 +104,9 @@ const UploadFileListModal = forwardRef<UploadFileListModalRef, UploadFileListMod
|
||||
<Select
|
||||
placeholder={t('memoryConversation.fileType')}
|
||||
options={[
|
||||
{ label: t('memoryConversation.image'), value: 'image' }
|
||||
{ label: t('memoryConversation.image'), value: 'image' },
|
||||
{ label: t('memoryConversation.audio'), value: 'audio' },
|
||||
{ label: t('memoryConversation.video'), value: 'video' },
|
||||
]}
|
||||
className="rb:w-30"
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:58:03
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-10 17:41:05
|
||||
* @Last Modified time: 2026-03-04 12:10:44
|
||||
*/
|
||||
/**
|
||||
* Conversation Page
|
||||
@@ -14,11 +14,12 @@ import { type FC, useState, useEffect, useRef } from 'react'
|
||||
import { useParams, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InfiniteScroll from 'react-infinite-scroll-component';
|
||||
import { Flex, Skeleton, Form, Dropdown, type MenuProps } from 'antd'
|
||||
import { Flex, Skeleton, Form, Dropdown, type MenuProps, App, Divider } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import clsx from 'clsx'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
import { getConversationHistory, sendConversation, getConversationDetail, getShareToken } from '@/api/application'
|
||||
import { getConversationHistory, sendConversation, getConversationDetail, getShareToken, getExperienceConfig } from '@/api/application'
|
||||
import type { HistoryItem, QueryParams, UploadFileListModalRef } from './types'
|
||||
import Empty from '@/components/Empty'
|
||||
import { formatDateTime } from '@/utils/format';
|
||||
@@ -34,15 +35,19 @@ import OnlineCheckedIcon from '@/assets/images/conversation/onlineChecked.svg'
|
||||
import MemoryFunctionCheckedIcon from '@/assets/images/conversation/memoryFunctionChecked.svg'
|
||||
import { type SSEMessage } from '@/utils/stream'
|
||||
import UploadFiles from './components/FileUpload'
|
||||
// import AudioRecorder from '@/components/AudioRecorder'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import { shareFileUploadUrlWithoutApiPrefix } from '@/api/fileStorage'
|
||||
import UploadFileListModal from './components/UploadFileListModal'
|
||||
import type { VariableConfigModalRef } from '@/views/Workflow/types'
|
||||
import type { Variable } from '@/views/Workflow/components/Properties/VariableList/types'
|
||||
import VariableConfigModal from '@/views/Workflow/components/Chat/VariableConfigModal';
|
||||
|
||||
/**
|
||||
* Conversation component for shared applications
|
||||
*/
|
||||
const Conversation: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { message: messageApi } = App.useApp()
|
||||
const { token } = useParams()
|
||||
const location = useLocation()
|
||||
const searchParams = new URLSearchParams(location.search)
|
||||
@@ -64,6 +69,22 @@ const Conversation: FC = () => {
|
||||
const queryValues = Form.useWatch<QueryParams>([], form)
|
||||
|
||||
const uploadFileListModalRef = useRef<UploadFileListModalRef>(null)
|
||||
|
||||
const variableConfigModalRef = useRef<VariableConfigModalRef>(null)
|
||||
const [variables, setVariables] = useState<Variable[]>([]) // Workflow input variables
|
||||
|
||||
/**
|
||||
* Opens the variable configuration modal
|
||||
*/
|
||||
const handleEditVariables = () => {
|
||||
variableConfigModalRef.current?.handleOpen(variables)
|
||||
}
|
||||
/**
|
||||
* Saves updated variable values from the modal
|
||||
*/
|
||||
const handleSave = (values: Variable[]) => {
|
||||
setVariables([...values])
|
||||
}
|
||||
useEffect(() => {
|
||||
const shareToken = localStorage.getItem(`shareToken_${token}`)
|
||||
setShareToken(shareToken)
|
||||
@@ -81,6 +102,17 @@ const Conversation: FC = () => {
|
||||
getHistory()
|
||||
}
|
||||
}, [token, shareToken, page, hasMore, historyList])
|
||||
useEffect(() => {
|
||||
if (shareToken && token) {
|
||||
getExperienceConfig(token)
|
||||
.then(res => {
|
||||
const response = res as { variables: Variable[] }
|
||||
setVariables(response.variables || [])
|
||||
})
|
||||
} else {
|
||||
setChatList([])
|
||||
}
|
||||
}, [shareToken, token])
|
||||
|
||||
/** Group conversation history by date */
|
||||
const groupHistoryByDate = (items: HistoryItem[]): Record<string, HistoryItem[]> => {
|
||||
@@ -191,12 +223,35 @@ const Conversation: FC = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const isNeedVariableConfig = variables.some(vo => vo.required && (vo.value === null || vo.value === undefined || vo.value === ''))
|
||||
|
||||
/** Send message and handle streaming response */
|
||||
const handleSend = () => {
|
||||
if (!token || !shareToken) {
|
||||
return
|
||||
}
|
||||
const { files = [], ...rest } = queryValues || {}
|
||||
// Validate required variables before sending
|
||||
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)
|
||||
setStreamLoading(true)
|
||||
addUserMessage(message, files)
|
||||
@@ -212,8 +267,8 @@ const Conversation: FC = () => {
|
||||
currentConversationId = newId
|
||||
break
|
||||
case 'message':
|
||||
const { content, chunk, conversation_id: curId } = item.data as { content: string; chunk: string; conversation_id: string; }
|
||||
updateAssistantMessage(content ?? chunk)
|
||||
const { content, conversation_id: curId } = item.data as { content: string; conversation_id: string; }
|
||||
updateAssistantMessage(content)
|
||||
|
||||
if (curId) {
|
||||
currentConversationId = curId;
|
||||
@@ -247,19 +302,30 @@ const Conversation: FC = () => {
|
||||
upload_file_id: file.response.data.file_id
|
||||
}
|
||||
}
|
||||
})
|
||||
}),
|
||||
variables: params
|
||||
}, handleStreamMessage, shareToken)
|
||||
.catch(() => {
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
setStreamLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
const fileChange = (file?: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), file])
|
||||
}
|
||||
// const handleRecordingComplete = async (file: any) => {
|
||||
// console.log('file', file)
|
||||
// }
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), {
|
||||
uid: file.file_id,
|
||||
response: { data: file },
|
||||
thumbUrl: file.url,
|
||||
type: file.type
|
||||
}])
|
||||
}
|
||||
|
||||
const handleShowUpload: MenuProps['onClick'] = ({ key }) => {
|
||||
switch(key) {
|
||||
@@ -273,6 +339,7 @@ const Conversation: FC = () => {
|
||||
form.setFieldValue('files', [...(queryValues.files || []), ...fileList])
|
||||
}
|
||||
const updateFileList = (fileList?: any[]) => {
|
||||
console.log('fileList', fileList)
|
||||
form.setFieldValue('files', [...(fileList || [])])
|
||||
}
|
||||
|
||||
@@ -327,7 +394,7 @@ const Conversation: FC = () => {
|
||||
<div className='rb:w-190 rb:h-screen rb:mx-auto rb:pt-10'>
|
||||
<Chat
|
||||
empty={<Empty url={ChatEmpty} className="rb:h-full" size={[320,180]} title={t('memoryConversation.chatEmpty')} subTitle={t('memoryConversation.emptyDesc')} />}
|
||||
contentClassName="rb:h-[calc(100%-180px)]"
|
||||
contentClassName={!queryValues?.files?.length ? "rb:h-[calc(100%-144px)]" : "rb:h-[calc(100%-208px)]"}
|
||||
data={chatList}
|
||||
streamLoading={streamLoading}
|
||||
loading={loading}
|
||||
@@ -349,13 +416,12 @@ const Conversation: FC = () => {
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
action={shareFileUploadUrlWithoutApiPrefix}
|
||||
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']}
|
||||
onChange={fileChange}
|
||||
requestConfig={{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${shareToken || ''}`,
|
||||
} }}
|
||||
}}}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -384,11 +450,34 @@ const Conversation: FC = () => {
|
||||
{t(`memoryConversation.memory`)}
|
||||
</ButtonCheckbox>
|
||||
</Form.Item>
|
||||
{variables.length > 0 && (
|
||||
<Form.Item name="variables" className="rb:mb-0!">
|
||||
<div
|
||||
className={clsx("rb:flex rb:items-center rb:border rb:rounded-lg rb:px-2 rb:text-[12px] rb:h-6 rb:cursor-pointer rb:hover:bg-[#F0F3F8] rb:text-[#212332]", {
|
||||
'rb:border-[#FF5D34] rb:text-[#FF5D34]': isNeedVariableConfig,
|
||||
'rb:border-[#DFE4ED]': !isNeedVariableConfig,
|
||||
})}
|
||||
onClick={handleEditVariables}
|
||||
>
|
||||
<SettingOutlined className="rb:mr-1" />
|
||||
{t(`memoryConversation.variableConfig`)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Flex>
|
||||
{/* <Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Flex align="center">
|
||||
<AudioRecorder
|
||||
action={shareFileUploadUrlWithoutApiPrefix}
|
||||
requestConfig={{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${shareToken || ''}`,
|
||||
}
|
||||
}}
|
||||
onRecordingComplete={handleRecordingComplete}
|
||||
/>
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex> */}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Form>
|
||||
</Chat>
|
||||
@@ -399,6 +488,11 @@ const Conversation: FC = () => {
|
||||
ref={uploadFileListModalRef}
|
||||
refresh={addFileList}
|
||||
/>
|
||||
<VariableConfigModal
|
||||
ref={variableConfigModalRef}
|
||||
refresh={handleSave}
|
||||
variables={variables}
|
||||
/>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:57:46
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-06 21:11:19
|
||||
* @Last Modified time: 2026-03-03 13:46:55
|
||||
*/
|
||||
/**
|
||||
* Type definitions for Conversation
|
||||
@@ -51,6 +51,7 @@ export interface QueryParams {
|
||||
/** Current conversation ID */
|
||||
conversation_id?: string | null;
|
||||
files?: any[];
|
||||
variables?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UploadFileListModalRef {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '@/api/knowledgeBase'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import SliderInput from '@/components/SliderInput'
|
||||
import { stringRegExp } from '@/utils/validator'
|
||||
const { TextArea } = Input;
|
||||
const { confirm } = Modal
|
||||
|
||||
@@ -519,12 +520,16 @@ const CreateModal = forwardRef<CreateModalRef, CreateModalRefProps>(({
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('knowledgeBase.createForm.name')}
|
||||
rules={[{ required: true, message: t('knowledgeBase.createForm.nameRequired') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('knowledgeBase.createForm.nameRequired') },
|
||||
{ max: 50 },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('knowledgeBase.createForm.name')} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="description" label={t('knowledgeBase.createForm.description')}>
|
||||
<Form.Item name="description" label={t('knowledgeBase.createForm.description')} rules={[{ max: 500 }]}>
|
||||
<TextArea rows={2} placeholder={t('knowledgeBase.createForm.description')} />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* @Author: yujiangping
|
||||
* @Date: 2025-11-10 18:52:55
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2026-02-10 15:18:32
|
||||
* @LastEditTime: 2026-03-03 14:46:08
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react';
|
||||
import { Switch } from 'antd';
|
||||
@@ -75,7 +75,12 @@ const ShareModal = forwardRef<ShareModalRef,ShareModalRefProps>(({ handleShare:
|
||||
updateKnowledgeBase(item.target_kb?.id, {
|
||||
status: checked ? 1 : 2
|
||||
}).then(() => {
|
||||
messageApi.success(t('knowledgeBase.shareSuccess'));
|
||||
if(checked){
|
||||
messageApi.success(t('knowledgeBase.shareSuccess'));
|
||||
}else{
|
||||
messageApi.success(t('knowledgeBase.stopShareSuccess'));
|
||||
}
|
||||
|
||||
getShareSpaceList(kbId);
|
||||
}).catch(() => {
|
||||
messageApi.error(t('knowledgeBase.shareFailed'));
|
||||
|
||||
@@ -152,7 +152,11 @@ const MemberModal = forwardRef<MemberModalRef, MemberModalProps>(({
|
||||
<FormItem
|
||||
name="email"
|
||||
label={t('member.email')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ type: 'email' },
|
||||
{ max: 100 },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.enterPlaceholder', { title: t('member.email') })} disabled={!!editingUser} />
|
||||
</FormItem>
|
||||
|
||||
@@ -18,6 +18,7 @@ import RbModal from '@/components/RbModal'
|
||||
import { createMemoryConfig, updateMemoryConfig } from '@/api/memory'
|
||||
import { getOntologyScenesSimpleUrl } from '@/api/ontology'
|
||||
import CustomSelect from '@/components/CustomSelect';
|
||||
import { stringRegExp } from '@/utils/validator';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
@@ -110,7 +111,11 @@ const MemoryForm = forwardRef<MemoryFormRef, MemoryFormProps>(({
|
||||
<FormItem
|
||||
name="config_name"
|
||||
label={t('memory.configurationName')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ max: 50 },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.pleaseEnter')} />
|
||||
</FormItem>
|
||||
@@ -118,6 +123,7 @@ const MemoryForm = forwardRef<MemoryFormRef, MemoryFormProps>(({
|
||||
<FormItem
|
||||
name="config_desc"
|
||||
label={t('memory.desc')}
|
||||
rules={[{ max: 500 }]}
|
||||
>
|
||||
<Input.TextArea placeholder={t('common.pleaseEnter')} />
|
||||
</FormItem>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:49:28
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 17:24:05
|
||||
* @Last Modified time: 2026-03-04 11:31:43
|
||||
*/
|
||||
/**
|
||||
* Custom Model Modal
|
||||
@@ -10,8 +10,8 @@
|
||||
* Supports logo upload, type/provider selection, and tagging
|
||||
*/
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, App } from 'antd';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, App, Checkbox } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { CustomModelForm, ModelListItem, CustomModelModalRef, CustomModelModalProps } from '../types';
|
||||
@@ -20,6 +20,7 @@ import CustomSelect from '@/components/CustomSelect'
|
||||
import UploadImages from '@/components/Upload/UploadImages'
|
||||
import { updateCustomModel, addCustomModel, modelTypeUrl, modelProviderUrl } from '@/api/models'
|
||||
import { getFileLink } from '@/api/fileStorage'
|
||||
import { validateSquareImage, stringRegExp } from '@/utils/validator'
|
||||
|
||||
/**
|
||||
* Custom model modal component
|
||||
@@ -34,6 +35,14 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [form] = Form.useForm<CustomModelForm>();
|
||||
const [loading, setLoading] = useState(false)
|
||||
const modelType = Form.useWatch(['type'], form);
|
||||
const isOmni = Form.useWatch(['is_omni'], form);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOmni) {
|
||||
form.setFieldsValue({ is_vision: true })
|
||||
}
|
||||
}, [isOmni])
|
||||
|
||||
/** Close modal and reset state */
|
||||
const handleClose = () => {
|
||||
@@ -48,9 +57,12 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
if (model) {
|
||||
setIsEdit(true);
|
||||
setModel(model);
|
||||
const { capability, is_omni, ...rest} = model
|
||||
form.setFieldsValue({
|
||||
...model,
|
||||
logo: model.logo && model.logo.startsWith('http') ? { url: model.logo, uid: model.logo, status: 'done', name: 'logo' } : undefined
|
||||
...rest,
|
||||
logo: model.logo && model.logo.startsWith('http') ? { url: model.logo, uid: model.logo, status: 'done', name: 'logo' } : undefined,
|
||||
is_omni,
|
||||
is_vision: capability?.includes('vision') || false,
|
||||
});
|
||||
} else {
|
||||
setIsEdit(false);
|
||||
@@ -65,7 +77,7 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
const res = isEdit ? updateCustomModel(model.id, rest) : addCustomModel(data)
|
||||
|
||||
res.then(() => {
|
||||
refresh && refresh(isEdit)
|
||||
refresh?.(isEdit)
|
||||
handleClose()
|
||||
message.success(isEdit ? t('common.updateSuccess') : t('common.createSuccess'))
|
||||
})
|
||||
@@ -78,9 +90,14 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
const { logo, ...rest } = values;
|
||||
let formData: CustomModelForm = {
|
||||
...rest
|
||||
const { logo, type, is_vision, is_omni, ...rest } = values;
|
||||
const formData: CustomModelForm = {
|
||||
...rest,
|
||||
type,
|
||||
}
|
||||
if (!['embedding', 'rerank'].includes(type as string)) {
|
||||
formData.capability = is_omni ? ["vision", "audio"] : is_vision ? ['vision'] : []
|
||||
formData.is_omni = is_omni
|
||||
}
|
||||
|
||||
if (typeof logo === 'object' && logo?.response?.data.file_id) {
|
||||
@@ -107,7 +124,7 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
}));
|
||||
|
||||
console.log('modelType', modelType)
|
||||
return (
|
||||
<RbModal
|
||||
title={isEdit ? `${model.name} - ${t('modelNew.modelConfiguration')}` : t('modelNew.createCustomModel')}
|
||||
@@ -125,14 +142,22 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
name="logo"
|
||||
label={t('modelNew.logo')}
|
||||
valuePropName="fileList"
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseSelect') },
|
||||
{ validator: validateSquareImage(t('common.imageSquareRequired')) }
|
||||
]}
|
||||
extra={t('common.logoTip')?.split('\n').map((vo, index) => <div key={index}>{vo}</div>)}
|
||||
>
|
||||
<UploadImages />
|
||||
<UploadImages fileSize={2} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('modelNew.name')}
|
||||
rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('modelNew.name') }) }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.inputPlaceholder', { title: t('modelNew.name') }) },
|
||||
{ max: 50 },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
@@ -166,11 +191,11 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={t('modelNew.description')}
|
||||
rules={[{ max: 500 }]}
|
||||
>
|
||||
<Input.TextArea placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
<Form.Item
|
||||
name={["api_keys", 0, "api_key"]}
|
||||
label={t('modelNew.api_key')}
|
||||
@@ -186,6 +211,17 @@ const CustomModelModal = forwardRef<CustomModelModalRef, CustomModelModalProps>(
|
||||
>
|
||||
<Input placeholder="https://api.example.com/v1" />
|
||||
</Form.Item>
|
||||
|
||||
{!['embedding', 'rerank'].includes(modelType as string) &&
|
||||
<>
|
||||
<Form.Item name="is_omni" valuePropName="checked" className="rb:mb-2!">
|
||||
<Checkbox>{t('modelNew.is_omni')}</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item name="is_vision" valuePropName="checked" className="rb:mb-0!">
|
||||
<Checkbox disabled={isOmni}>{t('modelNew.is_vision')}</Checkbox>
|
||||
</Form.Item>
|
||||
</>
|
||||
}
|
||||
</Form>
|
||||
</RbModal>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:49:33
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:49:33
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-02 12:23:13
|
||||
*/
|
||||
/**
|
||||
* Group Model Modal
|
||||
@@ -21,6 +21,7 @@ import { updateCompositeModel, modelTypeUrl, addCompositeModel } from '@/api/mod
|
||||
import UploadImages from '@/components/Upload/UploadImages'
|
||||
import ModelImplement from './ModelImplement'
|
||||
import { getFileLink } from '@/api/fileStorage'
|
||||
import { validateSquareImage, stringRegExp } from '@/utils/validator'
|
||||
|
||||
/**
|
||||
* Group model modal component
|
||||
@@ -133,15 +134,26 @@ const GroupModelModal = forwardRef<GroupModelModalRef, GroupModelModalProps>(({
|
||||
name="logo"
|
||||
label={t('modelNew.logo')}
|
||||
valuePropName="fileList"
|
||||
rules={[{ required: true, message: t('common.pleaseSelect') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseSelect') },
|
||||
{ validator: validateSquareImage(t('common.imageSquareRequired')) }
|
||||
]}
|
||||
extra={t('common.logoTip')?.split('\n').map((vo, index) => <div key={index}>{vo}</div>)}
|
||||
>
|
||||
<UploadImages />
|
||||
<UploadImages
|
||||
fileSize={2}
|
||||
fileType={['png', 'jpg']}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('modelNew.name')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ max: 50 },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
@@ -165,6 +177,7 @@ const GroupModelModal = forwardRef<GroupModelModalRef, GroupModelModalProps>(({
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={t('modelNew.description')}
|
||||
rules={[{ max: 500 }]}
|
||||
>
|
||||
<Input.TextArea placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:49:20
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:54:54
|
||||
* @Last Modified time: 2026-03-04 11:51:01
|
||||
*/
|
||||
/**
|
||||
* Sub-Model Modal
|
||||
@@ -10,8 +10,8 @@
|
||||
* Uses cascader for hierarchical selection
|
||||
*/
|
||||
|
||||
import { forwardRef, useImperativeHandle, useState, useEffect } from 'react';
|
||||
import { Form, Cascader, App, type CascaderProps } from 'antd';
|
||||
import { type ReactNode, forwardRef, useImperativeHandle, useState, useEffect } from 'react';
|
||||
import { Form, Cascader, App, type CascaderProps, Space } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { SubModelModalForm, SubModelModalRef, SubModelModalProps } from './types';
|
||||
@@ -19,6 +19,7 @@ import RbModal from '@/components/RbModal'
|
||||
import CustomSelect from '@/components/CustomSelect'
|
||||
import { modelProviderUrl, getModelNewList } from '@/api/models'
|
||||
import type { ProviderModelItem } from '../../types'
|
||||
import Tag from '@/components/Tag';
|
||||
|
||||
const { SHOW_CHILD } = Cascader;
|
||||
|
||||
@@ -27,7 +28,7 @@ const { SHOW_CHILD } = Cascader;
|
||||
*/
|
||||
interface Option {
|
||||
value: string | number;
|
||||
label: string;
|
||||
label: string | ReactNode;
|
||||
children?: Option[];
|
||||
[key: string]: any;
|
||||
}
|
||||
@@ -116,7 +117,11 @@ const SubModelModal = forwardRef<SubModelModalRef, SubModelModalProps>(({
|
||||
}))
|
||||
return {
|
||||
...vo,
|
||||
label: vo.name,
|
||||
label: <Space>
|
||||
{vo.name}
|
||||
<Tag>{t(`modelNew.${vo.type}`)}</Tag>
|
||||
{vo.capability?.filter(item => item !== 'video').map(vo => <Tag key={vo}>{t(`modelNew.${vo}`)}</Tag>)}
|
||||
</Space>,
|
||||
value: vo.id,
|
||||
children: children
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:49:45
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:49:45
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 11:50:47
|
||||
*/
|
||||
/**
|
||||
* Model List Detail Drawer
|
||||
@@ -133,9 +133,10 @@ const ModelListDetail = forwardRef<ModelListDetailRef, ModelListDetailProps>(({
|
||||
<RbCard
|
||||
key={item.id}
|
||||
title={item.name}
|
||||
subTitle={<Space className="rb:mt-1!">
|
||||
subTitle={<Space size={8} className="rb:mt-1!">
|
||||
<Tag>{t(`modelNew.${item.type}`)}</Tag>
|
||||
<Tag color="warning">{item.api_keys.length}{t('modelNew.apiKeyNum')}</Tag>
|
||||
{item.capability?.filter(item => item !=='video').map(vo => <Tag key={vo}>{t(`modelNew.${vo}`)}</Tag>)}
|
||||
</Space>}
|
||||
avatarUrl={getLogoUrl(item.logo)}
|
||||
avatar={
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:49:49
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:54:26
|
||||
* @Last Modified time: 2026-03-04 11:50:31
|
||||
*/
|
||||
/**
|
||||
* Model Square Detail Drawer
|
||||
@@ -89,9 +89,10 @@ const ModelSquareDetail = forwardRef<ModelSquareDetailRef, ModelSquareDetailProp
|
||||
<RbCard
|
||||
key={item.id}
|
||||
title={item.name}
|
||||
subTitle={<Space size={8}>
|
||||
<Tag className="rb:mt-1">{t(`modelNew.${item.type}`)}</Tag>
|
||||
{item.is_official && <Tag color="success" className="rb:mt-1">{t(`modelNew.official`)}</Tag>}
|
||||
subTitle={<Space size={8} className="rb:mt-1!">
|
||||
<Tag>{t(`modelNew.${item.type}`)}</Tag>
|
||||
{item.is_official && <Tag color="success">{t(`modelNew.official`)}</Tag>}
|
||||
{item.capability?.filter(item => item !== 'video').map(vo => <Tag key={vo}>{t(`modelNew.${vo}`)}</Tag>)}
|
||||
</Space>}
|
||||
avatarUrl={getLogoUrl(item.logo)}
|
||||
avatar={
|
||||
|
||||
@@ -121,6 +121,7 @@ const tabKeys = ['group', 'list', 'square']
|
||||
{activeTab !== 'list' &&
|
||||
<Form.Item name="search" noStyle>
|
||||
<SearchInput
|
||||
maxLength={50}
|
||||
placeholder={t(`modelNew.${activeTab}SearchPlaceholder`)}
|
||||
className="rb:w-70!"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 16:50:18
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 16:50:18
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 11:39:20
|
||||
*/
|
||||
/**
|
||||
* Type definitions for Model Management
|
||||
@@ -148,7 +148,9 @@ export interface ModelListItem {
|
||||
/** Update timestamp */
|
||||
updated_at: number;
|
||||
/** Associated API keys */
|
||||
api_keys: ModelApiKey[]
|
||||
api_keys: ModelApiKey[];
|
||||
capability?: string[];
|
||||
is_omni?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,6 +263,8 @@ export interface ModelPlazaItem {
|
||||
add_count: number;
|
||||
/** Whether user has added this model */
|
||||
is_added: boolean;
|
||||
capability?: string[];
|
||||
is_omni?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,6 +295,9 @@ export interface CustomModelForm {
|
||||
/** API base URL */
|
||||
api_base: string;
|
||||
}>
|
||||
is_vision?: boolean;
|
||||
is_omni?: boolean;
|
||||
capability?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -182,7 +182,11 @@ const OntologyClassExtractModal = forwardRef<OntologyClassExtractModalRef, Ontol
|
||||
<FormItem
|
||||
name="scenario"
|
||||
label={t('ontology.scenario')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ max: 2000 },
|
||||
{ pattern: /^(?!\s*$).+$/, message: t('common.notAllSpaces') },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea placeholder={t('ontology.scenarioPlaceholder')} />
|
||||
</FormItem>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import type { AddClassItem, OntologyClassModalRef } from '../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import { createOntologyClass } from '@/api/ontology'
|
||||
import { stringRegExp } from '@/utils/validator';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
@@ -105,7 +106,11 @@ const OntologyClassModal = forwardRef<OntologyClassModalRef, OntologyClassModalP
|
||||
<FormItem
|
||||
name="class_name"
|
||||
label={t('ontology.class_name')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ max: 50 },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.enter')} />
|
||||
</FormItem>
|
||||
@@ -113,6 +118,7 @@ const OntologyClassModal = forwardRef<OntologyClassModalRef, OntologyClassModalP
|
||||
<FormItem
|
||||
name="class_description"
|
||||
label={t('ontology.class_description')}
|
||||
rules={[{ max: 500 }]}
|
||||
>
|
||||
<Input.TextArea placeholder={t('ontology.classDescriptionPlaceholder')} />
|
||||
</FormItem>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import type { OntologyItem, OntologyModalData, OntologyModalRef } from '../types'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import { createOntologyScene, updateOntologyScene } from '@/api/ontology'
|
||||
import { stringRegExp } from '@/utils/validator';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
@@ -109,7 +110,11 @@ const OntologyModal = forwardRef<OntologyModalRef, OntologyModalProps>(({
|
||||
<FormItem
|
||||
name="scene_name"
|
||||
label={t('ontology.scene_name')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ max: 50 },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.enter')} />
|
||||
</FormItem>
|
||||
@@ -117,6 +122,7 @@ const OntologyModal = forwardRef<OntologyModalRef, OntologyModalProps>(({
|
||||
<FormItem
|
||||
name="scene_description"
|
||||
label={t('ontology.scene_description')}
|
||||
rules={[{ max: 500 }]}
|
||||
>
|
||||
<Input.TextArea placeholder={t('ontology.descriptionPlaceholder')} />
|
||||
</FormItem>
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { AiPromptModalRef } from '@/views/ApplicationConfig/types'
|
||||
import exitIcon from '@/assets/images/knowledgeBase/exit.png';
|
||||
import type { SkillFormData } from '../types'
|
||||
import { getSkillDetail, createSkill, updateSkill } from '@/api/skill'
|
||||
import { stringRegExp } from '@/utils/validator';
|
||||
|
||||
/**
|
||||
* Skill Configuration Page Component
|
||||
@@ -110,7 +111,7 @@ const SkillConfig: FC = () => {
|
||||
// Format tools data for API
|
||||
const formData = {
|
||||
...rest,
|
||||
tools: tools?.map((item: any) => ({
|
||||
tools: tools?.map((item) => ({
|
||||
tool_id: item.tool_id,
|
||||
operation: item.operation
|
||||
}))
|
||||
@@ -144,13 +145,18 @@ const SkillConfig: FC = () => {
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('skills.name')}
|
||||
rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('skills.name') }) }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.inputPlaceholder', { title: t('skills.name') }) },
|
||||
{ max: 50 },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.pleaseEnter')} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="description"
|
||||
label={t('skills.description')}
|
||||
rules={[{ max: 500 }]}
|
||||
>
|
||||
<Input.TextArea placeholder={t('skills.descriptionPlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface SkillFormData {
|
||||
tools: Array<{
|
||||
/** Tool identifier */
|
||||
tool_id: string;
|
||||
/** Tool operation/action */
|
||||
operation?: string;
|
||||
}>;
|
||||
/** Skill configuration settings */
|
||||
config: {
|
||||
|
||||
@@ -23,6 +23,7 @@ import UploadImages from '@/components/Upload/UploadImages'
|
||||
import { getFileLink } from '@/api/fileStorage'
|
||||
import ragIcon from '@/assets/images/space/rag.png'
|
||||
import neo4jIcon from '@/assets/images/space/neo4j.png'
|
||||
import { stringRegExp } from '@/utils/validator';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
@@ -91,7 +92,7 @@ const SpaceModal = forwardRef<SpaceModalRef, SpaceModalProps>(({
|
||||
setCurrentStep(1)
|
||||
} else {
|
||||
const { icon, ...rest } = values
|
||||
let formData: SpaceModalData = {
|
||||
const formData: SpaceModalData = {
|
||||
...rest
|
||||
}
|
||||
if (icon?.response?.data.file_id) {
|
||||
@@ -164,14 +165,19 @@ const SpaceModal = forwardRef<SpaceModalRef, SpaceModalProps>(({
|
||||
valuePropName="fileList"
|
||||
hidden={currentStep === 1}
|
||||
rules={[{ required: true, message: t('common.selectPlaceholder', { title: t('space.spaceIcon') }) }]}
|
||||
extra={t('common.logoTip')?.split('\n').map((vo, index) => <div key={index}>{vo}</div>)}
|
||||
>
|
||||
<UploadImages />
|
||||
<UploadImages fileSize={2} />
|
||||
</Form.Item>
|
||||
<FormItem
|
||||
name="name"
|
||||
label={t('space.spaceName')}
|
||||
hidden={currentStep === 1}
|
||||
rules={[{ required: true, message: t('common.inputPlaceholder', { title: t('space.spaceName') }) }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.inputPlaceholder', { title: t('space.spaceName') }) },
|
||||
{ max: 50 },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.inputPlaceholder', { title: t('space.spaceName') })} />
|
||||
</FormItem>
|
||||
|
||||
315
web/src/views/ToolManagement/Market.tsx
Normal file
315
web/src/views/ToolManagement/Market.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import React, { useState, useRef, type ReactNode } from 'react';
|
||||
import { Input, Button, Spin, App } from 'antd';
|
||||
import { SearchOutlined, SettingOutlined, GlobalOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MarketConfigModal, { type MarketConfigModalRef } from './components/MarketConfigModal';
|
||||
|
||||
interface MarketSource {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
desc: string;
|
||||
apiKey: string;
|
||||
connected: boolean;
|
||||
mcpCount: number;
|
||||
}
|
||||
|
||||
interface MarketMcp {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
type: string;
|
||||
desc: string;
|
||||
downloads?: string;
|
||||
stars?: string;
|
||||
icon: string;
|
||||
configTemplate: any;
|
||||
}
|
||||
|
||||
interface MarketCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const Market: React.FC<{ getStatusTag?: (status: string) => ReactNode }> = () => {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedSource, setSelectedSource] = useState<string | null>(null);
|
||||
const marketConfigModalRef = useRef<MarketConfigModalRef>(null);
|
||||
const [marketSources, setMarketSources] = useState<MarketSource[]>([
|
||||
{ id: 'smithery', name: 'Smithery', category: 'official', icon: '🔧', url: 'https://mcp.smithery.ai', desc: '官方 MCP 服务市场,提供丰富的 MCP 服务', apiKey: '', connected: false, mcpCount: 2847 },
|
||||
{ id: 'mcpmarket', name: 'MCP Market', category: 'official', icon: '🏪', url: 'https://mcpmarket.com', desc: '综合性 MCP 市场平台', apiKey: '', connected: false, mcpCount: 1523 },
|
||||
{ id: 'glama', name: 'Glama.ai MCP', category: 'official', icon: '✨', url: 'https://glama.ai/mcp', desc: 'Glama AI 提供的 MCP 服务集合', apiKey: '', connected: false, mcpCount: 892 },
|
||||
{ id: 'github-mcp', name: 'modelcontextprotocol/servers', category: 'official', icon: '🐙', url: 'https://github.com/modelcontextprotocol/servers', desc: 'GitHub 官方 MCP 服务器仓库', apiKey: '', connected: true, mcpCount: 156 },
|
||||
{ id: 'aliyun-bailian', name: '阿里云百炼 MCP', category: 'china-cloud', icon: '☁️', url: 'https://bailian.console.aliyun.com/mcp', desc: '阿里云百炼平台 MCP 市场', apiKey: '', connected: false, mcpCount: 423 },
|
||||
{ id: 'modelscope', name: '魔搭社区 MCP', category: 'china-cloud', icon: '🎭', url: 'https://modelscope.cn/mcp', desc: '阿里达摩院魔搭社区 MCP 市场', apiKey: '', connected: false, mcpCount: 312 },
|
||||
]);
|
||||
|
||||
const [categories] = useState<MarketCategory[]>([
|
||||
{ id: 'official', name: '官方/综合', icon: '🌐' },
|
||||
{ id: 'china-cloud', name: '国内云', icon: '☁️' },
|
||||
{ id: 'community', name: '社区/垂直', icon: '👥' }
|
||||
]);
|
||||
|
||||
const [mcpCache, setMcpCache] = useState<Record<string, MarketMcp[]>>({
|
||||
'github-mcp': [
|
||||
{ id: 'gh-1', name: 'Fetch', provider: 'modelcontextprotocol', type: 'Hosted', desc: '使用浏览器模拟大型语言模型检索和处理网页内容', downloads: '203.7m', stars: '308.2k', icon: '🌐', configTemplate: {} },
|
||||
{ id: 'gh-2', name: 'Filesystem', provider: 'modelcontextprotocol', type: 'Local', desc: '安全的文件系统操作,支持读写文件和目录管理', downloads: '156.2m', stars: '245.1k', icon: '📁', configTemplate: {} },
|
||||
{ id: 'gh-3', name: 'GitHub', provider: 'modelcontextprotocol', type: 'Hosted', desc: 'GitHub API 集成,支持仓库、Issue、PR 等操作', downloads: '89.4m', stars: '178.3k', icon: '🐙', configTemplate: {} },
|
||||
]
|
||||
});
|
||||
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
|
||||
const handleSelectSource = (sourceId: string) => {
|
||||
setSelectedSource(sourceId);
|
||||
};
|
||||
|
||||
const handleRefresh = (sourceId: string) => {
|
||||
setLoading(true);
|
||||
setTimeout(() => {
|
||||
// 模拟刷新数据
|
||||
const source = marketSources.find(s => s.id === sourceId);
|
||||
if (source) {
|
||||
message.success(`${source.name} 列表已刷新`);
|
||||
}
|
||||
setLoading(false);
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const handleOpenConfig = (sourceId: string) => {
|
||||
const source = marketSources.find(s => s.id === sourceId);
|
||||
if (source) {
|
||||
marketConfigModalRef.current?.handleOpen(source);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = (sourceId: string, apiKey: string) => {
|
||||
// 更新市场源状态
|
||||
setMarketSources(prev => prev.map(source => {
|
||||
if (source.id === sourceId) {
|
||||
return {
|
||||
...source,
|
||||
apiKey,
|
||||
connected: true
|
||||
};
|
||||
}
|
||||
return source;
|
||||
}));
|
||||
|
||||
// 模拟获取MCP列表
|
||||
setTimeout(() => {
|
||||
const source = marketSources.find(s => s.id === sourceId);
|
||||
if (source && !mcpCache[sourceId]) {
|
||||
// 生成模拟数据
|
||||
const mockData: MarketMcp[] = [
|
||||
{ id: `${sourceId}-1`, name: `${source.name} 服务 1`, provider: source.name, type: 'Hosted', desc: `来自 ${source.name} 的 MCP 服务`, downloads: '10.2m', stars: '23.4k', icon: '🔧', configTemplate: {} },
|
||||
{ id: `${sourceId}-2`, name: `${source.name} 服务 2`, provider: source.name, type: 'Local', desc: `来自 ${source.name} 的本地 MCP 服务`, downloads: '8.5m', stars: '18.7k', icon: '⚙️', configTemplate: {} }
|
||||
];
|
||||
setMcpCache(prev => ({
|
||||
...prev,
|
||||
[sourceId]: mockData
|
||||
}));
|
||||
}
|
||||
message.success(`已连接 ${source?.name}`);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
const renderSourceDetail = () => {
|
||||
if (!selectedSource) {
|
||||
return (
|
||||
<div className="rb:flex rb:flex-col rb:items-center rb:justify-center rb:h-full rb:text-center">
|
||||
<div className="rb:text-6xl rb:mb-4">🏪</div>
|
||||
<h3 className="rb:text-lg rb:font-semibold rb:text-gray-900 rb:mb-2">选择一个 MCP 市场</h3>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:max-w-md">从左侧选择一个市场源,配置连接后即可浏览该市场的 MCP 服务</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const source = marketSources.find(s => s.id === selectedSource);
|
||||
if (!source) return null;
|
||||
|
||||
const mcpList = mcpCache[selectedSource] || [];
|
||||
const filteredList = mcpList.filter(mcp =>
|
||||
mcp.name.toLowerCase().includes(searchKeyword.toLowerCase()) ||
|
||||
mcp.desc.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rb:flex rb:justify-between rb:items-start rb:pb-6 rb:border-b rb:border-gray-200 rb:mb-6">
|
||||
<div className="rb:flex rb:gap-4">
|
||||
<div className="rb:text-5xl rb:w-16 rb:h-16 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded-xl rb:flex-shrink-0">
|
||||
{source.icon}
|
||||
</div>
|
||||
<div className="rb:flex-1">
|
||||
<h2 className="rb:text-xl rb:font-semibold rb:text-gray-900 rb:mb-2">{source.name}</h2>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed">{source.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rb:flex rb:gap-3">
|
||||
<Button icon={<SettingOutlined />} onClick={() => handleOpenConfig(selectedSource)}>
|
||||
配置
|
||||
</Button>
|
||||
<Button type="primary" icon={<GlobalOutlined />} onClick={() => window.open(source.url, '_blank')}>
|
||||
前往市场
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rb:mt-6">
|
||||
<div className="rb:flex rb:justify-between rb:items-center rb:mb-5">
|
||||
<h3 className="rb:text-base rb:font-semibold rb:text-gray-900 rb:m-0">
|
||||
可用 MCP 服务 <span className="rb:text-gray-600 rb:font-normal">({mcpList.length})</span>
|
||||
</h3>
|
||||
<div className="rb:flex rb:gap-3 rb:items-center">
|
||||
{source.connected && (
|
||||
<Button size="small" icon={<SyncOutlined />} onClick={() => handleRefresh(selectedSource)}>
|
||||
刷新
|
||||
</Button>
|
||||
)}
|
||||
{mcpList.length > 0 && (
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="搜索服务..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mcpList.length > 0 ? (
|
||||
<Spin spinning={loading}>
|
||||
<div className="rb:grid rb:grid-cols-1 md:rb:grid-cols-2 lg:rb:grid-cols-3 rb:gap-4">
|
||||
{filteredList.map(mcp => (
|
||||
<div
|
||||
key={mcp.id}
|
||||
className="rb:bg-white rb:border rb:border-gray-200 rb:rounded-lg rb:p-4 rb:transition-all rb:duration-200 hover:rb:shadow-lg hover:rb:border-gray-300"
|
||||
>
|
||||
<div className="rb:flex rb:justify-between rb:items-center rb:mb-3">
|
||||
<div className="rb:text-3xl rb:w-12 rb:h-12 rb:flex rb:items-center rb:justify-center rb:bg-gray-50 rb:rounded-lg">
|
||||
{mcp.icon}
|
||||
</div>
|
||||
<span className={`rb:px-2 rb:py-1 rb:rounded rb:text-xs rb:font-medium ${
|
||||
mcp.type === 'Hosted'
|
||||
? 'rb:bg-blue-50 rb:text-blue-700'
|
||||
: 'rb:bg-gray-100 rb:text-gray-600'
|
||||
}`}>
|
||||
{mcp.type}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="rb:text-base rb:font-semibold rb:text-gray-900 rb:mb-1">{mcp.name}</h3>
|
||||
{mcp.provider && (
|
||||
<div className="rb:mb-2">
|
||||
<span className="rb:text-xs rb:text-gray-500">@ {mcp.provider}</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed rb:mb-3 rb:min-h-[42px]">{mcp.desc}</p>
|
||||
<div className="rb:flex rb:gap-4 rb:mb-3 rb:pt-3 rb:border-t rb:border-gray-100">
|
||||
{mcp.downloads && (
|
||||
<span className="rb:flex rb:items-center rb:gap-1 rb:text-xs rb:text-gray-500">
|
||||
<GlobalOutlined /> {mcp.downloads}
|
||||
</span>
|
||||
)}
|
||||
{mcp.stars && (
|
||||
<span className="rb:flex rb:items-center rb:gap-1 rb:text-xs rb:text-gray-500">
|
||||
⭐ {mcp.stars}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="rb:flex rb:justify-end">
|
||||
<Button type="primary" size="small">
|
||||
+ 添加
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Spin>
|
||||
) : (
|
||||
<div className="rb:flex rb:flex-col rb:items-center rb:justify-center rb:py-16 rb:text-center">
|
||||
<div className="rb:text-6xl rb:mb-4">{source.connected ? '📭' : '🔌'}</div>
|
||||
<h4 className="rb:text-base rb:font-semibold rb:text-gray-900 rb:mb-2">
|
||||
{source.connected ? '暂无可用的 MCP 服务' : '尚未连接此市场'}
|
||||
</h4>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:mb-4">
|
||||
{source.connected ? '该市场暂时没有可用的服务' : '点击右上角"配置"按钮设置连接信息'}
|
||||
</p>
|
||||
{!source.connected && (
|
||||
<Button type="primary" onClick={() => handleOpenConfig(selectedSource)}>
|
||||
配置连接
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rb:flex rb:gap-4 rb:h-[calc(100vh-178px)]">
|
||||
{/* 左侧市场源列表 */}
|
||||
<div className="rb:w-70 rb:bg-white rb:rounded-lg rb:border rb:border-gray-200 rb:overflow-y-auto rb:flex-shrink-0">
|
||||
<div className="rb:p-4 rb:border-b rb:border-gray-200">
|
||||
<span className="rb:text-base rb:font-semibold rb:text-gray-900">MCP 市场</span>
|
||||
</div>
|
||||
{categories.map(cat => (
|
||||
<div key={cat.id} className="rb:py-3 rb:border-b rb:border-gray-100 last:rb:border-b-0">
|
||||
<div className="rb:flex rb:items-center rb:gap-2 rb:px-4 rb:py-2 rb:text-xs rb:font-medium rb:text-gray-500 rb:uppercase">
|
||||
<span className="rb:text-sm">{cat.icon}</span>
|
||||
<span>{cat.name}</span>
|
||||
</div>
|
||||
<div className="rb:px-2 rb:py-1">
|
||||
{marketSources
|
||||
.filter(s => s.category === cat.id)
|
||||
.map(source => (
|
||||
<div
|
||||
key={source.id}
|
||||
className={`rb:flex rb:items-center rb:gap-2 rb:px-3 rb:py-2.5 rb:rounded-md rb:cursor-pointer rb:transition-all rb:relative ${
|
||||
selectedSource === source.id
|
||||
? 'rb:bg-blue-50 rb:text-blue-600'
|
||||
: 'hover:rb:bg-gray-50'
|
||||
}`}
|
||||
onClick={() => handleSelectSource(source.id)}
|
||||
>
|
||||
<span className="rb:text-lg rb:flex-shrink-0">{source.icon}</span>
|
||||
<span className="rb:flex-1 rb:text-sm rb:font-medium rb:overflow-hidden rb:text-ellipsis rb:whitespace-nowrap">
|
||||
{source.name}
|
||||
</span>
|
||||
<span className="rb:text-xs rb:text-gray-500 rb:px-1.5 rb:py-0.5 rb:bg-gray-100 rb:rounded-full">
|
||||
{source.mcpCount}
|
||||
</span>
|
||||
{source.connected && (
|
||||
<span className="rb:text-green-500 rb:text-[8px] rb:ml-1">●</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 右侧内容区 */}
|
||||
<div className="rb:flex-1 rb:bg-white rb:rounded-lg rb:border rb:border-gray-200 rb:overflow-hidden">
|
||||
<div className="rb:h-full rb:overflow-y-auto rb:p-6">
|
||||
{renderSourceDetail()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 配置弹窗 */}
|
||||
<MarketConfigModal
|
||||
ref={marketConfigModalRef}
|
||||
onConnect={handleConnect}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Market;
|
||||
@@ -6,6 +6,7 @@ import type { CustomToolItem, CustomToolModalRef, ToolItem } from '../types'
|
||||
import RbModal from '@/components/RbModal';
|
||||
import { parseSchema, addTool, updateTool } from '@/api/tools';
|
||||
import Table from '@/components/Table';
|
||||
import { stringRegExp } from '@/utils/validator';
|
||||
const FormItem = Form.Item;
|
||||
|
||||
interface CustomToolModalProps {
|
||||
@@ -134,7 +135,11 @@ const CustomToolModal = forwardRef<CustomToolModalRef, CustomToolModalProps>(({
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('tool.name')}
|
||||
rules={[{ required: true, message: t('common.enterNamePlaceholder') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('tool.enterNamePlaceholder') },
|
||||
{ max: 50 },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('tool.enterNamePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
173
web/src/views/ToolManagement/components/MarketConfigModal.tsx
Normal file
173
web/src/views/ToolManagement/components/MarketConfigModal.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { forwardRef, useImperativeHandle, useState } from 'react';
|
||||
import { Form, Input, Button, App, Space } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CopyOutlined, EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import RbModal from '@/components/RbModal';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
interface MarketSource {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
desc: string;
|
||||
apiKey: string;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
interface MarketConfigModalProps {
|
||||
onConnect: (sourceId: string, apiKey: string) => void;
|
||||
}
|
||||
|
||||
export interface MarketConfigModalRef {
|
||||
handleOpen: (source: MarketSource) => void;
|
||||
handleClose: () => void;
|
||||
}
|
||||
|
||||
const MarketConfigModal = forwardRef<MarketConfigModalRef, MarketConfigModalProps>(({
|
||||
onConnect
|
||||
}, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [currentSource, setCurrentSource] = useState<MarketSource | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
setLoading(false);
|
||||
setCurrentSource(null);
|
||||
setShowApiKey(false);
|
||||
};
|
||||
|
||||
const handleOpen = (source: MarketSource) => {
|
||||
setCurrentSource(source);
|
||||
form.setFieldsValue({
|
||||
url: source.url,
|
||||
apiKey: source.apiKey,
|
||||
});
|
||||
setVisible(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
if (!currentSource) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
// 模拟连接延迟
|
||||
setTimeout(() => {
|
||||
onConnect(currentSource.id, values.apiKey || '');
|
||||
message.success(`正在连接 ${currentSource.name}...`);
|
||||
setLoading(false);
|
||||
handleClose();
|
||||
}, 500);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('表单验证失败:', err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
if (currentSource?.url) {
|
||||
navigator.clipboard.writeText(currentSource.url).then(() => {
|
||||
message.success(t('common.copySuccess'));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleOpen,
|
||||
handleClose
|
||||
}));
|
||||
|
||||
if (!currentSource) return null;
|
||||
|
||||
return (
|
||||
<RbModal
|
||||
title={`配置 ${currentSource.name}`}
|
||||
open={visible}
|
||||
onCancel={handleClose}
|
||||
okText="保存并连接"
|
||||
onOk={handleSave}
|
||||
confirmLoading={loading}
|
||||
width={600}
|
||||
>
|
||||
<div>
|
||||
{/* 市场源信息头部 */}
|
||||
<div className="rb:flex rb:gap-4 rb:mb-6 rb:p-4 rb:bg-gray-50 rb:rounded-lg">
|
||||
<div className="rb:text-4xl rb:w-16 rb:h-16 rb:flex rb:items-center rb:justify-center rb:bg-white rb:rounded-lg rb:flex-shrink-0">
|
||||
{currentSource.icon}
|
||||
</div>
|
||||
<div className="rb:flex-1">
|
||||
<h3 className="rb:text-base rb:font-semibold rb:mb-1 rb:text-gray-900">{currentSource.name}</h3>
|
||||
<p className="rb:text-sm rb:text-gray-600 rb:leading-relaxed">{currentSource.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
>
|
||||
{/* 市场地址 */}
|
||||
<FormItem
|
||||
name="url"
|
||||
label="市场地址"
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
readOnly
|
||||
placeholder="市场地址"
|
||||
/>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={handleCopyUrl}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</FormItem>
|
||||
|
||||
{/* API Key */}
|
||||
<FormItem
|
||||
name="apiKey"
|
||||
label={
|
||||
<span>
|
||||
API Key <span className="rb:text-gray-400 rb:font-normal">(可选)</span>
|
||||
</span>
|
||||
}
|
||||
extra="部分市场需要 API Key 才能获取完整的服务列表"
|
||||
>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="输入 API Key 以获取更多服务"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Button
|
||||
icon={showApiKey ? <EyeInvisibleOutlined /> : <EyeOutlined />}
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</FormItem>
|
||||
|
||||
{/* 连接状态 */}
|
||||
<div className="rb:flex rb:items-center rb:gap-2 rb:p-3 rb:bg-gray-50 rb:rounded rb:text-sm">
|
||||
<span className="rb:text-gray-600">连接状态:</span>
|
||||
<span className={`rb:font-medium ${currentSource.connected ? 'rb:text-green-600' : 'rb:text-gray-400'}`}>
|
||||
{currentSource.connected ? '● 已连接' : '○ 未连接'}
|
||||
</span>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</RbModal>
|
||||
);
|
||||
});
|
||||
|
||||
export default MarketConfigModal;
|
||||
@@ -9,6 +9,7 @@ import RequestHeaderModal from './RequestHeaderModal';
|
||||
import Table from '@/components/Table';
|
||||
import { addTool, updateTool, testConnection } from '@/api/tools'
|
||||
import type { McpServiceModalRef } from '../types'
|
||||
import { stringRegExp } from '@/utils/validator';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
@@ -168,14 +169,22 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
name={['config', "server_url"]}
|
||||
label={t('tool.serviceEndpoint')}
|
||||
extra={t('tool.serviceEndpointExtra')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ max: 500 },
|
||||
{ pattern: /^https?:\/\/\S+$/, message: t('tool.serverUrlInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('tool.serviceEndpointPlaceholder')} />
|
||||
</FormItem>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('tool.name')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ max: 50 },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('tool.namePlaceholder')} />
|
||||
</Form.Item>
|
||||
@@ -201,6 +210,7 @@ const McpServiceModal = forwardRef<McpServiceModalRef, McpServiceModalProps>(({
|
||||
<FormItem
|
||||
name="description"
|
||||
label={t('tool.description')}
|
||||
rules={[{ max: 500 }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder={t('common.inputPlaceholder', { title: t('tool.description') })}/>
|
||||
</FormItem>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import type { RequestHeader, RequestHeaderModalRef } from './McpServiceModal'
|
||||
import RbModal from '@/components/RbModal'
|
||||
import { stringRegExp } from '@/utils/validator';
|
||||
|
||||
const FormItem = Form.Item;
|
||||
|
||||
@@ -82,7 +83,11 @@ const RequestHeaderModal = forwardRef<RequestHeaderModalRef, RequestHeaderModalP
|
||||
<FormItem
|
||||
name="key"
|
||||
label={t('tool.requestHeaderName')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ pattern: /^[a-zA-Z0-9][a-zA-Z0-9\-_]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/, message: t('tool.requestHeaderKeyInvalid') },
|
||||
{ max: 100 }
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.enter')} />
|
||||
</FormItem>
|
||||
@@ -90,7 +95,11 @@ const RequestHeaderModal = forwardRef<RequestHeaderModalRef, RequestHeaderModalP
|
||||
<FormItem
|
||||
name="value"
|
||||
label={t('tool.requestHeaderValue')}
|
||||
rules={[{ required: true, message: t('common.pleaseEnter') }]}
|
||||
rules={[
|
||||
{ required: true, message: t('common.pleaseEnter') },
|
||||
{ pattern: stringRegExp, message: t('common.nameInvalid') },
|
||||
{ max: 2000 }
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('common.enter',)} />
|
||||
</FormItem>
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/*
|
||||
* @Description:
|
||||
* @Version: 0.0.1
|
||||
* @Author: yujiangping
|
||||
* @Date: 2026-01-05 17:22:23
|
||||
* @LastEditors: yujiangping
|
||||
* @LastEditTime: 2026-03-04 15:12:48
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Tabs } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -5,9 +13,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import Mcp from './Mcp';
|
||||
import Inner from './Inner';
|
||||
import Custom from './Custom';
|
||||
import Market from './Market';
|
||||
import Tag from '@/components/Tag'
|
||||
|
||||
const tabKeys = ['mcp', 'inner', 'custom']
|
||||
const tabKeys = ['mcp', 'inner', 'custom', 'market']
|
||||
const ToolManagement: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState('mcp');
|
||||
@@ -45,6 +54,7 @@ const ToolManagement: React.FC = () => {
|
||||
{activeTab === 'mcp' && <Mcp getStatusTag={getStatusTag} />}
|
||||
{activeTab === 'inner' && <Inner getStatusTag={getStatusTag} />}
|
||||
{activeTab === 'custom' && <Custom getStatusTag={getStatusTag} />}
|
||||
{/* {activeTab === 'market' && <Market getStatusTag={getStatusTag} />} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 18:31:50
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-03 18:31:50
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 16:22:03
|
||||
*/
|
||||
import { useEffect, useState, forwardRef, useImperativeHandle } from 'react'
|
||||
import { useEffect, useState, useRef, forwardRef, useImperativeHandle } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { App } from 'antd'
|
||||
|
||||
import Empty from '@/components/Empty'
|
||||
import RbCard from '@/components/RbCard/Card'
|
||||
@@ -20,6 +21,7 @@ import RbAlert from '@/components/RbAlert'
|
||||
* @property {Array} suggestions - List of suggestions with actionable steps
|
||||
*/
|
||||
interface Suggestions {
|
||||
exists?: boolean;
|
||||
health_summary: string;
|
||||
suggestions: Array<{
|
||||
type: string;
|
||||
@@ -35,14 +37,17 @@ interface Suggestions {
|
||||
* Displays emotional health suggestions with actionable steps
|
||||
* Shows health summary and prioritized recommendations
|
||||
*/
|
||||
const Suggestions = forwardRef<{ handleRefresh: () => void; }>((_props, ref) => {
|
||||
const Suggestions = forwardRef<{ handleRefresh: () => void; }, { refresh: () => void; }>(({ refresh }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const { modal } = App.useApp()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [suggestions, setSuggestions] = useState<Suggestions | null>(null)
|
||||
const modalInstanceRef = useRef<{ destroy: () => void } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
getSuggestionData()
|
||||
return () => modalInstanceRef.current?.destroy()
|
||||
}, [id])
|
||||
|
||||
const getSuggestionData = () => {
|
||||
@@ -52,7 +57,18 @@ const Suggestions = forwardRef<{ handleRefresh: () => void; }>((_props, ref) =>
|
||||
setLoading(true)
|
||||
getEmotionSuggestions(id)
|
||||
.then((res) => {
|
||||
setSuggestions(res as Suggestions)
|
||||
const response = res as Suggestions
|
||||
if (!response.exists && (!response.suggestions || !response.suggestions?.length)) {
|
||||
modalInstanceRef.current = modal.warning({
|
||||
title: t('statementDetail.noData'),
|
||||
okText: t('common.refresh'),
|
||||
onOk: () => {
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setSuggestions(res as Suggestions)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-01-08 19:46:02
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 16:26:55
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Row, Col } from 'antd'
|
||||
import { Row, Col, App } from 'antd'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import Preferences from '../components/Preferences'
|
||||
@@ -9,16 +15,44 @@ import InterestAreas from '../components/InterestAreas'
|
||||
import Habits from '../components/Habits'
|
||||
import {
|
||||
generateProfile,
|
||||
implicitCheckData,
|
||||
} from '@/api/memory'
|
||||
|
||||
const ImplicitDetail = forwardRef<{ handleRefresh: () => void; }>((_props, ref) => {
|
||||
/**
|
||||
* ImplicitDetail Component - Displays user's implicit memory profile
|
||||
* Shows unconscious preferences, personality traits, interests and habits
|
||||
*/
|
||||
const ImplicitDetail = forwardRef<{ handleRefresh: () => void; }, { refresh: () => void; }>(({
|
||||
refresh
|
||||
}, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const { id } = useParams()
|
||||
const { modal } = App.useApp()
|
||||
const preferencesRef = useRef<{ handleRefresh: () => void; }>(null)
|
||||
const portraitRef = useRef<{ handleRefresh: () => void; }>(null)
|
||||
const interestAreasRef = useRef<{ handleRefresh: () => void; }>(null)
|
||||
const habitsRef = useRef<{ handleRefresh: () => void; }>(null)
|
||||
|
||||
// Check if implicit data exists, prompt user to initialize if not
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
let modalInstance: { destroy: () => void } | null = null
|
||||
implicitCheckData(id)
|
||||
.then(res => {
|
||||
if (!(res as { exists: boolean }).exists) {
|
||||
modalInstance = modal.warning({
|
||||
title: t('implicitDetail.noData'),
|
||||
okText: t('common.refresh'),
|
||||
onOk: () => {
|
||||
refresh()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
return () => modalInstance?.destroy()
|
||||
}, [id])
|
||||
|
||||
// Refresh all implicit memory components by regenerating profile
|
||||
const handleRefresh = () => {
|
||||
if (!id) {
|
||||
return Promise.resolve()
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-19 16:54:52
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 16:28:00
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useRef } from 'react'
|
||||
import { Row, Col, Space } from 'antd';
|
||||
import { useParams } from 'react-router-dom'
|
||||
@@ -9,9 +15,17 @@ import Suggestions from '../components/Suggestions'
|
||||
import { generateSuggestions } from '@/api/memory'
|
||||
|
||||
|
||||
const StatementDetail = forwardRef((_props, ref) => {
|
||||
/**
|
||||
* StatementDetail - Displays emotional memory analysis for a user
|
||||
* Shows word cloud, emotion tags, health index, and personalized suggestions
|
||||
*/
|
||||
const StatementDetail = forwardRef<{ handleRefresh: () => void },{ refresh: () => void; }>(({
|
||||
refresh
|
||||
}, ref) => {
|
||||
const { id } = useParams()
|
||||
const suggestionsRef = useRef<{ handleRefresh: () => void; }>(null)
|
||||
|
||||
// Regenerate suggestions and refresh the Suggestions child component
|
||||
const handleRefresh = () => {
|
||||
if (!id) {
|
||||
return Promise.resolve()
|
||||
@@ -41,7 +55,7 @@ const StatementDetail = forwardRef((_props, ref) => {
|
||||
</Space>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Suggestions ref={suggestionsRef} />
|
||||
<Suggestions ref={suggestionsRef} refresh={refresh} />
|
||||
</Col>
|
||||
</Row>
|
||||
)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-01-07 20:37:34
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-04 16:27:14
|
||||
*/
|
||||
import { type FC, useEffect, useState, useMemo, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Dropdown, Button } from 'antd'
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
|
||||
import PageHeader from '../components/PageHeader'
|
||||
import StatementDetail from './StatementDetail'
|
||||
@@ -19,11 +24,16 @@ import {
|
||||
import refreshIcon from '@/assets/images/refresh_hover.svg'
|
||||
import GraphDetail from './GraphDetail'
|
||||
|
||||
/**
|
||||
* Detail page for user memory - renders different memory type views
|
||||
* based on the `type` route param
|
||||
*/
|
||||
const Detail: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { id, type } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [name, setName] = useState<string>('')
|
||||
// Refs for child components that support imperative refresh
|
||||
const forgetDetailRef = useRef<{ handleRefresh: () => void }>(null)
|
||||
const statementDetailRef = useRef<{ handleRefresh: () => void }>(null)
|
||||
const implicitDetailRef = useRef<{ handleRefresh: () => void }>(null)
|
||||
@@ -33,6 +43,7 @@ const Detail: FC = () => {
|
||||
getData()
|
||||
}, [id])
|
||||
|
||||
// Fetch end user profile to display the user's name in the header
|
||||
const getData = () => {
|
||||
if (!id) return
|
||||
getEndUserProfile(id).then((res) => {
|
||||
@@ -40,15 +51,21 @@ const Detail: FC = () => {
|
||||
setName(response.other_name || response.id)
|
||||
})
|
||||
}
|
||||
|
||||
// Build dropdown menu items for switching between memory types
|
||||
const items = useMemo(() => {
|
||||
return ['PERCEPTUAL_MEMORY', 'WORKING_MEMORY', 'EMOTIONAL_MEMORY', 'SHORT_TERM_MEMORY', 'IMPLICIT_MEMORY', 'EPISODIC_MEMORY', 'EXPLICIT_MEMORY', 'FORGET_MEMORY']
|
||||
.map(key => ({ key, label: t(`userMemory.${key}`) }))
|
||||
}, [t])
|
||||
|
||||
// Navigate to the selected memory type detail page
|
||||
const onClick = ({ key }: { key: string }) => {
|
||||
navigate(`/user-memory/detail/${id}/${key}`, { replace: true })
|
||||
}
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
// Trigger refresh on the active memory type's child component
|
||||
const handleRefresh = () => {
|
||||
setLoading(true)
|
||||
let response: any = null
|
||||
@@ -64,6 +81,7 @@ const Detail: FC = () => {
|
||||
break
|
||||
}
|
||||
|
||||
// If the child returns a Promise, wait for it before clearing loading state
|
||||
if (response instanceof Promise) {
|
||||
response.finally(() => {
|
||||
setLoading(false)
|
||||
@@ -99,9 +117,9 @@ const Detail: FC = () => {
|
||||
</Button>}
|
||||
/>
|
||||
<div className="rb:h-[calc(100vh-64px)] rb:overflow-y-auto rb:py-3 rb:px-4">
|
||||
{type === 'EMOTIONAL_MEMORY' && <StatementDetail ref={statementDetailRef} />}
|
||||
{type === 'EMOTIONAL_MEMORY' && <StatementDetail ref={statementDetailRef} refresh={handleRefresh} />}
|
||||
{type === 'FORGET_MEMORY' && <ForgetDetail ref={forgetDetailRef} />}
|
||||
{type === 'IMPLICIT_MEMORY' && <ImplicitDetail ref={implicitDetailRef} />}
|
||||
{type === 'IMPLICIT_MEMORY' && <ImplicitDetail ref={implicitDetailRef} refresh={handleRefresh} />}
|
||||
{type === 'SHORT_TERM_MEMORY' && <ShortTermDetail />}
|
||||
{type === 'PERCEPTUAL_MEMORY' && <PerceptualDetail />}
|
||||
{type === 'EPISODIC_MEMORY' && <EpisodicDetail />}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-06 21:10:56
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-28 16:43:06
|
||||
* @Last Modified time: 2026-03-04 18:51:48
|
||||
*/
|
||||
/**
|
||||
* Workflow Chat Component
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
import { forwardRef, useImperativeHandle, useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { App, Space, Button, Flex, Dropdown, type MenuProps } from 'antd'
|
||||
import { App, Space, Button, Flex, Dropdown, type MenuProps, Divider } from 'antd'
|
||||
|
||||
import ChatIcon from '@/assets/images/application/chat.png'
|
||||
import RbDrawer from '@/components/RbDrawer';
|
||||
@@ -38,7 +38,7 @@ import { type SSEMessage } from '@/utils/stream'
|
||||
import type { Variable } from '../Properties/VariableList/types'
|
||||
import ChatInput from '@/components/Chat/ChatInput'
|
||||
import UploadFiles from '@/views/Conversation/components/FileUpload'
|
||||
// import AudioRecorder from '@/components/AudioRecorder'
|
||||
import AudioRecorder from '@/components/AudioRecorder'
|
||||
import UploadFileListModal from '@/views/Conversation/components/UploadFileListModal'
|
||||
import type { UploadFileListModalRef } from '@/views/Conversation/types'
|
||||
import Runtime from './Runtime';
|
||||
@@ -174,8 +174,8 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
*/
|
||||
const handleStreamMessage = (data: SSEMessage[]) => {
|
||||
data.forEach(item => {
|
||||
const { chunk, conversation_id, node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = item.data as {
|
||||
chunk: string;
|
||||
const { content, conversation_id, node_id, cycle_id, cycle_idx, input, output, error, elapsed_time, status } = item.data as {
|
||||
content: string;
|
||||
conversation_id: string | null;
|
||||
cycle_id: string;
|
||||
cycle_idx: number;
|
||||
@@ -202,7 +202,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
if (lastIndex >= 0) {
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
content: newList[lastIndex].content + chunk
|
||||
content: newList[lastIndex].content + content
|
||||
}
|
||||
}
|
||||
return newList
|
||||
@@ -320,7 +320,8 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
newList[lastIndex] = {
|
||||
...newList[lastIndex],
|
||||
status,
|
||||
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content
|
||||
error,
|
||||
content: newList[lastIndex].content === '' ? null : newList[lastIndex].content,
|
||||
}
|
||||
}
|
||||
return newList
|
||||
@@ -358,6 +359,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
setStreamLoading(true)
|
||||
draftRun(appId, data, handleStreamMessage)
|
||||
.catch((error) => {
|
||||
console.log('draftRun error', error)
|
||||
setChatList(prev => {
|
||||
const newList = [...prev]
|
||||
const lastIndex = newList.length - 1
|
||||
@@ -389,9 +391,13 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
const fileChange = (file?: any) => {
|
||||
setFileList([...fileList, file])
|
||||
}
|
||||
// const handleRecordingComplete = async (file: any) => {
|
||||
// console.log('file', file)
|
||||
// }
|
||||
const handleRecordingComplete = async (file: any) => {
|
||||
setFileList([...fileList, {
|
||||
response: { data: file },
|
||||
thumbUrl: file.url,
|
||||
type: file.type
|
||||
}])
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles dropdown menu actions for file upload
|
||||
@@ -423,6 +429,8 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
handleClose
|
||||
}));
|
||||
|
||||
console.log('fileList', fileList)
|
||||
|
||||
return (
|
||||
<RbDrawer
|
||||
title={<div className="rb:flex rb:items-center rb:gap-2.5">
|
||||
@@ -469,7 +477,6 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
{
|
||||
key: 'upload', label: (
|
||||
<UploadFiles
|
||||
fileType={['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']}
|
||||
onChange={fileChange}
|
||||
/>
|
||||
)
|
||||
@@ -483,10 +490,10 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef }>(({ appId
|
||||
></div>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
{/* <Flex align="center">
|
||||
<Flex align="center">
|
||||
<AudioRecorder onRecordingComplete={handleRecordingComplete} />
|
||||
<Divider type="vertical" className="rb:ml-1.5! rb:mr-3!" />
|
||||
</Flex> */}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</ChatInput>
|
||||
</div>
|
||||
|
||||
@@ -217,14 +217,20 @@ const Runtime: FC<{ item: ChatItem; index: number;}> = ({
|
||||
children: (
|
||||
detail
|
||||
? (
|
||||
<div className="rb:bg-[#FBFDFF] rb:rounded-md">
|
||||
<Button type="link" icon={<ArrowLeftOutlined />} onClick={() => setDetail(null)} className="rb:px-0! rb:text-[12px]!">
|
||||
{t('common.return')}
|
||||
</Button>
|
||||
{renderDetailChild(detail.subContent)}
|
||||
</div>
|
||||
)
|
||||
: renderChild(item.subContent)
|
||||
<div className="rb:bg-[#FBFDFF] rb:rounded-md">
|
||||
<Button type="link" icon={<ArrowLeftOutlined />} onClick={() => setDetail(null)} className="rb:px-0! rb:text-[12px]!">
|
||||
{t('common.return')}
|
||||
</Button>
|
||||
{renderDetailChild(detail.subContent)}
|
||||
</div>
|
||||
)
|
||||
: <>
|
||||
{item.error
|
||||
? <div className={clsx("rb:bg-[#FBFDFF] rb:rounded-md rb:py-2 rb:px-3 ", getStatus('failed'))}>
|
||||
<Markdown content={item.error} />
|
||||
</div>
|
||||
: renderChild(item.subContent)
|
||||
}</>
|
||||
)
|
||||
}]}
|
||||
/>
|
||||
|
||||
@@ -71,20 +71,21 @@ const VariableConfigModal = forwardRef<VariableConfigModalRef, VariableEditModal
|
||||
<Form.Item
|
||||
key={name}
|
||||
name={[name, 'value']}
|
||||
label={field.type === 'boolean' ? undefined : `${field.name}·${field.description}`}
|
||||
label={field.type === 'boolean' ? undefined : `${field.name}·${field.display_name || field.description}`}
|
||||
valuePropName={field.type === 'boolean' ? 'checked' : 'value'}
|
||||
rules={[
|
||||
{ required: field.required, message: field.type === 'boolean' ? t('common.pleaseSelect') : t('common.pleaseEnter') },
|
||||
]}
|
||||
>
|
||||
{
|
||||
field.type === 'string' && <Input placeholder={t('common.pleaseEnter')} />
|
||||
(field.type === 'string' || field.type === 'text') && <Input placeholder={t('common.pleaseEnter')} />
|
||||
}
|
||||
{ field.type === 'paragraph' && <Input.TextArea placeholder={t('common.pleaseEnter')} /> }
|
||||
{
|
||||
field.type === 'number' && <InputNumber placeholder={t('common.pleaseEnter')} style={{ width: '100%' }} onChange={(value) => form.setFieldValue(['variables', name, 'value'], value)} />
|
||||
}
|
||||
{
|
||||
field.type === 'boolean' && <Checkbox>{`${field.name}·${field.description}`}</Checkbox>
|
||||
field.type === 'boolean' && <Checkbox>{`${field.name}·${field.display_name || field.description}`}</Checkbox>
|
||||
}
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-23 12:29:46
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 10:12:48
|
||||
*/
|
||||
import { createCommand, type LexicalCommand } from 'lexical';
|
||||
import type { Suggestion } from '../plugin/AutocompletePlugin';
|
||||
|
||||
|
||||
// Payload interface for inserting variable command
|
||||
export interface InsertVariableCommandPayload {
|
||||
data: Suggestion;
|
||||
}
|
||||
|
||||
// Command to insert a variable into the editor
|
||||
export const INSERT_VARIABLE_COMMAND: LexicalCommand<InsertVariableCommandPayload> = createCommand('INSERT_VARIABLE_COMMAND');
|
||||
|
||||
// Command to clear all editor content
|
||||
export const CLEAR_EDITOR_COMMAND: LexicalCommand<void> = createCommand('CLEAR_EDITOR_COMMAND');
|
||||
|
||||
export const FOCUS_EDITOR_COMMAND: LexicalCommand<void> = createCommand('FOCUS_EDITOR_COMMAND');
|
||||
// Command to focus the editor
|
||||
export const FOCUS_EDITOR_COMMAND: LexicalCommand<void> = createCommand('FOCUS_EDITOR_COMMAND');
|
||||
|
||||
// Command to close the autocomplete dropdown
|
||||
export const CLOSE_AUTOCOMPLETE_COMMAND: LexicalCommand<void> = createCommand('CLOSE_AUTOCOMPLETE_COMMAND');
|
||||
@@ -1,3 +1,9 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-23 16:22:51
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 10:11:48
|
||||
*/
|
||||
import { type FC, useState, useEffect, useMemo } from 'react';
|
||||
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||
@@ -19,6 +25,7 @@ import LineNumberPlugin from './plugin/LineNumberPlugin';
|
||||
import BlurPlugin from './plugin/BlurPlugin';
|
||||
import { VariableNode } from './nodes/VariableNode'
|
||||
|
||||
// Props interface for Lexical Editor component
|
||||
export interface LexicalEditorProps {
|
||||
placeholder?: string;
|
||||
value?: string;
|
||||
@@ -30,9 +37,11 @@ export interface LexicalEditorProps {
|
||||
lineHeight?: number;
|
||||
size?: 'default' | 'small';
|
||||
type?: 'input' | 'textarea',
|
||||
language?: 'string' | 'jinja2'
|
||||
language?: 'string' | 'jinja2';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Default theme for editor
|
||||
const theme = {
|
||||
paragraph: 'editor-paragraph',
|
||||
text: {
|
||||
@@ -41,6 +50,7 @@ const theme = {
|
||||
},
|
||||
};
|
||||
|
||||
// Theme with Jinja2 syntax highlighting
|
||||
const jinja2Theme = {
|
||||
...theme,
|
||||
code: 'jinja2-expression',
|
||||
@@ -50,7 +60,8 @@ const jinja2Theme = {
|
||||
},
|
||||
};
|
||||
|
||||
const Editor: FC<LexicalEditorProps> =({
|
||||
// Main Lexical Editor component
|
||||
const Editor: FC<LexicalEditorProps> =(({
|
||||
placeholder = "请输入内容...",
|
||||
value = "",
|
||||
onChange,
|
||||
@@ -58,12 +69,15 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
variant = 'borderless',
|
||||
size = 'default',
|
||||
type = 'textarea',
|
||||
language = 'string'
|
||||
language = 'string',
|
||||
height,
|
||||
className
|
||||
}) => {
|
||||
const [_count, setCount] = useState(0);
|
||||
const [enableJinja2, setEnableJinja2] = useState(false)
|
||||
const [enableLineNumbers, setEnableLineNumbers] = useState(false)
|
||||
|
||||
// Setup Jinja2 mode and inject styles when language changes
|
||||
useEffect(() => {
|
||||
const needsLineNumbers = language === 'jinja2';
|
||||
setEnableJinja2(language === 'jinja2');
|
||||
@@ -136,11 +150,12 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
}
|
||||
}, [language])
|
||||
|
||||
// Lexical editor configuration
|
||||
const initialConfig = {
|
||||
namespace: 'AutocompleteEditor',
|
||||
theme: enableJinja2 ? jinja2Theme : theme,
|
||||
nodes: enableJinja2 ? [
|
||||
// 当启用jinja2时,不使用VariableNode,使用普通文本
|
||||
// When Jinja2 is enabled, use plain text instead of VariableNode
|
||||
] : [
|
||||
// HeadingNode,
|
||||
// QuoteNode,
|
||||
@@ -154,28 +169,37 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
console.error(error);
|
||||
},
|
||||
};
|
||||
|
||||
// Calculate minimum height based on type and size
|
||||
const minheight = useMemo(() => {
|
||||
if (type === 'input') {
|
||||
return `${size === 'small' ? 26 : 30}px`
|
||||
return `${height ? height : size === 'small' ? 28 : 30}px`
|
||||
}
|
||||
return `${size === 'small' ? 60 : 120}px`
|
||||
}, [type, size])
|
||||
return `${height ? height : size === 'small' ? 60 : 120}px`
|
||||
}, [type, size, height])
|
||||
|
||||
// Calculate font size based on size prop
|
||||
const fontSize = useMemo(() => {
|
||||
return `${size === 'small' ? 12 : 14}px`
|
||||
}, [size])
|
||||
|
||||
// Calculate line height based on size prop
|
||||
const lineHeight = useMemo(() => {
|
||||
return `${size === 'small' ? 16 : 20}px`
|
||||
return `${height ? height : size === 'small' ? 16 : 20}px`
|
||||
}, [size])
|
||||
|
||||
// Calculate placeholder minimum height
|
||||
const placeHolderMinheight = useMemo(() => {
|
||||
return `${size === 'small' ? 16 : 30}px`
|
||||
}, [type, size])
|
||||
return `${height ? height : size === 'small' ? 16 : 30}px`
|
||||
}, [type, size, height])
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ position: 'relative' }} className={className}>
|
||||
<RichTextPlugin
|
||||
contentEditable={
|
||||
enableLineNumbers ? (
|
||||
// Editor with line numbers for Jinja2 mode
|
||||
<div className="editor-with-line-numbers" style={{
|
||||
border: variant === 'borderless' ? 'none' : '1px solid #DFE4ED',
|
||||
borderRadius: '6px',
|
||||
@@ -200,6 +224,7 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Standard editor without line numbers
|
||||
<ContentEditable
|
||||
style={{
|
||||
minHeight: minheight,
|
||||
@@ -232,6 +257,7 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
{/* Editor plugins */}
|
||||
<HistoryPlugin />
|
||||
<CommandPlugin />
|
||||
{language === 'jinja2' && <Jinja2HighlightPlugin />}
|
||||
@@ -239,10 +265,10 @@ const Editor: FC<LexicalEditorProps> =({
|
||||
<AutocompletePlugin options={options} enableJinja2={enableJinja2} />
|
||||
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
|
||||
<InitialValuePlugin value={value} options={options} enableLineNumbers={enableLineNumbers} />
|
||||
{enableJinja2 && <BlurPlugin />}
|
||||
<BlurPlugin enableJinja2={enableJinja2} />
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
export default Editor;
|
||||
export default Editor;
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2025-12-23 16:22:51
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 10:12:33
|
||||
*/
|
||||
import { useEffect, useState, useRef, type FC } from 'react';
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_HIGH, KEY_ENTER_COMMAND, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND } from 'lexical';
|
||||
|
||||
import { INSERT_VARIABLE_COMMAND } from '../commands';
|
||||
import { INSERT_VARIABLE_COMMAND, CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
||||
import type { NodeProperties } from '../../../types'
|
||||
|
||||
// Suggestion item interface for autocomplete dropdown
|
||||
export interface Suggestion {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -13,10 +20,11 @@ export interface Suggestion {
|
||||
value: string;
|
||||
group?: string
|
||||
nodeData: NodeProperties;
|
||||
isContext?: boolean; // 标记是否为context变量
|
||||
disabled?: boolean; // 标记是否禁用
|
||||
isContext?: boolean; // Flag for context variable
|
||||
disabled?: boolean; // Flag for disabled state
|
||||
}
|
||||
|
||||
// Autocomplete plugin for variable suggestions triggered by '/' character
|
||||
const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> = ({ options, enableJinja2 = false }) => {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
@@ -43,6 +51,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
||||
}
|
||||
};
|
||||
|
||||
// Listen to editor updates and show suggestions when '/' is typed
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
@@ -69,6 +78,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
|
||||
// Calculate popup position to keep it within viewport bounds
|
||||
if (shouldShow) {
|
||||
const domSelection = window.getSelection();
|
||||
if (domSelection && domSelection.rangeCount > 0) {
|
||||
@@ -104,9 +114,22 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
// Register command to close autocomplete popup
|
||||
useEffect(() => {
|
||||
return editor.registerCommand(
|
||||
CLOSE_AUTOCOMPLETE_COMMAND,
|
||||
() => {
|
||||
setShowSuggestions(false);
|
||||
return true;
|
||||
},
|
||||
COMMAND_PRIORITY_HIGH
|
||||
);
|
||||
}, [editor]);
|
||||
|
||||
// Insert selected suggestion into editor
|
||||
const insertMention = (suggestion: Suggestion) => {
|
||||
if (enableJinja2) {
|
||||
// 在jinja2模式下,插入{{variable}}格式的文本
|
||||
// In Jinja2 mode, insert {{variable}} format text
|
||||
editor.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
@@ -114,7 +137,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
||||
const anchorOffset = selection.anchor.offset;
|
||||
const nodeText = anchorNode.getTextContent();
|
||||
|
||||
// 移除触发字符'/'
|
||||
// Remove trigger character '/'
|
||||
const textBefore = nodeText.substring(0, anchorOffset - 1);
|
||||
const textAfter = nodeText.substring(anchorOffset);
|
||||
const newText = textBefore + `{{${suggestion.value}}}` + textAfter;
|
||||
@@ -123,19 +146,20 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
||||
anchorNode.setTextContent(newText);
|
||||
}
|
||||
|
||||
// 设置光标位置到插入文本之后
|
||||
// Set cursor position after inserted text
|
||||
const newOffset = textBefore.length + `{{${suggestion.value}}}`.length;
|
||||
selection.anchor.offset = newOffset;
|
||||
selection.focus.offset = newOffset;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// 普通模式下使用VariableNode
|
||||
// In normal mode, use VariableNode
|
||||
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
|
||||
}
|
||||
setShowSuggestions(false);
|
||||
};
|
||||
|
||||
// Group suggestions by node ID
|
||||
const groupedSuggestions = options.reduce((groups: Record<string, Suggestion[]>, suggestion) => {
|
||||
const { nodeData } = suggestion
|
||||
const nodeId = nodeData.id as string;
|
||||
@@ -146,6 +170,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
||||
return groups;
|
||||
}, {});
|
||||
|
||||
// Handle Enter key to select suggestion
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) return;
|
||||
|
||||
@@ -168,11 +193,13 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
||||
);
|
||||
}, [showSuggestions, selectedIndex, groupedSuggestions, insertMention, editor]);
|
||||
|
||||
// Handle keyboard navigation (Arrow Up/Down, Escape)
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) return;
|
||||
|
||||
const allOptions = Object.values(groupedSuggestions).flat();
|
||||
|
||||
// Navigate down through suggestions, skip disabled items
|
||||
const unregisterArrowDown = editor.registerCommand(
|
||||
KEY_ARROW_DOWN_COMMAND,
|
||||
(event) => {
|
||||
@@ -194,6 +221,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
||||
COMMAND_PRIORITY_HIGH
|
||||
);
|
||||
|
||||
// Navigate up through suggestions, skip disabled items
|
||||
const unregisterArrowUp = editor.registerCommand(
|
||||
KEY_ARROW_UP_COMMAND,
|
||||
(event) => {
|
||||
@@ -215,6 +243,7 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
||||
COMMAND_PRIORITY_HIGH
|
||||
);
|
||||
|
||||
// Close suggestions on Escape key
|
||||
const unregisterEscape = editor.registerCommand(
|
||||
KEY_ESCAPE_COMMAND,
|
||||
(event) => {
|
||||
@@ -264,7 +293,9 @@ const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }>
|
||||
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
|
||||
return (
|
||||
<div key={nodeId}>
|
||||
{/* Divider between groups */}
|
||||
{groupIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />}
|
||||
{/* Group header with node name */}
|
||||
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}>
|
||||
{nodeName}
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,64 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-01-20 10:42:13
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-03 10:12:10
|
||||
*/
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||
import { useEffect } from 'react';
|
||||
import { $setSelection } from 'lexical';
|
||||
import { CLOSE_AUTOCOMPLETE_COMMAND } from '../commands';
|
||||
|
||||
export default function BlurPlugin() {
|
||||
// Plugin to handle blur events and close autocomplete when clicking outside
|
||||
export default function BlurPlugin({ enableJinja2 }: { enableJinja2: boolean }) {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => {
|
||||
// Close autocomplete when clicking outside the popup
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target?.closest('[data-autocomplete-popup="true"]')) {
|
||||
return;
|
||||
}
|
||||
editor.dispatchCommand(CLOSE_AUTOCOMPLETE_COMMAND, undefined);
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return editor.registerRootListener((rootElement) => {
|
||||
if (rootElement) {
|
||||
const handleBlur = (e: FocusEvent) => {
|
||||
// 检查是否点击了自动完成弹窗
|
||||
const target = e.target as HTMLElement;
|
||||
console.log('target', target)
|
||||
if (target?.closest('[data-autocomplete-popup="true"]')) {
|
||||
return;
|
||||
if (enableJinja2) {
|
||||
// Check if autocomplete popup was clicked
|
||||
const target = e.target as HTMLElement;
|
||||
if (target?.closest('[data-autocomplete-popup="true"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if blur was caused by paste operation
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (!relatedTarget || relatedTarget === document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear selection on blur
|
||||
editor.update(() => {
|
||||
$setSelection(null);
|
||||
});
|
||||
}
|
||||
|
||||
// 检查是否是粘贴操作导致的焦点变化
|
||||
const relatedTarget = e.relatedTarget as HTMLElement;
|
||||
if (!relatedTarget || relatedTarget === document.body) {
|
||||
return;
|
||||
}
|
||||
|
||||
editor.update(() => {
|
||||
$setSelection(null);
|
||||
});
|
||||
};
|
||||
|
||||
rootElement.addEventListener('blur', handleBlur);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
rootElement.removeEventListener('blur', handleBlur);
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
});
|
||||
}, [editor]);
|
||||
}, [editor, enableJinja2]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { type FC, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Select, Table, Form, type TableProps } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin';
|
||||
import Empty from '@/components/Empty';
|
||||
import VariableSelect from '../VariableSelect';
|
||||
import Editor from '../../Editor'
|
||||
|
||||
export interface TableRow {
|
||||
key?: string;
|
||||
@@ -21,7 +23,7 @@ interface EditableTableProps {
|
||||
size?: "small"
|
||||
}
|
||||
|
||||
const EditableTable: React.FC<EditableTableProps> = ({
|
||||
const EditableTable: FC<EditableTableProps> = ({
|
||||
parentName,
|
||||
title,
|
||||
options = [],
|
||||
@@ -37,10 +39,17 @@ const EditableTable: React.FC<EditableTableProps> = ({
|
||||
...(typeOptions.length > 0 && { type: typeOptions[0].value })
|
||||
});
|
||||
|
||||
// Filter options based on boolean type if needed
|
||||
const booleanFilterOptions = useMemo(() => {
|
||||
return filterBooleanType
|
||||
? options.filter(option => option.dataType !== 'boolean')
|
||||
: options
|
||||
}, [options, filterBooleanType])
|
||||
|
||||
const getColumns = (remove: (index: number) => void): TableProps<TableRow>['columns'] => {
|
||||
const hasType = typeOptions.length > 0;
|
||||
const cellClassName="rb:p-1!"
|
||||
const contentClassName ="rb:w-[108px]! rb:text-[12px]!"
|
||||
const contentClassName ="rb:w-[108px]! rb:text-[12px]! rb:overflow-hidden!"
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -49,14 +58,12 @@ const EditableTable: React.FC<EditableTableProps> = ({
|
||||
className: cellClassName,
|
||||
render: (_: any, __: TableRow, index: number) => (
|
||||
<Form.Item name={[index, 'name']} noStyle>
|
||||
<VariableSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
// size="small"
|
||||
options={options}
|
||||
filterBooleanType={filterBooleanType}
|
||||
popupMatchSelectWidth={false}
|
||||
<Editor
|
||||
options={booleanFilterOptions.filter(option => !option.dataType.includes('file'))}
|
||||
type="input"
|
||||
className={contentClassName}
|
||||
size={size}
|
||||
height={16}
|
||||
/>
|
||||
</Form.Item>
|
||||
)
|
||||
@@ -101,19 +108,17 @@ const EditableTable: React.FC<EditableTableProps> = ({
|
||||
{(form) => {
|
||||
const currentType = form.getFieldValue([...Array.isArray(parentName) ? parentName : [parentName], index, 'type']);
|
||||
const filteredOptions = currentType === 'file'
|
||||
? options.filter(option => option.dataType === 'file')
|
||||
: options;
|
||||
? booleanFilterOptions.filter(option => option.dataType.includes('file'))
|
||||
: booleanFilterOptions.filter(option => !option.dataType.includes('file'));
|
||||
|
||||
return (
|
||||
<Form.Item name={[index, 'value']} noStyle>
|
||||
<VariableSelect
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
// size="small"
|
||||
<Editor
|
||||
options={filteredOptions}
|
||||
filterBooleanType={filterBooleanType}
|
||||
popupMatchSelectWidth={false}
|
||||
type="input"
|
||||
className={contentClassName}
|
||||
size={size}
|
||||
height={16}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-09 18:35:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-09 18:35:43
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-02 17:24:51
|
||||
*/
|
||||
import { type FC, useRef, useState } from "react";
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -13,7 +13,6 @@ import Editor from '../../Editor'
|
||||
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
|
||||
import AuthConfigModal from './AuthConfigModal'
|
||||
import type { AuthConfigModalRef, HttpRequestConfigForm } from './types'
|
||||
import VariableSelect from "../VariableSelect";
|
||||
import MessageEditor from '../MessageEditor'
|
||||
import EditableTable from './EditableTable'
|
||||
import { portTextAttrs } from '../../../constant'
|
||||
@@ -159,7 +158,7 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
|
||||
<EditableTable
|
||||
size="small"
|
||||
parentName={['body', 'data']}
|
||||
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number')}
|
||||
options={options.filter(vo => vo.dataType === 'string' || vo.dataType === 'number' || vo.dataType.includes('file'))}
|
||||
typeOptions={[
|
||||
{ label: 'text', value: 'text' },
|
||||
{ label: 'file', value: 'file' }
|
||||
@@ -201,10 +200,10 @@ const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: an
|
||||
}
|
||||
{values?.body?.content_type === 'binary' &&
|
||||
<Form.Item name={['body', 'data']} noStyle>
|
||||
<VariableSelect
|
||||
<Editor
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
options={options.filter(vo => vo.dataType.includes('file'))}
|
||||
filterBooleanType={true}
|
||||
type="input"
|
||||
size="small"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export interface Variable {
|
||||
name: string;
|
||||
display_name?: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/*
|
||||
* @Author: ZhaoYing
|
||||
* @Date: 2026-02-03 15:39:59
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-02-11 12:07:06
|
||||
* @Last Modified by: ZhaoYing
|
||||
* @Last Modified time: 2026-03-02 17:06:41
|
||||
*/
|
||||
import { type FC, useEffect, useState, useMemo } from "react";
|
||||
import clsx from 'clsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Graph, Node } from '@antv/x6';
|
||||
import { Form, Input, Select, InputNumber, Switch, Divider, Space } from 'antd'
|
||||
import { Form, Input, Select, InputNumber, Switch, Divider, Space, Button } from 'antd'
|
||||
import { CaretDownOutlined, CaretRightOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { NodeConfig, NodeProperties, ChatVariable } from '../../types'
|
||||
@@ -36,6 +36,7 @@ import Editor, { type LexicalEditorProps } from "../Editor";
|
||||
import RbSlider from './RbSlider'
|
||||
import JinjaRender from './JinjaRender'
|
||||
import CodeExecution from './CodeExecution'
|
||||
import { nodeLibrary } from '../../constant';
|
||||
|
||||
/**
|
||||
* Props for Properties component
|
||||
@@ -69,7 +70,8 @@ interface PropertiesProps {
|
||||
const Properties: FC<PropertiesProps> = ({
|
||||
selectedNode,
|
||||
graphRef,
|
||||
chatVariables
|
||||
chatVariables,
|
||||
blankClick
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [form] = Form.useForm<NodeConfig>();
|
||||
@@ -80,9 +82,8 @@ const Properties: FC<PropertiesProps> = ({
|
||||
useEffect(() => {
|
||||
if (selectedNode?.getData()?.id) {
|
||||
setOutputCollapsed(true)
|
||||
} else {
|
||||
form.resetFields()
|
||||
}
|
||||
form.resetFields()
|
||||
}, [selectedNode?.getData()?.id])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -94,7 +95,7 @@ const Properties: FC<PropertiesProps> = ({
|
||||
initialValue[key] = config[key].defaultValue
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
form.setFieldsValue({
|
||||
type,
|
||||
id: selectedNode.id,
|
||||
@@ -380,6 +381,41 @@ const Properties: FC<PropertiesProps> = ({
|
||||
}
|
||||
}
|
||||
console.log('variableList', variableList, currentNodeVariables)
|
||||
const handleSureReplace = () => {
|
||||
const { replaceNode } = values;
|
||||
const nodeLibraryConfig = [...nodeLibrary]
|
||||
.flatMap(category => category.nodes)
|
||||
.find(n => n.type === replaceNode)
|
||||
|
||||
if (replaceNode && nodeLibraryConfig) {
|
||||
// Preserve existing config values when switching node types
|
||||
const currentData = selectedNode?.data || {};
|
||||
const currentConfig = currentData.config || {};
|
||||
const newConfig = nodeLibraryConfig.config || {};
|
||||
|
||||
// Merge configs: keep existing values for matching keys, add new keys from template
|
||||
const mergedConfig: Record<string, any> = {};
|
||||
Object.keys(newConfig).forEach(key => {
|
||||
if (currentConfig[key] && currentConfig[key].defaultValue !== undefined) {
|
||||
// Preserve existing value if it exists
|
||||
mergedConfig[key] = {
|
||||
...newConfig[key],
|
||||
defaultValue: currentConfig[key].defaultValue
|
||||
};
|
||||
} else {
|
||||
// Use new config template
|
||||
mergedConfig[key] = { ...newConfig[key] };
|
||||
}
|
||||
});
|
||||
|
||||
selectedNode?.setData({
|
||||
...currentData,
|
||||
...nodeLibraryConfig,
|
||||
config: mergedConfig
|
||||
})
|
||||
blankClick()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx("rb:w-75 rb:fixed rb:right-0 rb:top-16 rb:bottom-0 rb:p-3 rb:pb-6", styles.properties)}>
|
||||
@@ -399,8 +435,27 @@ const Properties: FC<PropertiesProps> = ({
|
||||
<Form.Item name="id" label="ID">
|
||||
<Input disabled />
|
||||
</Form.Item>
|
||||
|
||||
{selectedNode?.data?.type === 'http-request'
|
||||
{selectedNode?.data?.type === 'unknown'
|
||||
? <>
|
||||
<Form.Item name="replaceNode" label={t('workflow.config.unknown.replaceNodeType')}>
|
||||
<Select
|
||||
options={nodeLibrary.map(category => ({
|
||||
label: t(`workflow.${category.category}`),
|
||||
options: category.nodes.filter(item => !['cycle-start', 'break'].includes(item.type)).map(node => ({
|
||||
label: <div className="rb:flex rb:items-center rb:gap-2 rb:flex-1">
|
||||
<img src={node.icon} className="rb:size-3.5" />
|
||||
<div className="rb:wrap-break-word rb:line-clamp-1">{t(`workflow.${node.type}`)}</div>
|
||||
</div>,
|
||||
value: node.type
|
||||
}))
|
||||
}))}
|
||||
placeholder={t('common.pleaseSelect')}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button type="primary" size="small" className="rb:text-[12px]!" onClick={handleSureReplace}>{t('workflow.sureReplace')}</Button>
|
||||
</>
|
||||
: selectedNode?.data?.type === 'http-request'
|
||||
? <HttpRequest
|
||||
options={variableList}
|
||||
selectedNode={selectedNode}
|
||||
|
||||
@@ -47,6 +47,7 @@ import breakIcon from '@/assets/images/workflow/break.png'
|
||||
import assignerIcon from '@/assets/images/workflow/assigner.png'
|
||||
import memoryReadIcon from '@/assets/images/workflow/memory-read.png'
|
||||
import memoryWriteIcon from '@/assets/images/workflow/memory-write.png'
|
||||
import unknownIcon from '@/assets/images/workflow/unknown.svg'
|
||||
|
||||
import { memoryConfigListUrl } from '@/api/memory'
|
||||
|
||||
@@ -524,6 +525,10 @@ export const nodeLibrary: NodeLibrary[] = [
|
||||
// ]
|
||||
// },
|
||||
];
|
||||
export const unknownNode = {
|
||||
type: 'unknown',
|
||||
icon: unknownIcon
|
||||
}
|
||||
|
||||
export const nodeWidth = 240;
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Graph, Node, MiniMap, Snapline, Clipboard, Keyboard, type Edge } from '
|
||||
import { register } from '@antv/x6-react-shape';
|
||||
import type { PortMetadata } from '@antv/x6/lib/model/port';
|
||||
|
||||
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth } from '../constant';
|
||||
import { nodeRegisterLibrary, graphNodeLibrary, nodeLibrary, portMarkup, portAttrs, edgeAttrs, edge_color, edge_selected_color, portTextAttrs, defaultAbsolutePortGroups, nodeWidth, unknownNode } from '../constant';
|
||||
import type { WorkflowConfig, NodeProperties, ChatVariable } from '../types';
|
||||
import { getWorkflowConfig, saveWorkflowConfig } from '@/api/application'
|
||||
|
||||
@@ -128,7 +128,7 @@ export const useWorkflowGraph = ({
|
||||
if (nodes.length) {
|
||||
const nodeList = nodes.map(node => {
|
||||
const { id, type, name, position, config = {} } = node
|
||||
let nodeLibraryConfig = [...nodeLibrary]
|
||||
let nodeLibraryConfig = [...nodeLibrary, { nodes: [unknownNode] }]
|
||||
.flatMap(category => category.nodes)
|
||||
.find(n => n.type === type)
|
||||
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
|
||||
|
||||
Reference in New Issue
Block a user