Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46036e6ce6 | ||
|
|
1ffda0dd34 | ||
|
|
da01b460fe | ||
|
|
90a1508b87 | ||
|
|
b07016113c | ||
|
|
d8317fcf81 | ||
|
|
a6bc642721 | ||
|
|
b730f243dc | ||
|
|
71a57275ab | ||
|
|
41bf8d925f | ||
|
|
6d172498d1 | ||
|
|
cad58658c2 | ||
|
|
a58b990855 | ||
|
|
b6b1903a37 | ||
|
|
ed5596a8f4 | ||
|
|
49d0acd188 | ||
|
|
58a74fe1fb | ||
|
|
a1ab4aec3d | ||
|
|
f77f7e1437 | ||
|
|
adda049265 | ||
|
|
9b2a9260ef | ||
|
|
c8cc31af88 | ||
|
|
d333de274f | ||
|
|
9e220d5d30 | ||
|
|
2cf0cb471f | ||
|
|
269ba6add9 | ||
|
|
78d460a6d1 | ||
|
|
3254018ddb | ||
|
|
f2b7df94d7 | ||
|
|
59fd3aad31 |
@@ -0,0 +1,13 @@
|
||||
name: "👾 Tracker"
|
||||
description: For inner usages, please donot use this template.
|
||||
title: "[Tracker] "
|
||||
labels:
|
||||
- tracker
|
||||
body:
|
||||
- type: textarea
|
||||
id: content
|
||||
attributes:
|
||||
label: Blockers
|
||||
placeholder: "- [ ] ..."
|
||||
validations:
|
||||
required: true
|
||||
@@ -9,7 +9,7 @@ class PackagingInfo(BaseSettings):
|
||||
|
||||
CURRENT_VERSION: str = Field(
|
||||
description="Dify version",
|
||||
default="1.0.0",
|
||||
default="1.0.1",
|
||||
)
|
||||
|
||||
COMMIT_SHA: str = Field(
|
||||
|
||||
@@ -316,7 +316,7 @@ class AppTraceApi(Resource):
|
||||
@account_initialization_required
|
||||
def post(self, app_id):
|
||||
# add app trace
|
||||
if not current_user.is_admin_or_owner:
|
||||
if not current_user.is_editing_role:
|
||||
raise Forbidden()
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("enabled", type=bool, required=True, location="json")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
from typing import cast
|
||||
|
||||
from flask import abort, request
|
||||
from flask_restful import Resource, inputs, marshal_with, reqparse # type: ignore
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
@@ -13,6 +15,7 @@ from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from extensions.ext_database import db
|
||||
from factories import variable_factory
|
||||
from fields.workflow_fields import workflow_fields, workflow_pagination_fields
|
||||
from fields.workflow_run_fields import workflow_run_node_execution_fields
|
||||
@@ -24,7 +27,7 @@ from models.account import Account
|
||||
from models.model import AppMode
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import WorkflowHashNotEqualError
|
||||
from services.workflow_service import WorkflowService
|
||||
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -439,10 +442,38 @@ class PublishedWorkflowApi(Resource):
|
||||
if not isinstance(current_user, Account):
|
||||
raise Forbidden()
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
workflow = workflow_service.publish_workflow(app_model=app_model, account=current_user)
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("marked_name", type=str, required=False, default="", location="json")
|
||||
parser.add_argument("marked_comment", type=str, required=False, default="", location="json")
|
||||
args = parser.parse_args()
|
||||
|
||||
return {"result": "success", "created_at": TimestampField().format(workflow.created_at)}
|
||||
# Validate name and comment length
|
||||
if args.marked_name and len(args.marked_name) > 20:
|
||||
raise ValueError("Marked name cannot exceed 20 characters")
|
||||
if args.marked_comment and len(args.marked_comment) > 100:
|
||||
raise ValueError("Marked comment cannot exceed 100 characters")
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
with Session(db.engine) as session:
|
||||
workflow = workflow_service.publish_workflow(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
marked_name=args.marked_name or "",
|
||||
marked_comment=args.marked_comment or "",
|
||||
)
|
||||
|
||||
app_model.workflow_id = workflow.id
|
||||
db.session.commit()
|
||||
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
|
||||
session.commit()
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
}
|
||||
|
||||
|
||||
class DefaultBlockConfigsApi(Resource):
|
||||
@@ -564,37 +595,193 @@ class PublishedAllWorkflowApi(Resource):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("page", type=inputs.int_range(1, 99999), required=False, default=1, location="args")
|
||||
parser.add_argument("limit", type=inputs.int_range(1, 100), required=False, default=20, location="args")
|
||||
parser.add_argument("user_id", type=str, required=False, location="args")
|
||||
parser.add_argument("named_only", type=inputs.boolean, required=False, default=False, location="args")
|
||||
args = parser.parse_args()
|
||||
page = args.get("page")
|
||||
limit = args.get("limit")
|
||||
page = int(args.get("page", 1))
|
||||
limit = int(args.get("limit", 10))
|
||||
user_id = args.get("user_id")
|
||||
named_only = args.get("named_only", False)
|
||||
|
||||
if user_id:
|
||||
if user_id != current_user.id:
|
||||
raise Forbidden()
|
||||
user_id = cast(str, user_id)
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(app_model=app_model, page=page, limit=limit)
|
||||
with Session(db.engine) as session:
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
page=page,
|
||||
limit=limit,
|
||||
user_id=user_id,
|
||||
named_only=named_only,
|
||||
)
|
||||
|
||||
return {"items": workflows, "page": page, "limit": limit, "has_more": has_more}
|
||||
return {
|
||||
"items": workflows,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"has_more": has_more,
|
||||
}
|
||||
|
||||
|
||||
api.add_resource(DraftWorkflowApi, "/apps/<uuid:app_id>/workflows/draft")
|
||||
api.add_resource(WorkflowConfigApi, "/apps/<uuid:app_id>/workflows/draft/config")
|
||||
api.add_resource(AdvancedChatDraftWorkflowRunApi, "/apps/<uuid:app_id>/advanced-chat/workflows/draft/run")
|
||||
api.add_resource(DraftWorkflowRunApi, "/apps/<uuid:app_id>/workflows/draft/run")
|
||||
api.add_resource(WorkflowTaskStopApi, "/apps/<uuid:app_id>/workflow-runs/tasks/<string:task_id>/stop")
|
||||
api.add_resource(DraftWorkflowNodeRunApi, "/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/run")
|
||||
class WorkflowByIdApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@marshal_with(workflow_fields)
|
||||
def patch(self, app_model: App, workflow_id: str):
|
||||
"""
|
||||
Update workflow attributes
|
||||
"""
|
||||
# Check permission
|
||||
if not current_user.is_editor:
|
||||
raise Forbidden()
|
||||
|
||||
if not isinstance(current_user, Account):
|
||||
raise Forbidden()
|
||||
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument("marked_name", type=str, required=False, location="json")
|
||||
parser.add_argument("marked_comment", type=str, required=False, location="json")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate name and comment length
|
||||
if args.marked_name and len(args.marked_name) > 20:
|
||||
raise ValueError("Marked name cannot exceed 20 characters")
|
||||
if args.marked_comment and len(args.marked_comment) > 100:
|
||||
raise ValueError("Marked comment cannot exceed 100 characters")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Prepare update data
|
||||
update_data = {}
|
||||
if args.get("marked_name") is not None:
|
||||
update_data["marked_name"] = args["marked_name"]
|
||||
if args.get("marked_comment") is not None:
|
||||
update_data["marked_comment"] = args["marked_comment"]
|
||||
|
||||
if not update_data:
|
||||
return {"message": "No valid fields to update"}, 400
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
# Create a session and manage the transaction
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
workflow = workflow_service.update_workflow(
|
||||
session=session,
|
||||
workflow_id=workflow_id,
|
||||
tenant_id=app_model.tenant_id,
|
||||
account_id=current_user.id,
|
||||
data=update_data,
|
||||
)
|
||||
|
||||
if not workflow:
|
||||
raise NotFound("Workflow not found")
|
||||
|
||||
# Commit the transaction in the controller
|
||||
session.commit()
|
||||
|
||||
return workflow
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def delete(self, app_model: App, workflow_id: str):
|
||||
"""
|
||||
Delete workflow
|
||||
"""
|
||||
# Check permission
|
||||
if not current_user.is_editor:
|
||||
raise Forbidden()
|
||||
|
||||
if not isinstance(current_user, Account):
|
||||
raise Forbidden()
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
# Create a session and manage the transaction
|
||||
with Session(db.engine) as session:
|
||||
try:
|
||||
workflow_service.delete_workflow(
|
||||
session=session, workflow_id=workflow_id, tenant_id=app_model.tenant_id
|
||||
)
|
||||
# Commit the transaction in the controller
|
||||
session.commit()
|
||||
except WorkflowInUseError as e:
|
||||
abort(400, description=str(e))
|
||||
except DraftWorkflowDeletionError as e:
|
||||
abort(400, description=str(e))
|
||||
except ValueError as e:
|
||||
raise NotFound(str(e))
|
||||
|
||||
return None, 204
|
||||
|
||||
|
||||
api.add_resource(
|
||||
DraftWorkflowApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft",
|
||||
)
|
||||
api.add_resource(
|
||||
WorkflowConfigApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/config",
|
||||
)
|
||||
api.add_resource(
|
||||
AdvancedChatDraftWorkflowRunApi,
|
||||
"/apps/<uuid:app_id>/advanced-chat/workflows/draft/run",
|
||||
)
|
||||
api.add_resource(
|
||||
DraftWorkflowRunApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/run",
|
||||
)
|
||||
api.add_resource(
|
||||
WorkflowTaskStopApi,
|
||||
"/apps/<uuid:app_id>/workflow-runs/tasks/<string:task_id>/stop",
|
||||
)
|
||||
api.add_resource(
|
||||
DraftWorkflowNodeRunApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/run",
|
||||
)
|
||||
api.add_resource(
|
||||
AdvancedChatDraftRunIterationNodeApi,
|
||||
"/apps/<uuid:app_id>/advanced-chat/workflows/draft/iteration/nodes/<string:node_id>/run",
|
||||
)
|
||||
api.add_resource(
|
||||
WorkflowDraftRunIterationNodeApi, "/apps/<uuid:app_id>/workflows/draft/iteration/nodes/<string:node_id>/run"
|
||||
WorkflowDraftRunIterationNodeApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/iteration/nodes/<string:node_id>/run",
|
||||
)
|
||||
api.add_resource(
|
||||
AdvancedChatDraftRunLoopNodeApi,
|
||||
"/apps/<uuid:app_id>/advanced-chat/workflows/draft/loop/nodes/<string:node_id>/run",
|
||||
)
|
||||
api.add_resource(WorkflowDraftRunLoopNodeApi, "/apps/<uuid:app_id>/workflows/draft/loop/nodes/<string:node_id>/run")
|
||||
api.add_resource(PublishedWorkflowApi, "/apps/<uuid:app_id>/workflows/publish")
|
||||
api.add_resource(PublishedAllWorkflowApi, "/apps/<uuid:app_id>/workflows")
|
||||
api.add_resource(DefaultBlockConfigsApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs")
|
||||
api.add_resource(
|
||||
DefaultBlockConfigApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs/<string:block_type>"
|
||||
WorkflowDraftRunLoopNodeApi,
|
||||
"/apps/<uuid:app_id>/workflows/draft/loop/nodes/<string:node_id>/run",
|
||||
)
|
||||
api.add_resource(
|
||||
PublishedWorkflowApi,
|
||||
"/apps/<uuid:app_id>/workflows/publish",
|
||||
)
|
||||
api.add_resource(
|
||||
PublishedAllWorkflowApi,
|
||||
"/apps/<uuid:app_id>/workflows",
|
||||
)
|
||||
api.add_resource(
|
||||
DefaultBlockConfigsApi,
|
||||
"/apps/<uuid:app_id>/workflows/default-workflow-block-configs",
|
||||
)
|
||||
api.add_resource(
|
||||
DefaultBlockConfigApi,
|
||||
"/apps/<uuid:app_id>/workflows/default-workflow-block-configs/<string:block_type>",
|
||||
)
|
||||
api.add_resource(
|
||||
ConvertToWorkflowApi,
|
||||
"/apps/<uuid:app_id>/convert-to-workflow",
|
||||
)
|
||||
api.add_resource(
|
||||
WorkflowByIdApi,
|
||||
"/apps/<uuid:app_id>/workflows/<string:workflow_id>",
|
||||
)
|
||||
api.add_resource(ConvertToWorkflowApi, "/apps/<uuid:app_id>/convert-to-workflow")
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
from datetime import datetime
|
||||
|
||||
from flask_restful import Resource, marshal_with, reqparse # type: ignore
|
||||
from flask_restful.inputs import int_range # type: ignore
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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_app_log_fields import workflow_app_log_pagination_fields
|
||||
from libs.login import login_required
|
||||
from models import App
|
||||
from models.model import AppMode
|
||||
from models.workflow import WorkflowRunStatus
|
||||
from services.workflow_app_service import WorkflowAppService
|
||||
|
||||
|
||||
@@ -24,17 +29,38 @@ class WorkflowAppLogApi(Resource):
|
||||
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(
|
||||
"created_at__before", type=str, location="args", help="Filter logs created before this timestamp"
|
||||
)
|
||||
parser.add_argument(
|
||||
"created_at__after", type=str, location="args", help="Filter logs created after this timestamp"
|
||||
)
|
||||
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()
|
||||
|
||||
args.status = WorkflowRunStatus(args.status) if args.status else None
|
||||
if args.created_at__before:
|
||||
args.created_at__before = datetime.fromisoformat(args.created_at__before.replace("Z", "+00:00"))
|
||||
|
||||
if args.created_at__after:
|
||||
args.created_at__after = datetime.fromisoformat(args.created_at__after.replace("Z", "+00:00"))
|
||||
|
||||
# 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
|
||||
)
|
||||
with Session(db.engine) as session:
|
||||
workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
keyword=args.keyword,
|
||||
status=args.status,
|
||||
created_at_before=args.created_at__before,
|
||||
created_at_after=args.created_at__after,
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
)
|
||||
|
||||
return workflow_app_log_pagination
|
||||
return workflow_app_log_pagination
|
||||
|
||||
|
||||
api.add_resource(WorkflowAppLogApi, "/apps/<uuid:app_id>/workflow-app-logs")
|
||||
|
||||
@@ -122,7 +122,7 @@ class DataSourceNotionListApi(Resource):
|
||||
if dataset.data_source_type != "notion_import":
|
||||
raise ValueError("Dataset is not notion type.")
|
||||
|
||||
documents = session.execute(
|
||||
documents = session.scalars(
|
||||
select(Document).filter_by(
|
||||
dataset_id=dataset_id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
|
||||
@@ -10,7 +10,12 @@ from controllers.console import api
|
||||
from controllers.console.apikey import api_key_fields, api_key_list
|
||||
from controllers.console.app.error import ProviderNotInitializeError
|
||||
from controllers.console.datasets.error import DatasetInUseError, DatasetNameDuplicateError, IndexingEstimateError
|
||||
from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_rate_limit_check,
|
||||
enterprise_license_required,
|
||||
setup_required,
|
||||
)
|
||||
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
|
||||
from core.indexing_runner import IndexingRunner
|
||||
from core.model_runtime.entities.model_entities import ModelType
|
||||
@@ -96,6 +101,7 @@ class DatasetListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def post(self):
|
||||
parser = reqparse.RequestParser()
|
||||
parser.add_argument(
|
||||
@@ -210,6 +216,7 @@ class DatasetApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def patch(self, dataset_id):
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str)
|
||||
@@ -276,7 +283,11 @@ class DatasetApi(Resource):
|
||||
data = request.get_json()
|
||||
|
||||
# check embedding model setting
|
||||
if data.get("indexing_technique") == "high_quality":
|
||||
if (
|
||||
data.get("indexing_technique") == "high_quality"
|
||||
and data.get("embedding_model_provider") is not None
|
||||
and data.get("embedding_model") is not None
|
||||
):
|
||||
DatasetService.check_embedding_model_setting(
|
||||
dataset.tenant_id, data.get("embedding_model_provider"), data.get("embedding_model")
|
||||
)
|
||||
@@ -313,6 +324,7 @@ class DatasetApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def delete(self, dataset_id):
|
||||
dataset_id_str = str(dataset_id)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from controllers.console.datasets.error import (
|
||||
)
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_rate_limit_check,
|
||||
cloud_edition_billing_resource_check,
|
||||
setup_required,
|
||||
)
|
||||
@@ -242,6 +243,7 @@ class DatasetDocumentListApi(Resource):
|
||||
@account_initialization_required
|
||||
@marshal_with(documents_and_batch_fields)
|
||||
@cloud_edition_billing_resource_check("vector_space")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def post(self, dataset_id):
|
||||
dataset_id = str(dataset_id)
|
||||
|
||||
@@ -297,6 +299,7 @@ class DatasetDocumentListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def delete(self, dataset_id):
|
||||
dataset_id = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id)
|
||||
@@ -320,6 +323,7 @@ class DatasetInitApi(Resource):
|
||||
@account_initialization_required
|
||||
@marshal_with(dataset_and_document_fields)
|
||||
@cloud_edition_billing_resource_check("vector_space")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def post(self):
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
if not current_user.is_editor:
|
||||
@@ -694,6 +698,7 @@ class DocumentProcessingApi(DocumentResource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def patch(self, dataset_id, document_id, action):
|
||||
dataset_id = str(dataset_id)
|
||||
document_id = str(document_id)
|
||||
@@ -730,6 +735,7 @@ class DocumentDeleteApi(DocumentResource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def delete(self, dataset_id, document_id):
|
||||
dataset_id = str(dataset_id)
|
||||
document_id = str(document_id)
|
||||
@@ -798,6 +804,7 @@ class DocumentStatusApi(DocumentResource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("vector_space")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def patch(self, dataset_id, action):
|
||||
dataset_id = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id)
|
||||
@@ -893,6 +900,7 @@ class DocumentPauseApi(DocumentResource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def patch(self, dataset_id, document_id):
|
||||
"""pause document."""
|
||||
dataset_id = str(dataset_id)
|
||||
@@ -925,6 +933,7 @@ class DocumentRecoverApi(DocumentResource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def patch(self, dataset_id, document_id):
|
||||
"""recover document."""
|
||||
dataset_id = str(dataset_id)
|
||||
@@ -954,6 +963,7 @@ class DocumentRetryApi(DocumentResource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def post(self, dataset_id):
|
||||
"""retry document."""
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from controllers.console.datasets.error import (
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_knowledge_limit_check,
|
||||
cloud_edition_billing_rate_limit_check,
|
||||
cloud_edition_billing_resource_check,
|
||||
setup_required,
|
||||
)
|
||||
@@ -106,6 +107,7 @@ class DatasetDocumentSegmentListApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def delete(self, dataset_id, document_id):
|
||||
# check dataset
|
||||
dataset_id = str(dataset_id)
|
||||
@@ -137,6 +139,7 @@ class DatasetDocumentSegmentApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("vector_space")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def patch(self, dataset_id, document_id, action):
|
||||
dataset_id = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id)
|
||||
@@ -191,6 +194,7 @@ class DatasetDocumentSegmentAddApi(Resource):
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("vector_space")
|
||||
@cloud_edition_billing_knowledge_limit_check("add_segment")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def post(self, dataset_id, document_id):
|
||||
# check dataset
|
||||
dataset_id = str(dataset_id)
|
||||
@@ -240,6 +244,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("vector_space")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def patch(self, dataset_id, document_id, segment_id):
|
||||
# check dataset
|
||||
dataset_id = str(dataset_id)
|
||||
@@ -299,6 +304,7 @@ class DatasetDocumentSegmentUpdateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def delete(self, dataset_id, document_id, segment_id):
|
||||
# check dataset
|
||||
dataset_id = str(dataset_id)
|
||||
@@ -336,6 +342,7 @@ class DatasetDocumentSegmentBatchImportApi(Resource):
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("vector_space")
|
||||
@cloud_edition_billing_knowledge_limit_check("add_segment")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def post(self, dataset_id, document_id):
|
||||
# check dataset
|
||||
dataset_id = str(dataset_id)
|
||||
@@ -402,6 +409,7 @@ class ChildChunkAddApi(Resource):
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("vector_space")
|
||||
@cloud_edition_billing_knowledge_limit_check("add_segment")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def post(self, dataset_id, document_id, segment_id):
|
||||
# check dataset
|
||||
dataset_id = str(dataset_id)
|
||||
@@ -499,6 +507,7 @@ class ChildChunkAddApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("vector_space")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def patch(self, dataset_id, document_id, segment_id):
|
||||
# check dataset
|
||||
dataset_id = str(dataset_id)
|
||||
@@ -542,6 +551,7 @@ class ChildChunkUpdateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def delete(self, dataset_id, document_id, segment_id, child_chunk_id):
|
||||
# check dataset
|
||||
dataset_id = str(dataset_id)
|
||||
@@ -586,6 +596,7 @@ class ChildChunkUpdateApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("vector_space")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def patch(self, dataset_id, document_id, segment_id, child_chunk_id):
|
||||
# check dataset
|
||||
dataset_id = str(dataset_id)
|
||||
|
||||
@@ -2,7 +2,11 @@ from flask_restful import Resource # type: ignore
|
||||
|
||||
from controllers.console import api
|
||||
from controllers.console.datasets.hit_testing_base import DatasetsHitTestingBase
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
cloud_edition_billing_rate_limit_check,
|
||||
setup_required,
|
||||
)
|
||||
from libs.login import login_required
|
||||
|
||||
|
||||
@@ -10,6 +14,7 @@ class HitTestingApi(Resource, DatasetsHitTestingBase):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def post(self, dataset_id):
|
||||
dataset_id_str = str(dataset_id)
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from libs.helper import TimestampField
|
||||
from libs.login import login_required
|
||||
from models.account import Tenant, TenantStatus
|
||||
from services.account_service import TenantService
|
||||
from services.feature_service import FeatureService
|
||||
from services.file_service import FileService
|
||||
from services.workspace_service import WorkspaceService
|
||||
|
||||
@@ -68,6 +69,11 @@ class TenantListApi(Resource):
|
||||
tenants = TenantService.get_join_tenants(current_user)
|
||||
|
||||
for tenant in tenants:
|
||||
features = FeatureService.get_features(tenant.id)
|
||||
if features.billing.enabled:
|
||||
tenant.plan = features.billing.subscription.plan
|
||||
else:
|
||||
tenant.plan = "sandbox"
|
||||
if tenant.id == current_user.current_tenant_id:
|
||||
tenant.current = True # Set current=True for current tenant
|
||||
return {"workspaces": marshal(tenants, tenants_fields)}, 200
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from functools import wraps
|
||||
|
||||
from flask import abort, request
|
||||
@@ -8,6 +9,8 @@ from flask_login import current_user # type: ignore
|
||||
from configs import dify_config
|
||||
from controllers.console.workspace.error import AccountNotInitializedError
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.dataset import RateLimitLog
|
||||
from models.model import DifySetup
|
||||
from services.feature_service import FeatureService, LicenseStatus
|
||||
from services.operation_service import OperationService
|
||||
@@ -67,7 +70,9 @@ def cloud_edition_billing_resource_check(resource: str):
|
||||
elif resource == "apps" and 0 < apps.limit <= apps.size:
|
||||
abort(403, "The number of apps has reached the limit of your subscription.")
|
||||
elif resource == "vector_space" and 0 < vector_space.limit <= vector_space.size:
|
||||
abort(403, "The capacity of the vector space has reached the limit of your subscription.")
|
||||
abort(
|
||||
403, "The capacity of the knowledge storage space has reached the limit of your subscription."
|
||||
)
|
||||
elif resource == "documents" and 0 < documents_upload_quota.limit <= documents_upload_quota.size:
|
||||
# The api of file upload is used in the multiple places,
|
||||
# so we need to check the source of the request from datasets
|
||||
@@ -112,6 +117,41 @@ def cloud_edition_billing_knowledge_limit_check(resource: str):
|
||||
return interceptor
|
||||
|
||||
|
||||
def cloud_edition_billing_rate_limit_check(resource: str):
|
||||
def interceptor(view):
|
||||
@wraps(view)
|
||||
def decorated(*args, **kwargs):
|
||||
if resource == "knowledge":
|
||||
knowledge_rate_limit = FeatureService.get_knowledge_rate_limit(current_user.current_tenant_id)
|
||||
if knowledge_rate_limit.enabled:
|
||||
current_time = int(time.time() * 1000)
|
||||
key = f"rate_limit_{current_user.current_tenant_id}"
|
||||
|
||||
redis_client.zadd(key, {current_time: current_time})
|
||||
|
||||
redis_client.zremrangebyscore(key, 0, current_time - 60000)
|
||||
|
||||
request_count = redis_client.zcard(key)
|
||||
|
||||
if request_count > knowledge_rate_limit.limit:
|
||||
# add ratelimit record
|
||||
rate_limit_log = RateLimitLog(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
subscription_plan=knowledge_rate_limit.subscription_plan,
|
||||
operation="knowledge",
|
||||
)
|
||||
db.session.add(rate_limit_log)
|
||||
db.session.commit()
|
||||
abort(
|
||||
403, "Sorry, you have reached the knowledge base request rate limit of your subscription."
|
||||
)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
return interceptor
|
||||
|
||||
|
||||
def cloud_utm_record(view):
|
||||
@wraps(view)
|
||||
def decorated(*args, **kwargs):
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from flask_restful import Resource, fields, marshal_with, reqparse # type: ignore
|
||||
from flask_restful.inputs import int_range # type: ignore
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
from controllers.service_api import api
|
||||
@@ -25,7 +27,7 @@ from extensions.ext_database import db
|
||||
from fields.workflow_app_log_fields import workflow_app_log_pagination_fields
|
||||
from libs import helper
|
||||
from models.model import App, AppMode, EndUser
|
||||
from models.workflow import WorkflowRun
|
||||
from models.workflow import WorkflowRun, WorkflowRunStatus
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.workflow_app_service import WorkflowAppService
|
||||
|
||||
@@ -125,17 +127,34 @@ class WorkflowAppLogApi(Resource):
|
||||
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("created_at__before", type=str, location="args")
|
||||
parser.add_argument("created_at__after", type=str, 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()
|
||||
|
||||
args.status = WorkflowRunStatus(args.status) if args.status else None
|
||||
if args.created_at__before:
|
||||
args.created_at__before = datetime.fromisoformat(args.created_at__before.replace("Z", "+00:00"))
|
||||
|
||||
if args.created_at__after:
|
||||
args.created_at__after = datetime.fromisoformat(args.created_at__after.replace("Z", "+00:00"))
|
||||
|
||||
# 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
|
||||
)
|
||||
with Session(db.engine) as session:
|
||||
workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
keyword=args.keyword,
|
||||
status=args.status,
|
||||
created_at_before=args.created_at__before,
|
||||
created_at_after=args.created_at__after,
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
)
|
||||
|
||||
return workflow_app_log_pagination
|
||||
return workflow_app_log_pagination
|
||||
|
||||
|
||||
api.add_resource(WorkflowRunApi, "/workflows/run")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import Enum
|
||||
@@ -13,8 +14,10 @@ from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import Forbidden, Unauthorized
|
||||
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from libs.login import _get_user
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantStatus
|
||||
from models.dataset import RateLimitLog
|
||||
from models.model import ApiToken, App, EndUser
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
@@ -139,6 +142,43 @@ def cloud_edition_billing_knowledge_limit_check(resource: str, api_token_type: s
|
||||
return interceptor
|
||||
|
||||
|
||||
def cloud_edition_billing_rate_limit_check(resource: str, api_token_type: str):
|
||||
def interceptor(view):
|
||||
@wraps(view)
|
||||
def decorated(*args, **kwargs):
|
||||
api_token = validate_and_get_api_token(api_token_type)
|
||||
|
||||
if resource == "knowledge":
|
||||
knowledge_rate_limit = FeatureService.get_knowledge_rate_limit(api_token.tenant_id)
|
||||
if knowledge_rate_limit.enabled:
|
||||
current_time = int(time.time() * 1000)
|
||||
key = f"rate_limit_{api_token.tenant_id}"
|
||||
|
||||
redis_client.zadd(key, {current_time: current_time})
|
||||
|
||||
redis_client.zremrangebyscore(key, 0, current_time - 60000)
|
||||
|
||||
request_count = redis_client.zcard(key)
|
||||
|
||||
if request_count > knowledge_rate_limit.limit:
|
||||
# add ratelimit record
|
||||
rate_limit_log = RateLimitLog(
|
||||
tenant_id=api_token.tenant_id,
|
||||
subscription_plan=knowledge_rate_limit.subscription_plan,
|
||||
operation="knowledge",
|
||||
)
|
||||
db.session.add(rate_limit_log)
|
||||
db.session.commit()
|
||||
raise Forbidden(
|
||||
"Sorry, you have reached the knowledge base request rate limit of your subscription."
|
||||
)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
return interceptor
|
||||
|
||||
|
||||
def validate_dataset_token(view=None):
|
||||
def decorator(view):
|
||||
@wraps(view)
|
||||
|
||||
@@ -7,7 +7,6 @@ from json import JSONDecodeError
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import or_
|
||||
|
||||
from constants import HIDDEN_VALUE
|
||||
from core.entities.model_entities import ModelStatus, ModelWithProviderEntity, SimpleModelProviderEntity
|
||||
@@ -180,37 +179,35 @@ class ProviderConfiguration(BaseModel):
|
||||
else [],
|
||||
)
|
||||
|
||||
def _get_custom_provider_credentials(self) -> Provider | None:
|
||||
"""
|
||||
Get custom provider credentials.
|
||||
"""
|
||||
# get provider
|
||||
model_provider_id = ModelProviderID(self.provider.provider)
|
||||
provider_names = [self.provider.provider]
|
||||
if model_provider_id.is_langgenius():
|
||||
provider_names.append(model_provider_id.provider_name)
|
||||
|
||||
provider_record = (
|
||||
db.session.query(Provider)
|
||||
.filter(
|
||||
Provider.tenant_id == self.tenant_id,
|
||||
Provider.provider_type == ProviderType.CUSTOM.value,
|
||||
Provider.provider_name.in_(provider_names),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
return provider_record
|
||||
|
||||
def custom_credentials_validate(self, credentials: dict) -> tuple[Provider | None, dict]:
|
||||
"""
|
||||
Validate custom credentials.
|
||||
:param credentials: provider credentials
|
||||
:return:
|
||||
"""
|
||||
# get provider
|
||||
model_provider_id = ModelProviderID(self.provider.provider)
|
||||
if model_provider_id.is_langgenius():
|
||||
provider_record = (
|
||||
db.session.query(Provider)
|
||||
.filter(
|
||||
Provider.tenant_id == self.tenant_id,
|
||||
Provider.provider_type == ProviderType.CUSTOM.value,
|
||||
or_(
|
||||
Provider.provider_name == model_provider_id.provider_name,
|
||||
Provider.provider_name == self.provider.provider,
|
||||
),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
provider_record = (
|
||||
db.session.query(Provider)
|
||||
.filter(
|
||||
Provider.tenant_id == self.tenant_id,
|
||||
Provider.provider_type == ProviderType.CUSTOM.value,
|
||||
Provider.provider_name == self.provider.provider,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
provider_record = self._get_custom_provider_credentials()
|
||||
|
||||
# Get provider credential secret variables
|
||||
provider_credential_secret_variables = self.extract_secret_variables(
|
||||
@@ -291,18 +288,7 @@ class ProviderConfiguration(BaseModel):
|
||||
:return:
|
||||
"""
|
||||
# get provider
|
||||
provider_record = (
|
||||
db.session.query(Provider)
|
||||
.filter(
|
||||
Provider.tenant_id == self.tenant_id,
|
||||
or_(
|
||||
Provider.provider_name == ModelProviderID(self.provider.provider).plugin_name,
|
||||
Provider.provider_name == self.provider.provider,
|
||||
),
|
||||
Provider.provider_type == ProviderType.CUSTOM.value,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
provider_record = self._get_custom_provider_credentials()
|
||||
|
||||
# delete provider
|
||||
if provider_record:
|
||||
@@ -349,6 +335,33 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
return None
|
||||
|
||||
def _get_custom_model_credentials(
|
||||
self,
|
||||
model_type: ModelType,
|
||||
model: str,
|
||||
) -> ProviderModel | None:
|
||||
"""
|
||||
Get custom model credentials.
|
||||
"""
|
||||
# get provider model
|
||||
model_provider_id = ModelProviderID(self.provider.provider)
|
||||
provider_names = [self.provider.provider]
|
||||
if model_provider_id.is_langgenius():
|
||||
provider_names.append(model_provider_id.provider_name)
|
||||
|
||||
provider_model_record = (
|
||||
db.session.query(ProviderModel)
|
||||
.filter(
|
||||
ProviderModel.tenant_id == self.tenant_id,
|
||||
ProviderModel.provider_name.in_(provider_names),
|
||||
ProviderModel.model_name == model,
|
||||
ProviderModel.model_type == model_type.to_origin_model_type(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
return provider_model_record
|
||||
|
||||
def custom_model_credentials_validate(
|
||||
self, model_type: ModelType, model: str, credentials: dict
|
||||
) -> tuple[ProviderModel | None, dict]:
|
||||
@@ -361,16 +374,7 @@ class ProviderConfiguration(BaseModel):
|
||||
:return:
|
||||
"""
|
||||
# get provider model
|
||||
provider_model_record = (
|
||||
db.session.query(ProviderModel)
|
||||
.filter(
|
||||
ProviderModel.tenant_id == self.tenant_id,
|
||||
ProviderModel.provider_name == self.provider.provider,
|
||||
ProviderModel.model_name == model,
|
||||
ProviderModel.model_type == model_type.to_origin_model_type(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
provider_model_record = self._get_custom_model_credentials(model_type, model)
|
||||
|
||||
# Get provider credential secret variables
|
||||
provider_credential_secret_variables = self.extract_secret_variables(
|
||||
@@ -451,16 +455,7 @@ class ProviderConfiguration(BaseModel):
|
||||
:return:
|
||||
"""
|
||||
# get provider model
|
||||
provider_model_record = (
|
||||
db.session.query(ProviderModel)
|
||||
.filter(
|
||||
ProviderModel.tenant_id == self.tenant_id,
|
||||
ProviderModel.provider_name == self.provider.provider,
|
||||
ProviderModel.model_name == model,
|
||||
ProviderModel.model_type == model_type.to_origin_model_type(),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
provider_model_record = self._get_custom_model_credentials(model_type, model)
|
||||
|
||||
# delete provider model
|
||||
if provider_model_record:
|
||||
@@ -475,6 +470,26 @@ class ProviderConfiguration(BaseModel):
|
||||
|
||||
provider_model_credentials_cache.delete()
|
||||
|
||||
def _get_provider_model_setting(self, model_type: ModelType, model: str) -> ProviderModelSetting | None:
|
||||
"""
|
||||
Get provider model setting.
|
||||
"""
|
||||
model_provider_id = ModelProviderID(self.provider.provider)
|
||||
provider_names = [self.provider.provider]
|
||||
if model_provider_id.is_langgenius():
|
||||
provider_names.append(model_provider_id.provider_name)
|
||||
|
||||
return (
|
||||
db.session.query(ProviderModelSetting)
|
||||
.filter(
|
||||
ProviderModelSetting.tenant_id == self.tenant_id,
|
||||
ProviderModelSetting.provider_name.in_(provider_names),
|
||||
ProviderModelSetting.model_type == model_type.to_origin_model_type(),
|
||||
ProviderModelSetting.model_name == model,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
def enable_model(self, model_type: ModelType, model: str) -> ProviderModelSetting:
|
||||
"""
|
||||
Enable model.
|
||||
@@ -482,16 +497,7 @@ class ProviderConfiguration(BaseModel):
|
||||
:param model: model name
|
||||
:return:
|
||||
"""
|
||||
model_setting = (
|
||||
db.session.query(ProviderModelSetting)
|
||||
.filter(
|
||||
ProviderModelSetting.tenant_id == self.tenant_id,
|
||||
ProviderModelSetting.provider_name == self.provider.provider,
|
||||
ProviderModelSetting.model_type == model_type.to_origin_model_type(),
|
||||
ProviderModelSetting.model_name == model,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
model_setting = self._get_provider_model_setting(model_type, model)
|
||||
|
||||
if model_setting:
|
||||
model_setting.enabled = True
|
||||
@@ -516,16 +522,7 @@ class ProviderConfiguration(BaseModel):
|
||||
:param model: model name
|
||||
:return:
|
||||
"""
|
||||
model_setting = (
|
||||
db.session.query(ProviderModelSetting)
|
||||
.filter(
|
||||
ProviderModelSetting.tenant_id == self.tenant_id,
|
||||
ProviderModelSetting.provider_name == self.provider.provider,
|
||||
ProviderModelSetting.model_type == model_type.to_origin_model_type(),
|
||||
ProviderModelSetting.model_name == model,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
model_setting = self._get_provider_model_setting(model_type, model)
|
||||
|
||||
if model_setting:
|
||||
model_setting.enabled = False
|
||||
@@ -550,13 +547,24 @@ class ProviderConfiguration(BaseModel):
|
||||
:param model: model name
|
||||
:return:
|
||||
"""
|
||||
return self._get_provider_model_setting(model_type, model)
|
||||
|
||||
def _get_load_balancing_config(self, model_type: ModelType, model: str) -> Optional[LoadBalancingModelConfig]:
|
||||
"""
|
||||
Get load balancing config.
|
||||
"""
|
||||
model_provider_id = ModelProviderID(self.provider.provider)
|
||||
provider_names = [self.provider.provider]
|
||||
if model_provider_id.is_langgenius():
|
||||
provider_names.append(model_provider_id.provider_name)
|
||||
|
||||
return (
|
||||
db.session.query(ProviderModelSetting)
|
||||
db.session.query(LoadBalancingModelConfig)
|
||||
.filter(
|
||||
ProviderModelSetting.tenant_id == self.tenant_id,
|
||||
ProviderModelSetting.provider_name == self.provider.provider,
|
||||
ProviderModelSetting.model_type == model_type.to_origin_model_type(),
|
||||
ProviderModelSetting.model_name == model,
|
||||
LoadBalancingModelConfig.tenant_id == self.tenant_id,
|
||||
LoadBalancingModelConfig.provider_name.in_(provider_names),
|
||||
LoadBalancingModelConfig.model_type == model_type.to_origin_model_type(),
|
||||
LoadBalancingModelConfig.model_name == model,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
@@ -568,11 +576,16 @@ class ProviderConfiguration(BaseModel):
|
||||
:param model: model name
|
||||
:return:
|
||||
"""
|
||||
model_provider_id = ModelProviderID(self.provider.provider)
|
||||
provider_names = [self.provider.provider]
|
||||
if model_provider_id.is_langgenius():
|
||||
provider_names.append(model_provider_id.provider_name)
|
||||
|
||||
load_balancing_config_count = (
|
||||
db.session.query(LoadBalancingModelConfig)
|
||||
.filter(
|
||||
LoadBalancingModelConfig.tenant_id == self.tenant_id,
|
||||
LoadBalancingModelConfig.provider_name == self.provider.provider,
|
||||
LoadBalancingModelConfig.provider_name.in_(provider_names),
|
||||
LoadBalancingModelConfig.model_type == model_type.to_origin_model_type(),
|
||||
LoadBalancingModelConfig.model_name == model,
|
||||
)
|
||||
@@ -582,16 +595,7 @@ class ProviderConfiguration(BaseModel):
|
||||
if load_balancing_config_count <= 1:
|
||||
raise ValueError("Model load balancing configuration must be more than 1.")
|
||||
|
||||
model_setting = (
|
||||
db.session.query(ProviderModelSetting)
|
||||
.filter(
|
||||
ProviderModelSetting.tenant_id == self.tenant_id,
|
||||
ProviderModelSetting.provider_name == self.provider.provider,
|
||||
ProviderModelSetting.model_type == model_type.to_origin_model_type(),
|
||||
ProviderModelSetting.model_name == model,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
model_setting = self._get_provider_model_setting(model_type, model)
|
||||
|
||||
if model_setting:
|
||||
model_setting.load_balancing_enabled = True
|
||||
@@ -616,11 +620,16 @@ class ProviderConfiguration(BaseModel):
|
||||
:param model: model name
|
||||
:return:
|
||||
"""
|
||||
model_provider_id = ModelProviderID(self.provider.provider)
|
||||
provider_names = [self.provider.provider]
|
||||
if model_provider_id.is_langgenius():
|
||||
provider_names.append(model_provider_id.provider_name)
|
||||
|
||||
model_setting = (
|
||||
db.session.query(ProviderModelSetting)
|
||||
.filter(
|
||||
ProviderModelSetting.tenant_id == self.tenant_id,
|
||||
ProviderModelSetting.provider_name == self.provider.provider,
|
||||
ProviderModelSetting.provider_name.in_(provider_names),
|
||||
ProviderModelSetting.model_type == model_type.to_origin_model_type(),
|
||||
ProviderModelSetting.model_name == model,
|
||||
)
|
||||
@@ -677,11 +686,16 @@ class ProviderConfiguration(BaseModel):
|
||||
return
|
||||
|
||||
# get preferred provider
|
||||
model_provider_id = ModelProviderID(self.provider.provider)
|
||||
provider_names = [self.provider.provider]
|
||||
if model_provider_id.is_langgenius():
|
||||
provider_names.append(model_provider_id.provider_name)
|
||||
|
||||
preferred_model_provider = (
|
||||
db.session.query(TenantPreferredModelProvider)
|
||||
.filter(
|
||||
TenantPreferredModelProvider.tenant_id == self.tenant_id,
|
||||
TenantPreferredModelProvider.provider_name == self.provider.provider,
|
||||
TenantPreferredModelProvider.provider_name.in_(provider_names),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@@ -88,7 +88,10 @@ class FixedRecursiveCharacterTextSplitter(EnhanceRecursiveCharacterTextSplitter)
|
||||
break
|
||||
# Now that we have the separator, split the text
|
||||
if separator:
|
||||
splits = text.split(separator)
|
||||
if separator == " ":
|
||||
splits = text.split()
|
||||
else:
|
||||
splits = text.split(separator)
|
||||
else:
|
||||
splits = list(text)
|
||||
# Now go merging things, recursively splitting longer texts.
|
||||
|
||||
@@ -129,13 +129,6 @@ class TextSplitter(BaseDocumentTransformer, ABC):
|
||||
while total > self._chunk_overlap or (
|
||||
total + _len + (separator_len if len(current_doc) > 0 else 0) > self._chunk_size and total > 0
|
||||
):
|
||||
if current_doc and len(current_doc[0]) == 0:
|
||||
current_doc = current_doc[1:]
|
||||
continue
|
||||
|
||||
if len(current_doc) == 0:
|
||||
break
|
||||
|
||||
total -= self._length_function([current_doc[0]])[0] + (
|
||||
separator_len if len(current_doc) > 1 else 0
|
||||
)
|
||||
|
||||
@@ -179,6 +179,18 @@ class ApiTool(Tool):
|
||||
for content_type in self.api_bundle.openapi["requestBody"]["content"]:
|
||||
headers["Content-Type"] = content_type
|
||||
body_schema = self.api_bundle.openapi["requestBody"]["content"][content_type]["schema"]
|
||||
|
||||
# handle ref schema
|
||||
if "$ref" in body_schema:
|
||||
ref_path = body_schema["$ref"].split("/")
|
||||
ref_name = ref_path[-1]
|
||||
if (
|
||||
"components" in self.api_bundle.openapi
|
||||
and "schemas" in self.api_bundle.openapi["components"]
|
||||
):
|
||||
if ref_name in self.api_bundle.openapi["components"]["schemas"]:
|
||||
body_schema = self.api_bundle.openapi["components"]["schemas"][ref_name]
|
||||
|
||||
required = body_schema.get("required", [])
|
||||
properties = body_schema.get("properties", {})
|
||||
for name, property in properties.items():
|
||||
@@ -186,6 +198,8 @@ class ApiTool(Tool):
|
||||
if property.get("format") == "binary":
|
||||
f = parameters[name]
|
||||
files.append((name, (f.filename, download(f), f.mime_type)))
|
||||
elif "$ref" in property:
|
||||
body[name] = parameters[name]
|
||||
else:
|
||||
# convert type
|
||||
body[name] = self._convert_body_property_type(property, parameters[name])
|
||||
|
||||
@@ -765,17 +765,22 @@ class ToolManager:
|
||||
|
||||
@classmethod
|
||||
def generate_builtin_tool_icon_url(cls, provider_id: str) -> str:
|
||||
return (
|
||||
dify_config.CONSOLE_API_URL
|
||||
+ "/console/api/workspaces/current/tool-provider/builtin/"
|
||||
+ provider_id
|
||||
+ "/icon"
|
||||
return str(
|
||||
URL(dify_config.CONSOLE_API_URL or "/")
|
||||
/ "console"
|
||||
/ "api"
|
||||
/ "workspaces"
|
||||
/ "current"
|
||||
/ "tool-provider"
|
||||
/ "builtin"
|
||||
/ provider_id
|
||||
/ "icon"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def generate_plugin_tool_icon_url(cls, tenant_id: str, filename: str) -> str:
|
||||
return str(
|
||||
URL(dify_config.CONSOLE_API_URL)
|
||||
URL(dify_config.CONSOLE_API_URL or "/")
|
||||
/ "console"
|
||||
/ "api"
|
||||
/ "workspaces"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -19,8 +20,10 @@ from core.workflow.entities.node_entities import NodeRunResult
|
||||
from core.workflow.nodes.base import BaseNode
|
||||
from core.workflow.nodes.enums import NodeType
|
||||
from extensions.ext_database import db
|
||||
from models.dataset import Dataset, Document
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.dataset import Dataset, Document, RateLimitLog
|
||||
from models.workflow import WorkflowNodeExecutionStatus
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
from .entities import KnowledgeRetrievalNodeData
|
||||
from .exc import (
|
||||
@@ -61,6 +64,31 @@ class KnowledgeRetrievalNode(BaseNode[KnowledgeRetrievalNodeData]):
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.FAILED, inputs=variables, error="Query is required."
|
||||
)
|
||||
# check rate limit
|
||||
if self.tenant_id:
|
||||
knowledge_rate_limit = FeatureService.get_knowledge_rate_limit(self.tenant_id)
|
||||
if knowledge_rate_limit.enabled:
|
||||
current_time = int(time.time() * 1000)
|
||||
key = f"rate_limit_{self.tenant_id}"
|
||||
redis_client.zadd(key, {current_time: current_time})
|
||||
redis_client.zremrangebyscore(key, 0, current_time - 60000)
|
||||
request_count = redis_client.zcard(key)
|
||||
if request_count > knowledge_rate_limit.limit:
|
||||
# add ratelimit record
|
||||
rate_limit_log = RateLimitLog(
|
||||
tenant_id=self.tenant_id,
|
||||
subscription_plan=knowledge_rate_limit.subscription_plan,
|
||||
operation="knowledge",
|
||||
)
|
||||
db.session.add(rate_limit_log)
|
||||
db.session.commit()
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.FAILED,
|
||||
inputs=variables,
|
||||
error="Sorry, you have reached the knowledge base request rate limit of your subscription.",
|
||||
error_type="RateLimitExceeded",
|
||||
)
|
||||
|
||||
# retrieve knowledge
|
||||
try:
|
||||
results = self._fetch_dataset_retriever(node_data=self.node_data, query=query)
|
||||
|
||||
@@ -94,6 +94,9 @@ class LLMNode(BaseNode[LLMNodeData]):
|
||||
def _run(self) -> Generator[NodeEvent | InNodeEvent, None, None]:
|
||||
node_inputs: Optional[dict[str, Any]] = None
|
||||
process_data = None
|
||||
result_text = ""
|
||||
usage = LLMUsage.empty_usage()
|
||||
finish_reason = None
|
||||
|
||||
try:
|
||||
# init messages template
|
||||
@@ -178,9 +181,6 @@ class LLMNode(BaseNode[LLMNodeData]):
|
||||
stop=stop,
|
||||
)
|
||||
|
||||
result_text = ""
|
||||
usage = LLMUsage.empty_usage()
|
||||
finish_reason = None
|
||||
for event in generator:
|
||||
if isinstance(event, RunStreamChunkEvent):
|
||||
yield event
|
||||
|
||||
@@ -270,7 +270,9 @@ class ToolNode(BaseNode[ToolNodeData]):
|
||||
if self.node_type == NodeType.AGENT:
|
||||
msg_metadata = message.message.json_object.pop("execution_metadata", {})
|
||||
agent_execution_metadata = {
|
||||
key: value for key, value in msg_metadata.items() if key in NodeRunMetadataKey
|
||||
key: value
|
||||
for key, value in msg_metadata.items()
|
||||
if key in NodeRunMetadataKey.__members__.values()
|
||||
}
|
||||
json.append(message.message.json_object)
|
||||
elif message.type == ToolInvokeMessage.MessageType.LINK:
|
||||
|
||||
@@ -32,11 +32,7 @@ class AwsS3Storage(BaseStorage):
|
||||
aws_access_key_id=dify_config.S3_ACCESS_KEY,
|
||||
endpoint_url=dify_config.S3_ENDPOINT,
|
||||
region_name=dify_config.S3_REGION,
|
||||
config=Config(
|
||||
s3={"addressing_style": dify_config.S3_ADDRESS_STYLE},
|
||||
request_checksum_calculation="when_required",
|
||||
response_checksum_validation="when_required",
|
||||
),
|
||||
config=Config(s3={"addressing_style": dify_config.S3_ADDRESS_STYLE}),
|
||||
)
|
||||
# create bucket
|
||||
try:
|
||||
|
||||
@@ -17,8 +17,8 @@ workflow_app_log_partial_fields = {
|
||||
|
||||
workflow_app_log_pagination_fields = {
|
||||
"page": fields.Integer,
|
||||
"limit": fields.Integer(attribute="per_page"),
|
||||
"limit": fields.Integer,
|
||||
"total": fields.Integer,
|
||||
"has_more": fields.Boolean(attribute="has_next"),
|
||||
"data": fields.List(fields.Nested(workflow_app_log_partial_fields), attribute="items"),
|
||||
"has_more": fields.Boolean,
|
||||
"data": fields.List(fields.Nested(workflow_app_log_partial_fields)),
|
||||
}
|
||||
|
||||
@@ -45,7 +45,9 @@ workflow_fields = {
|
||||
"graph": fields.Raw(attribute="graph_dict"),
|
||||
"features": fields.Raw(attribute="features_dict"),
|
||||
"hash": fields.String(attribute="unique_hash"),
|
||||
"version": fields.String(attribute="version"),
|
||||
"version": fields.String,
|
||||
"marked_name": fields.String,
|
||||
"marked_comment": fields.String,
|
||||
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account"),
|
||||
"created_at": TimestampField,
|
||||
"updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""add_rate_limit_logs
|
||||
|
||||
Revision ID: f051706725cc
|
||||
Revises: 923752d42eb6
|
||||
Create Date: 2025-01-14 06:17:35.536388
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'f051706725cc'
|
||||
down_revision = 'ee79d9b1c156'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('rate_limit_logs',
|
||||
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
|
||||
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
|
||||
sa.Column('subscription_plan', sa.String(length=255), nullable=False),
|
||||
sa.Column('operation', sa.String(length=255), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP(0)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name='rate_limit_log_pkey')
|
||||
)
|
||||
with op.batch_alter_table('rate_limit_logs', schema=None) as batch_op:
|
||||
batch_op.create_index('rate_limit_log_operation_idx', ['operation'], unique=False)
|
||||
batch_op.create_index('rate_limit_log_tenant_idx', ['tenant_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
with op.batch_alter_table('rate_limit_logs', schema=None) as batch_op:
|
||||
batch_op.drop_index('rate_limit_log_tenant_idx')
|
||||
batch_op.drop_index('rate_limit_log_operation_idx')
|
||||
|
||||
op.drop_table('rate_limit_logs')
|
||||
# ### end Alembic commands ###
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
"""add marked_name and marked_comment in workflows
|
||||
|
||||
Revision ID: ee79d9b1c156
|
||||
Revises: 4413929e1ec2
|
||||
Create Date: 2025-03-03 14:36:05.750346
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import models as models
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'ee79d9b1c156'
|
||||
down_revision = '5511c782ee4c'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table('workflows', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('marked_name', sa.String(), nullable=False, server_default=''))
|
||||
batch_op.add_column(sa.Column('marked_comment', sa.String(), nullable=False, server_default=''))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table('workflows', schema=None) as batch_op:
|
||||
batch_op.drop_column('marked_comment')
|
||||
batch_op.drop_column('marked_name')
|
||||
@@ -930,3 +930,18 @@ class DatasetAutoDisableLog(db.Model): # type: ignore[name-defined]
|
||||
document_id = db.Column(StringUUID, nullable=False)
|
||||
notified = db.Column(db.Boolean, nullable=False, server_default=db.text("false"))
|
||||
created_at = db.Column(db.DateTime, nullable=False, server_default=db.text("CURRENT_TIMESTAMP(0)"))
|
||||
|
||||
|
||||
class RateLimitLog(db.Model): # type: ignore[name-defined]
|
||||
__tablename__ = "rate_limit_logs"
|
||||
__table_args__ = (
|
||||
db.PrimaryKeyConstraint("id", name="rate_limit_log_pkey"),
|
||||
db.Index("rate_limit_log_tenant_idx", "tenant_id"),
|
||||
db.Index("rate_limit_log_operation_idx", "operation"),
|
||||
)
|
||||
|
||||
id = db.Column(StringUUID, server_default=db.text("uuid_generate_v4()"))
|
||||
tenant_id = db.Column(StringUUID, nullable=False)
|
||||
subscription_plan = db.Column(db.String(255), nullable=False)
|
||||
operation = db.Column(db.String(255), nullable=False)
|
||||
created_at = db.Column(db.DateTime, nullable=False, server_default=db.text("CURRENT_TIMESTAMP(0)"))
|
||||
|
||||
+1
-1
@@ -257,7 +257,7 @@ class App(Base):
|
||||
provider_id = tool.get("provider_id", "")
|
||||
|
||||
if provider_type == ToolProviderType.API.value:
|
||||
if provider_id not in existing_api_providers:
|
||||
if uuid.UUID(provider_id) not in existing_api_providers:
|
||||
deleted_tools.append(
|
||||
{
|
||||
"type": ToolProviderType.API.value,
|
||||
|
||||
+27
-27
@@ -2,7 +2,8 @@ import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import UTC, datetime
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Optional, Union
|
||||
from typing import TYPE_CHECKING, Any, Optional, Self, Union
|
||||
from uuid import uuid4
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.model import AppMode
|
||||
@@ -108,7 +109,9 @@ class Workflow(Base):
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
type: Mapped[str] = mapped_column(db.String(255), nullable=False)
|
||||
version: Mapped[str] = mapped_column(db.String(255), nullable=False)
|
||||
version: Mapped[str]
|
||||
marked_name: Mapped[str] = mapped_column(default="", server_default="")
|
||||
marked_comment: Mapped[str] = mapped_column(default="", server_default="")
|
||||
graph: Mapped[str] = mapped_column(sa.Text)
|
||||
_features: Mapped[str] = mapped_column("features", sa.TEXT)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
@@ -127,8 +130,9 @@ class Workflow(Base):
|
||||
"conversation_variables", db.Text, nullable=False, server_default="{}"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@classmethod
|
||||
def new(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
@@ -139,16 +143,25 @@ class Workflow(Base):
|
||||
created_by: str,
|
||||
environment_variables: Sequence[Variable],
|
||||
conversation_variables: Sequence[Variable],
|
||||
):
|
||||
self.tenant_id = tenant_id
|
||||
self.app_id = app_id
|
||||
self.type = type
|
||||
self.version = version
|
||||
self.graph = graph
|
||||
self.features = features
|
||||
self.created_by = created_by
|
||||
self.environment_variables = environment_variables or []
|
||||
self.conversation_variables = conversation_variables or []
|
||||
marked_name: str = "",
|
||||
marked_comment: str = "",
|
||||
) -> Self:
|
||||
workflow = Workflow()
|
||||
workflow.id = str(uuid4())
|
||||
workflow.tenant_id = tenant_id
|
||||
workflow.app_id = app_id
|
||||
workflow.type = type
|
||||
workflow.version = version
|
||||
workflow.graph = graph
|
||||
workflow.features = features
|
||||
workflow.created_by = created_by
|
||||
workflow.environment_variables = environment_variables or []
|
||||
workflow.conversation_variables = conversation_variables or []
|
||||
workflow.marked_name = marked_name
|
||||
workflow.marked_comment = marked_comment
|
||||
workflow.created_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
workflow.updated_at = workflow.created_at
|
||||
return workflow
|
||||
|
||||
@property
|
||||
def created_by_account(self):
|
||||
@@ -341,19 +354,6 @@ class WorkflowRunStatus(StrEnum):
|
||||
STOPPED = "stopped"
|
||||
PARTIAL_SUCCESSED = "partial-succeeded"
|
||||
|
||||
@classmethod
|
||||
def value_of(cls, value: str) -> "WorkflowRunStatus":
|
||||
"""
|
||||
Get value of given mode.
|
||||
|
||||
:param value: mode value
|
||||
:return: mode
|
||||
"""
|
||||
for mode in cls:
|
||||
if mode.value == value:
|
||||
return mode
|
||||
raise ValueError(f"invalid workflow run status value {value}")
|
||||
|
||||
|
||||
class WorkflowRun(Base):
|
||||
"""
|
||||
|
||||
Generated
+1184
-822
File diff suppressed because it is too large
Load Diff
@@ -151,6 +151,7 @@ pytest-benchmark = "~4.0.0"
|
||||
pytest-env = "~1.1.3"
|
||||
pytest-mock = "~3.14.0"
|
||||
types-beautifulsoup4 = "~4.12.0.20241020"
|
||||
types-deprecated = "~1.2.15.20250304"
|
||||
types-flask-cors = "~5.0.0.20240902"
|
||||
types-flask-migrate = "~4.1.0.20250112"
|
||||
types-html5lib = "~1.1.11.20241018"
|
||||
|
||||
@@ -22,6 +22,17 @@ class BillingService:
|
||||
billing_info = cls._send_request("GET", "/subscription/info", params=params)
|
||||
return billing_info
|
||||
|
||||
@classmethod
|
||||
def get_knowledge_rate_limit(cls, tenant_id: str):
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
knowledge_rate_limit = cls._send_request("GET", "/subscription/knowledge-rate-limit", params=params)
|
||||
|
||||
return {
|
||||
"limit": knowledge_rate_limit.get("limit", 10),
|
||||
"subscription_plan": knowledge_rate_limit.get("subscription_plan", "sandbox"),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_subscription(cls, plan: str, interval: str, prefilled_email: str = "", tenant_id: str = ""):
|
||||
params = {"plan": plan, "interval": interval, "prefilled_email": prefilled_email, "tenant_id": tenant_id}
|
||||
|
||||
@@ -245,7 +245,7 @@ class DatasetService:
|
||||
"No Embedding Model available. Please configure a valid provider in the Settings -> Model Provider."
|
||||
)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ValueError(f"The dataset in unavailable, due to: {ex.description}")
|
||||
raise ValueError(ex.description)
|
||||
|
||||
@staticmethod
|
||||
def update_dataset(dataset_id, data, user):
|
||||
@@ -327,31 +327,75 @@ class DatasetService:
|
||||
raise ValueError(ex.description)
|
||||
else:
|
||||
# add default plugin id to both setting sets, to make sure the plugin model provider is consistent
|
||||
plugin_model_provider = dataset.embedding_model_provider
|
||||
plugin_model_provider = str(ModelProviderID(plugin_model_provider))
|
||||
|
||||
new_plugin_model_provider = data["embedding_model_provider"]
|
||||
new_plugin_model_provider = str(ModelProviderID(new_plugin_model_provider))
|
||||
|
||||
# Skip embedding model checks if not provided in the update request
|
||||
if (
|
||||
new_plugin_model_provider != plugin_model_provider
|
||||
or data["embedding_model"] != dataset.embedding_model
|
||||
"embedding_model_provider" not in data
|
||||
or "embedding_model" not in data
|
||||
or not data.get("embedding_model_provider")
|
||||
or not data.get("embedding_model")
|
||||
):
|
||||
action = "update"
|
||||
# If the dataset already has embedding model settings, use those
|
||||
if dataset.embedding_model_provider and dataset.embedding_model:
|
||||
# Keep existing values
|
||||
filtered_data["embedding_model_provider"] = dataset.embedding_model_provider
|
||||
filtered_data["embedding_model"] = dataset.embedding_model
|
||||
# If collection_binding_id exists, keep it too
|
||||
if dataset.collection_binding_id:
|
||||
filtered_data["collection_binding_id"] = dataset.collection_binding_id
|
||||
# Otherwise, don't try to update embedding model settings at all
|
||||
# Remove these fields from filtered_data if they exist but are None/empty
|
||||
if "embedding_model_provider" in filtered_data and not filtered_data["embedding_model_provider"]:
|
||||
del filtered_data["embedding_model_provider"]
|
||||
if "embedding_model" in filtered_data and not filtered_data["embedding_model"]:
|
||||
del filtered_data["embedding_model"]
|
||||
else:
|
||||
skip_embedding_update = False
|
||||
try:
|
||||
model_manager = ModelManager()
|
||||
embedding_model = model_manager.get_model_instance(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
provider=data["embedding_model_provider"],
|
||||
model_type=ModelType.TEXT_EMBEDDING,
|
||||
model=data["embedding_model"],
|
||||
)
|
||||
filtered_data["embedding_model"] = embedding_model.model
|
||||
filtered_data["embedding_model_provider"] = embedding_model.provider
|
||||
dataset_collection_binding = DatasetCollectionBindingService.get_dataset_collection_binding(
|
||||
embedding_model.provider, embedding_model.model
|
||||
)
|
||||
filtered_data["collection_binding_id"] = dataset_collection_binding.id
|
||||
# Handle existing model provider
|
||||
plugin_model_provider = dataset.embedding_model_provider
|
||||
plugin_model_provider_str = None
|
||||
if plugin_model_provider:
|
||||
plugin_model_provider_str = str(ModelProviderID(plugin_model_provider))
|
||||
|
||||
# Handle new model provider from request
|
||||
new_plugin_model_provider = data["embedding_model_provider"]
|
||||
new_plugin_model_provider_str = None
|
||||
if new_plugin_model_provider:
|
||||
new_plugin_model_provider_str = str(ModelProviderID(new_plugin_model_provider))
|
||||
|
||||
# Only update embedding model if both values are provided and different from current
|
||||
if (
|
||||
plugin_model_provider_str != new_plugin_model_provider_str
|
||||
or data["embedding_model"] != dataset.embedding_model
|
||||
):
|
||||
action = "update"
|
||||
model_manager = ModelManager()
|
||||
try:
|
||||
embedding_model = model_manager.get_model_instance(
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
provider=data["embedding_model_provider"],
|
||||
model_type=ModelType.TEXT_EMBEDDING,
|
||||
model=data["embedding_model"],
|
||||
)
|
||||
except ProviderTokenNotInitError:
|
||||
# If we can't get the embedding model, skip updating it
|
||||
# and keep the existing settings if available
|
||||
if dataset.embedding_model_provider and dataset.embedding_model:
|
||||
filtered_data["embedding_model_provider"] = dataset.embedding_model_provider
|
||||
filtered_data["embedding_model"] = dataset.embedding_model
|
||||
if dataset.collection_binding_id:
|
||||
filtered_data["collection_binding_id"] = dataset.collection_binding_id
|
||||
# Skip the rest of the embedding model update
|
||||
skip_embedding_update = True
|
||||
if not skip_embedding_update:
|
||||
filtered_data["embedding_model"] = embedding_model.model
|
||||
filtered_data["embedding_model_provider"] = embedding_model.provider
|
||||
dataset_collection_binding = (
|
||||
DatasetCollectionBindingService.get_dataset_collection_binding(
|
||||
embedding_model.provider, embedding_model.model
|
||||
)
|
||||
)
|
||||
filtered_data["collection_binding_id"] = dataset_collection_binding.id
|
||||
except LLMBadRequestError:
|
||||
raise ValueError(
|
||||
"No Embedding Model available. Please configure a valid provider "
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
class WorkflowInUseError(ValueError):
|
||||
"""Raised when attempting to delete a workflow that's in use by an app"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class DraftWorkflowDeletionError(ValueError):
|
||||
"""Raised when attempting to delete a draft workflow"""
|
||||
|
||||
pass
|
||||
@@ -41,6 +41,7 @@ class FeatureModel(BaseModel):
|
||||
members: LimitationModel = LimitationModel(size=0, limit=1)
|
||||
apps: LimitationModel = LimitationModel(size=0, limit=10)
|
||||
vector_space: LimitationModel = LimitationModel(size=0, limit=5)
|
||||
knowledge_rate_limit: int = 10
|
||||
annotation_quota_limit: LimitationModel = LimitationModel(size=0, limit=10)
|
||||
documents_upload_quota: LimitationModel = LimitationModel(size=0, limit=50)
|
||||
docs_processing: str = "standard"
|
||||
@@ -52,6 +53,12 @@ class FeatureModel(BaseModel):
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
|
||||
class KnowledgeRateLimitModel(BaseModel):
|
||||
enabled: bool = False
|
||||
limit: int = 10
|
||||
subscription_plan: str = ""
|
||||
|
||||
|
||||
class SystemFeatureModel(BaseModel):
|
||||
sso_enforced_for_signin: bool = False
|
||||
sso_enforced_for_signin_protocol: str = ""
|
||||
@@ -81,6 +88,16 @@ class FeatureService:
|
||||
|
||||
return features
|
||||
|
||||
@classmethod
|
||||
def get_knowledge_rate_limit(cls, tenant_id: str):
|
||||
knowledge_rate_limit = KnowledgeRateLimitModel()
|
||||
if dify_config.BILLING_ENABLED and tenant_id:
|
||||
knowledge_rate_limit.enabled = True
|
||||
limit_info = BillingService.get_knowledge_rate_limit(tenant_id)
|
||||
knowledge_rate_limit.limit = limit_info.get("limit", 10)
|
||||
knowledge_rate_limit.subscription_plan = limit_info.get("subscription_plan", "sandbox")
|
||||
return knowledge_rate_limit
|
||||
|
||||
@classmethod
|
||||
def get_system_features(cls) -> SystemFeatureModel:
|
||||
system_features = SystemFeatureModel()
|
||||
@@ -149,6 +166,9 @@ class FeatureService:
|
||||
if "model_load_balancing_enabled" in billing_info:
|
||||
features.model_load_balancing_enabled = billing_info["model_load_balancing_enabled"]
|
||||
|
||||
if "knowledge_rate_limit" in billing_info:
|
||||
features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"]
|
||||
|
||||
@classmethod
|
||||
def _fulfill_params_from_enterprise(cls, features):
|
||||
enterprise_info = EnterpriseService.get_info()
|
||||
|
||||
@@ -29,7 +29,9 @@ logger = logging.getLogger(__name__)
|
||||
class ToolTransformService:
|
||||
@classmethod
|
||||
def get_plugin_icon_url(cls, tenant_id: str, filename: str) -> str:
|
||||
url_prefix = URL(dify_config.CONSOLE_API_URL) / "console" / "api" / "workspaces" / "current" / "plugin" / "icon"
|
||||
url_prefix = (
|
||||
URL(dify_config.CONSOLE_API_URL or "/") / "console" / "api" / "workspaces" / "current" / "plugin" / "icon"
|
||||
)
|
||||
return str(url_prefix % {"tenant_id": tenant_id, "filename": filename})
|
||||
|
||||
@classmethod
|
||||
@@ -37,7 +39,9 @@ class ToolTransformService:
|
||||
"""
|
||||
get tool provider icon url
|
||||
"""
|
||||
url_prefix = URL(dify_config.CONSOLE_API_URL) / "console" / "api" / "workspaces" / "current" / "tool-provider"
|
||||
url_prefix = (
|
||||
URL(dify_config.CONSOLE_API_URL or "/") / "console" / "api" / "workspaces" / "current" / "tool-provider"
|
||||
)
|
||||
|
||||
if provider_type == ToolProviderType.BUILT_IN.value:
|
||||
return str(url_prefix / "builtin" / provider_name / "icon")
|
||||
|
||||
@@ -1,30 +1,46 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from flask_sqlalchemy.pagination import Pagination
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models import App, EndUser, WorkflowAppLog, WorkflowRun
|
||||
from models.enums import CreatedByRole
|
||||
from models.workflow import WorkflowRunStatus
|
||||
|
||||
|
||||
class WorkflowAppService:
|
||||
def get_paginate_workflow_app_logs(self, app_model: App, args: dict) -> Pagination:
|
||||
def get_paginate_workflow_app_logs(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
app_model: App,
|
||||
keyword: str | None = None,
|
||||
status: WorkflowRunStatus | None = None,
|
||||
created_at_before: datetime | None = None,
|
||||
created_at_after: datetime | None = None,
|
||||
page: int = 1,
|
||||
limit: int = 20,
|
||||
) -> dict:
|
||||
"""
|
||||
Get paginate workflow app logs
|
||||
:param app: app model
|
||||
:param args: request args
|
||||
:return:
|
||||
Get paginate workflow app logs using SQLAlchemy 2.0 style
|
||||
:param session: SQLAlchemy session
|
||||
:param app_model: app model
|
||||
:param keyword: search keyword
|
||||
:param status: filter by status
|
||||
:param created_at_before: filter logs created before this timestamp
|
||||
:param created_at_after: filter logs created after this timestamp
|
||||
:param page: page number
|
||||
:param limit: items per page
|
||||
:return: Pagination object
|
||||
"""
|
||||
query = db.select(WorkflowAppLog).where(
|
||||
# Build base statement using SQLAlchemy 2.0 style
|
||||
stmt = 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
|
||||
keyword = args["keyword"]
|
||||
if keyword or status:
|
||||
query = query.join(WorkflowRun, WorkflowRun.id == WorkflowAppLog.workflow_run_id)
|
||||
stmt = stmt.join(WorkflowRun, WorkflowRun.id == WorkflowAppLog.workflow_run_id)
|
||||
|
||||
if keyword:
|
||||
keyword_like_val = f"%{keyword[:30].encode('unicode_escape').decode('utf-8')}%".replace(r"\u", r"\\u")
|
||||
@@ -40,20 +56,40 @@ class WorkflowAppService:
|
||||
if keyword_uuid:
|
||||
keyword_conditions.append(WorkflowRun.id == keyword_uuid)
|
||||
|
||||
query = query.outerjoin(
|
||||
stmt = stmt.outerjoin(
|
||||
EndUser,
|
||||
and_(WorkflowRun.created_by == EndUser.id, WorkflowRun.created_by_role == CreatedByRole.END_USER),
|
||||
).filter(or_(*keyword_conditions))
|
||||
).where(or_(*keyword_conditions))
|
||||
|
||||
if status:
|
||||
# join with workflow_run and filter by status
|
||||
query = query.filter(WorkflowRun.status == status.value)
|
||||
stmt = stmt.where(WorkflowRun.status == status)
|
||||
|
||||
query = query.order_by(WorkflowAppLog.created_at.desc())
|
||||
# Add time-based filtering
|
||||
if created_at_before:
|
||||
stmt = stmt.where(WorkflowAppLog.created_at <= created_at_before)
|
||||
|
||||
pagination = db.paginate(query, page=args["page"], per_page=args["limit"], error_out=False)
|
||||
if created_at_after:
|
||||
stmt = stmt.where(WorkflowAppLog.created_at >= created_at_after)
|
||||
|
||||
return pagination
|
||||
stmt = stmt.order_by(WorkflowAppLog.created_at.desc())
|
||||
|
||||
# Get total count using the same filters
|
||||
count_stmt = select(func.count()).select_from(stmt.subquery())
|
||||
total = session.scalar(count_stmt) or 0
|
||||
|
||||
# Apply pagination limits
|
||||
offset_stmt = stmt.offset((page - 1) * limit).limit(limit)
|
||||
|
||||
# Execute query and get items
|
||||
items = list(session.scalars(offset_stmt).all())
|
||||
|
||||
return {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"has_more": total > page * limit,
|
||||
"data": items,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _safe_parse_uuid(value: str):
|
||||
|
||||
@@ -5,7 +5,8 @@ from datetime import UTC, datetime
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
|
||||
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
|
||||
@@ -36,6 +37,8 @@ from models.workflow import (
|
||||
from services.errors.app import WorkflowHashNotEqualError
|
||||
from services.workflow.workflow_converter import WorkflowConverter
|
||||
|
||||
from .errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
|
||||
|
||||
|
||||
class WorkflowService:
|
||||
"""
|
||||
@@ -79,22 +82,38 @@ class WorkflowService:
|
||||
|
||||
return workflow
|
||||
|
||||
def get_all_published_workflow(self, app_model: App, page: int, limit: int) -> tuple[list[Workflow], bool]:
|
||||
def get_all_published_workflow(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
app_model: App,
|
||||
page: int,
|
||||
limit: int,
|
||||
user_id: str | None,
|
||||
named_only: bool = False,
|
||||
) -> tuple[Sequence[Workflow], bool]:
|
||||
"""
|
||||
Get published workflow with pagination
|
||||
"""
|
||||
if not app_model.workflow_id:
|
||||
return [], False
|
||||
|
||||
workflows = (
|
||||
db.session.query(Workflow)
|
||||
.filter(Workflow.app_id == app_model.id)
|
||||
.order_by(desc(Workflow.version))
|
||||
.offset((page - 1) * limit)
|
||||
stmt = (
|
||||
select(Workflow)
|
||||
.where(Workflow.app_id == app_model.id)
|
||||
.order_by(Workflow.version.desc())
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
.offset((page - 1) * limit)
|
||||
)
|
||||
|
||||
if user_id:
|
||||
stmt = stmt.where(Workflow.created_by == user_id)
|
||||
|
||||
if named_only:
|
||||
stmt = stmt.where(Workflow.marked_name != "")
|
||||
|
||||
workflows = session.scalars(stmt).all()
|
||||
|
||||
has_more = len(workflows) > limit
|
||||
if has_more:
|
||||
workflows = workflows[:-1]
|
||||
@@ -157,23 +176,26 @@ class WorkflowService:
|
||||
# return draft workflow
|
||||
return workflow
|
||||
|
||||
def publish_workflow(self, app_model: App, account: Account, draft_workflow: Optional[Workflow] = None) -> Workflow:
|
||||
"""
|
||||
Publish workflow from draft
|
||||
|
||||
:param app_model: App instance
|
||||
:param account: Account instance
|
||||
:param draft_workflow: Workflow instance
|
||||
"""
|
||||
if not draft_workflow:
|
||||
# fetch draft workflow by app_model
|
||||
draft_workflow = self.get_draft_workflow(app_model=app_model)
|
||||
|
||||
def publish_workflow(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
app_model: App,
|
||||
account: Account,
|
||||
marked_name: str = "",
|
||||
marked_comment: str = "",
|
||||
) -> Workflow:
|
||||
draft_workflow_stmt = select(Workflow).where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
Workflow.app_id == app_model.id,
|
||||
Workflow.version == "draft",
|
||||
)
|
||||
draft_workflow = session.scalar(draft_workflow_stmt)
|
||||
if not draft_workflow:
|
||||
raise ValueError("No valid workflow found.")
|
||||
|
||||
# create new workflow
|
||||
workflow = Workflow(
|
||||
workflow = Workflow.new(
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
type=draft_workflow.type,
|
||||
@@ -183,15 +205,12 @@ class WorkflowService:
|
||||
created_by=account.id,
|
||||
environment_variables=draft_workflow.environment_variables,
|
||||
conversation_variables=draft_workflow.conversation_variables,
|
||||
marked_name=marked_name,
|
||||
marked_comment=marked_comment,
|
||||
)
|
||||
|
||||
# commit db session changes
|
||||
db.session.add(workflow)
|
||||
db.session.flush()
|
||||
db.session.commit()
|
||||
|
||||
app_model.workflow_id = workflow.id
|
||||
db.session.commit()
|
||||
session.add(workflow)
|
||||
|
||||
# trigger app workflow events
|
||||
app_published_workflow_was_updated.send(app_model, published_workflow=workflow)
|
||||
@@ -436,3 +455,65 @@ class WorkflowService:
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid app mode: {app_model.mode}")
|
||||
|
||||
def update_workflow(
|
||||
self, *, session: Session, workflow_id: str, tenant_id: str, account_id: str, data: dict
|
||||
) -> Optional[Workflow]:
|
||||
"""
|
||||
Update workflow attributes
|
||||
|
||||
:param session: SQLAlchemy database session
|
||||
:param workflow_id: Workflow ID
|
||||
:param tenant_id: Tenant ID
|
||||
:param account_id: Account ID (for permission check)
|
||||
:param data: Dictionary containing fields to update
|
||||
:return: Updated workflow or None if not found
|
||||
"""
|
||||
stmt = select(Workflow).where(Workflow.id == workflow_id, Workflow.tenant_id == tenant_id)
|
||||
workflow = session.scalar(stmt)
|
||||
|
||||
if not workflow:
|
||||
return None
|
||||
|
||||
allowed_fields = ["marked_name", "marked_comment"]
|
||||
|
||||
for field, value in data.items():
|
||||
if field in allowed_fields:
|
||||
setattr(workflow, field, value)
|
||||
|
||||
workflow.updated_by = account_id
|
||||
workflow.updated_at = datetime.now(UTC).replace(tzinfo=None)
|
||||
|
||||
return workflow
|
||||
|
||||
def delete_workflow(self, *, session: Session, workflow_id: str, tenant_id: str) -> bool:
|
||||
"""
|
||||
Delete a workflow
|
||||
|
||||
:param session: SQLAlchemy database session
|
||||
:param workflow_id: Workflow ID
|
||||
:param tenant_id: Tenant ID
|
||||
:return: True if successful
|
||||
:raises: ValueError if workflow not found
|
||||
:raises: WorkflowInUseError if workflow is in use
|
||||
:raises: DraftWorkflowDeletionError if workflow is a draft version
|
||||
"""
|
||||
stmt = select(Workflow).where(Workflow.id == workflow_id, Workflow.tenant_id == tenant_id)
|
||||
workflow = session.scalar(stmt)
|
||||
|
||||
if not workflow:
|
||||
raise ValueError(f"Workflow with ID {workflow_id} not found")
|
||||
|
||||
# Check if workflow is a draft version
|
||||
if workflow.version == "draft":
|
||||
raise DraftWorkflowDeletionError("Cannot delete draft workflow versions")
|
||||
|
||||
# Check if this workflow is currently referenced by an app
|
||||
stmt = select(App).where(App.workflow_id == workflow_id)
|
||||
app = session.scalar(stmt)
|
||||
if app:
|
||||
# Cannot delete a workflow that's currently in use by an app
|
||||
raise WorkflowInUseError(f"Cannot delete workflow that is currently in use by app '{app.name}'")
|
||||
|
||||
session.delete(workflow)
|
||||
return True
|
||||
|
||||
@@ -18,6 +18,12 @@ def test_yarl_urls():
|
||||
assert str(URL("https://dify.ai/api") / "v1") == expected_3
|
||||
assert str(URL("https://dify.ai/api/") / "v1") == expected_3
|
||||
|
||||
expected_4 = "api"
|
||||
assert str(URL("") / "api") == expected_4
|
||||
|
||||
expected_5 = "/api"
|
||||
assert str(URL("/") / "api") == expected_5
|
||||
|
||||
with pytest.raises(ValueError) as e1:
|
||||
str(URL("https://dify.ai") / "/api")
|
||||
assert str(e1.value) == "Appending path '/api' starting from slash is forbidden"
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from models.model import App
|
||||
from models.workflow import Workflow
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
|
||||
class TestWorkflowService:
|
||||
@pytest.fixture
|
||||
def workflow_service(self):
|
||||
return WorkflowService()
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
app = MagicMock(spec=App)
|
||||
app.id = "app-id-1"
|
||||
app.workflow_id = "workflow-id-1"
|
||||
app.tenant_id = "tenant-id-1"
|
||||
return app
|
||||
|
||||
@pytest.fixture
|
||||
def mock_workflows(self):
|
||||
workflows = []
|
||||
for i in range(5):
|
||||
workflow = MagicMock(spec=Workflow)
|
||||
workflow.id = f"workflow-id-{i}"
|
||||
workflow.app_id = "app-id-1"
|
||||
workflow.created_at = f"2023-01-0{5 - i}" # Descending date order
|
||||
workflow.created_by = "user-id-1" if i % 2 == 0 else "user-id-2"
|
||||
workflow.marked_name = f"Workflow {i}" if i % 2 == 0 else ""
|
||||
workflows.append(workflow)
|
||||
return workflows
|
||||
|
||||
def test_get_all_published_workflow_no_workflow_id(self, workflow_service, mock_app):
|
||||
mock_app.workflow_id = None
|
||||
mock_session = MagicMock()
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=10, user_id=None
|
||||
)
|
||||
|
||||
assert workflows == []
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_not_called()
|
||||
|
||||
def test_get_all_published_workflow_basic(self, workflow_service, mock_app, mock_workflows):
|
||||
mock_session = MagicMock()
|
||||
mock_scalar_result = MagicMock()
|
||||
mock_scalar_result.all.return_value = mock_workflows[:3]
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=3, user_id=None
|
||||
)
|
||||
|
||||
assert workflows == mock_workflows[:3]
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_called_once()
|
||||
|
||||
def test_get_all_published_workflow_pagination(self, workflow_service, mock_app, mock_workflows):
|
||||
mock_session = MagicMock()
|
||||
mock_scalar_result = MagicMock()
|
||||
# Return 4 items when limit is 3, which should indicate has_more=True
|
||||
mock_scalar_result.all.return_value = mock_workflows[:4]
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=3, user_id=None
|
||||
)
|
||||
|
||||
# Should return only the first 3 items
|
||||
assert len(workflows) == 3
|
||||
assert workflows == mock_workflows[:3]
|
||||
assert has_more is True
|
||||
|
||||
# Test page 2
|
||||
mock_scalar_result.all.return_value = mock_workflows[3:]
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=2, limit=3, user_id=None
|
||||
)
|
||||
|
||||
assert len(workflows) == 2
|
||||
assert has_more is False
|
||||
|
||||
def test_get_all_published_workflow_user_filter(self, workflow_service, mock_app, mock_workflows):
|
||||
mock_session = MagicMock()
|
||||
mock_scalar_result = MagicMock()
|
||||
# Filter workflows for user-id-1
|
||||
filtered_workflows = [w for w in mock_workflows if w.created_by == "user-id-1"]
|
||||
mock_scalar_result.all.return_value = filtered_workflows
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=10, user_id="user-id-1"
|
||||
)
|
||||
|
||||
assert workflows == filtered_workflows
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_called_once()
|
||||
|
||||
# Verify that the select contains a user filter clause
|
||||
args = mock_session.scalars.call_args[0][0]
|
||||
assert "created_by" in str(args)
|
||||
|
||||
def test_get_all_published_workflow_named_only(self, workflow_service, mock_app, mock_workflows):
|
||||
mock_session = MagicMock()
|
||||
mock_scalar_result = MagicMock()
|
||||
# Filter workflows that have a marked_name
|
||||
named_workflows = [w for w in mock_workflows if w.marked_name]
|
||||
mock_scalar_result.all.return_value = named_workflows
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=10, user_id=None, named_only=True
|
||||
)
|
||||
|
||||
assert workflows == named_workflows
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_called_once()
|
||||
|
||||
# Verify that the select contains a named_only filter clause
|
||||
args = mock_session.scalars.call_args[0][0]
|
||||
assert "marked_name !=" in str(args)
|
||||
|
||||
def test_get_all_published_workflow_combined_filters(self, workflow_service, mock_app, mock_workflows):
|
||||
mock_session = MagicMock()
|
||||
mock_scalar_result = MagicMock()
|
||||
# Combined filter: user-id-1 and has marked_name
|
||||
filtered_workflows = [w for w in mock_workflows if w.created_by == "user-id-1" and w.marked_name]
|
||||
mock_scalar_result.all.return_value = filtered_workflows
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=10, user_id="user-id-1", named_only=True
|
||||
)
|
||||
|
||||
assert workflows == filtered_workflows
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_called_once()
|
||||
|
||||
# Verify that both filters are applied
|
||||
args = mock_session.scalars.call_args[0][0]
|
||||
assert "created_by" in str(args)
|
||||
assert "marked_name !=" in str(args)
|
||||
|
||||
def test_get_all_published_workflow_empty_result(self, workflow_service, mock_app):
|
||||
mock_session = MagicMock()
|
||||
mock_scalar_result = MagicMock()
|
||||
mock_scalar_result.all.return_value = []
|
||||
mock_session.scalars.return_value = mock_scalar_result
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=mock_session, app_model=mock_app, page=1, limit=10, user_id=None
|
||||
)
|
||||
|
||||
assert workflows == []
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_called_once()
|
||||
@@ -959,6 +959,7 @@ PLUGIN_DEBUGGING_PORT=5003
|
||||
EXPOSE_PLUGIN_DEBUGGING_HOST=localhost
|
||||
EXPOSE_PLUGIN_DEBUGGING_PORT=5003
|
||||
|
||||
# If this key is changed, DIFY_INNER_API_KEY in plugin_daemon service must also be updated or agent node will fail.
|
||||
PLUGIN_DIFY_INNER_API_KEY=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
|
||||
PLUGIN_DIFY_INNER_API_URL=http://api:5001
|
||||
|
||||
@@ -971,3 +972,5 @@ FORCE_VERIFYING_SIGNATURE=true
|
||||
|
||||
PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT=600
|
||||
# PIP_MIRROR_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
PIP_MIRROR_URL=
|
||||
|
||||
@@ -2,7 +2,7 @@ x-shared-env: &shared-api-worker-env
|
||||
services:
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.0.0
|
||||
image: langgenius/dify-api:1.0.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -29,7 +29,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing the queue.
|
||||
worker:
|
||||
image: langgenius/dify-api:1.0.0
|
||||
image: langgenius/dify-api:1.0.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.0.0
|
||||
image: langgenius/dify-web:1.0.1
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
@@ -91,8 +91,6 @@ services:
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
ports:
|
||||
- '${EXPOSE_DB_PORT:-5432}:5432'
|
||||
|
||||
# The redis cache.
|
||||
redis:
|
||||
@@ -133,7 +131,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.0.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.0.4-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -151,6 +149,7 @@ services:
|
||||
FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true}
|
||||
PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
ports:
|
||||
- "${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}:${PLUGIN_DEBUGGING_PORT:-5003}"
|
||||
volumes:
|
||||
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.0.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.0.4-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -90,6 +90,7 @@ services:
|
||||
FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true}
|
||||
PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
ports:
|
||||
- "${EXPOSE_PLUGIN_DAEMON_PORT:-5002}:${PLUGIN_DAEMON_PORT:-5002}"
|
||||
- "${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}:${PLUGIN_DEBUGGING_PORT:-5003}"
|
||||
|
||||
@@ -415,11 +415,12 @@ x-shared-env: &shared-api-worker-env
|
||||
FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true}
|
||||
PLUGIN_PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
|
||||
services:
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.0.0
|
||||
image: langgenius/dify-api:1.0.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -446,7 +447,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing the queue.
|
||||
worker:
|
||||
image: langgenius/dify-api:1.0.0
|
||||
image: langgenius/dify-api:1.0.1
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -470,7 +471,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.0.0
|
||||
image: langgenius/dify-web:1.0.1
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
@@ -508,8 +509,6 @@ services:
|
||||
interval: 1s
|
||||
timeout: 3s
|
||||
retries: 30
|
||||
ports:
|
||||
- '${EXPOSE_DB_PORT:-5432}:5432'
|
||||
|
||||
# The redis cache.
|
||||
redis:
|
||||
@@ -550,7 +549,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.0.3-local
|
||||
image: langgenius/dify-plugin-daemon:0.0.4-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -568,6 +567,7 @@ services:
|
||||
FORCE_VERIFYING_SIGNATURE: ${FORCE_VERIFYING_SIGNATURE:-true}
|
||||
PYTHON_ENV_INIT_TIMEOUT: ${PLUGIN_PYTHON_ENV_INIT_TIMEOUT:-120}
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT: ${PLUGIN_MAX_EXECUTION_TIMEOUT:-600}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
ports:
|
||||
- "${EXPOSE_PLUGIN_DEBUGGING_PORT:-5003}:${PLUGIN_DEBUGGING_PORT:-5003}"
|
||||
volumes:
|
||||
|
||||
@@ -118,3 +118,5 @@ FORCE_VERIFYING_SIGNATURE=true
|
||||
|
||||
PLUGIN_PYTHON_ENV_INIT_TIMEOUT=120
|
||||
PLUGIN_MAX_EXECUTION_TIMEOUT=600
|
||||
# PIP_MIRROR_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||
PIP_MIRROR_URL=
|
||||
@@ -4,19 +4,6 @@ server {
|
||||
listen ${NGINX_PORT};
|
||||
server_name ${NGINX_SERVER_NAME};
|
||||
|
||||
# Rule 1: Handle application entry points (preserve /app/{id})
|
||||
location ~ ^/app/[a-f0-9-]+$ {
|
||||
proxy_pass http://api:5001;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
# Rule 2: Handle static resource requests (remove /app/{id} prefix)
|
||||
location ~ ^/app/[a-f0-9-]+/(console/api/.*)$ {
|
||||
rewrite ^/app/[a-f0-9-]+/(.*)$ /$1 break;
|
||||
proxy_pass http://api:5001;
|
||||
include proxy.conf;
|
||||
}
|
||||
|
||||
location /console/api {
|
||||
proxy_pass http://api:5001;
|
||||
include proxy.conf;
|
||||
|
||||
@@ -33,7 +33,6 @@ import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/co
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import VersionInfoModal from './version-info-modal'
|
||||
|
||||
export type AppPublisherProps = {
|
||||
disabled?: boolean
|
||||
@@ -45,6 +44,7 @@ export type AppPublisherProps = {
|
||||
multipleModelConfigs?: ModelAndParameter[]
|
||||
/** modelAndParameter is passed when debugWithMultipleModel is true */
|
||||
onPublish?: (params?: any) => Promise<any> | any
|
||||
onRestore?: () => Promise<any> | any
|
||||
onToggle?: (state: boolean) => void
|
||||
crossAxisOffset?: number
|
||||
toolPublished?: boolean
|
||||
@@ -62,6 +62,7 @@ const AppPublisher = ({
|
||||
debugWithMultipleModel = false,
|
||||
multipleModelConfigs = [],
|
||||
onPublish,
|
||||
onRestore,
|
||||
onToggle,
|
||||
crossAxisOffset = 0,
|
||||
toolPublished,
|
||||
@@ -71,18 +72,18 @@ const AppPublisher = ({
|
||||
const { t } = useTranslation()
|
||||
const [published, setPublished] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [publishModalOpen, setPublishModalOpen] = useState(false)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
|
||||
const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode
|
||||
const appURL = `${appBaseURL}/${appMode}/${accessToken}`
|
||||
const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '')
|
||||
|
||||
const language = useGetLanguage()
|
||||
const formatTimeFromNow = useCallback((time: number) => {
|
||||
return dayjs(time).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow()
|
||||
}, [language])
|
||||
|
||||
const handlePublish = async (params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
try {
|
||||
await onPublish?.(params)
|
||||
setPublished(true)
|
||||
@@ -90,7 +91,15 @@ const AppPublisher = ({
|
||||
catch {
|
||||
setPublished(false)
|
||||
}
|
||||
}
|
||||
}, [onPublish])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
try {
|
||||
await onRestore?.()
|
||||
setOpen(false)
|
||||
}
|
||||
catch {}
|
||||
}, [onRestore])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
const state = !open
|
||||
@@ -122,20 +131,11 @@ const AppPublisher = ({
|
||||
|
||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
||||
|
||||
const openPublishModal = () => {
|
||||
setOpen(false)
|
||||
setPublishModalOpen(true)
|
||||
}
|
||||
|
||||
const closePublishModal = () => {
|
||||
setPublishModalOpen(false)
|
||||
}
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||
e.preventDefault()
|
||||
if (publishDisabled || published)
|
||||
return
|
||||
openPublishModal()
|
||||
handlePublish()
|
||||
}
|
||||
, { exactMatch: true, useCapture: true })
|
||||
|
||||
@@ -168,8 +168,18 @@ const AppPublisher = ({
|
||||
</div>
|
||||
{publishedAt
|
||||
? (
|
||||
<div className='flex items-center system-sm-medium text-text-secondary'>
|
||||
{t('workflow.common.publishedAt')} {formatTimeFromNow(publishedAt)}
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex items-center system-sm-medium text-text-secondary'>
|
||||
{t('workflow.common.publishedAt')} {formatTimeFromNow(publishedAt)}
|
||||
</div>
|
||||
{isChatApp && <Button
|
||||
variant='secondary-accent'
|
||||
size='small'
|
||||
onClick={handleRestore}
|
||||
disabled={published}
|
||||
>
|
||||
{t('workflow.common.restore')}
|
||||
</Button>}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
@@ -189,7 +199,7 @@ const AppPublisher = ({
|
||||
<Button
|
||||
variant='primary'
|
||||
className='w-full mt-3'
|
||||
onClick={openPublishModal}
|
||||
onClick={() => handlePublish()}
|
||||
disabled={publishDisabled || published}
|
||||
>
|
||||
{
|
||||
@@ -286,13 +296,6 @@ const AppPublisher = ({
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</PortalToFollowElem >
|
||||
{publishModalOpen && (
|
||||
<VersionInfoModal
|
||||
isOpen={publishModalOpen}
|
||||
onClose={closePublishModal}
|
||||
onPublish={handlePublish}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
|
||||
</div>
|
||||
<Input
|
||||
value={title}
|
||||
placeholder={t('workflow.versionHistory.nameThisVersion')}
|
||||
placeholder={`${t('workflow.versionHistory.nameThisVersion')}${t('workflow.panel.optional')}`}
|
||||
onChange={handleTitleChange}
|
||||
destructive={titleError}
|
||||
/>
|
||||
@@ -94,7 +94,7 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
|
||||
</div>
|
||||
<Textarea
|
||||
value={releaseNotes}
|
||||
placeholder={t('workflow.versionHistory.releaseNotesPlaceholder')}
|
||||
placeholder={`${t('workflow.versionHistory.releaseNotesPlaceholder')}${t('workflow.panel.optional')}`}
|
||||
onChange={handleDescriptionChange}
|
||||
destructive={releaseNotesError}
|
||||
/>
|
||||
|
||||
@@ -11,7 +11,7 @@ const GroupName: FC<IGroupNameProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex items-center mb-1'>
|
||||
<div className='mr-3 leading-[18px] text-xs font-semibold text-gray-500 uppercase'>{name}</div>
|
||||
<div className='mr-3 leading-[18px] text-xs font-semibold text-text-tertiary uppercase'>{name}</div>
|
||||
<div className='grow h-[1px]'
|
||||
style={{
|
||||
background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, #F3F4F6 100%)',
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Clipboard,
|
||||
ClipboardCheck,
|
||||
} from '@/app/components/base/icons/src/vender/line/files'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
@@ -150,19 +151,20 @@ const AdvancedPromptInput: FC<Props> = ({
|
||||
<RiErrorWarningFill className='mr-1 w-4 h-4 text-[#F79009]' />
|
||||
<div className='leading-[18px] text-[13px] font-medium text-[#DC6803]'>{t('appDebug.promptMode.contextMissing')}</div>
|
||||
</div>
|
||||
<div
|
||||
className='flex items-center h-6 px-2 rounded-md bg-[#fff] border border-gray-200 shadow-xs text-xs font-medium text-primary-600 cursor-pointer'
|
||||
<Button
|
||||
size='small'
|
||||
variant='secondary-accent'
|
||||
onClick={onHideContextMissingTip}
|
||||
>{t('common.operation.ok')}</div>
|
||||
>{t('common.operation.ok')}</Button>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className={`relative ${!isContextMissing ? s.gradientBorder : s.warningBorder}`}>
|
||||
<div className='rounded-xl bg-white'>
|
||||
<div className={`bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 rounded-xl p-0.5 shadow-xs ${!isContextMissing ? '' : s.warningBorder}`}>
|
||||
<div className='rounded-xl bg-background-default'>
|
||||
{isContextMissing
|
||||
? contextMissing
|
||||
: (
|
||||
<div className={cn(s.boxHeader, 'flex justify-between items-center h-11 pt-2 pr-3 pb-1 pl-4 rounded-tl-xl rounded-tr-xl bg-white hover:shadow-xs')}>
|
||||
<div className={cn(s.boxHeader, 'flex justify-between items-center h-11 pt-2 pr-3 pb-1 pl-4 rounded-tl-xl rounded-tr-xl bg-background-default hover:shadow-xs')}>
|
||||
{isChatMode
|
||||
? (
|
||||
<MessageTypeSelector value={type} onChange={onTypeChange} />
|
||||
@@ -182,30 +184,30 @@ const AdvancedPromptInput: FC<Props> = ({
|
||||
</div>)}
|
||||
<div className={cn(s.optionWrap, 'items-center space-x-1')}>
|
||||
{canDelete && (
|
||||
<RiDeleteBinLine onClick={onDelete} className='h-6 w-6 p-1 text-gray-500 cursor-pointer' />
|
||||
<RiDeleteBinLine onClick={onDelete} className='h-6 w-6 p-1 text-text-tertiary cursor-pointer' />
|
||||
)}
|
||||
{!isCopied
|
||||
? (
|
||||
<Clipboard className='h-6 w-6 p-1 text-gray-500 cursor-pointer' onClick={() => {
|
||||
<Clipboard className='h-6 w-6 p-1 text-text-tertiary cursor-pointer' onClick={() => {
|
||||
copy(value)
|
||||
setIsCopied(true)
|
||||
}} />
|
||||
)
|
||||
: (
|
||||
<ClipboardCheck className='h-6 w-6 p-1 text-gray-500' />
|
||||
<ClipboardCheck className='h-6 w-6 p-1 text-text-tertiary' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PromptEditorHeightResizeWrap
|
||||
className='px-4 min-h-[102px] overflow-y-auto text-sm text-gray-700'
|
||||
className='px-4 min-h-[102px] overflow-y-auto text-sm text-text-secondary'
|
||||
height={editorHeight}
|
||||
minHeight={minHeight}
|
||||
onHeightChange={setEditorHeight}
|
||||
footer={(
|
||||
<div className='pl-4 pb-2 flex'>
|
||||
<div className="h-[18px] leading-[18px] px-1 rounded-md bg-gray-100 text-xs text-gray-500">{value.length}</div>
|
||||
<div className="h-[18px] leading-[18px] px-1 rounded-md bg-divider-regular text-xs text-text-tertiary">{value.length}</div>
|
||||
</div>
|
||||
)}
|
||||
hideResize={noResize}
|
||||
|
||||
@@ -39,21 +39,17 @@ const ConfirmAddVar: FC<IConfirmAddVarProps> = ({
|
||||
}}>
|
||||
<div
|
||||
ref={mainContentRef}
|
||||
className='w-[420px] rounded-xl bg-gray-50 p-6'
|
||||
className='w-[420px] rounded-xl bg-components-panel-bg p-6'
|
||||
style={{
|
||||
boxShadow: '0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)',
|
||||
}}
|
||||
>
|
||||
<div className='flex items-start space-x-3'>
|
||||
<div
|
||||
className='shrink-0 flex items-center justify-center h-10 w-10 rounded-xl border border-gray-100'
|
||||
style={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
boxShadow: '0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)',
|
||||
}}
|
||||
className='shrink-0 flex items-center justify-center h-10 w-10 bg-components-card-bg-alt rounded-xl border border-components-card-border shadow-lg'
|
||||
>{VarIcon}</div>
|
||||
<div className='grow-1'>
|
||||
<div className='text-sm font-medium text-gray-900'>{t('appDebug.autoAddVar')}</div>
|
||||
<div className='text-sm font-medium text-text-primary'>{t('appDebug.autoAddVar')}</div>
|
||||
<div className='flex flex-wrap mt-[15px] max-h-[66px] overflow-y-auto px-1 space-x-1'>
|
||||
{varNameArr.map(name => (
|
||||
<VarHighlight key={name} name={name} />
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SimplePromptInput from './simple-prompt-input'
|
||||
import Button from '@/app/components/base/button'
|
||||
import AdvancedMessageInput from '@/app/components/app/configuration/config-prompt/advanced-prompt-input'
|
||||
import { PromptRole } from '@/models/debug'
|
||||
import type { PromptItem, PromptVariable } from '@/models/debug'
|
||||
@@ -155,12 +156,12 @@ const Prompt: FC<IPromptProps> = ({
|
||||
}
|
||||
</div>
|
||||
{(modelModeType === ModelModeType.chat && (currentAdvancedPrompt as PromptItem[]).length < MAX_PROMPT_MESSAGE_LENGTH) && (
|
||||
<div
|
||||
<Button
|
||||
onClick={handleAddMessage}
|
||||
className='mt-3 flex items-center h-8 justify-center bg-gray-50 rounded-lg cursor-pointer text-[13px] font-medium text-gray-700 space-x-2'>
|
||||
<RiAddLine className='w-4 h-4' />
|
||||
className='mt-3 w-full'>
|
||||
<RiAddLine className='w-4 h-4 mr-2' />
|
||||
<div>{t('appDebug.promptMode.operation.addMessage')}</div>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ const MessageTypeSelector: FC<Props> = ({
|
||||
<ChevronSelectorVertical className='w-3 h-3 ' />
|
||||
</div>
|
||||
{showOption && (
|
||||
<div className='absolute z-10 top-[30px] p-1 border border-gray-200 shadow-lg rounded-lg bg-white'>
|
||||
<div className='absolute z-10 top-[30px] p-1 border border-components-panel-border shadow-lg rounded-lg bg-components-panel-bg'>
|
||||
{allTypes.map(type => (
|
||||
<div
|
||||
key={type}
|
||||
@@ -37,7 +37,7 @@ const MessageTypeSelector: FC<Props> = ({
|
||||
setHide()
|
||||
onChange(type)
|
||||
}}
|
||||
className='flex items-center h-9 min-w-[44px] px-3 rounded-lg cursor-pointer text-sm font-medium text-gray-700 uppercase hover:bg-gray-50'
|
||||
className='flex items-center h-9 min-w-[44px] px-3 rounded-lg cursor-pointer text-sm font-medium text-text-secondary uppercase hover:bg-state-base-hover'
|
||||
>{type}</div>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useBoolean } from 'ahooks'
|
||||
import produce from 'immer'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import ConfirmAddVar from './confirm-add-var'
|
||||
import s from './style.module.css'
|
||||
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
@@ -48,7 +47,6 @@ const Prompt: FC<ISimplePromptInput> = ({
|
||||
readonly = false,
|
||||
onChange,
|
||||
noTitle,
|
||||
gradientBorder,
|
||||
editorHeight: initEditorHeight,
|
||||
noResize,
|
||||
}) => {
|
||||
@@ -161,12 +159,12 @@ const Prompt: FC<ISimplePromptInput> = ({
|
||||
const [editorHeight, setEditorHeight] = useState(minHeight)
|
||||
|
||||
return (
|
||||
<div className={cn((!readonly || gradientBorder) ? `${s.gradientBorder}` : 'bg-gray-50', ' relative shadow-md')}>
|
||||
<div className='rounded-xl bg-[#EEF4FF]'>
|
||||
<div className={cn('relative bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 rounded-xl p-0.5 shadow-xs')}>
|
||||
<div className='rounded-xl bg-background-section-burn'>
|
||||
{!noTitle && (
|
||||
<div className="flex justify-between items-center h-11 pl-3 pr-6">
|
||||
<div className="flex justify-between items-center h-11 pl-3 pr-2.5">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className='h2'>{mode !== AppType.completion ? t('appDebug.chatSubTitle') : t('appDebug.completionSubTitle')}</div>
|
||||
<div className='h2 system-sm-semibold-uppercase text-text-secondary'>{mode !== AppType.completion ? t('appDebug.chatSubTitle') : t('appDebug.completionSubTitle')}</div>
|
||||
{!readonly && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
@@ -186,14 +184,14 @@ const Prompt: FC<ISimplePromptInput> = ({
|
||||
)}
|
||||
|
||||
<PromptEditorHeightResizeWrap
|
||||
className='px-4 pt-2 min-h-[228px] bg-white rounded-t-xl text-sm text-gray-700'
|
||||
className='px-4 pt-2 min-h-[228px] bg-background-default rounded-t-xl text-sm text-text-secondary'
|
||||
height={editorHeight}
|
||||
minHeight={minHeight}
|
||||
onHeightChange={setEditorHeight}
|
||||
hideResize={noResize}
|
||||
footer={(
|
||||
<div className='pl-4 pb-2 flex bg-white rounded-b-xl'>
|
||||
<div className="h-[18px] leading-[18px] px-1 rounded-md bg-gray-100 text-xs text-gray-500">{promptTemplate.length}</div>
|
||||
<div className='pl-4 pb-2 flex bg-background-default rounded-b-xl'>
|
||||
<div className="h-[18px] leading-[18px] px-1 rounded-md bg-components-badge-bg-gray-soft text-xs text-text-tertiary">{promptTemplate.length}</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Generator } from '@/app/components/base/icons/src/vender/other'
|
||||
import {
|
||||
RiSparklingFill,
|
||||
} from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export type IAutomaticBtnProps = {
|
||||
onClick: () => void
|
||||
@@ -13,12 +16,10 @@ const AutomaticBtn: FC<IAutomaticBtnProps> = ({
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex space-x-1 items-center !h-8 cursor-pointer'
|
||||
onClick={onClick}
|
||||
>
|
||||
<Generator className='w-3.5 h-3.5 text-indigo-600' />
|
||||
<span className='text-xs font-semibold text-indigo-600'>{t('appDebug.operation.automatic')}</span>
|
||||
</div>
|
||||
<Button variant='secondary-accent' size='small' onClick={onClick}>
|
||||
<RiSparklingFill className='w-3.5 h-3.5 mr-1' />
|
||||
<span className=''>{t('appDebug.operation.automatic')}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
export default React.memo(AutomaticBtn)
|
||||
|
||||
@@ -38,7 +38,7 @@ import ModelName from '@/app/components/header/account-setting/model-provider-pa
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
|
||||
export interface IGetAutomaticResProps {
|
||||
export type IGetAutomaticResProps = {
|
||||
mode: AppType
|
||||
model: Model
|
||||
isShow: boolean
|
||||
@@ -54,11 +54,11 @@ const TryLabel: FC<{
|
||||
}> = ({ Icon, text, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
className='mt-2 mr-1 shrink-0 flex h-7 items-center px-2 bg-gray-100 rounded-lg cursor-pointer'
|
||||
className='mt-2 mr-1 shrink-0 flex h-7 items-center px-2 bg-components-button-secondary-bg rounded-lg cursor-pointer'
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className='w-4 h-4 text-gray-500'></Icon>
|
||||
<div className='ml-1 text-xs font-medium text-gray-700'>{text}</div>
|
||||
<Icon className='w-4 h-4 text-text-tertiary'></Icon>
|
||||
<div className='ml-1 text-xs font-medium text-text-secondary'>{text}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -140,14 +140,14 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
const renderLoading = (
|
||||
<div className='w-0 grow flex flex-col items-center justify-center h-full space-y-3'>
|
||||
<Loading />
|
||||
<div className='text-[13px] text-gray-400'>{t('appDebug.generate.loading')}</div>
|
||||
<div className='text-[13px] text-text-tertiary'>{t('appDebug.generate.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderNoData = (
|
||||
<div className='w-0 grow flex flex-col items-center px-8 justify-center h-full space-y-3'>
|
||||
<Generator className='w-14 h-14 text-gray-300' />
|
||||
<div className='leading-5 text-center text-[13px] font-normal text-gray-400'>
|
||||
<Generator className='w-14 h-14 text-text-tertiary' />
|
||||
<div className='leading-5 text-center text-[13px] font-normal text-text-tertiary'>
|
||||
<div>{t('appDebug.generate.noDataLine1')}</div>
|
||||
<div>{t('appDebug.generate.noDataLine2')}</div>
|
||||
</div>
|
||||
@@ -193,10 +193,10 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
closable
|
||||
>
|
||||
<div className='flex h-[680px] flex-wrap'>
|
||||
<div className='w-[570px] shrink-0 p-6 h-full overflow-y-auto border-r border-gray-100'>
|
||||
<div className='w-[570px] shrink-0 p-6 h-full overflow-y-auto border-r border-divider-regular'>
|
||||
<div className='mb-8'>
|
||||
<div className={`leading-[28px] text-lg font-bold ${s.textGradient}`}>{t('appDebug.generate.title')}</div>
|
||||
<div className='mt-1 text-[13px] font-normal text-gray-500'>{t('appDebug.generate.description')}</div>
|
||||
<div className='mt-1 text-[13px] font-normal text-text-tertiary'>{t('appDebug.generate.description')}</div>
|
||||
</div>
|
||||
<div className='flex items-center mb-8'>
|
||||
<ModelIcon
|
||||
@@ -213,7 +213,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
</div>
|
||||
<div >
|
||||
<div className='flex items-center'>
|
||||
<div className='mr-3 shrink-0 leading-[18px] text-xs font-semibold text-gray-500 uppercase'>{t('appDebug.generate.tryIt')}</div>
|
||||
<div className='mr-3 shrink-0 leading-[18px] text-xs font-semibold text-text-tertiary uppercase'>{t('appDebug.generate.tryIt')}</div>
|
||||
<div className='grow h-px' style={{
|
||||
background: 'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0))',
|
||||
}}></div>
|
||||
@@ -232,7 +232,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
{/* inputs */}
|
||||
<div className='mt-6'>
|
||||
<div className='text-[0px]'>
|
||||
<div className='mb-2 leading-5 text-sm font-medium text-gray-900'>{t('appDebug.generate.instruction')}</div>
|
||||
<div className='mb-2 leading-5 text-sm font-medium text-text-primary'>{t('appDebug.generate.instruction')}</div>
|
||||
<Textarea
|
||||
className="h-[200px] resize-none"
|
||||
placeholder={t('appDebug.generate.instructionPlaceHolder') as string}
|
||||
@@ -256,7 +256,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
|
||||
{(!isLoading && res) && (
|
||||
<div className='w-0 grow p-6 pb-0 h-full'>
|
||||
<div className='shrink-0 mb-3 leading-[160%] text-base font-semibold text-gray-800'>{t('appDebug.generate.resTitle')}</div>
|
||||
<div className='shrink-0 mb-3 leading-[160%] text-base font-semibold text-text-secondary'>{t('appDebug.generate.resTitle')}</div>
|
||||
<div className={cn('max-h-[555px] overflow-y-auto', !isInLLMNode && 'pb-2')}>
|
||||
<ConfigPrompt
|
||||
mode={mode}
|
||||
@@ -301,7 +301,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end py-4 bg-white'>
|
||||
<div className='flex justify-end py-4 bg-background-default'>
|
||||
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' className='ml-2' onClick={() => {
|
||||
setShowConfirmOverwrite(true)
|
||||
|
||||
@@ -2,11 +2,29 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import dayjs from 'dayjs'
|
||||
import { RiCalendarLine } from '@remixicon/react'
|
||||
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
|
||||
import type { QueryParam } from './index'
|
||||
import Chip from '@/app/components/base/chip'
|
||||
import Input from '@/app/components/base/input'
|
||||
dayjs.extend(quarterOfYear)
|
||||
|
||||
interface IFilterProps {
|
||||
const today = dayjs()
|
||||
|
||||
export const TIME_PERIOD_MAPPING: { [key: string]: { value: number; name: string } } = {
|
||||
1: { value: 0, name: 'today' },
|
||||
2: { value: 7, name: 'last7days' },
|
||||
3: { value: 28, name: 'last4weeks' },
|
||||
4: { value: today.diff(today.subtract(3, 'month'), 'day'), name: 'last3months' },
|
||||
5: { value: today.diff(today.subtract(12, 'month'), 'day'), name: 'last12months' },
|
||||
6: { value: today.diff(today.startOf('month'), 'day'), name: 'monthToDate' },
|
||||
7: { value: today.diff(today.startOf('quarter'), 'day'), name: 'quarterToDate' },
|
||||
8: { value: today.diff(today.startOf('year'), 'day'), name: 'yearToDate' },
|
||||
9: { value: -1, name: 'allTime' },
|
||||
}
|
||||
|
||||
type IFilterProps = {
|
||||
queryParams: QueryParam
|
||||
setQueryParams: (v: QueryParam) => void
|
||||
}
|
||||
@@ -27,6 +45,17 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
|
||||
{ value: 'stopped', name: 'Stop' },
|
||||
]}
|
||||
/>
|
||||
<Chip
|
||||
className='min-w-[150px]'
|
||||
panelClassName='w-[270px]'
|
||||
leftIcon={<RiCalendarLine className='h-4 w-4 text-text-secondary' />}
|
||||
value={queryParams.period}
|
||||
onSelect={(item) => {
|
||||
setQueryParams({ ...queryParams, period: item.value })
|
||||
}}
|
||||
onClear={() => setQueryParams({ ...queryParams, period: '9' })}
|
||||
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))}
|
||||
/>
|
||||
<Input
|
||||
wrapperClassName='w-[200px]'
|
||||
showLeftIcon
|
||||
|
||||
@@ -4,21 +4,30 @@ import React, { useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { omit } from 'lodash-es'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
import List from './list'
|
||||
import Filter from './filter'
|
||||
import Filter, { TIME_PERIOD_MAPPING } from './filter'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { fetchWorkflowLogs } from '@/service/log'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import type { App, AppMode } from '@/types/app'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
export type ILogsProps = {
|
||||
appDetail: App
|
||||
}
|
||||
|
||||
export type QueryParam = {
|
||||
period: string
|
||||
status?: string
|
||||
keyword?: string
|
||||
}
|
||||
@@ -48,7 +57,8 @@ const EmptyElement: FC<{ appUrl: string }> = ({ appUrl }) => {
|
||||
|
||||
const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
const { t } = useTranslation()
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>({ status: 'all' })
|
||||
const { userProfile: { timezone } } = useAppContext()
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>({ status: 'all', period: '2' })
|
||||
const [currPage, setCurrPage] = React.useState<number>(0)
|
||||
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
|
||||
const [limit, setLimit] = React.useState<number>(APP_PAGE_LIMIT)
|
||||
@@ -58,6 +68,13 @@ const Logs: FC<ILogsProps> = ({ appDetail }) => {
|
||||
limit,
|
||||
...(debouncedQueryParams.status !== 'all' ? { status: debouncedQueryParams.status } : {}),
|
||||
...(debouncedQueryParams.keyword ? { keyword: debouncedQueryParams.keyword } : {}),
|
||||
...((debouncedQueryParams.period !== '9')
|
||||
? {
|
||||
created_at__after: dayjs().subtract(TIME_PERIOD_MAPPING[debouncedQueryParams.period].value, 'day').startOf('day').tz(timezone).format('YYYY-MM-DDTHH:mm:ssZ'),
|
||||
created_at__before: dayjs().endOf('day').tz(timezone).format('YYYY-MM-DDTHH:mm:ssZ'),
|
||||
}
|
||||
: {}),
|
||||
...omit(debouncedQueryParams, ['period', 'status']),
|
||||
}
|
||||
|
||||
const getWebAppType = (appType: AppMode) => {
|
||||
|
||||
@@ -56,7 +56,7 @@ const WorkflowProcessItem = ({
|
||||
>
|
||||
{
|
||||
running && (
|
||||
<RiLoader2Line className='shrink-0 mr-1 w-3.5 h-3.5 text-text-tertiary' />
|
||||
<RiLoader2Line className='shrink-0 mr-1 w-3.5 h-3.5 animate-spin text-text-tertiary' />
|
||||
)
|
||||
}
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import mermaid from 'mermaid'
|
||||
import { usePrevious } from 'ahooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||
import { cleanUpSvgCode } from './utils'
|
||||
import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
|
||||
import cn from '@/utils/classnames'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
@@ -44,7 +45,7 @@ const Flowchart = React.forwardRef((props: {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && mermaidAPI) {
|
||||
const svgGraph = await mermaidAPI.render('flowchart', PrimitiveCode)
|
||||
const base64Svg: any = await svgToBase64(svgGraph.svg)
|
||||
const base64Svg: any = await svgToBase64(cleanUpSvgCode(svgGraph.svg))
|
||||
setSvgCode(base64Svg)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { cleanUpSvgCode } from './utils'
|
||||
|
||||
describe('cleanUpSvgCode', () => {
|
||||
it('replaces old-style <br> tags with the new style', () => {
|
||||
const result = cleanUpSvgCode('<br>test<br>')
|
||||
expect(result).toEqual('<br/>test<br/>')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
export function cleanUpSvgCode(svgCode: string): string {
|
||||
return svgCode.replaceAll('<br>', '<br/>')
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
@layer components {
|
||||
.premium-badge {
|
||||
@apply inline-flex justify-center items-center rounded-full border box-border border-[rgba(255,255,255,0.8)] text-white
|
||||
@apply inline-flex justify-center items-center rounded-md border box-border border-white/95 text-white
|
||||
}
|
||||
|
||||
/* m is for the regular button */
|
||||
|
||||
@@ -62,7 +62,7 @@ const PremiumBadge: React.FC<PremiumBadgeProps> = ({
|
||||
<Highlight
|
||||
className={classNames(
|
||||
'absolute top-0 opacity-50 hover:opacity-80',
|
||||
size === 's' ? 'h-4.5 w-12' : 'h-6 w-12',
|
||||
size === 's' ? 'h-[18px] w-12' : 'h-6 w-12',
|
||||
)}
|
||||
style={{
|
||||
right: '50%',
|
||||
|
||||
@@ -23,7 +23,7 @@ import FloatRightContainer from '@/app/components/base/float-right-container'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { LayoutRight2LineMod } from '@/app/components/base/icons/src/public/knowledge'
|
||||
import { useCheckSegmentBatchImportProgress, useChildSegmentListKey, useSegmentBatchImport, useSegmentListKey } from '@/service/knowledge/use-segment'
|
||||
import { useDocumentDetail, useDocumentMetadata } from '@/service/knowledge/use-document'
|
||||
import { useDocumentDetail, useDocumentMetadata, useInvalidDocumentList } from '@/service/knowledge/use-document'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
|
||||
type DocumentContextValue = {
|
||||
@@ -152,17 +152,22 @@ const DocumentDetail: FC<Props> = ({ datasetId, documentId }) => {
|
||||
|
||||
const invalidChunkList = useInvalid(useSegmentListKey)
|
||||
const invalidChildChunkList = useInvalid(useChildSegmentListKey)
|
||||
const invalidDocumentList = useInvalidDocumentList(datasetId)
|
||||
|
||||
const handleOperate = (operateName?: string) => {
|
||||
invalidDocumentList()
|
||||
if (operateName === 'delete') {
|
||||
backToPrev()
|
||||
}
|
||||
else {
|
||||
detailMutate()
|
||||
setTimeout(() => {
|
||||
invalidChunkList()
|
||||
invalidChildChunkList()
|
||||
}, 5000)
|
||||
// If operation is not rename, refresh the chunk list after 5 seconds
|
||||
if (operateName) {
|
||||
setTimeout(() => {
|
||||
invalidChunkList()
|
||||
invalidChildChunkList()
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useDebounce, useDebounceFn } from 'ahooks'
|
||||
import { groupBy, omit } from 'lodash-es'
|
||||
import { groupBy } from 'lodash-es'
|
||||
import { PlusIcon } from '@heroicons/react/24/solid'
|
||||
import { RiExternalLinkLine } from '@remixicon/react'
|
||||
import AutoDisabledDocument from '../common/document-status-with-action/auto-disabled-document'
|
||||
@@ -15,16 +14,16 @@ import Loading from '@/app/components/base/loading'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { get } from '@/service/base'
|
||||
import { createDocument, fetchDocuments } from '@/service/datasets'
|
||||
import { createDocument } from '@/service/datasets'
|
||||
import { useDatasetDetailContext } from '@/context/dataset-detail'
|
||||
import { NotionPageSelectorModal } from '@/app/components/base/notion-page-selector'
|
||||
import type { NotionPage } from '@/models/common'
|
||||
import type { CreateDocumentReq } from '@/models/datasets'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import { DataSourceType, ProcessMode } from '@/models/datasets'
|
||||
import IndexFailed from '@/app/components/datasets/common/document-status-with-action/index-failed'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useInvalidDocumentDetailKey } from '@/service/knowledge/use-document'
|
||||
import { useDocumentList, useInvalidDocumentDetailKey, useInvalidDocumentList } from '@/service/knowledge/use-document'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { useChildSegmentListKey, useSegmentListKey } from '@/service/knowledge/use-segment'
|
||||
|
||||
@@ -73,12 +72,12 @@ const EmptyElement: FC<{ canAdd: boolean; onClick: () => void; type?: 'upload' |
|
||||
</div>
|
||||
}
|
||||
|
||||
interface IDocumentsProps {
|
||||
type IDocumentsProps = {
|
||||
datasetId: string
|
||||
}
|
||||
|
||||
export const fetcher = (url: string) => get(url, {}, {})
|
||||
const DEFAULT_LIMIT = 15
|
||||
const DEFAULT_LIMIT = 10
|
||||
|
||||
const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -99,33 +98,33 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
|
||||
const debouncedSearchValue = useDebounce(searchValue, { wait: 500 })
|
||||
|
||||
const query = useMemo(() => {
|
||||
return { page: currPage + 1, limit, keyword: debouncedSearchValue, fetch: isDataSourceNotion ? true : '' }
|
||||
}, [currPage, debouncedSearchValue, isDataSourceNotion, limit])
|
||||
|
||||
const { data: documentsRes, mutate, isLoading: isListLoading } = useSWR(
|
||||
{
|
||||
action: 'fetchDocuments',
|
||||
datasetId,
|
||||
params: query,
|
||||
const { data: documentsRes, isFetching: isListLoading } = useDocumentList({
|
||||
datasetId,
|
||||
query: {
|
||||
page: currPage + 1,
|
||||
limit,
|
||||
keyword: debouncedSearchValue,
|
||||
},
|
||||
apiParams => fetchDocuments(omit(apiParams, 'action')),
|
||||
{ refreshInterval: (isDataSourceNotion && timerCanRun) ? 2500 : 0 },
|
||||
)
|
||||
refetchInterval: (isDataSourceNotion && timerCanRun) ? 2500 : 0,
|
||||
})
|
||||
|
||||
const invalidDocumentList = useInvalidDocumentList(datasetId)
|
||||
|
||||
const [isMuting, setIsMuting] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!isListLoading && isMuting)
|
||||
setIsMuting(false)
|
||||
}, [isListLoading, isMuting])
|
||||
if (documentsRes) {
|
||||
const totalPages = Math.ceil(documentsRes.total / limit)
|
||||
if (totalPages < currPage + 1)
|
||||
setCurrPage(totalPages === 0 ? 0 : totalPages - 1)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [documentsRes])
|
||||
|
||||
const invalidDocumentDetail = useInvalidDocumentDetailKey()
|
||||
const invalidChunkList = useInvalid(useSegmentListKey)
|
||||
const invalidChildChunkList = useInvalid(useChildSegmentListKey)
|
||||
|
||||
const handleUpdate = useCallback(() => {
|
||||
setIsMuting(true)
|
||||
mutate()
|
||||
invalidDocumentList()
|
||||
invalidDocumentDetail()
|
||||
setTimeout(() => {
|
||||
invalidChunkList()
|
||||
@@ -175,8 +174,6 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
router.push(`/datasets/${datasetId}/documents/create`)
|
||||
}
|
||||
|
||||
const isLoading = isListLoading // !documentsRes && !error
|
||||
|
||||
const handleSaveNotionPageSelected = async (selectedPages: NotionPage[]) => {
|
||||
const workspacesMap = groupBy(selectedPages, 'workspace_id')
|
||||
const workspaces = Object.keys(workspacesMap).map((workspaceId) => {
|
||||
@@ -209,7 +206,7 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
indexing_technique: dataset?.indexing_technique,
|
||||
process_rule: {
|
||||
rules: {},
|
||||
mode: 'automatic',
|
||||
mode: ProcessMode.general,
|
||||
},
|
||||
} as CreateDocumentReq
|
||||
|
||||
@@ -217,7 +214,7 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
datasetId,
|
||||
body: params,
|
||||
})
|
||||
mutate()
|
||||
invalidDocumentList()
|
||||
setTimerCanRun(true)
|
||||
// mutateDatasetIndexingStatus(undefined, { revalidate: true })
|
||||
setNotionPageSelectorModalVisible(false)
|
||||
@@ -272,7 +269,7 @@ const Documents: FC<IDocumentsProps> = ({ datasetId }) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(isLoading && !isMuting)
|
||||
{isListLoading
|
||||
? <Loading type='app' />
|
||||
: total > 0
|
||||
? <List
|
||||
|
||||
@@ -500,121 +500,123 @@ const DocumentList: FC<IDocumentListProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative w-full h-full overflow-x-auto'>
|
||||
<table className={`min-w-[700px] max-w-full w-full border-collapse border-0 text-sm mt-3 ${s.documentTable}`}>
|
||||
<thead className="h-8 leading-8 border-b border-divider-subtle text-text-tertiary font-medium text-xs uppercase">
|
||||
<tr>
|
||||
<td className='w-12'>
|
||||
<div className='flex items-center' onClick={e => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
className='shrink-0 mr-2'
|
||||
checked={isAllSelected}
|
||||
mixed={!isAllSelected && isSomeSelected}
|
||||
onCheck={onSelectedAll}
|
||||
/>
|
||||
#
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className='flex'>
|
||||
{t('datasetDocuments.list.table.header.fileName')}
|
||||
</div>
|
||||
</td>
|
||||
<td className='w-[130px]'>{t('datasetDocuments.list.table.header.chunkingMode')}</td>
|
||||
<td className='w-24'>{t('datasetDocuments.list.table.header.words')}</td>
|
||||
<td className='w-44'>{t('datasetDocuments.list.table.header.hitCount')}</td>
|
||||
<td className='w-44'>
|
||||
<div className='flex items-center' onClick={onClickSort}>
|
||||
{t('datasetDocuments.list.table.header.uploadTime')}
|
||||
<ArrowDownIcon className={cn('ml-0.5 h-3 w-3 stroke-current stroke-2 cursor-pointer', enableSort ? 'text-text-tertiary' : 'text-text-disabled')} />
|
||||
</div>
|
||||
</td>
|
||||
<td className='w-40'>{t('datasetDocuments.list.table.header.status')}</td>
|
||||
<td className='w-20'>{t('datasetDocuments.list.table.header.action')}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-text-secondary">
|
||||
{localDocs.map((doc, index) => {
|
||||
const isFile = doc.data_source_type === DataSourceType.FILE
|
||||
const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''
|
||||
return <tr
|
||||
key={doc.id}
|
||||
className={'border-b border-divider-subtle h-8 hover:bg-background-default-hover cursor-pointer'}
|
||||
onClick={() => {
|
||||
router.push(`/datasets/${datasetId}/documents/${doc.id}`)
|
||||
}}>
|
||||
<td className='text-left align-middle text-text-tertiary text-xs'>
|
||||
<div className='flex flex-col relative w-full h-full'>
|
||||
<div className='grow overflow-x-auto'>
|
||||
<table className={`min-w-[700px] max-w-full w-full border-collapse border-0 text-sm mt-3 ${s.documentTable}`}>
|
||||
<thead className="h-8 leading-8 border-b border-divider-subtle text-text-tertiary font-medium text-xs uppercase">
|
||||
<tr>
|
||||
<td className='w-12'>
|
||||
<div className='flex items-center' onClick={e => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
className='shrink-0 mr-2'
|
||||
checked={selectedIds.includes(doc.id)}
|
||||
onCheck={() => {
|
||||
onSelectedIdChange(
|
||||
selectedIds.includes(doc.id)
|
||||
? selectedIds.filter(id => id !== doc.id)
|
||||
: [...selectedIds, doc.id],
|
||||
)
|
||||
}}
|
||||
checked={isAllSelected}
|
||||
mixed={!isAllSelected && isSomeSelected}
|
||||
onCheck={onSelectedAll}
|
||||
/>
|
||||
{/* {doc.position} */}
|
||||
{index + 1}
|
||||
#
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className={'group flex items-center mr-6 hover:mr-0 max-w-[460px]'}>
|
||||
<div className='shrink-0'>
|
||||
{doc?.data_source_type === DataSourceType.NOTION && <NotionIcon className='inline-flex -mt-[3px] mr-1.5 align-middle' type='page' src={doc.data_source_info.notion_page_icon} />}
|
||||
{doc?.data_source_type === DataSourceType.FILE && <FileTypeIcon type={extensionToFileType(doc?.data_source_info?.upload_file?.extension ?? fileType)} className='mr-1.5' />}
|
||||
{doc?.data_source_type === DataSourceType.WEB && <Globe01 className='inline-flex -mt-[3px] mr-1.5 align-middle' />}
|
||||
</div>
|
||||
<span className='text-sm truncate grow-1'>{doc.name}</span>
|
||||
<div className='group-hover:flex group-hover:ml-auto hidden shrink-0'>
|
||||
<Tooltip
|
||||
popupContent={t('datasetDocuments.list.table.rename')}
|
||||
>
|
||||
<div
|
||||
className='p-1 rounded-md cursor-pointer hover:bg-state-base-hover'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleShowRenameModal(doc)
|
||||
}}
|
||||
>
|
||||
<Edit03 className='w-4 h-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className='flex'>
|
||||
{t('datasetDocuments.list.table.header.fileName')}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<ChunkingModeLabel
|
||||
isGeneralMode={isGeneralMode}
|
||||
isQAMode={isQAMode}
|
||||
/>
|
||||
</td>
|
||||
<td>{renderCount(doc.word_count)}</td>
|
||||
<td>{renderCount(doc.hit_count)}</td>
|
||||
<td className='text-text-secondary text-[13px]'>
|
||||
{formatTime(doc.created_at, t('datasetHitTesting.dateTimeFormat') as string)}
|
||||
</td>
|
||||
<td>
|
||||
{
|
||||
(['indexing', 'splitting', 'parsing', 'cleaning'].includes(doc.indexing_status) && doc?.data_source_type === DataSourceType.NOTION)
|
||||
? <ProgressBar percent={doc.percent || 0} />
|
||||
: <StatusItem status={doc.display_status} />
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<OperationAction
|
||||
embeddingAvailable={embeddingAvailable}
|
||||
datasetId={datasetId}
|
||||
detail={pick(doc, ['name', 'enabled', 'archived', 'id', 'data_source_type', 'doc_form'])}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
<td className='w-[130px]'>{t('datasetDocuments.list.table.header.chunkingMode')}</td>
|
||||
<td className='w-24'>{t('datasetDocuments.list.table.header.words')}</td>
|
||||
<td className='w-44'>{t('datasetDocuments.list.table.header.hitCount')}</td>
|
||||
<td className='w-44'>
|
||||
<div className='flex items-center' onClick={onClickSort}>
|
||||
{t('datasetDocuments.list.table.header.uploadTime')}
|
||||
<ArrowDownIcon className={cn('ml-0.5 h-3 w-3 stroke-current stroke-2 cursor-pointer', enableSort ? 'text-text-tertiary' : 'text-text-disabled')} />
|
||||
</div>
|
||||
</td>
|
||||
<td className='w-40'>{t('datasetDocuments.list.table.header.status')}</td>
|
||||
<td className='w-20'>{t('datasetDocuments.list.table.header.action')}</td>
|
||||
</tr>
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody className="text-text-secondary">
|
||||
{localDocs.map((doc, index) => {
|
||||
const isFile = doc.data_source_type === DataSourceType.FILE
|
||||
const fileType = isFile ? doc.data_source_detail_dict?.upload_file?.extension : ''
|
||||
return <tr
|
||||
key={doc.id}
|
||||
className={'border-b border-divider-subtle h-8 hover:bg-background-default-hover cursor-pointer'}
|
||||
onClick={() => {
|
||||
router.push(`/datasets/${datasetId}/documents/${doc.id}`)
|
||||
}}>
|
||||
<td className='text-left align-middle text-text-tertiary text-xs'>
|
||||
<div className='flex items-center' onClick={e => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
className='shrink-0 mr-2'
|
||||
checked={selectedIds.includes(doc.id)}
|
||||
onCheck={() => {
|
||||
onSelectedIdChange(
|
||||
selectedIds.includes(doc.id)
|
||||
? selectedIds.filter(id => id !== doc.id)
|
||||
: [...selectedIds, doc.id],
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{/* {doc.position} */}
|
||||
{index + 1}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className={'group flex items-center mr-6 hover:mr-0 max-w-[460px]'}>
|
||||
<div className='shrink-0'>
|
||||
{doc?.data_source_type === DataSourceType.NOTION && <NotionIcon className='inline-flex mt-[-3px] mr-1.5 align-middle' type='page' src={doc.data_source_info.notion_page_icon} />}
|
||||
{doc?.data_source_type === DataSourceType.FILE && <FileTypeIcon type={extensionToFileType(doc?.data_source_info?.upload_file?.extension ?? fileType)} className='mr-1.5' />}
|
||||
{doc?.data_source_type === DataSourceType.WEB && <Globe01 className='inline-flex mt-[-3px] mr-1.5 align-middle' />}
|
||||
</div>
|
||||
<span className='text-sm truncate grow-1'>{doc.name}</span>
|
||||
<div className='group-hover:flex group-hover:ml-auto hidden shrink-0'>
|
||||
<Tooltip
|
||||
popupContent={t('datasetDocuments.list.table.rename')}
|
||||
>
|
||||
<div
|
||||
className='p-1 rounded-md cursor-pointer hover:bg-state-base-hover'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleShowRenameModal(doc)
|
||||
}}
|
||||
>
|
||||
<Edit03 className='w-4 h-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<ChunkingModeLabel
|
||||
isGeneralMode={isGeneralMode}
|
||||
isQAMode={isQAMode}
|
||||
/>
|
||||
</td>
|
||||
<td>{renderCount(doc.word_count)}</td>
|
||||
<td>{renderCount(doc.hit_count)}</td>
|
||||
<td className='text-text-secondary text-[13px]'>
|
||||
{formatTime(doc.created_at, t('datasetHitTesting.dateTimeFormat') as string)}
|
||||
</td>
|
||||
<td>
|
||||
{
|
||||
(['indexing', 'splitting', 'parsing', 'cleaning'].includes(doc.indexing_status) && doc?.data_source_type === DataSourceType.NOTION)
|
||||
? <ProgressBar percent={doc.percent || 0} />
|
||||
: <StatusItem status={doc.display_status} />
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<OperationAction
|
||||
embeddingAvailable={embeddingAvailable}
|
||||
datasetId={datasetId}
|
||||
detail={pick(doc, ['name', 'enabled', 'archived', 'id', 'data_source_type', 'doc_form'])}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{(selectedIds.length > 0) && (
|
||||
<BatchAction
|
||||
className='absolute left-0 bottom-16 z-20'
|
||||
@@ -629,10 +631,10 @@ const DocumentList: FC<IDocumentListProps> = ({
|
||||
/>
|
||||
)}
|
||||
{/* Show Pagination only if the total is more than the limit */}
|
||||
{pagination.total && pagination.total > (pagination.limit || 10) && (
|
||||
{pagination.total && (
|
||||
<Pagination
|
||||
{...pagination}
|
||||
className='absolute bottom-0 left-0 w-full px-0 pb-0'
|
||||
className='shrink-0 w-full px-0 pb-0'
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
.modal {
|
||||
max-width: 480px !important;
|
||||
width: 480px !important;
|
||||
padding: 24px 32px !important;
|
||||
}
|
||||
@@ -3,9 +3,8 @@ import { useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
import dayjs from 'dayjs'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import s from './index.module.css'
|
||||
import classNames from '@/utils/classnames'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { LangGeniusVersionResponse } from '@/models/common'
|
||||
import { IS_CE_EDITION } from '@/config'
|
||||
import LogoSite from '@/app/components/base/logo/logo-site'
|
||||
@@ -29,18 +28,18 @@ export default function AccountAbout({
|
||||
<Modal
|
||||
isShow
|
||||
onClose={() => { }}
|
||||
className={s.modal}
|
||||
className='!w-[480px] !max-w-[480px] !px-6 !py-4'
|
||||
>
|
||||
<div className='relative pt-4'>
|
||||
<div className='absolute -top-2 -right-4 flex justify-center items-center w-8 h-8 cursor-pointer' onClick={onCancel}>
|
||||
<RiCloseLine className='w-4 h-4 text-gray-500' />
|
||||
<RiCloseLine className='w-4 h-4 text-text-tertiary' />
|
||||
</div>
|
||||
<div>
|
||||
<LogoSite className='mx-auto mb-2' />
|
||||
<div className='mb-3 text-center text-xs font-normal text-gray-500'>Version {langeniusVersionInfo?.current_version}</div>
|
||||
<div className='mb-4 text-center text-xs font-normal text-gray-700'>
|
||||
<div className='mb-3 text-center text-xs font-normal text-text-tertiary'>Version {langeniusVersionInfo?.current_version}</div>
|
||||
<div className='mb-4 text-center text-xs font-normal text-text-secondary'>
|
||||
<div>© {dayjs().year()} LangGenius, Inc., Contributors.</div>
|
||||
<div className='text-[#1C64F2]'>
|
||||
<div className='text-text-accent'>
|
||||
{
|
||||
IS_CE_EDITION
|
||||
? <Link href={'https://github.com/langgenius/dify/blob/main/LICENSE'} target='_blank' rel='noopener noreferrer'>Open Source License</Link>
|
||||
@@ -52,9 +51,9 @@ export default function AccountAbout({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mb-4 -mx-8 h-[0.5px] bg-gray-200' />
|
||||
<div className='mb-4 -mx-8 h-[0.5px] bg-divider-regular' />
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='text-xs font-medium text-gray-800'>
|
||||
<div className='text-xs font-medium text-text-primary'>
|
||||
{
|
||||
isLatest
|
||||
? t('common.about.latestAvailable', { version: langeniusVersionInfo.latest_version })
|
||||
@@ -62,22 +61,24 @@ export default function AccountAbout({
|
||||
}
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<Link
|
||||
className={classNames(buttonClassName, 'mr-2')}
|
||||
href={'https://github.com/langgenius/dify/releases'}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
>
|
||||
{t('common.about.changeLog')}
|
||||
</Link>
|
||||
<Button className='mr-2'>
|
||||
<Link
|
||||
href={'https://github.com/langgenius/dify/releases'}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
>
|
||||
{t('common.about.changeLog')}
|
||||
</Link>
|
||||
</Button>
|
||||
{
|
||||
!isLatest && !IS_CE_EDITION && (
|
||||
<Link
|
||||
className={classNames(buttonClassName, 'text-primary-600')}
|
||||
href={langeniusVersionInfo.release_notes}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
>
|
||||
{t('common.about.updateNow')}
|
||||
</Link>
|
||||
<Button variant='primary'>
|
||||
<Link
|
||||
href={langeniusVersionInfo.release_notes}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
>
|
||||
{t('common.about.updateNow')}
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import cn from '@/utils/classnames'
|
||||
import { switchWorkspace } from '@/service/common'
|
||||
import { useWorkspacesContext } from '@/context/workspace-context'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import PlanBadge from '../../plan-badge'
|
||||
import type { Plan } from '@/app/components/billing/type'
|
||||
|
||||
const WorkplaceSelector = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -69,6 +71,7 @@ const WorkplaceSelector = () => {
|
||||
<div className='flex py-1 pl-3 pr-2 items-center gap-2 self-stretch hover:bg-state-base-hover rounded-lg' key={workspace.id} onClick={() => handleSwitchWorkspace(workspace.id)}>
|
||||
<div className='flex items-center justify-center w-6 h-6 bg-[#EFF4FF] rounded-md text-xs font-medium text-primary-600'>{workspace.name[0].toLocaleUpperCase()}</div>
|
||||
<div className='line-clamp-1 grow overflow-hidden text-text-secondary text-ellipsis system-md-regular cursor-pointer'>{workspace.name}</div>
|
||||
<PlanBadge size='s' plan={workspace.plan as Plan} />
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -20,49 +20,41 @@ const PlanBadge: FC<PlanBadgeProps> = ({ plan, allowHover, size = 'm', sandboxAs
|
||||
|
||||
if (!isFetchedPlan) return null
|
||||
if (plan === Plan.sandbox && sandboxAsUpgrade) {
|
||||
return <div className='select-none'>
|
||||
<PremiumBadge color='blue' allowHover={allowHover} onClick={onClick}>
|
||||
<SparklesSoft className='flex items-center py-[1px] pl-[3px] w-3.5 h-3.5 text-components-premium-badge-indigo-text-stop-0' />
|
||||
<div className='system-xs-medium'>
|
||||
<span className='p-1'>
|
||||
{t('billing.upgradeBtn.encourageShort')}
|
||||
</span>
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
</div>
|
||||
return <PremiumBadge className='select-none' color='blue' allowHover={allowHover} onClick={onClick}>
|
||||
<SparklesSoft className='flex items-center py-[1px] pl-[3px] w-3.5 h-3.5 text-components-premium-badge-indigo-text-stop-0' />
|
||||
<div className='system-xs-medium'>
|
||||
<span className='p-1'>
|
||||
{t('billing.upgradeBtn.encourageShort')}
|
||||
</span>
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
}
|
||||
if (plan === Plan.sandbox) {
|
||||
return <div className='select-none'>
|
||||
<PremiumBadge size={size} color='gray' allowHover={allowHover} onClick={onClick}>
|
||||
<div className={classNames(size === 's' ? 'system-2xs-medium-uppercase' : 'system-xs-medium-uppercase')}>
|
||||
<span className='p-1'>
|
||||
{plan}
|
||||
</span>
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
</div>
|
||||
return <PremiumBadge className='select-none' size={size} color='gray' allowHover={allowHover} onClick={onClick}>
|
||||
<div className={classNames(size === 's' ? 'system-2xs-medium-uppercase' : 'system-xs-medium-uppercase')}>
|
||||
<span className='p-1'>
|
||||
{plan}
|
||||
</span>
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
}
|
||||
if (plan === Plan.professional) {
|
||||
return <div className='select-none'>
|
||||
<PremiumBadge size={size} color='blue' allowHover={allowHover} onClick={onClick}>
|
||||
<div className={classNames(size === 's' ? 'system-2xs-medium-uppercase' : 'system-xs-medium-uppercase')}>
|
||||
<span className='p-1'>
|
||||
pro
|
||||
</span>
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
</div>
|
||||
return <PremiumBadge className='select-none' size={size} color='blue' allowHover={allowHover} onClick={onClick}>
|
||||
<div className={classNames(size === 's' ? 'system-2xs-medium-uppercase' : 'system-xs-medium-uppercase')}>
|
||||
<span className='p-1'>
|
||||
pro
|
||||
</span>
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
}
|
||||
if (plan === Plan.team) {
|
||||
return <div className='select-none'>
|
||||
<PremiumBadge size={size} color='indigo' allowHover={allowHover} onClick={onClick}>
|
||||
<div className={classNames(size === 's' ? 'system-2xs-medium-uppercase' : 'system-xs-medium-uppercase')}>
|
||||
<span className='p-1'>
|
||||
{plan}
|
||||
</span>
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
</div>
|
||||
return <PremiumBadge className='select-none' size={size} color='indigo' allowHover={allowHover} onClick={onClick}>
|
||||
<div className={classNames(size === 's' ? 'system-2xs-medium-uppercase' : 'system-xs-medium-uppercase')}>
|
||||
<span className='p-1'>
|
||||
{plan}
|
||||
</span>
|
||||
</div>
|
||||
</PremiumBadge>
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export const VarItem: FC<VarItemProps> = ({
|
||||
<div className='py-1'>
|
||||
<div className='flex leading-[18px] items-center'>
|
||||
<div className='code-sm-semibold text-text-secondary'>{name}</div>
|
||||
<div className='ml-2 system-xs-regular text-text-tertiary'>{type}</div>
|
||||
<div className='ml-2 system-xs-regular text-text-tertiary capitalize'>{type}</div>
|
||||
</div>
|
||||
<div className='mt-0.5 system-xs-regular text-text-tertiary'>
|
||||
{description}
|
||||
|
||||
@@ -109,6 +109,7 @@ const Item: FC<ItemProps> = ({
|
||||
'relative w-full flex items-center h-6 pl-3 rounded-md cursor-pointer')
|
||||
}
|
||||
onClick={handleChosen}
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
>
|
||||
<div className='flex items-center w-0 grow'>
|
||||
{!isEnv && !isChatVar && <Variable02 className={cn('shrink-0 w-3.5 h-3.5 text-text-accent', isException && 'text-text-warning')} />}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=18.17.0"
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
CreateDocumentReq,
|
||||
DataSet,
|
||||
DataSetListResponse,
|
||||
DocumentListResponse,
|
||||
ErrorDocsResponse,
|
||||
ExternalAPIDeleteResponse,
|
||||
ExternalAPIItem,
|
||||
@@ -122,10 +121,6 @@ export const fetchProcessRule: Fetcher<ProcessRuleResponse, { params: { document
|
||||
return get<ProcessRuleResponse>('/datasets/process-rule', { params: { document_id: documentId } })
|
||||
}
|
||||
|
||||
export const fetchDocuments: Fetcher<DocumentListResponse, { datasetId: string; params: { keyword: string; page: number; limit: number; sort?: SortType } }> = ({ datasetId, params }) => {
|
||||
return get<DocumentListResponse>(`/datasets/${datasetId}/documents`, { params })
|
||||
}
|
||||
|
||||
export const createFirstDocument: Fetcher<createDocumentResponse, { body: CreateDocumentReq }> = ({ body }) => {
|
||||
return post<createDocumentResponse>('/datasets/init', { body })
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import {
|
||||
} from '@tanstack/react-query'
|
||||
import { del, get, patch } from '../base'
|
||||
import { useInvalid } from '../use-base'
|
||||
import type { MetadataType } from '../datasets'
|
||||
import type { DocumentDetailResponse, SimpleDocumentDetail, UpdateDocumentBatchParams } from '@/models/datasets'
|
||||
import type { MetadataType, SortType } from '../datasets'
|
||||
import type { DocumentDetailResponse, DocumentListResponse, UpdateDocumentBatchParams } from '@/models/datasets'
|
||||
import { DocumentActionType } from '@/models/datasets'
|
||||
import type { CommonResponse } from '@/models/common'
|
||||
|
||||
@@ -18,19 +18,23 @@ export const useDocumentList = (payload: {
|
||||
keyword: string
|
||||
page: number
|
||||
limit: number
|
||||
}
|
||||
sort?: SortType
|
||||
},
|
||||
refetchInterval?: number | false
|
||||
}) => {
|
||||
const { query, datasetId } = payload
|
||||
return useQuery<{ data: SimpleDocumentDetail[] }>({
|
||||
queryKey: [...useDocumentListKey, datasetId, query],
|
||||
queryFn: () => get<{ data: SimpleDocumentDetail[] }>(`/datasets/${datasetId}/documents`, {
|
||||
const { query, datasetId, refetchInterval } = payload
|
||||
const { keyword, page, limit, sort } = query
|
||||
return useQuery<DocumentListResponse>({
|
||||
queryKey: [...useDocumentListKey, datasetId, keyword, page, limit, sort],
|
||||
queryFn: () => get<DocumentListResponse>(`/datasets/${datasetId}/documents`, {
|
||||
params: query,
|
||||
}),
|
||||
refetchInterval,
|
||||
})
|
||||
}
|
||||
|
||||
export const useInvalidDocumentList = () => {
|
||||
return useInvalid(useDocumentListKey)
|
||||
export const useInvalidDocumentList = (datasetId?: string) => {
|
||||
return useInvalid(datasetId ? [...useDocumentListKey, datasetId] : useDocumentListKey)
|
||||
}
|
||||
|
||||
const useAutoDisabledDocumentKey = [NAME_SPACE, 'autoDisabledDocument']
|
||||
|
||||
+1
-2
@@ -17,7 +17,6 @@ import type {
|
||||
LogMessageAnnotationsResponse,
|
||||
LogMessageFeedbacksRequest,
|
||||
LogMessageFeedbacksResponse,
|
||||
WorkflowLogsRequest,
|
||||
WorkflowLogsResponse,
|
||||
WorkflowRunDetailResponse,
|
||||
} from '@/models/log'
|
||||
@@ -64,7 +63,7 @@ export const fetchAnnotationsCount: Fetcher<AnnotationsCountResponse, { url: str
|
||||
return get<AnnotationsCountResponse>(url)
|
||||
}
|
||||
|
||||
export const fetchWorkflowLogs: Fetcher<WorkflowLogsResponse, { url: string; params?: WorkflowLogsRequest }> = ({ url, params }) => {
|
||||
export const fetchWorkflowLogs: Fetcher<WorkflowLogsResponse, { url: string; params: Record<string, any> }> = ({ url, params }) => {
|
||||
return get<WorkflowLogsResponse>(url, { params })
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user