From aaf900cf167eaab11f4d5ab8ef20a8a136f464a4 Mon Sep 17 00:00:00 2001 From: balibabu Date: Tue, 10 Mar 2026 14:25:27 +0800 Subject: [PATCH] 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 --- api/apps/canvas_app.py | 20 ++++++++++ api/db/db_models.py | 2 + api/db/services/canvas_service.py | 20 +++++++++- api/db/services/user_canvas_version.py | 39 +++++++++++++++---- web/src/components/home-card.tsx | 27 +++++++++++-- web/src/custom.d.ts | 2 + web/src/hooks/use-agent-request.ts | 2 +- web/src/interfaces/database/agent.ts | 3 ++ web/src/interfaces/database/flow.ts | 1 + web/src/locales/en.ts | 5 +++ web/src/locales/zh.ts | 7 +++- .../components/publish-confirm-dialog.tsx | 7 ++-- web/src/pages/agent/version-dialog/index.tsx | 29 ++++++++++++-- web/src/pages/agents/agent-card.tsx | 7 +++- 14 files changed, 148 insertions(+), 23 deletions(-) diff --git a/api/apps/canvas_app.py b/api/apps/canvas_app.py index 3c5c501fd..0c4abe059 100644 --- a/api/apps/canvas_app.py +++ b/api/apps/canvas_app.py @@ -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) diff --git a/api/db/db_models.py b/api/db/db_models.py index 6348a68a3..2e3824050 100644 --- a/api/db/db_models.py +++ b/api/db/db_models.py @@ -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) diff --git a/api/db/services/canvas_service.py b/api/db/services/canvas_service.py index 838951a9a..f6aa41c4a 100644 --- a/api/db/services/canvas_service.py +++ b/api/db/services/canvas_service.py @@ -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() diff --git a/api/db/services/user_canvas_version.py b/api/db/services/user_canvas_version.py index f8bd6dae0..d2861d576 100644 --- a/api/db/services/user_canvas_version.py +++ b/api/db/services/user_canvas_version.py @@ -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 diff --git a/web/src/components/home-card.tsx b/web/src/components/home-card.tsx index 7320960b9..9e57a355f 100644 --- a/web/src/components/home-card.tsx +++ b/web/src/components/home-card.tsx @@ -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 ( +

{formatDate(time)}

+ ); +} export function HomeCard({ data, onClick, @@ -24,6 +32,8 @@ export function HomeCard({ icon, testId, }: IProps) { + const { t } = useTranslation(); + return (
-

- {formatDate(data.update_time)} -

+ {data.release_time ? ( +
+
+ {`${t('flow.lastSavedAt')}:`} + +
+
+ {`${t('flow.publishedAt')}:`} + +
+
+ ) : ( + + )} {sharedBadge}
diff --git a/web/src/custom.d.ts b/web/src/custom.d.ts index dafdf09f1..9495d4f3c 100644 --- a/web/src/custom.d.ts +++ b/web/src/custom.d.ts @@ -1,3 +1,5 @@ +type Nullable = T | null; + declare module '*.md' { const content: string; export default content; diff --git a/web/src/hooks/use-agent-request.ts b/web/src/hooks/use-agent-request.ts index df577530e..5efd47975 100644 --- a/web/src/hooks/use-agent-request.ts +++ b/web/src/hooks/use-agent-request.ts @@ -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: [], diff --git a/web/src/interfaces/database/agent.ts b/web/src/interfaces/database/agent.ts index 3d23cc81c..c9a08b7f2 100644 --- a/web/src/interfaces/database/agent.ts +++ b/web/src/interfaces/database/agent.ts @@ -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 { diff --git a/web/src/interfaces/database/flow.ts b/web/src/interfaces/database/flow.ts index 96de7ad21..aa2666150 100644 --- a/web/src/interfaces/database/flow.ts +++ b/web/src/interfaces/database/flow.ts @@ -41,6 +41,7 @@ export declare interface IFlow { user_id: string; permission: string; nickname: string; + release?: boolean; } export interface IFlowTemplate { diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 26c253051..f561c41bf 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -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', diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 3a624d42e..072153834 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -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 文件', diff --git a/web/src/pages/agent/components/publish-confirm-dialog.tsx b/web/src/pages/agent/components/publish-confirm-dialog.tsx index a058bef9c..85fe29751 100644 --- a/web/src/pages/agent/components/publish-confirm-dialog.tsx +++ b/web/src/pages/agent/components/publish-confirm-dialog.tsx @@ -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]); diff --git a/web/src/pages/agent/version-dialog/index.tsx b/web/src/pages/agent/version-dialog/index.tsx index 6a4bdee9b..a1d8d32b9 100644 --- a/web/src/pages/agent/version-dialog/index.tsx +++ b/web/src/pages/agent/version-dialog/index.tsx @@ -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 ( + + ); +} + export function VersionDialog({ hideModal, }: IModalProps & { initialName?: string; title?: ReactNode }) { @@ -58,7 +65,7 @@ export function VersionDialog({ return ( - + {t('flow.historyVersion')} @@ -78,7 +85,10 @@ export function VersionDialog({ })} onClick={handleClick(x.id)} > - {x.title} +
+ {x.title} + {x.release && } +
))} @@ -92,11 +102,24 @@ export function VersionDialog({
-
{agent?.title}
+
+ {agent?.title} + {agent?.release && ( + + + + )} +

Created: {formatDate(agent?.create_date)}

+ diff --git a/web/src/pages/agents/agent-card.tsx b/web/src/pages/agents/agent-card.tsx index 678d574b0..a9ee76080 100644 --- a/web/src/pages/agents/agent-card.tsx +++ b/web/src/pages/agents/agent-card.tsx @@ -19,7 +19,12 @@ export function AgentCard({ data, showAgentRenameModal }: DatasetCardProps) { return (