Compare commits

...
Author SHA1 Message Date
lyzno1 18d7c99ab7 Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-17 16:27:50 +08:00
lyzno1andGitHub de2a469048 feat: add trigger limit modal (#28257)
Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
2025-11-17 16:27:24 +08:00
zhsamaandGitHub 5d2fbf5215 Perf/mutual node UI (#28282) 2025-11-17 16:23:04 +08:00
lyzno1 1730572498 feat: enforce sandbox start-node limit by disabling publish and surfacing an upgrade CTA with localized copy 2025-11-15 15:02:49 +08:00
lyzno1 9e763e80e8 feat: add resets time in billing usage info 2025-11-15 13:04:48 +08:00
lyzno1 196bf3d9a0 fix: align without brand template 2025-11-15 11:34:51 +08:00
lyzno1 c78fbabe60 fix: email template design 2025-11-15 11:23:59 +08:00
lyzno1 7666013227 Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-15 10:24:16 +08:00
非法操作andGitHub 4a89403566 fix: click log panel of log page cause whole page crash (#28218) 2025-11-14 16:38:43 +09:00
crazywoolaandGitHub e0c05b2123 add icon for forum (#28164) 2025-11-14 16:38:19 +09:00
lyzno1andGitHub 85b99580ea fix: card view render (#28189) 2025-11-14 14:16:11 +08:00
lyzno1andGitHub 15fbedfcad feat: add icon gallery stories (#28214)
Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com>
2025-11-14 13:34:23 +08:00
lyzno1 20b1b1bf43 Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-14 10:44:35 +08:00
非法操作andGitHub 1e6d0de48b fix: knowledge pipeline can not published (#28203) 2025-11-14 09:47:37 +08:00
Anubhav SinghandGitHub cad751c00c Upgrade weave version to fix weave configuration failure (#28197) 2025-11-14 09:47:21 +08:00
MariesGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a47276ac24 chore: bump to 1.10.0 (#28186)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-11-13 22:36:04 +08:00
lyzno1 55e536b1e0 Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 22:08:11 +08:00
yangzheliandGitHub 20403c69b2 refactor(web): remove redundant add-tool-modal components and related code (#27996) 2025-11-13 20:21:04 +08:00
hofferandGitHub ffc04f2a9b fix: StreamableHTTPTransport got invalid json exception when receive a ping event from mcp server #28111 (#28116) 2025-11-13 20:19:48 +08:00
Asuka MinatoGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Copilot
d1580791e4 TypedBase + TypedDict (#28137)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 20:18:51 +08:00
NeatGuyCodingandGitHub c74eb4fcf3 minor fix(rag): return early when pushing empty tasks to avoid Redis DataError (#28027)
Signed-off-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com>
2025-11-13 20:18:11 +08:00
NeatGuyCodingandGitHub a798534337 fix(web): fix unit promotion in formatNumberAbbreviated (#27918)
Signed-off-by: NeatGuyCoding <15627489+NeatGuyCoding@users.noreply.github.com>
2025-11-13 20:17:26 +08:00
GuanMuandGitHub 470883858e fix: adjust padding in AgentNode and NodeComponent for consistent layout (#28175) 2025-11-13 20:16:56 +08:00
GuanMuandGitHub 4f4911686d fix: update start-worker alias to include additional queues for bette… (#28179) 2025-11-13 20:16:44 +08:00
GuanMuandGitHub 6d479dcdbb fix: update package manager version to 10.22.0 (#28181) 2025-11-13 20:16:00 +08:00
lyzno1 e6bc5a9629 Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 19:09:39 +08:00
zhsamaandGitHub 24348c40a6 feat: enhance start node metadata to be undeletable in chat mode (#28173) 2025-11-13 18:11:15 +08:00
lyzno1 f021b8248e refactor: usage info bar color 2025-11-13 17:36:38 +08:00
lyzno1 0ac9b308cf chore: rm per month unit in cards 2025-11-13 17:34:56 +08:00
lyzno1 6e5486b556 fix: remove 'Current plan' in billing setting 2025-11-13 17:22:38 +08:00
lyzno1 0f6e058c73 refactor: billing cards icon 2025-11-13 17:20:51 +08:00
lyzno1 4bb225c7ee feat: add tooltip in api rate limit card 2025-11-13 17:16:16 +08:00
lyzno1 79fdc5b07b fix: align translations 2025-11-13 17:03:54 +08:00
lyzno1 e7794be27a fix: billing titlePerMonth translations 2025-11-13 16:39:31 +08:00
lyzno1 ae36958ef4 refactor: pricing modal 2025-11-13 16:37:28 +08:00
lyzno1 86bc2924f3 Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 15:58:43 +08:00
yihongGitHubCopilotautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>crazywoola
a39b50adbb fix: skip tests if no database run (#28102)
Signed-off-by: yihong0618 <zouzou0208@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2025-11-13 15:57:13 +08:00
李龙飞GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>crazywoola
81832c14ee Fix: Correctly handle merged cells in DOCX tables to prevent content duplication and loss (#27871)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2025-11-13 15:56:24 +08:00
lyzno1 318b9d707b Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 15:45:44 +08:00
zhsamaGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
b86022c64a feat: add draft trigger detection to app model and UI (#28163)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2025-11-13 15:43:58 +08:00
lyzno1 9174597eb8 Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 15:43:27 +08:00
lyzno1 0e689b14a6 fix: align translations 2025-11-13 15:37:27 +08:00
breath57andGitHub 45e816a9f6 fix(knowledge-base): regenerate child chunks not working completely (#27934) 2025-11-13 15:36:27 +08:00
JoelandGitHub 667b1c37a3 fix: can still invite when api is pending (#28161) 2025-11-13 15:28:32 +08:00
lyzno1 9c09c993ba Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 15:26:20 +08:00
lyzno1 7a810a4412 Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 15:21:48 +08:00
b75d533f9b fix(moderation): change OpenAI moderation model to omni-moderation-la… (#28119)
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2025-11-13 15:21:44 +08:00
aece55d82f fix: fixed error when clear value of INTEGER and FLOAT type (#27954)
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2025-11-13 15:21:34 +08:00
lyzno1 639a78fc7b Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 15:18:09 +08:00
c432b398f4 fix: missing pipeline_templates.json when HOSTED_FETCH_PIPELINE_TEMPLATES_MODE is builtin (#27946)
Signed-off-by: kenwoodjw <blackxin55+@gmail.com>
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
2025-11-13 15:04:35 +08:00
lyzno1 dfff9ec00a Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 15:02:48 +08:00
katakyoandGitHub 9cb2645793 fix: update input field width for retry configuration in RetryOnPanel (#28142) 2025-11-13 15:00:22 +08:00
ye4241andGitHub 6ac61bd585 fix: correct spelling of "模板" in translation files (#28151) 2025-11-13 14:58:10 +08:00
lyzno1 d16d61425f Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 14:42:54 +08:00
非法操作andGitHub b02165ffe6 fix: inconsistent behaviour of zoom in button and shortcut (#27944) 2025-11-13 14:37:27 +08:00
lyzno1 3c06b62fc5 Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 13:12:49 +08:00
lyzno1 5ffe7f8c0c Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 11:49:26 +08:00
lyzno1 87954a8226 Merge remote-tracking branch 'origin/main' into feat/trigger-saas 2025-11-13 11:37:48 +08:00
lyzno1 6430e014b0 feat: align trigger usage text and add tooltips 2025-11-12 18:26:08 +08:00
lyzno1 494f6b06e1 fix: email template 2025-11-12 18:26:08 +08:00
lyzno1 404240baf9 fix: template translations 2025-11-12 18:26:08 +08:00
lyzno1 8fb027d331 feat: add sandbox api rate limit email template 2025-11-12 18:26:08 +08:00
lyzno1 d3d3868b4a feat: add trigger events usage warning email template 2025-11-12 18:26:08 +08:00
lyzno1 7c5a008f5c feat: add trigger events limit email template 2025-11-12 18:26:08 +08:00
151 changed files with 12772 additions and 1925 deletions
+1 -2
View File
@@ -6,11 +6,10 @@ cd web && pnpm install
pipx install uv
echo "alias start-api=\"cd $WORKSPACE_ROOT/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug\"" >> ~/.bashrc
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,generation,mail,ops_trace,app_deletion,plugin,workflow_storage\"" >> ~/.bashrc
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor\"" >> ~/.bashrc
echo "alias start-web=\"cd $WORKSPACE_ROOT/web && pnpm dev\"" >> ~/.bashrc
echo "alias start-web-prod=\"cd $WORKSPACE_ROOT/web && pnpm build && pnpm start\"" >> ~/.bashrc
echo "alias start-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d\"" >> ~/.bashrc
echo "alias stop-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env down\"" >> ~/.bashrc
source /home/vscode/.bashrc
File diff suppressed because one or more lines are too long
+31 -1
View File
@@ -15,11 +15,12 @@ from controllers.console.wraps import (
setup_required,
)
from core.ops.ops_trace_manager import OpsTraceManager
from core.workflow.enums import NodeType
from extensions.ext_database import db
from fields.app_fields import app_detail_fields, app_detail_fields_with_site, app_pagination_fields
from libs.login import current_account_with_tenant, login_required
from libs.validators import validate_description_length
from models import App
from models import App, Workflow
from services.app_dsl_service import AppDslService, ImportMode
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
@@ -106,6 +107,35 @@ class AppListApi(Resource):
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
workflow_capable_app_ids = [
str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"}
]
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
db.session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),
)
)
.scalars()
.all()
)
trigger_node_types = {
NodeType.TRIGGER_WEBHOOK,
NodeType.TRIGGER_SCHEDULE,
NodeType.TRIGGER_PLUGIN,
}
for workflow in draft_workflows:
for _, node_data in workflow.walk_nodes():
if node_data.get("type") in trigger_node_types:
draft_trigger_app_ids.add(str(workflow.app_id))
break
for app in app_pagination.items:
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
return marshal(app_pagination, app_pagination_fields), 200
@api.doc("create_app")
+4
View File
@@ -138,6 +138,10 @@ class StreamableHTTPTransport:
) -> bool:
"""Handle an SSE event, returning True if the response is complete."""
if sse.event == "message":
# ping event send by server will be recognized as a message event with empty data by httpx-sse's SSEDecoder
if not sse.data.strip():
return False
try:
message = JSONRPCMessage.model_validate_json(sse.data)
logger.debug("SSE message: %s", message)
@@ -52,7 +52,7 @@ class OpenAIModeration(Moderation):
text = "\n".join(str(inputs.values()))
model_manager = ModelManager()
model_instance = model_manager.get_model_instance(
tenant_id=self.tenant_id, provider="openai", model_type=ModelType.MODERATION, model="text-moderation-stable"
tenant_id=self.tenant_id, provider="openai", model_type=ModelType.MODERATION, model="omni-moderation-latest"
)
openai_moderation = model_instance.invoke_moderation(text=text)
+5 -3
View File
@@ -152,13 +152,15 @@ class WordExtractor(BaseExtractor):
# Initialize a row, all of which are empty by default
row_cells = [""] * total_cols
col_index = 0
for cell in row.cells:
while col_index < len(row.cells):
# make sure the col_index is not out of range
while col_index < total_cols and row_cells[col_index] != "":
while col_index < len(row.cells) and row_cells[col_index] != "":
col_index += 1
# if col_index is out of range the loop is jumped
if col_index >= total_cols:
if col_index >= len(row.cells):
break
# get the correct cell
cell = row.cells[col_index]
cell_content = self._parse_cell(cell, image_map).strip()
cell_colspan = cell.grid_span or 1
for i in range(cell_colspan):
+3
View File
@@ -54,6 +54,9 @@ class TenantIsolatedTaskQueue:
serialized_data = wrapper.serialize()
serialized_tasks.append(serialized_data)
if not serialized_tasks:
return
redis_client.lpush(self._queue, *serialized_tasks)
def pull_tasks(self, count: int = 1) -> Sequence[Any]:
+29
View File
@@ -202,6 +202,35 @@ class SegmentType(StrEnum):
raise ValueError(f"element_type is only supported by array type, got {self}")
return _ARRAY_ELEMENT_TYPES_MAPPING.get(self)
@staticmethod
def get_zero_value(t: "SegmentType"):
# Lazy import to avoid circular dependency
from factories import variable_factory
match t:
case (
SegmentType.ARRAY_OBJECT
| SegmentType.ARRAY_ANY
| SegmentType.ARRAY_STRING
| SegmentType.ARRAY_NUMBER
| SegmentType.ARRAY_BOOLEAN
):
return variable_factory.build_segment_with_type(t, [])
case SegmentType.OBJECT:
return variable_factory.build_segment({})
case SegmentType.STRING:
return variable_factory.build_segment("")
case SegmentType.INTEGER:
return variable_factory.build_segment(0)
case SegmentType.FLOAT:
return variable_factory.build_segment(0.0)
case SegmentType.NUMBER:
return variable_factory.build_segment(0)
case SegmentType.BOOLEAN:
return variable_factory.build_segment(False)
case _:
raise ValueError(f"unsupported variable type: {t}")
_ARRAY_ELEMENT_TYPES_MAPPING: Mapping[SegmentType, SegmentType] = {
# ARRAY_ANY does not have corresponding element type.
@@ -2,7 +2,6 @@ from collections.abc import Callable, Mapping, Sequence
from typing import TYPE_CHECKING, Any, TypeAlias
from core.variables import SegmentType, Variable
from core.variables.segments import BooleanSegment
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID
from core.workflow.conversation_variable_updater import ConversationVariableUpdater
from core.workflow.entities import GraphInitParams
@@ -12,7 +11,6 @@ from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.variable_assigner.common import helpers as common_helpers
from core.workflow.nodes.variable_assigner.common.exc import VariableOperatorNodeError
from factories import variable_factory
from ..common.impl import conversation_variable_updater_factory
from .node_data import VariableAssignerData, WriteMode
@@ -116,7 +114,7 @@ class VariableAssignerNode(Node):
updated_variable = original_variable.model_copy(update={"value": updated_value})
case WriteMode.CLEAR:
income_value = get_zero_value(original_variable.value_type)
income_value = SegmentType.get_zero_value(original_variable.value_type)
updated_variable = original_variable.model_copy(update={"value": income_value.to_object()})
# Over write the variable.
@@ -143,24 +141,3 @@ class VariableAssignerNode(Node):
process_data=common_helpers.set_updated_variables({}, updated_variables),
outputs={},
)
def get_zero_value(t: SegmentType):
# TODO(QuantumGhost): this should be a method of `SegmentType`.
match t:
case SegmentType.ARRAY_OBJECT | SegmentType.ARRAY_STRING | SegmentType.ARRAY_NUMBER | SegmentType.ARRAY_BOOLEAN:
return variable_factory.build_segment_with_type(t, [])
case SegmentType.OBJECT:
return variable_factory.build_segment({})
case SegmentType.STRING:
return variable_factory.build_segment("")
case SegmentType.INTEGER:
return variable_factory.build_segment(0)
case SegmentType.FLOAT:
return variable_factory.build_segment(0.0)
case SegmentType.NUMBER:
return variable_factory.build_segment(0)
case SegmentType.BOOLEAN:
return BooleanSegment(value=False)
case _:
raise VariableOperatorNodeError(f"unsupported variable type: {t}")
@@ -1,14 +0,0 @@
from core.variables import SegmentType
# Note: This mapping is duplicated with `get_zero_value`. Consider refactoring to avoid redundancy.
EMPTY_VALUE_MAPPING = {
SegmentType.STRING: "",
SegmentType.NUMBER: 0,
SegmentType.BOOLEAN: False,
SegmentType.OBJECT: {},
SegmentType.ARRAY_ANY: [],
SegmentType.ARRAY_STRING: [],
SegmentType.ARRAY_NUMBER: [],
SegmentType.ARRAY_OBJECT: [],
SegmentType.ARRAY_BOOLEAN: [],
}
@@ -16,7 +16,6 @@ from core.workflow.nodes.variable_assigner.common.exc import VariableOperatorNod
from core.workflow.nodes.variable_assigner.common.impl import conversation_variable_updater_factory
from . import helpers
from .constants import EMPTY_VALUE_MAPPING
from .entities import VariableAssignerNodeData, VariableOperationItem
from .enums import InputType, Operation
from .exc import (
@@ -249,7 +248,7 @@ class VariableAssignerNode(Node):
case Operation.OVER_WRITE:
return value
case Operation.CLEAR:
return EMPTY_VALUE_MAPPING[variable.value_type]
return SegmentType.get_zero_value(variable.value_type).to_object()
case Operation.APPEND:
return variable.value + [value]
case Operation.EXTEND:
@@ -3,7 +3,7 @@ import io
import json
from collections.abc import Generator
from google.cloud import storage as google_cloud_storage
from google.cloud import storage as google_cloud_storage # type: ignore
from configs import dify_config
from extensions.storage.base_storage import BaseStorage
+1
View File
@@ -116,6 +116,7 @@ app_partial_fields = {
"access_mode": fields.String,
"create_user_name": fields.String,
"author_name": fields.String,
"has_draft_trigger": fields.Boolean,
}
+78
View File
@@ -38,6 +38,12 @@ class EmailType(StrEnum):
EMAIL_REGISTER = auto()
EMAIL_REGISTER_WHEN_ACCOUNT_EXIST = auto()
RESET_PASSWORD_WHEN_ACCOUNT_NOT_EXIST_NO_REGISTER = auto()
TRIGGER_EVENTS_LIMIT_SANDBOX = auto()
TRIGGER_EVENTS_LIMIT_PROFESSIONAL = auto()
TRIGGER_EVENTS_USAGE_WARNING_SANDBOX = auto()
TRIGGER_EVENTS_USAGE_WARNING_PROFESSIONAL = auto()
API_RATE_LIMIT_LIMIT_SANDBOX = auto()
API_RATE_LIMIT_WARNING_SANDBOX = auto()
class EmailLanguage(StrEnum):
@@ -445,6 +451,78 @@ def create_default_email_config() -> EmailI18nConfig:
branded_template_path="clean_document_job_mail_template_zh-CN.html",
),
},
EmailType.TRIGGER_EVENTS_LIMIT_SANDBOX: {
EmailLanguage.EN_US: EmailTemplate(
subject="Youve reached your Sandbox Trigger Events limit",
template_path="trigger_events_limit_template_en-US.html",
branded_template_path="without-brand/trigger_events_limit_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="您的 Sandbox 触发事件额度已用尽",
template_path="trigger_events_limit_template_zh-CN.html",
branded_template_path="without-brand/trigger_events_limit_template_zh-CN.html",
),
},
EmailType.TRIGGER_EVENTS_LIMIT_PROFESSIONAL: {
EmailLanguage.EN_US: EmailTemplate(
subject="Youve reached your monthly Trigger Events limit",
template_path="trigger_events_limit_template_en-US.html",
branded_template_path="without-brand/trigger_events_limit_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="您的月度触发事件额度已用尽",
template_path="trigger_events_limit_template_zh-CN.html",
branded_template_path="without-brand/trigger_events_limit_template_zh-CN.html",
),
},
EmailType.TRIGGER_EVENTS_USAGE_WARNING_SANDBOX: {
EmailLanguage.EN_US: EmailTemplate(
subject="Youre nearing your Sandbox Trigger Events limit",
template_path="trigger_events_usage_warning_template_en-US.html",
branded_template_path="without-brand/trigger_events_usage_warning_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="您的 Sandbox 触发事件额度接近上限",
template_path="trigger_events_usage_warning_template_zh-CN.html",
branded_template_path="without-brand/trigger_events_usage_warning_template_zh-CN.html",
),
},
EmailType.TRIGGER_EVENTS_USAGE_WARNING_PROFESSIONAL: {
EmailLanguage.EN_US: EmailTemplate(
subject="Youre nearing your Monthly Trigger Events limit",
template_path="trigger_events_usage_warning_template_en-US.html",
branded_template_path="without-brand/trigger_events_usage_warning_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="您的月度触发事件额度接近上限",
template_path="trigger_events_usage_warning_template_zh-CN.html",
branded_template_path="without-brand/trigger_events_usage_warning_template_zh-CN.html",
),
},
EmailType.API_RATE_LIMIT_LIMIT_SANDBOX: {
EmailLanguage.EN_US: EmailTemplate(
subject="Youve reached your API Rate Limit",
template_path="api_rate_limit_limit_template_en-US.html",
branded_template_path="without-brand/api_rate_limit_limit_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="您的 API 速率额度已用尽",
template_path="api_rate_limit_limit_template_zh-CN.html",
branded_template_path="without-brand/api_rate_limit_limit_template_zh-CN.html",
),
},
EmailType.API_RATE_LIMIT_WARNING_SANDBOX: {
EmailLanguage.EN_US: EmailTemplate(
subject="Youre nearing your API Rate Limit",
template_path="api_rate_limit_warning_template_en-US.html",
branded_template_path="without-brand/api_rate_limit_warning_template_en-US.html",
),
EmailLanguage.ZH_HANS: EmailTemplate(
subject="您的 API 速率额度接近上限",
template_path="api_rate_limit_warning_template_zh-CN.html",
branded_template_path="without-brand/api_rate_limit_warning_template_zh-CN.html",
),
},
EmailType.EMAIL_REGISTER: {
EmailLanguage.EN_US: EmailTemplate(
subject="Register Your {application_title} Account",
+10 -5
View File
@@ -21,6 +21,7 @@ from configs import dify_config
from core.rag.index_processor.constant.built_in_field import BuiltInField, MetadataDataSource
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from extensions.ext_storage import storage
from models.base import TypeBase
from services.entities.knowledge_entities.knowledge_entities import ParentMode, Rule
from .account import Account
@@ -906,17 +907,21 @@ class ChildChunk(Base):
return db.session.query(DocumentSegment).where(DocumentSegment.id == self.segment_id).first()
class AppDatasetJoin(Base):
class AppDatasetJoin(TypeBase):
__tablename__ = "app_dataset_joins"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="app_dataset_join_pkey"),
sa.Index("app_dataset_join_app_dataset_idx", "dataset_id", "app_id"),
)
id = mapped_column(StringUUID, primary_key=True, nullable=False, server_default=sa.text("uuid_generate_v4()"))
app_id = mapped_column(StringUUID, nullable=False)
dataset_id = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=sa.func.current_timestamp())
id: Mapped[str] = mapped_column(
StringUUID, primary_key=True, nullable=False, server_default=sa.text("uuid_generate_v4()"), init=False
)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=sa.func.current_timestamp(), init=False
)
@property
def app(self):
+3 -3
View File
@@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.9.2"
version = "1.10.0"
requires-python = ">=3.11,<3.13"
dependencies = [
@@ -37,7 +37,7 @@ dependencies = [
"numpy~=1.26.4",
"openpyxl~=3.1.5",
"opik~=1.8.72",
"litellm==1.77.1", # Pinned to avoid madoka dependency issue
"litellm==1.77.1", # Pinned to avoid madoka dependency issue
"opentelemetry-api==1.27.0",
"opentelemetry-distro==0.48b0",
"opentelemetry-exporter-otlp==1.27.0",
@@ -79,7 +79,6 @@ dependencies = [
"tiktoken~=0.9.0",
"transformers~=4.56.1",
"unstructured[docx,epub,md,ppt,pptx]~=0.16.1",
"weave~=0.51.0",
"yarl~=1.18.3",
"webvtt-py~=0.5.1",
"sseclient-py~=1.8.0",
@@ -90,6 +89,7 @@ dependencies = [
"croniter>=6.0.0",
"weaviate-client==4.17.0",
"apscheduler>=3.11.0",
"weave>=0.52.16",
]
# Before adding new dependency, consider place it in
# alphabet order (a-z) and suitable group.
+16 -10
View File
@@ -1,5 +1,5 @@
import json
from typing import Any
from typing import Any, TypedDict
from core.app.app_config.entities import (
DatasetEntity,
@@ -28,6 +28,12 @@ from models.model import App, AppMode, AppModelConfig
from models.workflow import Workflow, WorkflowType
class _NodeType(TypedDict):
id: str
position: None
data: dict[str, Any]
class WorkflowConverter:
"""
App Convert to Workflow Mode
@@ -217,7 +223,7 @@ class WorkflowConverter:
return app_config
def _convert_to_start_node(self, variables: list[VariableEntity]):
def _convert_to_start_node(self, variables: list[VariableEntity]) -> _NodeType:
"""
Convert to Start Node
:param variables: list of variables
@@ -235,7 +241,7 @@ class WorkflowConverter:
def _convert_to_http_request_node(
self, app_model: App, variables: list[VariableEntity], external_data_variables: list[ExternalDataVariableEntity]
) -> tuple[list[dict], dict[str, str]]:
) -> tuple[list[_NodeType], dict[str, str]]:
"""
Convert API Based Extension to HTTP Request Node
:param app_model: App instance
@@ -285,7 +291,7 @@ class WorkflowConverter:
request_body_json = json.dumps(request_body)
request_body_json = request_body_json.replace(r"\{\{", "{{").replace(r"\}\}", "}}")
http_request_node = {
http_request_node: _NodeType = {
"id": f"http_request_{index}",
"position": None,
"data": {
@@ -303,7 +309,7 @@ class WorkflowConverter:
nodes.append(http_request_node)
# append code node for response body parsing
code_node: dict[str, Any] = {
code_node: _NodeType = {
"id": f"code_{index}",
"position": None,
"data": {
@@ -326,7 +332,7 @@ class WorkflowConverter:
def _convert_to_knowledge_retrieval_node(
self, new_app_mode: AppMode, dataset_config: DatasetEntity, model_config: ModelConfigEntity
) -> dict | None:
) -> _NodeType | None:
"""
Convert datasets to Knowledge Retrieval Node
:param new_app_mode: new app mode
@@ -384,7 +390,7 @@ class WorkflowConverter:
prompt_template: PromptTemplateEntity,
file_upload: FileUploadConfig | None = None,
external_data_variable_node_mapping: dict[str, str] | None = None,
):
) -> _NodeType:
"""
Convert to LLM Node
:param original_app_mode: original app mode
@@ -561,7 +567,7 @@ class WorkflowConverter:
return template
def _convert_to_end_node(self):
def _convert_to_end_node(self) -> _NodeType:
"""
Convert to End Node
:return:
@@ -577,7 +583,7 @@ class WorkflowConverter:
},
}
def _convert_to_answer_node(self):
def _convert_to_answer_node(self) -> _NodeType:
"""
Convert to Answer Node
:return:
@@ -598,7 +604,7 @@ class WorkflowConverter:
"""
return {"id": f"{source}-{target}", "source": source, "target": target}
def _append_node(self, graph: dict, node: dict):
def _append_node(self, graph: dict[str, Any], node: _NodeType):
"""
Append Node to Graph
@@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-family: 'Inter', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 434px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-header img {
width: 68px;
height: auto;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<div class="card-content">
<h1 class="title">Youve reached your API Rate Limit</h1>
<div class="body-group">
<p class="body-text">
<strong>Dear {{ recipientName | default(workspaceName ~ ' team', true) }},</strong>
</p>
<p class="body-text">
Your workspace <strong>{{workspaceName}}</strong> has used all available <strong>Monthly API Rate Limit</strong> for the
<strong>{{planName}} Plan (limit: {{planLimit}})</strong>.
</p>
<p class="body-text">
As a result, API access has been temporarily paused.
</p>
<p class="body-text">
To continue using the Dify API and unlock a higher limit, please upgrade to a paid plan.
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>Monthly API Rate Limit</strong> for the <strong>{{planName}} Plan</strong> will reset on <strong>{{resetDate}}</strong>.
</p>
</div>
<div class="signature">
<p class="signature-text">Best regards,</p>
<p class="signature-text">The Dify Team</p>
</div>
</div>
</div>
<p class="email-footer">Please do not reply directly to this email, it is automatically sent by the system.</p>
</body>
</html>
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<style>
body {
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 434px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-header img {
width: 68px;
height: auto;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<div class="card-content">
<h1 class="title">您的 API 速率额度已用尽</h1>
<div class="body-group">
<p class="body-text">
<strong>亲爱的 {{ recipientName | default(workspaceName, true) }}</strong>
</p>
<p class="body-text">
您的工作区 <strong>{{workspaceName}}</strong> 已用完 <strong>月度 API 速率额度</strong>,触及
<strong>{{planName}} 计划(上限:{{planLimit}}</strong>
</p>
<p class="body-text">
因此,API 访问已被暂时暂停。
</p>
<p class="body-text">
若要继续使用 Dify API 并解锁更高额度,请升级到付费套餐。
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>{{planName}} 计划的月度 API 速率额度</strong> 将于 <strong>{{resetDate}}</strong> 重置。</p>
</div>
<div class="signature">
<p class="signature-text">此致敬礼,</p>
<p class="signature-text">Dify 团队</p>
</div>
</div>
</div>
<p class="email-footer">请勿直接回复此邮件,该邮件由系统自动发送。</p>
</body>
</html>
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-family: 'Inter', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 454px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-header img {
width: 68px;
height: auto;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<div class="card-content">
<h1 class="title">Youre nearing your API Rate Limit</h1>
<div class="body-group">
<p class="body-text">
<strong>Dear {{ recipientName | default(workspaceName ~ ' team', true) }},</strong>
</p>
<p class="body-text">
Your workspace <strong>{{workspaceName}}</strong> has used <strong>80% of its Monthly API Rate Limit</strong> for the
<strong>{{planName}} Plan (limit: {{planLimit}})</strong>.
</p>
<p class="body-text">
Once the limit is reached, API access will be temporarily paused until the next monthly reset.
</p>
<p class="body-text">
To avoid service interruptions and ensure continued access to the Dify API, please consider upgrading your plan for a higher API
Rate Limit.
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>Monthly API Rate Limit</strong> for the <strong>{{planName}} Plan</strong> will reset on <strong>{{resetDate}}</strong>.
</p>
</div>
<div class="signature">
<p class="signature-text">Best regards,</p>
<p class="signature-text">The Dify Team</p>
</div>
</div>
</div>
<p class="email-footer">Please do not reply directly to this email, it is automatically sent by the system.</p>
</body>
</html>
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<style>
body {
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 454px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-header img {
width: 68px;
height: auto;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<div class="card-content">
<h1 class="title">您的 API 速率额度接近上限</h1>
<div class="body-group">
<p class="body-text">
<strong>亲爱的 {{ recipientName | default(workspaceName, true) }}</strong>
</p>
<p class="body-text">
您的工作区 <strong>{{workspaceName}}</strong> 已使用 <strong>80% 的月度 API 速率额度</strong>,触及
<strong>{{planName}} 计划(上限:{{planLimit}}</strong>
</p>
<p class="body-text">
一旦达到上限,API 访问将暂停,直至下一个月度重置。
</p>
<p class="body-text">
为避免服务中断并持续访问 Dify API,请考虑升级到额度更高的套餐。
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>{{planName}} 计划的月度 API 速率额度</strong> 将于 <strong>{{resetDate}}</strong> 重置。</p>
</div>
<div class="signature">
<p class="signature-text">此致敬礼,</p>
<p class="signature-text">Dify 团队</p>
</div>
</div>
</div>
<p class="email-footer">请勿直接回复此邮件,该邮件由系统自动发送。</p>
</body>
</html>
@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-family: 'Inter', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 454px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-header img {
width: 68px;
height: auto;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<div class="card-content">
<h1 class="title">Youve reached your trigger events limit</h1>
<div class="body-group">
<p class="body-text">
<strong>Dear {{ recipientName | default(workspaceName ~ ' team', true) }},</strong>
</p>
<p class="body-text">
Your workspace <strong>{{workspaceName}}</strong> has used all available <strong>{{usageScope | default('Trigger Events')}}</strong> for the
<strong>{{planName}} Plan (limit: {{planLimit}})</strong>.
</p>
<p class="body-text">
Workflows triggered by <strong>{{triggerSources}}</strong> events have been temporarily paused.
</p>
<p class="body-text">
To keep your workflows running without interruption, please upgrade your plan to unlock more Trigger Events.
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>
{% if resetLine is defined %}
{{ resetLine }}
{% else %}
Trigger Events for the {{planName}} Plan {{resetDescription}}
{% endif %}
</strong>
</p>
</div>
<div class="signature">
<p class="signature-text">Best regards,</p>
<p class="signature-text">The Dify Team</p>
</div>
</div>
</div>
<p class="email-footer">Please do not reply directly to this email, it is automatically sent by the system.</p>
</body>
</html>
@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<style>
body {
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 454px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-header img {
width: 68px;
height: auto;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<div class="card-content">
<h1 class="title">您的触发事件额度已用尽</h1>
<div class="body-group">
<p class="body-text">
<strong>亲爱的 {{ recipientName | default(workspaceName, true) }}</strong>
</p>
<p class="body-text">
您的工作区 <strong>{{workspaceName}}</strong> 已用完 <strong>{{usageScope | default('触发事件额度')}}</strong>,并耗尽
<strong>{{planName}} 计划(上限:{{planLimit}}</strong> 的全部额度。
</p>
<p class="body-text">
<strong>{{triggerSources}}</strong> 触发的工作流已被暂时暂停。
</p>
<p class="body-text">
为保证工作流不中断,请升级套餐以解锁更多触发事件额度。
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>
{% if resetLine is defined %}
{{ resetLine }}
{% else %}
{{planName}} 计划的触发事件额度{{resetDescription}}
{% endif %}
</strong>
</p>
</div>
<div class="signature">
<p class="signature-text">此致敬礼,</p>
<p class="signature-text">Dify 团队</p>
</div>
</div>
</div>
<p class="email-footer">请勿直接回复此邮件,该邮件由系统自动发送。</p>
</body>
</html>
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-family: 'Inter', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 454px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-header img {
width: 68px;
height: auto;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<div class="card-content">
<h1 class="title">Youre nearing your Trigger Events limit</h1>
<div class="body-group">
<p class="body-text">
<strong>Dear {{ recipientName | default(workspaceName ~ ' team', true) }},</strong>
</p>
<p class="body-text">
Your workspace <strong>{{workspaceName}}</strong> has used <strong>{{usagePercent}}</strong> of its
<strong>{{usageScope}}</strong> for the <strong>{{planName}} Plan (limit: {{planLimit}})</strong>.
</p>
<p class="body-text">
Once the limit is reached, workflows triggered by <strong>{{triggerSources}}</strong> events will be temporarily
paused.
</p>
<p class="body-text">
{{upgradeHint}}
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>
{% if resetLine is defined %}
{{ resetLine }}
{% else %}
Trigger Events for the {{planName}} Plan {{resetDescription}}
{% endif %}
</strong>
</p>
</div>
<div class="signature">
<p class="signature-text">Best regards,</p>
<p class="signature-text">The Dify Team</p>
</div>
</div>
</div>
<p class="email-footer">Please do not reply directly to this email, it is automatically sent by the system.</p>
</body>
</html>
@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<style>
body {
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 454px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-header img {
width: 68px;
height: auto;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
<img src="https://assets.dify.ai/images/logo.png" alt="Dify Logo" />
</div>
<div class="card-content">
<h1 class="title">您的触发事件额度接近上限</h1>
<div class="body-group">
<p class="body-text">
<strong>亲爱的 {{ recipientName | default(workspaceName, true) }}</strong>
</p>
<p class="body-text">
您的工作区 <strong>{{workspaceName}}</strong> 已使用 <strong>{{usagePercent}}</strong>
<strong>{{usageScope}}</strong>,触及 <strong>{{planName}} 计划(上限:{{planLimit}}</strong>
</p>
<p class="body-text">
一旦达到上限,由 <strong>{{triggerSources}}</strong> 触发的工作流将被暂时暂停。
</p>
<p class="body-text">
{{upgradeHint}}
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>
{% if resetLine is defined %}
{{ resetLine }}
{% else %}
{{planName}} 计划的触发事件额度{{resetDescription}}
{% endif %}
</strong>
</p>
</div>
<div class="signature">
<p class="signature-text">此致敬礼,</p>
<p class="signature-text">Dify 团队</p>
</div>
</div>
</div>
<p class="email-footer">请勿直接回复此邮件,该邮件由系统自动发送。</p>
</body>
</html>
@@ -0,0 +1,173 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-family: 'Inter', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 434px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
</div>
<div class="card-content">
<h1 class="title">Youve reached your API Rate Limit</h1>
<div class="body-group">
<p class="body-text">
<strong>Dear {{ recipientName | default(workspaceName ~ ' team', true) }},</strong>
</p>
<p class="body-text">
Your workspace <strong>{{workspaceName}}</strong> has used all available <strong>Monthly API Rate Limit</strong> for the
<strong>{{planName}} Plan (limit: {{planLimit}})</strong>.
</p>
<p class="body-text">
As a result, API access has been temporarily paused.
</p>
<p class="body-text">
To continue using the Dify API and unlock a higher limit, please upgrade to a paid plan.
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>Monthly API Rate Limit</strong> for the <strong>{{planName}} Plan</strong> will reset on <strong>{{resetDate}}</strong>.
</p>
</div>
<div class="signature">
<p class="signature-text">Best regards,</p>
<p class="signature-text">The Dify Team</p>
</div>
</div>
</div>
<p class="email-footer">Please do not reply directly to this email, it is automatically sent by the system.</p>
</body>
</html>
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<style>
body {
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 434px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
</div>
<div class="card-content">
<h1 class="title">您的 API 速率额度已用尽</h1>
<div class="body-group">
<p class="body-text">
<strong>亲爱的 {{ recipientName | default(workspaceName, true) }}</strong>
</p>
<p class="body-text">
您的工作区 <strong>{{workspaceName}}</strong> 已用完 <strong>月度 API 速率额度</strong>,触及
<strong>{{planName}} 计划(上限:{{planLimit}}</strong>
</p>
<p class="body-text">
因此,API 访问已被暂时暂停。
</p>
<p class="body-text">
若要继续使用 Dify API 并解锁更高额度,请升级到付费套餐。
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>{{planName}} 计划的月度 API 速率额度</strong> 将于 <strong>{{resetDate}}</strong> 重置。</p>
</div>
<div class="signature">
<p class="signature-text">此致敬礼,</p>
<p class="signature-text">Dify 团队</p>
</div>
</div>
</div>
<p class="email-footer">请勿直接回复此邮件,该邮件由系统自动发送。</p>
</body>
</html>
@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-family: 'Inter', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 454px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
</div>
<div class="card-content">
<h1 class="title">Youre nearing your API Rate Limit</h1>
<div class="body-group">
<p class="body-text">
<strong>Dear {{ recipientName | default(workspaceName ~ ' team', true) }},</strong>
</p>
<p class="body-text">
Your workspace <strong>{{workspaceName}}</strong> has used <strong>80% of its Monthly API Rate Limit</strong> for the
<strong>{{planName}} Plan (limit: {{planLimit}})</strong>.
</p>
<p class="body-text">
Once the limit is reached, API access will be temporarily paused until the next monthly reset.
</p>
<p class="body-text">
To avoid service interruptions and ensure continued access to the Dify API, please consider upgrading your plan for a higher API
Rate Limit.
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>Monthly API Rate Limit</strong> for the <strong>{{planName}} Plan</strong> will reset on <strong>{{resetDate}}</strong>.
</p>
</div>
<div class="signature">
<p class="signature-text">Best regards,</p>
<p class="signature-text">The Dify Team</p>
</div>
</div>
</div>
<p class="email-footer">Please do not reply directly to this email, it is automatically sent by the system.</p>
</body>
</html>
@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<style>
body {
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 454px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
</div>
<div class="card-content">
<h1 class="title">您的 API 速率额度接近上限</h1>
<div class="body-group">
<p class="body-text">
<strong>亲爱的 {{ recipientName | default(workspaceName, true) }}</strong>
</p>
<p class="body-text">
您的工作区 <strong>{{workspaceName}}</strong> 已使用 <strong>80% 的月度 API 速率额度</strong>,触及
<strong>{{planName}} 计划(上限:{{planLimit}}</strong>
</p>
<p class="body-text">
一旦达到上限,API 访问将暂停,直至下一个月度重置。
</p>
<p class="body-text">
为避免服务中断并持续访问 Dify API,请考虑升级到额度更高的套餐。
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>{{planName}} 计划的月度 API 速率额度</strong> 将于 <strong>{{resetDate}}</strong> 重置。</p>
</div>
<div class="signature">
<p class="signature-text">此致敬礼,</p>
<p class="signature-text">Dify 团队</p>
</div>
</div>
</div>
<p class="email-footer">请勿直接回复此邮件,该邮件由系统自动发送。</p>
</body>
</html>
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-family: 'Inter', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 454px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
</div>
<div class="card-content">
<h1 class="title">Youve reached your trigger events limit</h1>
<div class="body-group">
<p class="body-text">
<strong>Dear {{ recipientName | default(workspaceName ~ ' team', true) }},</strong>
</p>
<p class="body-text">
Your workspace <strong>{{workspaceName}}</strong> has used all available <strong>{{usageScope | default('Trigger Events')}}</strong> for the
<strong>{{planName}} Plan (limit: {{planLimit}})</strong>.
</p>
<p class="body-text">
Workflows triggered by <strong>{{triggerSources}}</strong> events have been temporarily paused.
</p>
<p class="body-text">
To keep your workflows running without interruption, please upgrade your plan to unlock more Trigger Events.
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>
{% if resetLine is defined %}
{{ resetLine }}
{% else %}
Trigger Events for the {{planName}} Plan {{resetDescription}}
{% endif %}
</strong>
</p>
</div>
<div class="signature">
<p class="signature-text">Best regards,</p>
<p class="signature-text">The Dify Team</p>
</div>
</div>
</div>
<p class="email-footer">Please do not reply directly to this email, it is automatically sent by the system.</p>
</body>
</html>
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<style>
body {
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 454px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
</div>
<div class="card-content">
<h1 class="title">您的触发事件额度已用尽</h1>
<div class="body-group">
<p class="body-text">
<strong>亲爱的 {{ recipientName | default(workspaceName, true) }}</strong>
</p>
<p class="body-text">
您的工作区 <strong>{{workspaceName}}</strong> 已用完 <strong>{{usageScope | default('触发事件额度')}}</strong>,并耗尽
<strong>{{planName}} 计划(上限:{{planLimit}}</strong> 的全部额度。
</p>
<p class="body-text">
<strong>{{triggerSources}}</strong> 触发的工作流已被暂时暂停。
</p>
<p class="body-text">
为保证工作流不中断,请升级套餐以解锁更多触发事件额度。
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>
{% if resetLine is defined %}
{{ resetLine }}
{% else %}
{{planName}} 计划的触发事件额度{{resetDescription}}
{% endif %}
</strong>
</p>
</div>
<div class="signature">
<p class="signature-text">此致敬礼,</p>
<p class="signature-text">Dify 团队</p>
</div>
</div>
</div>
<p class="email-footer">请勿直接回复此邮件,该邮件由系统自动发送。</p>
</body>
</html>
@@ -0,0 +1,180 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-family: 'Inter', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 454px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
</div>
<div class="card-content">
<h1 class="title">Youre nearing your Trigger Events limit</h1>
<div class="body-group">
<p class="body-text">
<strong>Dear {{ recipientName | default(workspaceName ~ ' team', true) }},</strong>
</p>
<p class="body-text">
Your workspace <strong>{{workspaceName}}</strong> has used <strong>{{usagePercent}}</strong> of its
<strong>{{usageScope}}</strong> for the <strong>{{planName}} Plan (limit: {{planLimit}})</strong>.
</p>
<p class="body-text">
Once the limit is reached, workflows triggered by <strong>{{triggerSources}}</strong> events will be temporarily
paused.
</p>
<p class="body-text">
{{upgradeHint}}
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>
{% if resetLine is defined %}
{{ resetLine }}
{% else %}
Trigger Events for the {{planName}} Plan {{resetDescription}}
{% endif %}
</strong>
</p>
</div>
<div class="signature">
<p class="signature-text">Best regards,</p>
<p class="signature-text">The Dify Team</p>
</div>
</div>
</div>
<p class="email-footer">Please do not reply directly to this email, it is automatically sent by the system.</p>
</body>
</html>
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<style>
body {
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
background-color: #e9ebf0;
margin: 0;
padding: 0;
color: #000000;
}
*, *::before, *::after {
box-sizing: border-box;
}
.card {
width: 600px;
min-height: 454px;
margin: 40px auto 0;
display: flex;
flex-direction: column;
align-items: flex-start;
background: #fcfcfd;
border: 1px solid #ffffff;
box-shadow: 0px 3px 10px -2px rgba(9, 9, 11, 0.08), 0px 2px 4px -2px rgba(9, 9, 11, 0.06);
border-radius: 16px;
}
.card-header {
display: flex;
padding: 36px 48px 24px 48px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
align-self: stretch;
}
.card-content {
display: flex;
padding: 8px 48px 48px 48px;
flex-direction: column;
align-items: flex-start;
gap: 16px;
align-self: stretch;
width: 100%;
}
.title {
margin: 0;
font-size: 24px;
font-weight: 600;
line-height: 120%;
color: #000000;
}
.body-group {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0 0;
}
.body-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.body-text strong {
font-weight: 600;
color: #101828;
}
.cta {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
max-width: 504px;
height: 32px;
padding: 0 16px;
gap: 8px;
background: #1677ff;
border-radius: 6px;
text-decoration: none;
color: #ffffff !important;
font-size: 14px;
line-height: 22px;
font-weight: 400;
}
.note {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.signature {
margin-top: 28px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
width: 100%;
}
.signature-text {
margin: 0;
font-size: 14px;
line-height: 20px;
letter-spacing: -0.005em;
color: #354052;
}
.email-footer {
margin: 20px auto 40px;
max-width: 600px;
color: #676f83;
text-align: center;
font-family: 'Inter', 'PingFang SC', 'Arial', sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px;
letter-spacing: -0.06px;
flex: 1 0 0;
}
</style>
</head>
<body>
<div class="card">
<div class="card-header">
</div>
<div class="card-content">
<h1 class="title">您的触发事件额度接近上限</h1>
<div class="body-group">
<p class="body-text">
<strong>亲爱的 {{ recipientName | default(workspaceName, true) }}</strong>
</p>
<p class="body-text">
您的工作区 <strong>{{workspaceName}}</strong> 已使用 <strong>{{usagePercent}}</strong>
<strong>{{usageScope}}</strong>,触及 <strong>{{planName}} 计划(上限:{{planLimit}}</strong>
</p>
<p class="body-text">
一旦达到上限,由 <strong>{{triggerSources}}</strong> 触发的工作流将被暂时暂停。
</p>
<p class="body-text">
{{upgradeHint}}
</p>
<a class="cta" href="{{upgradeUrl}}" target="_blank" rel="noopener noreferrer">{{ctaLabel}}</a>
<p class="note">
<strong>
{% if resetLine is defined %}
{{ resetLine }}
{% else %}
{{planName}} 计划的触发事件额度{{resetDescription}}
{% endif %}
</strong>
</p>
</div>
<div class="signature">
<p class="signature-text">此致敬礼,</p>
<p class="signature-text">Dify 团队</p>
</div>
</div>
</div>
<p class="email-footer">请勿直接回复此邮件,该邮件由系统自动发送。</p>
</body>
</html>
@@ -0,0 +1,49 @@
"""Primarily used for testing merged cell scenarios"""
from docx import Document
from core.rag.extractor.word_extractor import WordExtractor
def _generate_table_with_merged_cells():
doc = Document()
"""
The table looks like this:
+-----+-----+-----+
| 1-1 & 1-2 | 1-3 |
+-----+-----+-----+
| 2-1 | 2-2 | 2-3 |
| & |-----+-----+
| 3-1 | 3-2 | 3-3 |
+-----+-----+-----+
"""
table = doc.add_table(rows=3, cols=3)
table.style = "Table Grid"
for i in range(3):
for j in range(3):
cell = table.cell(i, j)
cell.text = f"{i + 1}-{j + 1}"
# Merge cells
cell_0_0 = table.cell(0, 0)
cell_0_1 = table.cell(0, 1)
merged_cell_1 = cell_0_0.merge(cell_0_1)
merged_cell_1.text = "1-1 & 1-2"
cell_1_0 = table.cell(1, 0)
cell_2_0 = table.cell(2, 0)
merged_cell_2 = cell_1_0.merge(cell_2_0)
merged_cell_2.text = "2-1 & 3-1"
ground_truth = [["1-1 & 1-2", "", "1-3"], ["2-1 & 3-1", "2-2", "2-3"], ["2-1 & 3-1", "3-2", "3-3"]]
return doc.tables[0], ground_truth
def test_parse_row():
table, gt = _generate_table_with_merged_cells()
extractor = object.__new__(WordExtractor)
for idx, row in enumerate(table.rows):
assert extractor._parse_row(row, {}, 3) == gt[idx]
@@ -179,7 +179,7 @@ class TestTenantIsolatedTaskQueue:
"""Test pushing empty task list."""
sample_queue.push_tasks([])
mock_redis.lpush.assert_called_once_with("tenant_self_test-key_task_queue:tenant-123")
mock_redis.lpush.assert_not_called()
@patch("core.rag.pipeline.queue.redis_client")
def test_pull_tasks_default_count(self, mock_redis, sample_queue):
@@ -1,3 +1,5 @@
import pytest
from core.variables.types import ArrayValidation, SegmentType
@@ -83,3 +85,81 @@ class TestSegmentTypeIsValidArrayValidation:
value = [1, 2, 3]
# validation is None, skip
assert SegmentType.ARRAY_STRING.is_valid(value, array_validation=ArrayValidation.NONE)
class TestSegmentTypeGetZeroValue:
"""
Test class for SegmentType.get_zero_value static method.
Provides comprehensive coverage of all supported SegmentType values to ensure
correct zero value generation for each type.
"""
def test_array_types_return_empty_list(self):
"""Test that all array types return empty list segments."""
array_types = [
SegmentType.ARRAY_ANY,
SegmentType.ARRAY_STRING,
SegmentType.ARRAY_NUMBER,
SegmentType.ARRAY_OBJECT,
SegmentType.ARRAY_BOOLEAN,
]
for seg_type in array_types:
result = SegmentType.get_zero_value(seg_type)
assert result.value == []
assert result.value_type == seg_type
def test_object_returns_empty_dict(self):
"""Test that OBJECT type returns empty dictionary segment."""
result = SegmentType.get_zero_value(SegmentType.OBJECT)
assert result.value == {}
assert result.value_type == SegmentType.OBJECT
def test_string_returns_empty_string(self):
"""Test that STRING type returns empty string segment."""
result = SegmentType.get_zero_value(SegmentType.STRING)
assert result.value == ""
assert result.value_type == SegmentType.STRING
def test_integer_returns_zero(self):
"""Test that INTEGER type returns zero segment."""
result = SegmentType.get_zero_value(SegmentType.INTEGER)
assert result.value == 0
assert result.value_type == SegmentType.INTEGER
def test_float_returns_zero_point_zero(self):
"""Test that FLOAT type returns 0.0 segment."""
result = SegmentType.get_zero_value(SegmentType.FLOAT)
assert result.value == 0.0
assert result.value_type == SegmentType.FLOAT
def test_number_returns_zero(self):
"""Test that NUMBER type returns zero segment."""
result = SegmentType.get_zero_value(SegmentType.NUMBER)
assert result.value == 0
# NUMBER type with integer value returns INTEGER segment type
# (NUMBER is a union type that can be INTEGER or FLOAT)
assert result.value_type == SegmentType.INTEGER
# Verify that exposed_type returns NUMBER for frontend compatibility
assert result.value_type.exposed_type() == SegmentType.NUMBER
def test_boolean_returns_false(self):
"""Test that BOOLEAN type returns False segment."""
result = SegmentType.get_zero_value(SegmentType.BOOLEAN)
assert result.value is False
assert result.value_type == SegmentType.BOOLEAN
def test_unsupported_types_raise_value_error(self):
"""Test that unsupported types raise ValueError."""
unsupported_types = [
SegmentType.SECRET,
SegmentType.FILE,
SegmentType.NONE,
SegmentType.GROUP,
SegmentType.ARRAY_FILE,
]
for seg_type in unsupported_types:
with pytest.raises(ValueError, match="unsupported variable type"):
SegmentType.get_zero_value(seg_type)
@@ -0,0 +1,46 @@
"""
Utilities for detecting if database service is available for workflow tests.
"""
import psycopg2
import pytest
from configs import dify_config
def is_database_available() -> bool:
"""
Check if the database service is available by attempting to connect to it.
Returns:
True if database is available, False otherwise.
"""
try:
# Try to establish a database connection using a context manager
with psycopg2.connect(
host=dify_config.DB_HOST,
port=dify_config.DB_PORT,
database=dify_config.DB_DATABASE,
user=dify_config.DB_USERNAME,
password=dify_config.DB_PASSWORD,
connect_timeout=2, # 2 second timeout
) as conn:
pass # Connection established and will be closed automatically
return True
except (psycopg2.OperationalError, psycopg2.Error):
return False
def skip_if_database_unavailable():
"""
Pytest skip decorator that skips tests when database service is unavailable.
Usage:
@skip_if_database_unavailable()
def test_my_workflow():
...
"""
return pytest.mark.skipif(
not is_database_available(),
reason="Database service is not available (connection refused or authentication failed)",
)
@@ -6,9 +6,11 @@ This module tests the iteration node's ability to:
2. Preserve nested array structure when flatten_output=False
"""
from .test_database_utils import skip_if_database_unavailable
from .test_table_runner import TableTestRunner, WorkflowTestCase
@skip_if_database_unavailable()
def test_iteration_with_flatten_output_enabled():
"""
Test iteration node with flatten_output=True (default behavior).
@@ -37,6 +39,7 @@ def test_iteration_with_flatten_output_enabled():
)
@skip_if_database_unavailable()
def test_iteration_with_flatten_output_disabled():
"""
Test iteration node with flatten_output=False.
@@ -65,6 +68,7 @@ def test_iteration_with_flatten_output_disabled():
)
@skip_if_database_unavailable()
def test_iteration_flatten_output_comparison():
"""
Run both flatten_output configurations in parallel to verify the difference.
@@ -199,6 +199,7 @@ def test__convert_to_knowledge_retrieval_node_for_chatbot():
node = WorkflowConverter()._convert_to_knowledge_retrieval_node(
new_app_mode=new_app_mode, dataset_config=dataset_config, model_config=model_config
)
assert node is not None
assert node["data"]["type"] == "knowledge-retrieval"
assert node["data"]["query_variable_selector"] == ["sys", "query"]
@@ -231,6 +232,7 @@ def test__convert_to_knowledge_retrieval_node_for_workflow_app():
node = WorkflowConverter()._convert_to_knowledge_retrieval_node(
new_app_mode=new_app_mode, dataset_config=dataset_config, model_config=model_config
)
assert node is not None
assert node["data"]["type"] == "knowledge-retrieval"
assert node["data"]["query_variable_selector"] == ["start", dataset_config.retrieve_config.query_variable]
Generated
+795 -803
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -2,7 +2,7 @@ x-shared-env: &shared-api-worker-env
services:
# API service
api:
image: langgenius/dify-api:1.10.0-rc1
image: langgenius/dify-api:1.10.0
restart: always
environment:
# Use the shared environment variables.
@@ -31,7 +31,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.10.0-rc1
image: langgenius/dify-api:1.10.0
restart: always
environment:
# Use the shared environment variables.
@@ -58,7 +58,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.10.0-rc1
image: langgenius/dify-api:1.10.0
restart: always
environment:
# Use the shared environment variables.
@@ -76,7 +76,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.10.0-rc1
image: langgenius/dify-web:1.10.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -182,7 +182,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.4.0-local
image: langgenius/dify-plugin-daemon:0.4.1-local
restart: always
environment:
# Use the shared environment variables.
+5 -5
View File
@@ -625,7 +625,7 @@ x-shared-env: &shared-api-worker-env
services:
# API service
api:
image: langgenius/dify-api:1.10.0-rc1
image: langgenius/dify-api:1.10.0
restart: always
environment:
# Use the shared environment variables.
@@ -654,7 +654,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.10.0-rc1
image: langgenius/dify-api:1.10.0
restart: always
environment:
# Use the shared environment variables.
@@ -681,7 +681,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.10.0-rc1
image: langgenius/dify-api:1.10.0
restart: always
environment:
# Use the shared environment variables.
@@ -699,7 +699,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.10.0-rc1
image: langgenius/dify-web:1.10.0
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@@ -805,7 +805,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.4.0-local
image: langgenius/dify-plugin-daemon:0.4.1-local
restart: always
environment:
# Use the shared environment variables.
@@ -1,6 +1,6 @@
'use client'
import type { FC } from 'react'
import React, { useMemo } from 'react'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppCard from '@/app/components/app/overview/app-card'
@@ -24,6 +24,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppWorkflow } from '@/service/use-workflow'
import type { BlockEnum } from '@/app/components/workflow/types'
import { isTriggerNode } from '@/app/components/workflow/types'
import { useDocLink } from '@/context/i18n'
export type ICardViewProps = {
appId: string
@@ -33,22 +34,56 @@ export type ICardViewProps = {
const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useContext(ToastContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const isWorkflowApp = appDetail?.mode === AppModeEnum.WORKFLOW
const showMCPCard = isInPanel
const showTriggerCard = isInPanel && appDetail?.mode === AppModeEnum.WORKFLOW
const { data: currentWorkflow } = useAppWorkflow(appDetail?.mode === AppModeEnum.WORKFLOW ? appDetail.id : '')
const hasTriggerNode = useMemo(() => {
if (appDetail?.mode !== AppModeEnum.WORKFLOW)
const showTriggerCard = isInPanel && isWorkflowApp
const { data: currentWorkflow } = useAppWorkflow(isWorkflowApp ? appDetail.id : '')
const hasTriggerNode = useMemo<boolean | null>(() => {
if (!isWorkflowApp)
return false
const nodes = currentWorkflow?.graph?.nodes || []
if (!currentWorkflow)
return null
const nodes = currentWorkflow.graph?.nodes || []
return nodes.some((node) => {
const nodeType = node.data?.type as BlockEnum | undefined
return !!nodeType && isTriggerNode(nodeType)
})
}, [appDetail?.mode, currentWorkflow])
}, [isWorkflowApp, currentWorkflow])
const shouldRenderAppCards = !isWorkflowApp || hasTriggerNode === false
const disableAppCards = !shouldRenderAppCards
const triggerDocUrl = docLink('/guides/workflow/node/start')
const buildTriggerModeMessage = useCallback((featureName: string) => (
<div className='flex flex-col gap-1'>
<div className='text-xs text-text-secondary'>
{t('appOverview.overview.disableTooltip.triggerMode', { feature: featureName })}
</div>
<div
className='cursor-pointer text-xs font-medium text-text-accent hover:underline'
onClick={(event) => {
event.stopPropagation()
window.open(triggerDocUrl, '_blank')
}}
>
{t('appOverview.overview.appInfo.enableTooltip.learnMore')}
</div>
</div>
), [t, triggerDocUrl])
const disableWebAppTooltip = disableAppCards
? buildTriggerModeMessage(t('appOverview.overview.appInfo.title'))
: null
const disableApiTooltip = disableAppCards
? buildTriggerModeMessage(t('appOverview.overview.apiInfo.title'))
: null
const disableMcpTooltip = disableAppCards
? buildTriggerModeMessage(t('tools.mcp.server.title'))
: null
const updateAppDetail = async () => {
try {
@@ -120,39 +155,48 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
if (!appDetail)
return <Loading />
return (
<div className={className || 'mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'}>
{
!hasTriggerNode && (
<>
<AppCard
appInfo={appDetail}
cardType="webapp"
isInPanel={isInPanel}
onChangeStatus={onChangeSiteStatus}
onGenerateCode={onGenerateCode}
onSaveSiteConfig={onSaveSiteConfig}
/>
<AppCard
cardType="api"
appInfo={appDetail}
isInPanel={isInPanel}
onChangeStatus={onChangeApiStatus}
/>
{showMCPCard && (
<MCPServiceCard
appInfo={appDetail}
/>
)}
</>
)
}
{showTriggerCard && (
<TriggerCard
const appCards = (
<>
<AppCard
appInfo={appDetail}
cardType="webapp"
isInPanel={isInPanel}
triggerModeDisabled={disableAppCards}
triggerModeMessage={disableWebAppTooltip}
onChangeStatus={onChangeSiteStatus}
onGenerateCode={onGenerateCode}
onSaveSiteConfig={onSaveSiteConfig}
/>
<AppCard
cardType="api"
appInfo={appDetail}
isInPanel={isInPanel}
triggerModeDisabled={disableAppCards}
triggerModeMessage={disableApiTooltip}
onChangeStatus={onChangeApiStatus}
/>
{showMCPCard && (
<MCPServiceCard
appInfo={appDetail}
onToggleResult={handleCallbackResult}
triggerModeDisabled={disableAppCards}
triggerModeMessage={disableMcpTooltip}
/>
)}
</>
)
const triggerCardNode = showTriggerCard ? (
<TriggerCard
appInfo={appDetail}
onToggleResult={handleCallbackResult}
/>
) : null
return (
<div className={className || 'mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'}>
{disableAppCards && triggerCardNode}
{appCards}
{!disableAppCards && triggerCardNode}
</div>
)
}
+52 -22
View File
@@ -49,6 +49,7 @@ import { fetchInstalledAppList } from '@/service/explore'
import { AppModeEnum } from '@/types/app'
import type { PublishWorkflowParams } from '@/types/workflow'
import { basePath } from '@/utils/var'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
const ACCESS_MODE_MAP: Record<AccessMode, { label: string, icon: React.ElementType }> = {
[AccessMode.ORGANIZATION]: {
@@ -106,6 +107,7 @@ export type AppPublisherProps = {
workflowToolAvailable?: boolean
missingStartNode?: boolean
hasTriggerNode?: boolean // Whether workflow currently contains any trigger nodes (used to hide missing-start CTA when triggers exist).
startNodeLimitExceeded?: boolean
}
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
@@ -127,6 +129,7 @@ const AppPublisher = ({
workflowToolAvailable = true,
missingStartNode = false,
hasTriggerNode = false,
startNodeLimitExceeded = false,
}: AppPublisherProps) => {
const { t } = useTranslation()
@@ -246,6 +249,13 @@ const AppPublisher = ({
const hasPublishedVersion = !!publishedAt
const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable
const workflowToolMessage = workflowToolDisabled ? t('workflow.common.workflowAsToolDisabledHint') : undefined
const showStartNodeLimitHint = Boolean(startNodeLimitExceeded)
const upgradeHighlightStyle = useMemo(() => ({
background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
}), [])
return (
<>
@@ -304,29 +314,49 @@ const AppPublisher = ({
/>
)
: (
<Button
variant='primary'
className='mt-3 w-full'
onClick={() => handlePublish()}
disabled={publishDisabled || published}
>
{
published
? t('workflow.common.published')
: (
<div className='flex gap-1'>
<span>{t('workflow.common.publishUpdate')}</span>
<div className='flex gap-0.5'>
{PUBLISH_SHORTCUT.map(key => (
<span key={key} className='system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white text-text-primary-on-surface'>
{getKeyboardKeyNameBySystem(key)}
</span>
))}
<>
<Button
variant='primary'
className='mt-3 w-full'
onClick={() => handlePublish()}
disabled={publishDisabled || published}
>
{
published
? t('workflow.common.published')
: (
<div className='flex gap-1'>
<span>{t('workflow.common.publishUpdate')}</span>
<div className='flex gap-0.5'>
{PUBLISH_SHORTCUT.map(key => (
<span key={key} className='system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white text-text-primary-on-surface'>
{getKeyboardKeyNameBySystem(key)}
</span>
))}
</div>
</div>
</div>
)
}
</Button>
)
}
</Button>
{showStartNodeLimitHint && (
<div className='mt-3 flex flex-col items-stretch'>
<p
className='text-sm font-semibold leading-5 text-transparent'
style={upgradeHighlightStyle}
>
<span className='block'>{t('workflow.publishLimit.startNodeTitlePrefix')}</span>
<span className='block'>{t('workflow.publishLimit.startNodeTitleSuffix')}</span>
</p>
<p className='mt-1 text-xs leading-4 text-text-secondary'>
{t('workflow.publishLimit.startNodeDesc')}
</p>
<UpgradeBtn
isShort
className='mb-[12px] mt-[9px] h-[32px] w-[93px] self-start'
/>
</div>
)}
</>
)
}
</div>
+12 -9
View File
@@ -42,6 +42,7 @@ import { getProcessedFilesFromResponse } from '@/app/components/base/file-upload
import cn from '@/utils/classnames'
import { noop } from 'lodash-es'
import PromptLogModal from '../../base/prompt-log-modal'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
type AppStoreState = ReturnType<typeof useAppStore.getState>
type ConversationListItem = ChatConversationGeneralDetail | CompletionConversationGeneralDetail
@@ -779,15 +780,17 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
}
</div>
{showMessageLogModal && (
<MessageLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowMessageLogModal(false)
}}
defaultTab={currentLogModalActiveTab}
/>
<WorkflowContextProvider>
<MessageLogModal
width={width}
currentLogItem={currentLogItem}
onCancel={() => {
setCurrentLogItem()
setShowMessageLogModal(false)
}}
defaultTab={currentLogModalActiveTab}
/>
</WorkflowContextProvider>
)}
{!isChatMode && showPromptLogModal && (
<PromptLogModal
+41 -17
View File
@@ -51,6 +51,8 @@ export type IAppCardProps = {
isInPanel?: boolean
cardType?: 'api' | 'webapp'
customBgColor?: string
triggerModeDisabled?: boolean // true when Trigger Node mode needs UI locked to avoid conflicting actions
triggerModeMessage?: React.ReactNode // contextual copy explaining why the card is disabled in trigger mode
onChangeStatus: (val: boolean) => Promise<void>
onSaveSiteConfig?: (params: ConfigParams) => Promise<void>
onGenerateCode?: () => Promise<void>
@@ -61,6 +63,8 @@ function AppCard({
isInPanel,
cardType = 'webapp',
customBgColor,
triggerModeDisabled = false,
triggerModeMessage = '',
onChangeStatus,
onSaveSiteConfig,
onGenerateCode,
@@ -111,7 +115,7 @@ function AppCard({
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
const missingStartNode = isWorkflowApp && !hasStartNode
const hasInsufficientPermissions = isApp ? !isCurrentWorkspaceEditor : !isCurrentWorkspaceManager
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
const runningStatus = (appUnpublished || missingStartNode) ? false : (isApp ? appInfo.enable_site : appInfo.enable_api)
const isMinimalState = appUnpublished || missingStartNode
const { app_base_url, access_token } = appInfo.site ?? {}
@@ -189,7 +193,20 @@ function AppCard({
className={
`${isInPanel ? 'border-l-[0.5px] border-t' : 'border-[0.5px] shadow-xs'} w-full max-w-full rounded-xl border-effects-highlight ${className ?? ''} ${isMinimalState ? 'h-12' : ''}`}
>
<div className={`${customBgColor ?? 'bg-background-default'} rounded-xl`}>
<div className={`${customBgColor ?? 'bg-background-default'} relative rounded-xl ${triggerModeDisabled ? 'opacity-60' : ''}`}>
{triggerModeDisabled && (
triggerModeMessage
? (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className='absolute inset-0 z-10 cursor-not-allowed rounded-xl' aria-hidden="true"></div>
</Tooltip>
)
: <div className='absolute inset-0 z-10 cursor-not-allowed rounded-xl' aria-hidden="true"></div>
)}
<div className={`flex w-full flex-col items-start justify-center gap-3 self-stretch p-3 ${isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle'}`}>
<div className='flex w-full items-center gap-3 self-stretch'>
<AppBasic
@@ -214,18 +231,23 @@ function AppCard({
</div>
<Tooltip
popupContent={
toggleDisabled && (appUnpublished || missingStartNode) ? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('appOverview.overview.appInfo.enableTooltip.description')}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/guides/workflow/node/user-input'), '_blank')}
>
{t('appOverview.overview.appInfo.enableTooltip.learnMore')}
</div>
</>
toggleDisabled ? (
triggerModeDisabled && triggerModeMessage
? triggerModeMessage
: (appUnpublished || missingStartNode) ? (
<>
<div className="mb-1 text-xs font-normal text-text-secondary">
{t('appOverview.overview.appInfo.enableTooltip.description')}
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/guides/workflow/node/user-input'), '_blank')}
>
{t('appOverview.overview.appInfo.enableTooltip.learnMore')}
</div>
</>
)
: ''
) : ''
}
position="right"
@@ -329,9 +351,11 @@ function AppCard({
{!isApp && <SecretKeyButton appId={appInfo.id} />}
{OPERATIONS_MAP[cardType].map((op) => {
const disabled
= op.opName === t('appOverview.overview.appInfo.settings.entry')
? false
: !runningStatus
= triggerModeDisabled
? true
: op.opName === t('appOverview.overview.appInfo.settings.entry')
? false
: !runningStatus
return (
<Button
className="mr-1 min-w-[88px]"
+12 -10
View File
@@ -282,21 +282,23 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
</>
)}
{
(!systemFeatures.webapp_auth.enabled)
? <>
<Divider className="my-1" />
<button type="button" className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickInstalledApp}>
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
</button>
</>
: !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && (
<>
!app.has_draft_trigger && (
(!systemFeatures.webapp_auth.enabled)
? <>
<Divider className="my-1" />
<button type="button" className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickInstalledApp}>
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
</button>
</>
)
: !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && (
<>
<Divider className="my-1" />
<button type="button" className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickInstalledApp}>
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
</button>
</>
)
)
}
<Divider className="my-1" />
{
+4 -1
View File
@@ -1,6 +1,6 @@
import React from 'react'
import Link from 'next/link'
import { RiDiscordFill, RiGithubFill } from '@remixicon/react'
import { RiDiscordFill, RiDiscussLine, RiGithubFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
type CustomLinkProps = {
@@ -38,6 +38,9 @@ const Footer = () => {
<CustomLink href='https://discord.gg/FngNHpbcY7'>
<RiDiscordFill className='h-5 w-5 text-text-tertiary' />
</CustomLink>
<CustomLink href='https://forum.dify.ai'>
<RiDiscussLine className='h-5 w-5 text-text-tertiary' />
</CustomLink>
</div>
</footer>
)
@@ -1,5 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="apps-2-line">
<path id="Vector" d="M4.66602 7.6665C3.00916 7.6665 1.66602 6.32336 1.66602 4.6665C1.66602 3.00965 3.00916 1.6665 4.66602 1.6665C6.32287 1.6665 7.66602 3.00965 7.66602 4.6665C7.66602 6.32336 6.32287 7.6665 4.66602 7.6665ZM4.66602 14.3332C3.00916 14.3332 1.66602 12.99 1.66602 11.3332C1.66602 9.6763 3.00916 8.33317 4.66602 8.33317C6.32287 8.33317 7.66602 9.6763 7.66602 11.3332C7.66602 12.99 6.32287 14.3332 4.66602 14.3332ZM11.3327 7.6665C9.67582 7.6665 8.33268 6.32336 8.33268 4.6665C8.33268 3.00965 9.67582 1.6665 11.3327 1.6665C12.9895 1.6665 14.3327 3.00965 14.3327 4.6665C14.3327 6.32336 12.9895 7.6665 11.3327 7.6665ZM11.3327 14.3332C9.67582 14.3332 8.33268 12.99 8.33268 11.3332C8.33268 9.6763 9.67582 8.33317 11.3327 8.33317C12.9895 8.33317 14.3327 9.6763 14.3327 11.3332C14.3327 12.99 12.9895 14.3332 11.3327 14.3332ZM4.66602 6.33317C5.58649 6.33317 6.33268 5.58698 6.33268 4.6665C6.33268 3.74603 5.58649 2.99984 4.66602 2.99984C3.74554 2.99984 2.99935 3.74603 2.99935 4.6665C2.99935 5.58698 3.74554 6.33317 4.66602 6.33317ZM4.66602 12.9998C5.58649 12.9998 6.33268 12.2536 6.33268 11.3332C6.33268 10.4127 5.58649 9.6665 4.66602 9.6665C3.74554 9.6665 2.99935 10.4127 2.99935 11.3332C2.99935 12.2536 3.74554 12.9998 4.66602 12.9998ZM11.3327 6.33317C12.2531 6.33317 12.9993 5.58698 12.9993 4.6665C12.9993 3.74603 12.2531 2.99984 11.3327 2.99984C10.4122 2.99984 9.66602 3.74603 9.66602 4.6665C9.66602 5.58698 10.4122 6.33317 11.3327 6.33317ZM11.3327 12.9998C12.2531 12.9998 12.9993 12.2536 12.9993 11.3332C12.9993 10.4127 12.2531 9.6665 11.3327 9.6665C10.4122 9.6665 9.66602 10.4127 9.66602 11.3332C9.66602 12.2536 10.4122 12.9998 11.3327 12.9998Z" fill="#155EEF"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.66602 14.3334C3.00916 14.3334 1.66602 12.9903 1.66602 11.3334C1.66602 9.67655 3.00916 8.33342 4.66602 8.33342C6.32287 8.33342 7.66602 9.67655 7.66602 11.3334C7.66602 12.9903 6.32287 14.3334 4.66602 14.3334ZM11.3327 7.66675C9.67582 7.66675 8.33268 6.3236 8.33268 4.66675C8.33268 3.00989 9.67582 1.66675 11.3327 1.66675C12.9895 1.66675 14.3327 3.00989 14.3327 4.66675C14.3327 6.3236 12.9895 7.66675 11.3327 7.66675ZM4.66602 13.0001C5.58649 13.0001 6.33268 12.2539 6.33268 11.3334C6.33268 10.4129 5.58649 9.66675 4.66602 9.66675C3.74554 9.66675 2.99935 10.4129 2.99935 11.3334C2.99935 12.2539 3.74554 13.0001 4.66602 13.0001ZM11.3327 6.33342C12.2531 6.33342 12.9993 5.58722 12.9993 4.66675C12.9993 3.74627 12.2531 3.00008 11.3327 3.00008C10.4122 3.00008 9.66602 3.74627 9.66602 4.66675C9.66602 5.58722 10.4122 6.33342 11.3327 6.33342ZM1.99935 5.33341C1.99935 3.49247 3.49174 2.00008 5.33268 2.00008H7.33268V3.33341H5.33268C4.22812 3.33341 3.33268 4.22885 3.33268 5.33341V7.33342H1.99935V5.33341ZM13.9993 8.66675H12.666V10.6667C12.666 11.7713 11.7706 12.6667 10.666 12.6667H8.66602V14.0001H10.666C12.5069 14.0001 13.9993 12.5077 13.9993 10.6667V8.66675Z" fill="#344054"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,3 +0,0 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 2.66659H3.33333V13.3333H12.6667V5.33325H10V2.66659ZM2 1.99445C2 1.62929 2.29833 1.33325 2.66567 1.33325H10.6667L13.9998 4.66658L14 13.9949C14 14.3659 13.7034 14.6666 13.3377 14.6666H2.66227C2.29651 14.6666 2 14.3631 2 14.0054V1.99445ZM11.7713 7.99992L9.4142 10.3569L8.4714 9.41412L9.8856 7.99992L8.4714 6.58571L9.4142 5.6429L11.7713 7.99992ZM4.22877 7.99992L6.58579 5.6429L7.5286 6.58571L6.11438 7.99992L7.5286 9.41412L6.58579 10.3569L4.22877 7.99992Z" fill="#344054"/>
</svg>

Before

Width:  |  Height:  |  Size: 586 B

@@ -0,0 +1,258 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import React from 'react'
declare const require: any
type IconComponent = React.ComponentType<Record<string, unknown>>
type IconEntry = {
name: string
category: string
path: string
Component: IconComponent
}
const iconContext = require.context('./src', true, /\.tsx$/)
const iconEntries: IconEntry[] = iconContext
.keys()
.filter((key: string) => !key.endsWith('.stories.tsx') && !key.endsWith('.spec.tsx'))
.map((key: string) => {
const mod = iconContext(key)
const Component = mod.default as IconComponent | undefined
if (!Component)
return null
const relativePath = key.replace(/^\.\//, '')
const path = `app/components/base/icons/src/${relativePath}`
const parts = relativePath.split('/')
const fileName = parts.pop() || ''
const category = parts.length ? parts.join('/') : '(root)'
const name = Component.displayName || fileName.replace(/\.tsx$/, '')
return {
name,
category,
path,
Component,
}
})
.filter(Boolean) as IconEntry[]
const sortedEntries = [...iconEntries].sort((a, b) => {
if (a.category === b.category)
return a.name.localeCompare(b.name)
return a.category.localeCompare(b.category)
})
const filterEntries = (entries: IconEntry[], query: string) => {
const normalized = query.trim().toLowerCase()
if (!normalized)
return entries
return entries.filter(entry =>
entry.name.toLowerCase().includes(normalized)
|| entry.path.toLowerCase().includes(normalized)
|| entry.category.toLowerCase().includes(normalized),
)
}
const groupByCategory = (entries: IconEntry[]) => entries.reduce((acc, entry) => {
if (!acc[entry.category])
acc[entry.category] = []
acc[entry.category].push(entry)
return acc
}, {} as Record<string, IconEntry[]>)
const containerStyle: React.CSSProperties = {
padding: 24,
display: 'flex',
flexDirection: 'column',
gap: 24,
}
const headerStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 8,
}
const controlsStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 12,
flexWrap: 'wrap',
}
const searchInputStyle: React.CSSProperties = {
padding: '8px 12px',
minWidth: 280,
borderRadius: 6,
border: '1px solid #d0d0d5',
}
const toggleButtonStyle: React.CSSProperties = {
padding: '8px 12px',
borderRadius: 6,
border: '1px solid #d0d0d5',
background: '#fff',
cursor: 'pointer',
}
const emptyTextStyle: React.CSSProperties = { color: '#5f5f66' }
const sectionStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: 12,
}
const gridStyle: React.CSSProperties = {
display: 'grid',
gap: 12,
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
}
const cardStyle: React.CSSProperties = {
border: '1px solid #e1e1e8',
borderRadius: 8,
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 8,
minHeight: 140,
}
const previewBaseStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: 48,
borderRadius: 6,
}
const nameButtonBaseStyle: React.CSSProperties = {
display: 'inline-flex',
padding: 0,
border: 'none',
background: 'transparent',
font: 'inherit',
cursor: 'pointer',
textAlign: 'left',
fontWeight: 600,
}
const PREVIEW_SIZE = 40
const IconGalleryStory = () => {
const [query, setQuery] = React.useState('')
const [copiedPath, setCopiedPath] = React.useState<string | null>(null)
const [previewTheme, setPreviewTheme] = React.useState<'light' | 'dark'>('light')
const filtered = React.useMemo(() => filterEntries(sortedEntries, query), [query])
const grouped = React.useMemo(() => groupByCategory(filtered), [filtered])
const categoryOrder = React.useMemo(
() => Object.keys(grouped).sort((a, b) => a.localeCompare(b)),
[grouped],
)
React.useEffect(() => {
if (!copiedPath)
return undefined
const timerId = window.setTimeout(() => {
setCopiedPath(null)
}, 1200)
return () => window.clearTimeout(timerId)
}, [copiedPath])
const handleCopy = React.useCallback((text: string) => {
navigator.clipboard?.writeText(text)
.then(() => {
setCopiedPath(text)
})
.catch((err) => {
console.error('Failed to copy icon path:', err)
})
}, [])
return (
<div style={containerStyle}>
<header style={headerStyle}>
<h1 style={{ margin: 0 }}>Icon Gallery</h1>
<p style={{ margin: 0, color: '#5f5f66' }}>
Browse all icon components sourced from <code>app/components/base/icons/src</code>. Use the search bar
to filter by name or path.
</p>
<div style={controlsStyle}>
<input
style={searchInputStyle}
placeholder="Search icons"
value={query}
onChange={event => setQuery(event.target.value)}
/>
<span style={{ color: '#5f5f66' }}>{filtered.length} icons</span>
<button
type="button"
onClick={() => setPreviewTheme(prev => (prev === 'light' ? 'dark' : 'light'))}
style={toggleButtonStyle}
>
Toggle {previewTheme === 'light' ? 'dark' : 'light'} preview
</button>
</div>
</header>
{categoryOrder.length === 0 && (
<p style={emptyTextStyle}>No icons match the current filter.</p>
)}
{categoryOrder.map(category => (
<section key={category} style={sectionStyle}>
<h2 style={{ margin: 0, fontSize: 18 }}>{category}</h2>
<div style={gridStyle}>
{grouped[category].map(entry => (
<div key={entry.path} style={cardStyle}>
<div
style={{
...previewBaseStyle,
background: previewTheme === 'dark' ? '#1f2024' : '#fff',
}}
>
<entry.Component style={{ width: PREVIEW_SIZE, height: PREVIEW_SIZE }} />
</div>
<button
type="button"
onClick={() => handleCopy(entry.path)}
style={{
...nameButtonBaseStyle,
color: copiedPath === entry.path ? '#00754a' : '#24262c',
}}
>
{copiedPath === entry.path ? 'Copied!' : entry.name}
</button>
</div>
))}
</div>
</section>
))}
</div>
)
}
const meta: Meta<typeof IconGalleryStory> = {
title: 'Base/Icons/Icon Gallery',
component: IconGalleryStory,
parameters: {
layout: 'fullscreen',
},
}
export default meta
type Story = StoryObj<typeof IconGalleryStory>
export const All: Story = {
render: () => <IconGalleryStory />,
}
@@ -1,36 +0,0 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "apps-2-line"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Vector",
"d": "M4.66602 7.6665C3.00916 7.6665 1.66602 6.32336 1.66602 4.6665C1.66602 3.00965 3.00916 1.6665 4.66602 1.6665C6.32287 1.6665 7.66602 3.00965 7.66602 4.6665C7.66602 6.32336 6.32287 7.6665 4.66602 7.6665ZM4.66602 14.3332C3.00916 14.3332 1.66602 12.99 1.66602 11.3332C1.66602 9.6763 3.00916 8.33317 4.66602 8.33317C6.32287 8.33317 7.66602 9.6763 7.66602 11.3332C7.66602 12.99 6.32287 14.3332 4.66602 14.3332ZM11.3327 7.6665C9.67582 7.6665 8.33268 6.32336 8.33268 4.6665C8.33268 3.00965 9.67582 1.6665 11.3327 1.6665C12.9895 1.6665 14.3327 3.00965 14.3327 4.6665C14.3327 6.32336 12.9895 7.6665 11.3327 7.6665ZM11.3327 14.3332C9.67582 14.3332 8.33268 12.99 8.33268 11.3332C8.33268 9.6763 9.67582 8.33317 11.3327 8.33317C12.9895 8.33317 14.3327 9.6763 14.3327 11.3332C14.3327 12.99 12.9895 14.3332 11.3327 14.3332ZM4.66602 6.33317C5.58649 6.33317 6.33268 5.58698 6.33268 4.6665C6.33268 3.74603 5.58649 2.99984 4.66602 2.99984C3.74554 2.99984 2.99935 3.74603 2.99935 4.6665C2.99935 5.58698 3.74554 6.33317 4.66602 6.33317ZM4.66602 12.9998C5.58649 12.9998 6.33268 12.2536 6.33268 11.3332C6.33268 10.4127 5.58649 9.6665 4.66602 9.6665C3.74554 9.6665 2.99935 10.4127 2.99935 11.3332C2.99935 12.2536 3.74554 12.9998 4.66602 12.9998ZM11.3327 6.33317C12.2531 6.33317 12.9993 5.58698 12.9993 4.6665C12.9993 3.74603 12.2531 2.99984 11.3327 2.99984C10.4122 2.99984 9.66602 3.74603 9.66602 4.6665C9.66602 5.58698 10.4122 6.33317 11.3327 6.33317ZM11.3327 12.9998C12.2531 12.9998 12.9993 12.2536 12.9993 11.3332C12.9993 10.4127 12.2531 9.6665 11.3327 9.6665C10.4122 9.6665 9.66602 10.4127 9.66602 11.3332C9.66602 12.2536 10.4122 12.9998 11.3327 12.9998Z",
"fill": "currentColor"
},
"children": []
}
]
}
]
},
"name": "Apps02"
}
@@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './Apps02.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Apps02'
export default Icon
@@ -1,26 +0,0 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M4.66602 14.3334C3.00916 14.3334 1.66602 12.9903 1.66602 11.3334C1.66602 9.67655 3.00916 8.33342 4.66602 8.33342C6.32287 8.33342 7.66602 9.67655 7.66602 11.3334C7.66602 12.9903 6.32287 14.3334 4.66602 14.3334ZM11.3327 7.66675C9.67582 7.66675 8.33268 6.3236 8.33268 4.66675C8.33268 3.00989 9.67582 1.66675 11.3327 1.66675C12.9895 1.66675 14.3327 3.00989 14.3327 4.66675C14.3327 6.3236 12.9895 7.66675 11.3327 7.66675ZM4.66602 13.0001C5.58649 13.0001 6.33268 12.2539 6.33268 11.3334C6.33268 10.4129 5.58649 9.66675 4.66602 9.66675C3.74554 9.66675 2.99935 10.4129 2.99935 11.3334C2.99935 12.2539 3.74554 13.0001 4.66602 13.0001ZM11.3327 6.33342C12.2531 6.33342 12.9993 5.58722 12.9993 4.66675C12.9993 3.74627 12.2531 3.00008 11.3327 3.00008C10.4122 3.00008 9.66602 3.74627 9.66602 4.66675C9.66602 5.58722 10.4122 6.33342 11.3327 6.33342ZM1.99935 5.33341C1.99935 3.49247 3.49174 2.00008 5.33268 2.00008H7.33268V3.33341H5.33268C4.22812 3.33341 3.33268 4.22885 3.33268 5.33341V7.33342H1.99935V5.33341ZM13.9993 8.66675H12.666V10.6667C12.666 11.7713 11.7706 12.6667 10.666 12.6667H8.66602V14.0001H10.666C12.5069 14.0001 13.9993 12.5077 13.9993 10.6667V8.66675Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "Exchange02"
}
@@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './Exchange02.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'Exchange02'
export default Icon
@@ -1,26 +0,0 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "16",
"height": "16",
"viewBox": "0 0 16 16",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"d": "M10 2.66659H3.33333V13.3333H12.6667V5.33325H10V2.66659ZM2 1.99445C2 1.62929 2.29833 1.33325 2.66567 1.33325H10.6667L13.9998 4.66658L14 13.9949C14 14.3659 13.7034 14.6666 13.3377 14.6666H2.66227C2.29651 14.6666 2 14.3631 2 14.0054V1.99445ZM11.7713 7.99992L9.4142 10.3569L8.4714 9.41412L9.8856 7.99992L8.4714 6.58571L9.4142 5.6429L11.7713 7.99992ZM4.22877 7.99992L6.58579 5.6429L7.5286 6.58571L6.11438 7.99992L7.5286 9.41412L6.58579 10.3569L4.22877 7.99992Z",
"fill": "currentColor"
},
"children": []
}
]
},
"name": "FileCode"
}
@@ -1,20 +0,0 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './FileCode.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconData } from '@/app/components/base/icons/IconBase'
const Icon = (
{
ref,
...props
}: React.SVGProps<SVGSVGElement> & {
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
},
) => <IconBase {...props} ref={ref} data={data as IconData} />
Icon.displayName = 'FileCode'
export default Icon
@@ -1,10 +1,7 @@
export { default as Apps02 } from './Apps02'
export { default as BubbleX } from './BubbleX'
export { default as Colors } from './Colors'
export { default as DragHandle } from './DragHandle'
export { default as Env } from './Env'
export { default as Exchange02 } from './Exchange02'
export { default as FileCode } from './FileCode'
export { default as GlobalVariable } from './GlobalVariable'
export { default as Icon3Dots } from './Icon3Dots'
export { default as LongArrowLeft } from './LongArrowLeft'
@@ -6,6 +6,7 @@ import { useStore } from '@/app/components/app/store'
import type { WorkflowRunDetailResponse } from '@/models/log'
import type { NodeTracing, NodeTracingListResponse } from '@/types/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
import { WorkflowContextProvider } from '@/app/components/workflow/context'
const SAMPLE_APP_DETAIL = {
id: 'app-demo-1',
@@ -143,10 +144,12 @@ const MessageLogPreview = (props: MessageLogModalProps) => {
return (
<div className="relative min-h-[640px] w-full bg-background-default-subtle p-6">
<MessageLogModal
{...props}
currentLogItem={mockCurrentLogItem}
/>
<WorkflowContextProvider>
<MessageLogModal
{...props}
currentLogItem={mockCurrentLogItem}
/>
</WorkflowContextProvider>
</div>
)
}
+4
View File
@@ -90,4 +90,8 @@ export const defaultPlan = {
apiRateLimit: ALL_PLANS.sandbox.apiRateLimit,
triggerEvents: ALL_PLANS.sandbox.triggerEvents,
},
reset: {
apiRateLimit: null,
triggerEvents: null,
},
}
+22 -9
View File
@@ -6,15 +6,16 @@ import { useRouter } from 'next/navigation'
import {
RiBook2Line,
RiFileEditLine,
RiFlashlightLine,
RiGraduationCapLine,
RiGroupLine,
RiSpeedLine,
} from '@remixicon/react'
import { Plan, SelfHostedPlan } from '../type'
import { NUM_INFINITE } from '../config'
import { getDaysUntilEndOfMonth } from '@/utils/time'
import VectorSpaceInfo from '../usage-info/vector-space-info'
import AppsInfo from '../usage-info/apps-info'
import UpgradeBtn from '../upgrade-btn'
import { ApiAggregate, TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import { useProviderContext } from '@/context/provider-context'
import { useAppContext } from '@/context/app-context'
import Button from '@/app/components/base/button'
@@ -44,9 +45,20 @@ const PlanComp: FC<Props> = ({
const {
usage,
total,
reset,
} = plan
const perMonthUnit = ` ${t('billing.usagePage.perMonth')}`
const triggerEventUnit = plan.type === Plan.sandbox ? undefined : perMonthUnit
const triggerEventsResetInDays = type === Plan.professional && total.triggerEvents !== NUM_INFINITE
? reset.triggerEvents ?? undefined
: undefined
const apiRateLimitResetInDays = (() => {
if (total.apiRateLimit === NUM_INFINITE)
return undefined
if (typeof reset.apiRateLimit === 'number')
return reset.apiRateLimit
if (type === Plan.sandbox)
return getDaysUntilEndOfMonth()
return undefined
})()
const [showModal, setShowModal] = React.useState(false)
const { mutateAsync } = useEducationVerify()
@@ -79,7 +91,6 @@ const PlanComp: FC<Props> = ({
<div className='grow'>
<div className='mb-1 flex items-center gap-1'>
<div className='system-md-semibold-uppercase text-text-primary'>{t(`billing.plans.${type}.name`)}</div>
<div className='system-2xs-medium-uppercase rounded-[5px] border border-divider-deep px-1 py-0.5 text-text-tertiary'>{t('billing.currentPlan')}</div>
</div>
<div className='system-xs-regular text-util-colors-gray-gray-600'>{t(`billing.plans.${type}.for`)}</div>
</div>
@@ -124,18 +135,20 @@ const PlanComp: FC<Props> = ({
total={total.annotatedResponse}
/>
<UsageInfo
Icon={RiFlashlightLine}
Icon={TriggerAll}
name={t('billing.usagePage.triggerEvents')}
usage={usage.triggerEvents}
total={total.triggerEvents}
unit={triggerEventUnit}
tooltip={t('billing.plansCommon.triggerEvents.tooltip') as string}
resetInDays={triggerEventsResetInDays}
/>
<UsageInfo
Icon={RiSpeedLine}
Icon={ApiAggregate}
name={t('billing.plansCommon.apiRateLimit')}
usage={usage.apiRateLimit}
total={total.apiRateLimit}
unit={perMonthUnit}
tooltip={total.apiRateLimit === NUM_INFINITE ? undefined : t('billing.plansCommon.apiRateLimitTooltip') as string}
resetInDays={apiRateLimitResetInDays}
/>
</div>
@@ -46,16 +46,10 @@ const List = ({
label={t('billing.plansCommon.documentsRequestQuota', { count: planInfo.documentsRequestQuota })}
tooltip={t('billing.plansCommon.documentsRequestQuotaTooltip')}
/>
<Item
label={
planInfo.apiRateLimit === NUM_INFINITE ? `${t('billing.plansCommon.unlimitedApiRate')}`
: `${t('billing.plansCommon.apiRateLimitUnit', { count: planInfo.apiRateLimit })} ${t('billing.plansCommon.apiRateLimit')}`
}
tooltip={planInfo.apiRateLimit === NUM_INFINITE ? undefined : t('billing.plansCommon.apiRateLimitTooltip') as string}
/>
<Item
label={[t(`billing.plansCommon.priority.${planInfo.documentProcessingPriority}`), t('billing.plansCommon.documentProcessingPriority')].join('')}
/>
<Divider bgStyle='gradient' />
<Item
label={
planInfo.triggerEvents === NUM_INFINITE
@@ -64,6 +58,14 @@ const List = ({
? t('billing.plansCommon.triggerEvents.sandbox', { count: planInfo.triggerEvents })
: t('billing.plansCommon.triggerEvents.professional', { count: planInfo.triggerEvents })
}
tooltip={t('billing.plansCommon.triggerEvents.tooltip') as string}
/>
<Item
label={
plan === Plan.sandbox
? t('billing.plansCommon.startNodes.limited', { count: 2 })
: t('billing.plansCommon.startNodes.unlimited')
}
/>
<Item
label={
@@ -73,13 +75,7 @@ const List = ({
? t('billing.plansCommon.workflowExecution.faster')
: t('billing.plansCommon.workflowExecution.priority')
}
/>
<Item
label={
plan === Plan.sandbox
? t('billing.plansCommon.startNodes.limited', { count: 2 })
: t('billing.plansCommon.startNodes.unlimited')
}
tooltip={t('billing.plansCommon.workflowExecution.tooltip') as string}
/>
<Divider bgStyle='gradient' />
<Item
@@ -89,6 +85,14 @@ const List = ({
<Item
label={t('billing.plansCommon.logsHistory', { days: planInfo.logHistory === NUM_INFINITE ? t('billing.plansCommon.unlimited') as string : `${planInfo.logHistory} ${t('billing.plansCommon.days')}` })}
/>
<Item
label={
planInfo.apiRateLimit === NUM_INFINITE
? t('billing.plansCommon.unlimitedApiRate')
: `${t('billing.plansCommon.apiRateLimitUnit', { count: planInfo.apiRateLimit })} ${t('billing.plansCommon.apiRateLimit')} / ${t('billing.plansCommon.month')}`
}
tooltip={planInfo.apiRateLimit === NUM_INFINITE ? undefined : t('billing.plansCommon.apiRateLimitTooltip') as string}
/>
<Divider bgStyle='gradient' />
<Item
label={t('billing.plansCommon.modelProviders')}
@@ -0,0 +1,30 @@
.surface {
border: 0.5px solid var(--color-components-panel-border, rgba(16, 24, 40, 0.08));
background:
linear-gradient(109deg, var(--color-background-section, #f9fafb) 0%, var(--color-background-section-burn, #f2f4f7) 100%),
var(--color-components-panel-bg, #fff);
}
.heroOverlay {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='54' height='54' fill='none'%3E%3Crect x='1' y='1' width='48' height='48' rx='12' stroke='rgba(16, 24, 40, 0.3)' stroke-width='1' opacity='0.08'/%3E%3C/svg%3E");
background-size: 54px 54px;
background-position: 31px -23px;
background-repeat: repeat;
mask-image: linear-gradient(180deg, rgba(255, 255, 255, 1) 45%, rgba(255, 255, 255, 0) 75%);
-webkit-mask-image: linear-gradient(180deg, rgba(255, 255, 255, 1) 45%, rgba(255, 255, 255, 0) 75%);
}
.icon {
border: 0.5px solid transparent;
background:
linear-gradient(180deg, var(--color-components-avatar-bg-mask-stop-0, rgba(255, 255, 255, 0.12)) 0%, var(--color-components-avatar-bg-mask-stop-100, rgba(255, 255, 255, 0.08)) 100%),
var(--color-util-colors-blue-brand-blue-brand-500, #296dff);
box-shadow: 0 10px 20px color-mix(in srgb, var(--color-util-colors-blue-brand-blue-brand-500, #296dff) 35%, transparent);
}
.highlight {
background: linear-gradient(97deg, var(--color-components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -4%, var(--color-components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
@@ -0,0 +1,97 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import React, { useEffect, useState } from 'react'
import i18next from 'i18next'
import { I18nextProvider } from 'react-i18next'
import TriggerEventsLimitModal from '.'
import { Plan } from '../type'
const i18n = i18next.createInstance()
i18n.init({
lng: 'en',
resources: {
en: {
translation: {
billing: {
triggerLimitModal: {
title: 'Upgrade to unlock more trigger events',
description: 'Youve reached the limit of workflow event triggers for this plan.',
dismiss: 'Dismiss',
upgrade: 'Upgrade',
usageTitle: 'TRIGGER EVENTS',
},
usagePage: {
triggerEvents: 'Trigger Events',
resetsIn: 'Resets in {{count, number}} days',
},
upgradeBtn: {
encourage: 'Upgrade Now',
encourageShort: 'Upgrade',
plain: 'View Plan',
},
},
},
},
},
})
const Template = (args: React.ComponentProps<typeof TriggerEventsLimitModal>) => {
const [visible, setVisible] = useState<boolean>(args.show ?? true)
useEffect(() => {
setVisible(args.show ?? true)
}, [args.show])
const handleHide = () => setVisible(false)
return (
<I18nextProvider i18n={i18n}>
<div className="flex flex-col gap-4">
<button
className="rounded-lg border border-divider-subtle px-4 py-2 text-sm text-text-secondary hover:border-divider-deep hover:text-text-primary"
onClick={() => setVisible(true)}
>
Open Modal
</button>
<TriggerEventsLimitModal
{...args}
show={visible}
onDismiss={handleHide}
onUpgrade={handleHide}
/>
</div>
</I18nextProvider>
)
}
const meta = {
title: 'Billing/TriggerEventsLimitModal',
component: TriggerEventsLimitModal,
parameters: {
layout: 'centered',
},
args: {
show: true,
usage: 120,
total: 120,
resetInDays: 5,
planType: Plan.professional,
},
} satisfies Meta<typeof TriggerEventsLimitModal>
export default meta
type Story = StoryObj<typeof meta>
export const Professional: Story = {
args: {
onDismiss: () => { /* noop */ },
onUpgrade: () => { /* noop */ },
},
render: args => <Template {...args} />,
}
export const Sandbox: Story = {
render: args => <Template {...args} />,
args: {
onDismiss: () => { /* noop */ },
onUpgrade: () => { /* noop */ },
resetInDays: undefined,
planType: Plan.sandbox,
},
}
@@ -0,0 +1,90 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
import UsageInfo from '@/app/components/billing/usage-info'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import type { Plan } from '@/app/components/billing/type'
import styles from './index.module.css'
type Props = {
show: boolean
onDismiss: () => void
onUpgrade: () => void
usage: number
total: number
resetInDays?: number
planType: Plan
}
const TriggerEventsLimitModal: FC<Props> = ({
show,
onDismiss,
onUpgrade,
usage,
total,
resetInDays,
}) => {
const { t } = useTranslation()
return (
<Modal
isShow={show}
onClose={onDismiss}
closable={false}
clickOutsideNotClose
className={`${styles.surface} flex h-[360px] w-[580px] flex-col overflow-hidden rounded-2xl !p-0 shadow-xl`}
>
<div className='relative flex w-full flex-1 items-stretch justify-center'>
<div
aria-hidden
className={`${styles.heroOverlay} pointer-events-none absolute inset-0`}
/>
<div className='relative z-10 flex w-full flex-col items-start gap-4 px-8 pt-8'>
<div className={`${styles.icon} flex h-12 w-12 items-center justify-center rounded-[12px]`}>
<TriggerAll className='h-5 w-5 text-text-primary-on-surface' />
</div>
<div className='flex flex-col items-start gap-2'>
<div className={`${styles.highlight} title-lg-semi-bold`}>
{t('billing.triggerLimitModal.title')}
</div>
<div className='body-md-regular text-text-secondary'>
{t('billing.triggerLimitModal.description')}
</div>
</div>
<UsageInfo
className='mb-5 w-full rounded-[12px] bg-components-panel-on-panel-item-bg'
Icon={TriggerAll}
name={t('billing.triggerLimitModal.usageTitle')}
usage={usage}
total={total}
resetInDays={resetInDays}
hideIcon
/>
</div>
</div>
<div className='flex h-[76px] w-full items-center justify-end gap-2 px-8 pb-8 pt-5'>
<Button
className='h-8 w-[77px] min-w-[72px] !rounded-lg !border-[0.5px] px-3 py-2'
onClick={onDismiss}
>
{t('billing.triggerLimitModal.dismiss')}
</Button>
<UpgradeBtn
isShort
onClick={onUpgrade}
className='flex w-[93px] items-center justify-center !rounded-lg !px-2'
style={{ height: 32 }}
labelKey='billing.triggerLimitModal.upgrade'
loc='trigger-events-limit-modal'
/>
</div>
</Modal>
)
}
export default React.memo(TriggerEventsLimitModal)
+7
View File
@@ -55,6 +55,11 @@ export type SelfHostedPlanInfo = {
export type UsagePlanInfo = Pick<PlanInfo, 'buildApps' | 'teamMembers' | 'annotatedResponse' | 'documentsUploadQuota' | 'apiRateLimit' | 'triggerEvents'> & { vectorSpace: number }
export type UsageResetInfo = {
apiRateLimit?: number | null
triggerEvents?: number | null
}
export enum DocumentProcessingPriority {
standard = 'standard',
priority = 'priority',
@@ -91,10 +96,12 @@ export type CurrentPlanInfoBackend = {
api_rate_limit?: {
size: number
limit: number // total. 0 means unlimited
reset_in_days?: number
}
trigger_events?: {
size: number
limit: number // total. 0 means unlimited
reset_in_days?: number
}
docs_processing: DocumentProcessingPriority
can_replace_logo: boolean
@@ -1,5 +1,5 @@
'use client'
import type { FC } from 'react'
import type { CSSProperties, FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import PremiumBadge from '../../base/premium-badge'
@@ -9,19 +9,24 @@ import { useModalContext } from '@/context/modal-context'
type Props = {
className?: string
style?: CSSProperties
isFull?: boolean
size?: 'md' | 'lg'
isPlain?: boolean
isShort?: boolean
onClick?: () => void
loc?: string
labelKey?: string
}
const UpgradeBtn: FC<Props> = ({
className,
style,
isPlain = false,
isShort = false,
onClick: _onClick,
loc,
labelKey,
}) => {
const { t } = useTranslation()
const { setShowPricingModal } = useModalContext()
@@ -40,10 +45,17 @@ const UpgradeBtn: FC<Props> = ({
}
}
const defaultBadgeLabel = t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}`)
const label = labelKey ? t(labelKey) : defaultBadgeLabel
if (isPlain) {
return (
<Button onClick={onClick}>
{t('billing.upgradeBtn.plain')}
<Button
className={className}
style={style}
onClick={onClick}
>
{labelKey ? label : t('billing.upgradeBtn.plain')}
</Button>
)
}
@@ -54,11 +66,13 @@ const UpgradeBtn: FC<Props> = ({
color='blue'
allowHover={true}
onClick={onClick}
className={className}
style={style}
>
<SparklesSoft className='flex h-3.5 w-3.5 items-center py-[1px] pl-[3px] text-components-premium-badge-indigo-text-stop-0' />
<div className='system-xs-medium'>
<span className='p-1'>
{t(`billing.upgradeBtn.${isShort ? 'encourageShort' : 'encourage'}`)}
{label}
</span>
</div>
</PremiumBadge>
+26 -17
View File
@@ -16,10 +16,12 @@ type Props = {
total: number
unit?: string
unitPosition?: 'inline' | 'suffix'
resetHint?: string
resetInDays?: number
hideIcon?: boolean
}
const LOW = 50
const MIDDLE = 80
const WARNING_THRESHOLD = 80
const UsageInfo: FC<Props> = ({
className,
@@ -30,28 +32,39 @@ const UsageInfo: FC<Props> = ({
total,
unit,
unitPosition = 'suffix',
resetHint,
resetInDays,
hideIcon = false,
}) => {
const { t } = useTranslation()
const percent = usage / total * 100
const color = (() => {
if (percent < LOW)
return 'bg-components-progress-bar-progress-solid'
if (percent < MIDDLE)
return 'bg-components-progress-warning-progress'
return 'bg-components-progress-error-progress'
})()
const color = percent >= 100
? 'bg-components-progress-error-progress'
: (percent >= WARNING_THRESHOLD ? 'bg-components-progress-warning-progress' : 'bg-components-progress-bar-progress-solid')
const isUnlimited = total === NUM_INFINITE
let totalDisplay: string | number = isUnlimited ? t('billing.plansCommon.unlimited') : total
if (!isUnlimited && unit && unitPosition === 'inline')
totalDisplay = `${total}${unit}`
const showUnit = !!unit && !isUnlimited && unitPosition === 'suffix'
const resetText = resetHint ?? (typeof resetInDays === 'number' ? t('billing.usagePage.resetsIn', { count: resetInDays }) : undefined)
const rightInfo = resetText
? (
<div className='system-xs-regular ml-auto flex-1 text-right text-text-tertiary'>
{resetText}
</div>
)
: (showUnit && (
<div className='system-xs-medium ml-auto text-text-tertiary'>
{unit}
</div>
))
return (
<div className={cn('flex flex-col gap-2 rounded-xl bg-components-panel-bg p-4', className)}>
<Icon className='h-4 w-4 text-text-tertiary' />
{!hideIcon && Icon && (
<Icon className='h-4 w-4 text-text-tertiary' />
)}
<div className='flex items-center gap-1'>
<div className='system-xs-medium text-text-tertiary'>{name}</div>
{tooltip && (
@@ -70,11 +83,7 @@ const UsageInfo: FC<Props> = ({
<div className='system-md-regular text-text-quaternary'>/</div>
<div>{totalDisplay}</div>
</div>
{showUnit && (
<div className='system-xs-medium ml-auto text-text-tertiary'>
{unit}
</div>
)}
{rightInfo}
</div>
<ProgressBar
percent={percent}
@@ -36,5 +36,9 @@ export const parseCurrentPlan = (data: CurrentPlanInfoBackend) => {
apiRateLimit: resolveLimit(data.api_rate_limit?.limit, planPreset?.apiRateLimit ?? NUM_INFINITE),
triggerEvents: resolveLimit(data.trigger_events?.limit, planPreset?.triggerEvents),
},
reset: {
apiRateLimit: data.api_rate_limit?.reset_in_days ?? null,
triggerEvents: data.trigger_events?.reset_in_days ?? null,
},
}
}
@@ -121,7 +121,7 @@ const RegenerationModal: FC<IRegenerationModalProps> = ({
})
return (
<Modal isShow={isShow} onClose={noop} className='!max-w-[480px] !rounded-2xl'>
<Modal isShow={isShow} onClose={noop} className='!max-w-[480px] !rounded-2xl' wrapperClassName='!z-[10000]'>
{!loading && !updateSucceeded && <DefaultContent onCancel={onCancel} onConfirm={onConfirm} />}
{loading && !updateSucceeded && <RegeneratingContent />}
{!loading && updateSucceeded && <RegenerationCompletedContent onClose={onClose} />}
@@ -124,6 +124,7 @@ const Completed: FC<ICompletedProps> = ({
const [limit, setLimit] = useState(DEFAULT_LIMIT)
const [fullScreen, setFullScreen] = useState(false)
const [showNewChildSegmentModal, setShowNewChildSegmentModal] = useState(false)
const [isRegenerationModalOpen, setIsRegenerationModalOpen] = useState(false)
const segmentListRef = useRef<HTMLDivElement>(null)
const childSegmentListRef = useRef<HTMLDivElement>(null)
@@ -669,6 +670,7 @@ const Completed: FC<ICompletedProps> = ({
onClose={onCloseSegmentDetail}
showOverlay={false}
needCheckChunks
modal={isRegenerationModalOpen}
>
<SegmentDetail
key={currSegment.segInfo?.id}
@@ -677,6 +679,7 @@ const Completed: FC<ICompletedProps> = ({
isEditMode={currSegment.isEditMode}
onUpdate={handleUpdateSegment}
onCancel={onCloseSegmentDetail}
onModalStateChange={setIsRegenerationModalOpen}
/>
</FullScreenDrawer>
{/* Create New Segment */}
@@ -27,6 +27,7 @@ type ISegmentDetailProps = {
onCancel: () => void
isEditMode?: boolean
docForm: ChunkingMode
onModalStateChange?: (isOpen: boolean) => void
}
/**
@@ -38,6 +39,7 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
onCancel,
isEditMode,
docForm,
onModalStateChange,
}) => {
const { t } = useTranslation()
const [question, setQuestion] = useState(isEditMode ? segInfo?.content || '' : segInfo?.sign_content || '')
@@ -68,11 +70,19 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
const handleRegeneration = useCallback(() => {
setShowRegenerationModal(true)
}, [])
onModalStateChange?.(true)
}, [onModalStateChange])
const onCancelRegeneration = useCallback(() => {
setShowRegenerationModal(false)
}, [])
onModalStateChange?.(false)
}, [onModalStateChange])
const onCloseAfterRegeneration = useCallback(() => {
setShowRegenerationModal(false)
onModalStateChange?.(false)
onCancel() // Close the edit drawer
}, [onCancel, onModalStateChange])
const onConfirmRegeneration = useCallback(() => {
onUpdate(segInfo?.id || '', question, answer, keywords, true)
@@ -161,7 +171,7 @@ const SegmentDetail: FC<ISegmentDetailProps> = ({
isShow={showRegenerationModal}
onConfirm={onConfirmRegeneration}
onCancel={onCancelRegeneration}
onClose={onCancelRegeneration}
onClose={onCloseAfterRegeneration}
/>
)
}
@@ -17,8 +17,9 @@ import type { InvitationResult } from '@/models/common'
import I18n from '@/context/i18n'
import 'react-multi-email/dist/style.css'
import { noop } from 'lodash-es'
import { useProviderContextSelector } from '@/context/provider-context'
import { useBoolean } from 'ahooks'
type IInviteModalProps = {
isEmailSetup: boolean
onCancel: () => void
@@ -49,9 +50,15 @@ const InviteModal = ({
const { locale } = useContext(I18n)
const [role, setRole] = useState<string>('normal')
const [isSubmitting, {
setTrue: setIsSubmitting,
setFalse: setIsSubmitted,
}] = useBoolean(false)
const handleSend = useCallback(async () => {
if (isLimitExceeded)
if (isLimitExceeded || isSubmitting)
return
setIsSubmitting()
if (emails.map((email: string) => emailRegex.test(email)).every(Boolean)) {
try {
const { result, invitation_results } = await inviteMember({
@@ -70,7 +77,8 @@ const InviteModal = ({
else {
notify({ type: 'error', message: t('common.members.emailInvalid') })
}
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, t])
setIsSubmitted()
}, [isLimitExceeded, emails, role, locale, onCancel, onSend, notify, t, isSubmitting])
return (
<div className={cn(s.wrap)}>
@@ -133,7 +141,7 @@ const InviteModal = ({
tabIndex={0}
className='w-full'
onClick={handleSend}
disabled={!emails.length || isLimitExceeded}
disabled={!emails.length || isLimitExceeded || isSubmitting}
variant='primary'
>
{t('common.members.sendInvite')}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,80 +0,0 @@
'use client'
import { useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { useMount } from 'ahooks'
import cn from '@/utils/classnames'
import { Apps02 } from '@/app/components/base/icons/src/vender/line/others'
import I18n from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import { useStore as useLabelStore } from '@/app/components/tools/labels/store'
import { fetchLabelList } from '@/service/tools'
import { renderI18nObject } from '@/i18n-config'
type Props = {
value: string
onSelect: (type: string) => void
}
const Icon = ({ svgString, active }: { svgString: string; active: boolean }) => {
const svgRef = useRef<SVGSVGElement | null>(null)
const SVGParser = (svg: string) => {
if (!svg)
return null
const parser = new DOMParser()
const doc = parser.parseFromString(svg, 'image/svg+xml')
return doc.documentElement
}
useMount(() => {
const svgElement = SVGParser(svgString)
if (svgRef.current && svgElement)
svgRef.current.appendChild(svgElement)
})
return <svg className={cn('h-4 w-4 text-gray-700', active && '!text-primary-600')} ref={svgRef} />
}
const Category = ({
value,
onSelect,
}: Props) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const labelList = useLabelStore(s => s.labelList)
const setLabelList = useLabelStore(s => s.setLabelList)
useMount(() => {
fetchLabelList().then((res) => {
setLabelList(res)
})
})
return (
<div className='mb-3'>
<div className='px-3 py-0.5 text-xs font-medium leading-[18px] text-gray-500'>{t('tools.addToolModal.category').toLocaleUpperCase()}</div>
<div className={cn('mb-0.5 flex cursor-pointer items-center rounded-lg p-1 pl-3 text-sm leading-5 text-gray-700 hover:bg-white', value === '' && '!bg-white font-medium !text-primary-600')} onClick={() => onSelect('')}>
<Apps02 className='mr-2 h-4 w-4 shrink-0' />
{t('tools.type.all')}
</div>
{labelList.map((label) => {
const labelText = typeof label.label === 'string'
? label.label
: (label.label ? renderI18nObject(label.label, language) : '')
return (
<div
key={label.name}
title={labelText}
className={cn('mb-0.5 flex cursor-pointer items-center overflow-hidden truncate rounded-lg p-1 pl-3 text-sm leading-5 text-gray-700 hover:bg-white', value === label.name && '!bg-white font-medium !text-primary-600')}
onClick={() => onSelect(label.name)}
>
<div className='mr-2 h-4 w-4 shrink-0'>
<Icon active={value === label.name} svgString={label.icon || ''} />
</div>
{labelText}
</div>
)
})}
</div>
)
}
export default Category
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

@@ -1,258 +0,0 @@
'use client'
import type { FC } from 'react'
import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { produce } from 'immer'
import {
RiAddLine,
RiCloseLine,
} from '@remixicon/react'
import { useMount } from 'ahooks'
import type { Collection, CustomCollectionBackend, Tool } from '../types'
import type { CollectionType } from '../types'
import Type from './type'
import Category from './category'
import Tools from './tools'
import cn from '@/utils/classnames'
import { basePath } from '@/utils/var'
import I18n from '@/context/i18n'
import Drawer from '@/app/components/base/drawer'
import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import Input from '@/app/components/base/input'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import {
createCustomCollection,
fetchAllBuiltInTools,
fetchAllCustomTools,
fetchAllWorkflowTools,
removeBuiltInToolCredential,
updateBuiltInToolCredential,
} from '@/service/tools'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import Toast from '@/app/components/base/toast'
import ConfigContext from '@/context/debug-configuration'
import type { ModelConfig } from '@/models/debug'
type Props = {
onHide: () => void
}
// Add and Edit
const AddToolModal: FC<Props> = ({
onHide,
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
const [currentType, setCurrentType] = useState('builtin')
const [currentCategory, setCurrentCategory] = useState('')
const [keywords, setKeywords] = useState<string>('')
const handleKeywordsChange = (value: string) => {
setKeywords(value)
}
const isMatchingKeywords = (text: string, keywords: string) => {
return text.toLowerCase().includes(keywords.toLowerCase())
}
const [toolList, setToolList] = useState<ToolWithProvider[]>([])
const [listLoading, setListLoading] = useState(true)
const getAllTools = async () => {
setListLoading(true)
const buildInTools = await fetchAllBuiltInTools()
if (basePath) {
buildInTools.forEach((item) => {
if (typeof item.icon == 'string' && !item.icon.includes(basePath))
item.icon = `${basePath}${item.icon}`
})
}
const customTools = await fetchAllCustomTools()
const workflowTools = await fetchAllWorkflowTools()
const mergedToolList = [
...buildInTools,
...customTools,
...workflowTools.filter((toolWithProvider) => {
return !toolWithProvider.tools.some((tool) => {
return !!tool.parameters.find(item => item.name === '__image')
})
}),
]
setToolList(mergedToolList)
setListLoading(false)
}
const filteredList = useMemo(() => {
return toolList.filter((toolWithProvider) => {
if (currentType === 'all')
return true
else
return toolWithProvider.type === currentType
}).filter((toolWithProvider) => {
if (!currentCategory)
return true
else
return toolWithProvider.labels.includes(currentCategory)
}).filter((toolWithProvider) => {
return (
isMatchingKeywords(toolWithProvider.name, keywords)
|| toolWithProvider.tools.some((tool) => {
return Object.values(tool.label).some((label) => {
return isMatchingKeywords(label, keywords)
})
})
)
})
}, [currentType, currentCategory, toolList, keywords])
const {
modelConfig,
setModelConfig,
} = useContext(ConfigContext)
const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false)
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
setIsShowEditCustomCollectionModal(false)
getAllTools()
}
const [showSettingAuth, setShowSettingAuth] = useState(false)
const [collection, setCollection] = useState<Collection>()
const toolSelectHandle = (collection: Collection, tool: Tool) => {
const parameters: Record<string, string> = {}
if (tool.parameters) {
tool.parameters.forEach((item) => {
parameters[item.name] = ''
})
}
const nexModelConfig = produce(modelConfig, (draft: ModelConfig) => {
draft.agentConfig.tools.push({
provider_id: collection.id || collection.name,
provider_type: collection.type as CollectionType,
provider_name: collection.name,
tool_name: tool.name,
tool_label: tool.label[locale] || tool.label[locale.replaceAll('-', '_')],
tool_parameters: parameters,
enabled: true,
})
})
setModelConfig(nexModelConfig)
}
const authSelectHandle = (provider: Collection) => {
setCollection(provider)
setShowSettingAuth(true)
}
const updateBuiltinAuth = async (value: Record<string, any>) => {
if (!collection)
return
await updateBuiltInToolCredential(collection.name, value)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
await getAllTools()
setShowSettingAuth(false)
}
const removeBuiltinAuth = async () => {
if (!collection)
return
await removeBuiltInToolCredential(collection.name)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
await getAllTools()
setShowSettingAuth(false)
}
useMount(() => {
getAllTools()
})
return (
<>
<Drawer
isOpen
mask
clickOutsideNotOpen
onClose={onHide}
footer={null}
panelClassName={cn('mx-2 mb-3 mt-16 rounded-xl !p-0 sm:mr-2', 'mt-2 !w-[640px]', '!max-w-[640px]')}
>
<div
className='flex w-full rounded-xl border-[0.5px] border-gray-200 bg-white shadow-xl'
style={{
height: 'calc(100vh - 16px)',
}}
>
<div className='relative w-[200px] shrink-0 overflow-y-auto rounded-l-xl border-r-[0.5px] border-black/2 bg-gray-100 pb-3'>
<div className='sticky left-0 right-0 top-0'>
<div className='text-md sticky left-0 right-0 top-0 px-5 py-3 font-semibold text-gray-900'>{t('tools.addTool')}</div>
<div className='px-3 pb-4 pt-2'>
<Button variant='primary' className='w-[176px]' onClick={() => setIsShowEditCustomCollectionModal(true)}>
<RiAddLine className='mr-1 h-4 w-4' />
{t('tools.createCustomTool')}
</Button>
</div>
</div>
<div className='px-2 py-1'>
<Type value={currentType} onSelect={setCurrentType} />
<Category value={currentCategory} onSelect={setCurrentCategory} />
</div>
</div>
<div className='relative grow overflow-y-auto rounded-r-xl bg-white'>
<div className='sticky left-0 right-0 top-0 z-10 flex items-center gap-1 bg-white p-2'>
<div className='grow'>
<Input
showLeftIcon
showClearIcon
value={keywords}
onChange={e => handleKeywordsChange(e.target.value)}
onClear={() => handleKeywordsChange('')}
/>
</div>
<div className='ml-2 mr-1 h-4 w-[1px] bg-gray-200'></div>
<div className='cursor-pointer p-2' onClick={onHide}>
<RiCloseLine className='h-4 w-4 text-gray-500' />
</div>
</div>
{listLoading && (
<div className='flex h-[200px] items-center justify-center bg-white'>
<Loading />
</div>
)}
{!listLoading && (
<Tools
showWorkflowEmpty={currentType === 'workflow'}
tools={filteredList}
addedTools={(modelConfig?.agentConfig?.tools as any) || []}
onSelect={toolSelectHandle}
onAuthSetup={authSelectHandle}
/>
)}
</div>
</div>
</Drawer>
{isShowEditCollectionToolModal && (
<EditCustomToolModal
positionLeft
payload={null}
onHide={() => setIsShowEditCustomCollectionModal(false)}
onAdd={doCreateCustomToolCollection}
/>
)}
{showSettingAuth && collection && (
<ConfigCredential
collection={collection}
onCancel={() => setShowSettingAuth(false)}
onSaved={updateBuiltinAuth}
onRemove={removeBuiltinAuth}
/>
)}
</>
)
}
export default React.memo(AddToolModal)
@@ -1,158 +0,0 @@
import {
memo,
useCallback,
} from 'react'
import { basePath } from '@/utils/var'
import { useTranslation } from 'react-i18next'
import {
RiAddLine,
} from '@remixicon/react'
import cn from '@/utils/classnames'
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import { Tag01 } from '@/app/components/base/icons/src/vender/line/financeAndECommerce'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { BlockEnum } from '@/app/components/workflow/types'
import BlockIcon from '@/app/components/workflow/block-icon'
import Tooltip from '@/app/components/base/tooltip'
import Button from '@/app/components/base/button'
import { useGetLanguage } from '@/context/i18n'
import { useStore as useLabelStore } from '@/app/components/tools/labels/store'
import Empty from '@/app/components/tools/add-tool-modal/empty'
import type { Tool } from '@/app/components/tools/types'
import { CollectionType } from '@/app/components/tools/types'
import type { AgentTool } from '@/types/app'
import { MAX_TOOLS_NUM } from '@/config'
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { renderI18nObject } from '@/i18n-config'
const resolveI18nText = (value: TypeWithI18N | string | undefined, language: string): string => {
if (!value)
return ''
return typeof value === 'string' ? value : renderI18nObject(value, language)
}
type ToolsProps = {
showWorkflowEmpty: boolean
tools: ToolWithProvider[]
addedTools: AgentTool[]
onSelect: (provider: ToolWithProvider, tool: Tool) => void
onAuthSetup: (provider: ToolWithProvider) => void
}
const Blocks = ({
showWorkflowEmpty,
tools,
addedTools,
onSelect,
onAuthSetup,
}: ToolsProps) => {
const { t } = useTranslation()
const language = useGetLanguage()
const labelList = useLabelStore(s => s.labelList)
const addable = addedTools.length < MAX_TOOLS_NUM
const renderGroup = useCallback((toolWithProvider: ToolWithProvider) => {
const list = toolWithProvider.tools
const needAuth = toolWithProvider.allow_delete && !toolWithProvider.is_team_authorization && toolWithProvider.type === CollectionType.builtIn
return (
<div
key={toolWithProvider.id}
className='group mb-1 last-of-type:mb-0'
>
<div className='flex h-[22px] w-full items-center justify-between pl-3 pr-1 text-xs font-medium text-gray-500'>
{resolveI18nText(toolWithProvider.label, language)}
<a className='hidden cursor-pointer items-center group-hover:flex' href={`${basePath}/tools?category=${toolWithProvider.type}`} target='_blank'>{t('tools.addToolModal.manageInTools')}<ArrowUpRight className='ml-0.5 h-3 w-3' /></a>
</div>
{list.map((tool) => {
const labelContent = (() => {
if (!tool.labels)
return ''
return tool.labels.map((name) => {
const label = labelList.find(item => item.name === name)
return resolveI18nText(label?.label, language)
}).filter(Boolean).join(', ')
})()
const added = !!addedTools?.find(v => v.provider_id === toolWithProvider.id && v.provider_type === toolWithProvider.type && v.tool_name === tool.name)
return (
<Tooltip
key={tool.name}
position='bottom'
popupClassName='!p-0 !px-3 !py-2.5 !w-[210px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !bg-transparent !rounded-xl !shadow-lg translate-x-[108px]'
popupContent={(
<div>
<BlockIcon
size='md'
className='mb-2'
type={BlockEnum.Tool}
toolIcon={toolWithProvider.icon}
/>
<div className='mb-1 text-sm leading-5 text-gray-900'>{resolveI18nText(tool.label, language)}</div>
<div className='text-xs leading-[18px] text-gray-700'>{resolveI18nText(tool.description, language)}</div>
{tool.labels?.length > 0 && (
<div className='mt-1 flex shrink-0 items-center'>
<div className='relative flex w-full items-center gap-1 rounded-md py-1 text-gray-500' title={labelContent}>
<Tag01 className='h-3 w-3 shrink-0 text-gray-500' />
<div className='grow truncate text-start text-xs font-normal leading-[18px]'>{labelContent}</div>
</div>
</div>
)}
</div>
)}
>
<div className='group/item flex h-8 w-full cursor-pointer items-center rounded-lg pl-3 pr-1 hover:bg-gray-50'>
<BlockIcon
className={cn('mr-2 shrink-0', needAuth && 'opacity-30')}
type={BlockEnum.Tool}
toolIcon={toolWithProvider.icon}
/>
<div className={cn('grow truncate text-sm text-gray-900', needAuth && 'opacity-30')}>{resolveI18nText(tool.label, language)}</div>
{!needAuth && added && (
<div className='flex items-center gap-1 rounded-[6px] border border-gray-100 bg-white px-2 py-[3px] text-xs font-medium leading-[18px] text-gray-300'>
<Check className='h-3 w-3' />
{t('tools.addToolModal.added').toLocaleUpperCase()}
</div>
)}
{!needAuth && !added && addable && (
<Button
variant='secondary-accent'
size='small'
className={cn('hidden shrink-0 items-center group-hover/item:flex')}
onClick={() => onSelect(toolWithProvider, tool)}
>
<RiAddLine className='h-3 w-3' />
{t('tools.addToolModal.add').toLocaleUpperCase()}
</Button>
)}
{needAuth && (
<Button
variant='secondary-accent'
size='small'
className={cn('hidden shrink-0 group-hover/item:flex')}
onClick={() => onAuthSetup(toolWithProvider)}
>{t('tools.auth.setup')}</Button>
)}
</div>
</Tooltip>
)
})}
</div>
)
}, [addable, language, t, labelList, addedTools, onAuthSetup, onSelect])
return (
<div className='max-w-[440px] p-1 pb-6'>
{!tools.length && !showWorkflowEmpty && (
<div className='flex h-[22px] items-center px-3 text-xs font-medium text-gray-500'>{t('workflow.tabs.noResult')}</div>
)}
{!tools.length && showWorkflowEmpty && (
<div className='pt-[280px]'>
<Empty />
</div>
)}
{!!tools.length && tools.map(renderGroup)}
</div>
)
}
export default memo(Blocks)
@@ -1,34 +0,0 @@
'use client'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import { Exchange02, FileCode } from '@/app/components/base/icons/src/vender/line/others'
type Props = {
value: string
onSelect: (type: string) => void
}
const Types = ({
value,
onSelect,
}: Props) => {
const { t } = useTranslation()
return (
<div className='mb-3'>
<div className={cn('mb-0.5 flex cursor-pointer items-center rounded-lg p-1 pl-3 text-sm leading-5 hover:bg-white', value === 'builtin' && '!bg-white font-medium')} onClick={() => onSelect('builtin')}>
<div className="mr-2 h-4 w-4 shrink-0 bg-[url('~@/app/components/tools/add-tool-modal/D.png')] bg-cover bg-no-repeat" />
<span className={cn('text-gray-700', value === 'builtin' && '!text-primary-600')}>{t('tools.type.builtIn')}</span>
</div>
<div className={cn('mb-0.5 flex cursor-pointer items-center rounded-lg p-1 pl-3 text-sm leading-5 text-gray-700 hover:bg-white', value === 'api' && '!bg-white font-medium !text-primary-600')} onClick={() => onSelect('api')}>
<FileCode className='mr-2 h-4 w-4 shrink-0' />
{t('tools.type.custom')}
</div>
<div className={cn('mb-0.5 flex cursor-pointer items-center rounded-lg p-1 pl-3 text-sm leading-5 text-gray-700 hover:bg-white', value === 'workflow' && '!bg-white font-medium !text-primary-600')} onClick={() => onSelect('workflow')}>
<Exchange02 className='mr-2 h-4 w-4 shrink-0' />
{t('tools.type.workflow')}
</div>
</div>
)
}
export default Types
@@ -30,10 +30,14 @@ import { useDocLink } from '@/context/i18n'
export type IAppCardProps = {
appInfo: AppDetailResponse & Partial<AppSSO>
triggerModeDisabled?: boolean // align with Trigger Node vs User Input exclusivity
triggerModeMessage?: React.ReactNode // display-only message explaining the trigger restriction
}
function MCPServiceCard({
appInfo,
triggerModeDisabled = false,
triggerModeMessage = '',
}: IAppCardProps) {
const { t } = useTranslation()
const docLink = useDocLink()
@@ -79,7 +83,7 @@ function MCPServiceCard({
const hasStartNode = currentWorkflow?.graph?.nodes?.some(node => node.data.type === BlockEnum.Start)
const missingStartNode = isWorkflowApp && !hasStartNode
const hasInsufficientPermissions = !isCurrentWorkspaceEditor
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode
const toggleDisabled = hasInsufficientPermissions || appUnpublished || missingStartNode || triggerModeDisabled
const isMinimalState = appUnpublished || missingStartNode
const [activated, setActivated] = useState(serverActivated)
@@ -144,7 +148,18 @@ function MCPServiceCard({
return (
<>
<div className={cn('w-full max-w-full rounded-xl border-l-[0.5px] border-t border-effects-highlight', isMinimalState && 'h-12')}>
<div className='rounded-xl bg-background-default'>
<div className={cn('relative rounded-xl bg-background-default', triggerModeDisabled && 'opacity-60')}>
{triggerModeDisabled && (
triggerModeMessage ? (
<Tooltip
popupContent={triggerModeMessage}
popupClassName="max-w-64 rounded-xl bg-components-panel-bg px-3 py-2 text-xs text-text-secondary shadow-lg"
position="right"
>
<div className='absolute inset-0 z-10 cursor-not-allowed rounded-xl' aria-hidden="true"></div>
</Tooltip>
) : <div className='absolute inset-0 z-10 cursor-not-allowed rounded-xl' aria-hidden="true"></div>
)}
<div className={cn('flex w-full flex-col items-start justify-center gap-3 self-stretch p-3', isMinimalState ? 'border-0' : 'border-b-[0.5px] border-divider-subtle')}>
<div className='flex w-full items-center gap-3 self-stretch'>
<div className='flex grow items-center'>
@@ -182,7 +197,7 @@ function MCPServiceCard({
{t('appOverview.overview.appInfo.enableTooltip.learnMore')}
</div>
</>
) : ''
) : triggerModeMessage || ''
) : ''
}
position="right"
+1 -1
View File
@@ -11,7 +11,7 @@ import Input from '@/app/components/base/input'
import ProviderDetail from '@/app/components/tools/provider/detail'
import Empty from '@/app/components/plugins/marketplace/empty'
import CustomCreateCard from '@/app/components/tools/provider/custom-create-card'
import WorkflowToolEmpty from '@/app/components/tools/add-tool-modal/empty'
import WorkflowToolEmpty from '@/app/components/tools/provider/empty'
import Card from '@/app/components/plugins/card'
import CardMoreInfo from '@/app/components/plugins/card/card-more-info'
import PluginDetailPanel from '@/app/components/plugins/plugin-detail-panel'
@@ -40,6 +40,8 @@ import useTheme from '@/hooks/use-theme'
import cn from '@/utils/classnames'
import { useIsChatMode } from '@/app/components/workflow/hooks'
import type { StartNodeType } from '@/app/components/workflow/nodes/start/types'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '@/app/components/billing/type'
const FeaturesTrigger = () => {
const { t } = useTranslation()
@@ -50,6 +52,7 @@ const FeaturesTrigger = () => {
const appID = appDetail?.id
const setAppDetail = useAppStore(s => s.setAppDetail)
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
const { plan, isFetchedPlan } = useProviderContext()
const publishedAt = useStore(s => s.publishedAt)
const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
const toolPublished = useStore(s => s.toolPublished)
@@ -95,6 +98,15 @@ const FeaturesTrigger = () => {
const hasTriggerNode = useMemo(() => (
nodes.some(node => isTriggerNode(node.data.type as BlockEnum))
), [nodes])
const startNodeLimitExceeded = useMemo(() => {
const entryCount = nodes.reduce((count, node) => {
const nodeType = node.data.type as BlockEnum
if (nodeType === BlockEnum.Start || isTriggerNode(nodeType))
return count + 1
return count
}, 0)
return isFetchedPlan && plan.type === Plan.sandbox && entryCount > 2
}, [nodes, plan.type, isFetchedPlan])
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
const invalidateAppTriggers = useInvalidateAppTriggers()
@@ -196,7 +208,8 @@ const FeaturesTrigger = () => {
crossAxisOffset: 4,
missingStartNode: !startNode,
hasTriggerNode,
publishDisabled: !hasWorkflowNodes,
startNodeLimitExceeded,
publishDisabled: !hasWorkflowNodes || startNodeLimitExceeded,
}}
/>
</>
@@ -17,9 +17,17 @@ export const useAvailableNodesMetaData = () => {
const isChatMode = useIsChatMode()
const docLink = useDocLink()
const startNodeMetaData = useMemo(() => ({
...StartDefault,
metaData: {
...StartDefault.metaData,
isUndeletable: isChatMode, // start node is undeletable in chat mode, @use-nodes-interactions: handleNodeDelete function
},
}), [isChatMode])
const mergedNodesMetaData = useMemo(() => [
...WORKFLOW_COMMON_NODES,
StartDefault,
startNodeMetaData,
...(
isChatMode
? [AnswerDefault]
@@ -30,7 +38,7 @@ export const useAvailableNodesMetaData = () => {
TriggerPluginDefault,
]
),
], [isChatMode])
], [isChatMode, startNodeMetaData])
const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => {
const { metaData } = node
@@ -4,7 +4,7 @@ import IndexBar, { groupItems } from './index-bar'
import type { ToolDefaultValue, ToolValue } from './types'
import type { ToolTypeEnum } from './types'
import { ViewType } from './view-type-select'
import Empty from '@/app/components/tools/add-tool-modal/empty'
import Empty from '@/app/components/tools/provider/empty'
import { useGetLanguage } from '@/context/i18n'
import ToolListTreeView from './tool/tool-list-tree-view/list'
import ToolListFlatView from './tool/tool-list-flat-view/list'
@@ -249,6 +249,8 @@ export const useChecklistBeforePublish = () => {
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const appMode = useAppStore.getState().appDetail?.mode
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
const getCheckData = useCallback((data: CommonNodeType<{}>, datasets: DataSet[]) => {
let checkData = data
@@ -366,17 +368,22 @@ export const useChecklistBeforePublish = () => {
}
}
if (!validNodes.find(n => n.id === node.id)) {
const isStartNodeMeta = nodesExtraData?.[node.data.type as BlockEnum]?.metaData.isStart ?? false
const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta : true
const isUnconnected = !validNodes.find(n => n.id === node.id)
if (isUnconnected && !canSkipConnectionCheck) {
notify({ type: 'error', message: `[${node.data.title}] ${t('workflow.common.needConnectTip')}` })
return false
}
}
const startNodesFiltered = nodes.filter(node => START_NODE_TYPES.includes(node.data.type as BlockEnum))
if (startNodesFiltered.length === 0) {
notify({ type: 'error', message: t('workflow.common.needStartNode') })
return false
if (shouldCheckStartNode) {
const startNodesFiltered = nodes.filter(node => START_NODE_TYPES.includes(node.data.type as BlockEnum))
if (startNodesFiltered.length === 0) {
notify({ type: 'error', message: t('workflow.common.needStartNode') })
return false
}
}
const isRequiredNodesType = Object.keys(nodesExtraData!).filter((key: any) => (nodesExtraData as any)[key].metaData.isRequired)
@@ -391,7 +398,7 @@ export const useChecklistBeforePublish = () => {
}
return true
}, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, workflowStore, buildInTools, customTools, workflowTools])
}, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, workflowStore, buildInTools, customTools, workflowTools, shouldCheckStartNode])
return {
handleCheckBeforePublish,
@@ -44,17 +44,17 @@ export const useShortcuts = (): void => {
fitView,
} = useReactFlow()
// Zoom out to a minimum of 0.5 for shortcut
// Zoom out to a minimum of 0.25 for shortcut
const constrainedZoomOut = () => {
const currentZoom = getZoom()
const newZoom = Math.max(currentZoom - 0.1, 0.5)
const newZoom = Math.max(currentZoom - 0.1, 0.25)
zoomTo(newZoom)
}
// Zoom in to a maximum of 1 for shortcut
// Zoom in to a maximum of 2 for shortcut
const constrainedZoomIn = () => {
const currentZoom = getZoom()
const newZoom = Math.min(currentZoom + 0.1, 1)
const newZoom = Math.min(currentZoom + 0.1, 2)
zoomTo(newZoom)
}
@@ -76,9 +76,11 @@ const RetryOnPanel = ({
/>
<Input
type='number'
wrapperClassName='w-[80px]'
wrapperClassName='w-[100px]'
value={retry_config?.max_retries || 3}
onChange={e => handleMaxRetriesChange(e.target.value as any)}
onChange={e =>
handleMaxRetriesChange(Number.parseInt(e.currentTarget.value, 10) || 3)
}
min={1}
max={10}
unit={t('workflow.nodes.common.retry.times') || ''}
@@ -96,9 +98,11 @@ const RetryOnPanel = ({
/>
<Input
type='number'
wrapperClassName='w-[80px]'
wrapperClassName='w-[100px]'
value={retry_config?.retry_interval || 1000}
onChange={e => handleRetryIntervalChange(e.target.value as any)}
onChange={e =>
handleRetryIntervalChange(Number.parseInt(e.currentTarget.value, 10) || 1000)
}
min={100}
max={5000}
unit={t('workflow.nodes.common.retry.ms') || ''}
@@ -65,7 +65,7 @@ const AgentNode: FC<NodeProps<AgentNodeType>> = (props) => {
})
return tools
}, [currentStrategy?.parameters, inputs.agent_parameters])
return <div className='mb-1 space-y-1 px-3 py-1'>
return <div className='mb-1 space-y-1 px-3'>
{inputs.agent_strategy_name
? <SettingItem
label={t('workflow.nodes.agent.strategy.shortLabel')}
@@ -25,7 +25,7 @@ const NodeComponent: FC<NodeProps<DocExtractorNodeType>> = ({
const isSystem = isSystemVar(variable)
const node = isSystem ? nodes.find(node => node.data.type === BlockEnum.Start) : nodes.find(node => node.id === variable[0])
return (
<div className='relative px-3'>
<div className='relative mb-1 px-3 py-1'>
<div className='system-2xs-medium-uppercase mb-1 text-text-tertiary'>{t(`${i18nPrefix}.inputVar`)}</div>
<VariableLabelInNode
variables={variable}
@@ -0,0 +1,130 @@
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from 'react'
import dayjs from 'dayjs'
import { NUM_INFINITE } from '@/app/components/billing/config'
import { Plan } from '@/app/components/billing/type'
import { IS_CLOUD_EDITION } from '@/config'
import type { ModalState } from '../modal-context'
export type TriggerEventsLimitModalPayload = {
usage: number
total: number
resetInDays?: number
planType: Plan
storageKey?: string
persistDismiss?: boolean
}
type TriggerPlanInfo = {
type: Plan
usage: { triggerEvents: number }
total: { triggerEvents: number }
reset: { triggerEvents?: number | null }
}
type UseTriggerEventsLimitModalOptions = {
plan: TriggerPlanInfo
isFetchedPlan: boolean
currentWorkspaceId?: string
}
type UseTriggerEventsLimitModalResult = {
showTriggerEventsLimitModal: ModalState<TriggerEventsLimitModalPayload> | null
setShowTriggerEventsLimitModal: Dispatch<SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null>>
persistTriggerEventsLimitModalDismiss: () => void
}
const TRIGGER_EVENTS_LOCALSTORAGE_PREFIX = 'trigger-events-limit-dismissed'
export const useTriggerEventsLimitModal = ({
plan,
isFetchedPlan,
currentWorkspaceId,
}: UseTriggerEventsLimitModalOptions): UseTriggerEventsLimitModalResult => {
const [showTriggerEventsLimitModal, setShowTriggerEventsLimitModal] = useState<ModalState<TriggerEventsLimitModalPayload> | null>(null)
const dismissedTriggerEventsLimitStorageKeysRef = useRef<Record<string, boolean>>({})
useEffect(() => {
if (!IS_CLOUD_EDITION)
return
if (typeof window === 'undefined')
return
if (!currentWorkspaceId)
return
if (!isFetchedPlan) {
setShowTriggerEventsLimitModal(null)
return
}
const { type, usage, total, reset } = plan
const isUnlimited = total.triggerEvents === NUM_INFINITE
const reachedLimit = total.triggerEvents > 0 && usage.triggerEvents >= total.triggerEvents
if (type === Plan.team || isUnlimited || !reachedLimit) {
if (showTriggerEventsLimitModal)
setShowTriggerEventsLimitModal(null)
return
}
const triggerResetInDays = type === Plan.professional && total.triggerEvents !== NUM_INFINITE
? reset.triggerEvents ?? undefined
: undefined
const cycleTag = (() => {
if (typeof reset.triggerEvents === 'number')
return dayjs().startOf('day').add(reset.triggerEvents, 'day').format('YYYY-MM-DD')
if (type === Plan.sandbox)
return dayjs().endOf('month').format('YYYY-MM-DD')
return 'none'
})()
const storageKey = `${TRIGGER_EVENTS_LOCALSTORAGE_PREFIX}-${currentWorkspaceId}-${type}-${total.triggerEvents}-${cycleTag}`
if (dismissedTriggerEventsLimitStorageKeysRef.current[storageKey])
return
let persistDismiss = true
let hasDismissed = false
try {
if (localStorage.getItem(storageKey) === '1')
hasDismissed = true
}
catch {
persistDismiss = false
}
if (hasDismissed)
return
if (showTriggerEventsLimitModal?.payload.storageKey === storageKey)
return
setShowTriggerEventsLimitModal({
payload: {
usage: usage.triggerEvents,
total: total.triggerEvents,
planType: type,
resetInDays: triggerResetInDays,
storageKey,
persistDismiss,
},
})
}, [plan, isFetchedPlan, showTriggerEventsLimitModal, currentWorkspaceId])
const persistTriggerEventsLimitModalDismiss = useCallback(() => {
const storageKey = showTriggerEventsLimitModal?.payload.storageKey
if (!storageKey)
return
if (showTriggerEventsLimitModal?.payload.persistDismiss) {
try {
localStorage.setItem(storageKey, '1')
return
}
catch {
// ignore error and fall back to in-memory guard
}
}
dismissedTriggerEventsLimitStorageKeysRef.current[storageKey] = true
}, [showTriggerEventsLimitModal])
return {
showTriggerEventsLimitModal,
setShowTriggerEventsLimitModal,
persistTriggerEventsLimitModalDismiss,
}
}
+181
View File
@@ -0,0 +1,181 @@
import React from 'react'
import { act, render, screen, waitFor } from '@testing-library/react'
import { ModalContextProvider } from '@/context/modal-context'
import { Plan } from '@/app/components/billing/type'
import { defaultPlan } from '@/app/components/billing/config'
jest.mock('@/config', () => {
const actual = jest.requireActual('@/config')
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
jest.mock('next/navigation', () => ({
useSearchParams: jest.fn(() => new URLSearchParams()),
}))
const mockUseProviderContext = jest.fn()
jest.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
const mockUseAppContext = jest.fn()
jest.mock('@/context/app-context', () => ({
useAppContext: () => mockUseAppContext(),
}))
let latestTriggerEventsModalProps: any = null
const triggerEventsLimitModalMock = jest.fn((props: any) => {
latestTriggerEventsModalProps = props
return (
<div data-testid="trigger-limit-modal">
<button type="button" onClick={props.onDismiss}>dismiss</button>
<button type="button" onClick={props.onUpgrade}>upgrade</button>
</div>
)
})
jest.mock('@/app/components/billing/trigger-events-limit-modal', () => ({
__esModule: true,
default: (props: any) => triggerEventsLimitModalMock(props),
}))
type DefaultPlanShape = typeof defaultPlan
type PlanOverrides = Partial<Omit<DefaultPlanShape, 'usage' | 'total' | 'reset'>> & {
usage?: Partial<DefaultPlanShape['usage']>
total?: Partial<DefaultPlanShape['total']>
reset?: Partial<DefaultPlanShape['reset']>
}
const createPlan = (overrides: PlanOverrides = {}): DefaultPlanShape => ({
...defaultPlan,
...overrides,
usage: {
...defaultPlan.usage,
...(overrides.usage ?? {}),
},
total: {
...defaultPlan.total,
...(overrides.total ?? {}),
},
reset: {
...defaultPlan.reset,
...(overrides.reset ?? {}),
},
})
const renderProvider = () => render(
<ModalContextProvider>
<div data-testid="modal-context-test-child" />
</ModalContextProvider>,
)
describe('ModalContextProvider trigger events limit modal', () => {
beforeEach(() => {
latestTriggerEventsModalProps = null
triggerEventsLimitModalMock.mockClear()
mockUseAppContext.mockReset()
mockUseProviderContext.mockReset()
window.localStorage.clear()
mockUseAppContext.mockReturnValue({
currentWorkspace: {
id: 'workspace-1',
},
})
})
afterEach(() => {
jest.restoreAllMocks()
})
it('opens the trigger events limit modal and persists dismissal in localStorage', async () => {
const plan = createPlan({
type: Plan.professional,
usage: { triggerEvents: 3000 },
total: { triggerEvents: 3000 },
reset: { triggerEvents: 5 },
})
mockUseProviderContext.mockReturnValue({
plan,
isFetchedPlan: true,
})
const setItemSpy = jest.spyOn(Storage.prototype, 'setItem')
renderProvider()
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal')).toBeInTheDocument())
expect(latestTriggerEventsModalProps).toMatchObject({
usage: 3000,
total: 3000,
resetInDays: 5,
planType: Plan.professional,
})
act(() => {
latestTriggerEventsModalProps.onDismiss()
})
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
const [key, value] = setItemSpy.mock.calls[0]
expect(key).toContain('trigger-events-limit-dismissed-workspace-1-professional-3000-')
expect(value).toBe('1')
})
it('relies on the in-memory guard when localStorage reads throw', async () => {
const plan = createPlan({
type: Plan.professional,
usage: { triggerEvents: 200 },
total: { triggerEvents: 200 },
reset: { triggerEvents: 3 },
})
mockUseProviderContext.mockReturnValue({
plan,
isFetchedPlan: true,
})
jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
throw new Error('Storage disabled')
})
const setItemSpy = jest.spyOn(Storage.prototype, 'setItem')
renderProvider()
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal')).toBeInTheDocument())
act(() => {
latestTriggerEventsModalProps.onDismiss()
})
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
expect(setItemSpy).not.toHaveBeenCalled()
await waitFor(() => expect(triggerEventsLimitModalMock).toHaveBeenCalledTimes(1))
})
it('falls back to the in-memory guard when localStorage.setItem fails', async () => {
const plan = createPlan({
type: Plan.professional,
usage: { triggerEvents: 120 },
total: { triggerEvents: 120 },
reset: { triggerEvents: 2 },
})
mockUseProviderContext.mockReturnValue({
plan,
isFetchedPlan: true,
})
jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
throw new Error('Quota exceeded')
})
renderProvider()
await waitFor(() => expect(screen.getByTestId('trigger-limit-modal')).toBeInTheDocument())
act(() => {
latestTriggerEventsModalProps.onDismiss()
})
await waitFor(() => expect(screen.queryByTestId('trigger-limit-modal')).not.toBeInTheDocument())
await waitFor(() => expect(triggerEventsLimitModalMock).toHaveBeenCalledTimes(1))
})
})
+43
View File
@@ -36,6 +36,12 @@ import { noop } from 'lodash-es'
import dynamic from 'next/dynamic'
import type { ExpireNoticeModalPayloadProps } from '@/app/education-apply/expire-notice-modal'
import type { ModelModalModeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useProviderContext } from '@/context/provider-context'
import { useAppContext } from '@/context/app-context'
import {
type TriggerEventsLimitModalPayload,
useTriggerEventsLimitModal,
} from './hooks/use-trigger-events-limit-modal'
const AccountSetting = dynamic(() => import('@/app/components/header/account-setting'), {
ssr: false,
@@ -74,6 +80,9 @@ const UpdatePlugin = dynamic(() => import('@/app/components/plugins/update-plugi
const ExpireNoticeModal = dynamic(() => import('@/app/education-apply/expire-notice-modal'), {
ssr: false,
})
const TriggerEventsLimitModal = dynamic(() => import('@/app/components/billing/trigger-events-limit-modal'), {
ssr: false,
})
export type ModalState<T> = {
payload: T
@@ -113,6 +122,7 @@ export type ModalContextState = {
}> | null>>
setShowUpdatePluginModal: Dispatch<SetStateAction<ModalState<UpdatePluginPayload> | null>>
setShowEducationExpireNoticeModal: Dispatch<SetStateAction<ModalState<ExpireNoticeModalPayloadProps> | null>>
setShowTriggerEventsLimitModal: Dispatch<SetStateAction<ModalState<TriggerEventsLimitModalPayload> | null>>
}
const PRICING_MODAL_QUERY_PARAM = 'pricing'
const PRICING_MODAL_QUERY_VALUE = 'open'
@@ -130,6 +140,7 @@ const ModalContext = createContext<ModalContextState>({
setShowOpeningModal: noop,
setShowUpdatePluginModal: noop,
setShowEducationExpireNoticeModal: noop,
setShowTriggerEventsLimitModal: noop,
})
export const useModalContext = () => useContext(ModalContext)
@@ -168,6 +179,7 @@ export const ModalContextProvider = ({
}> | null>(null)
const [showUpdatePluginModal, setShowUpdatePluginModal] = useState<ModalState<UpdatePluginPayload> | null>(null)
const [showEducationExpireNoticeModal, setShowEducationExpireNoticeModal] = useState<ModalState<ExpireNoticeModalPayloadProps> | null>(null)
const { currentWorkspace } = useAppContext()
const [showPricingModal, setShowPricingModal] = useState(
searchParams.get(PRICING_MODAL_QUERY_PARAM) === PRICING_MODAL_QUERY_VALUE,
@@ -228,6 +240,17 @@ export const ModalContextProvider = ({
window.history.replaceState(null, '', url.toString())
}, [showPricingModal])
const { plan, isFetchedPlan } = useProviderContext()
const {
showTriggerEventsLimitModal,
setShowTriggerEventsLimitModal,
persistTriggerEventsLimitModalDismiss,
} = useTriggerEventsLimitModal({
plan,
isFetchedPlan,
currentWorkspaceId: currentWorkspace?.id,
})
const handleCancelModerationSettingModal = () => {
setShowModerationSettingModal(null)
if (showModerationSettingModal?.onCancelCallback)
@@ -334,6 +357,7 @@ export const ModalContextProvider = ({
setShowOpeningModal,
setShowUpdatePluginModal,
setShowEducationExpireNoticeModal,
setShowTriggerEventsLimitModal,
}}>
<>
{children}
@@ -455,6 +479,25 @@ export const ModalContextProvider = ({
onClose={() => setShowEducationExpireNoticeModal(null)}
/>
)}
{
!!showTriggerEventsLimitModal && (
<TriggerEventsLimitModal
show
usage={showTriggerEventsLimitModal.payload.usage}
total={showTriggerEventsLimitModal.payload.total}
planType={showTriggerEventsLimitModal.payload.planType}
resetInDays={showTriggerEventsLimitModal.payload.resetInDays}
onDismiss={() => {
persistTriggerEventsLimitModalDismiss()
setShowTriggerEventsLimitModal(null)
}}
onUpgrade={() => {
persistTriggerEventsLimitModalDismiss()
setShowTriggerEventsLimitModal(null)
handleShowPricingModal()
}}
/>
)}
</>
</ModalContext.Provider>
)
+2 -1
View File
@@ -17,7 +17,7 @@ import {
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Model, ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { RETRIEVE_METHOD } from '@/types/app'
import type { Plan } from '@/app/components/billing/type'
import type { Plan, UsageResetInfo } from '@/app/components/billing/type'
import type { UsagePlanInfo } from '@/app/components/billing/type'
import { fetchCurrentPlanInfo } from '@/service/billing'
import { parseCurrentPlan } from '@/app/components/billing/utils'
@@ -40,6 +40,7 @@ type ProviderContextState = {
type: Plan
usage: UsagePlanInfo
total: UsagePlanInfo
reset: UsageResetInfo
}
isFetchedPlan: boolean
enableBilling: boolean
+2 -2
View File
@@ -64,7 +64,7 @@ const translation = {
messageRequest: {
title: 'Nachrichtenguthaben',
tooltip: 'Nachrichtenaufrufkontingente für verschiedene Tarife unter Verwendung von OpenAI-Modellen (außer gpt4).Nachrichten über dem Limit verwenden Ihren OpenAI-API-Schlüssel.',
titlePerMonth: '{{count,number}} Nachrichten/Monat',
titlePerMonth: '{{count,number}} Nachrichten / Monat',
},
annotatedResponse: {
title: 'Kontingentgrenzen für Annotationen',
@@ -83,7 +83,7 @@ const translation = {
cloud: 'Cloud-Dienst',
apiRateLimitTooltip: 'Die API-Datenbeschränkung gilt für alle Anfragen, die über die Dify-API gemacht werden, einschließlich Textgenerierung, Chat-Konversationen, Workflow-Ausführungen und Dokumentenverarbeitung.',
getStarted: 'Loslegen',
apiRateLimitUnit: '{{count,number}}/Monat',
apiRateLimitUnit: '{{count,number}}',
documentsTooltip: 'Vorgabe für die Anzahl der Dokumente, die aus der Wissensdatenquelle importiert werden.',
apiRateLimit: 'API-Datenlimit',
documents: '{{count,number}} Wissensdokumente',
-4
View File
@@ -2,7 +2,6 @@ const translation = {
title: 'Werkzeuge',
createCustomTool: 'Eigenes Werkzeug erstellen',
type: {
all: 'Alle',
builtIn: 'Integriert',
custom: 'Benutzerdefiniert',
workflow: 'Arbeitsablauf',
@@ -20,7 +19,6 @@ const translation = {
setupModalTitleDescription: 'Nach der Konfiguration der Anmeldeinformationen können alle Mitglieder im Arbeitsbereich dieses Werkzeug beim Orchestrieren von Anwendungen nutzen.',
},
includeToolNum: '{{num}} Werkzeuge inkludiert',
addTool: 'Werkzeug hinzufügen',
createTool: {
title: 'Eigenes Werkzeug erstellen',
editAction: 'Konfigurieren',
@@ -143,9 +141,7 @@ const translation = {
addToolModal: {
type: 'Art',
category: 'Kategorie',
add: 'hinzufügen',
added: 'zugefügt',
manageInTools: 'Verwalten in Tools',
custom: {
title: 'Kein benutzerdefiniertes Werkzeug verfügbar',
tip: 'Benutzerdefiniertes Werkzeug erstellen',
+3
View File
@@ -138,6 +138,9 @@ const translation = {
running: 'In Service',
disable: 'Disabled',
},
disableTooltip: {
triggerMode: 'The {{feature}} feature is not supported in Trigger Node mode.',
},
},
analysis: {
title: 'Analysis',
+17 -7
View File
@@ -9,8 +9,16 @@ const translation = {
vectorSpaceTooltip: 'Documents with the High Quality indexing mode will consume Knowledge Data Storage resources. When Knowledge Data Storage reaches the limit, new documents will not be uploaded.',
triggerEvents: 'Trigger Events',
perMonth: 'per month',
resetsIn: 'Resets in {{count,number}} days',
},
teamMembers: 'Team Members',
triggerLimitModal: {
title: 'Upgrade to unlock more trigger events',
description: 'Youve reached the limit of workflow event triggers for this plan.',
dismiss: 'Dismiss',
upgrade: 'Upgrade',
usageTitle: 'TRIGGER EVENTS',
},
upgradeBtn: {
plain: 'View Plan',
encourage: 'Upgrade Now',
@@ -61,11 +69,11 @@ const translation = {
documentsTooltip: 'Quota on the number of documents imported from the Knowledge Data Source.',
vectorSpace: '{{size}} Knowledge Data Storage',
vectorSpaceTooltip: 'Documents with the High Quality indexing mode will consume Knowledge Data Storage resources. When Knowledge Data Storage reaches the limit, new documents will not be uploaded.',
documentsRequestQuota: '{{count,number}}/min Knowledge Request Rate Limit',
documentsRequestQuota: '{{count,number}} Knowledge Request / min',
documentsRequestQuotaTooltip: 'Specifies the total number of actions a workspace can perform per minute within the knowledge base, including dataset creation, deletion, updates, document uploads, modifications, archiving, and knowledge base queries. This metric is used to evaluate the performance of knowledge base requests. For example, if a Sandbox user performs 10 consecutive hit tests within one minute, their workspace will be temporarily restricted from performing the following actions for the next minute: dataset creation, deletion, updates, and document uploads or modifications. ',
apiRateLimit: 'API Rate Limit',
apiRateLimitUnit: '{{count,number}}/month',
unlimitedApiRate: 'No API Rate Limit',
apiRateLimitUnit: '{{count,number}}',
unlimitedApiRate: 'No Dify API Rate Limit',
apiRateLimitTooltip: 'API Rate Limit applies to all requests made through the Dify API, including text generation, chat conversations, workflow executions, and document processing.',
documentProcessingPriority: ' Document Processing',
documentProcessingPriorityUpgrade: 'Process more data with higher accuracy at faster speeds.',
@@ -76,17 +84,19 @@ const translation = {
},
triggerEvents: {
sandbox: '{{count,number}} Trigger Events',
professional: '{{count,number}} Trigger Events/month',
professional: '{{count,number}} Trigger Events / month',
unlimited: 'Unlimited Trigger Events',
tooltip: 'The number of events that automatically start workflows through Plugin, Schedule, or Webhook triggers.',
},
workflowExecution: {
standard: 'Standard Workflow Execution',
faster: 'Faster Workflow Execution',
priority: 'Priority Workflow Execution',
tooltip: 'Workflow execution queue priority and speed.',
},
startNodes: {
limited: 'Up to {{count}} Start Nodes per Workflow',
unlimited: 'Unlimited Start Nodes per Workflow',
limited: 'Up to {{count}} Start Nodes / workflow',
unlimited: 'Unlimited Start Nodes / workflow',
},
logsHistory: '{{days}} Log history',
customTools: 'Custom Tools',
@@ -115,7 +125,7 @@ const translation = {
memberAfter: 'Member',
messageRequest: {
title: '{{count,number}} message credits',
titlePerMonth: '{{count,number}} message credits/month',
titlePerMonth: '{{count,number}} message credits / month',
tooltip: 'Message credits are provided to help you easily try out different OpenAI models in Dify. Credits are consumed based on the model type. Once theyre used up, you can switch to your own OpenAI API key.',
},
annotatedResponse: {
-4
View File
@@ -3,7 +3,6 @@ const translation = {
createCustomTool: 'Create Custom Tool',
customToolTip: 'Learn more about Dify custom tools',
type: {
all: 'All',
builtIn: 'Tools',
custom: 'Custom',
workflow: 'Workflow',
@@ -21,13 +20,10 @@ const translation = {
setupModalTitleDescription: 'After configuring credentials, all members within the workspace can use this tool when orchestrating applications.',
},
includeToolNum: '{{num}} {{action}} included',
addTool: 'Add Tool',
addToolModal: {
type: 'type',
category: 'category',
add: 'add',
added: 'added',
manageInTools: 'Manage in Tools',
custom: {
title: 'No custom tool available',
tip: 'Create a custom tool',

Some files were not shown because too many files have changed in this diff Show More