### 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
| from flask import request, Response | from flask import request, Response | ||||
| from flask_login import login_required, current_user | from flask_login import login_required, current_user | ||||
| from api.db.services.canvas_service import CanvasTemplateService, UserCanvasService | 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.settings import RetCode | ||||
| from api.utils import get_uuid | 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 api.utils.api_utils import get_json_result, server_error_response, validate_request, get_data_error_result | ||||
| from agent.canvas import Canvas | from agent.canvas import Canvas | ||||
| from peewee import MySQLDatabase, PostgresqlDatabase | from peewee import MySQLDatabase, PostgresqlDatabase | ||||
| from api.db.db_models import APIToken | from api.db.db_models import APIToken | ||||
| import time | |||||
| @manager.route('/templates', methods=['GET']) # noqa: F821 | @manager.route('/templates', methods=['GET']) # noqa: F821 | ||||
| @login_required | @login_required | ||||
| req["user_id"] = current_user.id | req["user_id"] = current_user.id | ||||
| if not isinstance(req["dsl"], str): | if not isinstance(req["dsl"], str): | ||||
| req["dsl"] = json.dumps(req["dsl"], ensure_ascii=False) | req["dsl"] = json.dumps(req["dsl"], ensure_ascii=False) | ||||
| req["dsl"] = json.loads(req["dsl"]) | req["dsl"] = json.loads(req["dsl"]) | ||||
| if "id" not in req: | if "id" not in req: | ||||
| if UserCanvasService.query(user_id=current_user.id, title=req["title"].strip()): | if UserCanvasService.query(user_id=current_user.id, title=req["title"].strip()): | ||||
| data=False, message='Only owner of canvas authorized for this operation.', | data=False, message='Only owner of canvas authorized for this operation.', | ||||
| code=RetCode.OPERATING_ERROR) | code=RetCode.OPERATING_ERROR) | ||||
| UserCanvasService.update_by_id(req["id"], req) | 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) | return get_json_result(data=req) | ||||
| @manager.route('/get/<canvas_id>', methods=['GET']) # noqa: F821 | @manager.route('/get/<canvas_id>', methods=['GET']) # noqa: F821 | ||||
| @login_required | @login_required | ||||
| except Exception as e: | except Exception as e: | ||||
| return server_error_response(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}") |
| class Meta: | class Meta: | ||||
| db_table = "canvas_template" | 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(): | def migrate_db(): | ||||
| with DB.transaction(): | with DB.transaction(): |
| 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 | |||||
| - ./nginx/ragflow.conf:/etc/nginx/conf.d/ragflow.conf | - ./nginx/ragflow.conf:/etc/nginx/conf.d/ragflow.conf | ||||
| - ./nginx/proxy.conf:/etc/nginx/proxy.conf | - ./nginx/proxy.conf:/etc/nginx/proxy.conf | ||||
| - ./nginx/nginx.conf:/etc/nginx/nginx.conf | - ./nginx/nginx.conf:/etc/nginx/nginx.conf | ||||
| - ../history_data_agent:/ragflow/history_data_agent | |||||
| env_file: .env | env_file: .env | ||||
| environment: | environment: | ||||
| - TZ=${TIMEZONE} | - TZ=${TIMEZONE} |
| return { data, loading }; | 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 = (): { | export const useFetchFlow = (): { | ||||
| data: IFlow; | data: IFlow; | ||||
| loading: boolean; | loading: boolean; |
| nextStep: 'Next step', | nextStep: 'Next step', | ||||
| datatype: 'MINE type of the HTTP request', | datatype: 'MINE type of the HTTP request', | ||||
| insertVariableTip: `Enter / Insert variables`, | 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: { | footer: { | ||||
| profile: 'All rights reserved @ React', | profile: 'All rights reserved @ React', |
| import { SwitchNode } from './node/switch-node'; | import { SwitchNode } from './node/switch-node'; | ||||
| import { TemplateNode } from './node/template-node'; | import { TemplateNode } from './node/template-node'; | ||||
| const nodeTypes: NodeTypes = { | |||||
| export const nodeTypes: NodeTypes = { | |||||
| ragNode: RagNode, | ragNode: RagNode, | ||||
| categorizeNode: CategorizeNode, | categorizeNode: CategorizeNode, | ||||
| beginNode: BeginNode, | beginNode: BeginNode, | ||||
| iterationStartNode: IterationStartNode, | iterationStartNode: IterationStartNode, | ||||
| }; | }; | ||||
| const edgeTypes = { | |||||
| export const edgeTypes = { | |||||
| buttonEdge: ButtonEdge, | buttonEdge: ButtonEdge, | ||||
| }; | }; | ||||
| } from '../hooks/use-save-graph'; | } from '../hooks/use-save-graph'; | ||||
| import { BeginQuery } from '../interface'; | import { BeginQuery } from '../interface'; | ||||
| import { | |||||
| HistoryVersionModal, | |||||
| useHistoryVersionModal, | |||||
| } from '../history-version-modal'; | |||||
| import styles from './index.less'; | import styles from './index.less'; | ||||
| interface IProps { | interface IProps { | ||||
| const { showEmbedModal, hideEmbedModal, embedVisible, beta } = | const { showEmbedModal, hideEmbedModal, embedVisible, beta } = | ||||
| useShowEmbedModal(); | useShowEmbedModal(); | ||||
| const isBeginNodeDataQuerySafe = useGetBeginNodeDataQueryIsSafe(); | const isBeginNodeDataQuerySafe = useGetBeginNodeDataQueryIsSafe(); | ||||
| const { setVisibleHistoryVersionModal, visibleHistoryVersionModal } = | |||||
| useHistoryVersionModal(); | |||||
| const handleShowEmbedModal = useCallback(() => { | const handleShowEmbedModal = useCallback(() => { | ||||
| showEmbedModal(); | showEmbedModal(); | ||||
| }, [showEmbedModal]); | }, [showEmbedModal]); | ||||
| } | } | ||||
| }, [getBeginNodeDataQuery, handleRun, showChatDrawer]); | }, [getBeginNodeDataQuery, handleRun, showChatDrawer]); | ||||
| const showListVersion = useCallback(() => { | |||||
| setVisibleHistoryVersionModal(true); | |||||
| }, [setVisibleHistoryVersionModal]); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Flex | <Flex | ||||
| > | > | ||||
| <b>{t('embedIntoSite', { keyPrefix: 'common' })}</b> | <b>{t('embedIntoSite', { keyPrefix: 'common' })}</b> | ||||
| </Button> | </Button> | ||||
| <Button type="primary" onClick={showListVersion}> | |||||
| <b>{t('historyversion')}</b> | |||||
| </Button> | |||||
| </Space> | </Space> | ||||
| </Flex> | </Flex> | ||||
| {embedVisible && ( | {embedVisible && ( | ||||
| isAgent | isAgent | ||||
| ></EmbedModal> | ></EmbedModal> | ||||
| )} | )} | ||||
| {visibleHistoryVersionModal && ( | |||||
| <HistoryVersionModal | |||||
| id={id || ''} | |||||
| visible={visibleHistoryVersionModal} | |||||
| hideModal={() => setVisibleHistoryVersionModal(false)} | |||||
| ></HistoryVersionModal> | |||||
| )} | |||||
| </> | </> | ||||
| ); | ); | ||||
| }; | }; |
| 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> | |||||
| ); | |||||
| } |
| getCanvas, | getCanvas, | ||||
| getCanvasSSE, | getCanvasSSE, | ||||
| setCanvas, | setCanvas, | ||||
| getListVersion, | |||||
| getVersion, | |||||
| listCanvas, | listCanvas, | ||||
| resetCanvas, | resetCanvas, | ||||
| removeCanvas, | removeCanvas, | ||||
| url: setCanvas, | url: setCanvas, | ||||
| method: 'post', | method: 'post', | ||||
| }, | }, | ||||
| getListVersion: { | |||||
| url: getListVersion, | |||||
| method: 'get', | |||||
| }, | |||||
| getVersion: { | |||||
| url: getVersion, | |||||
| method: 'get', | |||||
| }, | |||||
| listCanvas: { | listCanvas: { | ||||
| url: listCanvas, | url: listCanvas, | ||||
| method: 'get', | method: 'get', | ||||
| }, | }, | ||||
| } as const; | } as const; | ||||
| const chatService = registerServer<keyof typeof methods>(methods, request); | |||||
| const flowService = registerServer<keyof typeof methods>(methods, request); | |||||
| export default chatService; | |||||
| export default flowService; |
| getCanvasSSE: `${api_host}/canvas/getsse`, | getCanvasSSE: `${api_host}/canvas/getsse`, | ||||
| removeCanvas: `${api_host}/canvas/rm`, | removeCanvas: `${api_host}/canvas/rm`, | ||||
| setCanvas: `${api_host}/canvas/set`, | setCanvas: `${api_host}/canvas/set`, | ||||
| getListVersion: `${api_host}/canvas/getlistversion`, | |||||
| getVersion: `${api_host}/canvas/getversion`, | |||||
| resetCanvas: `${api_host}/canvas/reset`, | resetCanvas: `${api_host}/canvas/reset`, | ||||
| runCanvas: `${api_host}/canvas/completion`, | runCanvas: `${api_host}/canvas/completion`, | ||||
| testDbConnect: `${api_host}/canvas/test_db_connect`, | testDbConnect: `${api_host}/canvas/test_db_connect`, |