diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 649df278ec..a6f803785a 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -8,7 +8,7 @@ api = ExternalApi(bp) from . import admin, apikey, extension, feature, setup, version, ping # Import app controllers from .app import (advanced_prompt_template, annotation, app, audio, completion, conversation, generator, message, - model_config, site, statistic, workflow) + model_config, site, statistic, workflow, workflow_app_log) # Import auth controllers from .auth import activate, data_source_oauth, login, oauth # Import billing controllers diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 7c091ab456..c68d2b8588 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -40,9 +40,9 @@ class AppListApi(Resource): # get app list app_service = AppService() - app_models = app_service.get_paginate_apps(current_user.current_tenant_id, args) + app_pagination = app_service.get_paginate_apps(current_user.current_tenant_id, args) - return app_models + return app_pagination @setup_required @login_required diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 6023d0ba45..8e51ae8cbd 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -51,6 +51,41 @@ class DraftWorkflowApi(Resource): } +class PublishedWorkflowApi(Resource): + + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + @marshal_with(workflow_fields) + def get(self, app_model: App): + """ + Get published workflow + """ + # fetch published workflow by app_model + workflow_service = WorkflowService() + workflow = workflow_service.get_published_workflow(app_model=app_model) + + # return workflow, if not found, return None + return workflow + + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def post(self, app_model: App): + """ + Publish workflow + """ + workflow_service = WorkflowService() + workflow_service.publish_workflow(app_model=app_model, account=current_user) + + return { + "result": "success" + } + + + class DefaultBlockConfigApi(Resource): @setup_required @login_required @@ -88,5 +123,6 @@ class ConvertToWorkflowApi(Resource): api.add_resource(DraftWorkflowApi, '/apps//workflows/draft') +api.add_resource(PublishedWorkflowApi, '/apps//workflows/published') api.add_resource(DefaultBlockConfigApi, '/apps//workflows/default-workflow-block-configs') api.add_resource(ConvertToWorkflowApi, '/apps//convert-to-workflow') diff --git a/api/controllers/console/app/workflow_app_log.py b/api/controllers/console/app/workflow_app_log.py new file mode 100644 index 0000000000..87614d549d --- /dev/null +++ b/api/controllers/console/app/workflow_app_log.py @@ -0,0 +1,41 @@ +from flask_restful import Resource, marshal_with, reqparse +from flask_restful.inputs import int_range + +from controllers.console import api +from controllers.console.app.wraps import get_app_model +from controllers.console.setup import setup_required +from controllers.console.wraps import account_initialization_required +from fields.workflow_app_log_fields import workflow_app_log_pagination_fields +from libs.login import login_required +from models.model import AppMode, App +from services.workflow_app_service import WorkflowAppService + + +class WorkflowAppLogApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.WORKFLOW]) + @marshal_with(workflow_app_log_pagination_fields) + def get(self, app_model: App): + """ + Get workflow app logs + """ + parser = reqparse.RequestParser() + parser.add_argument('keyword', type=str, location='args') + parser.add_argument('status', type=str, choices=['succeeded', 'failed', 'stopped'], location='args') + parser.add_argument('page', type=int_range(1, 99999), default=1, location='args') + parser.add_argument('limit', type=int_range(1, 100), default=20, location='args') + args = parser.parse_args() + + # get paginate workflow app logs + workflow_app_service = WorkflowAppService() + workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs( + app_model=app_model, + args=args + ) + + return workflow_app_log_pagination + + +api.add_resource(WorkflowAppLogApi, '/apps//workflow-app-logs') diff --git a/api/fields/end_user_fields.py b/api/fields/end_user_fields.py new file mode 100644 index 0000000000..ee630c12c2 --- /dev/null +++ b/api/fields/end_user_fields.py @@ -0,0 +1,8 @@ +from flask_restful import fields + +simple_end_user_fields = { + 'id': fields.String, + 'type': fields.String, + 'is_anonymous': fields.Boolean, + 'session_id': fields.String, +} diff --git a/api/fields/workflow_app_log_fields.py b/api/fields/workflow_app_log_fields.py new file mode 100644 index 0000000000..6862f0411d --- /dev/null +++ b/api/fields/workflow_app_log_fields.py @@ -0,0 +1,25 @@ +from flask_restful import fields + +from fields.end_user_fields import simple_end_user_fields +from fields.member_fields import simple_account_fields +from fields.workflow_fields import workflow_run_fields +from libs.helper import TimestampField + + +workflow_app_log_partial_fields = { + "id": fields.String, + "workflow_run": fields.Nested(workflow_run_fields, attribute='workflow_run', allow_null=True), + "created_from": fields.String, + "created_by_role": fields.String, + "created_by_account": fields.Nested(simple_account_fields, attribute='created_by_account', allow_null=True), + "created_by_end_user": fields.Nested(simple_end_user_fields, attribute='created_by_end_user', allow_null=True), + "created_at": TimestampField +} + +workflow_app_log_pagination_fields = { + 'page': fields.Integer, + 'limit': fields.Integer(attribute='per_page'), + 'total': fields.Integer, + 'has_more': fields.Boolean(attribute='has_next'), + 'data': fields.List(fields.Nested(workflow_app_log_partial_fields), attribute='items') +} diff --git a/api/fields/workflow_fields.py b/api/fields/workflow_fields.py index decdc0567f..091f293150 100644 --- a/api/fields/workflow_fields.py +++ b/api/fields/workflow_fields.py @@ -13,3 +13,16 @@ workflow_fields = { 'updated_by': fields.Nested(simple_account_fields, attribute='updated_by_account', allow_null=True), 'updated_at': TimestampField } + +workflow_run_fields = { + "id": fields.String, + "version": fields.String, + "status": fields.String, + "error": fields.String, + "elapsed_time": fields.Float, + "total_tokens": fields.Integer, + "total_price": fields.Float, + "currency": fields.String, + "total_steps": fields.Integer, + "finished_at": TimestampField +} \ No newline at end of file diff --git a/api/models/__init__.py b/api/models/__init__.py index 44d37d3052..47eec53542 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -1 +1,44 @@ -# -*- coding:utf-8 -*- \ No newline at end of file +from enum import Enum + + +class CreatedByRole(Enum): + """ + Enum class for createdByRole + """ + ACCOUNT = "account" + END_USER = "end_user" + + @classmethod + def value_of(cls, value: str) -> 'CreatedByRole': + """ + Get value of given mode. + + :param value: mode value + :return: mode + """ + for role in cls: + if role.value == value: + return role + raise ValueError(f'invalid createdByRole value {value}') + + +class CreatedFrom(Enum): + """ + Enum class for createdFrom + """ + SERVICE_API = "service-api" + WEB_APP = "web-app" + EXPLORE = "explore" + + @classmethod + def value_of(cls, value: str) -> 'CreatedFrom': + """ + Get value of given mode. + + :param value: mode value + :return: mode + """ + for role in cls: + if role.value == value: + return role + raise ValueError(f'invalid createdFrom value {value}') diff --git a/api/models/workflow.py b/api/models/workflow.py index 251f33b0c0..41266fe9f5 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -5,6 +5,7 @@ from sqlalchemy.dialects.postgresql import UUID from extensions.ext_database import db from models.account import Account +from models.model import EndUser class CreatedByRole(Enum): @@ -148,6 +149,7 @@ class WorkflowRunStatus(Enum): RUNNING = 'running' SUCCEEDED = 'succeeded' FAILED = 'failed' + STOPPED = 'stopped' @classmethod def value_of(cls, value: str) -> 'WorkflowRunStatus': @@ -184,7 +186,7 @@ class WorkflowRun(db.Model): - version (string) Version - graph (text) Workflow canvas configuration (JSON) - inputs (text) Input parameters - - status (string) Execution status, `running` / `succeeded` / `failed` + - status (string) Execution status, `running` / `succeeded` / `failed` / `stopped` - outputs (text) `optional` Output content - error (string) `optional` Error reason - elapsed_time (float) `optional` Time consumption (s) @@ -366,3 +368,19 @@ class WorkflowAppLog(db.Model): created_by_role = db.Column(db.String(255), nullable=False) created_by = db.Column(UUID, nullable=False) created_at = db.Column(db.DateTime, nullable=False, server_default=db.text('CURRENT_TIMESTAMP(0)')) + + @property + def workflow_run(self): + return WorkflowRun.query.get(self.workflow_run_id) + + @property + def created_by_account(self): + created_by_role = CreatedByRole.value_of(self.created_by_role) + return Account.query.get(self.created_by) \ + if created_by_role == CreatedByRole.ACCOUNT else None + + @property + def created_by_end_user(self): + created_by_role = CreatedByRole.value_of(self.created_by_role) + return EndUser.query.get(self.created_by) \ + if created_by_role == CreatedByRole.END_USER else None diff --git a/api/services/app_service.py b/api/services/app_service.py index 6955a6dccb..5de87dbad5 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import cast import yaml +from flask_sqlalchemy.pagination import Pagination from constants.model_template import default_app_templates from core.errors.error import ProviderTokenNotInitError @@ -17,7 +18,7 @@ from services.workflow_service import WorkflowService class AppService: - def get_paginate_apps(self, tenant_id: str, args: dict) -> list[App]: + def get_paginate_apps(self, tenant_id: str, args: dict) -> Pagination: """ Get app list with pagination :param tenant_id: tenant id diff --git a/api/services/workflow_app_service.py b/api/services/workflow_app_service.py new file mode 100644 index 0000000000..5897fcf182 --- /dev/null +++ b/api/services/workflow_app_service.py @@ -0,0 +1,62 @@ +from flask_sqlalchemy.pagination import Pagination +from sqlalchemy import or_, and_ + +from extensions.ext_database import db +from models import CreatedByRole +from models.model import App, EndUser +from models.workflow import WorkflowAppLog, WorkflowRunStatus, WorkflowRun + + +class WorkflowAppService: + + def get_paginate_workflow_app_logs(self, app_model: App, args: dict) -> Pagination: + """ + Get paginate workflow app logs + :param app: app model + :param args: request args + :return: + """ + query = ( + db.select(WorkflowAppLog) + .where( + WorkflowAppLog.tenant_id == app_model.tenant_id, + WorkflowAppLog.app_id == app_model.id + ) + ) + + status = WorkflowRunStatus.value_of(args.get('status')) if args.get('status') else None + if args['keyword'] or status: + query = query.join( + WorkflowRun, WorkflowRun.id == WorkflowAppLog.workflow_run_id + ) + + if args['keyword']: + keyword_val = f"%{args['keyword'][:30]}%" + keyword_conditions = [ + WorkflowRun.inputs.ilike(keyword_val), + WorkflowRun.outputs.ilike(keyword_val), + # filter keyword by end user session id if created by end user role + and_(WorkflowRun.created_by_role == 'end_user', EndUser.session_id.ilike(keyword_val)) + ] + + query = query.outerjoin( + EndUser, + and_(WorkflowRun.created_by == EndUser.id, WorkflowRun.created_by_role == CreatedByRole.END_USER.value) + ).filter(or_(*keyword_conditions)) + + if status: + # join with workflow_run and filter by status + query = query.filter( + WorkflowRun.status == status.value + ) + + query = query.order_by(WorkflowAppLog.created_at.desc()) + + pagination = db.paginate( + query, + page=args['page'], + per_page=args['limit'], + error_out=False + ) + + return pagination diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index dac88d6396..ae6e4c46d3 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -15,7 +15,7 @@ class WorkflowService: Workflow Service """ - def get_draft_workflow(self, app_model: App) -> Workflow: + def get_draft_workflow(self, app_model: App) -> Optional[Workflow]: """ Get draft workflow """ @@ -29,6 +29,26 @@ class WorkflowService: # return draft workflow return workflow + def get_published_workflow(self, app_model: App) -> Optional[Workflow]: + """ + Get published workflow + """ + app_model_config = app_model.app_model_config + + if not app_model_config.workflow_id: + return None + + # fetch published workflow by workflow_id + workflow = db.session.query(Workflow).filter( + Workflow.tenant_id == app_model.tenant_id, + Workflow.app_id == app_model.id, + Workflow.id == app_model_config.workflow_id + ).first() + + # return published workflow + return workflow + + def sync_draft_workflow(self, app_model: App, graph: dict, account: Account) -> Workflow: """ Sync draft workflow @@ -116,6 +136,8 @@ class WorkflowService: app_model.app_model_config_id = new_app_model_config.id db.session.commit() + # TODO update app related datasets + # return new workflow return workflow