Feat: Display release status in agent version history. (#13479)

### What problem does this PR solve?
Feat: Display release status in agent version history.

### Type of change


- [x] New Feature (non-breaking change which adds functionality)

---------

Co-authored-by: balibabu <assassin_cike@163.com>
This commit is contained in:
balibabu
2026-03-10 14:25:27 +08:00
committed by GitHub
parent 249b78561b
commit aaf900cf16
14 changed files with 148 additions and 23 deletions

View File

@ -99,6 +99,7 @@ async def save():
user_canvas_id=req["id"],
dsl=req["dsl"],
title=UserCanvasVersionService.build_version_title(getattr(current_user, "nickname", current_user.id), req.get("title")),
release=req.get("release"),
)
replica_ok = CanvasReplicaService.replace_for_set(
canvas_id=req["id"],
@ -133,6 +134,25 @@ def get(canvas_id):
)
except ValueError as e:
return get_data_error_result(message=str(e))
# Get the last publication time (latest released version's update_time)
last_publish_time = None
versions = UserCanvasVersionService.list_by_canvas_id(canvas_id)
if versions:
released_versions = [v for v in versions if v.release]
if released_versions:
# Sort by update_time descending and get the latest
released_versions.sort(key=lambda x: x.update_time, reverse=True)
last_publish_time = released_versions[0].update_time
# Add last_publish_time to response data
if isinstance(c, dict):
c["last_publish_time"] = last_publish_time
else:
# If c is a model object, convert to dict first
c = c.to_dict()
c["last_publish_time"] = last_publish_time
return get_json_result(data=c)

View File

@ -1075,6 +1075,7 @@ class UserCanvasVersion(DataBaseModel):
title = CharField(max_length=255, null=True, help_text="Canvas title")
description = TextField(null=True, help_text="Canvas description")
release = BooleanField(null=False, help_text="is released", default=False, index=True)
dsl = JSONField(null=True, default={})
class Meta:
@ -1538,6 +1539,7 @@ def migrate_db():
alter_db_add_column(migrator, "dialog", "tenant_rerank_id", IntegerField(null=True, help_text="id in tenant_llm", index=True))
alter_db_add_column(migrator, "memory", "tenant_embd_id", IntegerField(null=True, help_text="id in tenant_llm", index=True))
alter_db_add_column(migrator, "memory", "tenant_llm_id", IntegerField(null=True, help_text="id in tenant_llm", index=True))
alter_db_add_column(migrator, "user_canvas_version", "release", BooleanField(null=False, help_text="is released", default=False, index=True))
logging.disable(logging.NOTSET)
# this is after re-enabling logging to allow logging changed user emails
migrate_add_unique_email(migrator)

View File

@ -19,7 +19,7 @@ import time
from uuid import uuid4
from agent.canvas import Canvas
from api.db import CanvasCategory, TenantPermission
from api.db.db_models import DB, CanvasTemplate, User, UserCanvas, API4Conversation
from api.db.db_models import DB, CanvasTemplate, User, UserCanvas, API4Conversation, UserCanvasVersion
from api.db.services.api_service import API4ConversationService
from api.db.services.common_service import CommonService
from common.misc_utils import get_uuid
@ -173,7 +173,23 @@ class UserCanvasService(CommonService):
count = agents.count()
if page_number and items_per_page:
agents = agents.paginate(page_number, items_per_page)
return list(agents.dicts()), count
agents_list = list(agents.dicts())
# Get latest release time for each canvas
if agents_list:
canvas_ids = [a['id'] for a in agents_list]
release_times = (
UserCanvasVersion.select(UserCanvasVersion.user_canvas_id, fn.MAX(UserCanvasVersion.create_time).alias("release_time"))
.where((UserCanvasVersion.user_canvas_id.in_(canvas_ids)) & (UserCanvasVersion.release))
.group_by(UserCanvasVersion.user_canvas_id)
)
release_time_map = {r.user_canvas_id: r.release_time for r in release_times}
for agent in agents_list:
agent['release_time'] = release_time_map.get(agent['id'])
return agents_list, count
@classmethod
@DB.connection_context()

View File

@ -45,7 +45,8 @@ class UserCanvasVersionService(CommonService):
cls.model.create_date,
cls.model.update_date,
cls.model.user_canvas_id,
cls.model.update_time]
cls.model.update_time,
cls.model.release]
).where(cls.model.user_canvas_id == user_canvas_id)
return user_canvas_version
except DoesNotExist:
@ -74,14 +75,14 @@ class UserCanvasVersionService(CommonService):
@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:
delete_ids = []
for i in range(20, user_canvas_version.count()):
delete_ids.append(user_canvas_version[i].id)
# Only get unpublished versions (False or None), keep all released versions
unpublished = cls.model.select().where(cls.model.user_canvas_id == user_canvas_id, (~cls.model.release) | (cls.model.release.is_null(True))).order_by(cls.model.create_time.desc())
# Only delete old unpublished versions beyond the limit
if unpublished.count() > 20:
delete_ids = [v.id for v in unpublished[20:]]
cls.delete_by_ids(delete_ids)
return True
except DoesNotExist:
return None
@ -90,12 +91,15 @@ class UserCanvasVersionService(CommonService):
@classmethod
@DB.connection_context()
def save_or_replace_latest(cls, user_canvas_id, dsl, title=None, description=None):
def save_or_replace_latest(cls, user_canvas_id, dsl, title=None, description=None, release=None):
"""
Persist a canvas snapshot into version history.
If the latest version has the same DSL content, update that version in place
instead of creating a new row.
Exception: If the latest version is released (release=True) and current save is not,
create a new version to protect the released version.
"""
try:
normalized_dsl = cls._normalize_dsl(dsl)
@ -107,11 +111,28 @@ class UserCanvasVersionService(CommonService):
)
if latest and cls._normalize_dsl(latest.dsl) == normalized_dsl:
# Protect released version: if latest is released and current is not,
# create a new version instead of updating
if latest.release and not release:
insert_data = {"user_canvas_id": user_canvas_id, "dsl": normalized_dsl}
if title is not None:
insert_data["title"] = title
if description is not None:
insert_data["description"] = description
if release is not None:
insert_data["release"] = release
cls.insert(**insert_data)
cls.delete_all_versions(user_canvas_id)
return None, True
# Normal case: update existing version
update_data = {"dsl": normalized_dsl}
if title is not None:
update_data["title"] = title
if description is not None:
update_data["description"] = description
if release is not None:
update_data["release"] = release
cls.update_by_id(latest.id, update_data)
cls.delete_all_versions(user_canvas_id)
return latest.id, False
@ -121,6 +142,8 @@ class UserCanvasVersionService(CommonService):
insert_data["title"] = title
if description is not None:
insert_data["description"] = description
if release is not None:
insert_data["release"] = release
cls.insert(**insert_data)
cls.delete_all_versions(user_canvas_id)
return None, True

View File

@ -2,6 +2,7 @@ import { RAGFlowAvatar } from '@/components/ragflow-avatar';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { formatDate } from '@/utils/date';
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
interface IProps {
data: {
@ -9,6 +10,7 @@ interface IProps {
description?: string;
avatar?: string;
update_time?: string | number;
release_time?: number;
};
onClick?: () => void;
moreDropdown: React.ReactNode;
@ -16,6 +18,12 @@ interface IProps {
icon?: React.ReactNode;
testId?: string;
}
function Time({ time }: { time: string | number | undefined }) {
return (
<p className="text-sm opacity-80 whitespace-nowrap">{formatDate(time)}</p>
);
}
export function HomeCard({
data,
onClick,
@ -24,6 +32,8 @@ export function HomeCard({
icon,
testId,
}: IProps) {
const { t } = useTranslation();
return (
<Card
as="article"
@ -72,9 +82,20 @@ export function HomeCard({
{data.description}
</div>
<div className="flex justify-between items-center">
<p className="text-sm opacity-80 whitespace-nowrap">
{formatDate(data.update_time)}
</p>
{data.release_time ? (
<section>
<div className="flex items-center gap-2 text-sm opacity-80">
{`${t('flow.lastSavedAt')}:`}
<Time time={data.update_time}></Time>
</div>
<div className="flex items-center gap-2 text-sm opacity-80">
{`${t('flow.publishedAt')}:`}
<Time time={data.release_time}></Time>
</div>
</section>
) : (
<Time time={data.update_time}></Time>
)}
{sharedBadge}
</div>
</section>

2
web/src/custom.d.ts vendored
View File

@ -1,3 +1,5 @@
type Nullable<T> = T | null;
declare module '*.md' {
const content: string;
export default content;

View File

@ -529,7 +529,7 @@ export const useFetchInputForm = (componentId?: string) => {
export const useFetchVersionList = () => {
const { id } = useParams();
const { data, isFetching: loading } = useQuery<
Array<{ created_at: string; title: string; id: string }>
Array<{ created_at: string; title: string; id: string; release?: boolean }>
>({
queryKey: [AgentApiAction.FetchVersionList],
initialData: [],

View File

@ -77,6 +77,9 @@ export declare interface IFlow {
nickname: string;
operator_permission: number;
canvas_category: string;
release?: boolean;
release_time?: number;
last_publish_time?: number;
}
export interface IFlowTemplate {

View File

@ -41,6 +41,7 @@ export declare interface IFlow {
user_id: string;
permission: string;
nickname: string;
release?: boolean;
}
export interface IFlowTemplate {

View File

@ -1500,6 +1500,7 @@ Example: Virtual Hosted Style`,
other: 'Other',
ingestionPipeline: 'Ingestion pipeline',
agents: 'Agents',
publishedAt: 'Published at',
days: 'Days',
beginInput: 'Begin input',
ref: 'Variable',
@ -1581,6 +1582,7 @@ Example: Virtual Hosted Style`,
citeTip: 'citeTip',
name: 'Name',
nameMessage: 'Please input name',
lastSavedAt: 'Last saved at',
description: 'Description',
descriptionMessage: 'This is an agent for a specific task.',
examples: 'Examples',
@ -2185,6 +2187,9 @@ This process aggregates variables from multiple branches into a single variable
'Write your SQL query here. You can use variables, raw SQL, or mix both using variable syntax.',
frameworkPrompts: 'Framework',
release: 'Publish',
production: 'Production',
productionTooltip:
'This version is published to production. Access it via the API or the embedded page.',
confirmPublish: 'Confirm Publish',
publishDescription: 'You are about to publish this data pipeline.',
linkedDataset: 'Linked dataset',

View File

@ -919,8 +919,7 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
githubDescription:
'连接 GitHub可同步 Pull Request 与 Issue 内容用于检索。',
airtableDescription: '连接 Airtable同步指定工作区下指定表格中的文件。',
dingtalkAITableDescription:
'连接钉钉AI表格同步指定表格中的记录。',
dingtalkAITableDescription: '连接钉钉AI表格同步指定表格中的记录。',
gitlabDescription:
'连接 GitLab同步仓库、Issue、合并请求MR及相关文档内容。',
asanaDescription: '连接 Asana同步工作区中的文件。',
@ -1250,6 +1249,7 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
consumerApp: '消费者应用',
other: '其他',
agents: '智能体',
publishedAt: '发布于',
beginInput: '开始输入',
seconds: '秒',
ref: '引用变量',
@ -1345,6 +1345,7 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
cite: '引用',
citeTip: '引用',
nameMessage: '请输入名称',
lastSavedAt: '上次保存于',
description: '描述',
examples: '示例',
to: '下一步',
@ -1888,6 +1889,8 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
'在此处编写您的 SQL 查询。您可以使用变量、原始 SQL或使用变量语法混合使用两者。',
frameworkPrompts: '框架',
release: '发布',
production: '正式版',
productionTooltip: '此版本已发布到生产环境。可通过 API 或嵌入页面访问。',
createFromBlank: '从空白创建',
createFromTemplate: '从模板创建',
importJsonFile: '导入 JSON 文件',

View File

@ -8,6 +8,7 @@ import {
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { IFlow } from '@/interfaces/database/agent';
import { Operator } from '@/pages/agent/constant';
import useGraphStore from '@/pages/agent/store';
import { formatDate } from '@/utils/date';
@ -16,7 +17,7 @@ import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
interface PublishConfirmDialogProps {
agentDetail: { title: string; update_time?: number };
agentDetail: IFlow;
loading: boolean;
onPublish: () => void;
}
@ -42,8 +43,8 @@ export function PublishConfirmDialog({
}, [nodes]);
const lastPublished = useMemo(() => {
if (agentDetail?.update_time) {
return formatDate(agentDetail.update_time);
if (agentDetail?.last_publish_time) {
return formatDate(agentDetail.last_publish_time);
}
return '-';
}, [agentDetail?.update_time]);

View File

@ -10,6 +10,7 @@ import {
} from '@/components/ui/dialog';
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
import { Spin } from '@/components/ui/spin';
import { RAGFlowTooltip } from '@/components/ui/tooltip';
import { useClientPagination } from '@/hooks/logic-hooks/use-pagination';
import {
useFetchVersion,
@ -25,6 +26,12 @@ import { ReactNode, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { nodeTypes } from '../canvas';
function Dot() {
return (
<span className="w-2 h-2 inline-block rounded-full bg-accent-primary flex-shrink-0" />
);
}
export function VersionDialog({
hideModal,
}: IModalProps<any> & { initialName?: string; title?: ReactNode }) {
@ -58,7 +65,7 @@ export function VersionDialog({
return (
<Dialog open onOpenChange={hideModal}>
<DialogContent className="max-w-[60vw]">
<DialogContent className="max-w-[900px]">
<DialogHeader>
<DialogTitle className="text-base">
{t('flow.historyVersion')}
@ -78,7 +85,10 @@ export function VersionDialog({
})}
onClick={handleClick(x.id)}
>
{x.title}
<div className="flex items-center gap-2">
<span className="truncate">{x.title}</span>
{x.release && <Dot></Dot>}
</div>
</li>
))}
</ul>
@ -92,11 +102,24 @@ export function VersionDialog({
<CardContent className="h-full p-5 flex flex-col">
<section className="flex justify-between">
<div>
<div className="pb-1 truncate">{agent?.title}</div>
<div className="flex">
<span className="pb-1 truncate">{agent?.title}</span>
{agent?.release && (
<RAGFlowTooltip tooltip={t('flow.productionTooltip')}>
<Button className="bg-accent-primary-5 ml-3">
<Dot></Dot>
<span className="text-accent-primary pl-2 rounded">
{t('flow.production')}
</span>
</Button>
</RAGFlowTooltip>
)}
</div>
<p className="text-text-secondary text-xs">
Created: {formatDate(agent?.create_date)}
</p>
</div>
<Button variant={'ghost'} onClick={downloadFile}>
<ArrowDownToLine />
</Button>

View File

@ -19,7 +19,12 @@ export function AgentCard({ data, showAgentRenameModal }: DatasetCardProps) {
return (
<HomeCard
testId="agent-card"
data={{ ...data, name: data.title, description: data.description || '' }}
data={{
...data,
name: data.title,
description: data.description || '',
release_time: data.release_time,
}}
moreDropdown={
<AgentDropdown showAgentRenameModal={showAgentRenameModal} agent={data}>
<MoreButton></MoreButton>