diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 8f33f43c..463332d4 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1579,6 +1579,8 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re export: 'Export', variableConfig: 'Variable Configuration', variableRequired: 'required', + addMessage: 'Add Message', + answerDesc: 'Reply' }, emotionEngine: { emotionEngineConfig: 'Emotion Engine Configuration', diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 9006fcbb..1baf6475 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1666,6 +1666,8 @@ export const zh = { export: '导出', variableConfig: '变量配置', variableRequired: '必填', + addMessage: '添加消息', + answerDesc: '回复' }, emotionEngine: { emotionEngineConfig: '情感引擎配置', diff --git a/web/src/views/SelfReflectionEngine/index.tsx b/web/src/views/SelfReflectionEngine/index.tsx index 0802b706..2c0eea86 100644 --- a/web/src/views/SelfReflectionEngine/index.tsx +++ b/web/src/views/SelfReflectionEngine/index.tsx @@ -272,69 +272,75 @@ const SelfReflectionEngine: React.FC = () => { - - - {result.reflexion_data.map((item, index) => ( + {result.reflexion_data.length > 0 && ( + + + {result.reflexion_data.map((item, index) => ( +
+ {['reason', 'solution'].map(key => ( +
+
{t(`reflectionEngine.${key}`)}
+
+ {item[key as keyof ReflexionData]} +
+
+ ))} +
+ ))} +
+
+ )} + {result.quality_assessments.length > 0 && ( + + {result.quality_assessments.map((item, index) => (
- {['reason', 'solution'].map(key => ( + {['score', 'summary'].map(key => (
-
{t(`reflectionEngine.${key}`)}
+
{t(`reflectionEngine.qualityAssessmentObj.${key}`)}
- {item[key as keyof ReflexionData]} + {item[key as keyof QualityAssessment]}
))}
))} -
-
- - {result.quality_assessments.map((item, index) => ( -
- {['score', 'summary'].map(key => ( -
-
{t(`reflectionEngine.qualityAssessmentObj.${key}`)}
-
- {item[key as keyof QualityAssessment]} + + )} + {result.memory_verifies.length > 0 && ( + + {result.memory_verifies.map((item, index) => ( +
+ {['has_privacy', 'privacy_types', 'summary'].map(key => ( +
+
{t(`reflectionEngine.privacyAuditObj.${key}`)}
+
+ {key === 'has_privacy' + ? {t(`reflectionEngine.privacyAuditObj.${item[key as keyof MemoryVerify]}`)} + : key === 'privacy_types' ? (item[key as keyof MemoryVerify] as string[]).join('、') + : item[key as keyof MemoryVerify] + } +
-
- ))} -
- ))} - - - {result.memory_verifies.map((item, index) => ( -
- {['has_privacy', 'privacy_types', 'summary'].map(key => ( -
-
{t(`reflectionEngine.privacyAuditObj.${key}`)}
-
- {key === 'has_privacy' - ? {t(`reflectionEngine.privacyAuditObj.${item[key as keyof MemoryVerify]}`)} - : key === 'privacy_types' ? (item[key as keyof MemoryVerify] as string[]).join('、') - : item[key as keyof MemoryVerify] - } -
-
- ))} -
- ))} -
+ ))} +
+ ))} + + )} } diff --git a/web/src/views/Workflow/components/Editor/index.tsx b/web/src/views/Workflow/components/Editor/index.tsx new file mode 100644 index 00000000..ac96dd73 --- /dev/null +++ b/web/src/views/Workflow/components/Editor/index.tsx @@ -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 =({ + placeholder = "请输入内容...", + value = "", + onChange, + suggestions, +}) => { + const [count, setCount] = useState(0); + const initialConfig = { + namespace: 'AutocompleteEditor', + theme, + onError: (error: Error) => { + console.error(error); + }, + }; + + return ( + +
+ + } + placeholder={ +
+ {placeholder} +
+ } + ErrorBoundary={LexicalErrorBoundary} + /> + + + + { setCount(count) }} onChange={onChange} /> + +
+
+ ); +}; + +export default Editor; \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/nodes/TagNode.tsx b/web/src/views/Workflow/components/Editor/nodes/TagNode.tsx new file mode 100644 index 00000000..f4264bfe --- /dev/null +++ b/web/src/views/Workflow/components/Editor/nodes/TagNode.tsx @@ -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 { + __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 ( + + + {icon} + + {this.__label} + + ); + } +} + +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; +} \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx new file mode 100644 index 00000000..79bd857b --- /dev/null +++ b/web/src/views/Workflow/components/Editor/plugin/AutocompletePlugin.tsx @@ -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, suggestion) => { + const { nodeData } = suggestion + const nodeId = nodeData.id as string; + if (!groups[nodeId]) { + groups[nodeId] = []; + } + groups[nodeId].push(suggestion); + return groups; + }, {}); + + return ( +
+ {Object.entries(groupedSuggestions).map(([nodeId, nodeOptions], groupIndex) => { + const nodeName = nodeOptions[0]?.nodeData?.name || nodeId; + return ( +
+ {groupIndex > 0 &&
} +
+ {nodeName} +
+ {nodeOptions.map((option, index) => { + const globalIndex = Object.values(groupedSuggestions).flat().indexOf(option); + return ( +
insertMention(option)} + onMouseEnter={() => setSelectedIndex(globalIndex)} + > +
+ + {option.type === 'context' ? '📄' : + option.type === 'system' ? 'x' : 'x'} + + {option.label} +
+ {option.dataType && ( + + {option.dataType} + + )} +
+ ); + })} +
+ ); + })} +
+ ); +} +export default AutocompletePlugin \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/plugin/CharacterCountPlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/CharacterCountPlugin.tsx new file mode 100644 index 00000000..911eff3d --- /dev/null +++ b/web/src/views/Workflow/components/Editor/plugin/CharacterCountPlugin.tsx @@ -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 \ No newline at end of file diff --git a/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx new file mode 100644 index 00000000..912801d8 --- /dev/null +++ b/web/src/views/Workflow/components/Editor/plugin/InitialValuePlugin.tsx @@ -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 = ({ 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; \ No newline at end of file diff --git a/web/src/views/Workflow/components/Properties/MessageEditor.tsx b/web/src/views/Workflow/components/Properties/MessageEditor.tsx index 2714e45f..dfb3ccdb 100644 --- a/web/src/views/Workflow/components/Properties/MessageEditor.tsx +++ b/web/src/views/Workflow/components/Properties/MessageEditor.tsx @@ -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 { 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 { + isArray?: boolean; parentName?: string; label?: string; placeholder?: string; value?: string; onChange?: (value?: string) => void; + selectedNode?: Node | null; + graphRef?: React.MutableRefObject; } const roleOptions = [ // { label: 'SYSTEM', value: 'SYSTEM' }, @@ -15,11 +22,92 @@ const roleOptions = [ { label: 'ASSISTANT', value: 'ASSISTANT' }, ] const MessageEditor: FC = ({ + isArray = true, parentName = 'messages', placeholder, + selectedNode, + graphRef, }) => { + const { t } = useTranslation() const form = Form.useFormInstance(); 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[] => { + 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 list = values[parentName]; @@ -30,58 +118,74 @@ const MessageEditor: FC = ({ content: undefined }) } - + return (
- - {(fields, { add, remove }) => ( - <> - {fields.map(({ key, name, ...restField }) => { - const currentRole = values[parentName]?.[key].role || 'USER' - - return ( - - - - - {currentRole === 'SYSTEM' - ? - : - + : +