Merge branch 'develop' into feature/end_zy

This commit is contained in:
yingzhao
2026-04-21 18:33:58 +08:00
committed by GitHub
37 changed files with 444 additions and 124 deletions

View File

@@ -53,12 +53,12 @@ export const saveWorkflowConfig = (app_id: string, values: WorkflowConfig) => {
return request.put(`/apps/${app_id}/workflow`, values)
}
// Model comparison test run
export const runCompare = (app_id: string, values: Record<string, unknown>, onMessage?: (data: SSEMessage[]) => void) => {
return handleSSE(`/apps/${app_id}/draft/run/compare`, values, onMessage)
export const runCompare = (app_id: string, values: Record<string, unknown>, onMessage?: (data: SSEMessage[]) => void, onAbort?: (abort: () => void) => void) => {
return handleSSE(`/apps/${app_id}/draft/run/compare`, values, onMessage, undefined, onAbort)
}
// Test run
export const draftRun = (app_id: string, values: Record<string, unknown>, onMessage?: (data: SSEMessage[]) => void) => {
return handleSSE(`/apps/${app_id}/draft/run`, values, onMessage)
export const draftRun = (app_id: string, values: Record<string, unknown>, onMessage?: (data: SSEMessage[]) => void, onAbort?: (abort: () => void) => void) => {
return handleSSE(`/apps/${app_id}/draft/run`, values, onMessage, undefined, onAbort)
}
// Delete application
export const deleteApplication = (app_id: string) => {
@@ -93,12 +93,12 @@ export const getConversationHistory = (share_token: string, data: { page: number
})
}
// Send conversation
export const sendConversation = (values: QueryParams, onMessage: (data: SSEMessage[]) => void, shareToken: string) => {
export const sendConversation = (values: QueryParams, onMessage: (data: SSEMessage[]) => void, shareToken: string, onAbort?: (abort: () => void) => void) => {
return handleSSE(`/public/share/chat`, values, onMessage, {
headers: {
'Authorization': `Bearer ${shareToken}`
}
})
}, onAbort)
}
// Get conversation details
export const getConversationDetail = (share_token: string, conversation_id: string) => {

View File

@@ -274,8 +274,8 @@ export const updateMemoryExtractionConfig = (values: ExtractionConfigForm) => {
return request.post('/memory-storage/update_config_extracted', values)
}
// Memory Extraction Engine - Pilot run
export const pilotRunMemoryExtractionConfig = (values: { config_id: number | string; dialogue_text: string; custom_text?: string; }, onMessage?: (data: SSEMessage[]) => void) => {
return handleSSE('/memory-storage/pilot_run', values, onMessage)
export const pilotRunMemoryExtractionConfig = (values: { config_id: number | string; dialogue_text: string; custom_text?: string; }, onMessage?: (data: SSEMessage[]) => void, onAbort?: (abort: () => void) => void) => {
return handleSSE('/memory-storage/pilot_run', values, onMessage, undefined, onAbort)
}
// Emotion Engine - Get configuration
export const getMemoryEmotionConfig = (config_id: number | string) => {

View File

@@ -14,8 +14,8 @@ export const createPromptSessions = () => {
return request.post(`/prompt/sessions`)
}
// Get prompt optimization
export const updatePromptMessages = (session_id: string, data: AiPromptForm, onMessage?: (data: SSEMessage[]) => void) => {
return handleSSE(`/prompt/sessions/${session_id}/messages`, data, onMessage)
export const updatePromptMessages = (session_id: string, data: AiPromptForm, onMessage?: (data: SSEMessage[]) => void, config?: any, onAbort?: (abort: () => void) => void) => {
return handleSSE(`/prompt/sessions/${session_id}/messages`, data, onMessage, config, onAbort)
}
// Prompt release list
export const getPromptReleaseListUrl = '/prompt/releases/list'

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 15:29:46
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-26 14:52:23
* @Last Modified time: 2026-04-14 17:55:15
*/
/**
* RbTable Component
@@ -27,7 +27,7 @@ import { useTranslation } from 'react-i18next';
import { request } from '@/utils/request';
import Empty from '@/components/Empty';
interface TablePaginationConfig { pagesize: number; page: number; }
interface TablePaginationConfig { pagesize?: number; page?: number; }
/** Props interface for Table component */
interface TableComponentProps<T = Record<string, unknown>, Q = Record<string, unknown>> extends Omit<TableProps<T>, 'pagination'> {
@@ -102,7 +102,7 @@ const RbTable = forwardRef(<T = Record<string, unknown>, Q = Record<string, unkn
const [loading, setLoading] = useState(false)
const [currentPagination, setCurrentPagination] = useState({
page: 1,
pagesize: 20,
pagesize: typeof pagination === 'object' ? (pagination.pagesize || 20) : 20,
});
const [total, setTotal] = useState(0);

View File

@@ -2519,6 +2519,7 @@ Memory Bear: After the rebellion, regional warlordism intensified for several re
arrange: 'Arrange',
redo: 'Redo',
undo: 'Undo',
fit: 'Fit View',
input_result: 'Input',
output_result: 'Output',

View File

@@ -2483,6 +2483,7 @@ export const zh = {
arrange: '整理',
redo: '重做',
undo: '撤销',
fit: '自适应',
input_result: '输入',
output_result: '输出',

View File

@@ -6,11 +6,15 @@
*/
import { create } from 'zustand'
import type { NodeCheckResult } from '@/views/Workflow/components/CheckList'
import type { ChatItem } from '@/components/Chat/types'
interface WorkflowState {
checkResults: Record<string, NodeCheckResult[]>
setCheckResults: (appId: string, results: NodeCheckResult[]) => void
getCheckResults: (appId: string) => NodeCheckResult[]
chatHistoryMap: Record<string, ChatItem[]>
setChatHistory: (conversationId: string, history: ChatItem[]) => void
getChatHistory: (conversationId: string) => ChatItem[]
}
export const useWorkflowStore = create<WorkflowState>((set, get) => ({
@@ -18,4 +22,8 @@ export const useWorkflowStore = create<WorkflowState>((set, get) => ({
setCheckResults: (appId, results) =>
set(state => ({ checkResults: { ...state.checkResults, [appId]: results } })),
getCheckResults: (appId) => get().checkResults[appId] ?? [],
chatHistoryMap: {},
setChatHistory: (conversationId, history) =>
set(state => ({ chatHistoryMap: { ...state.chatHistoryMap, [conversationId]: history } })),
getChatHistory: (conversationId) => get().chatHistoryMap[conversationId] ?? [],
}))

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-02 16:35:43
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-18 14:32:40
* @Last Modified time: 2026-04-21 14:20:39
*/
/**
* Server-Sent Events (SSE) Stream Utility Module
@@ -148,7 +148,7 @@ function parseDataContent(dataContent: string): string | object {
* @param config - Additional request configuration
* @returns Fetch response
*/
const makeSSERequest = async (url: string, data: any, token: string, config = { headers: {} }) => {
const makeSSERequest = async (url: string, data: any, token: string, config = { headers: {} }, signal?: AbortSignal) => {
return fetch(`${API_PREFIX}${url}`, {
method: 'POST',
headers: {
@@ -156,7 +156,8 @@ const makeSSERequest = async (url: string, data: any, token: string, config = {
'Authorization': `Bearer ${token}`,
...config.headers,
},
body: JSON.stringify(data)
body: JSON.stringify(data),
signal,
});
};
@@ -167,10 +168,14 @@ const makeSSERequest = async (url: string, data: any, token: string, config = {
* @param onMessage - Callback for each parsed message
* @param config - Additional request configuration
*/
export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMessage[]) => void, config = { headers: {} }) => {
export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMessage[]) => void, config = { headers: {} }, onAbort?: (abort: () => void) => void) => {
const controller = new AbortController();
const abort = () => controller.abort();
onAbort?.(abort);
try {
let token = cookieUtils.get('authToken');
let response = await makeSSERequest(url, data, token || '', config);
let response = await makeSSERequest(url, data, token || '', config, controller.signal);
switch (response.status) {
case 500:
@@ -199,7 +204,7 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
}
try {
const newToken = await refreshTokenForSSE();
response = await makeSSERequest(url, data, newToken, config);
response = await makeSSERequest(url, data, newToken, config, controller.signal);
} catch (refreshError) {
return;
}
@@ -211,30 +216,37 @@ export const handleSSE = async (url: string, data: any, onMessage?: (data: SSEMe
const decoder = new TextDecoder();
let buffer = ''; // Buffer for handling incomplete messages
while (true) {
const { done, value } = await reader.read();
if (done) break;
try {
while (true) {
const { done, value } = await reader.read();
if (done || controller.signal.aborted) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
// Process complete events
const events = buffer.split('\n\n');
buffer = events.pop() || ''; // Keep last potentially incomplete event
// Process complete events
const events = buffer.split('\n\n');
buffer = events.pop() || ''; // Keep last potentially incomplete event
for (const event of events) {
if (event.trim() && onMessage) {
onMessage(parseSSEToJSON(event) ?? {});
for (const event of events) {
if (event.trim() && onMessage) {
onMessage(parseSSEToJSON(event) ?? {});
}
}
}
}
// Process remaining buffer content
if (buffer.trim() && onMessage) {
onMessage(parseSSEToJSON(buffer) ?? {});
// Process remaining buffer content
if (!controller.signal.aborted && buffer.trim() && onMessage) {
onMessage(parseSSEToJSON(buffer) ?? {});
}
} finally {
reader.cancel();
}
} catch (error: any) {
if (error?.name !== 'AbortError') {
console.error('Request failed:', error);
throw error;
}
} catch (error) {
console.error('Request failed:', error);
throw error;
}
};

View File

@@ -92,6 +92,7 @@ const TestChat: FC<TestChatProps> = ({
const audioPollingRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map())
const streamLoadingRef = useRef(false)
const [audioStatusMap, setAudioStatusMap] = useState<Record<string, string>>({})
const abortRef = useRef<(() => void) | null>(null)
useEffect(() => {
getVariables()
@@ -99,6 +100,8 @@ const TestChat: FC<TestChatProps> = ({
useEffect(() => {
return () => {
abortRef.current?.()
abortRef.current = null
audioPollingRef.current.forEach(timer => clearInterval(timer))
audioPollingRef.current.clear()
}
@@ -262,7 +265,8 @@ const TestChat: FC<TestChatProps> = ({
draftRun(
application.id,
formatParams((msg || message) as string, conversationId, files, params),
handleStreamMessage
handleStreamMessage,
(abort) => { abortRef.current = abort }
)
.catch(() => {
updateErrorAssistantMessage(0)
@@ -373,7 +377,8 @@ const TestChat: FC<TestChatProps> = ({
draftRun(
application.id,
formatParams((msg || message) as string, conversationId, files, params),
handleWorkflowStreamMessage
handleWorkflowStreamMessage,
(abort) => { abortRef.current = abort }
)
.catch((error) => {
const errorInfo = JSON.parse(error.message)

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:26:44
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-20 13:53:05
* @Last Modified time: 2026-04-21 14:50:21
*/
/**
* AI Prompt Assistant Modal
@@ -61,11 +61,14 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
const aiPromptVariableModalRef = useRef<AiPromptVariableModalRef>(null)
const editorRef = useRef<any>(null)
const currentPromptValueRef = useRef<string>('')
const abortRef = useRef<(() => void) | null>(null)
const values = Form.useWatch([], form)
/** Close modal and reset state */
const handleClose = () => {
abortRef.current?.()
abortRef.current = null
setVisible(false);
setLoading(false)
setChatList([])
@@ -148,7 +151,7 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
updatePromptMessages(promptSession, {
...values,
skill: source === 'skills'
}, handleStreamMessage)
}, handleStreamMessage, undefined, abort => { abortRef.current = abort })
.finally(() => {
setLoading(false)
})
@@ -221,7 +224,7 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
</Form.Item>
<ChatContent
classNames="rb:h-105.5 rb:pb-[15px]!"
classNames="rb:h-[calc(100vh-330px)] rb:pb-[15px]!"
contentClassNames="rb:max-w-75!"
empty={<Empty url={ConversationEmptyIcon} title={t(`${source}.promptChatEmpty`)} isNeedSubTitle={false} size={[140, 100]} className="rb:h-full" />}
data={chatList || []}
@@ -292,10 +295,10 @@ const AiPromptModal = forwardRef<AiPromptModalRef, AiPromptModalProps>(({
{values?.current_prompt
? <Editor
ref={editorRef}
className="rb:h-119 rb:bg-white! rb:border-none! rb:p-0!"
className="rb:h-[calc(100vh-278px)] rb:bg-white! rb:border-none! rb:p-0!"
onChange={(value) => form.setFieldValue('current_prompt', value)}
/>
: <Empty url={analysisEmptyIcon} title={t(`${source}.promptOptimizationEmpty`)} isNeedSubTitle={false} size={[270, 170]} className="rb:h-119 rb:w-70 rb:mx-auto! rb:text-center! rb:text-[12px]! rb:leading-4!" />
: <Empty url={analysisEmptyIcon} title={t(`${source}.promptOptimizationEmpty`)} isNeedSubTitle={false} size={[270, 170]} className="rb:h-[calc(100vh-278px)] rb:w-70 rb:mx-auto! rb:text-center! rb:text-[12px]! rb:leading-4!" />
}
</Form.Item>
</div>

View File

@@ -73,11 +73,14 @@ const Chat: FC<ChatProps> = ({
const [message, setMessage] = useState<string | undefined>(undefined)
const [features, setFeatures] = useState<FeaturesConfigForm>({} as FeaturesConfigForm)
const [audioStatusMap, setAudioStatusMap] = useState<Record<string, string>>({})
const abortRef = useRef<(() => void) | null>(null)
useEffect(() => {
setCompareLoading(false)
setLoading(false)
return () => {
abortRef.current?.()
abortRef.current = null
audioPollingRef.current.forEach(timer => clearInterval(timer))
audioPollingRef.current.clear()
}
@@ -85,6 +88,8 @@ const Chat: FC<ChatProps> = ({
useEffect(() => {
return () => {
abortRef.current?.()
abortRef.current = null
audioPollingRef.current.forEach(timer => clearInterval(timer))
audioPollingRef.current.clear()
}
@@ -393,7 +398,7 @@ const Chat: FC<ChatProps> = ({
parallel: true,
stream: true,
timeout: 60,
}, handleStreamMessage)
}, handleStreamMessage, (abort) => { abortRef.current = abort })
.catch(() => {
setLoading(false)
setCompareLoading(false)
@@ -537,7 +542,8 @@ const Chat: FC<ChatProps> = ({
}
}),
},
handleStreamMessage
handleStreamMessage,
(abort) => { abortRef.current = abort }
)
.catch(() => {
setLoading(false)

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 16:58:03
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-13 18:32:58
* @Last Modified time: 2026-04-21 14:27:15
*/
/**
* Conversation Page
@@ -53,6 +53,7 @@ const Conversation: FC = () => {
const scrollRef = useRef<HTMLDivElement>(null);
const toolbarRef = useRef<ChatToolbarRef>(null)
const audioPollingRef = useRef<Map<string, ReturnType<typeof setInterval>>>(new Map())
const abortRef = useRef<(() => void) | null>(null)
const [shareToken, setShareToken] = useState<string | null>(localStorage.getItem(`shareToken_${token}`))
const [fileList, setFileList] = useState<any[]>([])
const [webSearch, setWebSearch] = useState(false)
@@ -67,6 +68,8 @@ const Conversation: FC = () => {
useEffect(() => {
return () => {
abortRef.current?.()
abortRef.current = null
audioPollingRef.current.forEach((timer) => clearInterval(timer))
audioPollingRef.current.clear()
}
@@ -150,6 +153,8 @@ const Conversation: FC = () => {
const handleChangeHistory = (id: string | null) => {
if (id !== conversation_id) setConversationId(id)
if (!id) setMessage('')
abortRef.current?.()
abortRef.current = null
}
useEffect(() => {
@@ -406,7 +411,7 @@ const Conversation: FC = () => {
}),
variables: params,
thinking,
}, handleStreamMessage, shareToken)
}, handleStreamMessage, shareToken, (abort) => { abortRef.current = abort })
.catch(() => {
setLoading(false)
streamLoadingRef.current = false

View File

@@ -120,6 +120,7 @@ const Index = () => {
rowKey="id"
bordered={false}
scrollY="100%"
pagination={{pagesize: 10}}
/>
</div>
</Flex>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 17:30:11
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-26 15:46:30
* @Last Modified time: 2026-04-21 14:54:14
*/
/**
* Result Component
@@ -10,7 +10,7 @@
* Shows text preprocessing, knowledge extraction, node/edge creation, and deduplication
*/
import { type FC, useState } from 'react'
import { type FC, useState, useRef, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Space, Button, Progress, Form, Input, Flex } from 'antd'
@@ -105,7 +105,14 @@ const Result: FC<ResultProps> = ({ loading, handleSave }) => {
const [runForm] = Form.useForm()
const customText = Form.useWatch(['custom_text'], runForm)
const abortRef = useRef<(() => void) | null>(null)
useEffect(() => {
return () => {
abortRef.current?.()
abortRef.current = null;
}
}, [])
/** Run pilot test */
const handleRun = () => {
if(!id) return
@@ -229,11 +236,13 @@ const Result: FC<ResultProps> = ({ loading, handleSave }) => {
})
}
setRunLoading(true)
abortRef.current?.()
abortRef.current = null;
pilotRunMemoryExtractionConfig({
config_id: id,
dialogue_text: t('memoryExtractionEngine.exampleText'),
custom_text: runForm.getFieldValue('custom_text')
}, handleStreamMessage)
}, handleStreamMessage, (abort) => { abortRef.current = abort })
.finally(() => {
setRunLoading(false)
})

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 17:44:15
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-03-27 15:14:58
* @Last Modified time: 2026-04-21 14:24:00
*/
/**
* Prompt Editor Component
@@ -46,9 +46,17 @@ const Prompt: FC = () => {
const promptSaveModalRef = useRef<PromptSaveModalRef>(null)
const editorRef = useRef<any>(null)
const currentPromptValueRef = useRef<string>(undefined)
const abortRef = useRef<(() => void) | null>(null)
const values = Form.useWatch([], form)
const [editVo, setEditVo] = useState<HistoryItem | null>(null)
useEffect(() => {
return () => {
abortRef.current?.()
abortRef.current = null
}
}, [])
useEffect(() => {
setEditVo(state)
}, [state])
@@ -126,7 +134,7 @@ const Prompt: FC = () => {
}
})
};
updatePromptMessages((promptSession) as string, values, handleStreamMessage)
updatePromptMessages((promptSession) as string, values, handleStreamMessage, undefined, (abort) => { abortRef.current = abort })
.finally(() => {
setLoading(false)
})

View File

@@ -1,8 +1,9 @@
import type { FC } from 'react';
import { Select, Divider } from 'antd';
import { PlusOutlined, MinusOutlined, FileAddOutlined } from '@ant-design/icons'
import { Select, Divider, Tooltip } from 'antd';
import { PlusOutlined, MinusOutlined, FileAddOutlined, UndoOutlined, RedoOutlined } from '@ant-design/icons'
import clsx from 'clsx'
import { Node } from '@antv/x6';
import { useTranslation } from 'react-i18next'
import type { GraphRef } from '../types'
@@ -15,6 +16,10 @@ interface CanvasToolbarProps {
setIsHandMode: React.Dispatch<React.SetStateAction<boolean>>;
zoomLevel: number;
addNotes: () => void;
canUndo: boolean;
canRedo: boolean;
onUndo: () => void;
onRedo: () => void;
}
const CanvasToolbar: FC<CanvasToolbarProps> = ({
@@ -22,12 +27,13 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
miniMapRef,
graphRef,
zoomLevel,
// canUndo,
// canRedo,
// onUndo,
// onRedo,
canUndo,
canRedo,
onUndo,
onRedo,
addNotes,
}) => {
const { t } = useTranslation()
return (
<>
{/* 小地图 */}
@@ -63,13 +69,16 @@ const CanvasToolbar: FC<CanvasToolbarProps> = ({
{ label: '125%', value: 125 },
{ label: '150%', value: 150 },
{ label: '200%', value: 200 },
{ label: '自适应', value: 'fit' },
{ label: t('workflow.fit'), value: 'fit' },
]}
variant='borderless'
size="small"
/>
<PlusOutlined className="rb:text-[16px] rb:cursor-pointer" onClick={() => graphRef.current?.zoom(0.1)} />
<Divider type="vertical" className="rb:h-4" />
<Tooltip title={`${t('workflow.undo')} (Ctrl+Z)`}><UndoOutlined className={clsx('rb:text-[16px]', canUndo ? 'rb:cursor-pointer' : 'rb:opacity-30 rb:cursor-not-allowed')} onClick={onUndo} /></Tooltip>
<Tooltip title={`${t('workflow.redo')} (Ctrl+Y)`}><RedoOutlined className={clsx('rb:text-[16px]', canRedo ? 'rb:cursor-pointer' : 'rb:opacity-30 rb:cursor-not-allowed')} onClick={onRedo} /></Tooltip>
<Divider type="vertical" className="rb:h-4" />
<FileAddOutlined onClick={addNotes} />
</div>
</>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-06 21:10:56
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-07 18:07:38
* @Last Modified time: 2026-04-21 14:59:13
*/
/**
* Workflow Chat Component
@@ -41,13 +41,17 @@ import type { ChatToolbarRef } from '@/components/Chat/ChatToolbar'
import Runtime from './Runtime';
import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
import { replaceVariables } from '@/views/ApplicationConfig/Agent';
import { useWorkflowStore } from '@/store/workflow';
const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: WorkflowConfig | null; features?: FeaturesConfigForm }>(({
const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: WorkflowConfig | null; features?: FeaturesConfigForm }>(({ // eslint-disable-line
appId, graphRef, features
}, ref) => {
const { t } = useTranslation()
const { message: messageApi } = App.useApp()
const { setChatHistory } = useWorkflowStore()
const conversationIdRef = useRef<string>('draft')
const toolbarRef = useRef<ChatToolbarRef>(null)
const abortRef = useRef<(() => void) | null>(null)
const [toolbarReady, setToolbarReady] = useState(false)
const toolbarCallbackRef = useCallback((node: ChatToolbarRef | null) => {
(toolbarRef as React.MutableRefObject<ChatToolbarRef | null>).current = node
@@ -62,6 +66,8 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
const [fileList, setFileList] = useState<any[]>([])
const [message, setMessage] = useState<string | undefined>(undefined)
console.log('abortRef', abortRef)
/**
* Opens the chat drawer and loads workflow variables from the start node
*/
@@ -113,11 +119,14 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
* Closes the drawer and resets all state
*/
const handleClose = () => {
abortRef.current?.()
abortRef.current = null;
setOpen(false)
setToolbarReady(false)
setChatList([])
setVariables([])
setConversationId(null)
conversationIdRef.current = 'draft'
setMessage(undefined)
toolbarRef.current?.setFiles([])
toolbarRef.current?.setVariables([])
@@ -189,7 +198,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
elapsed_time?: string;
error?: any;
state: Record<string, any>;
status?: 'completed' | 'failed',
status?: 'completed' | 'failed' | 'running',
citations?: {
document_id: string;
file_name: string;
@@ -231,6 +240,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
node_name: name,
node_type: type,
icon,
status: 'running',
content: {},
}
} else {
@@ -240,6 +250,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
node_name: name,
node_type: type,
icon,
status: 'running',
content: {},
})
}
@@ -344,6 +355,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
}
if (conversation_id && conversationId !== conversation_id) {
conversationIdRef.current = conversation_id
setConversationId(conversation_id)
}
})
@@ -388,7 +400,7 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
])
setLoading(true)
setStreamLoading(true)
draftRun(appId, data, handleStreamMessage)
draftRun(appId, data, handleStreamMessage, abort => { abortRef.current = abort })
.catch((error) => {
const errorInfo = JSON.parse(error.message)
setChatList(prev => {
@@ -440,6 +452,10 @@ const Chat = forwardRef<ChatRef, { appId: string; graphRef: GraphRef; data: Work
}
}, [chatList.length, features?.opening_statement, variables])
useEffect(() => {
setChatHistory(conversationIdRef.current, chatList)
}, [chatList])
return (
<RbDrawer
title={<Flex align="center" gap={10}>

View File

@@ -34,29 +34,24 @@ const NodeLibrary: FC<{ collapsed: boolean; handleToggle: () => void }> = ({ col
>
<Flex vertical align={collapsed ? 'center' : undefined} gap={collapsed ? 8 : 16}>
{collapsed
? <>
{nodeLibrary.map(category => (
<>
{category.nodes
.filter(node => node.type !== 'cycle-start' && node.type !== 'break')
.map((node, nodeIndex) => (
<Tooltip key={nodeIndex} title={t(`workflow.${node.type}`)} placement="right">
<div
className="rb:p-2 rb:rounded-lg rb:hover:bg-[rgba(33,35,50,0.08)]"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/reactflow', node.type);
e.dataTransfer.setData('application/json', JSON.stringify(node));
}}
>
<div className={`rb:size-6 rb:cursor-pointer rb:bg-cover ${node.icon}`} />
</div>
</Tooltip>
))
}
</>
))}
</>
? nodeLibrary.flatMap(category =>
category.nodes
.filter(node => node.type !== 'cycle-start' && node.type !== 'break')
.map(node => (
<Tooltip key={node.type} title={t(`workflow.${node.type}`)} placement="right">
<div
className="rb:p-2 rb:rounded-lg rb:hover:bg-[rgba(33,35,50,0.08)]"
draggable
onDragStart={(e) => {
e.dataTransfer.setData('application/reactflow', node.type);
e.dataTransfer.setData('application/json', JSON.stringify(node));
}}
>
<div className={`rb:size-6 rb:cursor-pointer rb:bg-cover ${node.icon}`} />
</div>
</Tooltip>
))
)
: nodeLibrary.map(category => (
<div
key={category.category}
@@ -65,9 +60,9 @@ const NodeLibrary: FC<{ collapsed: boolean; handleToggle: () => void }> = ({ col
<Flex gap={6} vertical>
{category.nodes
.filter(node => node.type !== 'cycle-start' && node.type !== 'break')
.map((node, nodeIndex) => (
.map((node) => (
<Flex
key={nodeIndex}
key={node.type}
align="center"
gap={8}
className="rb:rounded-xl rb:p-2! rb:border rb:border-[#EBEBEB] rb:cursor-pointer rb:hover:border rb:hover:border-[#171719]!"

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import clsx from 'clsx';
import type { ReactShapeConfig } from '@antv/x6-react-shape';
import { Flex } from 'antd';
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons';
import NodeTools from './NodeTools'
import { useVariableList } from '../Properties/hooks/useVariableList'
@@ -64,13 +65,23 @@ const ConditionNode: ReactShapeConfig['component'] = ({ node }) => {
return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:p-3 rb:border rb:rounded-2xl rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)]', {
'rb:border-[#171719]': data.isSelected,
'rb:border-[#FCFCFD]': !data.isSelected
'rb:border-[#171719]!': data.isSelected,
'rb:border-[#FCFCFD]': !data.isSelected,
'rb:border-[#369F21]!': !data.isSelected && data.executionStatus === 'completed',
'rb:border-[#FF5D34]!': !data.isSelected && data.executionStatus === 'failed',
})}>
<NodeTools node={node} />
<Flex align="center" gap={8} className="rb:flex-1">
<div className={`rb:size-6 rb:bg-cover ${data.icon}`} />
<div className="rb:wrap-break-word rb:line-clamp-1">{data.name ?? t(`workflow.${data.type}`)}</div>
<div className="rb:wrap-break-word rb:line-clamp-1 rb:flex-1">{data.name ?? t(`workflow.${data.type}`)}</div>
{data.executionStatus === 'completed'
? <CheckCircleFilled style={{ color: '#369F21', fontSize: 16 }} />
: data.executionStatus === 'failed'
? <CloseCircleFilled style={{ color: '#FF5D34', fontSize: 16 }} />
: data.executionStatus === 'running'
? <LoadingOutlined style={{ color: '#5B6167', fontSize: 16 }} />
: null
}
</Flex>
{data.type === 'question-classifier' &&

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next'
import clsx from 'clsx';
import type { ReactShapeConfig } from '@antv/x6-react-shape';
import { Flex } from 'antd';
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons';
import { graphNodeLibrary, edgeAttrs } from '../../constant';
import NodeTools from './NodeTools'
@@ -131,12 +132,22 @@ const LoopNode: ReactShapeConfig['component'] = ({ node, graph }) => {
return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:p-3 rb:border rb:rounded-2xl rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)]', {
'rb:border-[#171719]': data.isSelected,
'rb:border-[#FCFCFD]': !data.isSelected
'rb:border-[#FCFCFD]': !data.isSelected,
'rb:border-[#369F21]!': !data.isSelected && data.executionStatus === 'completed',
'rb:border-[#FF5D34]!': !data.isSelected && data.executionStatus === 'failed',
})}>
<NodeTools node={node} />
<Flex align="center" gap={8} className="rb:flex-1">
<div className={`rb:size-6 rb:bg-cover ${data.icon}`} />
<div className="rb:wrap-break-word rb:line-clamp-1">{data.name ?? t(`workflow.${data.type}`)}</div>
<div className="rb:wrap-break-word rb:line-clamp-1 rb:flex-1">{data.name ?? t(`workflow.${data.type}`)}</div>
{data.executionStatus === 'completed'
? <CheckCircleFilled style={{ color: '#369F21', fontSize: 16 }} />
: data.executionStatus === 'failed'
? <CloseCircleFilled style={{ color: '#FF5D34', fontSize: 16 }} />
: data.executionStatus === 'running'
? <LoadingOutlined style={{ color: '#5B6167', fontSize: 16 }} />
: null
}
</Flex>
<div className="rb:mt-3 rb:min-h-[calc(100%-36px)] rb:w-full rb:bg-[radial-gradient(circle,#939AB1_1px,#F0F3F8_1px)] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)] rb:rounded-[10px] rb:bg-size-[12px_12px]"></div>
</div>

View File

@@ -2,6 +2,7 @@ import clsx from 'clsx';
import { useTranslation } from 'react-i18next'
import type { ReactShapeConfig } from '@antv/x6-react-shape';
import { Flex } from 'antd';
import { CheckCircleFilled, CloseCircleFilled, LoadingOutlined } from '@ant-design/icons';
import NodeTools from './NodeTools'
@@ -11,13 +12,23 @@ const NormalNode: ReactShapeConfig['component'] = ({ node }) => {
return (
<div className={clsx('rb:cursor-pointer rb:group rb:relative rb:h-full rb:w-full rb:p-3 rb:border rb:rounded-2xl rb:bg-[#FCFCFD] rb:shadow-[0px_2px_4px_0px_rgba(23,23,25,0.03)]', {
'rb:border-[#171719]': data.isSelected,
'rb:border-[#FCFCFD]': !data.isSelected
'rb:border-[#171719]!': data.isSelected,
'rb:border-[#FCFCFD]': !data.isSelected,
'rb:border-[#369F21]!': !data.isSelected && data.executionStatus === 'completed',
'rb:border-[#FF5D34]!': !data.isSelected && data.executionStatus === 'failed',
})}>
<NodeTools node={node} />
<Flex align="center" gap={8} className="rb:flex-1">
<div className={`rb:size-6 rb:bg-cover ${data.icon}`} />
<div className="rb:wrap-break-word rb:line-clamp-1">{data.name ?? t(`workflow.${data.type}`)}</div>
<div className="rb:wrap-break-word rb:line-clamp-1 rb:flex-1">{data.name ?? t(`workflow.${data.type}`)}</div>
{data.executionStatus === 'completed'
? <CheckCircleFilled style={{ color: '#369F21', fontSize: 16 }} />
: data.executionStatus === 'failed'
? <CloseCircleFilled style={{ color: '#FF5D34', fontSize: 16 }} />
: data.executionStatus === 'running'
? <LoadingOutlined style={{ color: '#5B6167', fontSize: 16 }} />
: null
}
</Flex>
<div className="rb:text-[#5B6167] rb:text-[12px] rb:leading-4 rb:mt-3">{t('workflow.clickToConfigure')}</div>

View File

@@ -2,7 +2,7 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:39:59
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-20 15:42:36
* @Last Modified time: 2026-04-21 14:15:33
*/
import { type FC, useEffect, useState, useMemo } from "react";
import clsx from 'clsx'
@@ -154,7 +154,9 @@ const Properties: FC<PropertiesProps> = ({
selectedNode?.setData({
...nodeData,
...allRest,
})
},
// { deep: false }
)
}
}, [values, selectedNode, form])

View File

@@ -2,9 +2,10 @@
* @Author: ZhaoYing
* @Date: 2026-02-03 15:17:48
* @Last Modified by: ZhaoYing
* @Last Modified time: 2026-04-14 17:43:14
* @Last Modified time: 2026-04-20 16:00:26
*/
import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, type Edge } from '@antv/x6';
import { Clipboard, Graph, Keyboard, MiniMap, Node, Snapline, History, type Edge } from '@antv/x6';
import type { HistoryCommand as Command } from '@antv/x6/lib/plugin/history/type';
import { register } from '@antv/x6-react-shape';
import type { PortMetadata } from '@antv/x6/lib/model/port';
import { App } from 'antd';
@@ -18,6 +19,7 @@ import type { FeaturesConfigForm } from '@/views/ApplicationConfig/types';
import { conditionNodeHeight, conditionNodeItemHeight, conditionNodePortItemArgsY, defaultAbsolutePortGroups, defaultPortItems, edgeAttrs, edgeHoverTool, edge_color, edge_selected_color, edge_width, graphNodeLibrary, nodeLibrary, nodeRegisterLibrary, nodeWidth, notesConfig, portAttrs, portItemArgsY, portMarkup, portTextAttrs, unknownNode } from '../constant';
import type { ChatVariable, NodeProperties, WorkflowConfig } from '../types';
import { calcConditionNodeTotalHeight, getConditionNodeCasePortY } from '../utils';
import { useWorkflowStore } from '@/store/workflow';
/**
* Props for useWorkflowGraph hook
@@ -63,6 +65,14 @@ export interface UseWorkflowGraphReturn {
copyEvent: () => boolean | void;
/** Handler for paste keyboard event */
parseEvent: () => boolean | void;
/** Whether undo is available */
canUndo: boolean;
/** Whether redo is available */
canRedo: boolean;
/** Undo last action */
undo: () => void;
/** Redo last undone action */
redo: () => void;
/** Function to save workflow configuration */
handleSave: (flag?: boolean) => Promise<unknown>;
/** Chat variables for workflow */
@@ -94,6 +104,8 @@ export const useWorkflowGraph = ({
const { message } = App.useApp();
const { t } = useTranslation()
const { user } = useUser();
const { chatHistoryMap } = useWorkflowStore()
const chatHistory = Object.values(chatHistoryMap).at(-1) ?? []
// Refs
const graphRef = useRef<Graph>();
@@ -105,6 +117,8 @@ export const useWorkflowGraph = ({
const [config, setConfig] = useState<WorkflowConfig | null>(null);
const [chatVariables, setChatVariables] = useState<ChatVariable[]>([])
const featuresRef = useRef<FeaturesConfigForm | undefined>(undefined)
const [canUndo, setCanUndo] = useState(false)
const [canRedo, setCanRedo] = useState(false)
useEffect(() => {
if (!graphRef.current) return
@@ -470,6 +484,8 @@ export const useWorkflowGraph = ({
graphRef.current.getNodes().forEach(node => {
if (node.getData()?.cycle) node.toFront();
});
graphRef.current.enableHistory()
graphRef.current.cleanHistory()
}
}, 200)
}
@@ -505,6 +521,22 @@ export const useWorkflowGraph = ({
global: true,
}),
);
graphRef.current.use(
new History({
enabled: false,
beforeAddCommand(_event, args: any) {
const event = args?.key ? `cell:change:${args.key}` : _event;
if (event.startsWith('cell:change:') &&
event !== 'cell:change:position' &&
event !== 'cell:change:source' &&
event !== 'cell:change:target') return false;
},
}),
);
graphRef.current.on('history:change', ({ cmds }: { cmds: Command[] }) => {
setCanUndo(graphRef.current?.canUndo() ?? false)
setCanRedo(graphRef.current?.canRedo() ?? false)
})
};
// 显示/隐藏连接桩
// const showPorts = (show: boolean) => {
@@ -1093,6 +1125,9 @@ export const useWorkflowGraph = ({
graphRef.current.bindKey(['ctrl+v', 'cmd+v'], parseEvent);
// Delete selected nodes and edges
graphRef.current.bindKey(['ctrl+d', 'cmd+d', 'delete', 'backspace'], deleteEvent);
// Undo / Redo
graphRef.current.bindKey(['ctrl+z', 'cmd+z'], () => { graphRef.current?.undo(); return false; });
graphRef.current.bindKey(['ctrl+y', 'cmd+y', 'ctrl+shift+z', 'cmd+shift+z'], () => { graphRef.current?.redo(); return false; });
};
@@ -1411,6 +1446,9 @@ export const useWorkflowGraph = ({
return userVars
}
const undo = () => graphRef.current?.undo()
const redo = () => graphRef.current?.redo()
const handleSaveFeaturesConfig = (value?: FeaturesConfigForm) => {
const { statement = '' } = value?.opening_statement || {}
featuresRef.current = value
@@ -1446,6 +1484,31 @@ export const useWorkflowGraph = ({
}
}
}
useEffect(() => {
if (!graphRef.current) return;
const nodes = graphRef.current.getNodes();
const lastWithSub = [...chatHistory].reverse().find(item => item.subContent?.length);
// Reset all node execution status first
nodes.forEach(node => {
const data = node.getData();
if (typeof data.status === 'string') {
node.setData({ ...data, executionStatus: undefined });
}
});
if (!lastWithSub?.subContent) return;
// Build a nodeId -> status map first
const statusMap: Record<string, string> = {};
lastWithSub.subContent.forEach(sub => {
if (typeof sub.status === 'string') {
statusMap[sub.node_id] = sub.status;
const node = nodes.find(n => n.getData()?.id === sub.node_id);
if (node) {
node.setData({ ...node.getData(), executionStatus: sub.status });
}
}
});
}, [chatHistory, graphRef.current]);
return {
config,
@@ -1470,5 +1533,9 @@ export const useWorkflowGraph = ({
handleSaveFeaturesConfig,
features: featuresRef.current,
getStartNodeVariables,
canUndo,
canRedo,
undo,
redo,
};
};

View File

@@ -39,6 +39,10 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
handleSaveFeaturesConfig,
features,
getStartNodeVariables,
canUndo,
canRedo,
undo,
redo,
} = useWorkflowGraph({ containerRef, miniMapRef, onFeaturesLoad });
const onDragOver = (event: React.DragEvent) => {
@@ -96,6 +100,10 @@ const Workflow = forwardRef<WorkflowRef, { onFeaturesLoad?: (features: FeaturesC
setIsHandMode={setIsHandMode}
zoomLevel={zoomLevel}
addNotes={handleAddNotes}
canUndo={canUndo}
canRedo={canRedo}
onUndo={undo}
onRedo={redo}
/>
</div>