diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index fdc9aabc835d4c..67e849bf17e1a0 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -1,21 +1,25 @@ from importlib import import_module +from typing import TYPE_CHECKING from flask import Blueprint from flask_restx import Namespace from libs.external_api import ExternalApi +if TYPE_CHECKING: + from flask_restx import Api + +console_ns = Namespace("console", description="Console management API operations", path="/") + bp = Blueprint("console", __name__, url_prefix="/console/api") -api = ExternalApi( +api: "Api" = ExternalApi( bp, version="1.0", title="Console API", description="Console management APIs for app configuration, monitoring, and administration", ) -console_ns = Namespace("console", description="Console management API operations", path="/") - RESOURCE_MODULES = ( "controllers.console.app.app_import", "controllers.console.explore.audio", @@ -69,6 +73,9 @@ workflow_trigger, ) +# Register workflow tag routes +from .app.workflow_tag import WorkflowTagApi + # Import auth controllers from .auth import ( activate, @@ -133,6 +140,7 @@ workspace, ) +api.add_resource(WorkflowTagApi, "/apps//workflow-tags", "/apps//workflow-tags/") api.add_namespace(console_ns) __all__ = [ diff --git a/api/controllers/console/app/workflow_tag.py b/api/controllers/console/app/workflow_tag.py new file mode 100644 index 00000000000000..94223a785d9e4a --- /dev/null +++ b/api/controllers/console/app/workflow_tag.py @@ -0,0 +1,231 @@ +import logging +from dataclasses import dataclass +from typing import Any + +from flask_restx import Resource, marshal_with, reqparse +from flask_restx.inputs import int_range +from werkzeug.exceptions import BadRequest, Forbidden + +from controllers.console import api +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, setup_required +from extensions.ext_database import db +from fields.workflow_tag_fields import ( + workflow_tag_create_update_fields, + workflow_tag_fields, + workflow_tag_list_fields, +) +from libs.helper import tag_name, uuid_value +from libs.login import current_user, login_required +from models import App, AppMode +from models.account import Account +from services.workflow_service import WorkflowService +from services.workflow_tag_service import WorkflowTagArgs, WorkflowTagService + +logger = logging.getLogger(__name__) + + +@dataclass +class PaginatedResponse: + """Structured pagination response object.""" + + items: list[Any] + limit: int + offset: int + has_more: bool + + @property + def page(self) -> int: + """Calculate current page from offset and limit.""" + return (self.offset // self.limit) + 1 + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary format for API response.""" + return { + "items": self.items, + "page": self.page, + "limit": self.limit, + "has_more": self.has_more, + } + + +def _create_pagination_parser() -> reqparse.RequestParser: + """Create a parser with validated pagination parameters.""" + parser = reqparse.RequestParser() + parser.add_argument( + "workflow_ids", type=str, required=False, location="args", help="Comma-separated list of workflow IDs" + ) + parser.add_argument( + "limit", + type=int_range(1, 1000), + required=False, + default=100, + location="args", + help="Number of items to return (1-1000)", + ) + parser.add_argument( + "offset", + type=int_range(0, 1000000), + required=False, + default=0, + location="args", + help="Number of items to skip (0-1000000)", + ) + return parser + + +class WorkflowTagApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @marshal_with(workflow_tag_list_fields) + @api.doc("get_workflow_tags") + @api.doc(description="Get workflow tags for an app") + @api.doc(params={"app_id": "App ID"}) + @api.doc( + responses={ + 200: "Workflow tags retrieved successfully", + 401: "Unauthorized - user not logged in", + 403: "Forbidden - user is not an editor", + 404: "App not found", + } + ) + def get(self, app_model: App): + assert isinstance(current_user, Account) + if not current_user.has_edit_permission: + raise Forbidden() + + # Use validated pagination parser + parser = _create_pagination_parser() + args = parser.parse_args() + + workflow_ids = args.get("workflow_ids") + if workflow_ids: + workflow_ids = [wid.strip() for wid in workflow_ids.split(",") if wid.strip()] + + limit = args["limit"] + offset = args["offset"] + + workflow_tag_service = WorkflowTagService(session=db.session) + + tags = workflow_tag_service.get_tags_by_app( + app_id=app_model.id, + workflow_ids=workflow_ids, + limit=limit, + offset=offset, + ) + + return PaginatedResponse( + items=list(tags or []), + limit=limit, + offset=offset, + has_more=len(tags or []) == limit, + ).to_dict() + + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @marshal_with(workflow_tag_fields) + @api.doc("create_or_update_workflow_tag") + @api.doc(description="Create or update a workflow tag") + @api.doc(params={"app_id": "App ID"}) + @api.expect(workflow_tag_create_update_fields, validate=True) + @api.doc( + responses={ + 200: "Workflow tag created or updated successfully", + 400: "Bad request - invalid parameters", + 401: "Unauthorized - user not logged in", + 403: "Forbidden - user is not an editor", + 404: "App not found", + } + ) + def post(self, app_model: App): + assert isinstance(current_user, Account) + if not current_user.has_edit_permission: + raise Forbidden() + + parser = reqparse.RequestParser() + parser.add_argument("workflow_id", type=uuid_value, required=True, location="json", help="Workflow ID") + parser.add_argument("name", type=tag_name, required=True, location="json", help="Tag name") + + args = parser.parse_args() + + workflow_id = args.get("workflow_id") + name = args.get("name") + + workflow_service = WorkflowService() + workflow_tag_service = WorkflowTagService(session=db.session) + try: + request = WorkflowTagArgs( + app_id=app_model.id, + workflow_id=workflow_id.strip() if workflow_id else "", + name=name.strip() if name else "", + created_by=current_user.id, + ) + + # Check if tag already exists to determine create vs update + existing_tag = workflow_service.get_workflow_by_tag( + session=db.session, + app_id=app_model.id, + name=request.name, + ) + + if existing_tag: + # Update existing tag + tag = workflow_tag_service.update_tag( + request=request, + ) + else: + # Create new tag + tag = workflow_tag_service.create_tag( + request=request, + ) + + db.session.commit() + + return tag + + except ValueError as e: + raise BadRequest(str(e)) + except Exception as e: + logger.exception("Error creating/updating workflow tag") + raise BadRequest("Failed to create or update workflow tag") + + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @api.doc("delete_workflow_tag") + @api.doc(description="Delete a workflow tag") + @api.doc(params={"app_id": "App ID", "tag_id": "Tag ID"}) + @api.doc( + responses={ + 200: "Workflow tag deleted successfully", + 401: "Unauthorized - user not logged in", + 403: "Forbidden - user is not an editor", + 404: "App or tag not found", + } + ) + def delete(self, app_model: App, tag_id: str): + assert isinstance(current_user, Account) + if not current_user.has_edit_permission: + raise Forbidden() + + workflow_tag_service = WorkflowTagService(session=db.session) + + try: + workflow_tag_service.delete_tag( + tag_id=tag_id, + app_id=app_model.id, + ) + db.session.commit() + + return {"message": "Workflow tag deleted successfully"} + + except ValueError as e: + raise BadRequest(str(e)) + except Exception as e: + logger.exception("Error deleting workflow tag") + raise BadRequest("Failed to delete workflow tag") diff --git a/api/controllers/service_api/app/completion.py b/api/controllers/service_api/app/completion.py index b3836f3a478279..5bf3e21bcb8f3a 100644 --- a/api/controllers/service_api/app/completion.py +++ b/api/controllers/service_api/app/completion.py @@ -3,8 +3,9 @@ from uuid import UUID from flask import request -from flask_restx import Resource +from flask_restx import Resource, reqparse from pydantic import BaseModel, Field, field_validator +from sqlalchemy.orm import Session from werkzeug.exceptions import BadRequest, InternalServerError, NotFound import services @@ -29,12 +30,15 @@ ) from core.helper.trace_id_helper import get_external_trace_id from core.model_runtime.errors.invoke import InvokeError +from extensions.ext_database import db from libs import helper +from libs.helper import uuid_value from models.model import App, AppMode, EndUser from services.app_generate_service import AppGenerateService from services.app_task_service import AppTaskService from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError +from services.workflow_service import WorkflowService logger = logging.getLogger(__name__) @@ -47,6 +51,36 @@ class CompletionRequestPayload(BaseModel): retriever_from: str = Field(default="dev") +# Define parser for chat API +chat_parser = ( + reqparse.RequestParser() + .add_argument("inputs", type=dict, required=True, location="json", help="Input parameters for chat") + .add_argument("query", type=str, required=True, location="json", help="The chat query") + .add_argument("files", type=list, required=False, location="json", help="List of file attachments") + .add_argument("response_mode", type=str, choices=["blocking", "streaming"], location="json", help="Response mode") + .add_argument("conversation_id", type=uuid_value, location="json", help="Existing conversation ID") + .add_argument("retriever_from", type=str, required=False, default="dev", location="json", help="Retriever source") + .add_argument( + "auto_generate_name", + type=bool, + required=False, + default=True, + location="json", + help="Auto generate conversation name", + ) + .add_argument("workflow_id", type=str, required=False, location="json", help="Workflow ID for advanced chat") +) +chat_parser.add_argument("workflow_id", type=str, required=False, location="json", help="Workflow ID for advanced chat") +chat_parser.add_argument( + "workflow_tag", + type=str, + required=False, + default="", + location="json", + help="Workflow tag for advanced chat", +) + + class ChatRequestPayload(BaseModel): inputs: dict[str, Any] query: str @@ -56,6 +90,7 @@ class ChatRequestPayload(BaseModel): retriever_from: str = Field(default="dev") auto_generate_name: bool = Field(default=True, description="Auto generate conversation name") workflow_id: str | None = Field(default=None, description="Workflow ID for advanced chat") + workflow_tag: str | None = Field(default=None, description="Workflow tag for advanced chat") @field_validator("conversation_id", mode="before") @classmethod @@ -197,15 +232,25 @@ def post(self, app_model: App, end_user: EndUser): raise NotChatAppError() payload = ChatRequestPayload.model_validate(service_api_ns.payload or {}) + args = payload.model_dump(exclude_none=True) external_trace_id = get_external_trace_id(request) - args = payload.model_dump(exclude_none=True) if external_trace_id: args["external_trace_id"] = external_trace_id streaming = payload.response_mode == "streaming" try: + # Handle workflow_tag or workflow_id + workflow_tag = args.get("workflow_tag") + workflow_id = args.get("workflow_id") + + if workflow_tag: + resolved_workflow_id = self._resolve_workflow_identifier(app_model=app_model, identifier=workflow_tag) + args["workflow_id"] = resolved_workflow_id + elif workflow_id: + resolved_workflow_id = self._resolve_workflow_identifier(app_model=app_model, identifier=workflow_id) + args["workflow_id"] = resolved_workflow_id response = AppGenerateService.generate( app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=streaming ) @@ -240,6 +285,56 @@ def post(self, app_model: App, end_user: EndUser): logger.exception("internal server error.") raise InternalServerError() + def _resolve_workflow_identifier(self, app_model: App, identifier: str) -> str: + """ + Resolve identifier to workflow_id and verify it exists + Priority: UUID format > tag + + Args: + app_model: The app model + identifier: workflow_id (UUID) or workflow_tag + + Returns: + The resolved workflow_id + + Raises: + WorkflowIdFormatError: If identifier is invalid or workflow doesn't exist + """ + import uuid + + from models.workflow import Workflow + + workflow_service = WorkflowService() + + # First try to parse as UUID + try: + uuid.UUID(identifier) + # UUID format is valid, verify the workflow exists + with Session(db.engine) as session: + workflow = session.get(Workflow, identifier) + if workflow and str(workflow.app_id) == str(app_model.id): + return identifier + # UUID format but workflow doesn't exist or belongs to different app + raise WorkflowIdFormatError(f"Workflow with ID '{identifier}' not found.") + except WorkflowIdFormatError: + raise + except (ValueError, TypeError): + pass + + # Then try to find by tag + with Session(db.engine) as session: + workflow = workflow_service.get_workflow_by_tag( + session=session, + app_id=app_model.id, + name=identifier, + ) + + if workflow: + return workflow.id + + # Neither valid UUID with existing workflow nor valid tag + raise WorkflowIdFormatError(f"Invalid identifier '{identifier}'. Must be a valid workflow tag or UUID format.") + @service_api_ns.route("/chat-messages//stop") class ChatStopApi(Resource): diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index 6a549fc9267c39..daf8576fb1faa1 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -1,4 +1,5 @@ import logging +import uuid from typing import Any, Literal from dateutil.parser import isoparse @@ -35,11 +36,13 @@ from libs import helper from libs.helper import TimestampField from models.model import App, AppMode, EndUser +from models.workflow import Workflow from repositories.factory import DifyAPIRepositoryFactory from services.app_generate_service import AppGenerateService from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError from services.workflow_app_service import WorkflowAppService +from services.workflow_service import WorkflowService logger = logging.getLogger(__name__) @@ -174,12 +177,12 @@ def post(self, app_model: App, end_user: EndUser): raise InternalServerError() -@service_api_ns.route("/workflows//run") -class WorkflowRunByIdApi(Resource): +@service_api_ns.route("/workflows//run") +class WorkflowRunByIdentifierApi(Resource): @service_api_ns.expect(service_api_ns.models[WorkflowRunPayload.__name__]) - @service_api_ns.doc("run_workflow_by_id") - @service_api_ns.doc(description="Execute a specific workflow by ID") - @service_api_ns.doc(params={"workflow_id": "Workflow ID to execute"}) + @service_api_ns.doc("run_workflow_by_identifier") + @service_api_ns.doc(description="Execute a specific workflow by ID or tag") + @service_api_ns.doc(params={"identifier": "Workflow ID or tag to execute"}) @service_api_ns.doc( responses={ 200: "Workflow executed successfully", @@ -191,10 +194,10 @@ class WorkflowRunByIdApi(Resource): } ) @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) - def post(self, app_model: App, end_user: EndUser, workflow_id: str): - """Run specific workflow by ID. + def post(self, app_model: App, end_user: EndUser, identifier: str): + """Run specific workflow by ID or tag name. - Executes a specific workflow version identified by its ID. + Executes a specific workflow version identified by its ID or tag name. """ app_mode = AppMode.value_of(app_model.mode) if app_mode != AppMode.WORKFLOW: @@ -203,15 +206,15 @@ def post(self, app_model: App, end_user: EndUser, workflow_id: str): payload = WorkflowRunPayload.model_validate(service_api_ns.payload or {}) args = payload.model_dump(exclude_none=True) - # Add workflow_id to args for AppGenerateService - args["workflow_id"] = workflow_id - external_trace_id = get_external_trace_id(request) if external_trace_id: args["external_trace_id"] = external_trace_id streaming = payload.response_mode == "streaming" try: + workflow_id = self._resolve_workflow_id(app_model, identifier) + # Add workflow_id to args for AppGenerateService + args["workflow_id"] = workflow_id response = AppGenerateService.generate( app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.SERVICE_API, streaming=streaming ) @@ -239,6 +242,29 @@ def post(self, app_model: App, end_user: EndUser, workflow_id: str): logger.exception("internal server error.") raise InternalServerError() + def _resolve_workflow_id(self, app_model: App, identifier: str) -> str: + """ + Resolve identifier to workflow_id + Priority: workflow_id > tag + """ + try: + uuid.UUID(identifier) + return identifier + except (ValueError, TypeError): + with Session(db.engine) as session, session.begin(): + workflow = self._get_workflow_by_tag(session, app_model, identifier) + if workflow: + return workflow.id + + raise WorkflowIdFormatError( + f"Invalid identifier '{identifier}'. Must be a valid workflow tag or UUID format." + ) + + def _get_workflow_by_tag(self, session: Session, app_model: App, tag_name: str) -> Workflow | None: + """Get workflow by tag name""" + workflow_service = WorkflowService() + return workflow_service.get_workflow_by_tag(session=session, app_id=app_model.id, name=tag_name) + @service_api_ns.route("/workflows/tasks//stop") class WorkflowTaskStopApi(Resource): diff --git a/api/fields/workflow_tag_fields.py b/api/fields/workflow_tag_fields.py new file mode 100644 index 00000000000000..d1528850284215 --- /dev/null +++ b/api/fields/workflow_tag_fields.py @@ -0,0 +1,28 @@ +from flask_restx import fields + +from fields.member_fields import account_fields +from libs.helper import TimestampField + +workflow_tag_fields = { + "id": fields.String, + "app_id": fields.String, + "workflow_id": fields.String, + "name": fields.String, + "created_by": fields.Nested(account_fields, attribute="created_by_account"), + "created_at": TimestampField, + "updated_at": TimestampField, + "is_transferred": fields.Boolean(attribute="_is_transferred", default=False), + "old_workflow_id": fields.String(attribute="_old_workflow_id", default=None), +} + +workflow_tag_list_fields = { + "items": fields.List(fields.Nested(workflow_tag_fields)), + "page": fields.Integer, + "limit": fields.Integer, + "has_more": fields.Boolean, +} + +workflow_tag_create_update_fields = { + "workflow_id": fields.String, + "name": fields.String, +} diff --git a/api/libs/helper.py b/api/libs/helper.py index 07c48237275d48..4b912f188a14c3 100644 --- a/api/libs/helper.py +++ b/api/libs/helper.py @@ -173,6 +173,20 @@ def alphanumeric(value: str): raise ValueError(f"{value} is not a valid alphanumeric value") +def tag_name(value: str): + """Validate tag name according to business rules.""" + if not value or not value.strip(): + raise ValueError("Tag name is required") + + if len(value) > 100: + raise ValueError("Tag name cannot exceed 100 characters") + + if not re.match(r"^[a-zA-Z0-9_.-]+$", value): + raise ValueError("Tag name can only contain letters, numbers, hyphens, underscores, and dots") + + return value + + def timestamp_value(timestamp): try: int_timestamp = int(timestamp) diff --git a/api/migrations/versions/2026_01_29_0908-398394623b7b_add_workflow_tag_management.py b/api/migrations/versions/2026_01_29_0908-398394623b7b_add_workflow_tag_management.py new file mode 100644 index 00000000000000..ec830e1bf71e52 --- /dev/null +++ b/api/migrations/versions/2026_01_29_0908-398394623b7b_add_workflow_tag_management.py @@ -0,0 +1,50 @@ +"""add_workflow_tag_management + +Revision ID: 398394623b7b +Revises: 9d77545f524e +Create Date: 2025-11-24 09:08:07.344942 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = '398394623b7b' +down_revision = '9d77545f524e' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + # Create workflow_name_tags table + op.create_table('workflow_name_tags', + sa.Column('id', postgresql.UUID(), server_default=sa.text('uuidv7()'), nullable=False), + sa.Column('app_id', postgresql.UUID(), nullable=False), + sa.Column('workflow_id', postgresql.UUID(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('created_by', postgresql.UUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + + # Add unique constraint for app_id and name + op.create_unique_constraint('unique_workflow_tag_app_name', 'workflow_name_tags', ['app_id', 'name']) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + # Drop unique constraint + op.drop_constraint('unique_workflow_tag_app_name', 'workflow_name_tags', type_='unique') + + # Drop workflow_name_tags table + op.drop_table('workflow_name_tags') + + # ### end Alembic commands ### diff --git a/api/models/__init__.py b/api/models/__init__.py index 74b33130ef80c0..09ba9717933465 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -111,6 +111,7 @@ WorkflowRun, WorkflowType, ) +from .workflow_tag import WorkflowNameTag __all__ = [ "APIBasedExtension", @@ -205,6 +206,7 @@ "WorkflowAppLog", "WorkflowAppLogCreatedFrom", "WorkflowArchiveLog", + "WorkflowNameTag", "WorkflowNodeExecutionModel", "WorkflowNodeExecutionOffload", "WorkflowNodeExecutionTriggeredFrom", diff --git a/api/models/workflow_tag.py b/api/models/workflow_tag.py new file mode 100644 index 00000000000000..053e3fb4215a4e --- /dev/null +++ b/api/models/workflow_tag.py @@ -0,0 +1,64 @@ +from datetime import datetime + +import sqlalchemy as sa +from sqlalchemy import func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .account import Account +from .base import Base +from .types import StringUUID + + +class WorkflowNameTag(Base): + """ + Workflow Tag for managing version tags across different environments. + + This table allows users to assign human-readable tags to workflow versions, + making it easier to manage deployments across different environments. + + Attributes: + - id (uuid): Tag ID, primary key + - app_id (uuid): App ID + - workflow_id (uuid): Workflow version ID + - name (string): Tag name (e.g., 'production', 'staging', 'v1.0') + + - created_by (uuid): Creator ID + - created_at (timestamp): Creation time + - updated_at (timestamp): Last update time + """ + + __tablename__ = "workflow_name_tags" + __allow_unmapped__ = True # Allow non-mapped attributes + __slots__ = ("is_transferred", "old_workflow_id") + __table_args__ = ( + # Ensure tag name is unique within an app + sa.UniqueConstraint("app_id", "name", name="unique_workflow_tag_app_name"), + ) + + id: Mapped[str] = mapped_column(StringUUID, primary_key=True, server_default=sa.text("uuidv7()")) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + workflow_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + name: Mapped[str] = mapped_column(sa.String(255), nullable=False) + + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp()) + updated_at: Mapped[datetime] = mapped_column( + sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + ) + + # Relationship to Account without foreign key constraint + created_by_account: Mapped[Account] = relationship( + Account, + primaryjoin=lambda: Account.id == WorkflowNameTag.created_by, + foreign_keys=[created_by], + lazy="select", + viewonly=True, + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.is_transferred = False + self.old_workflow_id: str | None = None + + def __repr__(self): + return f"" diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 6404136994966e..4649cc85b29d82 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -2,10 +2,10 @@ import time import uuid from collections.abc import Callable, Generator, Mapping, Sequence -from typing import Any, cast +from typing import Any, Union, cast from sqlalchemy import exists, select -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import Session, scoped_session, sessionmaker from configs import dify_config from core.app.app_config.entities import VariableEntityType @@ -37,6 +37,7 @@ from models.model import App, AppMode from models.tools import WorkflowToolProvider from models.workflow import Workflow, WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom, WorkflowType +from models.workflow_tag import WorkflowNameTag from repositories.factory import DifyAPIRepositoryFactory from services.billing_service import BillingService from services.enterprise.plugin_manager_service import PluginCredentialType @@ -1035,6 +1036,24 @@ def delete_workflow(self, *, session: Session, workflow_id: str, tenant_id: str) session.delete(workflow) return True + def get_workflow_by_tag( + self, + session: Union[Session, "scoped_session"], + app_id: str, + name: str, + ) -> Workflow | None: + tag = session.scalar( + select(WorkflowNameTag).where( + WorkflowNameTag.app_id == app_id, + WorkflowNameTag.name == name, + ) + ) + + if not tag: + return None + + return session.get(Workflow, tag.workflow_id) + def _setup_variable_pool( query: str, diff --git a/api/services/workflow_tag_service.py b/api/services/workflow_tag_service.py new file mode 100644 index 00000000000000..877e97eaf4ccd0 --- /dev/null +++ b/api/services/workflow_tag_service.py @@ -0,0 +1,164 @@ +import logging +from collections.abc import Sequence +from typing import TYPE_CHECKING, Union + +from pydantic import BaseModel, Field +from sqlalchemy import and_, func, select +from sqlalchemy.orm import Session + +from libs.uuid_utils import uuidv7 + +if TYPE_CHECKING: + from sqlalchemy.orm import scoped_session + +from models import Workflow, WorkflowNameTag + + +class WorkflowTagArgs(BaseModel): + app_id: str = Field(..., description="App ID") + workflow_id: str = Field(..., description="Workflow ID") + name: str = Field(..., description="Tag name", max_length=100) + created_by: str | None = Field(default=None, description="User ID who created the tag") + + +logger = logging.getLogger(__name__) + + +class WorkflowTagService: + def __init__(self, session: Union[Session, "scoped_session"] | None = None): + """ + Initialize WorkflowTagService with optional database session. + + Args: + session: Database session. If provided, will be used for all operations. + If None, session must be passed to each method call. + """ + self._session = session + + def create_tag( + self, + request: WorkflowTagArgs, + session: Union[Session, "scoped_session"] | None = None, + ) -> WorkflowNameTag: + """ + Create a new workflow tag. Raises an error if tag already exists. + """ + # Use instance session if provided, otherwise use method parameter + db_session = self._session or session + if not db_session: + raise ValueError("Database session is required") + + workflow = db_session.get(Workflow, request.workflow_id) + if not workflow: + raise ValueError(f"Workflow {request.workflow_id} not found") + + if workflow.version == Workflow.VERSION_DRAFT: + raise ValueError("Cannot create tags for draft workflows") + + # Check if tag already exists + existing_tag = db_session.scalar( + select(WorkflowNameTag).where( + and_(WorkflowNameTag.app_id == request.app_id, WorkflowNameTag.name == request.name) + ) + ) + + if existing_tag: + raise ValueError(f"Tag '{request.name}' already exists for app {request.app_id}") + + tag = WorkflowNameTag( + id=str(uuidv7()), + app_id=request.app_id, + workflow_id=request.workflow_id, + name=request.name, + created_by=request.created_by, + ) + + db_session.add(tag) + return tag + + def update_tag( + self, + request: WorkflowTagArgs, + session: Union[Session, "scoped_session"] | None = None, + ) -> WorkflowNameTag: + """ + Update an existing workflow tag. Raises an error if tag doesn't exist. + """ + # Use instance session if provided, otherwise use method parameter + db_session = self._session or session + if not db_session: + raise ValueError("Database session is required") + + workflow = db_session.get(Workflow, request.workflow_id) + if not workflow: + raise ValueError(f"Workflow {request.workflow_id} not found") + + if workflow.version == Workflow.VERSION_DRAFT: + raise ValueError("Cannot update tags for draft workflows") + + # Find existing tag to update + existing_tag = db_session.scalar( + select(WorkflowNameTag).where( + and_(WorkflowNameTag.app_id == request.app_id, WorkflowNameTag.name == request.name) + ) + ) + + if not existing_tag: + raise ValueError(f"Tag '{request.name}' not found for app {request.app_id}") + + old_workflow_id = existing_tag.workflow_id + existing_tag.workflow_id = request.workflow_id + existing_tag.updated_at = func.current_timestamp() + + # Mark as transferred if workflow ID changed + if old_workflow_id != request.workflow_id: + existing_tag.is_transferred = True + existing_tag.old_workflow_id = old_workflow_id + + return existing_tag + + def get_tags_by_app( + self, + app_id: str, + workflow_ids: list[str] | None = None, + limit: int = 100, + offset: int = 0, + session: Union[Session, "scoped_session"] | None = None, + ) -> Sequence[WorkflowNameTag]: + # Use instance session if provided, otherwise use method parameter + db_session = self._session or session + if not db_session: + raise ValueError("Database session is required") + + conditions = [WorkflowNameTag.app_id == app_id] + + if workflow_ids: + conditions.append(WorkflowNameTag.workflow_id.in_(workflow_ids)) + + stmt = ( + select(WorkflowNameTag) + .where(and_(*conditions)) + .order_by(WorkflowNameTag.created_at.desc()) + .limit(limit) + .offset(offset) + ) + + return list(db_session.scalars(stmt)) + + def delete_tag( + self, + tag_id: str, + app_id: str, + session: Union[Session, "scoped_session"] | None = None, + ) -> bool: + # Use instance session if provided, otherwise use method parameter + db_session = self._session or session + if not db_session: + raise ValueError("Database session is required") + + tag = db_session.get(WorkflowNameTag, tag_id) + if not tag or tag.app_id != app_id: + raise ValueError("Tag not found") + + db_session.delete(tag) + return True diff --git a/web/app/components/app/overview/app-card.tsx b/web/app/components/app/overview/app-card.tsx index 9975c81b3ef927..0f1656d0302683 100644 --- a/web/app/components/app/overview/app-card.tsx +++ b/web/app/components/app/overview/app-card.tsx @@ -180,7 +180,7 @@ function AppCard({ }, [appDetail]) const handleAccessControlUpdate = useCallback(async () => { try { - const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail!.id }) + const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail?.id || '' }) setAppDetail(res) setShowAccessControl(false) } diff --git a/web/app/components/develop/template/template_advanced_chat.en.mdx b/web/app/components/develop/template/template_advanced_chat.en.mdx index 42dd57957bbf15..c7690d89803423 100644 --- a/web/app/components/develop/template/template_advanced_chat.en.mdx +++ b/web/app/components/develop/template/template_advanced_chat.en.mdx @@ -77,6 +77,9 @@ Chat applications support session persistence, allowing previous chat history to (Optional) Workflow ID to specify a specific version, if not provided, uses the default published version. + + (Optional) Workflow tag to specify a specific version of workflow. System searches in the following priority order: workflow_tag > workflow_id > latest published version. + (Optional) Trace ID. Used for integration with existing business trace components to achieve end-to-end distributed tracing. If not provided, the system will automatically generate a trace_id. Supports the following three ways to pass, in order of priority:
- Header: via HTTP Header X-Trace-Id, highest priority.
diff --git a/web/app/components/develop/template/template_advanced_chat.ja.mdx b/web/app/components/develop/template/template_advanced_chat.ja.mdx index 4ab123f56574f4..d40f5eb6ced507 100644 --- a/web/app/components/develop/template/template_advanced_chat.ja.mdx +++ b/web/app/components/develop/template/template_advanced_chat.ja.mdx @@ -77,6 +77,9 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from (オプション)ワークフローID、特定のバージョンを指定するために使用、提供されない場合はデフォルトの公開バージョンを使用。 + + (オプション)ワークフロータグ、タグで特定バージョンのワークフローを指定するために使用されます。システムは以下の優先順位で検索します:workflow_tag > workflow_id > 最新公開バージョン。 + (オプション)トレースID。既存の業務システムのトレースコンポーネントと連携し、エンドツーエンドの分散トレーシングを実現するために使用します。指定がない場合、システムが自動的に trace_id を生成します。以下の3つの方法で渡すことができ、優先順位は次のとおりです:
- Header:HTTPヘッダー X-Trace-Id で渡す(最優先)。
diff --git a/web/app/components/develop/template/template_advanced_chat.zh.mdx b/web/app/components/develop/template/template_advanced_chat.zh.mdx index 009b6e0cc029d1..1038785bc04b45 100755 --- a/web/app/components/develop/template/template_advanced_chat.zh.mdx +++ b/web/app/components/develop/template/template_advanced_chat.zh.mdx @@ -74,6 +74,9 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' (选填)工作流ID,用于指定特定版本,如果不提供则使用默认的已发布版本。 + + (选填)工作流标签,用于通过标签指定特定版本的工作流。系统按以下优先级查找:workflow_tag > workflow_id > 最新发布版本。 + (选填)链路追踪ID。适用于与业务系统已有的trace组件打通,实现端到端分布式追踪等场景。如果未指定,系统会自动生成trace_id。支持以下三种方式传递,具体优先级依次为:
- Header:通过 HTTP Header X-Trace-Id 传递,优先级最高。
diff --git a/web/app/components/develop/template/template_chat.en.mdx b/web/app/components/develop/template/template_chat.en.mdx index 1e4e767d6d215a..fbdd7babf44a7b 100644 --- a/web/app/components/develop/template/template_chat.en.mdx +++ b/web/app/components/develop/template/template_chat.en.mdx @@ -77,6 +77,9 @@ Chat applications support session persistence, allowing previous chat history to (Optional) Workflow ID to specify a specific version, if not provided, uses the default published version.
How to obtain: In the version history interface, click the copy icon on the right side of each version entry to copy the complete workflow ID.
+ + (Optional) Workflow tag to specify a specific version of workflow. System searches in the following priority order: workflow_tag > workflow_id > latest published version. + (Optional) Trace ID. Used for integration with existing business trace components to achieve end-to-end distributed tracing. If not provided, the system will automatically generate a trace_id. Supports the following three ways to pass, in order of priority:
- Header: via HTTP Header X-Trace-Id, highest priority.
diff --git a/web/app/components/develop/template/template_chat.ja.mdx b/web/app/components/develop/template/template_chat.ja.mdx index 6ba80d8890d018..988fddd732d1f0 100644 --- a/web/app/components/develop/template/template_chat.ja.mdx +++ b/web/app/components/develop/template/template_chat.ja.mdx @@ -77,6 +77,9 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from (オプション)ワークフローID、特定のバージョンを指定するために使用、提供されない場合はデフォルトの公開バージョンを使用。
取得方法:バージョン履歴インターフェースで、各バージョンエントリの右側にあるコピーアイコンをクリックすると、完全なワークフローIDをコピーできます。
+ + (オプション)ワークフロータグ、タグで特定バージョンのワークフローを指定するために使用されます。システムは以下の優先順位で検索します:workflow_tag > workflow_id > 最新公開バージョン。 + (オプション)トレースID。既存の業務システムのトレースコンポーネントと連携し、エンドツーエンドの分散トレーシングを実現するために使用します。指定がない場合、システムが自動的に trace_id を生成します。以下の3つの方法で渡すことができ、優先順位は次のとおりです:
- Header:HTTPヘッダー X-Trace-Id で渡す(最優先)。
diff --git a/web/app/components/develop/template/template_chat.zh.mdx b/web/app/components/develop/template/template_chat.zh.mdx index bf69be2c28bf23..255b4934995339 100644 --- a/web/app/components/develop/template/template_chat.zh.mdx +++ b/web/app/components/develop/template/template_chat.zh.mdx @@ -75,6 +75,9 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' (选填)工作流ID,用于指定特定版本,如果不提供则使用默认的已发布版本。
获取方式:在版本历史界面,点击每个版本条目右侧的复制图标即可复制完整的工作流 ID。
+ + (选填)工作流标签,用于通过标签指定特定版本的工作流。系统按以下优先级查找:workflow_tag > workflow_id > 最新发布版本。 + (选填)链路追踪ID。适用于与业务系统已有的trace组件打通,实现端到端分布式追踪等场景。如果未指定,系统会自动生成trace_id。支持以下三种方式传递,具体优先级依次为:
- Header:通过 HTTP Header X-Trace-Id 传递,优先级最高。
diff --git a/web/app/components/develop/template/template_workflow.en.mdx b/web/app/components/develop/template/template_workflow.en.mdx index a820516e88ba8f..cd36ab74840564 100644 --- a/web/app/components/develop/template/template_workflow.en.mdx +++ b/web/app/components/develop/template/template_workflow.en.mdx @@ -332,19 +332,25 @@ Workflow applications offers non-session support and is ideal for translation, a --- - Execute a specific version of workflow by specifying the workflow ID in the path parameter. + Execute a specific version of workflow, supporting both workflow ID and tag. ### Path - - `workflow_id` (string) Required Workflow ID to specify a specific version of workflow - - How to obtain: In the version history interface, click the copy icon on the right side of each version entry to copy the complete workflow ID. Each version entry contains a copyable ID field. + - `identifier` (string) Required Workflow identifier + - Workflow ID (UUID format) + - Workflow tag + + System prioritizes UUID format processing, falls back to tag lookup if not a valid UUID format. + + **How to obtain:** + - Workflow ID: Obtain specific version workflow ID through the version history page + - Workflow tag: Obtain corresponding workflow tag through the version history page ### Request Body - `inputs` (object) Required @@ -501,8 +507,8 @@ Workflow applications offers non-session support and is ideal for translation, a - パスパラメータでワークフローIDを指定して、特定バージョンのワークフローを実行します。 + 特定バージョンのワークフローを実行し、ワークフローIDまたはエイリアスでバージョンを指定できます。 ### パス - - `workflow_id` (string) 必須 特定バージョンのワークフローを指定するためのワークフローID - - 取得方法:バージョン履歴インターフェースで、各バージョンエントリの右側にあるコピーアイコンをクリックすると、完全なワークフローIDをコピーできます。 + - `identifier` (string) 必須 ワークフロー識別子 + - ワークフローID(UUID形式) + - ワークフロータグ + + システムは UUID 形式の処理を優先し、有効な UUID 形式でない場合はタグ検索にフォールバックします。 + + **しゅとくほうほう:** + - ワークフローID:バージョン履歴インターフェースで、各バージョンエントリの右側にあるコピーアイコンをクリックすると、完全なワークフローIDをコピーできます。 + - ワークフロータグ:バージョン履歴ページから対応するワークフロータグを取得 ### リクエストボディ - `inputs` (object) 必須 @@ -496,8 +502,8 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from - 执行指定版本的工作流,通过路径参数指定工作流ID。 + 执行指定版本的工作流,支持通过工作流ID或标签指定版本。 ### Path - - `workflow_id` (string) Required 工作流ID,用于指定特定版本的工作流 - - 获取方式:在版本历史界面,点击每个版本条目右侧的复制图标即可复制完整的工作流 ID。 + - `identifier` (string) Required 工作流标识符 + - 工作流ID(UUID格式) + - 工作流标签 + + 系统优先按UUID格式处理,如果不是有效的UUID格式,则按标签查询。 + + **获取方式:** + - 工作流ID:在版本历史界面,点击每个版本条目右侧的复制图标即可复制完整的工作流 ID + - 工作流标签:通过版本历史页面获取对应的工作流标签 ### Request Body - `inputs` (object) Required @@ -489,8 +495,8 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等 { }, [setCurrentLogItem, setShowMessageLogModal]) const viewHistoryProps = useMemo(() => { + if (!appDetail) + return undefined + return { onClearLogAndMessageModal: handleClearLogAndMessageModal, - historyUrl: isChatMode ? `/apps/${appDetail!.id}/advanced-chat/workflow-runs` : `/apps/${appDetail!.id}/workflow-runs`, + historyUrl: isChatMode ? `/apps/${appDetail.id}/advanced-chat/workflow-runs` : `/apps/${appDetail.id}/workflow-runs`, + historyFetcher: fetchWorkflowRunHistory, } }, [appDetail, isChatMode, handleClearLogAndMessageModal]) diff --git a/web/app/components/workflow-app/hooks/use-workflow-init.ts b/web/app/components/workflow-app/hooks/use-workflow-init.ts index 8e976937b5c361..f3b8ee8abdd3f3 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-init.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-init.ts @@ -38,13 +38,14 @@ export const useWorkflowInit = () => { nodes: nodesTemplate, edges: edgesTemplate, } = useWorkflowTemplate() - const appDetail = useAppStore(state => state.appDetail)! + const appDetail = useAppStore(state => state.appDetail) const setSyncWorkflowDraftHash = useStore(s => s.setSyncWorkflowDraftHash) const [data, setData] = useState() const [isLoading, setIsLoading] = useState(true) useEffect(() => { - workflowStore.setState({ appId: appDetail.id, appName: appDetail.name }) - }, [appDetail.id, workflowStore]) + if (appDetail) + workflowStore.setState({ appId: appDetail.id, appName: appDetail.name }) + }, [appDetail?.id, workflowStore]) const handleUpdateWorkflowFileUploadConfig = useCallback((config: FileUploadConfigResponse) => { const { setFileUploadConfig } = workflowStore.getState() @@ -56,6 +57,9 @@ export const useWorkflowInit = () => { } = useWorkflowConfig('/files/upload', handleUpdateWorkflowFileUploadConfig) const handleGetInitialWorkflowData = useCallback(async () => { + if (!appDetail) + return + try { const res = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`) setData(res) @@ -113,9 +117,12 @@ export const useWorkflowInit = () => { }, []) const handleFetchPreloadData = useCallback(async () => { + if (!appDetail) + return + try { - const nodesDefaultConfigsData = await fetchNodesDefaultConfigs(`/apps/${appDetail?.id}/workflows/default-workflow-block-configs`) - const publishedWorkflow = await fetchPublishedWorkflow(`/apps/${appDetail?.id}/workflows/publish`) + const nodesDefaultConfigsData = await fetchNodesDefaultConfigs(`/apps/${appDetail.id}/workflows/default-workflow-block-configs`) + const publishedWorkflow = await fetchPublishedWorkflow(`/apps/${appDetail.id}/workflows/publish`) workflowStore.setState({ nodesDefaultConfigs: nodesDefaultConfigsData.reduce((acc, block) => { if (!acc[block.type]) diff --git a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts index 90f65db0aa4b72..a45f9b1ca26236 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/utils/cron-parser.ts @@ -1,3 +1,4 @@ +import type { CronDate } from 'cron-parser' import { CronExpressionParser } from 'cron-parser' // Convert a UTC date from cron-parser to user timezone representation @@ -46,7 +47,7 @@ export const parseCronExpression = (cronExpression: string, timezone: string = ' // Convert CronDate objects to Date objects and ensure they represent // the time in user timezone (consistent with execution-time-calculator.ts) - return nextCronDates.map((cronDate) => { + return nextCronDates.map((cronDate: CronDate) => { const utcDate = cronDate.toDate() return convertToUserTimezoneRepresentation(utcDate, timezone) }) diff --git a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx index 8ff356cad7f738..62f6763c9f1601 100644 --- a/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx +++ b/web/app/components/workflow/panel/debug-and-preview/chat-wrapper.tsx @@ -92,7 +92,7 @@ const ChatWrapper = ( inputsForm: (startVariables || []) as any, }, [], - taskId => stopChatMessageResponding(appDetail!.id, taskId), + taskId => stopChatMessageResponding(appDetail?.id || '', taskId), ) const handleRestartChat = useCallback(() => { @@ -110,7 +110,7 @@ const ChatWrapper = ( parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || undefined, }, { - onGetSuggestedQuestions: (messageId, getAbortController) => fetchSuggestedQuestions(appDetail!.id, messageId, getAbortController), + onGetSuggestedQuestions: (messageId, getAbortController) => fetchSuggestedQuestions(appDetail?.id || '', messageId, getAbortController), }, ) }, [handleSend, workflowStore, conversationId, chatList, appDetail]) diff --git a/web/app/components/workflow/panel/version-history-panel/context-menu/use-context-menu.ts b/web/app/components/workflow/panel/version-history-panel/context-menu/use-context-menu.ts index 92d6ee6869adf5..310a6c1669ee10 100644 --- a/web/app/components/workflow/panel/version-history-panel/context-menu/use-context-menu.ts +++ b/web/app/components/workflow/panel/version-history-panel/context-menu/use-context-menu.ts @@ -42,8 +42,12 @@ const useContextMenu = (props: ContextMenuProps) => { key: VersionHistoryContextMenuOptions.copyId, name: t('versionHistory.copyId', { ns: 'workflow' }), }, + { + key: VersionHistoryContextMenuOptions.manageTag, + name: t('tag.manageTag', { ns: 'workflow' }), + }, ] - }, [isNamedVersion, t]) + }, [isNamedVersion, t, pipelineId]) return { deleteOperation, diff --git a/web/app/components/workflow/panel/version-history-panel/index.spec.tsx b/web/app/components/workflow/panel/version-history-panel/index.spec.tsx index 1765459bcb46cd..bcd7566db28b43 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.spec.tsx +++ b/web/app/components/workflow/panel/version-history-panel/index.spec.tsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { fireEvent, render, screen } from '@testing-library/react' import { WorkflowVersion } from '../../types' @@ -9,6 +10,17 @@ vi.mock('@/context/app-context', () => ({ useSelector: () => ({ id: 'test-user-id' }), })) +vi.mock('@/service/use-workflow-tag', () => ({ + useWorkflowTagListPaginated: () => ({ + data: { + pages: [], + }, + refetch: vi.fn(), + fetchNextPage: vi.fn(), + hasNextPage: false, + }), +})) + vi.mock('@/service/use-workflow', () => ({ useDeleteWorkflow: () => ({ mutateAsync: vi.fn() }), useInvalidAllLastRun: () => vi.fn(), @@ -117,7 +129,14 @@ vi.mock('@/app/components/app/app-publisher/version-info-modal', () => ({ })) describe('VersionHistoryPanel', () => { + let queryClient: QueryClient + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) vi.clearAllMocks() }) @@ -126,9 +145,11 @@ describe('VersionHistoryPanel', () => { const { VersionHistoryPanel } = await import('./index') render( - , + + + , ) // Draft version auto-clicks on mount via useEffect in VersionHistoryItem @@ -140,9 +161,11 @@ describe('VersionHistoryPanel', () => { const { VersionHistoryPanel } = await import('./index') render( - , + + + , ) // Clear mocks after initial render (draft version auto-clicks on mount) diff --git a/web/app/components/workflow/panel/version-history-panel/index.tsx b/web/app/components/workflow/panel/version-history-panel/index.tsx index 0ad3ef05497f52..cd823ccc26fbf3 100644 --- a/web/app/components/workflow/panel/version-history-panel/index.tsx +++ b/web/app/components/workflow/panel/version-history-panel/index.tsx @@ -1,4 +1,5 @@ 'use client' +import type { WorkflowTag } from '@/app/components/workflow/types' import type { VersionHistory } from '@/types/workflow' import { RiArrowDownDoubleLine, RiCloseLine, RiLoader2Line } from '@remixicon/react' import copy from 'copy-to-clipboard' @@ -6,10 +7,12 @@ import * as React from 'react' import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import VersionInfoModal from '@/app/components/app/app-publisher/version-info-modal' +import { useStore as useAppStore } from '@/app/components/app/store' import Divider from '@/app/components/base/divider' import Toast from '@/app/components/base/toast' import { useSelector as useAppContextSelector } from '@/context/app-context' import { useDeleteWorkflow, useInvalidAllLastRun, useResetWorkflowVersionHistory, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow' +import { useWorkflowTagListPaginated } from '@/service/use-workflow-tag' import { useDSL, useNodesSyncDraft, useWorkflowRun } from '../../hooks' import { useHooksStore } from '../../hooks-store' import { useStore, useWorkflowStore } from '../../store' @@ -19,6 +22,7 @@ import Empty from './empty' import Filter from './filter' import Loading from './loading' import RestoreConfirmModal from './restore-confirm-modal' +import TagManagementModal from './tag-management-modal' import VersionHistoryItem from './version-history-item' const HISTORY_PER_PAGE = 10 @@ -42,6 +46,7 @@ export const VersionHistoryPanel = ({ const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) const [editModalOpen, setEditModalOpen] = useState(false) + const [tagModalOpen, setTagModalOpen] = useState(false) const workflowStore = useWorkflowStore() const { handleSyncWorkflowDraft } = useNodesSyncDraft() const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun() @@ -56,6 +61,7 @@ export const VersionHistoryPanel = ({ deleteAllInspectVars, } = workflowStore.getState() const { t } = useTranslation() + const appDetail = useAppStore(s => s.appDetail) const { data: versionHistory, @@ -70,6 +76,41 @@ export const VersionHistoryPanel = ({ namedOnly: isOnlyShowNamedVersions, }) + // Extract all workflow IDs from version history for batch tag query (excluding draft) + const allWorkflowIds = versionHistory?.pages?.flatMap(page => + page.items?.filter(item => item.version !== 'draft').map(item => item.id) || [], + ) || [] + + // Batch query tags for all workflow versions (only when we have workflow IDs) + const { data: allTags, refetch: refetchTags, fetchNextPage: fetchNextTagsPage, hasNextPage: hasNextTagsPage } = useWorkflowTagListPaginated({ + appId: appDetail?.id || '', + workflowIds: allWorkflowIds, + limit: 100, + }) + + React.useEffect(() => { + if (hasNextTagsPage && allWorkflowIds.length > 0) + fetchNextTagsPage() + }, [allTags?.pages?.length, hasNextTagsPage, allWorkflowIds.length, fetchNextTagsPage]) + + // Create a map of workflow_id -> tags for efficient lookup + const tagsMap = React.useMemo(() => { + const map = new Map() + if (allTags?.pages) { + allTags.pages.forEach((page) => { + if (page.items) { + page.items.forEach((tag: WorkflowTag) => { + if (!map.has(tag.workflow_id)) + map.set(tag.workflow_id, []) + + map.get(tag.workflow_id)!.push(tag) + }) + } + }) + } + return map + }, [allTags]) + const handleVersionClick = useCallback((item: VersionHistory) => { if (item.id !== currentVersion?.id) { setCurrentVersion(item) @@ -123,6 +164,9 @@ export const VersionHistoryPanel = ({ message: t('versionHistory.action.copyIdSuccess', { ns: 'workflow' }), }) break + case VersionHistoryContextMenuOptions.manageTag: + setTagModalOpen(true) + break case VersionHistoryContextMenuOptions.exportDSL: handleExportDSL?.(false, item.id) break @@ -140,6 +184,9 @@ export const VersionHistoryPanel = ({ case VersionHistoryContextMenuOptions.delete: setDeleteConfirmOpen(false) break + case VersionHistoryContextMenuOptions.manageTag: + setTagModalOpen(false) + break } }, []) @@ -263,6 +310,7 @@ export const VersionHistoryPanel = ({ onClick={handleVersionClick} handleClickMenuItem={handleClickMenuItem.bind(null, item)} isLast={isLast} + tags={tagsMap.get(item.id) || []} /> ) }) @@ -315,6 +363,15 @@ export const VersionHistoryPanel = ({ onPublish={handleUpdateWorkflow} /> )} + {tagModalOpen && operatedItem && ( + + )} ) } diff --git a/web/app/components/workflow/panel/version-history-panel/tag-management-modal/index.tsx b/web/app/components/workflow/panel/version-history-panel/tag-management-modal/index.tsx new file mode 100644 index 00000000000000..d0ffb5f25e2ab9 --- /dev/null +++ b/web/app/components/workflow/panel/version-history-panel/tag-management-modal/index.tsx @@ -0,0 +1,230 @@ +'use client' +import type { WorkflowTag } from '../../../types' +import type { VersionHistory } from '@/types/workflow' +import { RiAddLine, RiDeleteBinLine } from '@remixicon/react' +import * as React from 'react' +import { useCallback, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useStore as useAppStore } from '@/app/components/app/store' +import Input from '@/app/components/base/input' +import Modal from '@/app/components/base/modal' +import Toast from '@/app/components/base/toast' +import { useCreateWorkflowTag, useDeleteWorkflowTag } from '@/service/use-workflow-tag' + +type TagManagementModalProps = { + isOpen: boolean + onClose: () => void + versionHistory: VersionHistory + tags: WorkflowTag[] + onTagChange: () => void +} + +const TagManagementModal: React.FC = ({ + isOpen, + onClose, + versionHistory, + tags, + onTagChange, +}) => { + const { t } = useTranslation() + const [isCreating, setIsCreating] = useState(false) + const [newTagName, setNewTagName] = useState('') + const [isAddingNew, setIsAddingNew] = useState(false) + const appDetail = useAppStore(s => s.appDetail) + + const { mutateAsync: createTag } = useCreateWorkflowTag(appDetail?.id || '') + const { mutateAsync: deleteTag } = useDeleteWorkflowTag(appDetail?.id || '') + + const resetForm = () => { + setNewTagName('') + } + + const handleCreateTag = useCallback(async () => { + if (!newTagName || !newTagName.trim()) { + Toast.notify({ + type: 'error', + message: t('tag.nameRequired', { ns: 'workflow' }), + }) + return + } + + setIsCreating(true) + try { + const result = await createTag({ + workflow_id: versionHistory.id, + name: newTagName.trim(), + }) + + let message + if (result.is_transferred) { + if (result.old_workflow_id === versionHistory.id) + message = t('tag.tagExists', { ns: 'workflow' }) + else + message = t('tag.transferSuccess', { ns: 'workflow' }) + } + else { + message = t('tag.createSuccess', { ns: 'workflow' }) + } + + Toast.notify({ + type: 'success', + message, + }) + resetForm() + setIsAddingNew(false) + onTagChange() + } + catch (error: unknown) { + Toast.notify({ + type: 'error', + message: (error instanceof Error ? error.message : String(error)) || t('tag.createFailure', { ns: 'workflow' }), + }) + } + finally { + setIsCreating(false) + } + }, [newTagName, versionHistory.id, createTag, onTagChange, t]) + + const handleDeleteTag = useCallback(async (tag: WorkflowTag) => { + try { + await deleteTag(tag.id) + Toast.notify({ + type: 'success', + message: t('tag.deleteSuccess', { ns: 'workflow' }), + }) + onTagChange() + } + catch (error: unknown) { + Toast.notify({ + type: 'error', + message: (error instanceof Error ? error.message : String(error)) || t('tag.deleteFailure', { ns: 'workflow' }), + }) + } + }, [deleteTag, onTagChange, t]) + + useEffect(() => { + if (!isOpen) { + // Use setTimeout to defer the state update + const timer = setTimeout(() => { + setNewTagName('') + }, 0) + return () => clearTimeout(timer) + } + }, [isOpen]) + + return ( + +
+ +
+ {tags && tags.length > 0 + ? ( +
+ {tags.map((tag: WorkflowTag) => ( +
+
+
+
+ + {tag.name} + +
+
+ {tag.created_by.name} + · + {new Date(tag.created_at * 1000).toLocaleString()} +
+
+
+ +
+
+ ))} +
+ ) + : ( +
+
+ {t('tag.noTags', { ns: 'workflow' })} +
+
+ )} +
+ {isAddingNew + ? ( +
+
+ setNewTagName(e.target.value)} + placeholder={t('tag.inputTag', { ns: 'workflow' })} + maxLength={255} + className="w-full" + /> +
+ +
+ + +
+
+ ) + : ( +
+
setIsAddingNew(true)} + > + + + {t('tag.add', { ns: 'workflow' })} + +
+
+ )} +
+
+ ) +} + +export default TagManagementModal diff --git a/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx b/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx index a6a911ef2a0be5..53751a24755106 100644 --- a/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx +++ b/web/app/components/workflow/panel/version-history-panel/version-history-item.tsx @@ -1,9 +1,11 @@ import type { VersionHistoryContextMenuOptions } from '../../types' +import type { WorkflowTag } from '@/app/components/workflow/types' import type { VersionHistory } from '@/types/workflow' import dayjs from 'dayjs' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import Tag from '@/app/components/base/tag' import { cn } from '@/utils/classnames' import { WorkflowVersion } from '../../types' import ContextMenu from './context-menu' @@ -15,6 +17,7 @@ type VersionHistoryItemProps = { onClick: (item: VersionHistory) => void handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void isLast: boolean + tags: WorkflowTag[] } const formatVersion = (versionHistory: VersionHistory, latestVersionId: string): string => { @@ -43,6 +46,7 @@ const VersionHistoryItem: React.FC = ({ onClick, handleClickMenuItem, isLast, + tags, }) => { const { t } = useTranslation() const [isHovering, setIsHovering] = useState(false) @@ -107,6 +111,27 @@ const VersionHistoryItem: React.FC = ({ )} + {!isDraft && tags && tags.length > 0 && ( +
+ {tags + .slice(0, 2) + .map((tag: WorkflowTag) => ( + + {tag.name} + + ))} + {tags.length > 2 && ( + + + + {tags.length - 2} + + )} +
+ )} { !isDraft && (
diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index be1254d33ce3ec..b8b16318d7c534 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -492,12 +492,37 @@ export enum VersionHistoryContextMenuOptions { delete = 'delete', exportDSL = 'exportDSL', copyId = 'copyId', + manageTag = 'manageTag', } export type ChildNodeTypeCount = { [key: string]: number } +export type WorkflowTag = { + id: string + tenant_id: string + app_id: string + workflow_id: string + name: string + created_by: { + id: string + name: string + email: string + } + created_at: number + updated_at: number + is_transferred?: boolean + old_workflow_id?: string +} + +export type WorkflowTagList = { + items: WorkflowTag[] + page: number + limit: number + has_more: boolean +} + export const TRIGGER_NODE_TYPES = [ BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 107dad5b288db5..504b83d9eaebdc 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -1014,6 +1014,22 @@ "tabs.usePlugin": "Select tool", "tabs.utilities": "Utilities", "tabs.workflowTool": "Workflow", + "tag.add": "Add Tag", + "tag.cancel": "Cancel", + "tag.confirmAdd": "Confirm add", + "tag.createFailure": "Failed to create tag", + "tag.createSuccess": "Tag created successfully", + "tag.deleteFailure": "Failed to delete tag", + "tag.deleteSuccess": "Tag deleted successfully", + "tag.deleteTag": "Delete tag", + "tag.inputTag": "Enter tag", + "tag.manageTag": "Manage Tag", + "tag.managementTitle": "Tag List", + "tag.nameRequired": "Please enter tag name", + "tag.noTags": "No tags", + "tag.tagExists": "Tag already exists", + "tag.transferSuccess": "Tag transferred to current workflow", + "tag.untitled": "Untitled", "tracing.stopBy": "Stop by {{user}}", "triggerStatus.disabled": "TRIGGER • DISABLED", "triggerStatus.enabled": "TRIGGER", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 7787c9db4b618e..43d3f8fe8e134f 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -1014,6 +1014,22 @@ "tabs.usePlugin": "选择工具", "tabs.utilities": "工具", "tabs.workflowTool": "工作流", + "tag.add": "添加标签", + "tag.cancel": "取消", + "tag.confirmAdd": "确认添加", + "tag.createFailure": "标签创建失败", + "tag.createSuccess": "标签创建成功", + "tag.deleteFailure": "标签删除失败", + "tag.deleteSuccess": "标签删除成功", + "tag.deleteTag": "删除标签", + "tag.inputTag": "输入标签", + "tag.manageTag": "管理标签", + "tag.managementTitle": "标签列表", + "tag.nameRequired": "请输入标签名称", + "tag.noTags": "暂无标签", + "tag.tagExists": "标签已存在", + "tag.transferSuccess": "标签已转移至当前工作流", + "tag.untitled": "未命名", "tracing.stopBy": "由{{user}}终止", "triggerStatus.disabled": "触发器 • 已禁用", "triggerStatus.enabled": "触发器", diff --git a/web/service/use-workflow-tag.ts b/web/service/use-workflow-tag.ts new file mode 100644 index 00000000000000..6faa361aa40db9 --- /dev/null +++ b/web/service/use-workflow-tag.ts @@ -0,0 +1,106 @@ +import type { WorkflowTag, WorkflowTagList } from '@/app/components/workflow/types' +import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { del, get, post } from './base' + +export const useWorkflowTagList = ({ + appId, + workflowIds, + limit = 100, + offset = 0, +}: { + appId: string + workflowIds?: string[] + limit?: number + offset?: number +}) => { + return useQuery({ + queryKey: ['workflow-tags', appId, workflowIds, limit, offset], + queryFn: async () => { + const params = new URLSearchParams() + + if (workflowIds && workflowIds.length > 0) + params.append('workflow_ids', workflowIds.join(',')) + + params.append('limit', limit.toString()) + params.append('offset', offset.toString()) + + const queryString = params.toString() + const url = queryString ? `/apps/${appId}/workflow-tags?${queryString}` : `/apps/${appId}/workflow-tags` + + return get(url) + }, + enabled: !!appId && (workflowIds === undefined || workflowIds.length > 0), + }) +} + +export const useWorkflowTagListPaginated = ({ + appId, + workflowIds, + limit = 100, +}: { + appId: string + workflowIds?: string[] + limit?: number +}) => { + return useInfiniteQuery({ + queryKey: ['workflow-tags-paginated', appId, workflowIds, limit], + initialPageParam: 0, + queryFn: async ({ pageParam }) => { + const params = new URLSearchParams() + + if (workflowIds && workflowIds.length > 0) + params.append('workflow_ids', workflowIds.join(',')) + + params.append('limit', limit.toString()) + params.append('offset', ((pageParam as number) * limit).toString()) + + const queryString = params.toString() + const url = `/apps/${appId}/workflow-tags?${queryString}` + + const response = await get(url) + return response + }, + getNextPageParam: (lastPage, allPages) => { + if (lastPage.has_more) + return allPages.length + return undefined + }, + enabled: !!appId && (workflowIds === undefined || workflowIds.length > 0), + }) +} + +export const useCreateWorkflowTag = (appId: string) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (data: { + workflow_id: string + name: string + }) => { + return post(`/apps/${appId}/workflow-tags`, { + body: { + workflow_id: data.workflow_id, + name: data.name, + }, + }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workflow-tags', appId] }) + queryClient.invalidateQueries({ queryKey: ['workflow-tags-paginated', appId] }) + }, + }) +} + +export const useDeleteWorkflowTag = (appId: string) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async (tagId: string) => { + return del(`/apps/${appId}/workflow-tags/${tagId}`) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['workflow-tags', appId] }) + queryClient.invalidateQueries({ queryKey: ['workflow-tags-paginated', appId] }) + }, + }) +} diff --git a/web/service/workflow.ts b/web/service/workflow.ts index 3a37db791b9ba0..890aa6f6e3c48e 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -6,6 +6,7 @@ import type { FetchWorkflowDraftResponse, NodesDefaultConfigsResponse, VarInInspect, + WorkflowRunHistoryResponse, } from '@/types/workflow' import { get, post } from './base' import { getFlowPrefix } from './utils' @@ -43,6 +44,10 @@ export const fetchPublishedWorkflow = (url: string) => { return get(url) } +export const fetchWorkflowRunHistory = (url: string) => { + return get(url) +} + export const stopWorkflowRun = (url: string) => { return post(url) }