Merge #38 into develop_web from feature/20251219_zy
feat(web): update reflection engine result * feature/20251219_zy: (4 commits) fix(web): workflow properties feat(web): workflow support lexical editor feat(web): workflow support lexical editor feat(web): update reflection engine result Signed-off-by: zhaoying <zhaoying@redbearai.com> Merged-by: zhaoying <zhaoying@redbearai.com> CR-link: https://codeup.aliyun.com/redbearai/python/redbear-mem-open/change/38
This commit is contained in:
@@ -1579,6 +1579,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
|
|||||||
export: 'Export',
|
export: 'Export',
|
||||||
variableConfig: 'Variable Configuration',
|
variableConfig: 'Variable Configuration',
|
||||||
variableRequired: 'required',
|
variableRequired: 'required',
|
||||||
|
addMessage: 'Add Message',
|
||||||
|
answerDesc: 'Reply'
|
||||||
},
|
},
|
||||||
emotionEngine: {
|
emotionEngine: {
|
||||||
emotionEngineConfig: 'Emotion Engine Configuration',
|
emotionEngineConfig: 'Emotion Engine Configuration',
|
||||||
|
|||||||
@@ -1666,6 +1666,8 @@ export const zh = {
|
|||||||
export: '导出',
|
export: '导出',
|
||||||
variableConfig: '变量配置',
|
variableConfig: '变量配置',
|
||||||
variableRequired: '必填',
|
variableRequired: '必填',
|
||||||
|
addMessage: '添加消息',
|
||||||
|
answerDesc: '回复'
|
||||||
},
|
},
|
||||||
emotionEngine: {
|
emotionEngine: {
|
||||||
emotionEngineConfig: '情感引擎配置',
|
emotionEngineConfig: '情感引擎配置',
|
||||||
|
|||||||
@@ -272,69 +272,75 @@ const SelfReflectionEngine: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</RbCard>
|
</RbCard>
|
||||||
<RbCard
|
{result.reflexion_data.length > 0 && (
|
||||||
title={t('reflectionEngine.conflictDetection')}
|
<RbCard
|
||||||
>
|
title={t('reflectionEngine.conflictDetection')}
|
||||||
<Space size={12} direction="vertical" className="rb:w-full">
|
>
|
||||||
{result.reflexion_data.map((item, index) => (
|
<Space size={12} direction="vertical" className="rb:w-full">
|
||||||
|
{result.reflexion_data.map((item, index) => (
|
||||||
|
<div key={index} className="rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md rb:text-[12px]">
|
||||||
|
{['reason', 'solution'].map(key => (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="rb:flex rb:gap-4 rb:justify-start rb:text-[14px] rb:leading-5 rb:mb-3"
|
||||||
|
>
|
||||||
|
<div className="rb:whitespace-nowrap rb:w-45 rb:font-medium">{t(`reflectionEngine.${key}`)}</div>
|
||||||
|
<div className='rb:flex-inline rb:text-left rb:py-px rb:rounded rb:text-[#5B6167] rb:flex-1'>
|
||||||
|
{item[key as keyof ReflexionData]}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</RbCard>
|
||||||
|
)}
|
||||||
|
{result.quality_assessments.length > 0 && (
|
||||||
|
<RbCard
|
||||||
|
title={t('reflectionEngine.qualityAssessment')}
|
||||||
|
>
|
||||||
|
{result.quality_assessments.map((item, index) => (
|
||||||
<div key={index} className="rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md rb:text-[12px]">
|
<div key={index} className="rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md rb:text-[12px]">
|
||||||
{['reason', 'solution'].map(key => (
|
{['score', 'summary'].map(key => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="rb:flex rb:gap-4 rb:justify-start rb:text-[14px] rb:leading-5 rb:mb-3"
|
className="rb:flex rb:gap-4 rb:justify-start rb:text-[14px] rb:leading-5 rb:mb-3"
|
||||||
>
|
>
|
||||||
<div className="rb:whitespace-nowrap rb:w-45 rb:font-medium">{t(`reflectionEngine.${key}`)}</div>
|
<div className="rb:whitespace-nowrap rb:w-45 rb:font-medium">{t(`reflectionEngine.qualityAssessmentObj.${key}`)}</div>
|
||||||
<div className='rb:flex-inline rb:text-left rb:py-px rb:rounded rb:text-[#5B6167] rb:flex-1'>
|
<div className='rb:flex-inline rb:text-left rb:py-px rb:rounded rb:text-[#5B6167] rb:flex-1'>
|
||||||
{item[key as keyof ReflexionData]}
|
{item[key as keyof QualityAssessment]}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Space>
|
</RbCard>
|
||||||
</RbCard>
|
)}
|
||||||
<RbCard
|
{result.memory_verifies.length > 0 && (
|
||||||
title={t('reflectionEngine.qualityAssessment')}
|
<RbCard
|
||||||
>
|
title={t('reflectionEngine.privacyAudit')}
|
||||||
{result.quality_assessments.map((item, index) => (
|
>
|
||||||
<div key={index} className="rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md rb:text-[12px]">
|
{result.memory_verifies.map((item, index) => (
|
||||||
{['score', 'summary'].map(key => (
|
<div key={index} className="rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md rb:text-[12px]">
|
||||||
<div
|
{['has_privacy', 'privacy_types', 'summary'].map(key => (
|
||||||
key={key}
|
<div
|
||||||
className="rb:flex rb:gap-4 rb:justify-start rb:text-[14px] rb:leading-5 rb:mb-3"
|
key={key}
|
||||||
>
|
className="rb:flex rb:gap-4 rb:justify-start rb:text-[14px] rb:leading-5 rb:mb-3"
|
||||||
<div className="rb:whitespace-nowrap rb:w-45 rb:font-medium">{t(`reflectionEngine.qualityAssessmentObj.${key}`)}</div>
|
>
|
||||||
<div className='rb:flex-inline rb:text-left rb:py-px rb:rounded rb:text-[#5B6167] rb:flex-1'>
|
<div className="rb:whitespace-nowrap rb:w-45 rb:font-medium">{t(`reflectionEngine.privacyAuditObj.${key}`)}</div>
|
||||||
{item[key as keyof QualityAssessment]}
|
<div className='rb:flex-inline rb:text-left rb:py-px rb:rounded rb:text-[#5B6167] rb:flex-1'>
|
||||||
|
{key === 'has_privacy'
|
||||||
|
? <Tag color={item[key as keyof MemoryVerify] ? 'success' : 'error'}>{t(`reflectionEngine.privacyAuditObj.${item[key as keyof MemoryVerify]}`)}</Tag>
|
||||||
|
: key === 'privacy_types' ? (item[key as keyof MemoryVerify] as string[]).join('、')
|
||||||
|
: item[key as keyof MemoryVerify]
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</RbCard>
|
||||||
</RbCard>
|
)}
|
||||||
<RbCard
|
|
||||||
title={t('reflectionEngine.privacyAudit')}
|
|
||||||
>
|
|
||||||
{result.memory_verifies.map((item, index) => (
|
|
||||||
<div key={index} className="rb:bg-[#F0F3F8] rb:px-3 rb:py-2.5 rb:rounded-md rb:text-[12px]">
|
|
||||||
{['has_privacy', 'privacy_types', 'summary'].map(key => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
className="rb:flex rb:gap-4 rb:justify-start rb:text-[14px] rb:leading-5 rb:mb-3"
|
|
||||||
>
|
|
||||||
<div className="rb:whitespace-nowrap rb:w-45 rb:font-medium">{t(`reflectionEngine.privacyAuditObj.${key}`)}</div>
|
|
||||||
<div className='rb:flex-inline rb:text-left rb:py-px rb:rounded rb:text-[#5B6167] rb:flex-1'>
|
|
||||||
{key === 'has_privacy'
|
|
||||||
? <Tag color={item[key as keyof MemoryVerify] ? 'success' : 'error'}>{t(`reflectionEngine.privacyAuditObj.${item[key as keyof MemoryVerify]}`)}</Tag>
|
|
||||||
: key === 'privacy_types' ? (item[key as keyof MemoryVerify] as string[]).join('、')
|
|
||||||
: item[key as keyof MemoryVerify]
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</RbCard>
|
|
||||||
</>}
|
</>}
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
86
web/src/views/Workflow/components/Editor/index.tsx
Normal file
86
web/src/views/Workflow/components/Editor/index.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { type FC, useState } from 'react';
|
||||||
|
import { LexicalComposer } from '@lexical/react/LexicalComposer';
|
||||||
|
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin';
|
||||||
|
import { ContentEditable } from '@lexical/react/LexicalContentEditable';
|
||||||
|
import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin';
|
||||||
|
import { AutoFocusPlugin } from '@lexical/react/LexicalAutoFocusPlugin';
|
||||||
|
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary';
|
||||||
|
import AutocompletePlugin, { type Suggestion } from './plugin/AutocompletePlugin'
|
||||||
|
import CharacterCountPlugin from './plugin/CharacterCountPlugin'
|
||||||
|
import InitialValuePlugin from './plugin/InitialValuePlugin';
|
||||||
|
|
||||||
|
interface LexicalEditorProps {
|
||||||
|
placeholder?: string;
|
||||||
|
value?: string;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
suggestions: Suggestion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = {
|
||||||
|
paragraph: 'editor-paragraph',
|
||||||
|
text: {
|
||||||
|
bold: 'editor-text-bold',
|
||||||
|
italic: 'editor-text-italic',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const Editor: FC<LexicalEditorProps> =({
|
||||||
|
placeholder = "请输入内容...",
|
||||||
|
value = "",
|
||||||
|
onChange,
|
||||||
|
suggestions,
|
||||||
|
}) => {
|
||||||
|
const [count, setCount] = useState(0);
|
||||||
|
const initialConfig = {
|
||||||
|
namespace: 'AutocompleteEditor',
|
||||||
|
theme,
|
||||||
|
onError: (error: Error) => {
|
||||||
|
console.error(error);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LexicalComposer initialConfig={initialConfig}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<RichTextPlugin
|
||||||
|
contentEditable={
|
||||||
|
<ContentEditable
|
||||||
|
style={{
|
||||||
|
minHeight: '60px',
|
||||||
|
padding: '0',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
resize: 'none',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '20px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0',
|
||||||
|
left: '0',
|
||||||
|
color: '#5B6167',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '20px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{placeholder}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
ErrorBoundary={LexicalErrorBoundary}
|
||||||
|
/>
|
||||||
|
<HistoryPlugin />
|
||||||
|
<AutoFocusPlugin />
|
||||||
|
<AutocompletePlugin suggestions={suggestions} />
|
||||||
|
<CharacterCountPlugin setCount={(count) => { setCount(count) }} onChange={onChange} />
|
||||||
|
<InitialValuePlugin value={value} />
|
||||||
|
</div>
|
||||||
|
</LexicalComposer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Editor;
|
||||||
113
web/src/views/Workflow/components/Editor/nodes/TagNode.tsx
Normal file
113
web/src/views/Workflow/components/Editor/nodes/TagNode.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
$applyNodeReplacement,
|
||||||
|
DecoratorNode,
|
||||||
|
} from 'lexical';
|
||||||
|
import type { NodeKey, SerializedLexicalNode, Spread } from 'lexical';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export type SerializedTagNode = Spread<
|
||||||
|
{
|
||||||
|
label: string;
|
||||||
|
tagType: string;
|
||||||
|
},
|
||||||
|
SerializedLexicalNode
|
||||||
|
>;
|
||||||
|
|
||||||
|
export class TagNode extends DecoratorNode<JSX.Element> {
|
||||||
|
__label: string;
|
||||||
|
__type: string;
|
||||||
|
|
||||||
|
static getType(): string {
|
||||||
|
return 'tagNode';
|
||||||
|
}
|
||||||
|
|
||||||
|
static clone(node: TagNode): TagNode {
|
||||||
|
return new TagNode(node.__label, node.__type, node.__key);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(label: string, type: string, key?: NodeKey) {
|
||||||
|
super(key);
|
||||||
|
this.__label = label;
|
||||||
|
this.__type = type;
|
||||||
|
}
|
||||||
|
|
||||||
|
createDOM(): HTMLElement {
|
||||||
|
return document.createElement('span');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDOM(): false {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static importJSON(serializedNode: SerializedTagNode): TagNode {
|
||||||
|
const { label, tagType } = serializedNode;
|
||||||
|
return $createTagNode(label, tagType);
|
||||||
|
}
|
||||||
|
|
||||||
|
exportJSON(): SerializedTagNode {
|
||||||
|
return {
|
||||||
|
label: this.__label,
|
||||||
|
tagType: this.__type,
|
||||||
|
type: 'tagNode',
|
||||||
|
version: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getTextContent(): string {
|
||||||
|
return this.__label;
|
||||||
|
}
|
||||||
|
|
||||||
|
decorate(): JSX.Element {
|
||||||
|
const getIconAndColor = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'context':
|
||||||
|
return { icon: '📄', bgColor: '#722ed1' };
|
||||||
|
case 'system':
|
||||||
|
return { icon: 'x', bgColor: '#1890ff' };
|
||||||
|
default:
|
||||||
|
return { icon: 'x', bgColor: '#52c41a' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const { icon, bgColor } = getIconAndColor(this.__type);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
background: '#f0f8ff',
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '2px 6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
margin: '0 2px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: bgColor,
|
||||||
|
color: 'white',
|
||||||
|
padding: '1px 4px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
fontSize: '10px',
|
||||||
|
minWidth: '12px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
<span>{this.__label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $createTagNode(label: string, type: string): TagNode {
|
||||||
|
return new TagNode(label, type);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $isTagNode(node: any): node is TagNode {
|
||||||
|
return node instanceof TagNode;
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import { useEffect, useState, type FC } from 'react';
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { $getRoot, $createTextNode, $createParagraphNode, $setSelection, $createRangeSelection, $getSelection } from 'lexical';
|
||||||
|
import type { NodeProperties } from '../../../types'
|
||||||
|
export interface Suggestion {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: string;
|
||||||
|
dataType: string;
|
||||||
|
value: string;
|
||||||
|
nodeData: NodeProperties
|
||||||
|
}
|
||||||
|
const AutocompletePlugin: FC<{ suggestions: Suggestion[] }> = ({ suggestions }) => {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerUpdateListener(({ editorState }) => {
|
||||||
|
editorState.read(() => {
|
||||||
|
const root = $getRoot();
|
||||||
|
const text = root.getTextContent();
|
||||||
|
const shouldShow = text.includes('/');
|
||||||
|
setShowSuggestions(shouldShow);
|
||||||
|
|
||||||
|
if (shouldShow) {
|
||||||
|
const selection = $getSelection();
|
||||||
|
if (selection) {
|
||||||
|
const domSelection = window.getSelection();
|
||||||
|
if (domSelection && domSelection.rangeCount > 0) {
|
||||||
|
const range = domSelection.getRangeAt(0);
|
||||||
|
const rect = range.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Calculate popup dimensions
|
||||||
|
const popupWidth = 280;
|
||||||
|
const popupHeight = 200;
|
||||||
|
|
||||||
|
// Get viewport dimensions
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
|
// Calculate position with viewport constraints
|
||||||
|
let left = rect.left;
|
||||||
|
let top = rect.top - 10;
|
||||||
|
|
||||||
|
// Adjust horizontal position if popup would overflow
|
||||||
|
if (left + popupWidth > viewportWidth) {
|
||||||
|
left = viewportWidth - popupWidth - 10;
|
||||||
|
}
|
||||||
|
if (left < 10) {
|
||||||
|
left = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adjust vertical position if popup would overflow
|
||||||
|
if (top - popupHeight < 10) {
|
||||||
|
// Show below cursor if not enough space above
|
||||||
|
top = rect.bottom + 10;
|
||||||
|
if (top + popupHeight > viewportHeight) {
|
||||||
|
top = viewportHeight - popupHeight - 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPopupPosition({ top, left });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
const insertMention = (suggestion: any) => {
|
||||||
|
editor.update(() => {
|
||||||
|
const root = $getRoot();
|
||||||
|
const text = root.getTextContent();
|
||||||
|
const lastSlashIndex = text.lastIndexOf('/');
|
||||||
|
const beforeSlash = text.slice(0, lastSlashIndex);
|
||||||
|
const afterSlash = text.slice(lastSlashIndex + 1);
|
||||||
|
const insertedText = `{{${suggestion.value}}} `;
|
||||||
|
const newText = beforeSlash + insertedText + afterSlash;
|
||||||
|
const cursorPosition = beforeSlash.length + insertedText.length;
|
||||||
|
|
||||||
|
root.clear();
|
||||||
|
const paragraph = $createParagraphNode();
|
||||||
|
paragraph.append($createTextNode(newText));
|
||||||
|
root.append(paragraph);
|
||||||
|
|
||||||
|
// Set cursor after the inserted text
|
||||||
|
const textNode = paragraph.getFirstChild();
|
||||||
|
if (textNode) {
|
||||||
|
const selection = $createRangeSelection();
|
||||||
|
selection.anchor.set(textNode.getKey(), cursorPosition, 'text');
|
||||||
|
selection.focus.set(textNode.getKey(), cursorPosition, 'text');
|
||||||
|
$setSelection(selection);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setShowSuggestions(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!showSuggestions) return null;
|
||||||
|
|
||||||
|
// Group suggestions by node id
|
||||||
|
const groupedSuggestions = suggestions.reduce((groups: Record<string, any[]>, suggestion) => {
|
||||||
|
const { nodeData } = suggestion
|
||||||
|
const nodeId = nodeData.id as string;
|
||||||
|
if (!groups[nodeId]) {
|
||||||
|
groups[nodeId] = [];
|
||||||
|
}
|
||||||
|
groups[nodeId].push(suggestion);
|
||||||
|
return groups;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: popupPosition.top,
|
||||||
|
left: popupPosition.left,
|
||||||
|
zIndex: 1000,
|
||||||
|
background: 'white',
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
borderRadius: '6px',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
|
||||||
|
minWidth: '280px',
|
||||||
|
maxHeight: '200px',
|
||||||
|
overflowY: 'auto',
|
||||||
|
transform: 'translateY(-100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Object.entries(groupedSuggestions).map(([nodeId, nodeOptions], groupIndex) => {
|
||||||
|
const nodeName = nodeOptions[0]?.nodeData?.name || nodeId;
|
||||||
|
return (
|
||||||
|
<div key={nodeId}>
|
||||||
|
{groupIndex > 0 && <div style={{ height: '1px', background: '#f0f0f0', margin: '4px 0' }} />}
|
||||||
|
<div style={{ padding: '4px 12px', fontSize: '12px', color: '#999', fontWeight: 'bold' }}>
|
||||||
|
{nodeName}
|
||||||
|
</div>
|
||||||
|
{nodeOptions.map((option, index) => {
|
||||||
|
const globalIndex = Object.values(groupedSuggestions).flat().indexOf(option);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={option.key}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: selectedIndex === globalIndex ? '#f0f8ff' : 'white',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
}}
|
||||||
|
onClick={() => insertMention(option)}
|
||||||
|
onMouseEnter={() => setSelectedIndex(globalIndex)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: option.type === 'context' ? '#722ed1' :
|
||||||
|
option.type === 'system' ? '#1890ff' : '#52c41a',
|
||||||
|
color: 'white',
|
||||||
|
padding: '2px 6px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px',
|
||||||
|
minWidth: '16px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.type === 'context' ? '📄' :
|
||||||
|
option.type === 'system' ? 'x' : 'x'}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: '14px' }}>{option.label}</span>
|
||||||
|
</div>
|
||||||
|
{option.dataType && (
|
||||||
|
<span style={{ fontSize: '12px', color: '#999' }}>
|
||||||
|
{option.dataType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export default AutocompletePlugin
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { $getRoot } from 'lexical';
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
|
||||||
|
const CharacterCountPlugin = ({ setCount, onChange }: { setCount: (count: number) => void; onChange?: (value: string) => void }) => {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return editor.registerUpdateListener(({ editorState }) => {
|
||||||
|
editorState.read(() => {
|
||||||
|
const root = $getRoot();
|
||||||
|
const textContent = root.getTextContent();
|
||||||
|
setCount(textContent.length);
|
||||||
|
onChange?.(textContent);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [editor, setCount, onChange]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CharacterCountPlugin
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
|
||||||
|
import { $getRoot, $createParagraphNode, $createTextNode } from 'lexical';
|
||||||
|
|
||||||
|
interface InitialValuePluginProps {
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InitialValuePlugin: React.FC<InitialValuePluginProps> = ({ value }) => {
|
||||||
|
const [editor] = useLexicalComposerContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
editor.update(() => {
|
||||||
|
const root = $getRoot();
|
||||||
|
if (root.getTextContent() === '') {
|
||||||
|
root.clear();
|
||||||
|
const paragraph = $createParagraphNode();
|
||||||
|
paragraph.append($createTextNode(value));
|
||||||
|
root.append(paragraph);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [editor, value]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default InitialValuePlugin;
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
import { type FC } from 'react';
|
import { type FC, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Input, Form, Space, Button, Row, Col, Select, type FormListOperation } from 'antd';
|
import { Input, Form, Space, Button, Row, Col, Select, type FormListOperation } from 'antd';
|
||||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { Graph, Node } from '@antv/x6';
|
||||||
|
import Editor from '../Editor'
|
||||||
|
import type { Suggestion } from '../Editor/plugin/AutocompletePlugin'
|
||||||
|
|
||||||
interface TextareaProps {
|
interface TextareaProps {
|
||||||
|
isArray?: boolean;
|
||||||
parentName?: string;
|
parentName?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
onChange?: (value?: string) => void;
|
onChange?: (value?: string) => void;
|
||||||
|
selectedNode?: Node | null;
|
||||||
|
graphRef?: React.MutableRefObject<Graph | undefined>;
|
||||||
}
|
}
|
||||||
const roleOptions = [
|
const roleOptions = [
|
||||||
// { label: 'SYSTEM', value: 'SYSTEM' },
|
// { label: 'SYSTEM', value: 'SYSTEM' },
|
||||||
@@ -15,11 +22,92 @@ const roleOptions = [
|
|||||||
{ label: 'ASSISTANT', value: 'ASSISTANT' },
|
{ label: 'ASSISTANT', value: 'ASSISTANT' },
|
||||||
]
|
]
|
||||||
const MessageEditor: FC<TextareaProps> = ({
|
const MessageEditor: FC<TextareaProps> = ({
|
||||||
|
isArray = true,
|
||||||
parentName = 'messages',
|
parentName = 'messages',
|
||||||
placeholder,
|
placeholder,
|
||||||
|
selectedNode,
|
||||||
|
graphRef,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
const form = Form.useFormInstance();
|
const form = Form.useFormInstance();
|
||||||
const values = form.getFieldsValue()
|
const values = form.getFieldsValue()
|
||||||
|
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
if (!selectedNode || !graphRef?.current) return [];
|
||||||
|
|
||||||
|
const suggestions: Suggestion[] = [];
|
||||||
|
const graph = graphRef.current;
|
||||||
|
const edges = graph.getEdges();
|
||||||
|
const nodes = graph.getNodes();
|
||||||
|
|
||||||
|
// Find all connected previous nodes (recursive)
|
||||||
|
const getAllPreviousNodes = (nodeId: string, visited = new Set<string>()): string[] => {
|
||||||
|
if (visited.has(nodeId)) return [];
|
||||||
|
visited.add(nodeId);
|
||||||
|
|
||||||
|
const directPrevious = edges
|
||||||
|
.filter(edge => edge.getTargetCellId() === nodeId)
|
||||||
|
.map(edge => edge.getSourceCellId());
|
||||||
|
|
||||||
|
const allPrevious = [...directPrevious];
|
||||||
|
directPrevious.forEach(prevNodeId => {
|
||||||
|
allPrevious.push(...getAllPreviousNodes(prevNodeId, visited));
|
||||||
|
});
|
||||||
|
|
||||||
|
return allPrevious;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allPreviousNodeIds = getAllPreviousNodes(selectedNode.id);
|
||||||
|
console.log('allPreviousNodeIds', allPreviousNodeIds)
|
||||||
|
|
||||||
|
allPreviousNodeIds.forEach(nodeId => {
|
||||||
|
const node = nodes.find(n => n.id === nodeId);
|
||||||
|
if (!node) return;
|
||||||
|
|
||||||
|
const nodeData = node.getData();
|
||||||
|
|
||||||
|
switch(nodeData.type) {
|
||||||
|
case 'start':
|
||||||
|
const list = [
|
||||||
|
...(nodeData.config?.variables?.defaultValue ?? []),
|
||||||
|
...(nodeData.config?.variables?.value ?? [])
|
||||||
|
]
|
||||||
|
list.forEach((variable: any) => {
|
||||||
|
suggestions.push({
|
||||||
|
key: `${nodeId}_${variable.name}`,
|
||||||
|
label: variable.name,
|
||||||
|
type: 'variable',
|
||||||
|
dataType: variable.type,
|
||||||
|
value: `${nodeId}.${variable.name}`,
|
||||||
|
nodeData: nodeData,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
nodeData.config?.variables?.sys.forEach((variable: any) => {
|
||||||
|
suggestions.push({
|
||||||
|
key: `${nodeId}_${variable.name}`,
|
||||||
|
label: variable.name,
|
||||||
|
type: 'variable',
|
||||||
|
dataType: variable.type,
|
||||||
|
value: `sys.${variable.name}`,
|
||||||
|
nodeData: nodeData,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break
|
||||||
|
case 'llm':
|
||||||
|
suggestions.push({
|
||||||
|
key: `${nodeId}_output`,
|
||||||
|
label: 'output',
|
||||||
|
type: 'variable',
|
||||||
|
dataType: 'String',
|
||||||
|
value: `${nodeId}.output`,
|
||||||
|
nodeData: nodeData,
|
||||||
|
});
|
||||||
|
break
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return suggestions;
|
||||||
|
}, [selectedNode, graphRef]);
|
||||||
|
|
||||||
const handleAdd = (add: FormListOperation['add']) => {
|
const handleAdd = (add: FormListOperation['add']) => {
|
||||||
const list = values[parentName];
|
const list = values[parentName];
|
||||||
@@ -30,58 +118,74 @@ const MessageEditor: FC<TextareaProps> = ({
|
|||||||
content: undefined
|
content: undefined
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Form.List name={parentName}>
|
{isArray
|
||||||
{(fields, { add, remove }) => (
|
? <Form.List name={parentName}>
|
||||||
<>
|
{(fields, { add, remove }) => (
|
||||||
{fields.map(({ key, name, ...restField }) => {
|
<Space size={12} direction="vertical" className="rb:w-full">
|
||||||
const currentRole = values[parentName]?.[key].role || 'USER'
|
{fields.map(({ key, name, ...restField }) => {
|
||||||
|
const currentRole = values[parentName]?.[key].role || 'USER'
|
||||||
return (
|
|
||||||
<Space key={key} direction="vertical" className="rb:w-full rb:border rb:border-[#DFE4ED] rb:rounded-md rb:px-2 rb:py-1.5">
|
return (
|
||||||
<Row>
|
<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">
|
||||||
<Col span={12}>
|
<Row>
|
||||||
<Form.Item
|
<Col span={12}>
|
||||||
{...restField}
|
<Form.Item
|
||||||
name={[name, 'role']}
|
{...restField}
|
||||||
noStyle
|
name={[name, 'role']}
|
||||||
>
|
noStyle
|
||||||
{currentRole === 'SYSTEM'
|
>
|
||||||
? <Input disabled />
|
{currentRole === 'SYSTEM'
|
||||||
:
|
? <Input disabled />
|
||||||
<Select
|
:
|
||||||
options={roleOptions}
|
<Select
|
||||||
disabled={currentRole === 'SYSTEM'}
|
options={roleOptions}
|
||||||
/>
|
disabled={currentRole === 'SYSTEM'}
|
||||||
}
|
/>
|
||||||
</Form.Item>
|
}
|
||||||
</Col>
|
</Form.Item>
|
||||||
{currentRole !== 'SYSTEM' && <Col span={12}>
|
</Col>
|
||||||
<div className="rb:h-full rb:flex rb:justify-end rb:items-center">
|
{currentRole !== 'SYSTEM' && <Col span={12}>
|
||||||
<MinusCircleOutlined onClick={() => remove(name)} />
|
<div className="rb:h-full rb:flex rb:justify-end rb:items-center">
|
||||||
</div>
|
<MinusCircleOutlined onClick={() => remove(name)} />
|
||||||
</Col>}
|
</div>
|
||||||
</Row>
|
</Col>}
|
||||||
<Form.Item
|
</Row>
|
||||||
{...restField}
|
<Form.Item
|
||||||
name={[name, 'content']}
|
{...restField}
|
||||||
noStyle
|
name={[name, 'content']}
|
||||||
>
|
noStyle
|
||||||
<Input.TextArea placeholder={placeholder} />
|
>
|
||||||
</Form.Item>
|
<Editor placeholder={placeholder} suggestions={suggestions} />
|
||||||
</Space>
|
</Form.Item>
|
||||||
)
|
</Space>
|
||||||
})}
|
)
|
||||||
<Form.Item className="rb:mt-3!">
|
})}
|
||||||
<Button type="dashed" onClick={() => handleAdd(add)} block icon={<PlusOutlined />}>
|
<Form.Item>
|
||||||
Add field
|
<Button type="dashed" onClick={() => handleAdd(add)} block icon={<PlusOutlined />}>
|
||||||
</Button>
|
+{t('workflow.addMessage')}
|
||||||
</Form.Item>
|
</Button>
|
||||||
</>
|
</Form.Item>
|
||||||
)}
|
</Space >
|
||||||
</Form.List>
|
)}
|
||||||
|
</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}>
|
||||||
|
{t('workflow.answerDesc')}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Form.Item
|
||||||
|
name={parentName}
|
||||||
|
noStyle
|
||||||
|
>
|
||||||
|
<Editor placeholder={placeholder} suggestions={suggestions} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface PropertiesProps {
|
|||||||
}
|
}
|
||||||
const Properties: FC<PropertiesProps> = ({
|
const Properties: FC<PropertiesProps> = ({
|
||||||
selectedNode,
|
selectedNode,
|
||||||
|
graphRef,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { modal } = App.useApp()
|
const { modal } = App.useApp()
|
||||||
@@ -30,6 +31,12 @@ const Properties: FC<PropertiesProps> = ({
|
|||||||
const variableModalRef = useRef<VariableEditModalRef>(null)
|
const variableModalRef = useRef<VariableEditModalRef>(null)
|
||||||
const [editIndex, setEditIndex] = useState<number | null>(null)
|
const [editIndex, setEditIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNode?.getData().id) {
|
||||||
|
form.resetFields()
|
||||||
|
}
|
||||||
|
}, [selectedNode?.getData().id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedNode && form) {
|
if (selectedNode && form) {
|
||||||
const { type = 'default', name = '', config } = selectedNode.getData() || {}
|
const { type = 'default', name = '', config } = selectedNode.getData() || {}
|
||||||
@@ -180,7 +187,14 @@ const Properties: FC<PropertiesProps> = ({
|
|||||||
if (selectedNode.data?.type === 'llm' && key === 'messages' && config.type === 'define') {
|
if (selectedNode.data?.type === 'llm' && key === 'messages' && config.type === 'define') {
|
||||||
return (
|
return (
|
||||||
<Form.Item key={key} name={key}>
|
<Form.Item key={key} name={key}>
|
||||||
<MessageEditor />
|
<MessageEditor selectedNode={selectedNode} graphRef={graphRef} />
|
||||||
|
</Form.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (selectedNode.data?.type === 'end' && key === 'output') {
|
||||||
|
return (
|
||||||
|
<Form.Item key={key} name={key}>
|
||||||
|
<MessageEditor isArray={false} parentName={key} selectedNode={selectedNode} graphRef={graphRef} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ export const nodeLibrary: NodeLibrary[] = [
|
|||||||
type: "end", icon: endIcon,
|
type: "end", icon: endIcon,
|
||||||
config: {
|
config: {
|
||||||
output: {
|
output: {
|
||||||
type: 'textarea'
|
type: 'define'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -121,7 +121,7 @@ export const nodeLibrary: NodeLibrary[] = [
|
|||||||
type: 'define',
|
type: 'define',
|
||||||
defaultValue: [
|
defaultValue: [
|
||||||
{
|
{
|
||||||
role: 'SYSTEM',
|
role: 'system',
|
||||||
content: undefined,
|
content: undefined,
|
||||||
readonly: true
|
readonly: true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -624,7 +624,7 @@ export const useWorkflowGraph = ({
|
|||||||
let nodeLibraryConfig = [...nodeLibrary]
|
let nodeLibraryConfig = [...nodeLibrary]
|
||||||
.flatMap(category => category.nodes)
|
.flatMap(category => category.nodes)
|
||||||
.find(n => n.type === dragData.type);
|
.find(n => n.type === dragData.type);
|
||||||
nodeLibraryConfig = { config: {}, ...nodeLibraryConfig } as NodeProperties;
|
nodeLibraryConfig = JSON.parse(JSON.stringify({ config: {}, ...nodeLibraryConfig })) as NodeProperties
|
||||||
|
|
||||||
// 创建干净的节点数据,只保留必要的字段
|
// 创建干净的节点数据,只保留必要的字段
|
||||||
const cleanNodeData = {
|
const cleanNodeData = {
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export interface NodeConfig {
|
|||||||
export interface NodeProperties {
|
export interface NodeProperties {
|
||||||
type: string;
|
type: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
name?: string;
|
||||||
|
id?: string;
|
||||||
config?: Record<string, NodeConfig>;
|
config?: Record<string, NodeConfig>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user