Merge branch 'develop' into feature/knowledgeBase_yjp

This commit is contained in:
yujiangping
2026-01-12 17:53:22 +08:00
231 changed files with 18309 additions and 4060 deletions

View File

@@ -134,6 +134,77 @@ export const getEmotionSuggestions = (group_id: string) => {
export const analyticsRefresh = (end_user_id: string) => {
return request.post('/memory-storage/analytics/generate_cache', { end_user_id })
}
// 遗忘
export const getForgetStats = (group_id: string) => {
return request.get(`/memory/forget/stats`, { group_id })
}
// 隐性记忆-偏好
export const getImplicitPreferences = (end_user_id: string) => {
return request.get(`/memory/implicit-memory/preferences/${end_user_id}`)
}
// 隐性记忆-核心特质
export const getImplicitPortrait = (end_user_id: string) => {
return request.get(`/memory/implicit-memory/portrait/${end_user_id}`)
}
// 隐性记忆-兴趣领域分布
export const getImplicitInterestAreas = (end_user_id: string) => {
return request.get(`/memory/implicit-memory/interest-areas/${end_user_id}`)
}
// 隐性记忆-用户习惯分析
export const getImplicitHabits = (end_user_id: string) => {
return request.get(`/memory/implicit-memory/habits/${end_user_id}`)
}
// 短期记忆
export const getShortTerm = (end_user_id: string) => {
return request.get(`/memory/short/short_term`, { end_user_id })
}
// 感知记忆-视觉记忆
export const getPerceptualLastVisual = (end_user: string) => {
return request.get(`/memory/perceptual/${end_user}/last_visual`)
}
// 感知记忆-音频记忆
export const getPerceptualLastListen = (end_user: string) => {
return request.get(`/memory/perceptual/${end_user}/last_listen`)
}
// 感知记忆-文本记忆
export const getPerceptualLastText = (end_user: string) => {
return request.get(`/memory/perceptual/${end_user}/last_text`)
}
// 感知记忆-感知记忆时间线
export const getPerceptualTimeline = (end_user: string) => {
return request.get(`/memory/perceptual/${end_user}/timeline`)
}
// 情景记忆-总览
export const getEpisodicOverview = (data: { end_user_id: string; time_range: string; episodic_type: string; } ) => {
return request.post(`/memory-storage/classifications/episodic-memory`, data)
}
export const getEpisodicDetail = (data: { end_user_id: string; summary_id: string; } ) => {
return request.post(`/memory-storage/classifications/episodic-memory-details`, data)
}
// 关系演化
export const getRelationshipEvolution = (data: { id: string; label: string; } ) => {
return request.get(`/memory-storage/memory_space/relationship_evolution`, data)
}
// 共同记忆时间线
export const getTimelineMemories = (data: { id: string; label: string; }) => {
return request.get(`/memory-storage/memory_space/timeline_memories`, data)
}
export const getExplicitMemory = (end_user_id: string) => {
return request.post(`/memory-storage/classifications/explicit-memory`, { end_user_id })
}
export const getExplicitMemoryDetails = (data: { end_user_id: string, memory_id: string; }) => {
return request.post(`/memory-storage/classifications/explicit-memory-details`, data)
}
export const getConversations = (end_user: string) => {
return request.get(`/memory/work/${end_user}/conversations`)
}
export const getConversationMessages = (end_user: string, conversation_id: string) => {
return request.get(`/memory/work/${end_user}/messages`, { conversation_id })
}
export const getConversationDetail = (end_user: string, conversation_id: string) => {
return request.get(`/memory/work/${end_user}/detail`, { conversation_id })
}
/*************** end 用户记忆 相关接口 ******************************/

View File

@@ -1,5 +1,6 @@
import { request } from '@/utils/request'
import type { AiPromptForm } from '@/views/ApplicationConfig/types'
import { handleSSE, type SSEMessage } from '@/utils/stream'
export const createPromptSessions = () => {
return request.post(`/prompt/sessions`)
@@ -7,6 +8,6 @@ export const createPromptSessions = () => {
export const getPrompt = (session_id: string) => {
return request.get(`/prompt/sessions/${session_id}`)
}
export const updatePromptMessages = (session_id: string, data: AiPromptForm) => {
return request.post(`/prompt/sessions/${session_id}/messages`, data)
export const updatePromptMessages = (session_id: string, data: AiPromptForm, onMessage?: (data: SSEMessage[]) => void) => {
return handleSSE(`/prompt/sessions/${session_id}/messages`, data, onMessage)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>编组 5</title>
<g id="V1.0版" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="红熊空间-记忆库-用户记忆-详情" transform="translate(-1212, -359)" stroke="#155EEF">
<g id="记忆洞察" transform="translate(1044, 79)">
<g id="编组-5" transform="translate(168, 280)">
<polyline id="路径" points="12.5 7.5 15 10 12.5 12.5"></polyline>
<line x1="15" y1="10" x2="2.5" y2="10" id="路径-2"></line>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 775 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>下拉备份</title>
<g id="V1.1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="红熊空间-记忆管理-交互" transform="translate(-210, -24)" stroke="#212332">
<g id="编组-3" transform="translate(20, 20)">
<g id="下拉备份" transform="translate(198, 12) scale(1, -1) translate(-198, -12)translate(190, 4)">
<rect id="矩形" opacity="0.196382068" fill-rule="nonzero" x="0.5" y="0.5" width="15" height="15" rx="4"></rect>
<polyline id="路径" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" points="11 6.5 8 9.5 5 6.5"></polyline>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 925 B

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>查看</title>
<g id="V1.1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="红熊空间-记忆管理-个人记忆-2" transform="translate(-245, -141)" stroke="#5B6167">
<g id="实体详情备份" transform="translate(62, 125)">
<g id="查看" transform="translate(183, 16)">
<g id="编组-22" transform="translate(2, 2)">
<path d="M0,4 L0,1 C0,0.44771525 0.44771525,1.11022302e-16 1,0 L4,0 L4,0" id="路径"></path>
<path d="M8,4 L8,1 C8,0.44771525 8.44771525,1.11022302e-16 9,0 L12,0 L12,0" id="路径备份-2" transform="translate(10, 2) scale(-1, 1) translate(-10, -2)"></path>
<circle id="椭圆形" cx="8" cy="8" r="3"></circle>
<path d="M0,12 L0,9 C0,8.44771525 0.44771525,8 1,8 L4,8 L4,8" id="路径备份" transform="translate(2, 10) scale(1, -1) translate(-2, -10)"></path>
<line x1="10.1926589" y1="10.2894584" x2="11.6795155" y2="11.7850158" id="路径-10"></line>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>查看</title>
<g id="V1.1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="红熊空间-记忆管理-个人记忆-2" transform="translate(-245, -141)" stroke="#5B6167">
<g id="实体详情备份" transform="translate(62, 125)">
<g id="查看" transform="translate(183, 16)">
<g id="编组-22" transform="translate(2, 2)">
<path d="M0,4 L0,1 C0,0.44771525 0.44771525,1.11022302e-16 1,0 L4,0 L4,0" id="路径"></path>
<path d="M8,4 L8,1 C8,0.44771525 8.44771525,1.11022302e-16 9,0 L12,0 L12,0" id="路径备份-2" transform="translate(10, 2) scale(-1, 1) translate(-10, -2)"></path>
<circle id="椭圆形" cx="8" cy="8" r="3"></circle>
<path d="M0,12 L0,9 C0,8.44771525 0.44771525,8 1,8 L4,8 L4,8" id="路径备份" transform="translate(2, 10) scale(1, -1) translate(-2, -10)"></path>
<line x1="10.1926589" y1="10.2894584" x2="11.6795155" y2="11.7850158" id="路径-10"></line>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 568 B

View File

@@ -0,0 +1,16 @@
import { useTranslation } from 'react-i18next'
import LoadingIcon from '@/assets/images/empty/pageLoading.png'
import Empty from './index'
const PageLoading = ({ size = [240, 210] }: { size?: number | number[] }) => {
const { t } = useTranslation()
return (
<Empty
url={LoadingIcon}
title={t('empty.loadingEmpty')}
subTitle={t('empty.loadingEmptyDesc')}
size={size}
className="rb:h-full"
/>
)
}
export default PageLoading;

View File

@@ -3,23 +3,27 @@ import { Tag } from 'antd';
import clsx from 'clsx';
interface StatusTagProps {
status: 'success' | 'error' | 'warning',
status: 'success' | 'error' | 'warning' | 'default' | 'lightBlue' | 'purple',
text: string;
}
const Colors = {
success: 'rb:bg-[#369F21]',
error: 'rb:bg-[#FF5D34]',
warning: 'rb:bg-[#FF8A4C]',
default: 'rb:bg-[#155EEF]',
lightBlue: 'rb:bg-[#4DA8FF]',
purple: 'rb:bg-[#9C6FFF]'
}
const StatusTag: FC<StatusTagProps> = ({
status,
text
}) => {
console.log('status', status)
return (
<Tag style={{ backgroundColor: '#fff', borderColor: '#DFE4ED' }}>
<span className='rb:flex rb:items-center rb:text-[#5B6167] rb:pl-[1px] rb:pr-[1px]'>
<span className={clsx(' rb:w-[5px] rb:h-[5px] rb:rounded-full rb:mr-[4px]', Colors[status])}></span>
<span className='rb:flex rb:items-center rb:text-[#5B6167] rb:pl-px rb:pr-px'>
<span className={clsx(' rb:w-1.25 rb:h-1.25 rb:rounded-full rb:mr-1', Colors[status])}></span>
{ text }
</span>
</Tag>

View File

@@ -1147,10 +1147,10 @@ export const en = {
promptEmpty: 'Describe your use case on the left, and the orchestration preview will be displayed here.',
master: 'Supervisor Mode',
master_agent: 'Supervisor Mode',
master_agentDesc: 'Unified scheduling and management by the main Agent, with sub-Agents executing tasks assigned by the supervisor, suitable for scenarios requiring centralized control.',
handoffs: 'Collaboration Mode',
handoffsDesc: 'Multiple Agents collaborate equally, autonomously coordinating according to task requirements, suitable for complex scenarios requiring flexible interaction.',
supervisor: 'Supervisor Mode',
supervisorDesc: 'Unified scheduling and management by the main Agent, with sub-Agents executing tasks assigned by the supervisor, suitable for scenarios requiring centralized control.',
collaboration: 'Collaboration Mode',
collaborationDesc: 'Multiple Agents collaborate equally, autonomously coordinating according to task requirements, suitable for complex scenarios requiring flexible interaction.',
masterConfig: 'Supervisor Configuration',
orchestrationMode: 'Task Assignment Strategy',
conditional: 'Intelligent Assignment',
@@ -1160,6 +1160,8 @@ export const en = {
merge: 'Complete Aggregation',
vote: 'Key Information Extraction',
priority: 'Structured Integration',
addTool: 'Add Tool',
tool: 'Tool',
},
userMemory: {
userMemory: 'User Memory',
@@ -1204,10 +1206,6 @@ export const en = {
nodeStatistics: 'Memory Classification',
total: 'Total',
Chunk: 'Long-term Memory',
MemorySummary: 'Episodic Memory',
Statement: 'Emotional Memory',
ExtractedEntity: 'Short-term Memory',
PERCEPTUAL_MEMORY: 'Perceptual Memory',
WORKING_MEMORY: 'Working Memory',
@@ -1217,6 +1215,7 @@ export const en = {
IMPLICIT_MEMORY: 'Implicit Memory',
EMOTIONAL_MEMORY: 'Emotional Memory',
EPISODIC_MEMORY: 'Episodic Memory',
FORGETTING_MANAGEMENT: 'Forgetting Management',
endUserProfile: 'Core Profile',
editEndUserProfile: 'Edit',
@@ -1234,6 +1233,33 @@ export const en = {
key_findings: 'Key Findings',
behavior_pattern: 'Behavior Pattern',
growth_trajectory: 'Growth Trajectory',
personality: 'Personality Traits',
core_values: 'Core Values',
Statement_emotion_keywords: 'Emotion Keywords',
Statement_emotion_type: 'Emotion Type',
Statement_emotion_subject: 'Emotion Subject',
Statement_importance_score: 'Importance Score',
ExtractedEntity_description: 'Description',
ExtractedEntity_name: 'Content',
ExtractedEntity_entity_type: 'Type',
ExtractedEntity_created_at: 'Created At',
ExtractedEntity_aliases: 'Aliases',
ExtractedEntity_connect_strngth: 'Connection Strength',
ExtractedEntity_importance_score: 'Importance Score',
associative_memory: 'Associative Memory',
unix: 'items',
completeMemory: 'Complete Memory',
relationshipEvolution: 'Relationship Evolution',
timelineMemories: 'Shared Memory Timeline',
emotionLine: 'Emotion Changes Over Time',
interaction: 'Interaction Frequency & Relationship Stages',
timelines_memory: 'All',
MemorySummary: 'Long-term Accumulation',
Statement: 'Emotional Memory',
ExtractedEntity: 'Episodic Memory',
},
space: {
createSpace: 'Create Space',
@@ -1809,12 +1835,20 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
"not_contains": 'Does Not Contain',
"startwith": 'Starts With',
"endwith": 'Ends With',
"eq": '==',
"ne": '!=',
"lt": '<',
"le": '<=',
"gt": '>',
"ge": '>=',
"eq": 'Equals',
"ne": 'Not Equals',
num: {
"eq": '=',
"ne": '',
"lt": '<',
"le": '≤',
"gt": '>',
"ge": '≥',
},
boolean: {
"eq": 'Is',
"ne": 'Is Not',
},
else_desc: 'Used to define the logic that should be executed when the if condition is not met.'
},
'http-request': {
@@ -1839,6 +1873,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
status_code: 'Status Code',
max_attempts: 'Max Retry Attempts',
retry_interval: 'Retry Interval',
errorBranch: 'Error Branch',
},
'jinja-render': {
template: 'Code',
@@ -1855,12 +1890,17 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
loop: {
cycle_vars: 'Loop Variables',
condition: 'Loop Termination Condition',
max_loop: 'Maximum Loop Count',
},
assigner: {
assignments: 'Variables',
cover: 'Overwrite',
cover: 'Override',
assign: 'Set',
clear: 'Clear'
clear: 'Clear',
add: '+=',
subtract: '-=',
multiply: '*=',
divide: '/=',
},
iteration: {
input: 'Input Variable',
@@ -1963,6 +2003,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
},
statementDetail: {
wordCloud: 'Emotion Distribution Analysis',
totalCount: 'Sample Count',
pieces: 'items',
emotionTags: 'High-Frequency Emotion Keywords',
joy: 'Joy',
@@ -2147,5 +2188,124 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
orderPayInfo: 'Payment Information',
create_time: 'Creation Time',
},
forgetDetail: {
title: 'The forgetting management system helps AI intelligently manage memory lifecycle by automatically identifying low-value memories, setting forgetting strategies, and executing regular cleanup to optimize memory storage space and improve retrieval efficiency.',
overviewTitle: 'Core Metrics Overview',
totalMemory: 'Total Memory',
MemoryHealth: 'Memory Health',
riskOfForgetting: 'Forgetting Risk',
statement_count: 'Statements',
entity_count: 'Entities',
summary_count: 'Summaries',
chunk_count: 'Chunks',
healthStatus: 'Health Status',
average: 'Average Activation Value',
threshold: 'Threshold Reference:',
unhealthy: 'Unhealthy',
healthy: 'Healthy',
low_nodes: 'Low Activation Nodes',
memoryHealthVisualization: 'Memory Health Visualization',
activationValueDistribution: 'Activation Value Distribution',
forgettingTrend: 'Forgetting Trend (Last 7 Days)',
nodes_without_activation: 'Observation Zone',
low_activation_nodes: 'Forgetting Zone',
health_nodes: 'Healthy Zone',
average_activation: 'Average Activation Value',
merged_count: 'Daily Merged Node Count',
pending_nodes: 'Risk Node Forgetting Pool',
content_summary: 'Content Summary',
node_type: 'Node Type',
last_access_time: 'Last Activation Time',
activation_value: 'Current Activation Value',
},
episodicDetail: {
title: 'Record every important scene you have truly experienced',
total_all: 'Total Episodic Memories',
all: "All",
today: 'Today',
this_week: 'This Week',
this_month: 'This Month',
conversation: "Conversation",
project_work: "Project/Work",
learning: "Learning",
decision: "Decision",
important_event: "Important Event",
titleKeywordPlaceholder: 'Search episode title or content',
curResult: 'Current Filter Results',
unix: 'items',
created: 'Occurrence Time',
episodic_type: 'Episode Type',
involved_objects: 'Involved Objects',
content_records: 'Episode Content Records',
emotion: 'Emotion and State Records',
},
implicitDetail: {
title: 'The invisible forces that shaped me',
preferences: 'My Subconscious Preferences',
preferencesDetail: 'Association Network',
portraitTitle: 'My Subconscious Portrait',
portraitSubTitle: 'Personalized insights generated by AI based on your preference tags',
portrait: 'Core Traits',
aesthetic: 'Aesthetic Driven',
creativity: 'Creative Thinking',
literature: 'Cultural Sensitivity',
technology: 'Technology Affinity',
interestAreas: 'Interest Area Distribution',
art: 'Art & Design',
music: 'Music & Culture',
tech: 'Technology & Future',
lifestyle: 'Lifestyle',
habits: 'User Habit Analysis',
habitsSubTitle: 'Habit characteristics identified based on your behavior patterns',
context_details: 'Preference Details',
supporting_evidence: 'Preference Source',
specific_examples: 'Source',
},
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.',
retrieval_number: 'Retrieval Count',
entity: 'Extracted Entities',
long_term_number: 'Long-term Candidates',
shortTermTitle: 'Deep Retrieval & Extended Answer Area',
shortTermSubTitle: 'Stores deep information retrieval performed to answer questions and the extended answers generated from it, including original questions, retrieved information, and generated answers.',
longTermTitle: 'Long-term Memory Candidate Pool',
longTermSubTitle: 'Aggregates short-term memory, filters and prepares content for storage in long-term memory. This is the "transfer station" and "filter" from short-term to long-term memory.',
answer: 'Answer',
query: 'Question',
noAnswer: 'No reply yet',
},
perceptualDetail: {
last_visual: 'Visual Perception Stream',
last_listen: 'Auditory Perception Stream',
last_text: 'Text Perception',
summary: 'Summary',
keywords: 'Keywords',
topic: 'Topic',
domain: 'Domain',
scene: 'Scene',
speaker_count: 'Number of Speakers',
section_count: 'Number of Sections',
timeLine: 'Perception Timeline',
lastInfo: 'Real-time Perception Dashboard',
},
explicitDetail: {
episodic_memories: 'Episodic Memories',
semantic_memories: 'Semantic Memories',
content: 'Core Description',
created_at: 'Created At',
emotion: 'Emotion',
core_definition: 'Core Definition',
detailed_notes: 'Detailed Notes',
},
workingDetail: {
conversationStream: 'Real-time Conversation Stream',
refresh: 'Refresh',
successfulTitle: 'Successful Experience',
question: 'Lessons Learned',
summary: 'Core Insights',
none: 'None'
}
},
};

View File

@@ -636,10 +636,10 @@ export const zh = {
promptEmpty: '在左侧描述您的用例,编排预览将在此处显示。',
master: '主管模式',
master_agent: '主管模式',
master_agentDesc: '由主 Agent 统一调度和管理,子 Agent 按照主管分配的任务执行,适合需要集中控制的场景。',
handoffs: '协作模式',
handoffsDesc: '多个 Agent 平等协作,根据任务需求自主协调配合,适合需要灵活互动的复杂场景。',
supervisor: '主管模式',
supervisorDesc: '由主 Agent 统一调度和管理,子 Agent 按照主管分配的任务执行,适合需要集中控制的场景。',
collaboration: '协作模式',
collaborationDesc: '多个 Agent 平等协作,根据任务需求自主协调配合,适合需要灵活互动的复杂场景。',
masterConfig: '主管配置',
orchestrationMode: '任务分配策略',
conditional: '智能分配',
@@ -649,6 +649,8 @@ export const zh = {
merge: '完整汇总',
vote: '关键信息提取',
priority: '结构化整合',
addTool: '添加工具',
tool: '工具',
},
// 角色管理相关翻译
role: {
@@ -1283,11 +1285,6 @@ export const zh = {
nodeStatistics: '记忆分类',
total: '总计',
Chunk: '长期记忆',
MemorySummary: '情景记忆',
Statement: '情绪记忆',
ExtractedEntity: '短期记忆',
PERCEPTUAL_MEMORY: '感知记忆',
WORKING_MEMORY: '工作记忆',
SHORT_TERM_MEMORY: '短期记忆',
@@ -1296,6 +1293,7 @@ export const zh = {
IMPLICIT_MEMORY: '隐性记忆',
EMOTIONAL_MEMORY: '情绪记忆',
EPISODIC_MEMORY: '情景记忆',
FORGETTING_MANAGEMENT: '遗忘',
endUserProfile: '核心档案',
editEndUserProfile: '编辑',
@@ -1315,6 +1313,33 @@ export const zh = {
key_findings: '关键发现',
behavior_pattern: '行为模式',
growth_trajectory: '成长轨迹',
personality: '性格特点',
core_values: '核心价值观',
Statement_emotion_keywords: '情感关键词',
Statement_emotion_type: '情感类型',
Statement_emotion_subject: '情感主体',
Statement_importance_score: '重要性评分',
ExtractedEntity_description: '描述',
ExtractedEntity_name: '内容',
ExtractedEntity_entity_type: '类型',
ExtractedEntity_created_at: '创建时间',
ExtractedEntity_aliases: '别名',
ExtractedEntity_connect_strngth: '连接强度',
ExtractedEntity_importance_score: '重要性评分',
associative_memory: '关联记忆',
unix: '个',
completeMemory: '完整记忆',
relationshipEvolution: '关系演化',
timelineMemories: '共同记忆时间线',
emotionLine: '情绪随时间变化',
interaction: '互动频率 & 关系阶段',
timelines_memory: '全部',
MemorySummary: '长期沉淀',
Statement: '情绪记忆',
ExtractedEntity: '情景记忆',
},
space: {
createSpace: '创建空间',
@@ -1909,12 +1934,20 @@ export const zh = {
"not_contains": '不包含',
"startwith": '开始是',
"endwith": '结束是',
"eq": '==',
"ne": '!=',
"lt": '<',
"le": '<=',
"gt": '>',
"ge": '>=',
"eq": '',
"ne": '不是',
num: {
"eq": '=',
"ne": '',
"lt": '<',
"le": '≤',
"gt": '>',
"ge": '≥',
},
boolean: {
"eq": '是',
"ne": '不是',
},
else_desc: '用于定义当 if 条件不满足时应执行的逻辑。'
},
'http-request': {
@@ -1939,6 +1972,7 @@ export const zh = {
status_code: '状态码',
max_attempts: '最大重试次数',
retry_interval: '重试间隔',
errorBranch: '异常分支',
},
'jinja-render': {
template: '代码',
@@ -1955,12 +1989,17 @@ export const zh = {
loop: {
cycle_vars: '循环变量',
condition: '循环终止条件',
max_loop: '最大循环次数',
},
assigner: {
assignments: '变量',
cover: '覆盖',
assign: '设置',
clear: '清空'
clear: '清空',
add: '+=',
subtract: '-=',
multiply: '*=',
divide: '/=',
},
iteration: {
input: '输入变量',
@@ -2063,6 +2102,7 @@ export const zh = {
},
statementDetail: {
wordCloud: '情感分布分析',
totalCount: '样本数',
pieces: '条',
emotionTags: '高频情绪关键词',
joy: '喜悦',
@@ -2247,5 +2287,124 @@ export const zh = {
orderPayInfo: '支付信息',
create_time: '创建时间',
},
forgetDetail: {
title: '遗忘管理系统帮助AI智能管理记忆生命周期通过自动识别低价值记忆、设置遗忘策略和执行定期清理优化记忆库存储空间提升检索效率。',
overviewTitle: '核心指标概览',
totalMemory: '记忆总量',
MemoryHealth: '记忆健康度',
riskOfForgetting: '遗忘风险',
statement_count: '陈述',
entity_count: '实体',
summary_count: '摘要',
chunk_count: '片段',
healthStatus: '健康状态',
average: '平均激活值',
threshold: '阈值参考:',
unhealthy: '不健康',
healthy: '健康',
low_nodes: '低激活节点',
memoryHealthVisualization: '记忆健康可视化',
activationValueDistribution: '激活值分布',
forgettingTrend: '遗忘趋势近7天',
nodes_without_activation: '观察区',
low_activation_nodes: '遗忘区',
health_nodes: '健康区',
average_activation: '平均激活值',
merged_count: '每日融合节点数',
pending_nodes: '风险节点遗忘池',
content_summary: '内容摘要',
node_type: '节点类型',
last_access_time: '最后激活时间',
activation_value: '当前激活值',
},
episodicDetail: {
title: '记录你真实经历过的每一个重要场景',
total_all: '情景记忆总数',
all: "全部",
today: '今天',
this_week: '本周',
this_month: '本月',
conversation: "对话",
project_work: "项目/工作",
learning: "学习",
decision: "决策",
important_event: "重要事件",
titleKeywordPlaceholder: '搜索情景标题或内容',
curResult: '当前筛选结果',
unix: '条',
created: '发生时间',
episodic_type: '情景类型',
involved_objects: '涉及对象',
content_records: '情景内容记录',
emotion: '情绪与状态记录',
},
implicitDetail: {
title: '那些塑造了我的无形力量',
preferences: '我的潜意识偏好',
preferencesDetail: '的联想网络',
portraitTitle: '我的潜意识画像',
portraitSubTitle: '基于您的偏好标签AI为您生成的个性化洞察',
portrait: '核心特质',
aesthetic: '审美驱动',
creativity: '创造性思维',
literature: '文化敏感度',
technology: '技术亲和力',
interestAreas: '兴趣领域分布',
art: '艺术与设计',
music: '音乐与文化',
tech: '科技与未来',
lifestyle: '生活方式',
habits: '用户习惯分析',
habitsSubTitle: '基于您的行为模式识别的习惯特征',
context_details: '偏好详情',
supporting_evidence: '偏好来源',
specific_examples: '来源',
},
shortTermDetail: {
title: '短期记忆是AI系统的"工作台",连接即时对话与长期知识库。通过实时捕获、深度检索、智能提取和筛选转化,将临时的非结构化信息转化为有价值的长期知识。',
retrieval_number: '检索次数',
entity: '提取实体',
long_term_number: '长期候选',
shortTermTitle: '深度检索与扩展答案区',
shortTermSubTitle: '存放为回答问题而进行的深度信息检索和由此生成的扩展答案,包含原始问题、检索信息和生成答案。',
longTermTitle: '长期记忆候选池',
longTermSubTitle: '聚合短期记忆,筛选并准备存入长期记忆的内容。这是从短时记忆到长时记忆的"中转站"和"过滤器"。',
answer: '回答',
query: '问题',
noAnswer: '暂无回复',
},
perceptualDetail: {
last_visual: '视觉感知流',
last_listen: '听觉感知流',
last_text: '文本感知',
summary: '摘要',
keywords: '关键词',
topic: '主题',
domain: '领域',
scene: '场景',
speaker_count: '对话人数',
section_count: '段落数',
timeLine: '感知时间线',
lastInfo: '实时感知仪表盘',
},
explicitDetail: {
episodic_memories: '情景记忆',
semantic_memories: '语义记忆',
content: '核心描述',
created_at: '创建时间',
emotion: '情绪',
core_definition: '核心定义',
detailed_notes: '详细笔记',
},
workingDetail: {
conversationStream: '实时对话流',
refresh: '刷新',
successfulTitle: '成功经验',
question: '踩过的坑',
summary: '核心洞察',
none: '无'
}
},
}

View File

@@ -59,6 +59,8 @@ const componentMap: Record<string, LazyExoticComponent<ComponentType<object>>> =
ApiKeyManagement: lazy(() => import('@/views/ApiKeyManagement')),
EmotionEngine: lazy(() => import('@/views/EmotionEngine')),
StatementDetail: lazy(() => import('@/views/UserMemoryDetail/pages/StatementDetail')),
ForgetDetail: lazy(() => import('@/views/UserMemoryDetail/pages/ForgetDetail')),
MemoryNodeDetail: lazy(() => import('@/views/UserMemoryDetail/pages/index')),
SelfReflectionEngine: lazy(() => import('@/views/SelfReflectionEngine')),
OrderPayment: lazy(() => import('@/views/OrderPayment')),
OrderHistory: lazy(() => import('@/views/OrderHistory')),

View File

@@ -43,7 +43,8 @@
{ "path": "/application/config/:id", "element": "ApplicationConfig" },
{ "path": "/conversation/:token", "element": "Conversation" },
{ "path": "/user-memory/neo4j/:id", "element": "Neo4jUserMemoryDetail" },
{ "path": "/statement/:id", "element": "StatementDetail" }
{ "path": "/statement/:id", "element": "StatementDetail" },
{ "path": "/user-memory/detail/:id/:type", "element": "MemoryNodeDetail" }
]
},
{

View File

@@ -18,7 +18,8 @@ import type {
Variable,
MemoryConfig,
AiPromptModalRef,
Source
Source,
ToolOption
} from './types'
import type { Model } from '@/views/ModelManagement/types'
import { getModelList } from '@/api/models';
@@ -31,6 +32,7 @@ import { memoryConfigListUrl } from '@/api/memory'
import CustomSelect from '@/components/CustomSelect'
import aiPrompt from '@/assets/images/application/aiPrompt.png'
import AiPromptModal from './components/AiPromptModal'
import ToolList from './components/ToolList'
const DescWrapper: FC<{desc: string, className?: string}> = ({desc, className}) => {
return (
@@ -47,12 +49,12 @@ const LabelWrapper: FC<{title: string, className?: string; children?: ReactNode}
</div>
)
}
const SwitchWrapper: FC<{ title: string, desc: string, name: string }> = ({ title, desc, name }) => {
const SwitchWrapper: FC<{ title: string, desc?: string, name: string | string[]; needTransition?: boolean; }> = ({ title, desc, name, needTransition = true }) => {
const { t } = useTranslation();
return (
<div className="rb:flex rb:items-center rb:justify-between">
<LabelWrapper title={t(`application.${title}`)}>
<DescWrapper desc={t(`application.${desc}`)} className="rb:mt-2" />
<LabelWrapper title={needTransition ? t(`application.${title}`) : title}>
{desc && <DescWrapper desc={needTransition ? t(`application.${desc}`) : desc} className="rb:mt-2" />}
</LabelWrapper>
<Form.Item
name={name}
@@ -100,17 +102,18 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
const [formData, setFormData] = useState<{
default_model_config_id?: string,
model_parameters?: Config['model_parameters'],
tools: ToolOption[],
} | null>(null)
const values = Form.useWatch<{
memoryEnabled: boolean;
memory_content?: string | number;
webSearch: boolean;
} & Config>([], form)
const [knowledgeConfig, setKnowledgeConfig] = useState<KnowledgeConfig>({ knowledge_bases: [] })
const [variableList, setVariableList] = useState<Variable[]>([])
const [isSave, setIsSave] = useState(false)
const initialized = useRef(false)
const [toolList, setToolList] = useState<ToolOption[]>([])
// 初始化完成标记
useEffect(() => {
@@ -139,6 +142,11 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
if (isSave) return
setIsSave(true)
}, [values])
useEffect(() => {
if (!initialized.current) return
if (isSave) return
setIsSave(true)
}, [toolList])
useEffect(() => {
getModels()
@@ -149,17 +157,21 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
setLoading(true)
getApplicationConfig(id as string).then(res => {
const response = res as Config
setData(response)
setData({
...response,
tools: Array.isArray(response.tools) ? response.tools : []
})
const { memory, tools } = response
form.setFieldsValue({
...response,
memoryEnabled: memory?.enabled || false,
memory_content: memory?.memory_content ? Number(memory?.memory_content) : undefined,
webSearch: tools?.web_search?.enabled || false,
tools: Array.isArray(tools) ? tools : []
})
setFormData({
default_model_config_id: response.default_model_config_id,
model_parameters: response.model_parameters || {},
tools: Array.isArray(tools) ? tools : []
})
if (response?.knowledge_retrieval?.knowledge_bases?.length) {
getDefaultKnowledgeList(response)
@@ -260,8 +272,9 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
// 保存Agent配置
const handleSave = (flag = true) => {
if (!isSave || !data) return Promise.resolve()
const { memoryEnabled, memory_content, webSearch, ...rest } = values
const { memoryEnabled, memory_content, ...rest } = values
const { knowledge_bases = [], ...knowledgeRest } = knowledgeConfig || {}
// 从原数据中获取memory的其他必要属性
const originalMemory = data.memory || ({} as MemoryConfig)
@@ -285,15 +298,14 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
...(item.config || {})
}))
} as KnowledgeConfig : null,
tools: {
web_search: {
enabled: webSearch,
config: {
web_search: webSearch
}
}
}
tools: toolList.map(vo => ({
tool_id: vo.tool_id,
operation: vo.operation,
enabled: vo.enabled
}))
}
console.log('params', rest, params)
return new Promise((resolve, reject) => {
saveAgentConfig(data.app_id, params)
@@ -342,6 +354,7 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
const updatePrompt = (value: string) => {
form.setFieldValue('system_prompt', value)
}
return (
<>
{loading && <Spin fullscreen></Spin>}
@@ -410,14 +423,12 @@ const Agent = forwardRef<AgentRef>((_props, ref) => {
data={data?.variables}
onUpdate={setVariableList}
/>
{/* 工具配置 */}
<Card title={t('application.toolConfiguration')}>
<Space size={24} direction='vertical' style={{ width: '100%' }}>
<SwitchWrapper title="webSearch" desc="webSearchDesc" name="webSearch" />
{/* <SwitchWrapper title="codeExecutor" desc="codeExecutorDesc" name="codeExecutor" />
<SwitchWrapper title="imageGeneration" desc="imageGenerationDesc" name="imageGeneration" /> */}
</Space>
</Card>
<ToolList
data={data?.tools || []}
onUpdate={setToolList}
/>
</Space>
</Form>
</Col>

View File

@@ -42,7 +42,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
const handleSave = (flag = true) => {
if (!data) return Promise.resolve()
if (!values.default_model_config_id) {
if (!values.default_model_config_id && values.orchestration_mode === 'supervisor') {
message.warning(t('common.selectPlaceholder', { title: t('application.model') }))
return Promise.resolve()
}
@@ -138,17 +138,16 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
</div>
<Form form={form} layout="vertical">
<Space size={20} direction="vertical" style={{width: '100%'}}>
<Card title={t('application.handoffs')}>
<Card title={t('application.collaboration')}>
<Form.Item
name={['execution_config', 'routing_mode']}
name="orchestration_mode"
noStyle
>
<RadioGroupCard
options={['master_agent', 'handoffs'].map((type) => ({
options={['supervisor', 'collaboration'].map((type) => ({
value: type,
label: t(`application.${type}`),
labelDesc: t(`application.${type}Desc`),
disabled: type === 'handoffs'
}))}
allowClear={false}
/>
@@ -192,7 +191,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
))}
</Card>
<Card title={t('application.masterConfig')}>
{values?.orchestration_mode !== 'collaboration' && <Card title={t('application.masterConfig')}>
<Form.Item
label={t('application.model')}
required={true}
@@ -218,11 +217,11 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
</Row>
</Form.Item>
<Form.Item
name="orchestration_mode"
name={['execution_config',"sub_agent_execution_mode"]}
label={t('application.orchestrationMode')}
>
<Select
options={['conditional', 'sequential', 'parallel'].map((type) => ({
options={['sequential', 'parallel'].map((type) => ({
value: type,
label: t(`application.${type}`),
}))}
@@ -239,7 +238,7 @@ const Cluster = forwardRef<ClusterRef>((_props, ref) => {
}))}
/>
</Form.Item>
</Card>
</Card>}
</Space>
</Form>
</Col>

View File

@@ -16,6 +16,8 @@ import ConversationEmptyIcon from '@/assets/images/conversation/conversationEmpt
import type { ChatItem } from '@/components/Chat/types'
import CustomSelect from '@/components/CustomSelect'
import AiPromptVariableModal from './AiPromptVariableModal'
import { type SSEMessage } from '@/utils/stream'
import Editor from './Editor'
interface AiPromptModalProps {
refresh: (value: string) => void;
@@ -35,7 +37,8 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
const [variables, setVariables] = useState<string[]>([])
const [promptSession, setPromptSession] = useState<string | null>(null)
const aiPromptVariableModalRef = useRef<AiPromptVariableModalRef>(null)
const currentPromptRef = useRef<any>(null)
const editorRef = useRef<any>(null)
const currentPromptValueRef = useRef<string>('')
const values = Form.useWatch([], form)
@@ -78,16 +81,55 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
setChatList(prev => {
return [...prev, { role: 'user', content: messageContent}]
})
form.setFieldsValue({ message: undefined })
updatePromptMessages(promptSession, values)
.then(res => {
const response = res as { prompt: string; desc: string; variables: string[] }
form.setFieldsValue({ current_prompt: response.prompt })
setChatList(prev => {
return [...prev, { role: 'assistant', content: response.desc }]
})
setVariables(response.variables)
form.setFieldsValue({ message: undefined, current_prompt: undefined })
const handleStreamMessage = (data: SSEMessage[]) => {
data.map(item => {
const { content, desc, variables } = item.data as { content: string; desc: string; variables: string[] };
switch (item.event) {
case 'start':
currentPromptValueRef.current = ''
if (editorRef.current?.clear) {
editorRef.current.clear();
}
break;
case 'message':
if (content) {
currentPromptValueRef.current += content;
if (editorRef.current?.appendText) {
editorRef.current.appendText(content);
editorRef.current.scrollToBottom();
} else {
form.setFieldsValue({ current_prompt: currentPromptValueRef.current })
}
}
if (desc) {
setChatList(prev => {
return [...prev, { role: 'assistant', content: desc }]
})
}
if (variables) {
setVariables(variables)
}
break;
case 'end':
setLoading(false)
// 流结束时同步表单值
form.setFieldsValue({ current_prompt: currentPromptValueRef.current })
break
}
})
};
updatePromptMessages(promptSession, values, handleStreamMessage)
// .then(res => {
// const response = res as { prompt: string; desc: string; variables: string[] }
// form.setFieldsValue({ current_prompt: response.prompt })
// setChatList(prev => {
// return [...prev, { role: 'assistant', content: response.desc }]
// })
// setVariables(response.variables)
// })
.finally(() => {
setLoading(false)
})
@@ -101,18 +143,8 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
aiPromptVariableModalRef.current?.handleOpen()
}
const handleVariableApply = (value: string) => {
const textArea = currentPromptRef.current?.resizableTextArea?.textArea
if (textArea) {
const cursorPosition = textArea.selectionStart
const currentValue = values.current_prompt || ''
const newValue = currentValue.slice(0, cursorPosition) + value + currentValue.slice(cursorPosition)
form.setFieldValue('current_prompt', newValue)
// 设置新的光标位置
setTimeout(() => {
textArea.focus()
textArea.setSelectionRange(cursorPosition + value.length, cursorPosition + value.length)
}, 0)
if (editorRef.current?.insertText) {
editorRef.current.insertText(value)
} else {
form.setFieldValue('current_prompt', (values.current_prompt || '') + value)
}
@@ -191,7 +223,11 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
</Col>
</Row>
<Form.Item name="current_prompt">
<Input.TextArea ref={currentPromptRef} className="rb:bg-[#FBFDFF]! rb:h-100.5!" />
<Editor
ref={editorRef}
className="rb:h-100.5 "
onChange={(value) => form.setFieldValue('current_prompt', value)}
/>
</Form.Item>
<div className="rb:grid rb:grid-cols-2 rb:gap-4 rb:mt-6">
<Button block disabled={!values?.current_prompt} onClick={handleCopy}>{t('common.copy')}</Button>

View File

@@ -0,0 +1,129 @@
import {forwardRef, useImperativeHandle } from 'react';
import clsx from 'clsx';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
import { $getSelection, $getRoot, $createParagraphNode, $createTextNode, $isParagraphNode, $isTextNode } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import InitialValuePlugin from './plugin/InitialValuePlugin'
import LineBreakPlugin from './plugin/LineBreakPlugin';
import InsertTextPlugin from './plugin/InsertTextPlugin';
export interface EditorRef {
insertText: (text: string) => void;
appendText: (text: string) => void;
clear: () => void;
scrollToBottom: () => void;
}
interface LexicalEditorProps {
className?: string;
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
height?: number;
}
const theme = {
paragraph: 'editor-paragraph',
text: {
bold: 'editor-text-bold',
italic: 'editor-text-italic',
},
};
const EditorContent = forwardRef<EditorRef, LexicalEditorProps>(({
className = '',
value,
placeholder = "请输入内容...",
onChange,
}, ref) => {
const [editor] = useLexicalComposerContext();
useImperativeHandle(ref, () => ({
insertText: (text: string) => {
editor.update(() => {
const selection = $getSelection();
if (selection) {
selection.insertText(text);
}
});
},
appendText: (text: string) => {
editor.update(() => {
const root = $getRoot();
const lastChild = root.getLastChild();
if (lastChild && $isParagraphNode(lastChild)) {
const lastTextNode = lastChild.getLastChild();
if (lastTextNode && $isTextNode(lastTextNode)) {
const currentText = lastTextNode.getTextContent();
lastTextNode.setTextContent(currentText + text);
} else {
const textNode = $createTextNode(text);
lastChild.append(textNode);
}
} else {
const paragraph = $createParagraphNode();
const textNode = $createTextNode(text);
paragraph.append(textNode);
root.append(paragraph);
}
});
},
clear: () => {
editor.update(() => {
const root = $getRoot();
root.clear();
const paragraph = $createParagraphNode();
root.append(paragraph);
});
},
scrollToBottom: () => {
const editorElement = editor.getRootElement();
if (editorElement) {
editorElement.scrollTop = editorElement.scrollHeight;
}
}
}), [editor]);
return (
<div style={{ position: 'relative' }}>
<RichTextPlugin
contentEditable={
<ContentEditable
className={clsx("rb:outline-none rb:resize-none rb:text-[14px] rb:leading-5 rb:px-4 rb:py-5 rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:overflow-auto", className)}
/>
}
placeholder={
<div className="rb:absolute rb:px-4 rb:py-5 rb:text-[14px] rb:text-[#5B6167] rb:leading-5 rb:pointer-none">
{placeholder}
</div>
}
ErrorBoundary={LexicalErrorBoundary}
/>
<LineBreakPlugin onChange={onChange} />
<InitialValuePlugin value={value} />
<InsertTextPlugin />
</div>
);
});
const Editor = forwardRef<EditorRef, LexicalEditorProps>((props, ref) => {
const initialConfig = {
namespace: 'Editor',
theme,
nodes: [],
onError: (error: Error) => {
console.error(error);
},
};
return (
<LexicalComposer initialConfig={initialConfig}>
<EditorContent {...props} ref={ref} />
</LexicalComposer>
);
});
export default Editor;

View File

@@ -0,0 +1,41 @@
import { type FC, useEffect, useRef } from 'react';
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
// 设置初始值的插件
const InitialValuePlugin: FC<{ value?: string }> = ({ value }) => {
const [editor] = useLexicalComposerContext();
const lastValueRef = useRef<string | undefined>(undefined);
useEffect(() => {
// 只有当value真正发生变化时才更新
if (lastValueRef.current !== value) {
editor.update(() => {
const root = $getRoot();
const currentText = root.getTextContent();
// 如果当前内容和新值相同,则不更新
if (currentText === (value || '')) {
return;
}
root.clear();
if (value) {
const paragraph = $createParagraphNode();
const textNode = $createTextNode(value);
paragraph.append(textNode);
root.append(paragraph);
} else {
// 当value为undefined或空时创建一个空段落
const paragraph = $createParagraphNode();
root.append(paragraph);
}
});
lastValueRef.current = value;
}
}, [editor, value]);
return null;
};
export default InitialValuePlugin

View File

@@ -0,0 +1,24 @@
import { forwardRef, useImperativeHandle } from 'react';
import { $getSelection } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import type { EditorRef } from '../index'
// 插入文本的插件
const InsertTextPlugin = forwardRef<EditorRef>((_, ref) => {
const [editor] = useLexicalComposerContext();
useImperativeHandle(ref, () => ({
insertText: (text: string) => {
editor.update(() => {
const selection = $getSelection();
if (selection) {
selection.insertText(text);
}
});
}
}), [editor]);
return null;
});
export default InsertTextPlugin;

View File

@@ -0,0 +1,24 @@
import { type FC, useEffect } from 'react';
import { $getRoot } from 'lexical';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
// 处理换行的插件
const LineBreakPlugin: FC<{ onChange?: (value: string) => void }> = ({ onChange }) => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const root = $getRoot();
const textContent = root.getTextContent();
// 将\n转换为实际换行
const processedContent = textContent.replace(/\\n/g, '\n');
onChange?.(processedContent);
});
});
}, [editor, onChange]);
return null;
};
export default LineBreakPlugin;

View File

@@ -31,10 +31,6 @@ const Knowledge: FC<{data: KnowledgeConfig; onUpdate: (config: KnowledgeConfig)
setEditConfig({ ...(data || {}) })
const knowledge_bases = [...(data.knowledge_bases || [])]
setKnowledgeList(knowledge_bases)
onUpdate(prev => ({
...prev,
knowledge_bases: knowledge_bases,
}))
}
}, [data])
@@ -47,10 +43,10 @@ const Knowledge: FC<{data: KnowledgeConfig; onUpdate: (config: KnowledgeConfig)
const handleDeleteKnowledge = (id: string) => {
const list = knowledgeList.filter(item => item.id !== id)
setKnowledgeList([...list])
onUpdate(prev => ({
...prev,
onUpdate({
...editConfig,
knowledge_bases: [...list],
}))
})
}
const handleEditKnowledge = (item: KnowledgeBase) => {
knowledgeConfigModalRef.current?.handleOpen(item)
@@ -69,10 +65,10 @@ const Knowledge: FC<{data: KnowledgeConfig; onUpdate: (config: KnowledgeConfig)
list = [...values as KnowledgeBase[]]
}
setKnowledgeList([...list])
onUpdate(prev => ({
...prev,
onUpdate({
...editConfig,
knowledge_bases: [...list],
}))
})
} else if (type === 'knowledgeConfig') {
const index = knowledgeList.findIndex(item => item.id === (values as KnowledgeBase).kb_id)
const list = [...knowledgeList]
@@ -81,18 +77,19 @@ const Knowledge: FC<{data: KnowledgeConfig; onUpdate: (config: KnowledgeConfig)
config: {...values as KnowledgeConfigForm}
}
setKnowledgeList([...list])
onUpdate(prev => ({
...prev,
onUpdate({
...editConfig,
knowledge_bases: [...list],
}))
})
} else if (type === 'rerankerConfig') {
setEditConfig(prev => ({ ...prev, ...(values as RerankerConfig) }))
onUpdate(prev => ({
...prev,
...values,
reranker_id: values.rerank_model ? values.reranker_id : undefined,
reranker_top_k: values.rerank_model ? values.reranker_top_k : undefined,
}))
const rerankerValues = values as RerankerConfig
setEditConfig(prev => ({ ...prev, ...rerankerValues }))
onUpdate({
...editConfig,
...rerankerValues,
reranker_id: rerankerValues.rerank_model ? rerankerValues.reranker_id : undefined,
reranker_top_k: rerankerValues.rerank_model ? rerankerValues.reranker_top_k : undefined,
})
}
}
return (
@@ -102,8 +99,8 @@ const Knowledge: FC<{data: KnowledgeConfig; onUpdate: (config: KnowledgeConfig)
<Button style={{padding: '0 8px', height: '24px'}} onClick={() => handleKnowledgeConfig()}>{t('application.globalConfig')}</Button>
}
>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-[12px]">
<div className="rb:font-medium rb:leading-[20px]">{t('application.associatedKnowledgeBase')}</div>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-3">
<div className="rb:font-medium rb:leading-5">{t('application.associatedKnowledgeBase')}</div>
<Button style={{padding: '0 8px', height: '24px'}} onClick={handleAddKnowledge}>+{t('application.addKnowledgeBase')}</Button>
</div>
@@ -115,21 +112,21 @@ const Knowledge: FC<{data: KnowledgeConfig; onUpdate: (config: KnowledgeConfig)
dataSource={knowledgeList}
renderItem={(item) => (
<List.Item>
<div key={item.id} className="rb:flex rb:items-center rb:justify-between rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-[8px]">
<div className="rb:font-medium rb:leading-[16px]">
<div key={item.id} className="rb:flex rb:items-center rb:justify-between rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg">
<div className="rb:font-medium rb:leading-4">
{item.name}
<Tag color={item.status === 1 ? 'success' : item.status === 0 ? 'default' : 'error'} className="rb:ml-[8px]">
<Tag color={item.status === 1 ? 'success' : item.status === 0 ? 'default' : 'error'} className="rb:ml-2">
{item.status === 1 ? t('common.enable') : item.status === 0 ? t('common.disabled') : t('common.deleted')}
</Tag>
<div className="rb:mt-[4px] rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[20px]">{t('application.contains', {include_count: item.doc_num})}</div>
<div className="rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-5">{t('application.contains', {include_count: item.doc_num})}</div>
</div>
<Space size={12}>
<div
className="rb:w-[24px] rb:h-[24px] rb:cursor-pointer rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
className="rb:w-6 rb:h-6 rb:cursor-pointer rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
onClick={() => handleEditKnowledge(item)}
></div>
<div
className="rb:w-[24px] rb:h-[24px] rb:cursor-pointer rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
className="rb:w-6 rb:h-6 rb:cursor-pointer rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={() => handleDeleteKnowledge(item.id)}
></div>
</Space>

View File

@@ -0,0 +1,149 @@
import { type FC, useRef, useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { Space, Button, List, Switch } from 'antd'
import Card from './Card'
import type {
ToolModalRef,
ToolOption
} from '../types'
import Empty from '@/components/Empty'
import ToolModal from './ToolModal'
import { getToolMethods, getToolDetail } from '@/api/tools'
const ToolList: FC<{ data: ToolOption[]; onUpdate: (config: ToolOption[]) => void}> = ({data, onUpdate}) => {
const { t } = useTranslation()
const toolModalRef = useRef<ToolModalRef>(null)
const [toolList, setToolList] = useState<ToolOption[]>([])
useEffect(() => {
if (data) {
const processedData = data.map(async (item) => {
if (!item.label && item.tool_id) {
try {
const [toolDetail, methods] = await Promise.all([
getToolDetail(item.tool_id),
getToolMethods(item.tool_id)
])
switch ((toolDetail as any).tool_type) {
case 'mcp':
const mcpFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
return {
...item,
label: mcpFilterItem?.description,
method_id: mcpFilterItem?.method_id,
value: mcpFilterItem?.name,
description: mcpFilterItem?.description,
parameters: mcpFilterItem?.parameters
}
break
case 'builtin':
if ((methods as any[]).length > 1) {
const builtinFilterItem = (methods as any[]).find(vo => vo.name === item.operation)
return {
...item,
label: builtinFilterItem?.description,
method_id: builtinFilterItem?.method_id,
value: builtinFilterItem?.name,
description: builtinFilterItem?.description,
parameters: builtinFilterItem?.parameters
}
}
return {
...item,
label: (methods as any[])[0]?.description,
method_id: (methods as any[])[0]?.method_id,
value: (methods as any[])[0]?.name,
description: (methods as any[])[0]?.description,
parameters: (methods as any[])[0]?.parameters
}
break
default:
const customFilterItem = (methods as any[]).find(vo => vo.method_id === item.operation)
return {
...item,
label: customFilterItem?.name,
method_id: customFilterItem?.method_id,
value: customFilterItem?.name,
description: customFilterItem?.description,
parameters: customFilterItem?.parameters
}
}
} catch (error) {
return item
}
}
return item
})
Promise.all(processedData).then(setToolList)
}
}, [data])
console.log('toolList', toolList)
const handleAddTool = () => {
toolModalRef.current?.handleOpen()
}
const updateTools = (tool: ToolOption) => {
const list = [...toolList, tool]
setToolList(list)
onUpdate(list)
}
const handleDeleteTool = (index: number) => {
const list = toolList.filter((_item, idx) => idx !== index)
setToolList([...list])
onUpdate(list)
}
const handleChangeEnabled = (index: number) => {
const list = toolList.map((item, idx) => {
if (idx === index) {
return {
...item,
enabled: !item.enabled
}
}
return item
})
setToolList([...list])
onUpdate(list)
}
return (
<Card
title={t('application.toolConfiguration')}
extra={
<Button style={{ padding: '0 8px', height: '24px' }} onClick={handleAddTool}>+{t('application.addTool')}</Button>
}
>
{toolList.length === 0
? <Empty size={88} />
:
<List
grid={{ gutter: 12, column: 1 }}
dataSource={toolList}
renderItem={(item, index) => (
<List.Item>
<div key={index} className="rb:flex rb:items-center rb:justify-between rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg">
<div className="rb:font-medium rb:leading-4">
{item.label}
</div>
<Space size={12}>
<div
className="rb:w-6 rb:h-6 rb:cursor-pointer rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={() => handleDeleteTool(index)}
></div>
<Switch checked={item.enabled} onChange={() => handleChangeEnabled(index)} />
</Space>
</div>
</List.Item>
)}
/>
}
<ToolModal
ref={toolModalRef}
refresh={updateTools}
/>
</Card>
)
}
export default ToolList

View File

@@ -0,0 +1,145 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Cascader, type CascaderProps } from 'antd';
import { useTranslation } from 'react-i18next';
import type { ToolModalRef, ToolOption } from '../types'
import RbModal from '@/components/RbModal'
import { getToolMethods, getTools } from '@/api/tools'
import type { ToolType, ToolItem } from '@/views/ToolManagement/types'
const FormItem = Form.Item;
interface ToolModalProps {
refresh: (tool: ToolOption) => void;
}
const ToolModal = forwardRef<ToolModalRef, ToolModalProps>(({
refresh,
}, ref) => {
const { t } = useTranslation();
const [visible, setVisible] = useState(false);
const [form] = Form.useForm();
const [loading, setLoading] = useState(false)
const [optionList, setOptionList] = useState<ToolOption[]>([
{ value: 'mcp', label: t('tool.mcp'), isLeaf: false },
{ value: 'builtin', label: t('tool.inner'), isLeaf: false },
{ value: 'custom', label: t('tool.custom'), isLeaf: false },
])
const [selectdTools, setSelectedTools] = useState<ToolOption[]>([])
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
form.resetFields();
setLoading(false)
setSelectedTools([])
};
const handleOpen = () => {
setVisible(true);
form.resetFields();
setSelectedTools([])
};
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form.validateFields().then(() => {
setLoading(false)
let operation: any = undefined
if (selectdTools[0].value === 'mcp' || (selectdTools[0].value === 'builtin' && selectdTools[1]?.children && selectdTools[1].children.length > 1)) {
operation = selectdTools[2].value
} else if (selectdTools[0].value === 'custom') {
operation = selectdTools[2].method_id
}
const tool = {
...selectdTools[2],
label: selectdTools[0].value === 'custom' ? selectdTools[2].label : selectdTools[2].description,
tool_id: selectdTools[1].value as string,
enabled: true
}
if (operation) {
tool.operation = operation
}
refresh(tool)
handleClose()
})
}
const loadData = (selectedOptions: ToolOption[]) => {
const targetOption = selectedOptions[selectedOptions.length - 1];
if (selectedOptions.length === 1) {
getTools({ tool_type: targetOption.value as ToolType })
.then(res => {
const response = res as ToolItem[]
targetOption.children = response.map((vo: any) => {
return {
value: vo.id,
label: vo.name,
isLeaf: response.length === 0,
}
})
setOptionList([...optionList])
})
} else {
getToolMethods(targetOption.value as string)
.then(res => {
const response = res as Array<{ method_id: string; name: string }>
targetOption.children = response.map((vo: any) => {
return {
value: vo.name,
label: vo.name,
description: vo.description,
isLeaf: true,
method_id: vo.method_id,
parameters: vo.parameters
}
})
setOptionList([...optionList])
})
}
};
const handleChange: CascaderProps<ToolOption>['onChange'] = (_value, selectedOptions) => {
console.log('selectedOptions', selectedOptions)
setSelectedTools(selectedOptions)
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
handleClose
}));
return (
<RbModal
title={t(`application.addTool`)}
open={visible}
onCancel={handleClose}
okText={t('common.save')}
onOk={handleSave}
confirmLoading={loading}
>
<Form
form={form}
layout="vertical"
>
<FormItem
name="agent_id"
label={t('application.tool')}
rules={[
{ required: true, message: t('common.pleaseSelect') },
]}
>
<Cascader
placeholder={t('common.pleaseSelect')}
options={optionList}
loadData={loadData}
onChange={handleChange}
changeOnSelect={false}
/>
</FormItem>
</Form>
</RbModal>
);
});
export default ToolModal;

View File

@@ -78,14 +78,7 @@ export interface Config extends MultiAgentConfig {
knowledge_retrieval: KnowledgeConfig | null;
memory?: MemoryConfig;
variables: Variable[];
tools: {
web_search: {
enabled: boolean;
config: {
web_search: boolean;
}
}
};
tools: ToolOption[];
is_active: boolean;
created_at: number;
updated_at: number;
@@ -95,11 +88,11 @@ export interface MultiAgentConfig {
app_id: string;
default_model_config_id?: string;
model_parameters: ModelConfig;
orchestration_mode: 'conditional' | 'sequential' | 'parallel';
sub_agents?: SubAgentItem[];
routing_rules: null;
orchestration_mode: 'supervisor' | 'collaboration';
execution_config: {
routing_mode: 'master' | 'handoffs'
sub_agent_execution_mode: 'sequential' | 'parallel';
};
aggregation_strategy: 'merge' | 'vote' | 'priority'
}
@@ -211,4 +204,31 @@ export interface AiPromptForm {
model_id?: string;
message?: string;
current_prompt?: string;
}
export interface ToolModalRef {
handleOpen: () => void;
}
export interface ToolOption {
value?: string | number | null;
label?: React.ReactNode;
description?: string;
children?: ToolOption[];
isLeaf?: boolean;
method_id?: string;
operation?: string;
parameters?: Parameter[];
tool_id?: string;
enabled?: boolean;
}
export interface Parameter {
name: string;
type: string;
description: string;
required: boolean;
default: any;
enum: null | string[];
minimum: number;
maximum: number;
pattern: null | string;
}

View File

@@ -4,10 +4,9 @@ import {
Col,
Tag,
List,
Space
Flex
} from 'antd';
import { EyeOutlined } from '@ant-design/icons';
import clsx from 'clsx'
import { useTranslation } from 'react-i18next';
import dayjs, { type Dayjs } from 'dayjs'
@@ -103,9 +102,9 @@ const Inner: React.FC<{ getStatusTag: (status: string) => ReactNode }> = ({ getS
<div className="rb:h-full rb:flex rb:flex-col rb:justify-between">
<div className="rb:text-[12px] rb:leading-4 rb:font-regular rb:text-[#5B6167]">
{t(`tool.${item.config_data.tool_class}_features`)} <br />
<Space size={4} className="rb:mt-2">
<Flex gap={4} wrap className="rb:mt-2 rb:w-full">
{InnerConfigData[item.config_data.tool_class].features.map(vo => <Tag key={vo} color="default">{ t(`tool.${vo}`) }</Tag>) }
</Space>
</Flex>
{item.config_data.tool_class === 'DateTimeTool'
? <div className="rb:mt-3 rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md">

View File

@@ -104,15 +104,15 @@ export default function UserMemory() {
return (
<div>
<Row gutter={16} className="rb:mb-[16px]">
<Row gutter={16} className="rb:mb-4">
{countList.map(key => (
<Col key={key} span={6}>
<div className="rb:bg-[#FBFDFF] rb:border-[1px] rb:border-[#DFE4ED] rb:rounded-[12px] rb:p-[18px_20px_20px_20px]">
<div className="rb:text-[28px] rb:font-extrabold rb:leading-[35px] rb:flex rb:items-center rb:justify-between rb:mb-[12px]">
<div className="rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-xl rb:p-[18px_20px_20px_20px]">
<div className="rb:text-[28px] rb:font-extrabold rb:leading-8.75 rb:flex rb:items-center rb:justify-between rb:mb-3">
{countData[key] || 0}{key === 'avgInteractionTime' ? 's' : ''}
<img className="rb:w-[24px] rb:h-[24px]" src={IconList[key]} />
<img className="rb:w-6 rb:h-6" src={IconList[key]} />
</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-[16px]">{t(`userMemory.${key}`)}</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-4">{t(`userMemory.${key}`)}</div>
</div>
</Col>
))}
@@ -140,22 +140,22 @@ export default function UserMemory() {
return (
<List.Item key={index}>
<div
className="rb:p-[20px] rb:rounded-[12px] rb:border-[1px] rb:border-[#DFE4ED] rb:cursor-pointer"
className="rb:p-5 rb:rounded-xl rb:border rb:border-[#DFE4ED] rb:cursor-pointer"
style={{
background: bgList[index % bgList.length],
}}
onClick={() => handleViewDetail(end_user.id)}
>
<div className="rb:flex rb:items-center">
<div className="rb:w-[48px] rb:h-[48px] rb:text-center rb:font-semibold rb:text-[28px] rb:leading-[48px] rb:rounded-[8px] rb:text-[#FBFDFF] rb:bg-[#155EEF]">{name[0]}</div>
<div className="rb:max-w-[calc(100%-60px)] rb:text-base rb:font-medium rb:leading-[24px] rb:ml-[12px] rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">
<div className="rb:w-12 rb:h-12 rb:text-center rb:font-semibold rb:text-[28px] rb:leading-12 rb:rounded-lg rb:text-[#FBFDFF] rb:bg-[#155EEF]">{name[0]}</div>
<div className="rb:max-w-[calc(100%-60px)] rb:text-base rb:font-medium rb:leading-6 rb:ml-3 rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">
{name || '-'}<br/>
</div>
</div>
<div className="rb:grid rb:grid-cols-1 rb:gap-[12px] rb:mt-[28px] rb:mb-[28px]">
<div className="rb:grid rb:grid-cols-1 rb:gap-3 rb:mt-7 rb:mb-7">
<div className="rb:text-center">
<div className="rb:text-[24px] rb:leading-[30px] rb:font-extrabold">{memory_num.total || 0}</div>
<div className="rb:break-words">{t(`userMemory.knowledgeEntryCount`)}</div>
<div className="rb:text-[24px] rb:leading-7.5 rb:font-extrabold">{memory_num.total || 0}</div>
<div className="rb:wrap-break-word">{t(`userMemory.knowledgeEntryCount`)}</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { type FC, useEffect, useRef, useState } from 'react'
import { type FC, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import { Row, Col, Space, Button } from 'antd'
import { useTranslation } from 'react-i18next';
@@ -25,7 +25,7 @@ const Neo4j: FC = () => {
const aboutMeRef = useRef<AboutMeRef>(null)
const handleNameUpdate = (data: { other_name?: string; id: string }) => {
setName(data.other_name ?? data.id)
setName(data.other_name && data.other_name !== '' ? data.other_name : data.id)
}
const handleRefresh = () => {

View File

@@ -5,29 +5,37 @@ import { Skeleton } from 'antd';
import RbCard from '@/components/RbCard/Card'
import Empty from '@/components/Empty';
import RbAlert from '@/components/RbAlert';
import {
getUserSummary,
} from '@/api/memory'
import type { AboutMeRef } from '../types'
interface Data {
user_summary: string;
personality: string;
core_values: string;
one_sentence: string;
[key: string]: string;
}
const AboutMe = forwardRef<AboutMeRef>((_props, ref) => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<string | null>(null)
const [data, setData] = useState<Data>({} as Data)
useEffect(() => {
if (!id) return
getData()
}, [id])
// 记忆洞察
const getData = () => {
if (!id) return
setLoading(true)
getUserSummary(id)
.then((res) => {
setData((res as { summary?: string }).summary || null)
setData((res as Data) || null)
})
.finally(() => {
setLoading(false)
@@ -40,14 +48,34 @@ const AboutMe = forwardRef<AboutMeRef>((_props, ref) => {
return (
<RbCard
title={t('userMemory.aboutMe')}
title={t('userMemory.aboutMe')}
headerClassName="rb:min-h-[46px]!"
>
{loading
? <Skeleton className="rb:mt-4" />
: data
? <div className="rb:font-regular rb:leading-5 rb:text-[#5B6167]">
{data || '-'}
</div>
: Object.keys(data).filter(key => data[key] !== null).length > 0
? <>
{data.user_summary &&
<div className="rb:font-regular rb:leading-5 rb:text-[#5B6167]">
{data.user_summary}
</div>
}
{data.personality && <>
<div className="rb:pt-4 rb:font-medium rb:leading-5 rb:mb-2">{t('userMemory.personality')}</div>
<div className="rb:font-regular rb:leading-5 rb:text-[#5B6167]">
{data.personality}
</div>
</>}
{data.core_values && <>
<div className="rb:pt-4 rb:font-medium rb:leading-5 rb:mb-2">{t('userMemory.core_values')}</div>
<div className="rb:font-regular rb:leading-5 rb:text-[#5B6167]">
{data.core_values}
</div>
</>}
{data.one_sentence &&
<RbAlert className="rb:mt-4">{data.one_sentence}</RbAlert>
}
</>
: <Empty size={88} className="rb:mt-12 rb:mb-20.25" />
}
</RbCard>

View File

@@ -0,0 +1,124 @@
import { type FC, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import ReactEcharts from 'echarts-for-react';
import Loading from '@/components/Empty/Loading'
import Empty from '@/components/Empty'
import RbCard from '@/components/RbCard/Card'
interface ActivationMetricsPieCardProps {
chartData: Array<Record<string, string | number>>;
loading: boolean;
}
const Colors = ['#155EEF', '#FFB048', '#FF5D34']
const ActivationMetricsPieCard: FC<ActivationMetricsPieCardProps> = ({ chartData, loading }) => {
const { t } = useTranslation()
const chartRef = useRef<ReactEcharts>(null);
const resizeScheduledRef = useRef(false)
useEffect(() => {
const handleResize = () => {
if (chartRef.current && !resizeScheduledRef.current) {
resizeScheduledRef.current = true
requestAnimationFrame(() => {
chartRef.current?.getEchartsInstance().resize();
resizeScheduledRef.current = false
});
}
}
const resizeObserver = new ResizeObserver(handleResize)
const chartElement = chartRef.current?.getEchartsInstance().getDom().parentElement
if (chartElement) {
resizeObserver.observe(chartElement)
}
return () => {
resizeObserver.disconnect()
}
}, [chartData])
return (
<RbCard
title={t('forgetDetail.activationValueDistribution')}
headerType="borderless"
>
{loading
? <Loading size={249} />
: !chartData || chartData.length === 0
? <Empty size={120} className="rb:mt-12 rb:mb-20.25" />
: <ReactEcharts
option={{
color: Colors,
tooltip: {
trigger: 'item',
textStyle: {
color: '#5B6167',
fontSize: 12,
width: 27,
height: 16,
},
formatter: '{d}%',
padding: [8, 5],
backgroundColor: '#FFFFFF',
borderColor: '#DFE4ED',
extraCssText: 'width: 36px; height: 36px; box-shadow: 0px 2px 4px 0px rgba(33,35,50,0.12);border-radius: 36px;'
},
legend: {
bottom: 14 ,
padding: 0,
itemGap: 24,
itemWidth: 40,
itemHeight: 12,
borderRadius: 2,
orient: 'horizontal',
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
}
},
series: [
{
name: 'Access From',
type: 'pie',
radius: ['50%', '90%'],
avoidLabelOverlap: false,
percentPrecision: 0,
padAngle: 4,
width: 200,
height: 200,
left: 143,
itemStyle: {
borderRadius: 0
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: 24,
fontWeight: 'bold',
color: '#212332',
formatter: '{d}%\n{b}',
}
},
labelLine: {
show: false
},
data: chartData
}
]
}}
style={{ height: '265px', width: '100%', minWidth: '400px' }}
notMerge={true}
lazyUpdate={true}
/>
}
</RbCard>
)
}
export default ActivationMetricsPieCard

View File

@@ -0,0 +1,169 @@
import { type FC, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import ReactEcharts from 'echarts-for-react';
import Empty from '@/components/Empty'
import Loading from '@/components/Empty/Loading'
import type { Emotion } from './GraphDetail'
interface EmotionLineProps {
chartData: Emotion[];
loading?: boolean;
}
const Colors = ['#369F21', '#155EEF', '#FF5D34']
const EmotionLine: FC<EmotionLineProps> = ({ chartData, loading }) => {
const { t } = useTranslation()
const chartRef = useRef<ReactEcharts>(null);
const getSeries = () => {
const emotionTypes = [...new Set(chartData.map(item => item.emotion_type))]
const timePoints = [...new Set(chartData.map(item => item.created_at))].sort()
return emotionTypes.map((emotionType, index) => {
const emotionData = chartData.filter(item => item.emotion_type === emotionType)
const dataMap = new Map(emotionData.map(item => [item.created_at, item.emotion_intensity]))
const seriesData = timePoints.map(time => dataMap.get(time) || 0)
return {
name: emotionType,
type: 'line',
smooth: true,
lineStyle: {
width: 3,
color: Colors[index % Colors.length]
},
itemStyle: {
color: Colors[index % Colors.length]
},
areaStyle: {
color: Colors[index % Colors.length],
opacity: 0.08
},
data: seriesData
}
})
}
return (
<>
<div>{t('userMemory.emotionLine')}</div>
{loading
? <Loading size={249} />
: !chartData || chartData.length === 0
? <Empty size={120} className="rb:mt-12 rb:mb-20.25" />
: <ReactEcharts
ref={chartRef}
option={{
color: Colors,
tooltip: {
trigger: 'axis',
extraCssText: 'box-shadow: 0px 2px 6px 0px rgba(33,35,50,0.16); border-radius: 8px;',
axisPointer: {
type: 'line',
crossStyle: {
color: '#5F6266',
},
lineStyle: {
color: '#5F6266',
}
},
formatter: function(params: any) {
let result = `${params[0].axisValue}<br/>`
params.forEach((param: any) => {
result += `${param.marker}${param.seriesName}: ${param.value}<br/>`
})
return result
}
},
legend: {
bottom: 2,
padding: 0,
itemGap: 24,
itemWidth: 40,
itemHeight: 12,
borderRadius: 2,
orient: 'horizontal',
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
}
},
grid: {
top: 16,
left: 30,
right: 36,
bottom: 48,
// containLabel: false
},
xAxis: {
type: 'category',
data: [...new Set(chartData.map(item => item.created_at))].sort(),
boundaryGap: false,
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC'
},
axisLine: {
show: true,
lineStyle: {
color: '#EBEBEB'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
},
axisTick: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
}
},
yAxis: {
type: 'value',
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC'
},
axisLine: {
show: true,
lineStyle: {
color: '#EBEBEB'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
},
axisTick: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
},
max: 1,
min: 0
},
series: getSeries()
}}
style={{ height: '265px', width: '100%', minWidth: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
}
</>
)
}
export default EmotionLine

View File

@@ -1,6 +1,8 @@
import { type FC, useEffect, useState } from 'react'
import { type FC, useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import * as echarts from 'echarts'
import 'echarts-wordcloud'
import Empty from '@/components/Empty'
import RbCard from '@/components/RbCard/Card'
@@ -13,40 +15,23 @@ interface TagList {
const EmotionTags: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const chartRef = useRef<HTMLDivElement>(null)
const chartInstance = useRef<echarts.ECharts | null>(null)
const [data, setData] = useState<TagList | null>(null)
useEffect(() => {
if (!id) return
getEmotionTagData()
}, [id])
const getEmotionTagData = () => {
if (!id) {
return
}
if (!id) return
getWordCloud(id)
.then((res) => {
setData(res as TagList)
})
}
const [visibleCount, setVisibleCount] = useState(0)
useEffect(() => {
if (!data || data?.keywords.length === 0) return
const timer = setInterval(() => {
setVisibleCount(prev => {
if (prev >= data?.keywords.length) {
clearInterval(timer)
return prev
}
return prev + 1
})
}, 200)
return () => clearInterval(timer)
}, [data?.keywords.length])
const getEmotionColor = (emotionType: string) => {
const colors: Record<string, string> = {
joy: '#52c41a',
@@ -59,6 +44,56 @@ const EmotionTags: FC = () => {
return colors[emotionType] || '#8c8c8c'
}
useEffect(() => {
if (!chartRef.current || !data?.keywords.length) return
if (chartInstance.current) {
chartInstance.current.dispose()
}
chartInstance.current = echarts.init(chartRef.current)
const wordCloudData = data.keywords.map((item) => ({
name: item.keyword,
value: item.frequency,
textStyle: {
color: getEmotionColor(item.emotion_type)
}
}))
const option = {
series: [{
type: 'wordCloud',
gridSize: 8,
sizeRange: [14, 60],
rotationRange: [-45, 45],
shape: 'pentagon',
width: '100%',
height: '100%',
textStyle: {
fontFamily: 'sans-serif',
fontWeight: 'bold'
},
emphasis: {
textStyle: {
shadowBlur: 10,
shadowColor: '#333'
}
},
data: wordCloudData
}]
}
chartInstance.current.setOption(option)
return () => {
if (chartInstance.current) {
chartInstance.current.dispose()
chartInstance.current = null
}
}
}, [data])
const emotionStats = data?.keywords.reduce((acc, item) => {
acc[item.emotion_type] = (acc[item.emotion_type] || 0) + item.frequency
return acc
@@ -68,41 +103,25 @@ const EmotionTags: FC = () => {
<RbCard
title={t('statementDetail.emotionTags')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
bodyClassName='rb:p-0! rb:pb-3! rb:relative'
headerClassName="rb:leading-[24px] rb:bg-[#F6F8FC]! rb:min-h-[46px]! rb:border-b! rb:border-b-[#DFE4ED]!"
bodyClassName="rb:p-0!"
>
{data?.keywords && data?.keywords.length > 0
? <>
<div className="rb:flex rb:flex-wrap rb:items-center rb:gap-6 rb:text-sm rb:mt-3 rb:p-3 rb:bg-[#F0F3F8]">
? <div>
<div ref={chartRef} className="rb:mt-6 rb:px-6" style={{ height: '320px', width: '100%' }} />
<div className="rb:flex rb:flex-wrap rb:items-center rb:justify-center rb:gap-10 rb:text-sm rb:mt-3 rb:p-3 rb:bg-[#F0F3F8] rb:rounded-[0_0_8px_8px]">
{Object.entries(emotionStats).map(([type, count]) => {
console.log(type)
return (
<div key={type} className="rb:flex rb:items-center rb:gap-2">
<div className="rb:w-3 rb:h-3 rb:rounded-full" style={{ backgroundColor: getEmotionColor(type) }}></div>
<span className="rb:text-gray-600">{t(`statementDetail.${type || 'neutral'}`)} ({count})</span>
<span className="rb:leading-5">{t(`statementDetail.${type || 'neutral'}`)} ({count})</span>
</div>
)
})}
</div>
<div className="rb:mt-6 rb:flex rb:items-center rb:flex-wrap rb:gap-3 rb:mb-3 rb:px-6">
{data.keywords.slice(0, visibleCount).map((item, index) => (
<div
key={index}
className="rb:flex rb:items-center rb:justify-center rb:animate-fadeIn rb:px-4 rb:py-2 rb:rounded-full rb:text-white rb:font-medium"
style={{
backgroundColor: getEmotionColor(item.emotion_type),
fontSize: `${12 + item.avg_intensity * 8}px`,
animationDelay: `${index * 200}ms`,
height: `${20 + item.avg_intensity * 20}px`,
transition: 'all 0.3s ease-in-out'
}}
>
{item.keyword}
</div>
))}
</div>
</>
: <Empty size={88} />
</div>
: <Empty size={88} className="rb:h-full" />
}
</RbCard>
)

View File

@@ -68,6 +68,7 @@ const EndUserProfile = forwardRef<EndUserProfileRef, EndUserProfileProps>(({ onD
onClick={handleEdit}
></div>
}
headerClassName="rb:min-h-[46px]!"
>
{loading
? <Skeleton />

View File

@@ -0,0 +1,100 @@
import { forwardRef, useImperativeHandle, useState, useCallback } from 'react';
import { useParams } from 'react-router-dom'
import { Descriptions, Skeleton } from 'antd';
import { useTranslation } from 'react-i18next';
import RbModal from '@/components/RbModal'
import { getExplicitMemoryDetails } from '@/api/memory'
import { formatDateTime } from '@/utils/format'
import type { ExplicitDetailModalRef, EpisodicMemory, SemanticMemory } from '../pages/ExplicitDetail'
interface Data {
memory_type: 'episodic' | 'semantic';
title: string;
content: string;
emotion: string;
created_at: number;
name: string;
core_definition: string;
detailed_notes: string;
}
const ExplicitDetailModal = forwardRef<ExplicitDetailModalRef>((_props, ref) => {
const { t } = useTranslation();
const { id } = useParams()
const [visible, setVisible] = useState(false);
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Data>({} as Data)
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
setVisible(false);
setData({} as Data)
};
const handleOpen = (vo: EpisodicMemory | SemanticMemory) => {
setLoading(true)
getExplicitMemoryDetails({
end_user_id: id as string,
memory_id: vo.id
})
.then(res => {
setVisible(true);
setData(res as Data)
})
.finally(() => {
setLoading(false)
})
};
const getEmotionColor = (emotionType: string) => {
const colors: Record<string, string> = {
joy: '#52c41a',
anger: '#ff4d4f',
sadness: '#1890ff',
fear: '#fa8c16',
neutral: '#8c8c8c',
surprise: '#722ed1'
}
return colors[emotionType] || '#8c8c8c'
}
// 暴露给父组件的方法
useImperativeHandle(ref, () => ({
handleOpen,
}));
return (
<RbModal
title={data.name || data.title}
open={visible}
footer={null}
onCancel={handleClose}
>
{loading ? <Skeleton active />
: <Descriptions column={data.memory_type === 'semantic' ? 1 : 2} classNames={{ label: 'rb:w-20' }}>
{data.emotion && <Descriptions.Item label={t('explicitDetail.emotion')}>
<div className="rb:flex rb:items-center rb:gap-2">
<div className="rb:w-3 rb:h-3 rb:rounded-full" style={{ backgroundColor: getEmotionColor(data.emotion) }}></div>
<span className="rb:text-gray-600">{t(`statementDetail.${data.emotion || 'neutral'}`)}</span>
</div>
</Descriptions.Item>}
{data.core_definition && <Descriptions.Item label={t('explicitDetail.core_definition')}>
{data.core_definition}
</Descriptions.Item>}
{data.detailed_notes && <Descriptions.Item label={t('explicitDetail.detailed_notes')}>
{data.detailed_notes}
</Descriptions.Item>}
{data.created_at && <Descriptions.Item label={t('explicitDetail.created_at')}>
{formatDateTime(data.created_at)}
</Descriptions.Item>}
{data.content && <Descriptions.Item span="filled" label={t('explicitDetail.content')}>
{data.content}
</Descriptions.Item>}
</Descriptions>
}
</RbModal>
);
});
export default ExplicitDetailModal;

View File

@@ -0,0 +1,140 @@
import { useState, forwardRef, useImperativeHandle, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Row, Col, Tabs, Space, Skeleton } from 'antd'
import { getRelationshipEvolution, getTimelineMemories } from '@/api/memory'
import type { Node, GraphDetailRef } from '../types'
import RbDrawer from '@/components/RbDrawer'
import RbCard from '@/components/RbCard/Card'
import EmotionLine from './EmotionLine'
import { formatDateTime } from '@/utils/format'
import Tag from '@/components/Tag'
import InteractionBar from './InteractionBar'
import Empty from '@/components/Empty'
export interface Emotion {
emotion_intensity: number;
emotion_type: string;
created_at: string | number;
}
export interface Interaction {
created_at: string | number;
count: number;
}
interface TimelineMemory {
text: string;
type: string;
created_at: number | string;
}
interface Timeline {
MemorySummary: TimelineMemory[];
Statement: TimelineMemory[];
ExtractedEntity: TimelineMemory[];
timelines_memory: TimelineMemory[];
}
const GraphDetail = forwardRef<GraphDetailRef>((_props, ref) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false);
const [vo, setVo] = useState<Node | null>(null)
const [loading, setLoading] = useState(false)
const [emotionData, setEmotionData] = useState<Emotion[]>([])
const [interactionData, setInteractionData] = useState<Interaction[]>([])
const [activeTab, setActiveTab] = useState('timelines_memory')
const [timelineLoading, setTimelineLoading] = useState(false)
const [timelineMemories, setTimelineMemories] = useState<Timeline>({ timelines_memory: [], MemorySummary: [], Statement: [], ExtractedEntity: []})
const handleCancel = () => {
setVo(null)
setOpen(false)
}
const handleOpen = (vo: Node) => {
setActiveTab('timelines_memory')
setOpen(true)
setVo(vo)
getRelationshipEvolutionData(vo)
getTimelineMemoriesData(vo)
}
const getRelationshipEvolutionData = (vo: Node) => {
if (!vo.id || !vo.label) return
setLoading(true)
getRelationshipEvolution({ id: vo.id as string, label: vo.label })
.then(res => {
const { emotion, interaction } = res as { emotion: Emotion[]; interaction: Interaction[] } || {}
setEmotionData(emotion)
setInteractionData(interaction)
})
.finally(() => setLoading(false))
}
const getTimelineMemoriesData = (vo: Node) => {
if (!vo.id || !vo.label) return
setTimelineLoading(true)
getTimelineMemories({ id: vo.id as string, label: vo.label })
.then(res => {
setTimelineMemories(res as Timeline)
})
.finally(() => setTimelineLoading(false))
}
useImperativeHandle(ref, () => ({
handleOpen,
}));
const activeContent = useMemo(() => {
return timelineMemories[activeTab as keyof Timeline] || []
}, [activeTab, timelineMemories])
return (
<RbDrawer
title={vo?.name}
open={open}
onClose={handleCancel}
width={1000}
>
<div className="rb:text-[16px] rb:font-medium rb:leading-5.5 rb:mb-3">{t('userMemory.relationshipEvolution')}</div>
<RbCard>
<Row gutter={16}>
<Col span={12}>
<EmotionLine chartData={emotionData} loading={loading} />
</Col>
<Col span={12}>
<InteractionBar chartData={interactionData} loading={loading} />
</Col>
</Row>
</RbCard>
<div className="rb:text-[16px] rb:font-medium rb:leading-5.5 rb:mb-3 rb:mt-6">{t('userMemory.timelineMemories')}</div>
<RbCard>
<Tabs
activeKey={activeTab}
items={['timelines_memory', 'ExtractedEntity', 'Statement', 'MemorySummary'].map(key => ({
label: t(`userMemory.${key}`),
key
}))}
onChange={(key: string) => setActiveTab(key)}
/>
{timelineLoading
? <Skeleton active />
: !activeContent || activeContent.length === 0
? <Empty size={120} className="rb:mt-12 rb:mb-20.25" />
: <Space size={16} direction="vertical" className="rb:w-full">
{activeContent.map((vo, index) => (
<RbCard
key={index}
headerType="borderL"
headerClassName="rb:before:bg-[#155EEF]!"
title={vo.text}
>
<div className="rb:text-[#A8A9AA] rb:text-[12px] rb:leading-4">{formatDateTime(vo.created_at)}</div>
<Tag className="rb:mt-2">{vo.type}</Tag>
</RbCard>
))}
</Space>
}
</RbCard>
</RbDrawer>
)
})
export default GraphDetail

View File

@@ -0,0 +1,84 @@
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, Space, Progress } from 'antd';
import RbCard from '@/components/RbCard/Card'
import Empty from '@/components/Empty'
import {
getImplicitHabits,
} from '@/api/memory'
interface HabitsItem {
habit_description: string;
frequency_pattern: string;
time_context: string;
confidence_level: number;
supporting_summaries: string[];
first_observed: string;
last_observed: string;
is_current: boolean;
specific_examples: string[];
}
const Habits: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<HabitsItem[]>([])
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
if (!id) return
setLoading(true)
getImplicitHabits(id).then((res) => {
const response = res as HabitsItem[]
setData(response)
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
return (
<>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('implicitDetail.habits')}</div>
<div className="rb:my-3 rb:text-[#5B6167] rb:leading-5">{t('implicitDetail.habitsSubTitle')}</div>
<RbCard>
{loading
? <Skeleton active />
: data.length === 0
? <Empty size={88} />
: <Space size={12} direction="vertical" className="rb:w-full!">
{data.map((vo, voIdx) => (
<div key={voIdx} className="rb:leading-5 rb:shadow-[inset_3px_0px_0px_0px_#155EEF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:p-3">
<div className="rb:flex rb:items-center rb:justify-between">
<div>
<div className="rb:mb-1">{vo.habit_description}</div>
<div className="rb:mb-1 rb:text-[#5B6167]">{vo.time_context}</div>
</div>
<div className="rb:text-[24px] rb:font-medium">{vo.confidence_level}%</div>
</div>
{vo.specific_examples.length > 0 && <>
<div className="rb:mt-3 rb:mb-2">{t('implicitDetail.specific_examples')}</div>
<div className="rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:p-3">
{vo.specific_examples.map((item, index) => (
<div key={index} className="rb:text-[#5B6167] rb:text-[12px] rb:mt-1">- {item}</div>
))}
</div>
</>}
<Progress percent={vo.confidence_level} showInfo={false} className="rb:mt-3" />
</div>
))}
</Space>
}
</RbCard>
</>
)
}
export default Habits

View File

@@ -1,7 +1,8 @@
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Progress } from 'antd'
import { Row, Col, Progress } from 'antd'
import ReactEcharts from 'echarts-for-react'
import Empty from '@/components/Empty'
import RbCard from '@/components/RbCard/Card'
@@ -58,37 +59,95 @@ const Health: FC = () => {
<RbCard
title={t('statementDetail.health')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
headerClassName="rb:leading-[24px] rb:bg-[#F6F8FC]! rb:min-h-[46px]! rb:border-b! rb:border-b-[#DFE4ED]!"
bodyClassName="rb:px-[28px]! rb:py-[16px]!"
>
{health?.health_score && health?.health_score > 0
? <>
<div className="rb:flex rb:justify-center rb:items-center">
<Progress
size={250}
type="circle"
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
percent={health.health_score}
format={(percent) => `${percent}(${health.level})`}
/>
</div>
<Row gutter={59}>
<Col span={12}>
<div className="rb:flex rb:justify-center rb:items-center">
<ReactEcharts
option={{
series: [{
type: 'pie',
radius: ['65%', '80%'],
center: ['50%', '50%'],
startAngle: 90,
data: [
{
value: health.health_score,
name: health.level,
itemStyle: {
color: '#155EEF',
borderRadius: [10, 10, 10, 10]
}
},
{
value: 100 - health.health_score,
name: '',
itemStyle: {
color: '#DFE4ED',
borderRadius: [10, 10, 10, 10]
}
}
],
label: {
show: true,
position: 'center',
formatter: '{score|' + health.health_score + '}\n{level|' + health.level + '}',
rich: {
score: {
fontSize: 36,
fontWeight: 'bold',
color: '#212332',
lineHeight: 36
},
level: {
fontSize: 14,
color: '#5B6167',
lineHeight: 20
}
}
},
labelLine: { show: false },
emphasis: { disabled: true },
itemStyle: {
borderRadius: 10
}
}]
}}
style={{ height: '200px', width: '200px' }}
/>
</div>
</Col>
<Col span={12}>
{health.dimensions && <div className="rb:space-y-7">
<div>
<div className="rb:flex rb:items-center rb:justify-between rb:text-[#5B6167]">
{t('statementDetail.positivity_rate')}
<div className="rb:text-[12px] rb:text-[#155EEF] rb:font-medium">{health.dimensions.positivity_rate.score}%</div>
</div>
<Progress strokeColor="#155EEF" percent={health.dimensions.positivity_rate.score} showInfo={false} />
</div>
<div>
<div className="rb:flex rb:items-center rb:justify-between rb:text-[#5B6167]">
{t('statementDetail.stability')}
<div className="rb:text-[12px] rb:text-[#155EEF] rb:font-medium">{health.dimensions.stability.score}%</div>
</div>
<Progress strokeColor="#155EEF" percent={health.dimensions.stability.score} showInfo={false} />
</div>
<div>
<div className="rb:flex rb:items-center rb:justify-between rb:text-[#5B6167]">
{t('statementDetail.resilience')}
<div className="rb:text-[12px] rb:text-[#155EEF] rb:font-medium">{health.dimensions.resilience.score}%</div>
</div>
<Progress strokeColor="#155EEF" percent={health.dimensions.resilience.score} showInfo={false} />
</div>
</div>}
</Col>
</Row>
{health.dimensions && <>
<div className="rb:flex rb:items-center rb:justify-between rb:mt-6">
<div className="rb:w-40 rb:mr-3">{t('statementDetail.positivity_rate')}</div>
<Progress className="rb:w-[calc(100%-180px)]" percent={health.dimensions.positivity_rate.score} />
</div>
<div className="rb:flex rb:items-center rb:gap-3 rb:mt-3">
<div className="rb:w-40 rb:mr-3">{t('statementDetail.stability')}</div>
<Progress className="rb:w-[calc(100%-180px)]" percent={health.dimensions.stability.score} />
</div>
<div className="rb:flex rb:items-center rb:gap-3 rb:mt-3">
<div className="rb:w-40 rb:mr-3">{t('statementDetail.resilience')}</div>
<Progress className="rb:w-[calc(100%-180px)]" percent={health.dimensions.resilience.score} />
</div>
</>}
</>
: <Empty size={88} className="rb:h-full" />
}

View File

@@ -0,0 +1,119 @@
import { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import ReactEcharts from 'echarts-for-react'
import Empty from '@/components/Empty'
import Loading from '@/components/Empty/Loading'
import type { Interaction } from './GraphDetail'
interface InteractionBarProps {
chartData: Interaction[];
loading?: boolean;
}
const Colors = ['#155EEF', '#369F21', '#FF5D34']
const InteractionBar: FC<InteractionBarProps> = ({ chartData, loading }) => {
const { t } = useTranslation()
const series = [{
name: 'Interaction Count',
type: 'bar',
data: chartData.map(item => item.count)
}]
return (
<>
<div>{t('userMemory.interaction')}</div>
{loading
? <Loading size={249} />
: !chartData || chartData.length === 0
? <Empty size={120} className="rb:mt-12 rb:mb-20.25" />
: <ReactEcharts
option={{
color: Colors,
tooltip: {
trigger: 'axis',
extraCssText: 'box-shadow: 0px 2px 6px 0px rgba(33,35,50,0.16); border-radius: 8px;',
axisPointer: {
type: 'line',
crossStyle: {
color: '#5F6266',
},
lineStyle: {
color: '#5F6266',
}
},
},
grid: {
top: 16,
left: 30,
right: 36,
bottom: 48,
// containLabel: false
},
xAxis: {
type: 'category',
data: chartData.map(item => item.created_at),
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC'
},
axisLine: {
show: true,
lineStyle: {
color: '#EBEBEB'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
},
axisTick: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
}
},
yAxis: {
type: 'value',
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC'
},
axisLine: {
show: true,
lineStyle: {
color: '#EBEBEB'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
},
axisTick: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
},
max: 1,
min: 0
},
series
}}
style={{ height: '265px', width: '100%' }}
/>
}
</>
)
}
export default InteractionBar

View File

@@ -0,0 +1,74 @@
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, Progress } from 'antd';
import RbCard from '@/components/RbCard/Card'
import {
getImplicitInterestAreas,
} from '@/api/memory'
interface Item {
category_name: string;
percentage: number;
evidence: string[];
trending_direction: string | null;
}
interface InterestAreasItem {
user_id: string;
analysis_timestamp: number | string;
total_summaries_analyzed: number;
tech: Item;
lifestyle: Item;
music: Item;
art: Item;
}
const InterestAreas: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<InterestAreasItem>({} as InterestAreasItem)
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
if (!id) return
setLoading(true)
getImplicitInterestAreas(id).then((res) => {
const response = res as InterestAreasItem
setData(response)
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
return (
<RbCard
title={t('implicitDetail.interestAreas')}
headerType="borderless"
>
{loading
? <Skeleton active />
: <div>
{(['art', 'music', 'tech', 'lifestyle'] as const).map((key) => {
return (
<div key={key} >
<div className="rb:flex rb:justify-between rb:items-center">
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mb-1">{t(`implicitDetail.${key}`)}</div>
{data[key]?.percentage ?? 0}%
</div>
<Progress percent={data[key]?.percentage || 0} showInfo={false} />
</div>
)
})}
</div>
}
</RbCard>
)
}
export default InterestAreas

View File

@@ -63,6 +63,7 @@ const InterestDistribution: FC = () => {
return (
<RbCard
title={t('userMemory.interestDistribution')}
headerClassName="rb:min-h-[46px]!"
>
{loading
? <Loading size={249} />

View File

@@ -50,6 +50,7 @@ const MemoryInsight = forwardRef<MemoryInsightRef>((_props, ref) => {
<RbCard
title={t('userMemory.memoryInsight')}
headerType="borderless"
headerClassName="rb:min-h-[46px]!"
>
{loading
? <Skeleton />

View File

@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next'
import { useParams, useNavigate } from 'react-router-dom'
import { Skeleton } from 'antd';
import RbCard from '@/components/RbCard/Card'
import Empty from '@/components/Empty';
import {
getNodeStatistics,
} from '@/api/memory'
@@ -15,11 +14,25 @@ const BG_LIST = [
'rb:bg-[linear-gradient(316deg,rgba(21,94,239,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(316deg,rgba(54,159,33,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(314deg,rgba(156,111,255,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(314deg,rgba(255,93,52,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(180deg,rgba(156,111,255,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(180deg,rgba(21,94,239,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(180deg,rgba(54,159,33,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[]',
'rb:bg-[linear-gradient(332deg,rgba(255,93,52,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(313deg,rgba(156,111,255,0.06)_0%,rgba(251,253,255,0)_100%)]',
'rb:bg-[linear-gradient(332deg,rgba(54,159,33,0.06)_0%,rgba(251,253,255,0)_100%)]',
]
const typeList = [
{ key: 'PERCEPTUAL_MEMORY', bg: 0 },
{ key: 'WORKING_MEMORY', bg: 1 },
{ key: 'EMOTIONAL_MEMORY', bg: 2 },
{ key: 'SHORT_TERM_MEMORY', bg: 3 },
{
key: 'LONG_TERM_MEMORY',
bg: 4,
children: [
{ key: 'IMPLICIT_MEMORY' },
{ key: 'EPISODIC_MEMORY' },
{ key: 'EXPLICIT_MEMORY' }
]
},
{ key: 'FORGETTING_MANAGEMENT', bg: 5 },
]
const NodeStatistics: FC = () => {
@@ -34,8 +47,7 @@ const NodeStatistics: FC = () => {
if (!id) return
getData()
}, [id])
// 记忆洞察
const getData = () => {
if (!id) return
setLoading(true)
@@ -52,43 +64,60 @@ const NodeStatistics: FC = () => {
})
}
const handleViewDetail = (type: string) => {
switch (type) {
case 'EMOTIONAL_MEMORY':
navigate(`/statement/${id}`)
break
}
navigate(`/user-memory/detail/${id}/${type}`)
}
const renderCard = (key: string, bgIndex: number | null, isChild: boolean = false) => {
const item = data.find((item) => item.type === key)
return (
<div
className={clsx(
"rb:flex rb:flex-col rb:justify-between rb:group rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:pt-3 rb:px-4 rb:pb-5 rb:cursor-pointer",
{
'rb:h-45': !isChild,
'rb:h-31': isChild
},
typeof bgIndex === 'number' ? BG_LIST[bgIndex] : 'rb:bg-[#FBFDFF]'
)}
onClick={() => handleViewDetail(key)}
>
<div>
<div className={clsx("rb:text-[#5B6167] rb:leading-5 rb:font-regular", {
'rb:mb-2': !isChild,
'rb:mb-1': isChild
})}>
{t(`userMemory.${key}`)}
</div>
<div className="rb:w-3 rb:h-3 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/arrow_right.svg')] rb:group-hover:bg-[url('@/assets/images/userMemory/arrow_right_hover.svg')]"></div>
</div>
<div className="rb:text-[28px] rb:leading-8.75 rb:font-extrabold">{item?.count ?? 0}</div>
</div>
)
}
return (
<RbCard
title={<>{t('userMemory.nodeStatistics')} <span className="rb:text-[#5B6167] rb:font-normal!">({t('userMemory.total')}: {total})</span></>}
headerType="borderless"
headerClassName="rb:min-h-[46px]!"
>
{loading
? <Skeleton />
: data && data.length > 0
? <div className={`rb:w-full rb:grid rb:grid-cols-8 rb:gap-3`}>
{data.map((vo, index) => (
<div
key={vo.type}
className={clsx("rb:flex rb:flex-col rb:justify-between rb:group rb:border rb:border-[#DFE4ED] rb:h-45 rb:rounded-lg rb:pt-3 rb:px-4 rb:pb-5", {
'rb:cursor-pointer': vo.type === 'EMOTIONAL_MEMORY'
}, BG_LIST[index])}
onClick={() => handleViewDetail(vo.type)}
>
<div>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular">
{t(`userMemory.${vo.type}`)}
</div>
{vo.type === 'EMOTIONAL_MEMORY' && <div
className="rb:w-3 rb:h-3 rb:-ml-0.75 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/home/arrow_top_right.svg')] rb:group-hover:bg-[url('@/assets/images/home/arrow_top_right_hover.svg')]"
></div>}
? <Skeleton active />
: <div className="rb:w-full rb:grid rb:grid-cols-8 rb:gap-3">
{typeList.map((vo) => {
if (!vo.children) {
return <div key={vo.key}>{renderCard(vo.key, vo.bg)}</div>
}
return (
<div key={vo.key} className={clsx("rb:col-span-3 rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:p-3", BG_LIST[vo.bg])}>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mb-3">{t(`userMemory.${vo.key}`)}</div>
<div className="rb:grid rb:grid-cols-3 rb:gap-3">
{vo.children.map((child) => <div key={child.key}>{renderCard(child.key, null, true)}</div>)}
</div>
<div className="rb:text-[28px] rb:leading-8.75 rb:font-extrabold">{vo.count ?? 0}</div>
</div>
))}
)
})}
</div>
: <Empty size={80} />
}
}
</RbCard>
)
}

View File

@@ -9,7 +9,7 @@ const { Header } = Layout;
interface ConfigHeaderProps {
name?: string;
operation?: ReactNode;
source?: 'detail' | 'statement'
source?: 'detail' | 'node'
}
const PageHeader: FC<ConfigHeaderProps> = ({
name,

View File

@@ -0,0 +1,120 @@
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, Space, Tooltip, Image } from 'antd';
import RbCard from '@/components/RbCard/Card'
import {
getPerceptualLastVisual,
getPerceptualLastListen,
getPerceptualLastText,
} from '@/api/memory'
interface PerceptualLastInfoItem {
id: string;
file_name: string;
file_ext: string;
file_path: string;
storage_type: number;
summary: string;
keywords: string[];
topic: string;
domain: string;
created_time: number | string;
scene: string[]
speaker_count: number;
section_count: number;
}
const KEYS = {
last_visual: ['summary', 'keywords', 'topic', 'domain', 'scene'],
last_listen: ['summary', 'keywords', 'topic', 'domain', 'speaker_count'],
last_text: ['summary', 'keywords', 'topic', 'domain', 'section_count'],
}
const PerceptualLastInfo: FC<{ type: 'last_visual' | 'last_listen' | 'last_text' }> = ({ type }) => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<PerceptualLastInfoItem>({} as PerceptualLastInfoItem)
useEffect(() => {
if (!id) return
getData()
}, [id, type])
const getData = () => {
if (!id || !type) return
setLoading(true)
const request = type === 'last_visual'
? getPerceptualLastVisual(id)
: type === 'last_listen'
? getPerceptualLastListen(id)
: getPerceptualLastText(id)
request.then((res) => {
const response = res as PerceptualLastInfoItem
setData(response)
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
return (
<RbCard
title={t(`perceptualDetail.${type}`)}
headerType="borderless"
>
{loading
? <Skeleton active />
: <div>
<div className="rb:bg-[#F0F3F8] rb:h-36 rb:rounded-sm rb:flex rb:items-center rb:justify-center rb:overflow-hidden">
{data.file_path ? (
type === 'last_visual' ? (
/\.(mp4|webm|ogg|mov)$/i.test(data.file_name) ? (
<video controls className="rb:max-w-full rb:max-h-full">
<source src={data.file_path} />
</video>
) : /\.(jpg|jpeg|png|gif|webp|svg)$/i.test(data.file_name) ? (
<Image src={data.file_path} alt={data.file_name} />
// <img src={data.file_path} alt={data.file_name} className="rb:max-w-full rb:max-h-full rb:object-contain" />
) : (
<div className="rb:text-gray-500">{data.file_name}</div>
)
) : type === 'last_listen' && /\.(mp3|wav|ogg|m4a|aac)$/i.test(data.file_name) ? (
<audio controls className="rb:w-full">
<source src={data.file_path} />
</audio>
) : (
<div className="rb:text-gray-500">{data.file_name}</div>
)
) : (
<div className="rb:text-gray-400">No file</div>
)}
</div>
<Space size={4} direction="vertical" className="rb:w-full rb:mt-3">
{KEYS[type].map(key => {
const value = (data as any)[key]
return (
<div key={key} className="rb:flex rb:justify-between rb:items-center rb:gap-3">
<div className="rb:text-[#5B6167]">{t(`perceptualDetail.${key}`)}</div>
{key === 'summary' ? (
<Tooltip title={value}>
<div className="rb:flex-1 rb:text-right rb:text-ellipsis rb:overflow-hidden rb:whitespace-nowrap">
{typeof value === 'string' ? value : Array.isArray(value) ? value.join('、') : '-'}
</div>
</Tooltip>
)
: <div className="rb:flex-1 rb:text-right">
{typeof value === 'string' ? value : Array.isArray(value) ? value.join('、') : '-'}
</div>
}
</div>
)
})}
</Space>
</div>
}
</RbCard>
)
}
export default PerceptualLastInfo

View File

@@ -0,0 +1,77 @@
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, Progress } from 'antd';
import RbCard from '@/components/RbCard/Card'
import {
getImplicitPortrait,
} from '@/api/memory'
interface Item {
dimension_name: string;
percentage: number;
evidence: string[];
reasoning: string;
confidence_level: string;
}
interface PortraitItem {
user_id: string;
analysis_timestamp: number | string;
total_summaries_analyzed: number;
historical_trends: null;
creativity: Item;
aesthetic: Item;
technology: Item;
literature: Item;
}
const Portrait: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<PortraitItem>({} as PortraitItem)
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
if (!id) return
setLoading(true)
getImplicitPortrait(id).then((res) => {
const response = res as PortraitItem
setData(response)
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
return (
<RbCard
title={t('implicitDetail.portrait')}
headerType="borderless"
>
{loading
? <Skeleton active />
: <div className="rb:mt-1">
{(['aesthetic', 'creativity', 'literature', 'technology'] as const).map((key) => {
const item = data[key] as Item
return (
<div key={key}>
<div className="rb:flex rb:justify-between rb:items-center">
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mb-1">{t(`implicitDetail.${key}`)}</div>
{item?.percentage ?? 0}%
</div>
<Progress percent={item?.percentage || 0} showInfo={false} />
</div>
)
})}
</div>
}
</RbCard>
)
}
export default Portrait

View File

@@ -0,0 +1,183 @@
import { type FC, useEffect, useState, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Row, Col, Skeleton } from 'antd'
import * as echarts from 'echarts'
import 'echarts-wordcloud'
import Empty from '@/components/Empty'
import RbCard from '@/components/RbCard/Card'
import { getImplicitPreferences } from '@/api/memory'
interface PreferenceItem {
tag_name: string;
confidence_score: number;
supporting_evidence: string[];
context_details: string;
created_at: number | string;
updated_at: number | string;
conversation_references: string[];
category: string;
}
const DEFAULT_COLORS = ['#FF5D34', '#155EEF', '#9C6FFF', '#369F21', '#4DA8FF', '#FF8C00', '#32CD32', '#FF69B4', '#20B2AA', '#DDA0DD']
const generateCategoryColors = (categories: string[]) => {
const colors: Record<string, string> = {}
categories.forEach((category, index) => {
colors[category] = DEFAULT_COLORS[index % DEFAULT_COLORS.length]
})
return colors
}
const Preferences: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const chartRef = useRef<HTMLDivElement>(null)
const chartInstance = useRef<echarts.ECharts | null>(null)
const [selectedWord, setSelectedWord] = useState<number | null>(null)
const [loading, setLoading] = useState(false)
const [data, setData] = useState<PreferenceItem[]>([])
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
if (!id) {
return
}
setLoading(true)
setSelectedWord(null)
getImplicitPreferences(id)
.then((res) => {
setData(res as PreferenceItem[])
})
.finally(() => {
setLoading(false)
})
}
const uniqueCategories = [...new Set(data.map(item => item.category).filter(Boolean))]
const categoryColors = generateCategoryColors(uniqueCategories)
const getCategoryColor = (category: string) => {
return categoryColors[category] || '#4DA8FF'
}
useEffect(() => {
if (!chartRef.current || !data.length) return
if (chartInstance.current) {
chartInstance.current.dispose()
}
chartInstance.current = echarts.init(chartRef.current)
const wordCloudData = data.map((item, index) => ({
name: item.tag_name,
value: Math.round(item.confidence_score * 100),
itemIndex: index,
textStyle: {
color: getCategoryColor(item.category)
}
}))
const option = {
series: [{
type: 'wordCloud',
gridSize: 8,
sizeRange: [14, 60],
rotationRange: [-45, 45],
shape: 'pentagon',
width: '100%',
height: '100%',
textStyle: {
fontFamily: 'sans-serif',
fontWeight: 'bold'
},
emphasis: {
textStyle: {
shadowBlur: 10,
shadowColor: '#333'
}
},
data: wordCloudData
}]
}
chartInstance.current.setOption(option)
chartInstance.current.on('click', (params) => {
const clickedIndex = (params.data as any).itemIndex
if (selectedWord !== clickedIndex) {
setSelectedWord(clickedIndex)
}
// Highlight selected word without redrawing
chartInstance.current?.dispatchAction({
type: 'highlight',
dataIndex: clickedIndex
})
})
return () => {
if (chartInstance.current) {
chartInstance.current.dispose()
chartInstance.current = null
}
}
}, [data])
console.log(selectedWord, data)
const detailTitle = useMemo(() => {
return selectedWord !== null && data[selectedWord].tag_name ? <>{data[selectedWord].tag_name}{t('implicitDetail.preferencesDetail')}</> : ''
}, [selectedWord, data, t])
return (
<>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-4 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('forgetDetail.overviewTitle')}</div>
<Row gutter={16}>
<Col span={16}>
<RbCard
title={t('implicitDetail.preferences')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
bodyClassName='rb:p-0! rb:pb-3! rb:relative rb:h-[350px]'
>
{loading
? <Skeleton active className="rb:px-4" />
: data && data.length > 0
? <div ref={chartRef} className="rb:mt-6 rb:px-6" style={{ height: '350px' }} />
: <Empty size={88} className="rb:h-full" />
}
</RbCard>
</Col>
<Col span={8}>
<RbCard
title={detailTitle}
headerType="borderless"
height="100%"
bodyClassName='rb:p-3! rb:h-[326px]'
>
{selectedWord === null
? <Empty size={88} className="rb:h-full!" />
: <>
<div className="rb:leading-5 rb:mb-1 rb:font-medium">{t('implicitDetail.context_details')}</div>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular">{data[selectedWord].context_details}</div>
<div className="rb:leading-5 rb:mt-3 rb:font-medium">{t('implicitDetail.supporting_evidence')}</div>
{data[selectedWord].supporting_evidence.map((vo, index) => <div key={index} className="rb:text-[#5B6167] rb:leading-5 rb:font-regular">-{vo}</div>)}
</>
}
</RbCard>
</Col>
</Row>
</>
)
}
export default Preferences

View File

@@ -0,0 +1,191 @@
import { type FC, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import ReactEcharts from 'echarts-for-react';
import * as echarts from 'echarts';
import Empty from '@/components/Empty'
import Loading from '@/components/Empty/Loading'
import RbCard from '@/components/RbCard/Card'
interface RecentTrendsLineCardProps {
chartData: Array<Record<string, string | number>>;
seriesList: string[];
loading?: boolean;
}
const Colors = ['#155EEF', '#FF5D34']
const RecentTrendsLineCard: FC<RecentTrendsLineCardProps> = ({ chartData, seriesList, loading }) => {
const { t } = useTranslation()
const chartRef = useRef<ReactEcharts>(null);
const getSeries = () => {
return seriesList.map((key, index) => ({
name: key === 'merged_count' ? t('forgetDetail.merged_count') : t('forgetDetail.average_activation'),
type: 'line',
yAxisIndex: key === 'merged_count' ? 0 : 1,
smooth: true,
lineStyle: {
width: 3,
color: Colors[index]
},
itemStyle: {
color: Colors[index]
},
areaStyle: {
color: Colors[index],
opacity: 0.08
},
data: chartData.map(item => item[key])
}))
}
return (
<RbCard
title={t('forgetDetail.forgettingTrend')}
headerType="borderless"
>
{loading
? <Loading size={249} />
: !chartData || chartData.length === 0
? <Empty size={120} className="rb:mt-12 rb:mb-20.25" />
: <ReactEcharts
ref={chartRef}
option={{
color: Colors,
tooltip: {
trigger: 'axis',
extraCssText: 'box-shadow: 0px 2px 6px 0px rgba(33,35,50,0.16); border-radius: 8px;',
axisPointer: {
type: 'line',
crossStyle: {
color: '#5F6266',
},
lineStyle: {
color: '#5F6266',
}
},
formatter: function(params: any) {
let result = `${params[0].axisValue}<br/>`
params.forEach((param: any) => {
result += `${param.marker}${param.seriesName}: ${param.value}<br/>`
})
return result
}
},
legend: {
bottom: 2,
padding: 0,
itemGap: 24,
itemWidth: 40,
itemHeight: 12,
borderRadius: 2,
orient: 'horizontal',
textStyle: {
color: '#5B6167',
fontFamily: 'PingFangSC, PingFang SC',
lineHeight: 16,
}
},
grid: {
top: 16,
left: 30,
right: 36,
bottom: 48,
// containLabel: false
},
xAxis: {
type: 'category',
data: chartData.map(item => item.date),
boundaryGap: false,
axisLabel: {
color: '#A8A9AA',
fontFamily: 'PingFangSC, PingFang SC'
},
axisLine: {
show: true,
lineStyle: {
color: '#EBEBEB'
}
},
splitLine: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
},
axisTick: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
}
},
yAxis: [
{
type: 'value',
position: 'left',
axisLabel: {
color: Colors[0],
fontFamily: 'PingFangSC, PingFang SC'
},
axisLine: {
lineStyle: {
color: Colors[0]
}
},
splitLine: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
},
axisTick: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
},
},
{
type: 'value',
position: 'right',
axisLabel: {
color: Colors[1],
fontFamily: 'PingFangSC, PingFang SC',
formatter: '{value}'
},
axisLine: {
lineStyle: {
color: Colors[1]
}
},
splitLine: {
show: false,
},
axisTick: {
show: true,
lineStyle: {
color: '#EBEBEB',
type: 'solid'
}
},
max: 1,
min: 0
}
],
series: getSeries()
}}
style={{ height: '265px', width: '100%', minWidth: '100%' }}
notMerge={true}
lazyUpdate={true}
/>
}
</RbCard>
)
}
export default RecentTrendsLineCard

View File

@@ -1,17 +1,19 @@
import React, { type FC, useEffect, useState, useRef, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Col, Row } from 'antd'
import { Col, Row, Space, Button } from 'antd'
import dayjs from 'dayjs'
import RbCard from '@/components/RbCard/Card'
import ReactEcharts from 'echarts-for-react'
import detailEmpty from '@/assets/images/userMemory/detail_empty.png'
import type { Node, Edge, GraphData } from '../types'
import type { Node, Edge, GraphData, StatementNodeProperties, ExtractedEntityNodeProperties, GraphDetailRef } from '../types'
import {
getMemorySearchEdges,
} from '@/api/memory'
import Empty from '@/components/Empty'
import Tag from '@/components/Tag'
import GraphDetail from '../components/GraphDetail'
const colors = ['#155EEF', '#369F21', '#4DA8FF', '#FF5D34', '#9C6FFF', '#FF8A4C', '#8BAEF7', '#FFB048']
const RelationshipNetwork:FC = () => {
@@ -24,6 +26,7 @@ const RelationshipNetwork:FC = () => {
const [categories, setCategories] = useState<{ name: string }[]>([])
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
// const [fullScreen, setFullScreen] = useState<boolean>(false)
const graphDetailRef = useRef<GraphDetailRef>(null)
console.log('categories', categories)
// 关系网络
@@ -136,6 +139,11 @@ const RelationshipNetwork:FC = () => {
console.log('selectedNode', selectedNode)
const handleViewAll = () => {
if (!selectedNode) return
graphDetailRef.current?.handleOpen(selectedNode)
}
return (
<Row gutter={16}>
{/* 关系网络 */}
@@ -143,6 +151,7 @@ const RelationshipNetwork:FC = () => {
<RbCard
title={t('userMemory.relationshipNetwork')}
headerType="borderless"
headerClassName="rb:min-h-[46px]!"
// extra={
// <div
// onClick={handleFullScreen}
@@ -239,15 +248,22 @@ const RelationshipNetwork:FC = () => {
<RbCard
title={t('userMemory.memoryDetails')}
headerType="borderless"
headerClassName="rb:min-h-[46px]!"
bodyClassName='rb:p-0!'
extra={selectedNode && <Button type="text" onClick={handleViewAll}>
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/view.svg')] rb:hover:bg-[url('@/assets/images/userMemory/view_hover.svg')]"
></div>
{t('userMemory.completeMemory')}
</Button>}
>
<div className="rb:h-133.5">
<div className="rb:h-133.5 rb:overflow-y-auto">
{!selectedNode
? <Empty
url={detailEmpty}
subTitle={t('userMemory.memoryDetailEmptyDesc')}
className="rb:h-full rb:mx-10 rb:text-center"
size={90}
size={[197.81, 150]}
/>
: <>
<div className="rb:bg-[#F6F8FC] rb:border-t rb:border-b rb:border-[#DFE4ED] rb:font-medium rb:py-2 rb:px-4 rb:h-10">{selectedNode.name}</div>
@@ -267,9 +283,52 @@ const RelationshipNetwork:FC = () => {
</>
<div className="rb:font-medium rb:mb-2 rb:mt-4">
<div className="rb:font-medium rb:leading-5">{t('userMemory.created_at')}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4">
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{dayjs(selectedNode?.properties.created_at).format('YYYY-MM-DD HH:mm:ss')}
</div>
{selectedNode?.properties.associative_memory > 0 && <div className="rb:mt-4">
<div className="rb:font-medium rb:leading-5">{t('userMemory.associative_memory')}</div>
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
<span className="rb:text-[#155EEF] rb:font-medium">{selectedNode?.properties.associative_memory}</span> {t('userMemory.unix')}{t('userMemory.associative_memory')}
</div>
</div>}
{selectedNode.label === 'Statement' && <>
{(['emotion_keywords', 'emotion_type', 'emotion_subject', 'importance_score'] as const).map(key => {
const statementProps = selectedNode.properties as StatementNodeProperties;
if ((key === 'emotion_keywords' && statementProps[key]?.length > 0) || statementProps[key]) {
return (
<div className="rb:mt-4" key={key}>
{t(`userMemory.Statement_${key}`)}
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{key === 'emotion_keywords'
? <Space>{statementProps.emotion_keywords.map((vo, index) => <Tag key={index}>{vo}</Tag>)}</Space>
: statementProps[key]
}
</div>
</div>
)
}
return null
})}
</>}
{selectedNode.label === 'ExtractedEntity' && <>
{(['name', 'entity_type', 'aliases', 'connect_strngth', 'importance_score'] as const).map(key => {
const entityProps = selectedNode.properties as ExtractedEntityNodeProperties;
if (entityProps[key]) {
return (
<div className="rb:mt-4" key={key}>
{t(`userMemory.ExtractedEntity_${key}`)}
<div className="rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:mt-1 rb:pb-4 rb:border-b rb:border-[#DFE4ED]">
{entityProps[key]}
</div>
</div>
)
}
return null
})}
</>}
</div>
</div>
</>
@@ -277,6 +336,8 @@ const RelationshipNetwork:FC = () => {
</div>
</RbCard>
</Col>
<GraphDetail ref={graphDetailRef} />
</Row>
)
}

View File

@@ -41,18 +41,24 @@ const Suggestions: FC = () => {
<RbCard
title={t('statementDetail.suggestions')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
headerClassName="rb:leading-[24px] rb:bg-[#F6F8FC]! rb:min-h-[46px]! rb:border-b! rb:border-b-[#DFE4ED]!"
bodyClassName="rb:px-[16px]! rb:pt-[20px]! rb:pb-[24px]!"
>
{suggestions?.suggestions && suggestions?.suggestions.length > 0
? <>
<RbAlert className="rb:mb-3">{suggestions.health_summary}</RbAlert>
{suggestions.suggestions.map((item, index) => (
<div key={index} className="rb:mb-3">
<div className="rb:font-medium">{index + 1}. {item.title}</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-1 rb:mb-2">{item.content}</div>
{item.actionable_steps.map((vo, idx) => <div key={idx} className="rb:ml-6 rb:text-[12px] rb:text-[#5B6167] rb:mt-1">- {vo}</div>)}
</div>
))}
<div className="rb:space-y-8">
{suggestions.suggestions.map((item, index) => (
<div key={index}>
<div className="rb:font-medium">{index + 1}. {item.title}</div>
<div className="rb:text-[12px] rb:text-[#5B6167] rb:mt-2 rb:mb-2 rb:leading-5">{item.content}</div>
<ul className="rb:list-disc rb:ml-4 rb:text-[12px] rb:text-[#5B6167] rb:leading-5">
{item.actionable_steps.map((vo, idx) => <li key={idx}>{vo}</li>)}
</ul>
</div>
))}
</div>
</>
: <Empty size={88} className="rb:h-full" />
}

View File

@@ -0,0 +1,82 @@
import { type FC, useEffect, useState } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Skeleton, Progress, Space, Tooltip, Divider } from 'antd';
import RbCard from '@/components/RbCard/Card'
import {
getPerceptualTimeline
} from '@/api/memory'
import { formatDateTime } from '@/utils/format';
import Empty from '@/components/Empty'
interface TimelineItem {
id: string;
perceptual_type: number;
file_path: string;
file_name: string;
summary: string;
storage_type: number;
created_time: string | number;
}
const KEYS = {
last_visual: ['summary', 'keywords', 'topic', 'domain', 'scene'],
last_listen: ['summary', 'keywords', 'topic', 'domain', 'speaker_count'],
last_text: ['summary', 'keywords', 'topic', 'domain', 'section_count'],
}
const perceptual_type: Record<number, string> = {
1: 'last_visual',
2: 'last_listen',
3: 'last_text',
}
const Timeline: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<TimelineItem[]>([])
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
if (!id) return
setLoading(true)
getPerceptualTimeline(id).then((res) => {
const response = res as { memories: TimelineItem[] }
setData(response.memories || [])
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
return (
<RbCard>
{loading
? <Skeleton active />
: data.length === 0
? <Empty />
: <Space size={8} direction="vertical" className="rb:w-full">
{data.map((vo, index) => (
<div key={vo.id} className="rb:flex rb:gap-6 rb:min-h-16">
<div className="rb:text-[#155EEF] rb:leading-5 rb:font-medium rb:flex rb:flex-col rb:gap-2 rb:items-center">
{formatDateTime(vo.created_time)}
{index !== data.length - 1 && <Divider type="vertical" className="rb:flex-1 rb:w-px rb:border-[#155EEF]!" />}
</div>
<div className="rb:flex rb:justify-between rb:flex-1 rb:mb-4">
<div className="rb:w-150 rb:leading-5">{vo.summary}</div>
<div className="rb:text-[#5B6167] rb:font-medium">{t(`perceptualDetail.${perceptual_type[vo.perceptual_type]}`)}</div>
</div>
</div>
))}
</Space>
}
</RbCard>
)
}
export default Timeline

View File

@@ -2,7 +2,7 @@ import { type FC, useEffect, useState, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import ReactEcharts from 'echarts-for-react'
import { Progress } from 'antd'
import { Progress, Row, Col } from 'antd'
import Empty from '@/components/Empty'
import RbCard from '@/components/RbCard/Card'
@@ -101,27 +101,35 @@ const WordCloud: FC = () => {
<RbCard
title={t('statementDetail.wordCloud')}
headerType="borderless"
headerClassName="rb:text-[18px]! rb:leading-[24px]"
height="100%"
headerClassName="rb:leading-[24px] rb:bg-[#F6F8FC]! rb:min-h-[46px]! rb:border-b! rb:border-b-[#DFE4ED]!"
bodyClassName="rb:px-[28px]! rb:py-[16px]!"
>
{wordCloud?.total_count && wordCloud?.total_count > 0
? <div className="rb:flex rb:h-100">
<ReactEcharts ref={chartRef} option={radarOption} style={{ width: '50%', height: '100%' }} />
<div className="rb:w-[50%] rb:pl-4 rb:flex rb:flex-col rb:justify-center">
<div className="rb:text-[18px] rb:font-medium rb:mb-4">{wordCloud.total_count}</div>
<div className="rb:space-y-3">
? <Row gutter={50}>
<Col span={12}>
<ReactEcharts ref={chartRef} option={radarOption} style={{ width: '100%', height: 'calc(100% - 100px)' }} />
<div className="rb:mb-4 rb:text-center rb:bg-[#F5F7FC] rb:rounded-lg rb:p-2.5 rb:mt-4">
<span className="rb:text-[#155EEF] rb:text-[28px] rb:font-bold rb:leading-8">{wordCloud.total_count}</span><br />
<span className="rb:text-[#5B6167] rb:leading-5">{t('statementDetail.totalCount')}</span>
</div>
</Col>
<Col span={12}>
<div className="rb:space-y-5">
{wordCloud.tags.map(item => (
<div key={item.emotion_type}>
<div className="rb:flex rb:items-center rb:justify-between rb:font-medium">
{t(`statementDetail.${item.emotion_type}`)}
<div className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular">{item.count}{t('statementDetail.pieces')}</div>
<div className="rb:flex rb:items-center rb:justify-between">
<div>
<span className="rb:font-medium">{t(`statementDetail.${item.emotion_type}`)}</span>
<span className="rb:font-regular rb:text-[#5B6167]"> ( {item.count} {t('statementDetail.pieces')} )</span>
</div>
<div className="rb:text-[12px] rb:text-[#155EEF] rb:font-medium">{item.percentage.toFixed(1)}%</div>
</div>
<Progress size="small" percent={item.percentage} />
<Progress strokeColor="#155EEF" percent={item.percentage} showInfo={false} />
</div>
))}
</div>
</div>
</div>
</Col>
</Row>
: <Empty size={88} />
}
</RbCard>

View File

@@ -0,0 +1,249 @@
import { type FC, useEffect, useState } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Row, Col, Select, Form, Space, Skeleton, Input } from 'antd'
import RbCard from '@/components/RbCard/Card'
import {
getEpisodicOverview,
getEpisodicDetail,
} from '@/api/memory'
import { formatDateTime } from '@/utils/format'
import Tag from '@/components/Tag'
import RbAlert from '@/components/RbAlert'
import Empty from '@/components/Empty'
interface EpisodicMemory {
id: string;
title: string;
type: string;
created_at: number;
}
interface EpisodicOverviewData {
total: number;
total_all: number;
episodic_memories: EpisodicMemory[]
}
interface EpisodicMemoryDetail {
id: string;
created_at: number;
involved_objects: string[];
episodic_type: string;
content_records: string[];
emotion: string;
}
const TAG_COLORS: Record<string, "processing" | "success" | "warning" | "error" | "default"> = {
conversation: "processing",
project_work: "success",
learning: "warning",
decision: "warning",
important_event: "error",
}
const BG_COLORS: Record<string, string> = {
conversation: "rb:bg-[#155EEF]",
project_work: "rb:bg-[#369F21]",
learning: "rb:bg-[#FF5D34]",
decision: "rb:bg-[#FF5D34]",
important_event: "rb:bg-[#5B6167]",
}
// Map display types to internal keys
const getTypeKey = (type: string): string => {
const typeMap: Record<string, string> = {
'Learning': 'learning',
'Project/Work': 'project_work',
'Conversation': 'conversation',
'Decision': 'decision',
'Important Event': 'important_event',
}
return typeMap[type] || type.toLowerCase().replace(/[^a-z0-9]/g, '_')
}
const EpisodicDetail: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [form] = Form.useForm()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<EpisodicOverviewData>({} as EpisodicOverviewData)
const values = Form.useWatch([], form)
const [detailLoading, setDetailLoading] = useState<boolean>(false)
const [detail, setDetail] = useState<EpisodicMemoryDetail | null>(null)
const [selected, setSelected] = useState<EpisodicMemory | null>(null)
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
if (!id) return
setLoading(true)
setSelected(null)
setDetail(null)
getEpisodicOverview({
end_user_id: id,
...values
}).then((res) => {
const response = res as EpisodicOverviewData
setData(response)
if (response.episodic_memories.length > 0) {
setSelected(response.episodic_memories[0])
}
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
useEffect(() => {
getData()
}, [values])
useEffect(() => {
getDetail()
}, [selected])
const getDetail = () => {
if (!selected || !selected.id) return
setDetailLoading(true)
getEpisodicDetail({
end_user_id: id as string,
summary_id: selected.id
})
.then(res => {
setDetail(res as EpisodicMemoryDetail)
})
.finally(() => {
setDetailLoading(false)
})
}
return (
<div className="rb:h-full rb:max-w-266 rb:mx-auto">
<div className="rb:flex rb:justify-between rb:items-center rb:text-[#FFFFFF] rb:leading-5 rb:h-30 rb:p-5 rb:bg-[url('@/assets/images/userMemory/shortTerm.png')] rb:bg-cover rb:mb-6">
<div className="rb:max-w-135">{t('episodicDetail.title')}</div>
<div className="rb:grid rb:grid-cols-1 rb:gap-4">
<div className="rb:bg-[rgba(255,255,255,0.2)] rb:rounded-lg rb:p-3.5 rb:text-[12px] rb:text-center">
<div className="rb:text-[24px] rb:leading-8 rb:mb-1">{data.total_all ?? 0}</div>
{t(`episodicDetail.total_all`)}
</div>
</div>
</div>
<Form form={form} initialValues={{ time_range: 'all', episodic_type: 'all' }}>
<Row gutter={16}>
<Col span={6}>
<Form.Item name="time_range">
<Select
placeholder={t('common.pleaseSelect')}
options={[
{ value: 'all', label: t('episodicDetail.all') },
{ value: 'today', label: t('episodicDetail.today') },
{ value: 'this_week', label: t('episodicDetail.this_week') },
{ value: 'this_month', label: t('episodicDetail.this_month') },
]}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="episodic_type">
<Select
placeholder={t('common.pleaseSelect')}
options={[
{ value: 'all', label: t('episodicDetail.all') },
{ value: 'conversation', label: t('episodicDetail.conversation') },
{ value: 'project_work', label: t('episodicDetail.project_work') },
{ value: 'learning', label: t('episodicDetail.learning') },
{ value: 'decision', label: t('episodicDetail.decision') },
{ value: 'important_event', label: t('episodicDetail.important_event') },
]}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="title_keyword">
<Input placeholder={t('episodicDetail.titleKeywordPlaceholder')} />
</Form.Item>
</Col>
</Row>
</Form>
<Row gutter={16}>
<Col span={8}>
<RbCard
title={<>{t('episodicDetail.curResult')}<span className="rb:text-[#5B6167] rb:font-regular!"> ({data.total || 0}{t('episodicDetail.unix')})</span></>}
headerType="borderless"
>
{loading
? <Skeleton active />
: !data.episodic_memories || data.episodic_memories.length === 0
? <Empty />
: (
<Space size={8} direction="vertical" className="rb:w-full">
{data.episodic_memories.map((vo, index) => (
<div
key={vo.id}
className={clsx("rb:cursor-pointer rb:flex rb:items-center rb:bg-[#FFFFFF] rb:border rb:rounded-lg rb:px-3 rb:py-2 rb:leading-5", {
'rb:border-[#DFE4ED] rb:shadow-[0px_2px_4px_0px_rgba(33,35,50,0.16)]': selected?.id !== vo.id,
'rb:border-[#155EEF]': selected?.id === vo.id,
})}
onClick={() => setSelected(vo)}
>
<div className={clsx("rb:bg-[#369F21] rb:rounded-lg rb:text-[#FFFFFF] rb:size-6 rb:text-[12px] rb:leading-6 rb:text-center rb:mr-3", BG_COLORS[getTypeKey(vo.type)])}>{index + 1}</div>
<div className="rb:flex-1">
<div className="rb:flex rb:items-center rb:justify-between">{vo.title} <Tag color={TAG_COLORS[getTypeKey(vo.type)]}>{t(`episodicDetail.${getTypeKey(vo.type)}`)}</Tag></div>
<div className="rb:text-[#5B6167] rb:text-[12px]">{formatDateTime(vo.created_at)}</div>
</div>
</div>
))}
</Space>
)
}
</RbCard>
</Col>
<Col span={16}>
<RbCard
title={selected?.title}
headerType="borderless"
>
{detailLoading
? <Skeleton active />
: !selected || !detail
? <Empty className="rb:mt-14" />
: (
<Space size={12} direction="vertical" className="rb:w-full">
<div className="rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:px-3 rb:py-2 rb:leading-5">
<Row gutter={[12, 16]}>
<Col span={12}>
<div className="rb:text-[#5B6167]">{t('episodicDetail.created')}<br />{formatDateTime(detail.created_at)}</div>
</Col>
<Col span={12}>
<div className="rb:text-[#5B6167]">{t('episodicDetail.episodic_type')}<br />{detail.episodic_type}</div>
</Col>
{detail.involved_objects.length > 0 && <Col span={24}>
<div className="rb:font-medium rb:leading-5 rb:mb-1">{t('episodicDetail.involved_objects')}</div>
<Space size={8}>{detail.involved_objects.map((vo, index) => <Tag key={index}>{vo}</Tag>)}</Space>
</Col>}
</Row>
</div>
<div className="rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:px-3 rb:py-2 rb:leading-5">
<div className="rb:font-medium rb:leading-5 rb:mb-1">{t('episodicDetail.content_records')}</div>
{detail.content_records.map((vo, index) => <div key={index} className="rb:text-[#5B6167] rb:leading-5">- {vo}</div>)}
</div>
<RbAlert>
{t('episodicDetail.emotion')}: {t(`statementDetail.${detail.emotion}`)}
</RbAlert>
</Space>
)
}
</RbCard>
</Col>
</Row>
</div>
)
}
export default EpisodicDetail

View File

@@ -0,0 +1,109 @@
import { type FC, useEffect, useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { List, Skeleton, Row, Col } from 'antd'
import RbCard from '@/components/RbCard/Card'
import {
getExplicitMemory,
} from '@/api/memory'
import { formatDateTime } from '@/utils/format'
import Empty from '@/components/Empty'
import ExplicitDetailModal from '../components/ExplicitDetailModal'
export interface EpisodicMemory {
id: string;
title: string;
content: string;
created_at: number;
}
export interface SemanticMemory {
id: string;
name: string;
entity_type: string;
core_definition: string;
created_at: number;
}
interface Data {
episodic_memories: EpisodicMemory[];
semantic_memories: SemanticMemory[]
}
export interface ExplicitDetailModalRef {
handleOpen: (vo: EpisodicMemory | SemanticMemory) => void;
}
const ExplicitDetail: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const explicitDetailModalRef = useRef<ExplicitDetailModalRef>(null)
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<Data>({ episodic_memories: [], semantic_memories: [] })
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
if (!id) return
setLoading(true)
getExplicitMemory(id).then((res) => {
const response = res as Data
setData(response)
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
const handleView = (item: EpisodicMemory | SemanticMemory) => {
explicitDetailModalRef.current?.handleOpen(item)
}
return (
<div className="rb:h-full rb:w-full">
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mt-3 rb:rounded-md rb:mb-4">{t('explicitDetail.episodic_memories')}</div>
{loading ?
<Skeleton active />
: data.episodic_memories?.length > 0 ? (
<Row gutter={16}>
{data.episodic_memories.map(item => (
<Col key={item.id} span={6}>
<RbCard
title={item.title}
className="rb:h-full! rb:cursor-pointer"
onClick={() => handleView(item)}
>
<div>{formatDateTime(item.created_at)}</div>
<div>{item.content}</div>
</RbCard>
</Col>
))}
</Row>
) : <Empty />}
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mt-6 rb:rounded-md rb:mb-4">{t('explicitDetail.semantic_memories')}</div>
{loading ?
<Skeleton active />
: data.semantic_memories?.length > 0 ? (
<Row gutter={16}>
{data.semantic_memories.map(item => (
<Col key={item.id} span={6}>
<RbCard
title={item.name}
className="rb:h-full! rb:cursor-pointer"
onClick={() => handleView(item)}
>
<div>{item.core_definition}</div>
</RbCard>
</Col>
))}
</Row>
) : <Empty />}
<ExplicitDetailModal
ref={explicitDetailModalRef}
/>
</div>
)
}
export default ExplicitDetail

View File

@@ -0,0 +1,158 @@
import { type FC, useEffect, useState, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Row, Col, Progress } from 'antd'
import RbCard from '@/components/RbCard/Card'
import {
getForgetStats,
} from '@/api/memory'
import type { ForgetData } from '../types'
import ActivationMetricsPieCard from '../components/ActivationMetricsPieCard'
import RecentTrendsLineCard from '../components/RecentTrendsLineCard'
import Table from '@/components/Table'
import { formatDateTime } from '@/utils/format'
import StatusTag from '@/components/StatusTag'
const statusTagColors: Record<string, 'success' | 'purple' | 'default' | 'warning' | 'error' | 'lightBlue'> = {
statement: 'success',
entity: 'purple',
summary: 'default',
chunk: 'warning',
}
const ForgetDetail: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<ForgetData>({} as ForgetData)
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
if (!id) return
setLoading(true)
getForgetStats(id).then((res) => {
const response = res as ForgetData
setData(response)
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
const chartData = useMemo(() => {
const { activation_metrics } = data
if (!activation_metrics) return []
let health_nodes = (activation_metrics.total_nodes || 0) - (activation_metrics.low_activation_nodes || 0) - (activation_metrics.nodes_without_activation || 0)
return [
{ name: t('forgetDetail.health_nodes'), value: health_nodes },
{ name: t('forgetDetail.nodes_without_activation'), value: activation_metrics.nodes_without_activation || 0 },
{ name: t('forgetDetail.low_activation_nodes'), value: activation_metrics.low_activation_nodes || 0 },
]
}, [data.activation_metrics, t])
const seriesList = useMemo(() => {
const { recent_trends = [] } = data
if (!recent_trends || recent_trends.length === 0) return { chartData: [], seriesList: [] }
return {
chartData: recent_trends,
seriesList: ['merged_count', 'average_activation']
}
}, [data.recent_trends])
return (
<div className="rb:h-full rb:max-w-266 rb:mx-auto">
<div className="rb:text-[#5B6167] rb:leading-5 rb:mt-3">{t('forgetDetail.title')}</div>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-4 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('forgetDetail.overviewTitle')}</div>
<Row gutter={16}>
<Col span={8}>
<RbCard>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mb-2">{t('forgetDetail.totalMemory')}</div>
<div className="rb:text-[26px] rb:font-bold rb:leading-8.5">{data?.activation_metrics?.total_nodes ?? 0}</div>
<div className="rb:mt-4 rb:grid rb:grid-cols-2 rb:gap-x-2 rb:gap-y-5 rb:bg-[#F6F8FC] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:px-3 rb:py-2">
{['statement_count', 'entity_count', 'summary_count', 'chunk_count'].map((key, index) => (
<div key={index}>
<div className="rb:text-[16px] rb:font-bold rb:leading-5.5">{data?.node_distribution?.[key as keyof typeof data.node_distribution] ?? 0}</div>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mt-1">{t(`forgetDetail.${key}`)}</div>
</div>
))}
</div>
</RbCard>
</Col>
<Col span={8}>
<RbCard>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mb-2">{t('forgetDetail.MemoryHealth')}</div>
<div className="rb:text-[26px] rb:font-bold rb:leading-8.5">{data?.activation_metrics?.average_activation_value ?? 0}</div>
<Progress className="rb:mt-px" showInfo={false} percent={data?.activation_metrics?.average_activation_value ?? 0} />
<div className="rb:mt-4 rb:bg-[#F6F8FC] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:px-3 rb:py-2">
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular">{t('forgetDetail.healthStatus')}</div>
<div className="rb:text-[20px] rb:font-semibold rb:leading-7">{data?.activation_metrics?.average_activation_value > data.activation_metrics?.forgetting_threshold ? t('forgetDetail.healthy') : t('forgetDetail.unhealthy')}</div>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4.25 rb:mt-2">
{t('forgetDetail.average')}<br />
{t('forgetDetail.threshold')}{data.activation_metrics?.forgetting_threshold ?? 0}
</div>
</div>
</RbCard>
</Col>
<Col span={8}>
<RbCard>
<div className="rb:text-[#5B6167] rb:leading-5 rb:font-regular rb:mb-2">{t('forgetDetail.riskOfForgetting')}</div>
<div className="rb:text-[26px] rb:font-bold rb:leading-8.5">{data.activation_metrics?.low_activation_nodes ?? 0}</div>
<div className="rb:mb-31.5 rb:text-[#A8A9AA] rb:text-[12px] rb:leading-4 rb:font-regular rb:mt-1">{t('forgetDetail.low_nodes')}</div>
</RbCard>
</Col>
</Row>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-4 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('forgetDetail.memoryHealthVisualization')}</div>
<Row gutter={16}>
<Col span={12}>
<ActivationMetricsPieCard chartData={chartData} loading={loading} />
</Col>
<Col span={12}>
<RecentTrendsLineCard chartData={seriesList.chartData} seriesList={seriesList.seriesList} loading={loading} />
</Col>
</Row>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-4 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('forgetDetail.pending_nodes')}</div>
<Table
rowKey='node_id'
initialData={data.pending_nodes ?? []}
columns={[
{
title: t('forgetDetail.content_summary'),
dataIndex: 'content_summary',
key: 'content_summary',
width: 340,
render: (content_summary) => <div className="rb:wrap-break-word rb:line-clamp-2">{content_summary}</div>
},
{
title: t('forgetDetail.node_type'),
dataIndex: 'node_type',
key: 'node_type',
render: (node_type: string) => {
return <StatusTag status={statusTagColors[node_type] || 'default'} text={node_type} />}
},
{
title: t('forgetDetail.last_access_time'),
dataIndex: 'last_access_time',
key: 'last_access_time',
render: (last_access_time) => formatDateTime(last_access_time, 'YYYY-MM-DD HH:mm')
},
{
title: t('forgetDetail.activation_value'),
dataIndex: 'activation_value',
key: 'activation_value',
},
]}
pagination={false}
/>
</div>
)
}
export default ForgetDetail

View File

@@ -0,0 +1,34 @@
import { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { Row, Col } from 'antd'
import Preferences from '../components/Preferences'
import Portrait from '../components/Portrait'
import InterestAreas from '../components/InterestAreas'
import Habits from '../components/Habits'
const ImplicitDetail: FC = () => {
const { t } = useTranslation()
return (
<div className="rb:h-full rb:max-w-266 rb:mx-auto">
<div className="rb:text-[#5B6167] rb:leading-5 rb:mt-3">{t('implicitDetail.title')}</div>
<Preferences />
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('implicitDetail.portraitTitle')}</div>
<div className="rb:my-3 rb:text-[#5B6167] rb:leading-5">{t('implicitDetail.portraitSubTitle')}</div>
<Row gutter={[16, 16]} className="rb:mt-4">
<Col span={12}>
<Portrait />
</Col>
<Col span={12}>
<InterestAreas />
</Col>
</Row>
<Habits />
</div>
)
}
export default ImplicitDetail

View File

@@ -0,0 +1,32 @@
import { type FC } from 'react'
import { useTranslation } from 'react-i18next'
import { Row, Col } from 'antd'
import PerceptualLastInfo from '../components/PerceptualLastInfo'
import Timeline from '../components/Timeline'
const PerceptualDetail: FC = () => {
const { t } = useTranslation()
return (
<div className="rb:h-full rb:max-w-266 rb:mx-auto">
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mt-6 rb:rounded-md rb:mb-4">{t('perceptualDetail.lastInfo')}</div>
<Row gutter={[16, 16]}>
<Col span={8}>
<PerceptualLastInfo type="last_visual" />
</Col>
<Col span={8}>
<PerceptualLastInfo type="last_listen" />
</Col>
<Col span={8}>
<PerceptualLastInfo type="last_text" />
</Col>
</Row>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mt-6 rb:rounded-md rb:mb-4">{t('perceptualDetail.timeLine')}</div>
<Timeline />
</div>
)
}
export default PerceptualDetail

View File

@@ -0,0 +1,114 @@
import { type FC, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Space, Skeleton } from 'antd'
import {
getShortTerm,
} from '@/api/memory'
import Empty from '@/components/Empty'
interface ShortTermItem {
retrieval: Array<{ query: string; retrieval: string[]; }>;
message: string;
answer: string;
}
interface LongTermItem {
query: string;
retrieval: string;
}
interface ShortData {
short_term: ShortTermItem[];
long_term: LongTermItem[];
entity: number;
retrieval_number: number;
long_term_number: number;
}
const ShortTermDetail: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<ShortData>({} as ShortData)
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
if (!id) return
setLoading(true)
getShortTerm(id).then((res) => {
const response = res as ShortData
setData(response)
setLoading(false)
})
.finally(() => {
setLoading(false)
})
}
return (
<div className="rb:h-full rb:max-w-266 rb:mx-auto">
<div className="rb:flex rb:justify-between rb:items-center rb:text-[#FFFFFF] rb:leading-5 rb:h-30 rb:p-5 rb:bg-[url('@/assets/images/userMemory/shortTerm.png')] rb:bg-cover">
<div className="rb:max-w-135">{t('shortTermDetail.title')}</div>
<div className="rb:grid rb:grid-cols-3 rb:gap-4">
{(['retrieval_number', 'entity', 'long_term_number'] as const).map(key => (
<div key={key} className="rb:bg-[rgba(255,255,255,0.2)] rb:rounded-lg rb:p-3.5 rb:text-[12px] rb:text-center">
<div className="rb:text-[24px] rb:leading-8 rb:mb-1">{(data as any)[key] ?? 0}</div>
{t(`shortTermDetail.${key}`)}
</div>
))}
</div>
</div>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('shortTermDetail.shortTermTitle')}</div>
<div className="rb:my-3 rb:text-[#5B6167] rb:leading-5">{t('shortTermDetail.shortTermSubTitle')}</div>
<Space size={16} direction="vertical" className="rb:w-full">
{loading
? <Skeleton active />
: !data.short_term || data.short_term.length === 0
? <Empty />
:data.short_term?.map((vo, voIdx) => (
<div key={voIdx} className="rb:leading-5 rb:shadow-[inset_3px_0px_0px_0px_#155EEF] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:px-6 rb:py-3">
<div className="rb:font-medium rb:text-[16px] rb:leading-5.5 rb:mb-3">{vo.message}</div>
<Space size={16} direction="vertical" className="rb:w-full">
{vo.retrieval.map((item, index) => (
<div key={index} className="rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-3 rb:py-2.5 rb:leading-5">
<div className="rb:font-medium rb:mb-3">{t('shortTermDetail.query')}: {item.query}</div>
<div className="rb:font-medium rb:leading-5 rb:mb-1">{t('shortTermDetail.answer')}:</div>
{item.retrieval.length > 0 ? item.retrieval.map((retrieval, retrievalIdx) => (
<div key={retrievalIdx} className="rb:text-[#5B6167] rb:text-[12px]">- {retrieval}</div>
)) : <div className="rb:text-[#5B6167] rb:text-[12px]">{t('shortTermDetail.noAnswer')}</div>}
</div>
))}
<div>
<div className="rb:font-medium rb:leading-5 rb:mb-1">{t('shortTermDetail.answer')}</div>
<div className="rb:bg-[#FFFFFF] rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-3 rb:py-2.5 rb:leading-5">{vo.answer}</div>
</div>
</Space>
</div>
))
}
</Space>
<div className="rb:bg-[rgba(21,94,239,0.12)] rb:px-3 rb:py-2.5 rb:font-medium rb:leading-5 rb:mb-4 rb:mt-6 rb:rounded-md">{t('shortTermDetail.longTermTitle')}</div>
<div className="rb:my-3 rb:text-[#5B6167] rb:leading-5">{t('shortTermDetail.shortTermSubTitle')}</div>
<Space size={16} direction="vertical" className="rb:w-full">
{loading
? <Skeleton active />
: !data.long_term || data.long_term.length === 0
? <Empty />
: data.long_term?.map((vo, voIdx) => (
<div key={voIdx} className="rb:leading-5 rb:shadow-[inset_3px_0px_0px_0px_#155EEF] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg rb:px-6 rb:py-3">
<div className="rb:mb-1 rb:font-medium rb:leading-5.5">{vo.query}</div>
<div className="rb:mt-1 rb:leading-5 rb:text-[#5B6167] rb:text-[12px]">{vo.retrieval}</div>
</div>
))
}
</Space>
</div>
)
}
export default ShortTermDetail

View File

@@ -1,53 +1,26 @@
import { type FC, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { type FC } from 'react'
import { Row, Col, Space } from 'antd';
import WordCloud from '../components/WordCloud'
import EmotionTags from '../components/EmotionTags'
import Health from '../components/Health'
import Suggestions from '../components/Suggestions'
import PageHeader from '../components/PageHeader'
import {
getEndUserProfile,
} from '@/api/memory'
const StatementDetail: FC = () => {
const { id } = useParams()
const [name, setName] = useState<string>('')
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
if (!id) return
getEndUserProfile(id).then((res) => {
const response = res as { other_name: string; id: string; }
setName(response.other_name ?? response.id)
})
}
return (
<div className="rb:h-full rb:w-full">
<PageHeader
name={name}
source="statement"
/>
<div className="rb:h-[calc(100vh-64px)] rb:overflow-y-auto rb:py-3 rb:px-4">
<Row gutter={[16, 16]}>
<Col span={12}>
<Space size={16} direction="vertical" className="rb:w-full">
<WordCloud />
<EmotionTags />
<Health />
</Space>
</Col>
<Col span={12}>
<Suggestions />
</Col>
</Row>
</div>
</div>
<Row gutter={[16, 16]}>
<Col span={12}>
<Space size={16} direction="vertical" className="rb:w-full">
<WordCloud />
<EmotionTags />
<Health />
</Space>
</Col>
<Col span={12}>
<Suggestions />
</Col>
</Row>
)
}

View File

@@ -0,0 +1,209 @@
import { type FC, useEffect, useState, useMemo } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next'
import { useParams } from 'react-router-dom'
import { Row, Col, Select, Form, Space, Skeleton, Input, Button, Divider } from 'antd'
import RbCard from '@/components/RbCard/Card'
import {
getConversations,
getConversationMessages,
getConversationDetail,
} from '@/api/memory'
import { formatDateTime } from '@/utils/format'
import Tag from '@/components/Tag'
import RbAlert from '@/components/RbAlert'
import Empty from '@/components/Empty'
import ChatContent from '@/components/Chat/ChatContent'
import type { ChatItem } from '@/components/Chat/types'
import PageLoading from '@/components/Empty/PageLoading'
interface Conversation {
title: string;
id: string;
}
interface Detail {
theme: string;
theme_intro: string;
summary: string;
question: string[];
takeaways: string[];
info_score: number;
}
const WorkingDetail: FC = () => {
const { t } = useTranslation()
const { id } = useParams()
const [form] = Form.useForm()
const [loading, setLoading] = useState<boolean>(false)
const [data, setData] = useState<Conversation[]>([])
const [messagesLoading, setMessagesLoading] = useState<boolean>(false)
const [messages, setMessages] = useState<ChatItem[]>([])
const [detailLoading, setDetailLoading] = useState<boolean>(false)
const [detail, setDetail] = useState<Detail | null>(null)
const [selected, setSelected] = useState<Conversation | null>(null)
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
if (!id) return
setLoading(true)
setSelected(null)
setDetail(null)
setData([])
getConversations(id).then((res) => {
const response = res as Conversation[]
setData(response)
setSelected(response[0] || null)
})
.finally(() => {
setLoading(false)
})
}
useEffect(() => {
if (!id || !selected || !selected.id) return
getDetail(selected.id)
}, [id, selected])
const getDetail = (conversationId: string) => {
if (!id || !conversationId) return
setDetail(null)
setMessages([])
setDetailLoading(true)
setMessagesLoading(true)
getConversationMessages(id, conversationId)
.then(res => {
setMessages(res as ChatItem[])
})
.finally(() => {
setMessagesLoading(false)
})
getConversationDetail(id, conversationId)
.then(res => {
setDetail(res as Detail)
})
.finally(() => {
setDetailLoading(false)
})
}
const timeRange = useMemo(() => {
const times = messages.filter(m => m.created_at).map(m => Number(m.created_at))
if (times.length === 0) return ''
const minTime = Math.min(...times)
const maxTime = Math.max(...times)
return `${formatDateTime(minTime, 'YYYY.MM')} - ${formatDateTime(maxTime, 'YYYY.MM')}`
}, [messages])
return (
<div className="rb:h-[calc(100vh-64px)]! rb:w-full rb:-mx-4 rb:-my-3">
{loading
? <PageLoading />
: data.length === 0
? <Empty />
:(
<Row gutter={16} className="rb:h-full">
<Col span={5}>
<div className="rb:h-full! rb:border-r rb:border-[#EAECEE] rb:py-3 rb:px-4">
{data.map(item => (
<div key={item.id} className="rb:mb-3">
<div className={clsx("rb:p-[8px_13px] rb:rounded-lg rb:leading-5 rb:cursor-pointer rb:hover:bg-[#F0F3F8]", {
'rb:bg-[#FFFFFF] rb:shadow-[0px_2px_4px_0px_rgba(0,0,0,0.15)] rb:font-medium rb:hover:bg-[#FFFFFF]!': item.id === selected?.id,
})}
onClick={() => getDetail(item.id)}
>
{item.title}
</div>
</div>
))}
</div>
</Col>
{selected && <>
<Col span={19}>
<div className="rb:text-[18px] rb:font-medium rb:leading-6 rb:mt-4">{selected.title}</div>
<div className="rb:mt-1 rb:text-[#5B6167] rb:leading-5">{timeRange}</div>
<Row gutter={16}>
<Col span={16}>
<RbCard
title={t('workingDetail.conversationStream')}
extra={<Button className="rb:h-6!" onClick={() => getDetail(selected.id)}>{t('workingDetail.refresh')}</Button>}
className="rb:mt-4!"
headerClassName='rb:bg-[#F6F8FC]! rb:border-b! rb:border-b-[#DFE4ED]! rb:min-h-11!'
headerType="borderless"
bodyClassName="rb:h-[calc(100vh-210px)]"
>
{messagesLoading
? <Skeleton active />
: messages.length === 0
? <Empty />
: (
<ChatContent
classNames="rb:h-[calc(100vh-244px)]"
data={messages}
streamLoading={false}
labelFormat={(item) => formatDateTime(item.created_at)}
/>
)
}
</RbCard>
</Col>
<Col span={8}>
<RbCard className="rb:mt-4!" bodyClassName="rb:h-[calc(100vh-166px)] rb:overflow-y-auto">
{detailLoading
? <Skeleton active />
: detail
? <>
<>
<div className="rb:text-[#369F21] rb:font-medium rb:text-[18px] rb:leading-4 rb:mb-3">{t('workingDetail.successfulTitle')}</div>
{detail.takeaways.length > 0
? (
<ul className="rb:text-[#5B6167] rb:leading-5.5 rb:list-disc rb:ml-4">
{detail.takeaways.map(vo => <li>{vo}</li>)}
</ul>
)
: <Empty size={88} />
}
</>
<>
<Divider />
<div className="rb:text-[#FF5D34] rb:font-medium rb:text-[18px] rb:leading-4 rb:mb-3">{t('workingDetail.question')}</div>
{detail.question.length > 0
? (
<ul className="rb:text-[#5B6167] rb:leading-5.5 rb:list-disc rb:ml-4">
{detail.question.map(vo => <li>{vo}</li>)}
</ul>
)
: <Empty size={88} />
}
</>
<>
<Divider />
<div className="rb:text-[#369F21] rb:font-medium rb:text-[18px] rb:leading-4 rb:mb-3">{t('workingDetail.summary')}</div>
{detail.summary
? <RbAlert className="rb:text-[#212332]! rb:text-[14px]! rb:leading-5.5! rb:p-3!">{detail.summary}</RbAlert>
: <Empty size={88} />
}
</>
</>
: <Empty />
}
</RbCard>
</Col>
</Row>
</Col>
</>}
</Row>
)
}
</div>
)
}
export default WorkingDetail

View File

@@ -0,0 +1,74 @@
import { type FC, useEffect, useState, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Dropdown } from 'antd'
import PageHeader from '../components/PageHeader'
import StatementDetail from './StatementDetail'
import ForgetDetail from './ForgetDetail'
import ImplicitDetail from './ImplicitDetail'
import ShortTermDetail from './ShortTermDetail'
import PerceptualDetail from './PerceptualDetail'
import EpisodicDetail from './EpisodicDetail'
import ExplicitDetail from './ExplicitDetail'
import WorkingDetail from './WorkingDetail'
import {
getEndUserProfile,
} from '@/api/memory'
const Detail: FC = () => {
const { t } = useTranslation()
const { id, type } = useParams()
const navigate = useNavigate()
const [name, setName] = useState<string>('')
useEffect(() => {
if (!id) return
getData()
}, [id])
const getData = () => {
if (!id) return
getEndUserProfile(id).then((res) => {
const response = res as { other_name: string; id: string; }
setName(response.other_name || response.id)
})
}
const items = useMemo(() => {
return ['PERCEPTUAL_MEMORY', 'WORKING_MEMORY', 'EMOTIONAL_MEMORY', 'SHORT_TERM_MEMORY', 'IMPLICIT_MEMORY', 'EPISODIC_MEMORY', 'EXPLICIT_MEMORY', 'FORGETTING_MANAGEMENT']
.map(key => ({ key, label: t(`userMemory.${key}`) }))
}, [t])
const onClick = ({ key }: { key: string }) => {
navigate(`/user-memory/detail/${id}/${key}`, { replace: true })
}
return (
<div className="rb:h-full rb:w-full">
<PageHeader
name={name}
source="node"
operation={
<Dropdown menu={{ items, onClick, selectedKeys: type ? [type] : [] }}>
<div className="rb:cursor-pointer rb:group rb:flex rb:items-center rb:gap-1">
- {type ? t(`userMemory.${type}`) : ''}
<div
className="rb:w-5 rb:h-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/userMemory/up_border.svg')] rb:transform-[rotate(180deg)] rb:group-hover:transform-[rotate(0deg)]"
></div>
</div>
</Dropdown>
}
/>
<div className="rb:h-[calc(100vh-64px)] rb:overflow-y-auto rb:py-3 rb:px-4">
{type === 'EMOTIONAL_MEMORY' && <StatementDetail />}
{type === 'FORGETTING_MANAGEMENT' && <ForgetDetail />}
{type === 'IMPLICIT_MEMORY' && <ImplicitDetail />}
{type === 'SHORT_TERM_MEMORY' && <ShortTermDetail />}
{type === 'PERCEPTUAL_MEMORY' && <PerceptualDetail />} {/** TODO */}
{type === 'EPISODIC_MEMORY' && <EpisodicDetail />}
{type === 'WORKING_MEMORY' && <WorkingDetail />} {/** TODO */}
{type === 'EXPLICIT_MEMORY' && <ExplicitDetail />} {/** TODO */}
</div>
</div>
)
}
export default Detail

View File

@@ -44,6 +44,7 @@ export interface Data {
export interface BaseProperties {
content: string;
created_at: number;
associative_memory: number;
}
export interface StatementNodeProperties {
temporal_info: string;
@@ -51,12 +52,21 @@ export interface StatementNodeProperties {
statement: string;
valid_at: string;
created_at: number;
emotion_keywords: string[];
emotion_type: string;
emotion_subject: string;
importance_score: number;
associative_memory: number;
}
export interface ExtractedEntityNodeProperties {
description: string;
name: string;
entity_type: string;
created_at: number;
aliases: string;
connect_strngth: string;
importance_score: number;
associative_memory: number;
}
export interface MemorySummaryNode {
id: string;
@@ -72,7 +82,7 @@ export interface MemorySummaryNode {
created_at: number;
}
caption: string;
associative_memory: number;
}
export interface Node {
@@ -140,4 +150,41 @@ export interface AboutMeRef {
}
export interface EndUserProfileRef {
data: EndUser | null
}
export interface ForgetData {
activation_metrics: {
total_nodes: number;
nodes_with_activation: number;
nodes_without_activation: number;
average_activation_value: number;
low_activation_nodes: number;
timestamp: number;
forgetting_threshold: number;
},
node_distribution: {
statement_count: number;
entity_count: number;
summary_count: number;
chunk_count: number;
},
recent_trends: {
date: string;
merged_count: number;
average_activation: number;
total_nodes: number;
execution_time: number;
}[],
pending_nodes: {
node_id: string;
node_type: string;
content_summary: string;
activation_value: number;
last_access_time: number;
}[],
timestamp: number;
}
export interface GraphDetailRef {
handleOpen: (vo: Node) => void
}

View File

@@ -1,5 +1,5 @@
import { forwardRef, useImperativeHandle, useState } from 'react';
import { Form, Input, Select, Checkbox, InputNumber } from 'antd';
import { Form, Input, Select, InputNumber } from 'antd';
import { useTranslation } from 'react-i18next';
import type { ChatVariableModalRef } from './types'
@@ -26,7 +26,7 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
const [form] = Form.useForm<ChatVariable>();
const [loading, setLoading] = useState(false)
const [editIndex, setEditIndex] = useState<number | undefined>(undefined)
const typeValue = Form.useWatch('type', form);
const type = Form.useWatch('type', form);
// 封装取消方法,添加关闭弹窗逻辑
const handleClose = () => {
@@ -39,7 +39,8 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
const handleOpen = (variable?: ChatVariable, index?: number) => {
setVisible(true);
if (variable) {
form.setFieldsValue(variable)
const { default: _, ...rest } = variable
form.setFieldsValue({ ...rest })
setEditIndex(index)
} else {
form.resetFields();
@@ -49,7 +50,7 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
// 封装保存方法,添加提交逻辑
const handleSave = () => {
form.validateFields().then((values) => {
refresh({ ...values }, editIndex)
refresh({ ...values, default: values.defaultValue }, editIndex)
handleClose()
})
}
@@ -90,52 +91,36 @@ const ChatVariableModal = forwardRef<ChatVariableModalRef, ChatVariableModalProp
>
<Select
placeholder={t('common.pleaseSelect')}
onChange={() => form.setFieldValue('default', undefined)}
onChange={() => form.setFieldValue('defaultValue', undefined)}
options={types.map(key => ({
value: key,
label: t(`workflow.config.parameter-extractor.${key}`),
}))}
/>
</FormItem>
<FormItem
name="default"
<Form.Item
name="defaultValue"
label={t('workflow.config.parameter-extractor.default')}
>
<Form.Item noStyle shouldUpdate={(prevValues, currentValues) => prevValues.type !== currentValues.type}>
{({ getFieldValue }) => {
const type = getFieldValue('type');
if (type === 'number') {
return <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />;
}
if (type === 'boolean') {
return (
<Select
placeholder={t('common.pleaseSelect')}
options={[
{ value: true, label: 'true' },
{ value: false, label: 'false' }
]}
/>
);
}
return <Input placeholder={t('common.enter')} />;
}}
</Form.Item>
</FormItem>
{type === 'number'
? <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />
: type === 'boolean'
? <Select
placeholder={t('common.pleaseSelect')}
options={[
{ value: true, label: 'true' },
{ value: false, label: 'false' }
]}
/>
: <Input placeholder={t('common.enter')} />
}
</Form.Item>
<FormItem
name="description"
label={t('workflow.config.parameter-extractor.desc')}
>
<Input.TextArea placeholder={t('common.enter')} />
</FormItem>
<FormItem
name="required"
valuePropName="checked"
>
<Checkbox>{t('workflow.config.parameter-extractor.required')}</Checkbox>
</FormItem>
</Form>
</RbModal>
);

View File

@@ -40,7 +40,7 @@ const AddChatVariable = forwardRef<AddChatVariableRef, AddChatVariableProps>(({
}
const handleSave = (value: ChatVariable, index?: number) => {
const list = [...variables]
if (index && index > -1) {
if (typeof index === 'number' && index > -1) {
list[index] = value
} else {
list.push(value)
@@ -75,17 +75,15 @@ const AddChatVariable = forwardRef<AddChatVariableRef, AddChatVariableProps>(({
dataSource={variables}
renderItem={(item, index) => (
<List.Item>
<div key={index} className="rb:group rb:relative rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:cursor-pointer rb:border rb:border-[#DFE4ED] rb:rounded-lg">
<div key={index} className="rb:relative rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:cursor-pointer rb:border rb:border-[#DFE4ED] rb:rounded-lg">
<div className="rb:flex rb:items-center rb:justify-between">
<div className="rb:leading-4">
<span className="rb:font-medium">{item.name}</span>
<span className="rb:text-[12px] rb:text-[#5B6167] rb:font-regular"> ({t(`workflow.config.parameter-extractor.${item.type}`)})</span>
</div>
<span className="rb:block rb:group-hover:hidden rb:text-[12px] rb:text-[#5B6167] rb:font-regular">{item.required ? t('workflow.config.parameter-extractor.required') : ''}</span>
</div>
<div className="rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-5 rb:wrap-break-word rb:line-clamp-1">{item.description}</div>
<Space size={12} className="rb:hidden! rb:group-hover:flex! rb:absolute rb:right-4 rb:top-[50%] rb:transform-[translateY(-50%)] rb:bg-white">
<Space size={12} className="rb:flex rb:absolute rb:right-4 rb:top-[50%] rb:transform-[translateY(-50%)] rb:bg-white">
<div
className="rb:size-5 rb:cursor-pointer rb:bg-cover rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
onClick={() => handleEdit(index)}

View File

@@ -11,8 +11,8 @@ export interface VariableFormData {
name: string;
type: ChatVariable['type'];
description?: string;
required?: boolean;
defaultValue?: any;
defaultValue?: string;
default?: string;
}
export interface ChatVariableModalRef {

View File

@@ -1,4 +1,4 @@
import { type FC, useState } from 'react';
import { type FC, useState, useEffect } from 'react';
import { LexicalComposer } from '@lexical/react/LexicalComposer';
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
@@ -23,6 +23,7 @@ interface LexicalEditorProps {
options: Suggestion[];
variant?: 'outlined' | 'borderless';
height?: number;
enableJinja2?: boolean;
}
const theme = {
@@ -33,6 +34,15 @@ const theme = {
},
};
const jinja2Theme = {
...theme,
code: 'jinja2-expression',
text: {
...theme.text,
code: 'jinja2-inline',
},
};
const Editor: FC<LexicalEditorProps> =({
placeholder = "请输入内容...",
value = "",
@@ -40,19 +50,62 @@ const Editor: FC<LexicalEditorProps> =({
options,
variant = 'borderless',
height = 60,
enableJinja2 = false,
}) => {
const [_count, setCount] = useState(0);
useEffect(() => {
if (enableJinja2) {
const styleId = 'jinja2-styles';
let existingStyle = document.getElementById(styleId);
if (!existingStyle) {
const style = document.createElement('style');
style.id = styleId;
style.textContent = `
.jinja2-expression {
background-color: #f6f8fa !important;
border: 1px solid #d1d9e0 !important;
border-radius: 3px !important;
padding: 2px 4px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.jinja2-inline {
background-color: #f6f8fa !important;
padding: 1px 3px !important;
border-radius: 2px !important;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
font-size: 13px !important;
color: #0969da !important;
}
.editor-paragraph {
margin: 0;
}
.editor-paragraph:has-text('{') .editor-text,
.editor-paragraph:has-text('[') .editor-text {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace !important;
}
`;
document.head.appendChild(style);
}
}
}, [enableJinja2]);
const initialConfig = {
namespace: 'AutocompleteEditor',
theme,
nodes: [
theme: enableJinja2 ? jinja2Theme : theme,
nodes: enableJinja2 ? [
// 当启用jinja2时不使用VariableNode使用普通文本
] : [
// HeadingNode,
// QuoteNode,
// ListItemNode,
// ListNode,
// LinkNode,
// CodeNode,
VariableNode
VariableNode,
],
onError: (error: Error) => {
console.error(error);
@@ -96,9 +149,9 @@ const Editor: FC<LexicalEditorProps> =({
/>
<HistoryPlugin />
<CommandPlugin />
<AutocompletePlugin options={options} />
<AutocompletePlugin options={options} enableJinja2={enableJinja2} />
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
<InitialValuePlugin value={value} options={options} />
<InitialValuePlugin value={value} options={options} enableJinja2={enableJinja2} />
</div>
</LexicalComposer>
);

View File

@@ -17,7 +17,7 @@ export interface Suggestion {
disabled?: boolean; // 标记是否禁用
}
const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
const AutocompletePlugin: FC<{ options: Suggestion[], enableJinja2?: boolean }> = ({ options, enableJinja2 = false }) => {
const [editor] = useLexicalComposerContext();
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -82,7 +82,32 @@ const AutocompletePlugin: FC<{ options: Suggestion[] }> = ({ options }) => {
}, [editor]);
const insertMention = (suggestion: Suggestion) => {
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
if (enableJinja2) {
// 在jinja2模式下插入{{variable}}格式的文本
editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const anchorNode = selection.anchor.getNode();
const anchorOffset = selection.anchor.offset;
const nodeText = anchorNode.getTextContent();
// 移除触发字符'/'
const textBefore = nodeText.substring(0, anchorOffset - 1);
const textAfter = nodeText.substring(anchorOffset);
const newText = textBefore + `{{${suggestion.value}}}` + textAfter;
anchorNode.setTextContent(newText);
// 设置光标位置到插入文本之后
const newOffset = textBefore.length + `{{${suggestion.value}}}`.length;
selection.anchor.offset = newOffset;
selection.focus.offset = newOffset;
}
});
} else {
// 普通模式下使用VariableNode
editor.dispatchCommand(INSERT_VARIABLE_COMMAND, { data: suggestion });
}
setShowSuggestions(false);
};

View File

@@ -14,18 +14,23 @@ const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number
let serializedContent = '';
// Traverse all nodes and serialize properly
const paragraphs: string[] = [];
root.getChildren().forEach(child => {
if ($isParagraphNode(child)) {
let paragraphContent = '';
child.getChildren().forEach(node => {
if ($isVariableNode(node)) {
serializedContent += node.getTextContent();
paragraphContent += node.getTextContent();
} else {
serializedContent += node.getTextContent();
paragraphContent += node.getTextContent();
}
});
paragraphs.push(paragraphContent);
}
});
serializedContent = paragraphs.join('\n');
setCount(serializedContent.length);
onChange?.(serializedContent);
});

View File

@@ -8,14 +8,31 @@ import { type Suggestion } from '../plugin/AutocompletePlugin'
interface InitialValuePluginProps {
value: string;
options?: Suggestion[];
enableJinja2?: boolean;
}
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [] }) => {
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options = [], enableJinja2 = false }) => {
const [editor] = useLexicalComposerContext();
const initializedRef = useRef(false);
const prevValueRef = useRef<string>('');
const isUserInputRef = useRef(false);
useEffect(() => {
if (!initializedRef.current && value) {
// 监听编辑器变化,标记是否为用户输入
const removeListener = editor.registerUpdateListener(({ editorState }) => {
editorState.read(() => {
const root = $getRoot();
const textContent = root.getTextContent();
if (textContent !== prevValueRef.current) {
isUserInputRef.current = true;
}
});
});
return removeListener;
}, [editor]);
useEffect(() => {
if (value !== prevValueRef.current && !isUserInputRef.current) {
editor.update(() => {
const root = $getRoot();
root.clear();
@@ -26,8 +43,13 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
parts.forEach(part => {
const match = part.match(/^\{\{([^.]+)\.([^}]+)\}\}$/);
const contextMatch = part.match(/^\{\{context\}\}$/);
const conversationMatch = part.match(/^\{\{conv\.([^}]+)\}\}$/);
if (enableJinja2) {
paragraph.append($createTextNode(part));
return;
}
// 匹配{{context}}格式
if (contextMatch) {
const contextSuggestion = options.find(s => s.isContext && s.label === 'context');
if (contextSuggestion) {
@@ -38,7 +60,19 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
return
}
// 匹配普通变量{{nodeId.label}}格式
if (conversationMatch) {
const [_, variableName] = conversationMatch;
const conversationSuggestion = options.find(s =>
s.group === 'CONVERSATION' && s.label === variableName
);
if (conversationSuggestion) {
paragraph.append($createVariableNode(conversationSuggestion));
} else {
paragraph.append($createTextNode(part));
}
return
}
if (match) {
const [_, nodeId, label] = match;
@@ -60,11 +94,12 @@ const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value, options
});
root.append(paragraph);
});
initializedRef.current = true;
}, { discrete: true });
}
}, [options]);
prevValueRef.current = value;
isUserInputRef.current = false;
}, [value, options, editor, enableJinja2]);
return null;
};

View File

@@ -0,0 +1,109 @@
import { useEffect } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { $getRoot, $getSelection, $isRangeSelection, TextNode, $createTextNode } from 'lexical';
const JsonHighlightPlugin = () => {
const [editor] = useLexicalComposerContext();
useEffect(() => {
return editor.registerNodeTransform(TextNode, (textNode: TextNode) => {
const text = textNode.getTextContent();
// Check if text contains JSON-like patterns
if (containsJsonPatterns(text)) {
const parent = textNode.getParent();
if (!parent) return;
// Split text into tokens and create new nodes with appropriate classes
const tokens = tokenizeJson(text);
const newNodes = tokens.map(token => {
const newNode = $createTextNode(token.text);
// Set format based on token type
switch (token.type) {
case 'string':
newNode.setFormat('code');
newNode.setStyle('color: #032f62');
break;
case 'number':
newNode.setFormat('code');
newNode.setStyle('color: #005cc5');
break;
case 'boolean':
newNode.setFormat('code');
newNode.setStyle('color: #d73a49');
break;
case 'null':
newNode.setFormat('code');
newNode.setStyle('color: #6f42c1');
break;
case 'key':
newNode.setFormat('code');
newNode.setStyle('color: #22863a; font-weight: bold');
break;
case 'punctuation':
newNode.setFormat('code');
newNode.setStyle('color: #24292e');
break;
}
return newNode;
});
// Replace the original text node with the new highlighted nodes
if (newNodes.length > 1) {
textNode.replace(newNodes[0]);
for (let i = 1; i < newNodes.length; i++) {
newNodes[i - 1].insertAfter(newNodes[i]);
}
}
}
});
}, [editor]);
return null;
};
function containsJsonPatterns(text: string): boolean {
// Check for JSON-like patterns
return /[{}\[\]:,]/.test(text) ||
/"[^"]*"/.test(text) ||
/\b\d+(\.\d+)?\b/.test(text) ||
/\b(true|false|null)\b/.test(text);
}
function tokenizeJson(text: string): Array<{text: string, type: string}> {
const tokens: Array<{text: string, type: string}> = [];
const regex = /("[^"]*")|([{}\[\]:,])|(\b\d+(?:\.\d+)?\b)|(\b(?:true|false|null)\b)|(\s+)|([^\s{}\[\]:,"]+)/g;
let match;
while ((match = regex.exec(text)) !== null) {
const [fullMatch, string, punctuation, number, boolean, whitespace, other] = match;
if (string) {
// Check if it's a key (followed by colon)
const afterMatch = text.slice(match.index + fullMatch.length).trim();
if (afterMatch.startsWith(':')) {
tokens.push({ text: fullMatch, type: 'key' });
} else {
tokens.push({ text: fullMatch, type: 'string' });
}
} else if (punctuation) {
tokens.push({ text: fullMatch, type: 'punctuation' });
} else if (number) {
tokens.push({ text: fullMatch, type: 'number' });
} else if (boolean) {
if (fullMatch === 'null') {
tokens.push({ text: fullMatch, type: 'null' });
} else {
tokens.push({ text: fullMatch, type: 'boolean' });
}
} else if (whitespace || other) {
tokens.push({ text: fullMatch, type: 'text' });
}
}
return tokens;
}
export default JsonHighlightPlugin;

View File

@@ -13,13 +13,15 @@ const AddNode: ReactShapeConfig['component'] = ({ node, graph }) => {
const handleNodeSelect = (selectedNodeType: any) => {
const parentBBox = node.getBBox();
const cycleId = data.cycle;
const id = `${selectedNodeType.type.replace(/-/g, '_') }_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const newNode = graph.addNode({
...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default),
x: parentBBox.x,
y: parentBBox.y,
id,
data: {
id: `${selectedNodeType.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
id,
type: selectedNodeType.type,
icon: selectedNodeType.icon,
name: t(`workflow.${selectedNodeType.type}`),

View File

@@ -75,12 +75,15 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
const parentBBox = node.getBBox();
const centerX = parentBBox.x + 24; // 默认节点宽度的一半
const centerY = parentBBox.y + 50; // 默认节点高度的一半
const cycleStartNodeId = `cycle_start_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const cycleStartNode = graph.addNode({
...graphNodeLibrary.cycleStart,
x: centerX,
y: centerY,
id: cycleStartNodeId,
data: {
id: cycleStartNodeId,
type: 'cycle-start',
parentId: node.id,
isDefault: true, // 标记为默认节点,不可删除

View File

@@ -43,12 +43,14 @@ const PortClickHandler: React.FC<PortClickHandlerProps> = ({ graph }) => {
const newY = sourceBBox.y;
// 创建新节点
const id = `${selectedNodeType.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
const newNode = graph.addNode({
...(graphNodeLibrary[selectedNodeType.type] || graphNodeLibrary.default),
x: newX,
y: newY,
id,
data: {
id: `${selectedNodeType.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
id,
type: selectedNodeType.type,
icon: selectedNodeType.icon,
name: t(`workflow.${selectedNodeType.type}`),

View File

@@ -1,6 +1,6 @@
import { type FC } from 'react'
import { useTranslation } from 'react-i18next';
import { Form, Input, Button, Row, Col, Select } from 'antd'
import { Form, Input, Row, Col, Select, InputNumber, Radio } from 'antd'
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
import VariableSelect from '../VariableSelect'
@@ -11,6 +11,23 @@ interface AssignmentListProps {
options: Suggestion[];
}
const operationsObj = {
number: [
{ value: 'cover', label: 'workflow.config.assigner.cover' },
{ value: 'clear', label: 'workflow.config.assigner.clear' },
{ value: 'assign', label: 'workflow.config.assigner.assign' },
{ value: 'add', label: 'workflow.config.assigner.add' },
{ value: 'subtract', label: 'workflow.config.assigner.subtract' },
{ value: 'multiply', label: 'workflow.config.assigner.multiply' },
{ value: 'divide', label: 'workflow.config.assigner.divide' },
],
default: [
{ value: 'cover', label: 'workflow.config.assigner.cover' },
{ value: 'clear', label: 'workflow.config.assigner.clear' },
{ value: 'assign', label: 'workflow.config.assigner.assign' },
],
}
const AssignmentList: FC<AssignmentListProps> = ({
parentName,
options = [],
@@ -27,6 +44,11 @@ const AssignmentList: FC<AssignmentListProps> = ({
<PlusOutlined onClick={() => add({ operation: 'cover'})} />
</div>
{fields.map(({ key, name, ...restField }) => {
const variableSelector = form.getFieldValue([parentName, name, 'variable_selector']);
const selectedOption = options.find(option => `{{${option.value}}}` === variableSelector);
const dataType = selectedOption?.dataType;
const operationOptions = dataType === 'number' ? operationsObj.number : operationsObj.default;
return (
<div key={key} className="rb:mb-4">
<Row gutter={12} className="rb:mb-2!">
@@ -40,6 +62,10 @@ const AssignmentList: FC<AssignmentListProps> = ({
placeholder={t('common.pleaseSelect')}
options={options}
popupMatchSelectWidth={false}
onChange={() => {
form.setFieldValue([parentName, name, 'operation'], undefined);
form.setFieldValue([parentName, name, 'value'], undefined);
}}
/>
</Form.Item>
</Col>
@@ -50,11 +76,11 @@ const AssignmentList: FC<AssignmentListProps> = ({
noStyle
>
<Select
options={[
{ value: 'cover', label: t('workflow.config.assigner.cover') },
{ value: 'clear', label: t('workflow.config.assigner.clear') },
{ value: 'assign', label: t('workflow.config.assigner.assign') },
]}
placeholder={t('common.pleaseSelect')}
options={operationOptions.map(op => ({
...op,
label: t(op.label)
}))}
popupMatchSelectWidth={false}
onChange={() => {
form.setFieldValue([parentName, name, 'value'], undefined);
@@ -77,20 +103,38 @@ const AssignmentList: FC<AssignmentListProps> = ({
{...restField}
name={[name, 'value']}
noStyle
rules={[{ required: true, message: 'Missing last name' }]}
>
{operation === 'assign' ? (
<Input.TextArea
placeholder={t('common.pleaseEnter')}
rows={3}
/>
) : (
<VariableSelect
{dataType === 'number' && operation === 'cover'
? <VariableSelect
placeholder={t('common.pleaseSelect')}
options={options}
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
popupMatchSelectWidth={false}
/>
)}
: dataType === 'number'
? <InputNumber
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
onChange={(value) => form.setFieldValue([name, 'value'], value)}
/>
: operation === 'assign'
? <>
{dataType === 'boolean'
? <Radio.Group block>
<Radio.Button value={true}>True</Radio.Button>
<Radio.Button value={false}>False</Radio.Button>
</Radio.Group>
: <Input.TextArea
placeholder={t('common.pleaseEnter')}
rows={3}
/>
}
</>
: <VariableSelect
placeholder={t('common.pleaseSelect')}
options={dataType ? options.filter(vo => vo.dataType === dataType) : options}
popupMatchSelectWidth={false}
/>
}
</Form.Item>
);
}}

View File

@@ -1,7 +1,7 @@
import { type FC } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next';
import { Form, Button, Select, Space, Row, Col, Divider } from 'antd'
import { Form, Button, Select, Space, Row, Col, Divider, InputNumber, Radio, type SelectProps } from 'antd'
import { DeleteOutlined } from '@ant-design/icons';
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
@@ -9,37 +9,50 @@ import VariableSelect from '../VariableSelect'
import Editor from '../../Editor'
interface CaseListProps {
value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; comparison_operator: string; right: string; }[] }>;
onChange?: (value: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; comparison_operator: string; right: string; }[] }>) => void;
value?: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; input_type?: string; }[] }>;
onChange?: (value: Array<{ logical_operator: 'and' | 'or'; expressions: { left: string; operator: string; right: string; }[] }>) => void;
options: Suggestion[];
name: string;
selectedNode?: any;
graphRef?: any;
}
const operatorList = [
"empty",
"not_empty",
"contains",
"not_contains",
"startwith",
"endwith",
"eq",
"ne",
"lt",
"le",
"gt",
"ge"
]
const operatorsObj: { [key: string]: SelectProps['options'] } = {
default: [
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
{ value: 'contains', label: 'workflow.config.if-else.contains' },
{ value: 'not_contains', label: 'workflow.config.if-else.not_contains' },
{ value: 'startwith', label: 'workflow.config.if-else.startwith' },
{ value: 'endwith', label: 'workflow.config.if-else.endwith' },
{ value: 'eq', label: 'workflow.config.if-else.eq' },
{ value: 'ne', label: 'workflow.config.if-else.ne' },
],
number: [
{ value: 'eq', label: 'workflow.config.if-else.num.eq' },
{ value: 'ne', label: 'workflow.config.if-else.num.ne' },
{ value: 'lt', label: 'workflow.config.if-else.num.lt' },
{ value: 'le', label: 'workflow.config.if-else.num.le' },
{ value: 'gt', label: 'workflow.config.if-else.num.gt' },
{ value: 'ge', label: 'workflow.config.if-else.num.ge' },
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
],
boolean: [
{ value: 'eq', label: 'workflow.config.if-else.boolean.eq' },
{ value: 'ne', label: 'workflow.config.if-else.boolean.ne' },
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
]
}
const CaseList: FC<CaseListProps> = ({
value = [],
options,
name,
onChange,
selectedNode,
graphRef
}) => {
const { t } = useTranslation();
const form = Form.useFormInstance();
const updateNodePorts = (caseCount: number, removedCaseIndex?: number) => {
if (!selectedNode || !graphRef?.current) return;
@@ -175,29 +188,49 @@ const CaseList: FC<CaseListProps> = ({
});
}, 50);
};
const handleChangeLogicalOperator = (index: number) => {
const newValue = [...value]
newValue[index] = {
...newValue[index],
logical_operator: newValue[index].logical_operator === 'and' ? 'or' : 'and'
}
onChange && onChange(newValue)
}
const currentValue = form.getFieldValue([name, index, 'logical_operator']);
form.setFieldValue([name, index, 'logical_operator'], currentValue === 'and' ? 'or' : 'and');
};
const handleLeftFieldChange = (caseIndex: number, conditionIndex: number, newValue: string) => {
form.setFieldsValue({
[name]: {
[caseIndex]: {
expressions: {
[conditionIndex]: {
left: newValue,
operator: undefined,
right: undefined,
input_type: undefined
}
}
}
}
});
};
const handleAddCase = (addCaseFunc: Function) => {
addCaseFunc({ logical_operator: 'and', expressions: [] });
setTimeout(() => {
updateNodePorts((value?.length || 0) + 1);
const currentCases = form.getFieldValue(name) || [];
updateNodePorts(currentCases.length);
}, 100);
};
const handleRemoveCase = (removeCaseFunc: Function, fieldName: number, caseIndex: number) => {
removeCaseFunc(fieldName);
setTimeout(() => {
updateNodePorts((value?.length || 1) - 1, caseIndex);
const currentCases = form.getFieldValue(name) || [];
updateNodePorts(currentCases.length, caseIndex);
}, 100);
};
const handleInputTypeChange = (caseIndex: number, conditionIndex: number) => {
form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'right'], undefined);
};
return (
<>
<Form.List name={name}>
@@ -207,6 +240,7 @@ const CaseList: FC<CaseListProps> = ({
<div key={caseField.key}>
<Form.List name={[caseField.name, 'expressions']}>
{(conditionFields, { add: addCondition, remove: removeCondition }) => {
const logicalOperator = form.getFieldValue(name)?.[caseIndex]?.logical_operator || 'and'
return (
<div className={clsx("rb:relative rb:mb-4 rb:border rb:border-gray-200 rb:rounded rb:p-3 rb:pl-5")}>
<div className="rb:flex rb:items-center rb:justify-between rb:mb-3">
@@ -218,7 +252,7 @@ const CaseList: FC<CaseListProps> = ({
<Space>
<Button
type="dashed"
onClick={() => addCondition()}
onClick={() => addCondition({})}
size="small"
>
+ {t('workflow.config.addCase')}
@@ -234,15 +268,22 @@ const CaseList: FC<CaseListProps> = ({
<div className="rb:absolute rb:w-3 rb:left-2 rb:top-15 rb:bottom-6 rb:z-10 rb:border rb:border-[#DFE4ED] rb:rounded-l-md rb:border-r-0"></div>
<div className="rb:absolute rb:z-10 rb:left-0 rb:top-[50%] rb:transform-[translateY(-50%)]]">
<Form.Item name={[caseField.name, 'logical_operator']} noStyle >
<Button size="small" className="rb:cursor-pointer" onClick={() => handleChangeLogicalOperator(caseIndex)}>{value?.[caseIndex].logical_operator}</Button>
<Button size="small" className="rb:cursor-pointer" onClick={() => handleChangeLogicalOperator(caseIndex)}>{logicalOperator}</Button>
</Form.Item>
</div>
</>
}
{conditionFields.map((conditionField, conditionIndex) => {
const currentOperator = value?.[caseIndex]?.expressions?.[conditionIndex]?.comparison_operator;
const cases = form.getFieldValue(name) || [];
const currentCase = cases[caseIndex] || {};
const currentExpression = currentCase.expressions?.[conditionIndex] || {};
const currentOperator = currentExpression.operator;
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty';
const leftFieldValue = currentExpression.left;
const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue);
const leftFieldType = leftFieldOption?.dataType;
const operatorList = operatorsObj[leftFieldType || 'default'] || operatorsObj.default || [];
const inputType = leftFieldType === 'number' ? currentExpression.input_type : undefined;
return (
<div key={conditionField.key} className={clsx({
"rb:mb-3": conditionIndex !== conditionFields.length - 1
@@ -257,18 +298,20 @@ const CaseList: FC<CaseListProps> = ({
size="small"
allowClear={false}
popupMatchSelectWidth={false}
onChange={(val) => handleLeftFieldChange(caseIndex, conditionIndex, val)}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name={[conditionField.name, 'comparison_operator']} noStyle>
<Form.Item name={[conditionField.name, 'operator']} noStyle>
<Select
options={operatorList.map(key => ({
value: key,
label: t(`workflow.config.if-else.${key}`)
options={operatorList.map(vo => ({
...vo,
label: t(String(vo?.label || ''))
}))}
size="small"
popupMatchSelectWidth={false}
placeholder={t('common.pleaseSelect')}
/>
</Form.Item>
</Col>
@@ -280,11 +323,52 @@ const CaseList: FC<CaseListProps> = ({
</Col>
</Row>
{!hideRightField && (
<Form.Item name={[conditionField.name, 'right']} noStyle>
<Editor options={options} />
</Form.Item>
)}
{!hideRightField && <>
{leftFieldType === 'number'
? <Row>
<Col span={12}>
<Form.Item name={[conditionField.name, 'input_type']} noStyle>
<Select
placeholder={t('common.pleaseSelect')}
options={[{ value: 'Variable', label: 'Variable' }, { value: 'Constant', label: 'Constant' }]}
popupMatchSelectWidth={false}
variant="borderless"
onChange={() => handleInputTypeChange(caseIndex, conditionIndex)}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name={[conditionField.name, 'right']} noStyle>
{inputType === 'Variable'
?
<VariableSelect
placeholder={t('common.pleaseSelect')}
options={options.filter(vo => vo.dataType === 'number')}
allowClear={false}
popupMatchSelectWidth={false}
variant="borderless"
/>
: <InputNumber
placeholder={t('common.pleaseEnter')}
variant="borderless"
className="rb:w-full!"
onChange={(value) => form.setFieldValue([name, caseIndex, 'expressions', conditionIndex, 'right'], value)}
/>
}
</Form.Item>
</Col>
</Row>
: <Form.Item name={[conditionField.name, 'right']} noStyle>
{leftFieldType === 'boolean'
? <Radio.Group block>
<Radio.Button value={true}>True</Radio.Button>
<Radio.Button value={false}>False</Radio.Button>
</Radio.Group>
: <Editor options={options} />
}
</Form.Item>
}
</>}
</div>
</div>
)

View File

@@ -1,7 +1,6 @@
import { type FC } from 'react'
import clsx from 'clsx'
import { useTranslation } from 'react-i18next';
import { Form, Button, Select, Space, Row, Col, Divider } from 'antd'
import { Form, Button, Select, Row, Col, InputNumber, Radio, type SelectProps } from 'antd'
import { DeleteOutlined } from '@ant-design/icons';
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
@@ -10,7 +9,7 @@ import Editor from '../../Editor'
interface Case {
logical_operator: 'and' | 'or';
expressions: Array<{ left: string; comparison_operator: string; right: string; }>
expressions: Array<{ left: string; operator: string; right: string; input_type: string; }>
}
interface CaseListProps {
@@ -22,36 +21,65 @@ interface CaseListProps {
graphRef?: any;
addBtnText?: string;
}
const operatorList = [
"empty",
"not_empty",
"contains",
"not_contains",
"startwith",
"endwith",
"eq",
"ne",
"lt",
"le",
"gt",
"ge"
]
const operatorsObj: { [key: string]: SelectProps['options'] } = {
default: [
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
{ value: 'contains', label: 'workflow.config.if-else.contains' },
{ value: 'not_contains', label: 'workflow.config.if-else.not_contains' },
{ value: 'startwith', label: 'workflow.config.if-else.startwith' },
{ value: 'endwith', label: 'workflow.config.if-else.endwith' },
{ value: 'eq', label: 'workflow.config.if-else.eq' },
{ value: 'ne', label: 'workflow.config.if-else.ne' },
],
number: [
{ value: 'eq', label: 'workflow.config.if-else.num.eq' },
{ value: 'ne', label: 'workflow.config.if-else.num.ne' },
{ value: 'lt', label: 'workflow.config.if-else.num.lt' },
{ value: 'le', label: 'workflow.config.if-else.num.le' },
{ value: 'gt', label: 'workflow.config.if-else.num.gt' },
{ value: 'ge', label: 'workflow.config.if-else.num.ge' },
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
],
boolean: [
{ value: 'eq', label: 'workflow.config.if-else.boolean.eq' },
{ value: 'ne', label: 'workflow.config.if-else.boolean.ne' },
{ value: 'empty', label: 'workflow.config.if-else.empty' },
{ value: 'not_empty', label: 'workflow.config.if-else.not_empty' },
]
}
const ConditionList: FC<CaseListProps> = ({
value,
options,
parentName,
onChange,
}) => {
const { t } = useTranslation();
const form = Form.useFormInstance();
const handleLeftFieldChange = (index: number, newValue: string) => {
form.setFieldsValue({
[parentName]: {
expressions: {
[index]: {
left: newValue,
operator: undefined,
right: undefined,
input_type: undefined
}
}
}
});
};
const handleInputTypeChange = (index: number) => {
form.setFieldValue([parentName, 'expressions', index, 'right'], undefined);
};
const handleChangeLogicalOperator = () => {
if (!value) return;
onChange && onChange({
logical_operator: value.logical_operator === 'and' ? 'or' : 'and',
expressions: value.expressions || []
})
}
const currentValue = form.getFieldValue([parentName, 'logical_operator']);
form.setFieldValue([parentName, 'logical_operator'], currentValue === 'and' ? 'or' : 'and');
};
return (
<>
<Form.List name={[parentName, 'expressions']}>
@@ -59,8 +87,16 @@ const ConditionList: FC<CaseListProps> = ({
<div>
<div className="rb:relative">
{fields.map((field, index) => {
const currentOperator = value?.expressions?.[index]?.comparison_operator;
const expressions = form.getFieldValue([parentName, 'expressions']) || [];
const currentExpression = expressions[index] || {};
const currentOperator = currentExpression.operator;
const hideRightField = currentOperator === 'empty' || currentOperator === 'not_empty';
const leftFieldValue = currentExpression.left;
const leftFieldOption = options.find(option => `{{${option.value}}}` === leftFieldValue);
const leftFieldType = leftFieldOption?.dataType;
const operatorList = operatorsObj[leftFieldType || 'default'] || operatorsObj.default || [];
const inputType = leftFieldType === 'number' ? currentExpression.input_type : undefined;
const logicalOperator = form.getFieldValue([parentName, 'logical_operator']);
return (
<div key={field.key} className="rb:mb-3">
@@ -68,7 +104,7 @@ const ConditionList: FC<CaseListProps> = ({
<div className="rb:absolute rb:w-3 rb:left-2 rb:top-3.75 rb:bottom-3.75 rb:z-10 rb:border rb:border-[#DFE4ED] rb:rounded-l-md rb:border-r-0"></div>
<div className="rb:absolute rb:z-10 rb:left-0 rb:top-[50%] rb:transform-[translateY(-50%)]]">
<Form.Item name={[parentName, 'logical_operator']} noStyle >
<Button size="small" className="rb:cursor-pointer" onClick={handleChangeLogicalOperator}>{value?.logical_operator}</Button>
<Button size="small" className="rb:cursor-pointer" onClick={handleChangeLogicalOperator}>{logicalOperator}</Button>
</Form.Item>
</div>
</>)}
@@ -82,16 +118,17 @@ const ConditionList: FC<CaseListProps> = ({
size="small"
allowClear={false}
popupMatchSelectWidth={false}
onChange={(val) => handleLeftFieldChange(index, val)}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name={[field.name, 'comparison_operator']} noStyle>
<Form.Item name={[field.name, 'operator']} noStyle>
<Select
options={operatorList.map(key => ({
value: key,
label: t(`workflow.config.if-else.${key}`)
options={operatorList.map(vo => ({
...vo,
label: t(String(vo?.label || ''))
}))}
size="small"
popupMatchSelectWidth={false}
@@ -104,14 +141,57 @@ const ConditionList: FC<CaseListProps> = ({
onClick={() => remove(field.name)}
/>
</Col>
{!hideRightField && (
<Col span={24}>
<Form.Item name={[field.name, 'right']} noStyle>
<Editor options={options} />
</Form.Item>
</Col>
)}
{!hideRightField && <>
{leftFieldType === 'number'
? <Col span={24}><Row>
<Col span={12}>
<Form.Item name={[field.name, 'input_type']} noStyle>
<Select
placeholder={t('common.pleaseSelect')}
options={[{ value: 'Variable', label: 'Variable' }, { value: 'Constant', label: 'Constant' }]}
popupMatchSelectWidth={false}
variant="borderless"
className="rb:w-full!"
onChange={() => handleInputTypeChange(index)}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name={[field.name, 'right']} noStyle>
{inputType === 'Variable'
?
<VariableSelect
placeholder={t('common.pleaseSelect')}
options={options.filter(vo => vo.dataType === 'number')}
allowClear={false}
popupMatchSelectWidth={false}
variant="borderless"
className="rb:w-full!"
/>
: <InputNumber
placeholder={t('common.pleaseEnter')}
variant="borderless"
className="rb:w-full!"
onChange={(value) => form.setFieldValue([parentName, 'expressions', index, 'right'], value)}
/>
}
</Form.Item>
</Col>
</Row></Col>
: <Col span={24}>
<Form.Item name={[field.name, 'right']} noStyle>
{leftFieldType === 'boolean'
? <Radio.Group block>
<Radio.Button value={true}>True</Radio.Button>
<Radio.Button value={false}>False</Radio.Button>
</Radio.Group>
: <Editor options={options} />
}
</Form.Item>
</Col>
}
</>}
</Row>
</div>
@@ -122,7 +202,7 @@ const ConditionList: FC<CaseListProps> = ({
<Button
type="dashed"
onClick={() => add({ left: '', comparison_operator: '', right: '' })}
onClick={() => add({ left: '', operator: '', right: '' })}
className="rb:w-full rb:ml-6 rb:mt-2"
icon={<span>+</span>}
>

View File

@@ -65,7 +65,7 @@ const CycleVarsList: FC<CycleVarsListProps> = ({
label: `${childData.name || childData.type}.${key}`,
type: 'output',
dataType: 'string',
value: `{{${childData.id}.${key}}}`,
value: `${childData.id}.${key}`,
nodeData: childData
});
}

View File

@@ -25,7 +25,6 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
<Row gutter={12} className="rb:mb-2!">
<Col span={12}>
<Form.Item
name={[name,0, 'key']}
noStyle
>
{t('workflow.config.var-aggregator.variable')}
@@ -34,9 +33,8 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
</Row>
<Form.Item
name={[name, 0, 'value']}
name={name}
noStyle
rules={[{ required: true, message: 'Missing last name' }]}
>
<VariableSelect
placeholder={t('common.pleaseSelect')}
@@ -76,7 +74,6 @@ const GroupVariableList: FC<GroupVariableListProps> = ({
{...restField}
name={[name, 'value']}
noStyle
rules={[{ required: true, message: 'Missing last name' }]}
>
<VariableSelect
placeholder={t('common.pleaseSelect')}

View File

@@ -28,12 +28,18 @@ const AuthConfigModal = forwardRef<AuthConfigModalRef, AuthConfigModalProps>(({
const handleOpen = (data?: HttpRequestConfigForm['auth']) => {
if (data) {
form.setFieldsValue({
const initialValues = {
auth: !data.auth_type || data.auth_type === 'none' ? 'none' : 'api_key',
auth_type: !data.auth_type || data.auth_type === 'none' ? undefined : data.auth_type,
header: data.header,
api_key: data.api_key
})
}
form.setFieldValue('auth', initialValues.auth)
if (initialValues.auth !== 'none') {
setTimeout(() => {
form.setFieldsValue(initialValues)
}, 1)
}
}
setVisible(true);
};
@@ -91,6 +97,9 @@ const AuthConfigModal = forwardRef<AuthConfigModalRef, AuthConfigModalProps>(({
<FormItem
name="auth"
label={t('workflow.config.http-request.authType')}
rules={[
{ required: true, message: t('common.pleaseSelect') }
]}
>
<Select
options={[
@@ -103,6 +112,9 @@ const AuthConfigModal = forwardRef<AuthConfigModalRef, AuthConfigModalProps>(({
<FormItem
name="auth_type"
label={t('workflow.config.http-request.authType')}
rules={[
{ required: true, message: t('common.pleaseSelect') }
]}
>
<Select
options={[

View File

@@ -1,230 +1,175 @@
import { useState, useEffect } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next'
import { Button, Select, Table } from 'antd';
import { Button, Select, Table, Form, type TableProps } from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import Editor from '../../Editor';
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin';
import Empty from '@/components/Empty';
import VariableSelect from '../VariableSelect';
export interface TableRow {
key: string;
name: string;
value: string;
key?: string;
name?: string;
value?: string;
type?: string;
}
interface EditableTableProps {
parentName: string | string[];
title?: string;
value?: Record<string, string> | TableRow[];
onChange?: (value: TableRow[]) => void;
options?: Suggestion[];
typeOptions?: {value: string, label: string}[]
typeOptions?: { value: string, label: string }[]
filterBooleanType?: boolean;
}
const EditableTable: React.FC<EditableTableProps> = ({
parentName,
title,
value,
onChange,
options = [],
typeOptions = []
typeOptions = [],
filterBooleanType = false
}) => {
const { t } = useTranslation()
const [rows, setRows] = useState<TableRow[]>([]);
const { t } = useTranslation();
useEffect(() => {
console.log('EditableTable value', value)
if (Array.isArray(value)) {
setRows([...value])
} else if (value && Object.keys(value).length > 0) {
// Only update if rows are empty or significantly different
const valueEntries = Object.entries(value)
if (rows.length === 0 || rows.length !== valueEntries.length) {
setRows(valueEntries.map(([key, val], index) => {
console.log('val', val)
return {
key: index.toString(),
name: key || '',
value: val || '',
type: typeOptions.length > 0 ? typeOptions[0].value : undefined
}
}))
}
} else {
setRows([])
}
}, [JSON.stringify(value), typeOptions.length])
const createNewRow = (): TableRow => ({
name: undefined,
value: undefined,
...(typeOptions.length > 0 && { type: typeOptions[0].value })
});
const handleChange = (key: string, field: 'name' | 'value' | 'type', val: string) => {
const newRows = [...rows.map(row =>
row.key === key ? { ...row, [field]: val } : row
)];
const getColumns = (remove: (index: number) => void): TableProps<TableRow>['columns'] => {
const hasType = typeOptions.length > 0;
const baseWidth = hasType ? '35%' : '45%';
setRows(newRows);
onChange?.(newRows);
};
const handleAdd = () => {
const newKey = Date.now().toString();
if (typeOptions.length) {
setRows([...rows, { key: newKey, name: '', value: '', type: typeOptions[0].value }]);
} else {
setRows([...rows, { key: newKey, name: '', value: '' }]);
}
};
const handleDelete = (key: string, index: number) => {
console.log('index', index)
if (rows.length === 1) {
setRows([]);
onChange?.([]);
} else {
const newRows = rows.filter(row => row.key !== key);
setRows(newRows);
onChange?.(newRows);
}
};
const columns = typeOptions?.length > 0 ? [
{
title: t('workflow.config.name'),
dataIndex: 'name',
width: '45%',
render: (text: string, record: TableRow) => (
<Editor
options={options}
value={text}
height={32}
variant="outlined"
onChange={(value) => handleChange(record.key, 'name', value)}
/>
),
},
{
title: t('workflow.config.type'),
dataIndex: 'type',
width: '20%',
render: (text: string, record: TableRow) => (
<Select
value={text}
options={typeOptions}
onChange={(value) => {
console.log('value record', value)
handleChange(record.key, 'type', value)
}}
/>
),
},
{
title: t('workflow.config.value'),
dataIndex: 'value',
width: '45%',
render: (text: string, record: TableRow) => {
if (record.type === 'file') {
return (
<VariableSelect
return [
{
title: t('workflow.config.name'),
dataIndex: 'name',
width: baseWidth,
render: (_: any, __: TableRow, index: number) => (
<Form.Item name={[index, 'name']} noStyle>
<VariableSelect
placeholder={t('common.pleaseSelect')}
size="small"
options={options}
value={text}
onChange={(value) => {
console.log('value record', value)
handleChange(record.key, 'value', value)
}}
filterBooleanType={filterBooleanType}
popupMatchSelectWidth={false}
/>
)
}
return (
<Editor
options={options}
value={text}
height={32}
variant="outlined"
onChange={(value) => {
console.log('value record', value)
handleChange(record.key, 'value', value)
}}
/>
</Form.Item>
)
},
},
{
title: '',
width: '10%',
render: (_: any, record: TableRow, index: number) => (
<Button
type="text"
icon={<DeleteOutlined />}
onClick={() => handleDelete(record.key, index)}
/>
),
},
] : [
{
title: '键',
dataIndex: 'name',
width: '45%',
render: (text: string, record: TableRow) => (
<Editor
options={options}
value={text}
height={32}
variant="outlined"
onChange={(value) => handleChange(record.key, 'name', value)}
/>
),
},
{
title: '值',
dataIndex: 'value',
width: '45%',
render: (text: string, record: TableRow) => (
<Editor
options={options}
value={text}
height={32}
variant="outlined"
onChange={(value) => handleChange(record.key, 'value', value)}
/>
),
},
{
title: '',
width: '10%',
render: (_: any, record: TableRow, index: number) => (
<Button
type="text"
icon={<DeleteOutlined />}
onClick={() => handleDelete(record.key, index)}
/>
),
},
];
...(hasType ? [{
title: t('workflow.config.type'),
dataIndex: 'type',
width: '20%',
render: (_: any, __: TableRow, index: number) => (
<Form.Item shouldUpdate noStyle>
{(form) => (
<Form.Item name={[index, 'type']} noStyle>
<Select
placeholder={t('common.pleaseSelect')}
size="small"
options={typeOptions}
popupMatchSelectWidth={false}
onChange={() => {
form.setFieldValue([...Array.isArray(parentName) ? parentName : [parentName], index, 'value'], undefined);
}}
/>
</Form.Item>
)}
</Form.Item>
)
}] : []),
{
title: t('workflow.config.value'),
dataIndex: 'value',
width: baseWidth,
render: (_: any, __: TableRow, index: number) => (
<Form.Item
shouldUpdate={(prevValues, currentValues) => {
const prevType = prevValues?.[Array.isArray(parentName) ? parentName.join('.') : parentName]?.[index]?.type;
const currentType = currentValues?.[Array.isArray(parentName) ? parentName.join('.') : parentName]?.[index]?.type;
return prevType !== currentType;
}}
noStyle
>
{(form) => {
const currentType = form.getFieldValue([...Array.isArray(parentName) ? parentName : [parentName], index, 'type']);
const filteredOptions = currentType === 'file'
? options.filter(option => option.dataType === 'file')
: options;
return (
<Form.Item name={[index, 'value']} noStyle>
<VariableSelect
placeholder={t('common.pleaseSelect')}
size="small"
options={filteredOptions}
filterBooleanType={filterBooleanType}
popupMatchSelectWidth={false}
/>
</Form.Item>
);
}}
</Form.Item>
)
},
{
title: '',
dataIndex: 'actions',
width: '10%',
render: (_: any, __: TableRow, index: number) => (
<Button type="text" icon={<DeleteOutlined />} onClick={() => remove(index)} />
)
}
];
};
return (
<div className="rb:mb-4">
{title && <div className="rb:flex rb:items-center rb:mb-2 rb:justify-between">
<div className="rb:font-medium">{title}</div>
<Button
type="text"
icon={<PlusOutlined />}
onClick={handleAdd}
size="small"
/>
</div>}
<Table
columns={columns}
dataSource={rows}
pagination={false}
size="small"
locale={{ emptyText: <Empty size={88} /> }}
scroll={{ x: 'max-content' }}
/>
{!title &&
<Button type="dashed" onClick={handleAdd} block className='rb:mt-1'>
+{t('common.add')}
</Button>
}
<Form.List name={parentName}>
{(fields, { add, remove }) => {
const AddButton = ({ block = false }: { block?: boolean }) => (
<Button
type={block ? "dashed" : "text"}
icon={<PlusOutlined />}
onClick={() => add(createNewRow())}
size="small"
block={block}
className={block ? "rb:mt-1" : ""}
>
{block && `+${t('common.add')}`}
</Button>
);
return (
<>
{title && (
<div className="rb:flex rb:items-center rb:mb-2 rb:justify-between">
<div className="rb:font-medium">{title}</div>
<AddButton />
</div>
)}
<Table<TableRow>
bordered
dataSource={fields.map((field) => ({
key: String(field.key),
name: undefined,
value: undefined,
type: undefined
}))}
columns={getColumns(remove)}
pagination={false}
size="small"
locale={{ emptyText: <Empty size={88} /> }}
scroll={{ x: 'max-content' }}
/>
{!title && <AddButton block />}
</>
);
}}
</Form.List>
</div>
);
};

View File

@@ -1,16 +1,18 @@
import { type FC, useEffect, useRef } from "react";
import { type FC, useRef } from "react";
import { useTranslation } from 'react-i18next'
import { Form, Row, Col, Select, Button, Divider, InputNumber, Switch, Input, Slider } from 'antd'
import { Form, Row, Col, Select, Button, Divider, InputNumber, Switch, Input } from 'antd'
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, { type TableRow } from './EditableTable'
import EditableTable from './EditableTable'
const HttpRequest: FC<{ options: Suggestion[]; }> = ({
const HttpRequest: FC<{ options: Suggestion[]; selectedNode?: any; graphRef?: any; }> = ({
options,
selectedNode,
graphRef
}) => {
const { t } = useTranslation()
const form = Form.useFormInstance();
@@ -22,29 +24,45 @@ const HttpRequest: FC<{ options: Suggestion[]; }> = ({
}
const handleRefresh = (auth: HttpRequestConfigForm['auth']) => {
console.log('handleRefresh', auth)
form.setFieldsValue({ auth: {...auth} })
form.setFieldsValue({ auth })
}
const handleChangeBodyContentType = (contentType: string) => {
const currentValues = form.getFieldsValue()
const handleChangeBodyContentType = () => {
form.setFieldValue(['body', 'data'], undefined)
}
const handleChangeErrorHandleMethod = (method: string) => {
form.setFieldsValue({
body: {
...currentValues?.body,
content_type: contentType,
data: undefined
error_handle: {
method,
body: undefined,
status_code: undefined,
headers: undefined
}
})
}
const updateObjectList = (data: TableRow[], key: string) => {
let obj: Record<string, string> = {}
if (data.length) {
data.forEach(vo => {
obj[vo.name] = vo.value
})
// 更新节点连接桩
console.log('handleChangeErrorHandleMethod', selectedNode, graphRef?.current)
if (selectedNode && graphRef?.current) {
const existingPorts = selectedNode.getPorts();
const errorPort = existingPorts.find((port: any) => port.id === 'ERROR');
if (method === 'branch' && !errorPort) {
// 添加异常节点连接桩
selectedNode.addPort({
id: 'ERROR',
group: 'right',
attrs: { text: { text: t('workflow.config.http-request.errorBranch'), fontSize: 12, fill: '#5B6167' }}
});
} else if (method !== 'branch' && errorPort) {
// 移除异常节点连接桩和相关连线
const edges = graphRef.current.getEdges().filter((edge: any) =>
edge.getSourceCellId() === selectedNode.id && edge.getSourcePortId() === 'ERROR'
);
edges.forEach((edge: any) => graphRef.current.removeCell(edge));
selectedNode.removePort('ERROR');
}
}
form.setFieldValue(key, obj)
}
console.log('HttpRequest', values)
@@ -81,17 +99,19 @@ const HttpRequest: FC<{ options: Suggestion[]; }> = ({
<Form.Item name="headers">
<EditableTable
parentName="headers"
title="HEADERS"
options={options}
onChange={(headers) => updateObjectList(headers, 'headers')}
filterBooleanType={true}
/>
</Form.Item>
<Form.Item name="params">
<EditableTable
parentName="params"
title="PARAMS"
options={options}
onChange={(params) => updateObjectList(params, 'params')}
filterBooleanType={true}
/>
</Form.Item>
@@ -113,15 +133,9 @@ const HttpRequest: FC<{ options: Suggestion[]; }> = ({
{values?.body?.content_type === 'form-data' &&
<Form.Item name={['body', 'data']} noStyle>
<EditableTable
parentName={['body', 'data']}
options={options}
onChange={(data) => {
form.setFieldsValue({
body: {
...form.getFieldValue('body'),
data
}
})
}}
filterBooleanType={true}
typeOptions={[
{ label: 'text', value: 'text' },
{ label: 'file', value: 'file' }
@@ -132,19 +146,17 @@ const HttpRequest: FC<{ options: Suggestion[]; }> = ({
{values?.body?.content_type === 'x-www-form-urlencoded' &&
<Form.Item name={['body', 'data']} noStyle>
<EditableTable
parentName={['body', 'data']}
options={options}
onChange={(data) => {
const currentBody = form.getFieldValue('body') || {}
form.setFieldsValue({
body: { ...currentBody, data }
})
}}
filterBooleanType={true}
/>
</Form.Item>
}
{values?.body?.content_type === 'json' &&
<Form.Item name={['body', 'data']}>
<MessageEditor
key="json"
parentName={['body', 'data']}
options={options}
isArray={false}
title="JSON"
@@ -154,6 +166,8 @@ const HttpRequest: FC<{ options: Suggestion[]; }> = ({
{values?.body?.content_type === 'raw' &&
<Form.Item name={['body', 'data']}>
<MessageEditor
key="raw"
parentName={['body', 'data']}
options={options}
isArray={false}
title="RAW TEXT"
@@ -164,6 +178,7 @@ const HttpRequest: FC<{ options: Suggestion[]; }> = ({
<Form.Item name={['body', 'data']}>
<VariableSelect
options={options}
filterBooleanType={true}
/>
</Form.Item>
}
@@ -179,19 +194,31 @@ const HttpRequest: FC<{ options: Suggestion[]; }> = ({
name={['timeouts', 'connect_timeout']}
label={t('workflow.config.http-request.connect_timeout')}
>
<InputNumber placeholder={t('common.pleaseEnter')} className="rb:w-full!" />
<InputNumber
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
onChange={(value) => form.setFieldValue(['timeouts', 'connect_timeout'], value)}
/>
</Form.Item>
<Form.Item
name={['timeouts', 'read_timeout']}
label={t('workflow.config.http-request.read_timeout')}
>
<InputNumber placeholder={t('common.pleaseEnter')} className="rb:w-full!" />
<InputNumber
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
onChange={(value) => form.setFieldValue(['timeouts', 'read_timeout'], value)}
/>
</Form.Item>
<Form.Item
name={['timeouts', 'write_timeout']}
label={t('workflow.config.http-request.write_timeout')}
>
<InputNumber placeholder={t('common.pleaseEnter')} className="rb:w-full!" />
<InputNumber
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
onChange={(value) => form.setFieldValue(['timeouts', 'write_timeout'], value)}
/>
</Form.Item>
<Divider />
@@ -204,13 +231,21 @@ const HttpRequest: FC<{ options: Suggestion[]; }> = ({
name={['retry', 'max_attempts']}
label={t('workflow.config.http-request.max_attempts')}
>
<InputNumber placeholder={t('common.pleaseEnter')} className="rb:w-full!" />
<InputNumber
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
onChange={(value) => form.setFieldValue(['retry', 'max_attempts'], value)}
/>
</Form.Item>
<Form.Item
name={['retry', 'retry_interval']}
label={t('workflow.config.http-request.retry_interval')}
label={<>{t('workflow.config.http-request.retry_interval')} <span className="rb:text-[#5B6167]">(ms)</span></>}
>
<InputNumber placeholder={t('common.pleaseEnter')} className="rb:w-full!" />
<InputNumber
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
onChange={(value) => form.setFieldValue(['retry', 'retry_interval'], value)}
/>
</Form.Item>
</>
}
@@ -219,6 +254,7 @@ const HttpRequest: FC<{ options: Suggestion[]; }> = ({
<Form.Item layout="horizontal" name={['error_handle', 'method']} label={t('workflow.config.http-request.error_handle')}>
<Select
placeholder={t('common.pleaseSelect')}
onChange={handleChangeErrorHandleMethod}
options={[
{ value: 'none', label: t('workflow.config.http-request.none') },
{ value: 'default', label: t('workflow.config.http-request.default') },
@@ -230,32 +266,23 @@ const HttpRequest: FC<{ options: Suggestion[]; }> = ({
<>
<Form.Item
name={['error_handle', 'body']}
label="body"
label={<>body <span className="rb:text-[#5B6167] rb:ml-1">string</span></>}
>
<Input placeholder={t('common.pleaseEnter')} />
</Form.Item>
<Form.Item
name={['error_handle', 'status_code']}
label="status_code"
label={<>status_code <span className="rb:text-[#5B6167] rb:ml-1">number</span></>}
>
<InputNumber placeholder={t('common.pleaseEnter')} className="rb:w-full!" />
<InputNumber
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
onChange={(value) => form.setFieldValue(['error_handle', 'status_code'], value)}
/>
</Form.Item>
<Form.Item
name={['error_handle', 'headers']}
label="headers"
rules={[
{
validator: (_, value) => {
if (!value) return Promise.resolve();
try {
JSON.parse(value);
return Promise.resolve();
} catch {
return Promise.reject(new Error('Please enter valid JSON format'));
}
}
}
]}
label={<>headers <span className="rb:text-[#5B6167] rb:ml-1">object</span></>}
>
<Input.TextArea placeholder={t('common.pleaseEnter')} />
</Form.Item>

View File

@@ -128,29 +128,32 @@ const Knowledge: FC<{value?: KnowledgeConfig; onChange?: (config: KnowledgeConfi
<List
grid={{ gutter: 12, column: 1 }}
dataSource={knowledgeList}
renderItem={(item) => (
<List.Item>
<div key={item.id} className="rb:flex rb:items-center rb:justify-between rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg">
<div className="rb:font-medium rb:leading-4">
{item.name}
<Tag color={item.status === 1 ? 'success' : item.status === 0 ? 'default' : 'error'} className="rb:ml-2">
{item.status === 1 ? t('common.enable') : item.status === 0 ? t('common.disabled') : t('common.deleted')}
</Tag>
<div className="rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-5">{t('application.contains', {include_count: item.doc_num})}</div>
renderItem={(item) => {
if (!item.id) return null
return (
<List.Item>
<div key={item.id} className="rb:flex rb:items-center rb:justify-between rb:p-[12px_16px] rb:bg-[#FBFDFF] rb:border rb:border-[#DFE4ED] rb:rounded-lg">
<div className="rb:font-medium rb:leading-4">
{item.name}
<Tag color={item.status === 1 ? 'success' : item.status === 0 ? 'default' : 'error'} className="rb:ml-2">
{item.status === 1 ? t('common.enable') : item.status === 0 ? t('common.disabled') : t('common.deleted')}
</Tag>
<div className="rb:mt-1 rb:text-[12px] rb:text-[#5B6167] rb:font-regular rb:leading-5">{t('application.contains', {include_count: item.doc_num})}</div>
</div>
<Space size={12}>
<div
className="rb:w-6 rb:h-6 rb:cursor-pointer rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
onClick={() => handleEditKnowledge(item)}
></div>
<div
className="rb:w-6 rb:h-6 rb:cursor-pointer rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={() => handleDeleteKnowledge(item.id)}
></div>
</Space>
</div>
<Space size={12}>
<div
className="rb:w-6 rb:h-6 rb:cursor-pointer rb:bg-[url('@/assets/images/editBorder.svg')] rb:hover:bg-[url('@/assets/images/editBg.svg')]"
onClick={() => handleEditKnowledge(item)}
></div>
<div
className="rb:w-6 rb:h-6 rb:cursor-pointer rb:bg-[url('@/assets/images/deleteBorder.svg')] rb:hover:bg-[url('@/assets/images/deleteBg.svg')]"
onClick={() => handleDeleteKnowledge(item.id)}
></div>
</Space>
</div>
</List.Item>
)}
</List.Item>
)
}}
/>
}
{/* 全局设置 */}

View File

@@ -117,7 +117,12 @@ const KnowledgeConfigModal = forwardRef<KnowledgeConfigModalRef, KnowledgeConfig
extra={t('application.top_k_desc')}
initialValue={5}
>
<InputNumber style={{ width: '100%' }} min={1} max={20} />
<InputNumber
style={{ width: '100%' }}
min={1}
max={20}
onChange={(value) => form.setFieldValue('top_k', value)}
/>
</FormItem>
{/* 语义相似度阈值 similarity_threshold */}
{values?.retrieve_type === 'semantic' && (

View File

@@ -110,7 +110,12 @@ const KnowledgeGlobalConfigModal = forwardRef<KnowledgeGlobalConfigModalRef, Kno
rules={[{ required: true, message: t('common.pleaseEnter') }]}
extra={t('application.reranker_top_k_desc')}
>
<InputNumber style={{ width: '100%' }} min={1} max={20} />
<InputNumber
style={{ width: '100%' }}
min={1}
max={20}
onChange={(value) => form.setFieldValue('reranker_top_k', value)}
/>
</FormItem>
</>}
</Form>

View File

@@ -1,12 +1,15 @@
import React from 'react';
import { useTranslation } from 'react-i18next'
import { MinusCircleOutlined } from '@ant-design/icons';
import { Button, Form, Input, Space } from 'antd';
import { Button, Form, Input, Space, Row, Col } from 'antd';
import type { Suggestion } from '../../Editor/plugin/AutocompletePlugin'
import VariableSelect from '../VariableSelect'
interface MappingListProps {
name: string;
options: Suggestion[];
}
const MappingList: React.FC<MappingListProps> = ({ name }) => {
const MappingList: React.FC<MappingListProps> = ({ name, options }) => {
const { t } = useTranslation()
return (
<>
@@ -14,23 +17,33 @@ const MappingList: React.FC<MappingListProps> = ({ name }) => {
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name, ...restField }) => (
<Space key={key} style={{ display: 'flex', marginBottom: 8 }} align="baseline">
<Form.Item
{...restField}
name={[name, 'name']}
noStyle
>
<Input placeholder={t('common.pleaseEnter')} />
</Form.Item>
<Form.Item
{...restField}
name={[name, 'value']}
noStyle
>
<Input placeholder={t('common.pleaseEnter')} />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(name)} />
</Space>
<Row key={key} gutter={12} className="rb:mb-2">
<Col span={10}>
<Form.Item
{...restField}
name={[name, 'name']}
noStyle
>
<Input placeholder={t('common.pleaseEnter')} data-field-type="mapping-name" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
{...restField}
name={[name, 'value']}
noStyle
>
<VariableSelect
placeholder={t('common.pleaseSelect')}
options={options}
popupMatchSelectWidth={false}
/>
</Form.Item>
</Col>
<Col span={2}>
<MinusCircleOutlined onClick={() => remove(name)} />
</Col>
</Row>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} block>

View File

@@ -5,14 +5,15 @@ import { MinusCircleOutlined } from '@ant-design/icons';
import Editor from '../Editor'
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
interface TextareaProps {
interface MessageEditor {
options: Suggestion[];
title?: string
isArray?: boolean;
parentName?: string;
parentName?: string | string[];
label?: string;
placeholder?: string;
value?: string;
enableJinja2?: boolean;
onChange?: (value?: string) => void;
}
const roleOptions = [
@@ -20,24 +21,32 @@ const roleOptions = [
{ label: 'USER', value: 'USER' },
{ label: 'ASSISTANT', value: 'ASSISTANT' },
]
const MessageEditor: FC<TextareaProps> = ({
const MessageEditor: FC<MessageEditor> = ({
title,
isArray = true,
parentName = 'messages',
placeholder,
options,
enableJinja2 = false,
}) => {
const { t } = useTranslation()
const form = Form.useFormInstance();
const values = form.getFieldsValue()
const values = Form.useWatch([], form);
// 检查是否已经使用了context变量将已使用的context设置为disabled
const processedOptions = useMemo(() => {
if (!isArray || !values[parentName]) return options;
if (!isArray) return options;
// 获取表单中对应字段的值
const fieldValue = Array.isArray(parentName)
? parentName.reduce((obj, key) => obj?.[key], values)
: values?.[parentName];
if (!fieldValue) return options;
// 获取所有消息内容
const allContents = values[parentName]
.map((msg: any) => msg.content || '')
const allContents = fieldValue
.map((msg: any) => msg?.content || '')
.join(' ');
// 将已使用的context变量标记为disabled
@@ -50,83 +59,82 @@ const MessageEditor: FC<TextareaProps> = ({
}, [options, values, parentName, isArray]);
const handleAdd = (add: FormListOperation['add']) => {
const list = values[parentName];
const lastRole = list[list.length - 1].role
const fieldValue = Array.isArray(parentName)
? parentName.reduce((obj, key) => obj?.[key], values)
: values?.[parentName];
const list = fieldValue || [];
const lastRole = list.length > 0 ? list[list.length - 1]?.role : 'ASSISTANT';
add({
role: lastRole === 'USER' ? 'ASSISTANT' : 'USER',
content: undefined
})
content: ''
});
};
if (!isArray) {
return (
<Space size={12} direction="vertical" className="rb:w-full rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5 rb:bg-white" data-editor-type={parentName === 'template' ? 'template' : undefined}>
<Row>
<Col span={12}>
{title ?? t('workflow.answerDesc')}
</Col>
</Row>
<Form.Item name={parentName} noStyle>
<Editor enableJinja2={enableJinja2} placeholder={placeholder} options={processedOptions} />
</Form.Item>
</Space>
);
}
return (
<div>
{isArray
? <Form.List name={parentName}>
{(fields, { add, remove }) => (
<Space size={12} direction="vertical" className="rb:w-full">
{fields.map(({ key, name, ...restField }) => {
const currentRole = (values[parentName]?.[key].role || 'USER').toUpperCase()
return (
<Space key={key} size={12} direction="vertical" className="rb:w-full rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5 rb:bg-white">
<Row>
<Col span={12}>
<Form.Item
{...restField}
name={[name, 'role']}
noStyle
>
{currentRole === 'SYSTEM'
? <Input disabled />
:
<Select
options={roleOptions}
disabled={currentRole === 'SYSTEM'}
/>
}
</Form.Item>
</Col>
{currentRole !== 'SYSTEM' && <Col span={12}>
<div className="rb:h-full rb:flex rb:justify-end rb:items-center">
<MinusCircleOutlined onClick={() => remove(name)} />
</div>
</Col>}
</Row>
<Form.Item
{...restField}
name={[name, 'content']}
noStyle
>
<Editor placeholder={placeholder} options={processedOptions} />
<Form.List name={parentName}>
{(fields, { add, remove }) => (
<Space size={12} direction="vertical" className="rb:w-full">
{fields.map(({ key, name, ...restField }) => {
const fieldValue = Array.isArray(parentName)
? parentName.reduce((obj, key) => obj?.[key], values)
: values?.[parentName];
const currentRole = (fieldValue?.[name]?.role || 'USER').toUpperCase();
return (
<Space key={key} size={12} direction="vertical" className="rb:w-full rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5 rb:bg-white">
<Row>
<Col span={12}>
<Form.Item {...restField} name={[name, 'role']} noStyle>
{currentRole === 'SYSTEM' ? (
<Input disabled />
) : (
<Select
options={roleOptions}
disabled={currentRole === 'SYSTEM'}
/>
)}
</Form.Item>
</Space>
)
})}
<Form.Item>
<Button type="dashed" onClick={() => handleAdd(add)} block>
+{t('workflow.addMessage')}
</Button>
</Form.Item>
</Space >
)}
</Form.List>
:
<Space size={12} direction="vertical" className="rb:w-full rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5 rb:bg-white">
<Row>
<Col span={12}>
{title ?? t('workflow.answerDesc')}
</Col>
</Row>
<Form.Item
name={parentName}
noStyle
>
<Editor placeholder={placeholder} options={processedOptions} />
</Col>
{currentRole !== 'SYSTEM' && (
<Col span={12}>
<div className="rb:h-full rb:flex rb:justify-end rb:items-center">
<MinusCircleOutlined onClick={() => remove(name)} />
</div>
</Col>
)}
</Row>
<Form.Item {...restField} name={[name, 'content']} noStyle>
<Editor enableJinja2={enableJinja2} placeholder={placeholder} options={processedOptions} />
</Form.Item>
</Space>
);
})}
<Form.Item>
<Button type="dashed" onClick={() => handleAdd(add)} block>
+{t('workflow.addMessage')}
</Button>
</Form.Item>
</Space>
}
</div>
)}
</Form.List>
);
};

View File

@@ -103,6 +103,7 @@ const ParamEditModal = forwardRef<ParamEditModalRef, ParamEditModalProps>(({
<FormItem
name="desc"
label={t('workflow.config.parameter-extractor.desc')}
rules={[{ required: true, message: t('common.pleaseEnter') }]}
>
<Input.TextArea placeholder={t('common.enter')} />
</FormItem>

View File

@@ -197,7 +197,14 @@ const ToolConfig: FC<{ options: Suggestion[]; }> = ({
: parameter.type === 'boolean'
? <Switch />
: parameter.type === 'integer' || parameter.type === 'number'
? <InputNumber min={parameter.minimum} max={parameter.maximum} step={parameter.type === 'integer' ? 1 : 0.01} placeholder={t('common.pleaseEnter')} className="rb:w-full!" />
? <InputNumber
min={parameter.minimum}
max={parameter.maximum}
step={parameter.type === 'integer' ? 1 : 0.01}
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
onChange={(value) => form.setFieldValue(['tool_parameters', parameter.name], value)}
/>
: <Editor
height={32}
variant="outlined"

View File

@@ -141,7 +141,11 @@ const VariableEditModal = forwardRef<VariableEditModalRef, VariableEditModalProp
name="max_length"
label={t('workflow.config.start.max_length')}
>
<InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />
<InputNumber
placeholder={t('common.enter')}
style={{ width: '100%' }}
onChange={(value) => form.setFieldValue('max_length', value)}
/>
</FormItem>
)}
{/* 默认值 */}
@@ -151,7 +155,13 @@ const VariableEditModal = forwardRef<VariableEditModalRef, VariableEditModalProp
label={t('workflow.config.start.default')}
>
{['string'].includes(values.type) && <Input placeholder={t('common.enter')} />}
{['number'].includes(values.type) && <InputNumber placeholder={t('common.enter')} style={{ width: '100%' }} />}
{['number'].includes(values.type) && (
<InputNumber
placeholder={t('common.enter')}
style={{ width: '100%' }}
onChange={(value) => form.setFieldValue('default', value)}
/>
)}
{['boolean'].includes(values.type) && <Select placeholder={t('common.pleaseSelect')} options={[{ value: true, label: t('workflow.config.start.defaultChecked') }, { value: false, label: t('workflow.config.start.notDefaultChecked') }]} />}
</FormItem>
)}

View File

@@ -9,6 +9,7 @@ interface VariableSelectProps extends SelectProps {
value?: string;
onChange?: (value: string) => void;
allowClear?: boolean;
filterBooleanType?: boolean;
}
const VariableSelect: FC<VariableSelectProps> = ({
@@ -18,6 +19,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
allowClear = true,
onChange,
size,
filterBooleanType = false,
...resetPorps
}) => {
@@ -26,7 +28,7 @@ const VariableSelect: FC<VariableSelectProps> = ({
}
const labelRender: LabelRender = (props) => {
const { value } = props
const filterOption = options.find(vo => `{{${vo.value}}}` === value)
const filterOption = filteredOptions.find(vo => `{{${vo.value}}}` === value)
if (filterOption) {
return (
@@ -54,7 +56,11 @@ const VariableSelect: FC<VariableSelectProps> = ({
}
return null
}
const groupedSuggestions = options.reduce((groups: Record<string, any[]>, suggestion) => {
const filteredOptions = filterBooleanType
? options.filter(option => option.dataType !== 'boolean')
: options;
const groupedSuggestions = filteredOptions.reduce((groups: Record<string, any[]>, suggestion) => {
const { nodeData } = suggestion
const nodeId = nodeData.id as string;
if (!groups[nodeId]) {
@@ -64,12 +70,13 @@ const VariableSelect: FC<VariableSelectProps> = ({
return groups;
}, {});
const groupedOptions = Object.entries(groupedSuggestions).map(([nodeId, suggestions]) => ({
const groupedOptions = Object.entries(groupedSuggestions).map(([_nodeId, suggestions]) => ({
label: suggestions[0].nodeData.name,
options: suggestions.map(s => ({ label: s.label, value: `{{${s.value}}}` }))
options: suggestions.map(s => ({
label: <div className="rb:flex rb:items-center rb:gap-1 rb:justify-between"> { s.label } <span>{s.dataType}</span></div>,
value: `{{${s.value}}}`
}))
}));
console.log('groupedOptions', groupedOptions)
return (
<Select

View File

@@ -36,7 +36,7 @@ interface PropertiesProps {
const Properties: FC<PropertiesProps> = ({
selectedNode,
graphRef,
config,
config: workflowConfig,
}) => {
const { t } = useTranslation()
const { modal } = App.useApp()
@@ -45,13 +45,134 @@ const Properties: FC<PropertiesProps> = ({
const values = Form.useWatch([], form);
const variableModalRef = useRef<VariableEditModalRef>(null)
const [editIndex, setEditIndex] = useState<number | null>(null)
const prevMappingNamesRef = useRef<string[]>([])
const prevTemplateVarsRef = useRef<string[]>([])
const syncTimeoutRef = useRef<number | null>(null)
const isSyncingRef = useRef(false)
const lastSyncSourceRef = useRef<'mapping' | 'template' | null>(null)
useEffect(() => {
if (selectedNode?.getData()?.id) {
form.resetFields()
prevMappingNamesRef.current = []
prevTemplateVarsRef.current = []
lastSyncSourceRef.current = null
}
}, [selectedNode?.getData()?.id])
// Sync template when mapping names change
useEffect(() => {
if (isSyncingRef.current || lastSyncSourceRef.current === 'mapping' || selectedNode?.data?.type !== 'jinja-render' || !values?.mapping || !values?.template) return
const currentMappingNames = Array.isArray(values.mapping) ? values.mapping.map((item: any) => item.name).filter(Boolean) : []
const prevNames = prevMappingNamesRef.current
if (prevNames.length === 0) {
prevMappingNamesRef.current = currentMappingNames
return
}
if (JSON.stringify(prevNames) === JSON.stringify(currentMappingNames)) return
if (syncTimeoutRef.current) clearTimeout(syncTimeoutRef.current)
const activeElement = document.activeElement as HTMLElement
syncTimeoutRef.current = setTimeout(() => {
let updatedTemplate = String(form.getFieldValue('template') || '')
prevNames.forEach((oldName, index) => {
const newName = currentMappingNames[index]
if (newName && oldName !== newName) {
updatedTemplate = updatedTemplate.replace(new RegExp(`{{\\s*${oldName}\\s*}}`, 'g'), `{{${newName}}}`)
}
})
if (updatedTemplate !== form.getFieldValue('template')) {
isSyncingRef.current = true
lastSyncSourceRef.current = 'mapping'
const newTemplateVars = (updatedTemplate.match(/{{\s*([\w.]+)\s*}}/g) || []).map(m => m.replace(/{{\s*|\s*}}/g, ''))
prevTemplateVarsRef.current = newTemplateVars
prevMappingNamesRef.current = currentMappingNames
form.setFieldValue('template', updatedTemplate)
requestAnimationFrame(() => {
activeElement?.focus?.()
setTimeout(() => {
isSyncingRef.current = false
lastSyncSourceRef.current = null
}, 50)
})
} else {
prevMappingNamesRef.current = currentMappingNames
}
}, 0)
}, [values?.mapping, selectedNode?.data?.type, form])
// Sync mapping when template variables change
useEffect(() => {
if (isSyncingRef.current || lastSyncSourceRef.current === 'template' || selectedNode?.data?.type !== 'jinja-render' || !values?.template || !values?.mapping) return
const templateVars = (String(values.template).match(/{{\s*([\w.]+)\s*}}/g) || []).map(m => m.replace(/{{\s*|\s*}}/g, ''))
if (JSON.stringify(prevTemplateVarsRef.current) === JSON.stringify(templateVars)) return
const isTemplateEditor = document.activeElement?.closest('[data-editor-type="template"]')
if (!isTemplateEditor) {
prevTemplateVarsRef.current = templateVars
return
}
const updatedMapping = Array.isArray(values.mapping) ? [...values.mapping] : []
const existingNames = updatedMapping.map(item => item.name)
let updatedTemplate = String(values.template)
if (prevTemplateVarsRef.current.length > 0) {
prevTemplateVarsRef.current.forEach((oldVar, index) => {
const newVar = templateVars[index]
if (newVar && oldVar !== newVar && updatedMapping[index]) {
updatedMapping[index] = { ...updatedMapping[index], name: newVar }
}
})
}
templateVars.forEach(varName => {
const existingMapping = updatedMapping.find(item => item.value === `{{${varName}}}`)
const regex = new RegExp(`{{\\s*${varName.replace(/\./g, '\\.')}\\s*}}`, 'g')
if (existingMapping) {
updatedTemplate = updatedTemplate.replace(regex, `{{${existingMapping.name}}}`)
} else if (!existingNames.includes(varName)) {
const mappingName = varName.includes('.') ? varName.split('.').pop() || varName : varName
updatedMapping.push({ name: mappingName, value: `{{${varName}}}` })
updatedTemplate = updatedTemplate.replace(regex, `{{${mappingName}}}`)
}
})
const seenNames = new Set<string>()
const finalMapping = updatedMapping.filter(item => {
const isUsed = templateVars.some(v => item.name === v || item.value === `{{${v}}}`)
if (!isUsed || seenNames.has(item.name)) return false
seenNames.add(item.name)
return true
})
isSyncingRef.current = true
lastSyncSourceRef.current = 'template'
prevMappingNamesRef.current = finalMapping.map((item: any) => item.name).filter(Boolean)
prevTemplateVarsRef.current = templateVars
if (JSON.stringify(finalMapping) !== JSON.stringify(values.mapping)) {
form.setFieldValue('mapping', finalMapping)
}
if (updatedTemplate !== String(values.template)) {
form.setFieldValue('template', updatedTemplate)
}
setTimeout(() => {
isSyncingRef.current = false
lastSyncSourceRef.current = null
}, 50)
}, [values?.template, selectedNode?.data?.type, form])
useEffect(() => {
if (selectedNode && form) {
const { type = 'default', name = '', config } = selectedNode.getData() || {}
@@ -82,18 +203,9 @@ const Properties: FC<PropertiesProps> = ({
useEffect(() => {
if (values && selectedNode) {
const { id, knowledge_retrieval, group, group_names, ...rest } = values
const { id, knowledge_retrieval, group, group_variables, ...rest } = values
const { knowledge_bases = [], ...restKnowledgeConfig } = (knowledge_retrieval as any) || {}
let groupNames: Record<string, string[]> | string[] = {}
if (group && group_names?.length) {
group_names.forEach(vo => {
(groupNames as Record<string, string[]>)[vo.key] = vo.value
})
} else if (!group) {
groupNames = group_names?.[0]?.value || []
}
let allRest = {
...rest,
...restKnowledgeConfig,
@@ -105,9 +217,18 @@ const Properties: FC<PropertiesProps> = ({
}))
}
Object.keys(values).forEach(key => {
if (selectedNode.data?.config?.[key]) {
selectedNode.data.config[key].defaultValue = values[key]
// Create a deep copy to avoid reference sharing between nodes
if (!selectedNode.data.config[key]) {
selectedNode.data.config[key] = {};
}
selectedNode.data.config[key] = {
...selectedNode.data.config[key],
defaultValue: values[key]
};
}
})
@@ -116,7 +237,7 @@ const Properties: FC<PropertiesProps> = ({
...allRest,
})
}
}, [values, selectedNode])
}, [values, selectedNode, form])
const handleAddVariable = () => {
variableModalRef.current?.handleOpen()
@@ -192,16 +313,95 @@ const Properties: FC<PropertiesProps> = ({
.map(node => node.id);
};
// Find parent loop/iteration node if current node is a child
const getParentLoopNode = (nodeId: string): Node | null => {
const node = nodes.find(n => n.id === nodeId);
if (!node) return null;
const nodeData = node.getData();
const cycle = nodeData?.cycle;
if (cycle) {
const parentNode = nodes.find(n => n.getData().id === cycle);
if (parentNode) {
const parentData = parentNode.getData();
if (parentData?.type === 'loop' || parentData?.type === 'iteration') {
return parentNode;
}
}
}
return null;
};
const allPreviousNodeIds = getAllPreviousNodes(selectedNode.id);
const childNodeIds = getChildNodes(selectedNode.id);
console.log('childNodeIds', childNodeIds)
const parentLoopNode = getParentLoopNode(selectedNode.id);
console.log('childNodeIds', selectedNode, childNodeIds)
const allRelevantNodeIds = [...allPreviousNodeIds, ...childNodeIds];
// Add parent loop/iteration node variables if current node is a child
if (parentLoopNode) {
const parentData = parentLoopNode.getData();
const parentNodeId = parentLoopNode.getData().id;
if (parentData.type === 'loop') {
const cycleVars = parentData.cycle_vars || [];
cycleVars.forEach((cycleVar: any) => {
const key = `${parentNodeId}_cycle_${cycleVar.name}`;
if (!addedKeys.has(key)) {
addedKeys.add(key);
variableList.push({
key,
label: cycleVar.name,
type: 'variable',
dataType: cycleVar.type || 'String',
value: `${parentNodeId}.${cycleVar.name}`,
nodeData: parentData,
});
}
});
} else if (parentData.type === 'iteration') {
// Add item and index variables for iteration parent
const itemKey = `${parentNodeId}_item`;
const indexKey = `${parentNodeId}_index`;
if (!addedKeys.has(itemKey)) {
addedKeys.add(itemKey);
variableList.push({
key: itemKey,
label: 'item',
type: 'variable',
dataType: 'Object',
value: `${parentNodeId}.item`,
nodeData: parentData,
});
}
if (!addedKeys.has(indexKey)) {
addedKeys.add(indexKey);
variableList.push({
key: indexKey,
label: 'index',
type: 'variable',
dataType: 'Number',
value: `${parentNodeId}.index`,
nodeData: parentData,
});
}
}
// Add variables from nodes preceding the parent loop/iteration node
const parentPreviousNodeIds = getAllPreviousNodes(parentLoopNode.id);
allRelevantNodeIds.push(...parentPreviousNodeIds);
}
allRelevantNodeIds.forEach(nodeId => {
const node = nodes.find(n => n.id === nodeId);
if (!node) return;
const nodeData = node.getData();
const dataNodeId = nodeData.id; // Use the data.id instead of node.id for consistency
switch(nodeData.type) {
case 'start':
@@ -211,7 +411,7 @@ const Properties: FC<PropertiesProps> = ({
]
list.forEach((variable: any) => {
if (!variable || !variable?.name) return;
const key = `${nodeId}_${variable.name}`;
const key = `${dataNodeId}_${variable.name}`;
if (!addedKeys.has(key)) {
addedKeys.add(key);
variableList.push({
@@ -219,14 +419,14 @@ const Properties: FC<PropertiesProps> = ({
label: variable.name,
type: 'variable',
dataType: variable.type,
value: `{{${nodeId}.${variable.name}}}`,
value: `${dataNodeId}.${variable.name}`,
nodeData: nodeData,
});
}
});
nodeData.config?.variables?.sys?.forEach((variable: any) => {
if (!variable || !variable?.name) return;
const key = `${nodeId}_sys_${variable.name}`;
const key = `${dataNodeId}_sys_${variable.name}`;
if (!addedKeys.has(key)) {
addedKeys.add(key);
variableList.push({
@@ -241,7 +441,7 @@ const Properties: FC<PropertiesProps> = ({
});
break
case 'llm':
const llmKey = `${nodeId}_output`;
const llmKey = `${dataNodeId}_output`;
if (!addedKeys.has(llmKey)) {
addedKeys.add(llmKey);
variableList.push({
@@ -249,13 +449,13 @@ const Properties: FC<PropertiesProps> = ({
label: 'output',
type: 'variable',
dataType: 'String',
value: `${nodeId}.output`,
value: `${dataNodeId}.output`,
nodeData: nodeData,
});
}
break
case 'knowledge-retrieval':
const knowledgeKey = `${nodeId}_message`;
const knowledgeKey = `${dataNodeId}_message`;
if (!addedKeys.has(knowledgeKey)) {
addedKeys.add(knowledgeKey);
variableList.push({
@@ -263,7 +463,219 @@ const Properties: FC<PropertiesProps> = ({
label: 'message',
type: 'variable',
dataType: 'array[object]',
value: `${nodeId}.message`,
value: `${dataNodeId}.message`,
nodeData: nodeData,
});
}
break
case 'parameter-extractor':
const successKey = `${dataNodeId}___is_success`;
const reasonKey = `${dataNodeId}___reason`;
if (!addedKeys.has(successKey)) {
addedKeys.add(successKey);
variableList.push({
key: successKey,
label: '__is_success',
type: 'variable',
dataType: 'number',
value: `${dataNodeId}.__is_success`,
nodeData: nodeData,
});
}
if (!addedKeys.has(reasonKey)) {
addedKeys.add(reasonKey);
variableList.push({
key: reasonKey,
label: '__reason',
type: 'variable',
dataType: 'string',
value: `${dataNodeId}.__reason`,
nodeData: nodeData,
});
}
// Add params variables
const paramsList = nodeData.config?.params?.defaultValue || [];
paramsList.forEach((param: any) => {
if (!param || !param?.name) return;
const paramKey = `${dataNodeId}_${param.name}`;
if (!addedKeys.has(paramKey)) {
addedKeys.add(paramKey);
variableList.push({
key: paramKey,
label: param.name,
type: 'variable',
dataType: param.type || 'string',
value: `${dataNodeId}.${param.name}`,
nodeData: nodeData,
});
}
});
break
case 'var-aggregator':
if (nodeData.config.group.defaultValue) {
// If group=true, add variables from group_variables with key as variable name
const groupVariables = nodeData.config.group_variables.defaultValue || [];
groupVariables?.forEach((groupVar: any) => {
if (!groupVar || !groupVar.key) return;
const groupVarKey = `${dataNodeId}_${groupVar.key}`;
if (!addedKeys.has(groupVarKey)) {
addedKeys.add(groupVarKey);
variableList.push({
key: groupVarKey,
label: groupVar.key,
type: 'variable',
dataType: 'string',
value: `${dataNodeId}.${groupVar.key}`,
nodeData: nodeData,
});
}
});
} else {
// If group=false, add output variable
const varAggregatorKey = `${dataNodeId}_output`;
if (!addedKeys.has(varAggregatorKey)) {
addedKeys.add(varAggregatorKey);
variableList.push({
key: varAggregatorKey,
label: 'output',
type: 'variable',
dataType: 'string',
value: `${dataNodeId}.output`,
nodeData: nodeData,
});
}
}
break
case 'http-request':
const httpBodyKey = `${dataNodeId}_body`;
const httpStatusKey = `${dataNodeId}_status_code`;
if (!addedKeys.has(httpBodyKey)) {
addedKeys.add(httpBodyKey);
variableList.push({
key: httpBodyKey,
label: 'body',
type: 'variable',
dataType: 'string',
value: `${dataNodeId}.body`,
nodeData: nodeData,
});
}
if (!addedKeys.has(httpStatusKey)) {
addedKeys.add(httpStatusKey);
variableList.push({
key: httpStatusKey,
label: 'status_code',
type: 'variable',
dataType: 'number',
value: `${dataNodeId}.status_code`,
nodeData: nodeData,
});
}
break
case 'jinja-render':
const jinjaOutputKey = `${dataNodeId}_output`;
if (!addedKeys.has(jinjaOutputKey)) {
addedKeys.add(jinjaOutputKey);
variableList.push({
key: jinjaOutputKey,
label: 'output',
type: 'variable',
dataType: 'string',
value: `${dataNodeId}.output`,
nodeData: nodeData,
});
}
break
case 'question-classifier':
const classNameKey = `${dataNodeId}_class_name`;
const outputKey = `${dataNodeId}_output`;
if (!addedKeys.has(classNameKey)) {
addedKeys.add(classNameKey);
variableList.push({
key: classNameKey,
label: 'class_name',
type: 'variable',
dataType: 'string',
value: `${dataNodeId}.class_name`,
nodeData: nodeData,
});
}
if (!addedKeys.has(outputKey)) {
addedKeys.add(outputKey);
variableList.push({
key: outputKey,
label: 'output',
type: 'variable',
dataType: 'string',
value: `${dataNodeId}.output`,
nodeData: nodeData,
});
}
break
case 'iteration':
console.log('iteration addedKeys', addedKeys)
const iterationOutputKey = `${dataNodeId}_output`;
const iterationItemKey = `${dataNodeId}_item`;
if (!addedKeys.has(iterationOutputKey)) {
addedKeys.add(iterationOutputKey);
// Get the data type from the output configuration, default to string
const outputConfig = nodeData.output;
let outputDataType = 'string';
if (outputConfig) {
// Find the selected variable from variableList to get its type
const selectedVariable = variableList.find(v => v.value === outputConfig);
if (selectedVariable) {
outputDataType = selectedVariable.dataType;
}
}
variableList.push({
key: iterationOutputKey,
label: 'output',
type: 'variable',
dataType: outputDataType,
value: `${dataNodeId}.output`,
nodeData: nodeData,
});
}
if (!addedKeys.has(iterationItemKey)) {
addedKeys.add(iterationItemKey);
variableList.push({
key: iterationItemKey,
label: 'item',
type: 'variable',
dataType: 'string',
value: `${dataNodeId}.item`,
nodeData: nodeData,
});
}
break
case 'loop':
const cycleVars = nodeData.config.cycle_vars.defaultValue || [];
cycleVars.forEach((cycleVar: any) => {
const cycleVarKey = `${dataNodeId}_cycle_${cycleVar.name}`;
if (!addedKeys.has(cycleVarKey)) {
addedKeys.add(cycleVarKey);
variableList.push({
key: cycleVarKey,
label: cycleVar.name,
type: 'variable',
dataType: cycleVar.type || 'string',
value: `${dataNodeId}.${cycleVar.name}`,
nodeData: nodeData,
});
}
});
break
case 'tool':
const toolDataKey = `${dataNodeId}_data`;
if (!addedKeys.has(toolDataKey)) {
addedKeys.add(toolDataKey);
variableList.push({
key: toolDataKey,
label: 'data',
type: 'variable',
dataType: 'object',
value: `${dataNodeId}.data`,
nodeData: nodeData,
});
}
@@ -272,7 +684,7 @@ const Properties: FC<PropertiesProps> = ({
});
// Add conversation variables from global config
const conversationVariables = config?.variables || [];
const conversationVariables = workflowConfig?.variables || [];
conversationVariables.forEach((variable: any) => {
const key = `CONVERSATION_${variable.name}`;
@@ -283,7 +695,7 @@ const Properties: FC<PropertiesProps> = ({
label: variable.name,
type: 'variable',
dataType: variable.type,
value: `conversation.${variable.name}`,
value: `conv.${variable.name}`,
nodeData: { type: 'CONVERSATION', name: 'CONVERSATION', icon: '' },
group: 'CONVERSATION'
});
@@ -291,7 +703,15 @@ const Properties: FC<PropertiesProps> = ({
});
return variableList;
}, [selectedNode, graphRef]);
}, [selectedNode, graphRef, workflowConfig?.variables]);
// Filter out boolean type variables for loop and llm nodes
const getFilteredVariableList = (nodeType?: string) => {
if (nodeType === 'loop' || nodeType === 'llm') {
return variableList.filter(variable => variable.dataType !== 'boolean');
}
return variableList;
};
console.log('values', values)
console.log('variableList', variableList, selectedNode?.data)
@@ -317,6 +737,8 @@ const Properties: FC<PropertiesProps> = ({
{selectedNode?.data?.type === 'http-request'
? <HttpRequest
options={variableList}
selectedNode={selectedNode}
graphRef={graphRef}
/>
: selectedNode?.data?.type === 'tool'
? <ToolConfig options={variableList} />
@@ -374,7 +796,7 @@ const Properties: FC<PropertiesProps> = ({
if (selectedNode?.data?.type === 'llm' && key === 'messages' && config.type === 'define') {
// 为llm节点且isArray=true时添加context变量支持
let contextVariableList = [...variableList];
let contextVariableList = [...getFilteredVariableList('llm')];
const isArrayMode = config.isArray !== false; // 默认为true
if (isArrayMode) {
@@ -387,7 +809,7 @@ const Properties: FC<PropertiesProps> = ({
label: 'context',
type: 'variable',
dataType: 'String',
value: `{{context}}`,
value: `context`,
nodeData: selectedNode.getData(),
isContext: true,
});
@@ -396,14 +818,14 @@ const Properties: FC<PropertiesProps> = ({
return (
<Form.Item key={key} name={key}>
<MessageEditor options={contextVariableList} parentName={key} />
<MessageEditor key={key} options={contextVariableList} parentName={key} />
</Form.Item>
)
}
if (selectedNode?.data?.type === 'end' && key === 'output') {
return (
<Form.Item key={key} name={key}>
<MessageEditor isArray={false} parentName={key} options={variableList} />
<MessageEditor key={key} isArray={false} parentName={key} options={variableList} />
</Form.Item>
)
}
@@ -430,7 +852,8 @@ const Properties: FC<PropertiesProps> = ({
title={t(`workflow.config.${selectedNode?.data?.type}.${key}`)}
isArray={!!config.isArray}
parentName={key}
options={variableList}
enableJinja2={config.enableJinja2 as boolean}
options={getFilteredVariableList(selectedNode?.data?.type)}
/>
</Form.Item>
)
@@ -451,7 +874,7 @@ const Properties: FC<PropertiesProps> = ({
<Form.Item key={key} name={key}>
<GroupVariableList
name={key}
options={variableList}
options={getFilteredVariableList(selectedNode?.data?.type)}
isCanAdd={!!(values as any)?.group}
/>
</Form.Item>
@@ -463,7 +886,7 @@ const Properties: FC<PropertiesProps> = ({
<Form.Item key={key} name={key}>
<CaseList
name={key}
options={variableList}
options={getFilteredVariableList(selectedNode?.data?.type)}
selectedNode={selectedNode}
graphRef={graphRef}
/>
@@ -476,7 +899,7 @@ const Properties: FC<PropertiesProps> = ({
<Form.Item key={key} name={key}
label={t(`workflow.config.${selectedNode?.data?.type}.${key}`)}
>
<MappingList name={key} />
<MappingList name={key} options={getFilteredVariableList(selectedNode?.data?.type)} />
</Form.Item>
)
@@ -486,7 +909,7 @@ const Properties: FC<PropertiesProps> = ({
<Form.Item key={key} name={key}>
<CycleVarsList
parentName={key}
options={variableList}
options={getFilteredVariableList(selectedNode?.data?.type)}
/>
</Form.Item>
)
@@ -498,71 +921,11 @@ const Properties: FC<PropertiesProps> = ({
parentName={key}
options={(() => {
if (config.filterLoopIterationVars) {
// Add loop cycle variables and iteration item/index variables
const loopIterationVars: Suggestion[] = [];
const graph = graphRef.current;
if (graph && selectedNode) {
const nodes = graph.getNodes();
// Find parent loop/iteration nodes
const findParentLoopIteration = (nodeId: string): string[] => {
const node = nodes.find(n => n.id === nodeId);
if (!node) return [];
const nodeData = node.getData();
const cycle = nodeData?.cycle;
if (cycle) {
const parentNode = nodes.find(n => n.getData().id === cycle);
if (parentNode) {
const parentData = parentNode.getData();
if (parentData?.type === 'loop') {
console.log('parentData', parentData)
// Add cycle variables from loop node
const cycleVars = parentData.cycle_vars || [];
cycleVars.forEach((cycleVar: any) => {
loopIterationVars.push({
key: `${cycle}_cycle_${cycleVar.name}`,
label: cycleVar.name,
type: 'variable',
dataType: 'String',
value: `${cycle}.${cycleVar.name}`,
nodeData: parentData,
});
});
} else if (parentData?.type === 'iteration') {
// Add item and index variables from iteration node
loopIterationVars.push(
{
key: `${cycle}_item`,
label: 'item',
type: 'variable',
dataType: 'Object',
value: `${cycle}.item`,
nodeData: parentData,
},
{
key: `${cycle}_index`,
label: 'index',
type: 'variable',
dataType: 'Number',
value: `${cycle}.index`,
nodeData: parentData,
}
);
}
return [cycle, ...findParentLoopIteration(cycle)];
}
}
return [];
};
findParentLoopIteration(selectedNode.id);
}
return [...variableList, ...loopIterationVars];
return [...getFilteredVariableList(selectedNode?.data?.type), ...loopIterationVars];
}
return variableList;
return getFilteredVariableList(selectedNode?.data?.type);
})()
}
/>
@@ -583,11 +946,15 @@ const Properties: FC<PropertiesProps> = ({
? <Input.TextArea placeholder={t('common.pleaseEnter')} />
: config.type === 'select'
? <Select
options={config.needTranslation ? config.options?.map(vo => ({ ...vo, label: t(vo.label) })) : config.options}
options={config.needTranslation ? (config.options || []).map(vo => ({ ...vo, label: t(vo.label) })) : config.options}
placeholder={t('common.pleaseSelect')}
/>
: config.type === 'inputNumber'
? <InputNumber />
? <InputNumber
placeholder={t('common.pleaseEnter')}
className="rb:w-full!"
onChange={(value) => form.setFieldValue(key, value)}
/>
: config.type === 'slider'
? <Slider min={config.min} max={config.max} step={config.step} />
: config.type === 'customSelect'
@@ -603,9 +970,10 @@ const Properties: FC<PropertiesProps> = ({
? <VariableSelect
placeholder={t('common.pleaseSelect')}
options={(() => {
const baseVariableList = getFilteredVariableList(selectedNode?.data?.type);
// Apply filtering if specified in config
if (config.filterNodeTypes || config.filterVariableNames) {
return variableList.filter(variable => {
return baseVariableList.filter(variable => {
const nodeTypeMatch = !config.filterNodeTypes ||
(Array.isArray(config.filterNodeTypes) && config.filterNodeTypes.includes(variable.nodeData?.type));
const variableNameMatch = !config.filterVariableNames ||
@@ -626,22 +994,57 @@ const Properties: FC<PropertiesProps> = ({
return nodeData?.cycle === selectedNode.id;
});
return variableList.filter(variable =>
return baseVariableList.filter(variable =>
childNodes.some(node => node.id === variable.nodeData?.id)
);
}
return variableList;
return baseVariableList;
})()
}
/>
: config.type === 'switch'
? <Switch />
? <Switch onChange={key === 'group' ? () => { form.setFieldValue('group_variables', []) } : undefined} />
: config.type === 'categoryList'
? <CategoryList parentName={key} selectedNode={selectedNode} graphRef={graphRef} />
: config.type === 'conditionList'
? <ConditionList
parentName={key}
options={variableList}
options={(() => {
// For loop nodes, add cycle_vars to condition options
if (selectedNode?.data?.type === 'loop') {
const cycleVars = values?.cycle_vars || [];
const cycleVarSuggestions: Suggestion[] = cycleVars.map((cycleVar: any) => ({
key: `${selectedNode.id}_cycle_${cycleVar.name}`,
label: cycleVar.name,
type: 'variable',
dataType: cycleVar.type || 'String',
value: `${selectedNode.getData().id}.${cycleVar.name}`,
nodeData: selectedNode.getData(),
}));
return [...getFilteredVariableList(selectedNode?.data?.type).filter(variable => {
// Keep conversation variables
if (variable.group === 'CONVERSATION') return true;
// Keep sys variables from start nodes
if (variable.nodeData?.type === 'start' && variable.value?.startsWith('sys.')) return true;
// Keep variables from non-start nodes
if (variable.nodeData?.type !== 'start') return true;
// Filter out custom variables from start nodes
return false;
}), ...cycleVarSuggestions];
}
// Filter options for condition list: only sys variables from start nodes and conversation variables
return getFilteredVariableList(selectedNode?.data?.type).filter(variable => {
// Keep conversation variables
if (variable.group === 'CONVERSATION') return true;
// Keep sys variables from start nodes
if (variable.nodeData?.type === 'start' && variable.value?.startsWith('sys.')) return true;
// Keep variables from non-start nodes
if (variable.nodeData?.type !== 'start') return true;
// Filter out custom variables from start nodes
return false;
});
})()
}
selectedNode={selectedNode}
graphRef={graphRef}
addBtnText={t('workflow.config.addCase')}

View File

@@ -39,6 +39,9 @@ import processEvolutionIcon from '@/assets/images/workflow/process_evolution.png
import questionClassifierIcon from '@/assets/images/workflow/question-classifier.png'
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 { memoryConfigListUrl } from '@/api/memory'
import { getModelListUrl } from '@/api/models'
@@ -159,6 +162,7 @@ export const nodeLibrary: NodeLibrary[] = [
},
text: {
type: 'variableList',
filterLoopIterationVars: true
},
params: {
type: 'paramList',
@@ -174,8 +178,7 @@ export const nodeLibrary: NodeLibrary[] = [
{
category: "cognitiveUpgrading",
nodes: [
{
type: "memory-read", icon: memoryEnhancementIcon,
{ type: "memory-read", icon: memoryReadIcon,
config: {
message: {
type: 'messageEditor',
@@ -198,7 +201,7 @@ export const nodeLibrary: NodeLibrary[] = [
}
}
},
{ type: "memory-write", icon: memoryEnhancementIcon,
{ type: "memory-write", icon: memoryWriteIcon,
config: {
message: {
type: 'messageEditor',
@@ -272,6 +275,7 @@ export const nodeLibrary: NodeLibrary[] = [
},
parallel: {
type: 'switch',
defaultValue: false
},
parallel_count: {
type: 'slider',
@@ -284,6 +288,7 @@ export const nodeLibrary: NodeLibrary[] = [
},
flatten: { // 扁平化输出
type: 'switch',
defaultValue: false
},
output: {
type: 'variableList',
@@ -295,6 +300,7 @@ export const nodeLibrary: NodeLibrary[] = [
config: {
cycle_vars: {
type: 'cycleVarsList',
defaultValue: []
},
condition: {
type: 'conditionList',
@@ -304,6 +310,13 @@ export const nodeLibrary: NodeLibrary[] = [
expressions: []
}
},
max_loop: {
type: 'slider',
min: 1,
max: 100,
step: 1,
defaultValue: 10
},
}
},
{ type: "cycle-start", icon: loopIcon },
@@ -315,9 +328,9 @@ export const nodeLibrary: NodeLibrary[] = [
type: 'switch',
defaultValue: false
},
group_names: {
group_variables: {
type: 'groupVariableList',
defaultValue: [{ key: 'Group1', value: []}]
defaultValue: [],
}
}
},
@@ -361,11 +374,11 @@ export const nodeLibrary: NodeLibrary[] = [
},
headers: {
type: 'define',
defaultValue: {}
defaultValue: []
},
params: {
type: 'define',
defaultValue: {}
defaultValue: []
},
body: {
type: 'define',
@@ -382,12 +395,15 @@ export const nodeLibrary: NodeLibrary[] = [
defaultValue: {}
},
retry: {
type: 'define',
type: 'switch',
defaultValue: {
enable: false
}
},
error_handle: {
type: 'define',
defaultValue: {
method: 'default'
method: 'none'
}
}
}
@@ -407,11 +423,13 @@ export const nodeLibrary: NodeLibrary[] = [
config: {
mapping: {
type: 'mappingList',
defaultValue: []
defaultValue: [{name: 'arg1'}]
},
template: {
type: 'messageEditor',
isArray: false,
enableJinja2: true,
defaultValue: "{{arg1}}"
},
}
}

View File

@@ -63,7 +63,17 @@ export const useWorkflowGraph = ({
if (!id) return
getWorkflowConfig(id)
.then(res => {
setConfig(res as WorkflowConfig)
const { variables, ...rest } = res as WorkflowConfig
setConfig({
...rest,
variables: variables.map(v => {
const { default: _, ...cleanV } = v
return {
...cleanV,
defaultValue: v.default ?? ''
}
})
})
})
}
@@ -90,13 +100,13 @@ export const useWorkflowGraph = ({
nodeLibraryConfig.config[key].defaultValue = {
...rest
}
} else if (key === 'group_names' && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
const { group_names, group } = config
} else if (key === 'group_variables' && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
const { group_variables, group } = config
nodeLibraryConfig.config[key].defaultValue = group
? Object.entries(group_names as Record<string, any>).map(([key, value]) => ({ key, value }))
: [{ key: 'Group1', value: group_names }]
console.log('group_names', nodeLibraryConfig.config)
? Object.entries(group_variables as Record<string, any>).map(([key, value]) => ({ key, value }))
: group_variables
} else if (type === 'http-request' && (key === 'headers' || key === 'params') && config[key] && typeof config[key] === 'object' && !Array.isArray(config[key]) && nodeLibraryConfig.config && nodeLibraryConfig.config[key]) {
nodeLibraryConfig.config[key].defaultValue = Object.entries(config[key]).map(([name, value]) => ({ name, value }))
} else if (nodeLibraryConfig.config && nodeLibraryConfig.config[key] && config[key]) {
nodeLibraryConfig.config[key].defaultValue = config[key]
}
@@ -193,6 +203,27 @@ export const useWorkflowGraph = ({
nodeConfig.height = newHeight;
}
// 如果是http-request节点检查error_handle.method配置
if (type === 'http-request' && (config as any).error_handle?.method === 'branch') {
const portAttrs = {
circle: {
r: 4, magnet: true, stroke: '#155EEF', strokeWidth: 2, fill: '#155EEF', position: { top: 22 }
},
};
nodeConfig.ports = {
groups: {
right: { position: 'right', attrs: portAttrs },
left: { position: 'left', attrs: portAttrs },
},
items: [
{ group: 'left' },
{ group: 'right', id: 'right' },
{ group: 'right', id: 'ERROR', attrs: { text: { text: t('workflow.config.http-request.errorBranch'), fontSize: 12, fill: '#5B6167' }}}
]
};
}
return nodeConfig
})
@@ -284,6 +315,14 @@ export const useWorkflowGraph = ({
}
}
// 如果是http-request节点且有label根据label匹配对应的端口
if (sourceCell.getData()?.type === 'http-request' && label) {
const matchingPort = sourcePorts.find((port: any) => port.id === label);
if (matchingPort) {
sourcePort = label;
}
}
const edgeConfig = {
source: {
cell: sourceCell.id,
@@ -303,6 +342,7 @@ export const useWorkflowGraph = ({
},
},
},
zIndex: 0
}
return edgeConfig
@@ -832,7 +872,7 @@ export const useWorkflowGraph = ({
// 创建干净的节点数据,只保留必要的字段
const cleanNodeData = {
id: `${dragData.type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
id: `${dragData.type.replace(/-/g, '_')}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
name: t(`workflow.${dragData.type}`),
...nodeLibraryConfig
};
@@ -842,6 +882,7 @@ export const useWorkflowGraph = ({
...graphNodeLibrary[dragData.type],
x: point.x - 150,
y: point.y - 100,
id: cleanNodeData.id,
data: { ...cleanNodeData, isGroup: true },
});
} else if (dragData.type === 'if-else') {
@@ -850,6 +891,7 @@ export const useWorkflowGraph = ({
...graphNodeLibrary[dragData.type],
x: point.x - 100,
y: point.y - 60,
id: cleanNodeData.id,
data: { ...cleanNodeData },
});
} else {
@@ -858,6 +900,7 @@ export const useWorkflowGraph = ({
...(graphNodeLibrary[dragData.type] || graphNodeLibrary.default),
x: point.x - 60,
y: point.y - 20,
id: cleanNodeData.id,
data: { ...cleanNodeData },
});
}
@@ -874,6 +917,13 @@ export const useWorkflowGraph = ({
const params = {
...config,
variables: config.variables.map(v => {
const { defaultValue, ...cleanV } = v
return {
...cleanV,
default: defaultValue ?? ''
}
}),
nodes: nodes.map((node: Node) => {
const data = node.getData();
const position = node.getPosition();
@@ -881,7 +931,23 @@ export const useWorkflowGraph = ({
if (data.config) {
Object.keys(data.config).forEach(key => {
if (data.config[key] && 'defaultValue' in data.config[key] && key !== 'knowledge_retrieval') {
if (data.config[key] && 'defaultValue' in data.config[key] && key === 'group_variables') {
let group_variables = data.config.group.defaultValue ? {} : data.config[key].defaultValue
if (data.config.group.defaultValue) {
data.config[key].defaultValue.map((vo: any) => {
group_variables[vo.key] = vo.value
})
}
itemConfig[key] = group_variables
} else if (data.type === 'http-request' && (key === 'headers' || key === 'params') && data.config[key] && 'defaultValue' in data.config[key]) {
const value = data.config[key].defaultValue
itemConfig[key] = {}
if (value.length > 0) {
value.forEach((vo: any) => {
itemConfig[key][vo.name] = vo.value
})
}
} else if (data.config[key] && 'defaultValue' in data.config[key] && key !== 'knowledge_retrieval') {
itemConfig[key] = data.config[key].defaultValue
} else if (key === 'knowledge_retrieval' && data.config[key] && 'defaultValue' in data.config[key]) {
const { knowledge_bases } = data.config[key].defaultValue
@@ -910,7 +976,7 @@ export const useWorkflowGraph = ({
const sourceCell = graphRef.current?.getCellById(edge.getSourceCellId());
const targetCell = graphRef.current?.getCellById(edge.getTargetCellId());
const sourcePortId = edge.getSourcePortId();
// 过滤无效连线源节点或目标节点不存在或者是add-node类型
if (!sourceCell?.getData()?.id || !targetCell?.getData()?.id ||
sourceCell?.getData()?.type === 'add-node' || targetCell?.getData()?.type === 'add-node') {
@@ -935,6 +1001,23 @@ export const useWorkflowGraph = ({
};
}
// 如果是http-request节点的右侧端口连线添加label
if (sourceCell?.getData()?.type === 'http-request') {
if (sourcePortId === 'ERROR') {
return {
source: sourceCell.getData().id,
target: targetCell?.getData().id,
label: 'ERROR',
};
} else {
return {
source: sourceCell.getData().id,
target: targetCell?.getData().id,
label: 'SUCCESS',
};
}
}
return {
source: sourceCell?.getData().id,
target: targetCell?.getData().id,

View File

@@ -24,8 +24,9 @@ export interface NodeConfig {
knowledge_retrieval?: KnowledgeConfig;
group_names?: Array<{ key: string, value: string[] }>
group_variables?: Array<{ key: string, value: string[] }>
cycle?: string;
cycle_vars?: Array<{ name: string; type: string; value: string; input_type: string; }>
[key: string]: unknown;
}
@@ -73,7 +74,8 @@ export interface WorkflowConfig {
type: string;
required: boolean;
description: string;
default: string;
default?: string;
defaultValue: string;
}>,
execution_config: {
max_execution_time: number;
@@ -113,7 +115,8 @@ export interface ChatVariable {
type: string;
required: boolean;
description: string;
default: string;
default?: string;
defaultValue: string;
}
export interface AddChatVariableRef {
handleOpen: (value?: ChatVariable) => void;