Merge branch 'release/v0.2.6' into feature/memory_zy

This commit is contained in:
yingzhao
2026-03-05 16:49:46 +08:00
committed by GitHub
164 changed files with 6833 additions and 3090 deletions

View File

@@ -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}`)}`
}
})
}

View File

@@ -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, {

View File

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

View 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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ export interface ChatItem {
status?: string;
subContent?: Record<string, any>[];
files?: any[];
error?: string;
}
/**

View File

@@ -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 */

View File

@@ -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.',

View File

@@ -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系统的"工作台",连接即时对话与长期知识库。通过实时捕获、深度检索、智能提取和筛选转化,将临时的非结构化信息转化为有价值的长期知识。',

View File

@@ -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'));

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

View File

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

View File

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

View File

@@ -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[]>([])

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
/>

View File

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

View File

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

View File

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

View File

@@ -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'));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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!"
/>

View File

@@ -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[];
}
/**

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,8 @@ export interface SkillFormData {
tools: Array<{
/** Tool identifier */
tool_id: string;
/** Tool operation/action */
operation?: string;
}>;
/** Skill configuration settings */
config: {

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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');

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
export interface Variable {
name: string;
display_name?: string;
type: string;
required: boolean;
description: string;

View File

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

View File

@@ -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;
/**

View File

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