Feat: support agent version history. (#6130)

### What problem does this PR solve?
Add history version save
- Allows users to view and download agent files by version revision
history

![image](https://github.com/user-attachments/assets/c300375d-8b97-4230-9fc4-83d148137132)

_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>
This commit is contained in:
so95 2025-03-19 14:22:53 +07:00 committed by GitHub
parent e689532e6e
commit 53ac27c3ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 362 additions and 7 deletions

View File

@ -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,9 +75,14 @@ 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
def get(canvas_id):
@ -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}")

View File

@ -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():

View File

@ -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

View File

@ -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}

View File

@ -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;

View File

@ -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',

View File

@ -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,
};

View File

@ -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>
)}
</>
);
};

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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`,