### What problem does this PR solve? Add history version save - Allows users to view and download agent files by version revision history  _Briefly describe what this PR aims to solve. Include background context that will help reviewers understand the purpose of the PR._ ### Type of change - [ ] Bug Fix (non-breaking change which fixes an issue) - [x] New Feature (non-breaking change which adds functionality) - [ ] Documentation Update - [ ] Refactoring - [ ] Performance Improvement - [ ] Other (please describe): --------- Co-authored-by: Kevin Hu <kevinhu.sh@gmail.com>tags/v0.18.0
| @@ -18,13 +18,14 @@ import traceback | |||
| from flask import request, Response | |||
| from flask_login import login_required, current_user | |||
| from api.db.services.canvas_service import CanvasTemplateService, UserCanvasService | |||
| from api.db.services.user_canvas_version import UserCanvasVersionService | |||
| from api.settings import RetCode | |||
| from api.utils import get_uuid | |||
| from api.utils.api_utils import get_json_result, server_error_response, validate_request, get_data_error_result | |||
| from agent.canvas import Canvas | |||
| from peewee import MySQLDatabase, PostgresqlDatabase | |||
| from api.db.db_models import APIToken | |||
| import time | |||
| @manager.route('/templates', methods=['GET']) # noqa: F821 | |||
| @login_required | |||
| @@ -61,7 +62,6 @@ def save(): | |||
| req["user_id"] = current_user.id | |||
| if not isinstance(req["dsl"], str): | |||
| req["dsl"] = json.dumps(req["dsl"], ensure_ascii=False) | |||
| req["dsl"] = json.loads(req["dsl"]) | |||
| if "id" not in req: | |||
| if UserCanvasService.query(user_id=current_user.id, title=req["title"].strip()): | |||
| @@ -75,8 +75,13 @@ def save(): | |||
| data=False, message='Only owner of canvas authorized for this operation.', | |||
| code=RetCode.OPERATING_ERROR) | |||
| UserCanvasService.update_by_id(req["id"], req) | |||
| # save version | |||
| UserCanvasVersionService.insert( user_canvas_id=req["id"], dsl=req["dsl"], title="{0}_{1}".format(req["title"], time.strftime("%Y_%m_%d_%H_%M_%S"))) | |||
| UserCanvasVersionService.delete_all_versions(req["id"]) | |||
| return get_json_result(data=req) | |||
| @manager.route('/get/<canvas_id>', methods=['GET']) # noqa: F821 | |||
| @login_required | |||
| @@ -284,3 +289,27 @@ def test_db_connect(): | |||
| except Exception as e: | |||
| return server_error_response(e) | |||
| #api get list version dsl of canvas | |||
| @manager.route('/getlistversion/<canvas_id>', methods=['GET']) # noqa: F821 | |||
| @login_required | |||
| def getlistversion(canvas_id): | |||
| try: | |||
| list =sorted([c.to_dict() for c in UserCanvasVersionService.list_by_canvas_id(canvas_id)], key=lambda x: x["update_time"]*-1) | |||
| return get_json_result(data=list) | |||
| except Exception as e: | |||
| return get_data_error_result(message=f"Error getting history files: {e}") | |||
| #api get version dsl of canvas | |||
| @manager.route('/getversion/<version_id>', methods=['GET']) # noqa: F821 | |||
| @login_required | |||
| def getversion( version_id): | |||
| try: | |||
| e, version = UserCanvasVersionService.get_by_id(version_id) | |||
| if version: | |||
| return get_json_result(data=version.to_dict()) | |||
| except Exception as e: | |||
| return get_json_result(data=f"Error getting history file: {e}") | |||
| @@ -988,6 +988,16 @@ class CanvasTemplate(DataBaseModel): | |||
| class Meta: | |||
| db_table = "canvas_template" | |||
| class UserCanvasVersion(DataBaseModel): | |||
| id = CharField(max_length=32, primary_key=True) | |||
| user_canvas_id = CharField(max_length=255, null=False, help_text="user_canvas_id", index=True) | |||
| title = CharField(max_length=255, null=True, help_text="Canvas title") | |||
| description = TextField(null=True, help_text="Canvas description") | |||
| dsl = JSONField(null=True, default={}) | |||
| class Meta: | |||
| db_table = "user_canvas_version" | |||
| def migrate_db(): | |||
| with DB.transaction(): | |||
| @@ -0,0 +1,43 @@ | |||
| from api.db.db_models import UserCanvasVersion, DB | |||
| from api.db.services.common_service import CommonService | |||
| from peewee import DoesNotExist | |||
| class UserCanvasVersionService(CommonService): | |||
| model = UserCanvasVersion | |||
| @classmethod | |||
| @DB.connection_context() | |||
| def list_by_canvas_id(cls, user_canvas_id): | |||
| try: | |||
| user_canvas_version = cls.model.select( | |||
| *[cls.model.id, | |||
| cls.model.create_time, | |||
| cls.model.title, | |||
| cls.model.create_date, | |||
| cls.model.update_date, | |||
| cls.model.user_canvas_id, | |||
| cls.model.update_time] | |||
| ).where(cls.model.user_canvas_id == user_canvas_id) | |||
| return user_canvas_version | |||
| except DoesNotExist: | |||
| return None | |||
| except Exception: | |||
| return None | |||
| @classmethod | |||
| @DB.connection_context() | |||
| def delete_all_versions(cls, user_canvas_id): | |||
| try: | |||
| user_canvas_version = cls.model.select().where(cls.model.user_canvas_id == user_canvas_id).order_by(cls.model.create_time.desc()) | |||
| if user_canvas_version.count() > 20: | |||
| for i in range(20, user_canvas_version.count()): | |||
| cls.delete(user_canvas_version[i].id) | |||
| return True | |||
| except DoesNotExist: | |||
| return None | |||
| except Exception: | |||
| return None | |||
| @@ -17,6 +17,8 @@ services: | |||
| - ./nginx/ragflow.conf:/etc/nginx/conf.d/ragflow.conf | |||
| - ./nginx/proxy.conf:/etc/nginx/proxy.conf | |||
| - ./nginx/nginx.conf:/etc/nginx/nginx.conf | |||
| - ../history_data_agent:/ragflow/history_data_agent | |||
| env_file: .env | |||
| environment: | |||
| - TZ=${TIMEZONE} | |||
| @@ -90,6 +90,53 @@ export const useFetchFlowList = (): { data: IFlow[]; loading: boolean } => { | |||
| return { data, loading }; | |||
| }; | |||
| export const useFetchListVersion = ( | |||
| canvas_id: string, | |||
| ): { | |||
| data: { | |||
| created_at: string; | |||
| title: string; | |||
| id: string; | |||
| }[]; | |||
| loading: boolean; | |||
| } => { | |||
| const { data, isFetching: loading } = useQuery({ | |||
| queryKey: ['fetchListVersion'], | |||
| initialData: [], | |||
| gcTime: 0, | |||
| queryFn: async () => { | |||
| const { data } = await flowService.getListVersion({}, canvas_id); | |||
| return data?.data ?? []; | |||
| }, | |||
| }); | |||
| return { data, loading }; | |||
| }; | |||
| export const useFetchVersion = ( | |||
| version_id?: string, | |||
| ): { | |||
| data?: IFlow; | |||
| loading: boolean; | |||
| } => { | |||
| const { data, isFetching: loading } = useQuery({ | |||
| queryKey: ['fetchVersion', version_id], | |||
| initialData: undefined, | |||
| gcTime: 0, | |||
| enabled: !!version_id, // Only call API when both values are provided | |||
| queryFn: async () => { | |||
| if (!version_id) return undefined; | |||
| const { data } = await flowService.getVersion({}, version_id); | |||
| return data?.data ?? undefined; | |||
| }, | |||
| }); | |||
| return { data, loading }; | |||
| }; | |||
| export const useFetchFlow = (): { | |||
| data: IFlow; | |||
| loading: boolean; | |||
| @@ -1194,6 +1194,16 @@ This delimiter is used to split the input text into several text pieces echo of | |||
| nextStep: 'Next step', | |||
| datatype: 'MINE type of the HTTP request', | |||
| insertVariableTip: `Enter / Insert variables`, | |||
| historyversion: 'History version', | |||
| filename: 'File name', | |||
| version: { | |||
| created: 'Created', | |||
| details: 'Version details', | |||
| dsl: 'DSL', | |||
| download: 'Download', | |||
| version: 'Version', | |||
| select: 'No version selected', | |||
| }, | |||
| }, | |||
| footer: { | |||
| profile: 'All rights reserved @ React', | |||
| @@ -46,7 +46,7 @@ import { RewriteNode } from './node/rewrite-node'; | |||
| import { SwitchNode } from './node/switch-node'; | |||
| import { TemplateNode } from './node/template-node'; | |||
| const nodeTypes: NodeTypes = { | |||
| export const nodeTypes: NodeTypes = { | |||
| ragNode: RagNode, | |||
| categorizeNode: CategorizeNode, | |||
| beginNode: BeginNode, | |||
| @@ -66,7 +66,7 @@ const nodeTypes: NodeTypes = { | |||
| iterationStartNode: IterationStartNode, | |||
| }; | |||
| const edgeTypes = { | |||
| export const edgeTypes = { | |||
| buttonEdge: ButtonEdge, | |||
| }; | |||
| @@ -18,6 +18,10 @@ import { | |||
| } from '../hooks/use-save-graph'; | |||
| import { BeginQuery } from '../interface'; | |||
| import { | |||
| HistoryVersionModal, | |||
| useHistoryVersionModal, | |||
| } from '../history-version-modal'; | |||
| import styles from './index.less'; | |||
| interface IProps { | |||
| @@ -36,7 +40,8 @@ const FlowHeader = ({ showChatDrawer, chatDrawerVisible }: IProps) => { | |||
| const { showEmbedModal, hideEmbedModal, embedVisible, beta } = | |||
| useShowEmbedModal(); | |||
| const isBeginNodeDataQuerySafe = useGetBeginNodeDataQueryIsSafe(); | |||
| const { setVisibleHistoryVersionModal, visibleHistoryVersionModal } = | |||
| useHistoryVersionModal(); | |||
| const handleShowEmbedModal = useCallback(() => { | |||
| showEmbedModal(); | |||
| }, [showEmbedModal]); | |||
| @@ -50,6 +55,9 @@ const FlowHeader = ({ showChatDrawer, chatDrawerVisible }: IProps) => { | |||
| } | |||
| }, [getBeginNodeDataQuery, handleRun, showChatDrawer]); | |||
| const showListVersion = useCallback(() => { | |||
| setVisibleHistoryVersionModal(true); | |||
| }, [setVisibleHistoryVersionModal]); | |||
| return ( | |||
| <> | |||
| <Flex | |||
| @@ -83,6 +91,9 @@ const FlowHeader = ({ showChatDrawer, chatDrawerVisible }: IProps) => { | |||
| > | |||
| <b>{t('embedIntoSite', { keyPrefix: 'common' })}</b> | |||
| </Button> | |||
| <Button type="primary" onClick={showListVersion}> | |||
| <b>{t('historyversion')}</b> | |||
| </Button> | |||
| </Space> | |||
| </Flex> | |||
| {embedVisible && ( | |||
| @@ -95,6 +106,13 @@ const FlowHeader = ({ showChatDrawer, chatDrawerVisible }: IProps) => { | |||
| isAgent | |||
| ></EmbedModal> | |||
| )} | |||
| {visibleHistoryVersionModal && ( | |||
| <HistoryVersionModal | |||
| id={id || ''} | |||
| visible={visibleHistoryVersionModal} | |||
| hideModal={() => setVisibleHistoryVersionModal(false)} | |||
| ></HistoryVersionModal> | |||
| )} | |||
| </> | |||
| ); | |||
| }; | |||
| @@ -0,0 +1,184 @@ | |||
| import { useTranslate } from '@/hooks/common-hooks'; | |||
| import { useFetchListVersion, useFetchVersion } from '@/hooks/flow-hooks'; | |||
| import { | |||
| Background, | |||
| ConnectionMode, | |||
| ReactFlow, | |||
| ReactFlowProvider, | |||
| } from '@xyflow/react'; | |||
| import { Card, Col, Empty, List, Modal, Row, Spin, Typography } from 'antd'; | |||
| import React, { useState } from 'react'; | |||
| import { nodeTypes } from '../canvas'; | |||
| export function useHistoryVersionModal() { | |||
| const [visibleHistoryVersionModal, setVisibleHistoryVersionModal] = | |||
| React.useState(false); | |||
| return { | |||
| visibleHistoryVersionModal, | |||
| setVisibleHistoryVersionModal, | |||
| }; | |||
| } | |||
| type HistoryVersionModalProps = { | |||
| visible: boolean; | |||
| hideModal: () => void; | |||
| id: string; | |||
| }; | |||
| export function HistoryVersionModal({ | |||
| visible, | |||
| hideModal, | |||
| id, | |||
| }: HistoryVersionModalProps) { | |||
| const { t } = useTranslate('flow'); | |||
| const { data, loading } = useFetchListVersion(id); | |||
| const [selectedVersion, setSelectedVersion] = useState<any>(null); | |||
| const { data: flow, loading: loadingVersion } = useFetchVersion( | |||
| selectedVersion?.id, | |||
| ); | |||
| React.useEffect(() => { | |||
| if (!loading && data?.length > 0 && !selectedVersion) { | |||
| setSelectedVersion(data[0]); | |||
| } | |||
| }, [data, loading, selectedVersion]); | |||
| const downloadfile = React.useCallback( | |||
| function (e: any) { | |||
| e.stopPropagation(); | |||
| console.log('Restore version:', selectedVersion); | |||
| // Create a JSON blob and trigger download | |||
| const jsonContent = JSON.stringify(flow?.dsl.graph, null, 2); | |||
| const blob = new Blob([jsonContent], { | |||
| type: 'application/json', | |||
| }); | |||
| const url = URL.createObjectURL(blob); | |||
| const a = document.createElement('a'); | |||
| a.href = url; | |||
| a.download = `${selectedVersion.filename || 'flow-version'}-${selectedVersion.id}.json`; | |||
| document.body.appendChild(a); | |||
| a.click(); | |||
| document.body.removeChild(a); | |||
| URL.revokeObjectURL(url); | |||
| }, | |||
| [selectedVersion, flow?.dsl], | |||
| ); | |||
| return ( | |||
| <React.Fragment> | |||
| <Modal | |||
| title={t('historyversion')} | |||
| open={visible} | |||
| width={'80vw'} | |||
| onCancel={hideModal} | |||
| footer={null} | |||
| getContainer={() => document.body} | |||
| > | |||
| <Row gutter={16} style={{ height: '60vh' }}> | |||
| <Col span={10} style={{ height: '100%', overflowY: 'auto' }}> | |||
| {loading && <Spin />} | |||
| {!loading && data.length === 0 && ( | |||
| <Empty description="No versions found" /> | |||
| )} | |||
| {!loading && data.length > 0 && ( | |||
| <List | |||
| itemLayout="horizontal" | |||
| dataSource={data} | |||
| pagination={{ | |||
| pageSize: 5, | |||
| simple: true, | |||
| }} | |||
| renderItem={(item) => ( | |||
| <List.Item | |||
| key={item.id} | |||
| onClick={(e) => { | |||
| e.stopPropagation(); | |||
| setSelectedVersion(item); | |||
| }} | |||
| style={{ | |||
| cursor: 'pointer', | |||
| background: | |||
| selectedVersion?.id === item.id ? '#f0f5ff' : 'inherit', | |||
| padding: '8px 12px', | |||
| borderRadius: '4px', | |||
| }} | |||
| > | |||
| <List.Item.Meta | |||
| title={`${t('filename')}: ${item.title || '-'}`} | |||
| description={item.created_at} | |||
| /> | |||
| </List.Item> | |||
| )} | |||
| /> | |||
| )} | |||
| </Col> | |||
| {/* Right panel - Version details */} | |||
| <Col span={14} style={{ height: '100%', overflowY: 'auto' }}> | |||
| {selectedVersion ? ( | |||
| <Card title={t('version.details')} bordered={false}> | |||
| <Row gutter={[16, 16]}> | |||
| {/* Add actions for the selected version (restore, download, etc.) */} | |||
| <Col span={24}> | |||
| <div style={{ textAlign: 'right' }}> | |||
| <Typography.Link onClick={downloadfile}> | |||
| {t('version.download')} | |||
| </Typography.Link> | |||
| </div> | |||
| </Col> | |||
| </Row> | |||
| <Typography.Title level={4}> | |||
| {selectedVersion.title || '-'} | |||
| </Typography.Title> | |||
| <Typography.Text | |||
| type="secondary" | |||
| style={{ display: 'block', marginBottom: 16 }} | |||
| > | |||
| {t('version.created')}: {selectedVersion.create_date} | |||
| </Typography.Text> | |||
| {/*render dsl form api*/} | |||
| {loadingVersion && <Spin />} | |||
| {!loadingVersion && flow?.dsl && ( | |||
| <ReactFlowProvider key={`flow-${selectedVersion.id}`}> | |||
| <div | |||
| style={{ | |||
| height: '400px', | |||
| position: 'relative', | |||
| zIndex: 0, | |||
| }} | |||
| > | |||
| <ReactFlow | |||
| connectionMode={ConnectionMode.Loose} | |||
| nodes={flow?.dsl.graph?.nodes || []} | |||
| edges={ | |||
| flow?.dsl.graph?.edges.flatMap((x) => ({ | |||
| ...x, | |||
| type: 'default', | |||
| })) || [] | |||
| } | |||
| fitView | |||
| nodeTypes={nodeTypes} | |||
| edgeTypes={{}} | |||
| zoomOnScroll={true} | |||
| panOnDrag={true} | |||
| zoomOnDoubleClick={false} | |||
| preventScrolling={true} | |||
| minZoom={0.1} | |||
| > | |||
| <Background /> | |||
| </ReactFlow> | |||
| </div> | |||
| </ReactFlowProvider> | |||
| )} | |||
| </Card> | |||
| ) : ( | |||
| <Empty description={t('version.select')} /> | |||
| )} | |||
| </Col> | |||
| </Row> | |||
| </Modal> | |||
| </React.Fragment> | |||
| ); | |||
| } | |||
| @@ -6,6 +6,8 @@ const { | |||
| getCanvas, | |||
| getCanvasSSE, | |||
| setCanvas, | |||
| getListVersion, | |||
| getVersion, | |||
| listCanvas, | |||
| resetCanvas, | |||
| removeCanvas, | |||
| @@ -29,6 +31,14 @@ const methods = { | |||
| url: setCanvas, | |||
| method: 'post', | |||
| }, | |||
| getListVersion: { | |||
| url: getListVersion, | |||
| method: 'get', | |||
| }, | |||
| getVersion: { | |||
| url: getVersion, | |||
| method: 'get', | |||
| }, | |||
| listCanvas: { | |||
| url: listCanvas, | |||
| method: 'get', | |||
| @@ -63,6 +73,6 @@ const methods = { | |||
| }, | |||
| } as const; | |||
| const chatService = registerServer<keyof typeof methods>(methods, request); | |||
| const flowService = registerServer<keyof typeof methods>(methods, request); | |||
| export default chatService; | |||
| export default flowService; | |||
| @@ -127,6 +127,8 @@ export default { | |||
| getCanvasSSE: `${api_host}/canvas/getsse`, | |||
| removeCanvas: `${api_host}/canvas/rm`, | |||
| setCanvas: `${api_host}/canvas/set`, | |||
| getListVersion: `${api_host}/canvas/getlistversion`, | |||
| getVersion: `${api_host}/canvas/getversion`, | |||
| resetCanvas: `${api_host}/canvas/reset`, | |||
| runCanvas: `${api_host}/canvas/completion`, | |||
| testDbConnect: `${api_host}/canvas/test_db_connect`, | |||