Merge remote-tracking branch 'origin/main' into feat/trigger

This commit is contained in:
lyzno1 2025-10-23 11:54:35 +08:00
commit 863b4f8fe9
No known key found for this signature in database
366 changed files with 5222 additions and 3901 deletions

View File

@ -437,6 +437,9 @@ CODE_EXECUTION_SSL_VERIFY=True
CODE_EXECUTION_POOL_MAX_CONNECTIONS=100
CODE_EXECUTION_POOL_MAX_KEEPALIVE_CONNECTIONS=20
CODE_EXECUTION_POOL_KEEPALIVE_EXPIRY=5.0
CODE_EXECUTION_CONNECT_TIMEOUT=10
CODE_EXECUTION_READ_TIMEOUT=60
CODE_EXECUTION_WRITE_TIMEOUT=10
CODE_MAX_NUMBER=9223372036854775807
CODE_MIN_NUMBER=-9223372036854775808
CODE_MAX_STRING_LENGTH=400000

View File

@ -56,11 +56,15 @@ else:
}
DOCUMENT_EXTENSIONS: set[str] = convert_to_lower_and_upper_set(_doc_extensions)
# console
COOKIE_NAME_ACCESS_TOKEN = "access_token"
COOKIE_NAME_REFRESH_TOKEN = "refresh_token"
COOKIE_NAME_PASSPORT = "passport"
COOKIE_NAME_CSRF_TOKEN = "csrf_token"
# webapp
COOKIE_NAME_WEBAPP_ACCESS_TOKEN = "webapp_access_token"
COOKIE_NAME_PASSPORT = "passport"
HEADER_NAME_CSRF_TOKEN = "X-CSRF-Token"
HEADER_NAME_APP_CODE = "X-App-Code"
HEADER_NAME_PASSPORT = "X-App-Passport"

View File

@ -31,3 +31,9 @@ def supported_language(lang):
error = f"{lang} is not a valid language."
raise ValueError(error)
def get_valid_language(lang: str | None) -> str:
if lang and lang in languages:
return lang
return languages[0]

View File

@ -4,7 +4,7 @@ from flask_restx import Resource, reqparse
import services
from configs import dify_config
from constants.languages import languages
from constants.languages import get_valid_language
from controllers.console import console_ns
from controllers.console.auth.error import (
AuthenticationFailedError,
@ -204,10 +204,12 @@ class EmailCodeLoginApi(Resource):
.add_argument("email", type=str, required=True, location="json")
.add_argument("code", type=str, required=True, location="json")
.add_argument("token", type=str, required=True, location="json")
.add_argument("language", type=str, required=False, location="json")
)
args = parser.parse_args()
user_email = args["email"]
language = args["language"]
token_data = AccountService.get_email_code_login_data(args["token"])
if token_data is None:
@ -241,7 +243,9 @@ class EmailCodeLoginApi(Resource):
if account is None:
try:
account = AccountService.create_account_and_tenant(
email=user_email, name=user_email, interface_language=languages[0]
email=user_email,
name=user_email,
interface_language=get_valid_language(language),
)
except WorkSpaceNotAllowedCreateError:
raise NotAllowedCreateWorkspace()

View File

@ -74,12 +74,17 @@ class SetupApi(Resource):
.add_argument("email", type=email, required=True, location="json")
.add_argument("name", type=StrLen(30), required=True, location="json")
.add_argument("password", type=valid_password, required=True, location="json")
.add_argument("language", type=str, required=False, location="json")
)
args = parser.parse_args()
# setup
RegisterService.setup(
email=args["email"], name=args["name"], password=args["password"], ip_address=extract_remote_ip(request)
email=args["email"],
name=args["name"],
password=args["password"],
ip_address=extract_remote_ip(request),
language=args["language"],
)
return {"result": "success"}, 201

View File

@ -9,9 +9,10 @@ from controllers.console.app.mcp_server import AppMCPServerStatus
from controllers.mcp import mcp_ns
from core.app.app_config.entities import VariableEntity
from core.mcp import types as mcp_types
from core.mcp.server.streamable_http import handle_mcp_request
from extensions.ext_database import db
from libs import helper
from models.model import App, AppMCPServer, AppMode
from models.model import App, AppMCPServer, AppMode, EndUser
class MCPRequestError(Exception):
@ -192,6 +193,51 @@ class MCPAppApi(Resource):
except ValidationError as e:
raise MCPRequestError(mcp_types.INVALID_PARAMS, f"Invalid MCP request: {str(e)}")
mcp_server_handler = MCPServerStreamableHTTPRequestHandler(app, request, converted_user_input_form)
response = mcp_server_handler.handle()
return helper.compact_generate_response(response)
def _retrieve_end_user(self, tenant_id: str, mcp_server_id: str) -> EndUser | None:
"""Get end user - manages its own database session"""
with Session(db.engine, expire_on_commit=False) as session, session.begin():
return (
session.query(EndUser)
.where(EndUser.tenant_id == tenant_id)
.where(EndUser.session_id == mcp_server_id)
.where(EndUser.type == "mcp")
.first()
)
def _create_end_user(
self, client_name: str, tenant_id: str, app_id: str, mcp_server_id: str, session: Session
) -> EndUser:
"""Create end user in existing session"""
end_user = EndUser(
tenant_id=tenant_id,
app_id=app_id,
type="mcp",
name=client_name,
session_id=mcp_server_id,
)
session.add(end_user)
session.flush() # Use flush instead of commit to keep transaction open
session.refresh(end_user)
return end_user
def _handle_mcp_request(
self,
app: App,
mcp_server: AppMCPServer,
mcp_request: mcp_types.ClientRequest,
user_input_form: list[VariableEntity],
session: Session,
request_id: Union[int, str],
) -> mcp_types.JSONRPCResponse | mcp_types.JSONRPCError | None:
"""Handle MCP request and return response"""
end_user = self._retrieve_end_user(mcp_server.tenant_id, mcp_server.id)
if not end_user and isinstance(mcp_request.root, mcp_types.InitializeRequest):
client_info = mcp_request.root.params.clientInfo
client_name = f"{client_info.name}@{client_info.version}"
# Commit the session before creating end user to avoid transaction conflicts
session.commit()
with Session(db.engine, expire_on_commit=False) as create_session, create_session.begin():
end_user = self._create_end_user(client_name, app.tenant_id, app.id, mcp_server.id, create_session)
return handle_mcp_request(app, mcp_request, user_input_form, mcp_server, end_user, request_id)

View File

@ -17,8 +17,8 @@ from libs.helper import email
from libs.passport import PassportService
from libs.password import valid_password
from libs.token import (
clear_access_token_from_cookie,
extract_access_token,
clear_webapp_access_token_from_cookie,
extract_webapp_access_token,
)
from services.account_service import AccountService
from services.app_service import AppService
@ -81,7 +81,7 @@ class LoginStatusApi(Resource):
)
def get(self):
app_code = request.args.get("app_code")
token = extract_access_token(request)
token = extract_webapp_access_token(request)
if not app_code:
return {
"logged_in": bool(token),
@ -128,7 +128,7 @@ class LogoutApi(Resource):
response = make_response({"result": "success"})
# enterprise SSO sets same site to None in https deployment
# so we need to logout by calling api
clear_access_token_from_cookie(response, samesite="None")
clear_webapp_access_token_from_cookie(response, samesite="None")
return response

View File

@ -12,10 +12,8 @@ from controllers.web import web_ns
from controllers.web.error import WebAppAuthRequiredError
from extensions.ext_database import db
from libs.passport import PassportService
from libs.token import extract_access_token
from libs.token import extract_webapp_access_token
from models.model import App, EndUser, Site
from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.webapp_auth_service import WebAppAuthService, WebAppAuthType
@ -37,23 +35,18 @@ class PassportResource(Resource):
system_features = FeatureService.get_system_features()
app_code = request.headers.get(HEADER_NAME_APP_CODE)
user_id = request.args.get("user_id")
access_token = extract_access_token(request)
access_token = extract_webapp_access_token(request)
if app_code is None:
raise Unauthorized("X-App-Code header is missing.")
app_id = AppService.get_app_id_by_code(app_code)
# exchange token for enterprise logined web user
enterprise_user_decoded = decode_enterprise_webapp_user_id(access_token)
if enterprise_user_decoded:
# a web user has already logged in, exchange a token for this app without redirecting to the login page
return exchange_token_for_existing_web_user(
app_code=app_code, enterprise_user_decoded=enterprise_user_decoded
)
if system_features.webapp_auth.enabled:
app_settings = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=app_id)
if not app_settings or not app_settings.access_mode == "public":
raise WebAppAuthRequiredError()
enterprise_user_decoded = decode_enterprise_webapp_user_id(access_token)
app_auth_type = WebAppAuthService.get_app_auth_type(app_code=app_code)
if app_auth_type != WebAppAuthType.PUBLIC:
if not enterprise_user_decoded:
raise WebAppAuthRequiredError()
return exchange_token_for_existing_web_user(
app_code=app_code, enterprise_user_decoded=enterprise_user_decoded, auth_type=app_auth_type
)
# get site from db and check if it is normal
site = db.session.scalar(select(Site).where(Site.code == app_code, Site.status == "normal"))
@ -124,7 +117,7 @@ def decode_enterprise_webapp_user_id(jwt_token: str | None):
return decoded
def exchange_token_for_existing_web_user(app_code: str, enterprise_user_decoded: dict):
def exchange_token_for_existing_web_user(app_code: str, enterprise_user_decoded: dict, auth_type: WebAppAuthType):
"""
Exchange a token for an existing web user session.
"""
@ -145,13 +138,11 @@ def exchange_token_for_existing_web_user(app_code: str, enterprise_user_decoded:
if not app_model or app_model.status != "normal" or not app_model.enable_site:
raise NotFound()
app_auth_type = WebAppAuthService.get_app_auth_type(app_code=app_code)
if app_auth_type == WebAppAuthType.PUBLIC:
if auth_type == WebAppAuthType.PUBLIC:
return _exchange_for_public_app_token(app_model, site, enterprise_user_decoded)
elif app_auth_type == WebAppAuthType.EXTERNAL and user_auth_type != "external":
elif auth_type == WebAppAuthType.EXTERNAL and user_auth_type != "external":
raise WebAppAuthRequiredError("Please login as external user.")
elif app_auth_type == WebAppAuthType.INTERNAL and user_auth_type != "internal":
elif auth_type == WebAppAuthType.INTERNAL and user_auth_type != "internal":
raise WebAppAuthRequiredError("Please login as internal user.")
end_user = None

View File

@ -255,7 +255,7 @@ class PipelineGenerator(BaseAppGenerator):
json_text = json.dumps(text)
upload_file = FileService(db.engine).upload_text(json_text, name, user.id, dataset.tenant_id)
features = FeatureService.get_features(dataset.tenant_id)
if features.billing.subscription.plan == "sandbox":
if features.billing.enabled and features.billing.subscription.plan == "sandbox":
tenant_pipeline_task_key = f"tenant_pipeline_task:{dataset.tenant_id}"
tenant_self_pipeline_task_queue = f"tenant_self_pipeline_task_queue:{dataset.tenant_id}"

View File

@ -76,7 +76,7 @@ class PluginParameter(BaseModel):
auto_generate: PluginParameterAutoGenerate | None = None
template: PluginParameterTemplate | None = None
required: bool = False
default: Union[float, int, str] | None = None
default: Union[float, int, str, bool] | None = None
min: Union[float, int] | None = None
max: Union[float, int] | None = None
precision: int | None = None

View File

@ -40,7 +40,7 @@ class PluginDaemonBadRequestError(PluginDaemonClientSideError):
description: str = "Bad Request"
class PluginInvokeError(PluginDaemonClientSideError):
class PluginInvokeError(PluginDaemonClientSideError, ValueError):
description: str = "Invoke Error"
def _get_error_object(self) -> Mapping:

View File

@ -72,6 +72,19 @@ default_retrieval_model: dict[str, Any] = {
class DatasetRetrieval:
def __init__(self, application_generate_entity=None):
self.application_generate_entity = application_generate_entity
self._llm_usage = LLMUsage.empty_usage()
@property
def llm_usage(self) -> LLMUsage:
return self._llm_usage.model_copy()
def _record_usage(self, usage: LLMUsage | None) -> None:
if usage is None or usage.total_tokens <= 0:
return
if self._llm_usage.total_tokens == 0:
self._llm_usage = usage
else:
self._llm_usage = self._llm_usage.plus(usage)
def retrieve(
self,
@ -312,15 +325,18 @@ class DatasetRetrieval:
)
tools.append(message_tool)
dataset_id = None
router_usage = LLMUsage.empty_usage()
if planning_strategy == PlanningStrategy.REACT_ROUTER:
react_multi_dataset_router = ReactMultiDatasetRouter()
dataset_id = react_multi_dataset_router.invoke(
dataset_id, router_usage = react_multi_dataset_router.invoke(
query, tools, model_config, model_instance, user_id, tenant_id
)
elif planning_strategy == PlanningStrategy.ROUTER:
function_call_router = FunctionCallMultiDatasetRouter()
dataset_id = function_call_router.invoke(query, tools, model_config, model_instance)
dataset_id, router_usage = function_call_router.invoke(query, tools, model_config, model_instance)
self._record_usage(router_usage)
if dataset_id:
# get retrieval model config
@ -983,7 +999,8 @@ class DatasetRetrieval:
)
# handle invoke result
result_text, _ = self._handle_invoke_result(invoke_result=invoke_result)
result_text, usage = self._handle_invoke_result(invoke_result=invoke_result)
self._record_usage(usage)
result_text_json = parse_and_check_json_markdown(result_text, [])
automatic_metadata_filters = []

View File

@ -2,7 +2,7 @@ from typing import Union
from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEntity
from core.model_manager import ModelInstance
from core.model_runtime.entities.llm_entities import LLMResult
from core.model_runtime.entities.llm_entities import LLMResult, LLMUsage
from core.model_runtime.entities.message_entities import PromptMessageTool, SystemPromptMessage, UserPromptMessage
@ -13,15 +13,15 @@ class FunctionCallMultiDatasetRouter:
dataset_tools: list[PromptMessageTool],
model_config: ModelConfigWithCredentialsEntity,
model_instance: ModelInstance,
) -> Union[str, None]:
) -> tuple[Union[str, None], LLMUsage]:
"""Given input, decided what to do.
Returns:
Action specifying what tool to use.
"""
if len(dataset_tools) == 0:
return None
return None, LLMUsage.empty_usage()
elif len(dataset_tools) == 1:
return dataset_tools[0].name
return dataset_tools[0].name, LLMUsage.empty_usage()
try:
prompt_messages = [
@ -34,9 +34,10 @@ class FunctionCallMultiDatasetRouter:
stream=False,
model_parameters={"temperature": 0.2, "top_p": 0.3, "max_tokens": 1500},
)
usage = result.usage or LLMUsage.empty_usage()
if result.message.tool_calls:
# get retrieval model config
return result.message.tool_calls[0].function.name
return None
return result.message.tool_calls[0].function.name, usage
return None, usage
except Exception:
return None
return None, LLMUsage.empty_usage()

View File

@ -58,15 +58,15 @@ class ReactMultiDatasetRouter:
model_instance: ModelInstance,
user_id: str,
tenant_id: str,
) -> Union[str, None]:
) -> tuple[Union[str, None], LLMUsage]:
"""Given input, decided what to do.
Returns:
Action specifying what tool to use.
"""
if len(dataset_tools) == 0:
return None
return None, LLMUsage.empty_usage()
elif len(dataset_tools) == 1:
return dataset_tools[0].name
return dataset_tools[0].name, LLMUsage.empty_usage()
try:
return self._react_invoke(
@ -78,7 +78,7 @@ class ReactMultiDatasetRouter:
tenant_id=tenant_id,
)
except Exception:
return None
return None, LLMUsage.empty_usage()
def _react_invoke(
self,
@ -91,7 +91,7 @@ class ReactMultiDatasetRouter:
prefix: str = PREFIX,
suffix: str = SUFFIX,
format_instructions: str = FORMAT_INSTRUCTIONS,
) -> Union[str, None]:
) -> tuple[Union[str, None], LLMUsage]:
prompt: Union[list[ChatModelMessage], CompletionModelPromptTemplate]
if model_config.mode == "chat":
prompt = self.create_chat_prompt(
@ -120,7 +120,7 @@ class ReactMultiDatasetRouter:
memory=None,
model_config=model_config,
)
result_text, _ = self._invoke_llm(
result_text, usage = self._invoke_llm(
completion_param=model_config.parameters,
model_instance=model_instance,
prompt_messages=prompt_messages,
@ -131,8 +131,8 @@ class ReactMultiDatasetRouter:
output_parser = StructuredChatOutputParser()
react_decision = output_parser.parse(result_text)
if isinstance(react_decision, ReactAction):
return react_decision.tool
return None
return react_decision.tool, usage
return None, usage
def _invoke_llm(
self,

View File

@ -326,7 +326,8 @@ class ToolManager:
workflow_provider_stmt = select(WorkflowToolProvider).where(
WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == provider_id
)
workflow_provider = db.session.scalar(workflow_provider_stmt)
with Session(db.engine, expire_on_commit=False) as session, session.begin():
workflow_provider = session.scalar(workflow_provider_stmt)
if workflow_provider is None:
raise ToolProviderNotFoundError(f"workflow provider {provider_id} not found")

View File

@ -62,6 +62,11 @@ class ApiBasedToolSchemaParser:
root = root[ref]
interface["operation"]["parameters"][i] = root
for parameter in interface["operation"]["parameters"]:
# Handle complex type defaults that are not supported by PluginParameter
default_value = None
if "schema" in parameter and "default" in parameter["schema"]:
default_value = ApiBasedToolSchemaParser._sanitize_default_value(parameter["schema"]["default"])
tool_parameter = ToolParameter(
name=parameter["name"],
label=I18nObject(en_US=parameter["name"], zh_Hans=parameter["name"]),
@ -72,9 +77,7 @@ class ApiBasedToolSchemaParser:
required=parameter.get("required", False),
form=ToolParameter.ToolParameterForm.LLM,
llm_description=parameter.get("description"),
default=parameter["schema"]["default"]
if "schema" in parameter and "default" in parameter["schema"]
else None,
default=default_value,
placeholder=I18nObject(
en_US=parameter.get("description", ""), zh_Hans=parameter.get("description", "")
),
@ -134,6 +137,11 @@ class ApiBasedToolSchemaParser:
required = body_schema.get("required", [])
properties = body_schema.get("properties", {})
for name, property in properties.items():
# Handle complex type defaults that are not supported by PluginParameter
default_value = ApiBasedToolSchemaParser._sanitize_default_value(
property.get("default", None)
)
tool = ToolParameter(
name=name,
label=I18nObject(en_US=name, zh_Hans=name),
@ -144,12 +152,11 @@ class ApiBasedToolSchemaParser:
required=name in required,
form=ToolParameter.ToolParameterForm.LLM,
llm_description=property.get("description", ""),
default=property.get("default", None),
default=default_value,
placeholder=I18nObject(
en_US=property.get("description", ""), zh_Hans=property.get("description", "")
),
)
# check if there is a type
typ = ApiBasedToolSchemaParser._get_tool_parameter_type(property)
if typ:
@ -197,6 +204,22 @@ class ApiBasedToolSchemaParser:
return bundles
@staticmethod
def _sanitize_default_value(value):
"""
Sanitize default values for PluginParameter compatibility.
Complex types (list, dict) are converted to None to avoid validation errors.
Args:
value: The default value from OpenAPI schema
Returns:
None for complex types (list, dict), otherwise the original value
"""
if isinstance(value, (list, dict)):
return None
return value
@staticmethod
def _get_tool_parameter_type(parameter: dict) -> ToolParameter.ToolParameterType | None:
parameter = parameter or {}
@ -217,7 +240,11 @@ class ApiBasedToolSchemaParser:
return ToolParameter.ToolParameterType.STRING
elif typ == "array":
items = parameter.get("items") or parameter.get("schema", {}).get("items")
return ToolParameter.ToolParameterType.FILES if items and items.get("format") == "binary" else None
if items and items.get("format") == "binary":
return ToolParameter.ToolParameterType.FILES
else:
# For regular arrays, return ARRAY type instead of None
return ToolParameter.ToolParameterType.ARRAY
else:
return None

View File

@ -1,6 +1,7 @@
from collections.abc import Mapping
from pydantic import Field
from sqlalchemy.orm import Session
from core.app.app_config.entities import VariableEntity, VariableEntityType
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
@ -20,6 +21,7 @@ from core.tools.entities.tool_entities import (
from core.tools.utils.workflow_configuration_sync import WorkflowToolConfigurationUtils
from core.tools.workflow_as_tool.tool import WorkflowTool
from extensions.ext_database import db
from models.account import Account
from models.model import App, AppMode
from models.tools import WorkflowToolProvider
from models.workflow import Workflow
@ -44,29 +46,34 @@ class WorkflowToolProviderController(ToolProviderController):
@classmethod
def from_db(cls, db_provider: WorkflowToolProvider) -> "WorkflowToolProviderController":
app = db_provider.app
with Session(db.engine, expire_on_commit=False) as session, session.begin():
provider = session.get(WorkflowToolProvider, db_provider.id) if db_provider.id else None
if not provider:
raise ValueError("workflow provider not found")
app = session.get(App, provider.app_id)
if not app:
raise ValueError("app not found")
if not app:
raise ValueError("app not found")
user = session.get(Account, provider.user_id) if provider.user_id else None
controller = WorkflowToolProviderController(
entity=ToolProviderEntity(
identity=ToolProviderIdentity(
author=db_provider.user.name if db_provider.user_id and db_provider.user else "",
name=db_provider.label,
label=I18nObject(en_US=db_provider.label, zh_Hans=db_provider.label),
description=I18nObject(en_US=db_provider.description, zh_Hans=db_provider.description),
icon=db_provider.icon,
controller = WorkflowToolProviderController(
entity=ToolProviderEntity(
identity=ToolProviderIdentity(
author=user.name if user else "",
name=provider.label,
label=I18nObject(en_US=provider.label, zh_Hans=provider.label),
description=I18nObject(en_US=provider.description, zh_Hans=provider.description),
icon=provider.icon,
),
credentials_schema=[],
plugin_id=None,
),
credentials_schema=[],
plugin_id=None,
),
provider_id=db_provider.id or "",
)
provider_id=provider.id or "",
)
# init tools
controller.tools = [controller._get_db_provider_tool(db_provider, app)]
controller.tools = [
controller._get_db_provider_tool(provider, app, session=session, user=user),
]
return controller
@ -74,7 +81,14 @@ class WorkflowToolProviderController(ToolProviderController):
def provider_type(self) -> ToolProviderType:
return ToolProviderType.WORKFLOW
def _get_db_provider_tool(self, db_provider: WorkflowToolProvider, app: App) -> WorkflowTool:
def _get_db_provider_tool(
self,
db_provider: WorkflowToolProvider,
app: App,
*,
session: Session,
user: Account | None = None,
) -> WorkflowTool:
"""
get db provider tool
:param db_provider: the db provider
@ -82,7 +96,7 @@ class WorkflowToolProviderController(ToolProviderController):
:return: the tool
"""
workflow: Workflow | None = (
db.session.query(Workflow)
session.query(Workflow)
.where(Workflow.app_id == db_provider.app_id, Workflow.version == db_provider.version)
.first()
)
@ -101,8 +115,6 @@ class WorkflowToolProviderController(ToolProviderController):
def fetch_workflow_variable(variable_name: str) -> VariableEntity | None:
return next(filter(lambda x: x.variable == variable_name, variables), None)
user = db_provider.user
workflow_tool_parameters = []
for parameter in parameters:
variable = fetch_workflow_variable(parameter.name)
@ -187,22 +199,25 @@ class WorkflowToolProviderController(ToolProviderController):
if self.tools is not None:
return self.tools
db_providers: WorkflowToolProvider | None = (
db.session.query(WorkflowToolProvider)
.where(
WorkflowToolProvider.tenant_id == tenant_id,
WorkflowToolProvider.app_id == self.provider_id,
with Session(db.engine, expire_on_commit=False) as session, session.begin():
db_provider: WorkflowToolProvider | None = (
session.query(WorkflowToolProvider)
.where(
WorkflowToolProvider.tenant_id == tenant_id,
WorkflowToolProvider.app_id == self.provider_id,
)
.first()
)
.first()
)
if not db_providers:
return []
if not db_providers.app:
raise ValueError("app not found")
if not db_provider:
return []
app = db_providers.app
self.tools = [self._get_db_provider_tool(db_providers, app)]
app = session.get(App, db_provider.app_id)
if not app:
raise ValueError("app not found")
user = session.get(Account, db_provider.user_id) if db_provider.user_id else None
self.tools = [self._get_db_provider_tool(db_provider, app, session=session, user=user)]
return self.tools

View File

@ -1,12 +1,14 @@
import json
import logging
from collections.abc import Generator
from typing import Any
from collections.abc import Generator, Mapping, Sequence
from typing import Any, cast
from flask import has_request_context
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.file import FILE_MODEL_IDENTITY, File, FileTransferMethod
from core.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetadata
from core.tools.__base.tool import Tool
from core.tools.__base.tool_runtime import ToolRuntime
from core.tools.entities.tool_entities import (
@ -48,6 +50,7 @@ class WorkflowTool(Tool):
self.workflow_entities = workflow_entities
self.workflow_call_depth = workflow_call_depth
self.label = label
self._latest_usage = LLMUsage.empty_usage()
super().__init__(entity=entity, runtime=runtime)
@ -83,10 +86,11 @@ class WorkflowTool(Tool):
assert self.runtime.invoke_from is not None
user = self._resolve_user(user_id=user_id)
if user is None:
raise ToolInvokeError("User not found")
self._latest_usage = LLMUsage.empty_usage()
result = generator.generate(
app_model=app,
workflow=workflow,
@ -110,9 +114,68 @@ class WorkflowTool(Tool):
for file in files:
yield self.create_file_message(file) # type: ignore
self._latest_usage = self._derive_usage_from_result(data)
yield self.create_text_message(json.dumps(outputs, ensure_ascii=False))
yield self.create_json_message(outputs)
@property
def latest_usage(self) -> LLMUsage:
return self._latest_usage
@classmethod
def _derive_usage_from_result(cls, data: Mapping[str, Any]) -> LLMUsage:
usage_dict = cls._extract_usage_dict(data)
if usage_dict is not None:
return LLMUsage.from_metadata(cast(LLMUsageMetadata, dict(usage_dict)))
total_tokens = data.get("total_tokens")
total_price = data.get("total_price")
if total_tokens is None and total_price is None:
return LLMUsage.empty_usage()
usage_metadata: dict[str, Any] = {}
if total_tokens is not None:
try:
usage_metadata["total_tokens"] = int(str(total_tokens))
except (TypeError, ValueError):
pass
if total_price is not None:
usage_metadata["total_price"] = str(total_price)
currency = data.get("currency")
if currency is not None:
usage_metadata["currency"] = currency
if not usage_metadata:
return LLMUsage.empty_usage()
return LLMUsage.from_metadata(cast(LLMUsageMetadata, usage_metadata))
@classmethod
def _extract_usage_dict(cls, payload: Mapping[str, Any]) -> Mapping[str, Any] | None:
usage_candidate = payload.get("usage")
if isinstance(usage_candidate, Mapping):
return usage_candidate
metadata_candidate = payload.get("metadata")
if isinstance(metadata_candidate, Mapping):
usage_candidate = metadata_candidate.get("usage")
if isinstance(usage_candidate, Mapping):
return usage_candidate
for value in payload.values():
if isinstance(value, Mapping):
found = cls._extract_usage_dict(value)
if found is not None:
return found
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
for item in value:
if isinstance(item, Mapping):
found = cls._extract_usage_dict(item)
if found is not None:
return found
return None
def fork_tool_runtime(self, runtime: ToolRuntime) -> "WorkflowTool":
"""
fork a new tool with metadata
@ -179,16 +242,17 @@ class WorkflowTool(Tool):
"""
get the workflow by app id and version
"""
if not version:
workflow = (
db.session.query(Workflow)
.where(Workflow.app_id == app_id, Workflow.version != Workflow.VERSION_DRAFT)
.order_by(Workflow.created_at.desc())
.first()
)
else:
stmt = select(Workflow).where(Workflow.app_id == app_id, Workflow.version == version)
workflow = db.session.scalar(stmt)
with Session(db.engine, expire_on_commit=False) as session, session.begin():
if not version:
stmt = (
select(Workflow)
.where(Workflow.app_id == app_id, Workflow.version != Workflow.VERSION_DRAFT)
.order_by(Workflow.created_at.desc())
)
workflow = session.scalars(stmt).first()
else:
stmt = select(Workflow).where(Workflow.app_id == app_id, Workflow.version == version)
workflow = session.scalar(stmt)
if not workflow:
raise ValueError("workflow not found or not published")
@ -200,7 +264,8 @@ class WorkflowTool(Tool):
get the app by app id
"""
stmt = select(App).where(App.id == app_id)
app = db.session.scalar(stmt)
with Session(db.engine, expire_on_commit=False) as session, session.begin():
app = session.scalar(stmt)
if not app:
raise ValueError("app not found")

View File

@ -26,8 +26,8 @@ class AgentNodeData(BaseNodeData):
class ParamsAutoGenerated(IntEnum):
CLOSE = auto()
OPEN = auto()
CLOSE = 0
OPEN = 1
class AgentOldVersionModelFeatures(StrEnum):

View File

@ -1,4 +1,5 @@
from .entities import BaseIterationNodeData, BaseIterationState, BaseLoopNodeData, BaseLoopState, BaseNodeData
from .usage_tracking_mixin import LLMUsageTrackingMixin
__all__ = [
"BaseIterationNodeData",
@ -6,4 +7,5 @@ __all__ = [
"BaseLoopNodeData",
"BaseLoopState",
"BaseNodeData",
"LLMUsageTrackingMixin",
]

View File

@ -0,0 +1,28 @@
from core.model_runtime.entities.llm_entities import LLMUsage
from core.workflow.runtime import GraphRuntimeState
class LLMUsageTrackingMixin:
"""Provides shared helpers for merging and recording LLM usage within workflow nodes."""
graph_runtime_state: GraphRuntimeState
@staticmethod
def _merge_usage(current: LLMUsage, new_usage: LLMUsage | None) -> LLMUsage:
"""Return a combined usage snapshot, preserving zero-value inputs."""
if new_usage is None or new_usage.total_tokens <= 0:
return current
if current.total_tokens == 0:
return new_usage
return current.plus(new_usage)
def _accumulate_usage(self, usage: LLMUsage) -> None:
"""Push usage into the graph runtime accumulator for downstream reporting."""
if usage.total_tokens <= 0:
return
current_usage = self.graph_runtime_state.llm_usage
if current_usage.total_tokens == 0:
self.graph_runtime_state.llm_usage = usage.model_copy()
else:
self.graph_runtime_state.llm_usage = current_usage.plus(usage)

View File

@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, NewType, cast
from flask import Flask, current_app
from typing_extensions import TypeIs
from core.model_runtime.entities.llm_entities import LLMUsage
from core.variables import IntegerVariable, NoneSegment
from core.variables.segments import ArrayAnySegment, ArraySegment
from core.variables.variables import VariableUnion
@ -34,6 +35,7 @@ from core.workflow.node_events import (
NodeRunResult,
StreamCompletedEvent,
)
from core.workflow.nodes.base import LLMUsageTrackingMixin
from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.iteration.entities import ErrorHandleMode, IterationNodeData
@ -58,7 +60,7 @@ logger = logging.getLogger(__name__)
EmptyArraySegment = NewType("EmptyArraySegment", ArraySegment)
class IterationNode(Node):
class IterationNode(LLMUsageTrackingMixin, Node):
"""
Iteration Node.
"""
@ -118,6 +120,7 @@ class IterationNode(Node):
started_at = naive_utc_now()
iter_run_map: dict[str, float] = {}
outputs: list[object] = []
usage_accumulator = [LLMUsage.empty_usage()]
yield IterationStartedEvent(
start_at=started_at,
@ -130,22 +133,27 @@ class IterationNode(Node):
iterator_list_value=iterator_list_value,
outputs=outputs,
iter_run_map=iter_run_map,
usage_accumulator=usage_accumulator,
)
self._accumulate_usage(usage_accumulator[0])
yield from self._handle_iteration_success(
started_at=started_at,
inputs=inputs,
outputs=outputs,
iterator_list_value=iterator_list_value,
iter_run_map=iter_run_map,
usage=usage_accumulator[0],
)
except IterationNodeError as e:
self._accumulate_usage(usage_accumulator[0])
yield from self._handle_iteration_failure(
started_at=started_at,
inputs=inputs,
outputs=outputs,
iterator_list_value=iterator_list_value,
iter_run_map=iter_run_map,
usage=usage_accumulator[0],
error=e,
)
@ -196,6 +204,7 @@ class IterationNode(Node):
iterator_list_value: Sequence[object],
outputs: list[object],
iter_run_map: dict[str, float],
usage_accumulator: list[LLMUsage],
) -> Generator[GraphNodeEventBase | NodeEventBase, None, None]:
if self._node_data.is_parallel:
# Parallel mode execution
@ -203,6 +212,7 @@ class IterationNode(Node):
iterator_list_value=iterator_list_value,
outputs=outputs,
iter_run_map=iter_run_map,
usage_accumulator=usage_accumulator,
)
else:
# Sequential mode execution
@ -228,6 +238,9 @@ class IterationNode(Node):
# Update the total tokens from this iteration
self.graph_runtime_state.total_tokens += graph_engine.graph_runtime_state.total_tokens
usage_accumulator[0] = self._merge_usage(
usage_accumulator[0], graph_engine.graph_runtime_state.llm_usage
)
iter_run_map[str(index)] = (datetime.now(UTC).replace(tzinfo=None) - iter_start_at).total_seconds()
def _execute_parallel_iterations(
@ -235,6 +248,7 @@ class IterationNode(Node):
iterator_list_value: Sequence[object],
outputs: list[object],
iter_run_map: dict[str, float],
usage_accumulator: list[LLMUsage],
) -> Generator[GraphNodeEventBase | NodeEventBase, None, None]:
# Initialize outputs list with None values to maintain order
outputs.extend([None] * len(iterator_list_value))
@ -245,7 +259,16 @@ class IterationNode(Node):
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit all iteration tasks
future_to_index: dict[
Future[tuple[datetime, list[GraphNodeEventBase], object | None, int, dict[str, VariableUnion]]],
Future[
tuple[
datetime,
list[GraphNodeEventBase],
object | None,
int,
dict[str, VariableUnion],
LLMUsage,
]
],
int,
] = {}
for index, item in enumerate(iterator_list_value):
@ -264,7 +287,14 @@ class IterationNode(Node):
index = future_to_index[future]
try:
result = future.result()
iter_start_at, events, output_value, tokens_used, conversation_snapshot = result
(
iter_start_at,
events,
output_value,
tokens_used,
conversation_snapshot,
iteration_usage,
) = result
# Update outputs at the correct index
outputs[index] = output_value
@ -276,6 +306,8 @@ class IterationNode(Node):
self.graph_runtime_state.total_tokens += tokens_used
iter_run_map[str(index)] = (datetime.now(UTC).replace(tzinfo=None) - iter_start_at).total_seconds()
usage_accumulator[0] = self._merge_usage(usage_accumulator[0], iteration_usage)
# Sync conversation variables after iteration completion
self._sync_conversation_variables_from_snapshot(conversation_snapshot)
@ -303,7 +335,7 @@ class IterationNode(Node):
item: object,
flask_app: Flask,
context_vars: contextvars.Context,
) -> tuple[datetime, list[GraphNodeEventBase], object | None, int, dict[str, VariableUnion]]:
) -> tuple[datetime, list[GraphNodeEventBase], object | None, int, dict[str, VariableUnion], LLMUsage]:
"""Execute a single iteration in parallel mode and return results."""
with preserve_flask_contexts(flask_app=flask_app, context_vars=context_vars):
iter_start_at = datetime.now(UTC).replace(tzinfo=None)
@ -332,6 +364,7 @@ class IterationNode(Node):
output_value,
graph_engine.graph_runtime_state.total_tokens,
conversation_snapshot,
graph_engine.graph_runtime_state.llm_usage,
)
def _handle_iteration_success(
@ -341,6 +374,8 @@ class IterationNode(Node):
outputs: list[object],
iterator_list_value: Sequence[object],
iter_run_map: dict[str, float],
*,
usage: LLMUsage,
) -> Generator[NodeEventBase, None, None]:
# Flatten the list of lists if all outputs are lists
flattened_outputs = self._flatten_outputs_if_needed(outputs)
@ -351,7 +386,9 @@ class IterationNode(Node):
outputs={"output": flattened_outputs},
steps=len(iterator_list_value),
metadata={
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: self.graph_runtime_state.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price,
WorkflowNodeExecutionMetadataKey.CURRENCY: usage.currency,
WorkflowNodeExecutionMetadataKey.ITERATION_DURATION_MAP: iter_run_map,
},
)
@ -362,8 +399,11 @@ class IterationNode(Node):
status=WorkflowNodeExecutionStatus.SUCCEEDED,
outputs={"output": flattened_outputs},
metadata={
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: self.graph_runtime_state.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price,
WorkflowNodeExecutionMetadataKey.CURRENCY: usage.currency,
},
llm_usage=usage,
)
)
@ -400,6 +440,8 @@ class IterationNode(Node):
outputs: list[object],
iterator_list_value: Sequence[object],
iter_run_map: dict[str, float],
*,
usage: LLMUsage,
error: IterationNodeError,
) -> Generator[NodeEventBase, None, None]:
# Flatten the list of lists if all outputs are lists (even in failure case)
@ -411,7 +453,9 @@ class IterationNode(Node):
outputs={"output": flattened_outputs},
steps=len(iterator_list_value),
metadata={
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: self.graph_runtime_state.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price,
WorkflowNodeExecutionMetadataKey.CURRENCY: usage.currency,
WorkflowNodeExecutionMetadataKey.ITERATION_DURATION_MAP: iter_run_map,
},
error=str(error),
@ -420,6 +464,12 @@ class IterationNode(Node):
node_run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.FAILED,
error=str(error),
metadata={
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price,
WorkflowNodeExecutionMetadataKey.CURRENCY: usage.currency,
},
llm_usage=usage,
)
)

View File

@ -15,14 +15,11 @@ from core.app.entities.app_invoke_entities import ModelConfigWithCredentialsEnti
from core.entities.agent_entities import PlanningStrategy
from core.entities.model_entities import ModelStatus
from core.model_manager import ModelInstance, ModelManager
from core.model_runtime.entities.message_entities import (
PromptMessageRole,
)
from core.model_runtime.entities.model_entities import (
ModelFeature,
ModelType,
)
from core.model_runtime.entities.llm_entities import LLMUsage
from core.model_runtime.entities.message_entities import PromptMessageRole
from core.model_runtime.entities.model_entities import ModelFeature, ModelType
from core.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel
from core.model_runtime.utils.encoders import jsonable_encoder
from core.prompt.simple_prompt_transform import ModelMode
from core.rag.datasource.retrieval_service import RetrievalService
from core.rag.entities.metadata_entities import Condition, MetadataCondition
@ -33,8 +30,14 @@ from core.variables import (
)
from core.variables.segments import ArrayObjectSegment
from core.workflow.entities import GraphInitParams
from core.workflow.enums import ErrorStrategy, NodeType, WorkflowNodeExecutionStatus
from core.workflow.enums import (
ErrorStrategy,
NodeType,
WorkflowNodeExecutionMetadataKey,
WorkflowNodeExecutionStatus,
)
from core.workflow.node_events import ModelInvokeCompletedEvent, NodeRunResult
from core.workflow.nodes.base import LLMUsageTrackingMixin
from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.knowledge_retrieval.template_prompts import (
@ -80,7 +83,7 @@ default_retrieval_model = {
}
class KnowledgeRetrievalNode(Node):
class KnowledgeRetrievalNode(LLMUsageTrackingMixin, Node):
node_type = NodeType.KNOWLEDGE_RETRIEVAL
_node_data: KnowledgeRetrievalNodeData
@ -182,14 +185,21 @@ class KnowledgeRetrievalNode(Node):
)
# retrieve knowledge
usage = LLMUsage.empty_usage()
try:
results = self._fetch_dataset_retriever(node_data=self._node_data, query=query)
results, usage = self._fetch_dataset_retriever(node_data=self._node_data, query=query)
outputs = {"result": ArrayObjectSegment(value=results)}
return NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
inputs=variables,
process_data={},
process_data={"usage": jsonable_encoder(usage)},
outputs=outputs, # type: ignore
metadata={
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: usage.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: usage.total_price,
WorkflowNodeExecutionMetadataKey.CURRENCY: usage.currency,
},
llm_usage=usage,
)
except KnowledgeRetrievalNodeError as e:
@ -199,6 +209,7 @@ class KnowledgeRetrievalNode(Node):
inputs=variables,
error=str(e),
error_type=type(e).__name__,
llm_usage=usage,
)
# Temporary handle all exceptions from DatasetRetrieval class here.
except Exception as e:
@ -207,11 +218,15 @@ class KnowledgeRetrievalNode(Node):
inputs=variables,
error=str(e),
error_type=type(e).__name__,
llm_usage=usage,
)
finally:
db.session.close()
def _fetch_dataset_retriever(self, node_data: KnowledgeRetrievalNodeData, query: str) -> list[dict[str, Any]]:
def _fetch_dataset_retriever(
self, node_data: KnowledgeRetrievalNodeData, query: str
) -> tuple[list[dict[str, Any]], LLMUsage]:
usage = LLMUsage.empty_usage()
available_datasets = []
dataset_ids = node_data.dataset_ids
@ -245,9 +260,10 @@ class KnowledgeRetrievalNode(Node):
if not dataset:
continue
available_datasets.append(dataset)
metadata_filter_document_ids, metadata_condition = self._get_metadata_filter_condition(
metadata_filter_document_ids, metadata_condition, metadata_usage = self._get_metadata_filter_condition(
[dataset.id for dataset in available_datasets], query, node_data
)
usage = self._merge_usage(usage, metadata_usage)
all_documents = []
dataset_retrieval = DatasetRetrieval()
if node_data.retrieval_mode == DatasetRetrieveConfigEntity.RetrieveStrategy.SINGLE:
@ -330,6 +346,8 @@ class KnowledgeRetrievalNode(Node):
metadata_filter_document_ids=metadata_filter_document_ids,
metadata_condition=metadata_condition,
)
usage = self._merge_usage(usage, dataset_retrieval.llm_usage)
dify_documents = [item for item in all_documents if item.provider == "dify"]
external_documents = [item for item in all_documents if item.provider == "external"]
retrieval_resource_list = []
@ -406,11 +424,12 @@ class KnowledgeRetrievalNode(Node):
)
for position, item in enumerate(retrieval_resource_list, start=1):
item["metadata"]["position"] = position
return retrieval_resource_list
return retrieval_resource_list, usage
def _get_metadata_filter_condition(
self, dataset_ids: list, query: str, node_data: KnowledgeRetrievalNodeData
) -> tuple[dict[str, list[str]] | None, MetadataCondition | None]:
) -> tuple[dict[str, list[str]] | None, MetadataCondition | None, LLMUsage]:
usage = LLMUsage.empty_usage()
document_query = db.session.query(Document).where(
Document.dataset_id.in_(dataset_ids),
Document.indexing_status == "completed",
@ -420,9 +439,12 @@ class KnowledgeRetrievalNode(Node):
filters: list[Any] = []
metadata_condition = None
if node_data.metadata_filtering_mode == "disabled":
return None, None
return None, None, usage
elif node_data.metadata_filtering_mode == "automatic":
automatic_metadata_filters = self._automatic_metadata_filter_func(dataset_ids, query, node_data)
automatic_metadata_filters, automatic_usage = self._automatic_metadata_filter_func(
dataset_ids, query, node_data
)
usage = self._merge_usage(usage, automatic_usage)
if automatic_metadata_filters:
conditions = []
for sequence, filter in enumerate(automatic_metadata_filters):
@ -496,11 +518,12 @@ class KnowledgeRetrievalNode(Node):
metadata_filter_document_ids = defaultdict(list) if documents else None # type: ignore
for document in documents:
metadata_filter_document_ids[document.dataset_id].append(document.id) # type: ignore
return metadata_filter_document_ids, metadata_condition
return metadata_filter_document_ids, metadata_condition, usage
def _automatic_metadata_filter_func(
self, dataset_ids: list, query: str, node_data: KnowledgeRetrievalNodeData
) -> list[dict[str, Any]]:
) -> tuple[list[dict[str, Any]], LLMUsage]:
usage = LLMUsage.empty_usage()
# get all metadata field
stmt = select(DatasetMetadata).where(DatasetMetadata.dataset_id.in_(dataset_ids))
metadata_fields = db.session.scalars(stmt).all()
@ -548,6 +571,7 @@ class KnowledgeRetrievalNode(Node):
for event in generator:
if isinstance(event, ModelInvokeCompletedEvent):
result_text = event.text
usage = self._merge_usage(usage, event.usage)
break
result_text_json = parse_and_check_json_markdown(result_text, [])
@ -564,8 +588,8 @@ class KnowledgeRetrievalNode(Node):
}
)
except Exception:
return []
return automatic_metadata_filters
return [], usage
return automatic_metadata_filters, usage
def _process_metadata_filter_func(
self, sequence: int, condition: str, metadata_name: str, value: Any, filters: list[Any]

View File

@ -5,6 +5,7 @@ from collections.abc import Callable, Generator, Mapping, Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any, Literal, cast
from core.model_runtime.entities.llm_entities import LLMUsage
from core.variables import Segment, SegmentType
from core.workflow.enums import (
ErrorStrategy,
@ -27,6 +28,7 @@ from core.workflow.node_events import (
NodeRunResult,
StreamCompletedEvent,
)
from core.workflow.nodes.base import LLMUsageTrackingMixin
from core.workflow.nodes.base.entities import BaseNodeData, RetryConfig
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.loop.entities import LoopNodeData, LoopVariableData
@ -40,7 +42,7 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
class LoopNode(Node):
class LoopNode(LLMUsageTrackingMixin, Node):
"""
Loop Node.
"""
@ -108,7 +110,7 @@ class LoopNode(Node):
raise ValueError(f"Invalid value for loop variable {loop_variable.label}")
variable_selector = [self._node_id, loop_variable.label]
variable = segment_to_variable(segment=processed_segment, selector=variable_selector)
self.graph_runtime_state.variable_pool.add(variable_selector, variable)
self.graph_runtime_state.variable_pool.add(variable_selector, variable.value)
loop_variable_selectors[loop_variable.label] = variable_selector
inputs[loop_variable.label] = processed_segment.value
@ -117,6 +119,7 @@ class LoopNode(Node):
loop_duration_map: dict[str, float] = {}
single_loop_variable_map: dict[str, dict[str, Any]] = {} # single loop variable output
loop_usage = LLMUsage.empty_usage()
# Start Loop event
yield LoopStartedEvent(
@ -163,6 +166,9 @@ class LoopNode(Node):
# Update the total tokens from this iteration
cost_tokens += graph_engine.graph_runtime_state.total_tokens
# Accumulate usage from the sub-graph execution
loop_usage = self._merge_usage(loop_usage, graph_engine.graph_runtime_state.llm_usage)
# Collect loop variable values after iteration
single_loop_variable = {}
for key, selector in loop_variable_selectors.items():
@ -189,6 +195,7 @@ class LoopNode(Node):
)
self.graph_runtime_state.total_tokens += cost_tokens
self._accumulate_usage(loop_usage)
# Loop completed successfully
yield LoopSucceededEvent(
start_at=start_at,
@ -196,7 +203,9 @@ class LoopNode(Node):
outputs=self._node_data.outputs,
steps=loop_count,
metadata={
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: cost_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: loop_usage.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: loop_usage.total_price,
WorkflowNodeExecutionMetadataKey.CURRENCY: loop_usage.currency,
"completed_reason": "loop_break" if reach_break_condition else "loop_completed",
WorkflowNodeExecutionMetadataKey.LOOP_DURATION_MAP: loop_duration_map,
WorkflowNodeExecutionMetadataKey.LOOP_VARIABLE_MAP: single_loop_variable_map,
@ -207,22 +216,28 @@ class LoopNode(Node):
node_run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
metadata={
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: self.graph_runtime_state.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: loop_usage.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: loop_usage.total_price,
WorkflowNodeExecutionMetadataKey.CURRENCY: loop_usage.currency,
WorkflowNodeExecutionMetadataKey.LOOP_DURATION_MAP: loop_duration_map,
WorkflowNodeExecutionMetadataKey.LOOP_VARIABLE_MAP: single_loop_variable_map,
},
outputs=self._node_data.outputs,
inputs=inputs,
llm_usage=loop_usage,
)
)
except Exception as e:
self._accumulate_usage(loop_usage)
yield LoopFailedEvent(
start_at=start_at,
inputs=inputs,
steps=loop_count,
metadata={
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: self.graph_runtime_state.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: loop_usage.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: loop_usage.total_price,
WorkflowNodeExecutionMetadataKey.CURRENCY: loop_usage.currency,
"completed_reason": "error",
WorkflowNodeExecutionMetadataKey.LOOP_DURATION_MAP: loop_duration_map,
WorkflowNodeExecutionMetadataKey.LOOP_VARIABLE_MAP: single_loop_variable_map,
@ -235,10 +250,13 @@ class LoopNode(Node):
status=WorkflowNodeExecutionStatus.FAILED,
error=str(e),
metadata={
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: self.graph_runtime_state.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS: loop_usage.total_tokens,
WorkflowNodeExecutionMetadataKey.TOTAL_PRICE: loop_usage.total_price,
WorkflowNodeExecutionMetadataKey.CURRENCY: loop_usage.currency,
WorkflowNodeExecutionMetadataKey.LOOP_DURATION_MAP: loop_duration_map,
WorkflowNodeExecutionMetadataKey.LOOP_VARIABLE_MAP: single_loop_variable_map,
},
llm_usage=loop_usage,
)
)

View File

@ -6,10 +6,13 @@ from sqlalchemy.orm import Session
from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler
from core.file import File, FileTransferMethod
from core.model_runtime.entities.llm_entities import LLMUsage
from core.tools.__base.tool import Tool
from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter
from core.tools.errors import ToolInvokeError
from core.tools.tool_engine import ToolEngine
from core.tools.utils.message_transformer import ToolFileMessageTransformer
from core.tools.workflow_as_tool.tool import WorkflowTool
from core.variables.segments import ArrayAnySegment, ArrayFileSegment
from core.variables.variables import ArrayAnyVariable
from core.workflow.enums import (
@ -136,13 +139,14 @@ class ToolNode(Node):
try:
# convert tool messages
yield from self._transform_message(
_ = yield from self._transform_message(
messages=message_stream,
tool_info=tool_info,
parameters_for_log=parameters_for_log,
user_id=self.user_id,
tenant_id=self.tenant_id,
node_id=self._node_id,
tool_runtime=tool_runtime,
)
except ToolInvokeError as e:
yield StreamCompletedEvent(
@ -233,7 +237,8 @@ class ToolNode(Node):
user_id: str,
tenant_id: str,
node_id: str,
) -> Generator:
tool_runtime: Tool,
) -> Generator[NodeEventBase, None, LLMUsage]:
"""
Convert ToolInvokeMessages into tuple[plain_text, files]
"""
@ -421,17 +426,34 @@ class ToolNode(Node):
is_final=True,
)
usage = self._extract_tool_usage(tool_runtime)
metadata: dict[WorkflowNodeExecutionMetadataKey, Any] = {
WorkflowNodeExecutionMetadataKey.TOOL_INFO: tool_info,
}
if usage.total_tokens > 0:
metadata[WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS] = usage.total_tokens
metadata[WorkflowNodeExecutionMetadataKey.TOTAL_PRICE] = usage.total_price
metadata[WorkflowNodeExecutionMetadataKey.CURRENCY] = usage.currency
yield StreamCompletedEvent(
node_run_result=NodeRunResult(
status=WorkflowNodeExecutionStatus.SUCCEEDED,
outputs={"text": text, "files": ArrayFileSegment(value=files), "json": json_output, **variables},
metadata={
WorkflowNodeExecutionMetadataKey.TOOL_INFO: tool_info,
},
metadata=metadata,
inputs=parameters_for_log,
llm_usage=usage,
)
)
return usage
@staticmethod
def _extract_tool_usage(tool_runtime: Tool) -> LLMUsage:
if isinstance(tool_runtime, WorkflowTool):
return tool_runtime.latest_usage
return LLMUsage.empty_usage()
@classmethod
def _extract_variable_selector_to_variable_mapping(
cls,

View File

@ -64,7 +64,8 @@ if [[ "${MODE}" == "worker" ]]; then
exec celery -A app.celery worker -P ${WORKER_POOL} $CONCURRENCY_OPTION \
--max-tasks-per-child ${MAX_TASKS_PER_CHILD:-50} --loglevel ${LOG_LEVEL:-INFO} \
-Q ${DEFAULT_QUEUES}
-Q ${DEFAULT_QUEUES} \
--prefetch-multiplier=${CELERY_PREFETCH_MULTIPLIER:-1}
elif [[ "${MODE}" == "beat" ]]; then
exec celery -A app.celery beat --loglevel ${LOG_LEVEL:-INFO}

View File

@ -81,6 +81,8 @@ class AvatarUrlField(fields.Raw):
from models import Account
if isinstance(obj, Account) and obj.avatar is not None:
if obj.avatar.startswith(("http://", "https://")):
return obj.avatar
return file_helpers.get_signed_file_url(obj.avatar)
return None

View File

@ -12,6 +12,7 @@ from constants import (
COOKIE_NAME_CSRF_TOKEN,
COOKIE_NAME_PASSPORT,
COOKIE_NAME_REFRESH_TOKEN,
COOKIE_NAME_WEBAPP_ACCESS_TOKEN,
HEADER_NAME_CSRF_TOKEN,
HEADER_NAME_PASSPORT,
)
@ -81,6 +82,14 @@ def extract_access_token(request: Request) -> str | None:
return _try_extract_from_cookie(request) or _try_extract_from_header(request)
def extract_webapp_access_token(request: Request) -> str | None:
"""
Try to extract webapp access token from cookie, then header.
"""
return request.cookies.get(_real_cookie_name(COOKIE_NAME_WEBAPP_ACCESS_TOKEN)) or _try_extract_from_header(request)
def extract_webapp_passport(app_code: str, request: Request) -> str | None:
"""
Try to extract app token from header or params.
@ -155,6 +164,10 @@ def clear_access_token_from_cookie(response: Response, samesite: str = "Lax"):
_clear_cookie(response, COOKIE_NAME_ACCESS_TOKEN, samesite)
def clear_webapp_access_token_from_cookie(response: Response, samesite: str = "Lax"):
_clear_cookie(response, COOKIE_NAME_WEBAPP_ACCESS_TOKEN, samesite)
def clear_refresh_token_from_cookie(response: Response):
_clear_cookie(response, COOKIE_NAME_REFRESH_TOKEN)

View File

@ -0,0 +1,36 @@
"""remove-builtin-template-user
Revision ID: ae662b25d9bc
Revises: d98acf217d43
Create Date: 2025-10-21 14:30:28.566192
"""
from alembic import op
import models as models
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'ae662b25d9bc'
down_revision = 'd98acf217d43'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('pipeline_built_in_templates', schema=None) as batch_op:
batch_op.drop_column('updated_by')
batch_op.drop_column('created_by')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('pipeline_built_in_templates', schema=None) as batch_op:
batch_op.add_column(sa.Column('created_by', sa.UUID(), autoincrement=False, nullable=False))
batch_op.add_column(sa.Column('updated_by', sa.UUID(), autoincrement=False, nullable=True))
# ### end Alembic commands ###

View File

@ -1239,15 +1239,6 @@ class PipelineBuiltInTemplate(Base): # type: ignore[name-defined]
language = mapped_column(db.String(255), nullable=False)
created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
updated_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
created_by = mapped_column(StringUUID, nullable=False)
updated_by = mapped_column(StringUUID, nullable=True)
@property
def created_user_name(self):
account = db.session.query(Account).where(Account.id == self.created_by).first()
if account:
return account.name
return ""
class PipelineCustomizedTemplate(Base): # type: ignore[name-defined]

View File

@ -222,7 +222,7 @@ class WorkflowToolProvider(TypeBase):
sa.UniqueConstraint("tenant_id", "app_id", name="unique_workflow_tool_provider_app_id"),
)
id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()"), init=False)
id: Mapped[str] = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()"))
# name of the workflow provider
name: Mapped[str] = mapped_column(String(255), nullable=False)
# label of the workflow provider

View File

@ -1,6 +1,6 @@
[project]
name = "dify-api"
version = "1.9.1"
version = "1.9.2"
requires-python = ">=3.11,<3.13"
dependencies = [

View File

@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import Unauthorized
from configs import dify_config
from constants.languages import language_timezone_mapping, languages
from constants.languages import get_valid_language, language_timezone_mapping
from events.tenant_event import tenant_was_created
from extensions.ext_database import db
from extensions.ext_redis import redis_client, redis_fallback
@ -1259,7 +1259,7 @@ class RegisterService:
return f"member_invite:token:{token}"
@classmethod
def setup(cls, email: str, name: str, password: str, ip_address: str):
def setup(cls, email: str, name: str, password: str, ip_address: str, language: str):
"""
Setup dify
@ -1269,11 +1269,10 @@ class RegisterService:
:param ip_address: ip address
"""
try:
# Register
account = AccountService.create_account(
email=email,
name=name,
interface_language=languages[0],
interface_language=get_valid_language(language),
password=password,
is_setup=True,
)
@ -1315,7 +1314,7 @@ class RegisterService:
account = AccountService.create_account(
email=email,
name=name,
interface_language=language or languages[0],
interface_language=get_valid_language(language),
password=password,
is_setup=is_setup,
)

View File

@ -174,6 +174,7 @@ class FeatureService:
if dify_config.ENTERPRISE_ENABLED:
features.webapp_copyright_enabled = True
features.knowledge_pipeline.publish_enabled = True
cls._fulfill_params_from_workspace_info(features, tenant_id)
return features

View File

@ -74,5 +74,4 @@ class DatabasePipelineTemplateRetrieval(PipelineTemplateRetrievalBase):
"chunk_structure": pipeline_template.chunk_structure,
"export_data": pipeline_template.yaml_content,
"graph": graph_data,
"created_by": pipeline_template.created_user_name,
}

View File

@ -4,6 +4,7 @@ from datetime import datetime
from typing import Any
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from core.model_runtime.utils.encoders import jsonable_encoder
from core.tools.__base.tool_provider import ToolProviderController
@ -13,6 +14,7 @@ from core.tools.utils.workflow_configuration_sync import WorkflowToolConfigurati
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
from core.tools.workflow_as_tool.tool import WorkflowTool
from extensions.ext_database import db
from libs.uuid_utils import uuidv7
from models.model import App
from models.tools import WorkflowToolProvider
from models.workflow import Workflow
@ -63,27 +65,27 @@ class WorkflowToolManageService:
if workflow is None:
raise ValueError(f"Workflow not found for app {workflow_app_id}")
workflow_tool_provider = WorkflowToolProvider(
tenant_id=tenant_id,
user_id=user_id,
app_id=workflow_app_id,
name=name,
label=label,
icon=json.dumps(icon),
description=description,
parameter_configuration=json.dumps(parameters),
privacy_policy=privacy_policy,
version=workflow.version,
)
with Session(db.engine, expire_on_commit=False) as session, session.begin():
workflow_tool_provider = WorkflowToolProvider(
id=str(uuidv7()),
tenant_id=tenant_id,
user_id=user_id,
app_id=workflow_app_id,
name=name,
label=label,
icon=json.dumps(icon),
description=description,
parameter_configuration=json.dumps(parameters),
privacy_policy=privacy_policy,
version=workflow.version,
)
session.add(workflow_tool_provider)
try:
WorkflowToolProviderController.from_db(workflow_tool_provider)
except Exception as e:
raise ValueError(str(e))
db.session.add(workflow_tool_provider)
db.session.commit()
if labels is not None:
ToolLabelManager.update_tool_labels(
ToolTransformService.workflow_provider_to_controller(workflow_tool_provider), labels
@ -168,7 +170,6 @@ class WorkflowToolManageService:
except Exception as e:
raise ValueError(str(e))
db.session.add(workflow_tool_provider)
db.session.commit()
if labels is not None:

View File

@ -17,6 +17,7 @@ from core.variables.segments import (
StringSegment,
)
from core.variables.utils import dumps_with_segments
from core.workflow.nodes.variable_assigner.common.helpers import UpdatedVariable
_MAX_DEPTH = 100
@ -56,7 +57,7 @@ class UnknownTypeError(Exception):
pass
JSONTypes: TypeAlias = int | float | str | list | dict | None | bool
JSONTypes: TypeAlias = int | float | str | list[object] | dict[str, object] | None | bool
@dataclasses.dataclass(frozen=True)
@ -202,6 +203,9 @@ class VariableTruncator:
"""Recursively calculate JSON size without serialization."""
if isinstance(value, Segment):
return VariableTruncator.calculate_json_size(value.value)
if isinstance(value, UpdatedVariable):
# TODO(Workflow): migrate UpdatedVariable serialization upstream and drop this fallback.
return VariableTruncator.calculate_json_size(value.model_dump(), depth=depth + 1)
if depth > _MAX_DEPTH:
raise MaxDepthExceededError()
if isinstance(value, str):
@ -248,14 +252,14 @@ class VariableTruncator:
truncated_value = value[:truncated_size] + "..."
return _PartResult(truncated_value, self.calculate_json_size(truncated_value), True)
def _truncate_array(self, value: list, target_size: int) -> _PartResult[list]:
def _truncate_array(self, value: list[object], target_size: int) -> _PartResult[list[object]]:
"""
Truncate array with correct strategy:
1. First limit to 20 items
2. If still too large, truncate individual items
"""
truncated_value: list[Any] = []
truncated_value: list[object] = []
truncated = False
used_size = self.calculate_json_size([])
@ -278,7 +282,11 @@ class VariableTruncator:
if used_size > target_size:
break
part_result = self._truncate_json_primitives(item, target_size - used_size)
remaining_budget = target_size - used_size
if item is None or isinstance(item, (str, list, dict, bool, int, float)):
part_result = self._truncate_json_primitives(item, remaining_budget)
else:
raise UnknownTypeError(f"got unknown type {type(item)} in array truncation")
truncated_value.append(part_result.value)
used_size += part_result.value_size
truncated = part_result.truncated
@ -369,10 +377,10 @@ class VariableTruncator:
def _truncate_json_primitives(self, val: str, target_size: int) -> _PartResult[str]: ...
@overload
def _truncate_json_primitives(self, val: list, target_size: int) -> _PartResult[list]: ...
def _truncate_json_primitives(self, val: list[object], target_size: int) -> _PartResult[list[object]]: ...
@overload
def _truncate_json_primitives(self, val: dict, target_size: int) -> _PartResult[dict]: ...
def _truncate_json_primitives(self, val: dict[str, object], target_size: int) -> _PartResult[dict[str, object]]: ...
@overload
def _truncate_json_primitives(self, val: bool, target_size: int) -> _PartResult[bool]: ... # type: ignore
@ -387,10 +395,15 @@ class VariableTruncator:
def _truncate_json_primitives(self, val: None, target_size: int) -> _PartResult[None]: ...
def _truncate_json_primitives(
self, val: str | list | dict | bool | int | float | None, target_size: int
self,
val: UpdatedVariable | str | list[object] | dict[str, object] | bool | int | float | None,
target_size: int,
) -> _PartResult[Any]:
"""Truncate a value within an object to fit within budget."""
if isinstance(val, str):
if isinstance(val, UpdatedVariable):
# TODO(Workflow): push UpdatedVariable normalization closer to its producer.
return self._truncate_object(val.model_dump(), target_size)
elif isinstance(val, str):
return self._truncate_string(val, target_size)
elif isinstance(val, list):
return self._truncate_array(val, target_size)

View File

@ -58,6 +58,7 @@ def setup_account(request) -> Generator[Account, None, None]:
name=name,
password=secrets.token_hex(16),
ip_address="localhost",
language="en-US",
)
with _CACHED_APP.test_request_context():

View File

@ -2299,6 +2299,7 @@ class TestRegisterService:
name=admin_name,
password=admin_password,
ip_address=ip_address,
language="en-US",
)
# Verify account was created
@ -2348,6 +2349,7 @@ class TestRegisterService:
name=admin_name,
password=admin_password,
ip_address=ip_address,
language="en-US",
)
# Verify no entities were created (rollback worked)

View File

@ -6,6 +6,7 @@ from faker import Faker
from core.tools.entities.api_entities import ToolProviderApiEntity
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderType
from libs.uuid_utils import uuidv7
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider
from services.plugin.plugin_service import PluginService
from services.tools.tools_transform_service import ToolTransformService
@ -67,6 +68,7 @@ class TestToolTransformService:
)
elif provider_type == "workflow":
provider = WorkflowToolProvider(
id=str(uuidv7()),
name=fake.company(),
description=fake.text(max_nb_chars=100),
icon='{"background": "#FF6B6B", "content": "🔧"}',
@ -759,6 +761,7 @@ class TestToolTransformService:
# Create workflow tool provider
provider = WorkflowToolProvider(
id=str(uuidv7()),
name=fake.company(),
description=fake.text(max_nb_chars=100),
icon='{"background": "#FF6B6B", "content": "🔧"}',

View File

@ -109,3 +109,83 @@ def test_parse_openapi_to_tool_bundle_properties_all_of(app):
assert tool_bundles[0].parameters[0].llm_description == "desc prop1"
# TODO: support enum in OpenAPI
# assert set(tool_bundles[0].parameters[0].options) == {"option1", "option2", "option3"}
def test_parse_openapi_to_tool_bundle_default_value_type_casting(app):
"""
Test that default values are properly cast to match parameter types.
This addresses the issue where array default values like [] cause validation errors
when parameter type is inferred as string/number/boolean.
"""
openapi = {
"openapi": "3.0.0",
"info": {"title": "Test API", "version": "1.0.0"},
"servers": [{"url": "https://example.com"}],
"paths": {
"/product/create": {
"post": {
"operationId": "createProduct",
"summary": "Create a product",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"categories": {
"description": "List of category identifiers",
"default": [],
"type": "array",
"items": {"type": "string"},
},
"name": {
"description": "Product name",
"default": "Default Product",
"type": "string",
},
"price": {"description": "Product price", "default": 0.0, "type": "number"},
"available": {
"description": "Product availability",
"default": True,
"type": "boolean",
},
},
}
}
}
},
"responses": {"200": {"description": "Default Response"}},
}
}
},
}
with app.test_request_context():
tool_bundles = ApiBasedToolSchemaParser.parse_openapi_to_tool_bundle(openapi)
assert len(tool_bundles) == 1
bundle = tool_bundles[0]
assert len(bundle.parameters) == 4
# Find parameters by name
params_by_name = {param.name: param for param in bundle.parameters}
# Check categories parameter (array type with [] default)
categories_param = params_by_name["categories"]
assert categories_param.type == "array" # Will be detected by _get_tool_parameter_type
assert categories_param.default is None # Array default [] is converted to None
# Check name parameter (string type with string default)
name_param = params_by_name["name"]
assert name_param.type == "string"
assert name_param.default == "Default Product"
# Check price parameter (number type with number default)
price_param = params_by_name["price"]
assert price_param.type == "number"
assert price_param.default == 0.0
# Check available parameter (boolean type with boolean default)
available_param = params_by_name["available"]
assert available_param.type == "boolean"
assert available_param.default is True

View File

@ -1,5 +1,5 @@
from constants import COOKIE_NAME_ACCESS_TOKEN
from libs.token import extract_access_token
from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_WEBAPP_ACCESS_TOKEN
from libs.token import extract_access_token, extract_webapp_access_token
class MockRequest:
@ -14,10 +14,12 @@ def test_extract_access_token():
return MockRequest(headers, cookies, args)
test_cases = [
(_mock_request({"Authorization": "Bearer 123"}, {}, {}), "123"),
(_mock_request({}, {COOKIE_NAME_ACCESS_TOKEN: "123"}, {}), "123"),
(_mock_request({}, {}, {}), None),
(_mock_request({"Authorization": "Bearer_aaa 123"}, {}, {}), None),
(_mock_request({"Authorization": "Bearer 123"}, {}, {}), "123", "123"),
(_mock_request({}, {COOKIE_NAME_ACCESS_TOKEN: "123"}, {}), "123", None),
(_mock_request({}, {}, {}), None, None),
(_mock_request({"Authorization": "Bearer_aaa 123"}, {}, {}), None, None),
(_mock_request({}, {COOKIE_NAME_WEBAPP_ACCESS_TOKEN: "123"}, {}), None, "123"),
]
for request, expected in test_cases:
assert extract_access_token(request) == expected # pyright: ignore[reportArgumentType]
for request, expected_console, expected_webapp in test_cases:
assert extract_access_token(request) == expected_console # pyright: ignore[reportArgumentType]
assert extract_webapp_access_token(request) == expected_webapp # pyright: ignore[reportArgumentType]

View File

@ -893,7 +893,7 @@ class TestRegisterService:
mock_dify_setup.return_value = mock_dify_setup_instance
# Execute test
RegisterService.setup("admin@example.com", "Admin User", "password123", "192.168.1.1")
RegisterService.setup("admin@example.com", "Admin User", "password123", "192.168.1.1", "en-US")
# Verify results
mock_create_account.assert_called_once_with(
@ -925,6 +925,7 @@ class TestRegisterService:
"Admin User",
"password123",
"192.168.1.1",
"en-US",
)
# Verify rollback operations were called

View File

@ -1305,7 +1305,7 @@ wheels = [
[[package]]
name = "dify-api"
version = "1.9.1"
version = "1.9.2"
source = { virtual = "." }
dependencies = [
{ name = "apscheduler" },

View File

@ -10,7 +10,7 @@ PATH_TO_CHECK="$1"
# run basedpyright checks
if [ -n "$PATH_TO_CHECK" ]; then
uv run --directory api --dev basedpyright "$PATH_TO_CHECK"
uv run --directory api --dev -- basedpyright --threads $(nproc) "$PATH_TO_CHECK"
else
uv run --directory api --dev basedpyright
uv run --directory api --dev -- basedpyright --threads $(nproc)
fi

View File

@ -259,6 +259,18 @@ POSTGRES_MAINTENANCE_WORK_MEM=64MB
# Reference: https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE
POSTGRES_EFFECTIVE_CACHE_SIZE=4096MB
# Sets the maximum allowed duration of any statement before termination.
# Default is 60000 milliseconds.
#
# Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT
POSTGRES_STATEMENT_TIMEOUT=60000
# Sets the maximum allowed duration of any idle in-transaction session before termination.
# Default is 60000 milliseconds.
#
# Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT
POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=60000
# ------------------------------
# Redis Configuration
# This Redis configuration is used for caching and for pub/sub during conversation.

View File

@ -2,7 +2,7 @@ x-shared-env: &shared-api-worker-env
services:
# API service
api:
image: langgenius/dify-api:1.9.1
image: langgenius/dify-api:1.9.2
restart: always
environment:
# Use the shared environment variables.
@ -24,13 +24,6 @@ services:
volumes:
# Mount the storage directory to the container, for storing user files.
- ./volumes/app/storage:/app/api/storage
# TODO: Remove this entrypoint override when weaviate-client 4.17.0 is included in the next Dify release
entrypoint:
- /bin/bash
- -c
- |
uv pip install --system weaviate-client==4.17.0
exec /bin/bash /app/api/docker/entrypoint.sh
networks:
- ssrf_proxy_network
- default
@ -38,7 +31,7 @@ services:
# worker service
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
worker:
image: langgenius/dify-api:1.9.1
image: langgenius/dify-api:1.9.2
restart: always
environment:
# Use the shared environment variables.
@ -58,13 +51,6 @@ services:
volumes:
# Mount the storage directory to the container, for storing user files.
- ./volumes/app/storage:/app/api/storage
# TODO: Remove this entrypoint override when weaviate-client 4.17.0 is included in the next Dify release
entrypoint:
- /bin/bash
- -c
- |
uv pip install --system weaviate-client==4.17.0
exec /bin/bash /app/api/docker/entrypoint.sh
networks:
- ssrf_proxy_network
- default
@ -72,7 +58,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.9.1
image: langgenius/dify-api:1.9.2
restart: always
environment:
# Use the shared environment variables.
@ -90,7 +76,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.9.1
image: langgenius/dify-web:1.9.2
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@ -129,6 +115,8 @@ services:
-c 'work_mem=${POSTGRES_WORK_MEM:-4MB}'
-c 'maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}'
-c 'effective_cache_size=${POSTGRES_EFFECTIVE_CACHE_SIZE:-4096MB}'
-c 'statement_timeout=${POSTGRES_STATEMENT_TIMEOUT:-60000}'
-c 'idle_in_transaction_session_timeout=${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-60000}'
volumes:
- ./volumes/db/data:/var/lib/postgresql/data
healthcheck:
@ -191,7 +179,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.3.0-local
image: langgenius/dify-plugin-daemon:0.3.3-local
restart: always
environment:
# Use the shared environment variables.

View File

@ -15,6 +15,8 @@ services:
-c 'work_mem=${POSTGRES_WORK_MEM:-4MB}'
-c 'maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}'
-c 'effective_cache_size=${POSTGRES_EFFECTIVE_CACHE_SIZE:-4096MB}'
-c 'statement_timeout=${POSTGRES_STATEMENT_TIMEOUT:-60000}'
-c 'idle_in_transaction_session_timeout=${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-60000}'
volumes:
- ${PGDATA_HOST_VOLUME:-./volumes/db/data}:/var/lib/postgresql/data
ports:
@ -85,7 +87,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.3.0-local
image: langgenius/dify-plugin-daemon:0.3.3-local
restart: always
env_file:
- ./middleware.env

View File

@ -68,6 +68,8 @@ x-shared-env: &shared-api-worker-env
POSTGRES_WORK_MEM: ${POSTGRES_WORK_MEM:-4MB}
POSTGRES_MAINTENANCE_WORK_MEM: ${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}
POSTGRES_EFFECTIVE_CACHE_SIZE: ${POSTGRES_EFFECTIVE_CACHE_SIZE:-4096MB}
POSTGRES_STATEMENT_TIMEOUT: ${POSTGRES_STATEMENT_TIMEOUT:-60000}
POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT: ${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-60000}
REDIS_HOST: ${REDIS_HOST:-redis}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_USERNAME: ${REDIS_USERNAME:-}
@ -614,7 +616,7 @@ x-shared-env: &shared-api-worker-env
services:
# API service
api:
image: langgenius/dify-api:1.9.1
image: langgenius/dify-api:1.9.2
restart: always
environment:
# Use the shared environment variables.
@ -636,13 +638,6 @@ services:
volumes:
# Mount the storage directory to the container, for storing user files.
- ./volumes/app/storage:/app/api/storage
# TODO: Remove this entrypoint override when weaviate-client 4.17.0 is included in the next Dify release
entrypoint:
- /bin/bash
- -c
- |
uv pip install --system weaviate-client==4.17.0
exec /bin/bash /app/api/docker/entrypoint.sh
networks:
- ssrf_proxy_network
- default
@ -650,7 +645,7 @@ services:
# worker service
# The Celery worker for processing the queue.
worker:
image: langgenius/dify-api:1.9.1
image: langgenius/dify-api:1.9.2
restart: always
environment:
# Use the shared environment variables.
@ -670,13 +665,6 @@ services:
volumes:
# Mount the storage directory to the container, for storing user files.
- ./volumes/app/storage:/app/api/storage
# TODO: Remove this entrypoint override when weaviate-client 4.17.0 is included in the next Dify release
entrypoint:
- /bin/bash
- -c
- |
uv pip install --system weaviate-client==4.17.0
exec /bin/bash /app/api/docker/entrypoint.sh
networks:
- ssrf_proxy_network
- default
@ -684,7 +672,7 @@ services:
# worker_beat service
# Celery beat for scheduling periodic tasks.
worker_beat:
image: langgenius/dify-api:1.9.1
image: langgenius/dify-api:1.9.2
restart: always
environment:
# Use the shared environment variables.
@ -702,7 +690,7 @@ services:
# Frontend web application.
web:
image: langgenius/dify-web:1.9.1
image: langgenius/dify-web:1.9.2
restart: always
environment:
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
@ -741,6 +729,8 @@ services:
-c 'work_mem=${POSTGRES_WORK_MEM:-4MB}'
-c 'maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}'
-c 'effective_cache_size=${POSTGRES_EFFECTIVE_CACHE_SIZE:-4096MB}'
-c 'statement_timeout=${POSTGRES_STATEMENT_TIMEOUT:-60000}'
-c 'idle_in_transaction_session_timeout=${POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT:-60000}'
volumes:
- ./volumes/db/data:/var/lib/postgresql/data
healthcheck:
@ -803,7 +793,7 @@ services:
# plugin daemon
plugin_daemon:
image: langgenius/dify-plugin-daemon:0.3.0-local
image: langgenius/dify-plugin-daemon:0.3.3-local
restart: always
environment:
# Use the shared environment variables.

View File

@ -40,6 +40,18 @@ POSTGRES_MAINTENANCE_WORK_MEM=64MB
# Reference: https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-EFFECTIVE-CACHE-SIZE
POSTGRES_EFFECTIVE_CACHE_SIZE=4096MB
# Sets the maximum allowed duration of any statement before termination.
# Default is 60000 milliseconds.
#
# Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-STATEMENT-TIMEOUT
POSTGRES_STATEMENT_TIMEOUT=60000
# Sets the maximum allowed duration of any idle in-transaction session before termination.
# Default is 60000 milliseconds.
#
# Reference: https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT
POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=60000
# -----------------------------
# Environment Variables for redis Service
# -----------------------------

View File

@ -2,8 +2,7 @@ import type { StorybookConfig } from '@storybook/nextjs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const storybookDir = path.dirname(fileURLToPath(import.meta.url))
const config: StorybookConfig = {
stories: ['../app/components/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
@ -36,9 +35,9 @@ const config: StorybookConfig = {
config.resolve.alias = {
...config.resolve.alias,
// Mock the plugin index files to avoid circular dependencies
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/context-block/index.tsx')]: path.resolve(__dirname, '__mocks__/context-block.tsx'),
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/history-block/index.tsx')]: path.resolve(__dirname, '__mocks__/history-block.tsx'),
[path.resolve(__dirname, '../app/components/base/prompt-editor/plugins/query-block/index.tsx')]: path.resolve(__dirname, '__mocks__/query-block.tsx'),
[path.resolve(storybookDir, '../app/components/base/prompt-editor/plugins/context-block/index.tsx')]: path.resolve(storybookDir, '__mocks__/context-block.tsx'),
[path.resolve(storybookDir, '../app/components/base/prompt-editor/plugins/history-block/index.tsx')]: path.resolve(storybookDir, '__mocks__/history-block.tsx'),
[path.resolve(storybookDir, '../app/components/base/prompt-editor/plugins/query-block/index.tsx')]: path.resolve(storybookDir, '__mocks__/query-block.tsx'),
}
return config
},

View File

@ -0,0 +1,64 @@
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
type PlayerCallback = ((event: string) => void) | null
class MockAudioPlayer {
private callback: PlayerCallback = null
private finishTimer?: ReturnType<typeof setTimeout>
public setCallback(callback: PlayerCallback) {
this.callback = callback
}
public playAudio() {
this.clearTimer()
this.callback?.('play')
this.finishTimer = setTimeout(() => {
this.callback?.('ended')
}, 2000)
}
public pauseAudio() {
this.clearTimer()
this.callback?.('paused')
}
private clearTimer() {
if (this.finishTimer)
clearTimeout(this.finishTimer)
}
}
class MockAudioPlayerManager {
private readonly player = new MockAudioPlayer()
public getAudioPlayer(
_url: string,
_isPublic: boolean,
_id: string | undefined,
_msgContent: string | null | undefined,
_voice: string | undefined,
callback: PlayerCallback,
) {
this.player.setCallback(callback)
return this.player
}
public resetMsgId() {
// No-op for the mock
}
}
export const ensureMockAudioManager = () => {
const managerAny = AudioPlayerManager as unknown as {
getInstance: () => AudioPlayerManager
__isStorybookMockInstalled?: boolean
}
if (managerAny.__isStorybookMockInstalled)
return
const mock = new MockAudioPlayerManager()
managerAny.getInstance = () => mock as unknown as AudioPlayerManager
managerAny.__isStorybookMockInstalled = true
}

View File

@ -9,6 +9,7 @@ import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ProviderContextProvider } from '@/context/provider-context'
import { ModalContextProvider } from '@/context/modal-context'
import GotoAnything from '@/app/components/goto-anything'
import Zendesk from '@/app/components/base/zendesk'
const Layout = ({ children }: { children: ReactNode }) => {
return (
@ -28,6 +29,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
<Zendesk />
</SwrInitializer>
</>
)

View File

@ -53,7 +53,6 @@ const Annotation: FC<Props> = (props) => {
const [isShowViewModal, setIsShowViewModal] = useState(false)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
const [isBatchDeleting, setIsBatchDeleting] = useState(false)
const fetchAnnotationConfig = async () => {
const res = await doFetchAnnotationConfig(appDetail.id)
@ -108,9 +107,6 @@ const Annotation: FC<Props> = (props) => {
}
const handleBatchDelete = async () => {
if (isBatchDeleting)
return
setIsBatchDeleting(true)
try {
await delAnnotations(appDetail.id, selectedIds)
Toast.notify({ message: t('common.api.actionSuccess'), type: 'success' })
@ -121,9 +117,6 @@ const Annotation: FC<Props> = (props) => {
catch (e: any) {
Toast.notify({ type: 'error', message: e.message || t('common.api.actionFailed') })
}
finally {
setIsBatchDeleting(false)
}
}
const handleView = (item: AnnotationItem) => {
@ -213,7 +206,6 @@ const Annotation: FC<Props> = (props) => {
onSelectedIdsChange={setSelectedIds}
onBatchDelete={handleBatchDelete}
onCancel={() => setSelectedIds([])}
isBatchDeleting={isBatchDeleting}
/>
: <div className='flex h-full grow items-center justify-center'><EmptyElement /></div>
}

View File

@ -19,7 +19,6 @@ type Props = {
onSelectedIdsChange: (selectedIds: string[]) => void
onBatchDelete: () => Promise<void>
onCancel: () => void
isBatchDeleting?: boolean
}
const List: FC<Props> = ({
@ -30,7 +29,6 @@ const List: FC<Props> = ({
onSelectedIdsChange,
onBatchDelete,
onCancel,
isBatchDeleting,
}) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
@ -142,7 +140,6 @@ const List: FC<Props> = ({
selectedIds={selectedIds}
onBatchDelete={onBatchDelete}
onCancel={onCancel}
isBatchDeleting={isBatchDeleting}
/>
)}
</div>

View File

@ -78,7 +78,9 @@ const AdvancedPromptInput: FC<Props> = ({
const handleOpenExternalDataToolModal = () => {
setShowExternalDataToolModal({
payload: {},
onSaveCallback: (newExternalDataTool: ExternalDataTool) => {
onSaveCallback: (newExternalDataTool?: ExternalDataTool) => {
if (!newExternalDataTool)
return
eventEmitter?.emit({
type: ADD_EXTERNAL_DATA_TOOL,
payload: newExternalDataTool,

View File

@ -76,7 +76,9 @@ const Prompt: FC<ISimplePromptInput> = ({
const handleOpenExternalDataToolModal = () => {
setShowExternalDataToolModal({
payload: {},
onSaveCallback: (newExternalDataTool: ExternalDataTool) => {
onSaveCallback: (newExternalDataTool?: ExternalDataTool) => {
if (!newExternalDataTool)
return
eventEmitter?.emit({
type: ADD_EXTERNAL_DATA_TOOL,
payload: newExternalDataTool,

View File

@ -320,7 +320,7 @@ const ConfigModal: FC<IConfigModalProps> = ({
{type === InputVarType.paragraph && (
<Field title={t('appDebug.variableConfig.defaultValue')}>
<Textarea
value={tempPayload.default || ''}
value={String(tempPayload.default ?? '')}
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
placeholder={t('appDebug.variableConfig.inputPlaceholder')!}
/>

View File

@ -121,7 +121,9 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
icon,
icon_background,
},
onSaveCallback: (newExternalDataTool: ExternalDataTool) => {
onSaveCallback: (newExternalDataTool?: ExternalDataTool) => {
if (!newExternalDataTool)
return
const newPromptVariables = oldPromptVariables.map((item, i) => {
if (i === index) {
return {

View File

@ -51,7 +51,9 @@ const Editor: FC<Props> = ({
const handleOpenExternalDataToolModal = () => {
setShowExternalDataToolModal({
payload: {},
onSaveCallback: (newExternalDataTool: ExternalDataTool) => {
onSaveCallback: (newExternalDataTool?: ExternalDataTool) => {
if (!newExternalDataTool)
return
setExternalDataToolsConfig([...externalDataToolsConfig, newExternalDataTool])
},
onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => {

View File

@ -109,7 +109,7 @@ const Debug: FC<IDebug> = ({
setIsShowFormattingChangeConfirm(true)
}, [formattingChanged])
const debugWithSingleModelRef = React.useRef<DebugWithSingleModelRefType | null>(null)
const debugWithSingleModelRef = React.useRef<DebugWithSingleModelRefType>(null!)
const handleClearConversation = () => {
debugWithSingleModelRef.current?.handleRestart()
}

View File

@ -76,7 +76,11 @@ const Tools = () => {
const handleOpenExternalDataToolModal = (payload: ExternalDataTool, index: number) => {
setShowExternalDataToolModal({
payload,
onSaveCallback: (externalDataTool: ExternalDataTool) => handleSaveExternalDataToolModal(externalDataTool, index),
onSaveCallback: (externalDataTool?: ExternalDataTool) => {
if (!externalDataTool)
return
handleSaveExternalDataToolModal(externalDataTool, index)
},
onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => handleValidateBeforeSaveExternalDataToolModal(newExternalDataTool, index),
})
}

View File

@ -84,7 +84,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
handleFile(droppedFile)
}, [droppedFile])
const onCreate: MouseEventHandler = async () => {
const onCreate = async (_e?: React.MouseEvent) => {
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
return
if (currentTab === CreateFromDSLModalTab.FROM_URL && !dslUrlValue)
@ -154,7 +154,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
useKeyPress(['meta.enter', 'ctrl.enter'], () => {
if (show && !isAppsFull && ((currentTab === CreateFromDSLModalTab.FROM_FILE && currentFile) || (currentTab === CreateFromDSLModalTab.FROM_URL && dslUrlValue)))
handleCreateApp()
handleCreateApp(undefined)
})
useKeyPress('esc', () => {

View File

@ -14,6 +14,7 @@ import timezone from 'dayjs/plugin/timezone'
import { createContext, useContext } from 'use-context-selector'
import { useShallow } from 'zustand/react/shallow'
import { useTranslation } from 'react-i18next'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import type { ChatItemInTree } from '../../base/chat/types'
import Indicator from '../../header/indicator'
import VarPanel from './var-panel'
@ -42,6 +43,10 @@ import cn from '@/utils/classnames'
import { noop } from 'lodash-es'
import PromptLogModal from '../../base/prompt-log-modal'
type AppStoreState = ReturnType<typeof useAppStore.getState>
type ConversationListItem = ChatConversationGeneralDetail | CompletionConversationGeneralDetail
type ConversationSelection = ConversationListItem | { id: string; isPlaceholder?: true }
dayjs.extend(utc)
dayjs.extend(timezone)
@ -201,7 +206,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
const { formatTime } = useTimestamp()
const { onClose, appDetail } = useContext(DrawerContext)
const { notify } = useContext(ToastContext)
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow(state => ({
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow((state: AppStoreState) => ({
currentLogItem: state.currentLogItem,
setCurrentLogItem: state.setCurrentLogItem,
showMessageLogModal: state.showMessageLogModal,
@ -893,20 +898,113 @@ const ChatConversationDetailComp: FC<{ appId?: string; conversationId?: string }
const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh }) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const conversationIdInUrl = searchParams.get('conversation_id') ?? undefined
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [showDrawer, setShowDrawer] = useState<boolean>(false) // Whether to display the chat details drawer
const [currentConversation, setCurrentConversation] = useState<ChatConversationGeneralDetail | CompletionConversationGeneralDetail | undefined>() // Currently selected conversation
const [currentConversation, setCurrentConversation] = useState<ConversationSelection | undefined>() // Currently selected conversation
const closingConversationIdRef = useRef<string | null>(null)
const pendingConversationIdRef = useRef<string | null>(null)
const pendingConversationCacheRef = useRef<ConversationSelection | undefined>(undefined)
const isChatMode = appDetail.mode !== AppModeEnum.COMPLETION // Whether the app is a chat app
const isChatflow = appDetail.mode === AppModeEnum.ADVANCED_CHAT // Whether the app is a chatflow app
const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore(useShallow(state => ({
const { setShowPromptLogModal, setShowAgentLogModal, setShowMessageLogModal } = useAppStore(useShallow((state: AppStoreState) => ({
setShowPromptLogModal: state.setShowPromptLogModal,
setShowAgentLogModal: state.setShowAgentLogModal,
setShowMessageLogModal: state.setShowMessageLogModal,
})))
const activeConversationId = conversationIdInUrl ?? pendingConversationIdRef.current ?? currentConversation?.id
const buildUrlWithConversation = useCallback((conversationId?: string) => {
const params = new URLSearchParams(searchParams.toString())
if (conversationId)
params.set('conversation_id', conversationId)
else
params.delete('conversation_id')
const queryString = params.toString()
return queryString ? `${pathname}?${queryString}` : pathname
}, [pathname, searchParams])
const handleRowClick = useCallback((log: ConversationListItem) => {
if (conversationIdInUrl === log.id) {
if (!showDrawer)
setShowDrawer(true)
if (!currentConversation || currentConversation.id !== log.id)
setCurrentConversation(log)
return
}
pendingConversationIdRef.current = log.id
pendingConversationCacheRef.current = log
if (!showDrawer)
setShowDrawer(true)
if (currentConversation?.id !== log.id)
setCurrentConversation(undefined)
router.push(buildUrlWithConversation(log.id), { scroll: false })
}, [buildUrlWithConversation, conversationIdInUrl, currentConversation, router, showDrawer])
const currentConversationId = currentConversation?.id
useEffect(() => {
if (!conversationIdInUrl) {
if (pendingConversationIdRef.current)
return
if (showDrawer || currentConversationId) {
setShowDrawer(false)
setCurrentConversation(undefined)
}
closingConversationIdRef.current = null
pendingConversationCacheRef.current = undefined
return
}
if (closingConversationIdRef.current === conversationIdInUrl)
return
if (pendingConversationIdRef.current === conversationIdInUrl)
pendingConversationIdRef.current = null
const matchedConversation = logs?.data?.find((item: ConversationListItem) => item.id === conversationIdInUrl)
const nextConversation: ConversationSelection = matchedConversation
?? pendingConversationCacheRef.current
?? { id: conversationIdInUrl, isPlaceholder: true }
if (!showDrawer)
setShowDrawer(true)
if (!currentConversation || currentConversation.id !== conversationIdInUrl || (matchedConversation && currentConversation !== matchedConversation))
setCurrentConversation(nextConversation)
if (pendingConversationCacheRef.current?.id === conversationIdInUrl || matchedConversation)
pendingConversationCacheRef.current = undefined
}, [conversationIdInUrl, currentConversation, isChatMode, logs?.data, showDrawer])
const onCloseDrawer = useCallback(() => {
onRefresh()
setShowDrawer(false)
setCurrentConversation(undefined)
setShowPromptLogModal(false)
setShowAgentLogModal(false)
setShowMessageLogModal(false)
pendingConversationIdRef.current = null
pendingConversationCacheRef.current = undefined
closingConversationIdRef.current = conversationIdInUrl ?? null
if (conversationIdInUrl)
router.replace(buildUrlWithConversation(), { scroll: false })
}, [buildUrlWithConversation, conversationIdInUrl, onRefresh, router, setShowAgentLogModal, setShowMessageLogModal, setShowPromptLogModal])
// Annotated data needs to be highlighted
const renderTdValue = (value: string | number | null, isEmptyStyle: boolean, isHighlight = false, annotation?: LogAnnotation) => {
return (
@ -925,15 +1023,6 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
)
}
const onCloseDrawer = () => {
onRefresh()
setShowDrawer(false)
setCurrentConversation(undefined)
setShowPromptLogModal(false)
setShowAgentLogModal(false)
setShowMessageLogModal(false)
}
if (!logs)
return <Loading />
@ -960,11 +1049,8 @@ const ConversationList: FC<IConversationList> = ({ logs, appDetail, onRefresh })
const rightValue = get(log, isChatMode ? 'message_count' : 'message.answer')
return <tr
key={log.id}
className={cn('cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover', currentConversation?.id !== log.id ? '' : 'bg-background-default-hover')}
onClick={() => {
setShowDrawer(true)
setCurrentConversation(log)
}}>
className={cn('cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover', activeConversationId !== log.id ? '' : 'bg-background-default-hover')}
onClick={() => handleRowClick(log)}>
<td className='h-4'>
{!log.read_at && (
<div className='flex items-center p-3 pr-0.5'>

View File

@ -3,7 +3,7 @@ import { RiAddLine, RiDeleteBinLine, RiEditLine, RiMore2Fill, RiSaveLine, RiShar
import ActionButton, { ActionButtonState } from '.'
const meta = {
title: 'Base/ActionButton',
title: 'Base/Button/ActionButton',
component: ActionButton,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,75 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useEffect } from 'react'
import type { ComponentProps } from 'react'
import AudioBtn from '.'
import { ensureMockAudioManager } from '../../../../.storybook/utils/audio-player-manager.mock'
ensureMockAudioManager()
const StoryWrapper = (props: ComponentProps<typeof AudioBtn>) => {
useEffect(() => {
ensureMockAudioManager()
}, [])
return (
<div className="flex items-center justify-center space-x-3">
<AudioBtn {...props} />
<span className="text-xs text-gray-500">Click to toggle playback</span>
</div>
)
}
const meta = {
title: 'Base/Button/AudioBtn',
component: AudioBtn,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Audio playback toggle that streams assistant responses. The story uses a mocked audio player so you can inspect loading and playback states without calling the real API.',
},
},
nextjs: {
appDirectory: true,
navigation: {
pathname: '/apps/demo-app/text-to-audio',
params: { appId: 'demo-app' },
},
},
},
argTypes: {
id: {
control: 'text',
description: 'Message identifier used to scope the audio stream.',
},
value: {
control: 'text',
description: 'Text content that would be converted to speech.',
},
voice: {
control: 'text',
description: 'Voice profile used for playback.',
},
isAudition: {
control: 'boolean',
description: 'Switches to the audition style with minimal padding.',
},
className: {
control: 'text',
description: 'Optional custom class for the wrapper.',
},
},
} satisfies Meta<typeof AudioBtn>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: args => <StoryWrapper {...args} />,
args: {
id: 'message-1',
value: 'This is an audio preview for the current assistant response.',
voice: 'alloy',
},
}

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import AutoHeightTextarea from '.'
const meta = {
title: 'Base/AutoHeightTextarea',
title: 'Base/Input/AutoHeightTextarea',
component: AutoHeightTextarea,
parameters: {
layout: 'centered',
@ -23,6 +23,10 @@ const meta = {
control: 'text',
description: 'Textarea value',
},
onChange: {
action: 'changed',
description: 'Change handler',
},
minHeight: {
control: 'number',
description: 'Minimum height in pixels',
@ -44,6 +48,11 @@ const meta = {
description: 'Wrapper CSS classes',
},
},
args: {
onChange: (e) => {
console.log('Text changed:', e.target.value)
},
},
} satisfies Meta<typeof AutoHeightTextarea>
export default meta

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import BlockInput from '.'
const meta = {
title: 'Base/BlockInput',
title: 'Base/Input/BlockInput',
component: BlockInput,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,52 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import AddButton from './add-button'
const meta = {
title: 'Base/Button/AddButton',
component: AddButton,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compact icon-only button used for inline “add” actions in lists, cards, and modals.',
},
},
},
tags: ['autodocs'],
argTypes: {
className: {
control: 'text',
description: 'Extra classes appended to the clickable container.',
},
onClick: {
control: false,
description: 'Triggered when the add button is pressed.',
},
},
args: {
onClick: () => console.log('Add button clicked'),
},
} satisfies Meta<typeof AddButton>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
className: 'bg-white/80 shadow-sm backdrop-blur-sm',
},
}
export const InToolbar: Story = {
render: args => (
<div className="flex items-center gap-2 rounded-lg border border-divider-subtle bg-components-panel-bg p-3">
<span className="text-xs text-text-tertiary">Attachments</span>
<div className="ml-auto flex items-center gap-2">
<AddButton {...args} />
</div>
</div>
),
args: {
className: 'border border-dashed border-primary-200',
},
}

View File

@ -4,7 +4,7 @@ import { RocketLaunchIcon } from '@heroicons/react/20/solid'
import { Button } from '.'
const meta = {
title: 'Base/Button',
title: 'Base/Button/Button',
component: Button,
parameters: {
layout: 'centered',

View File

@ -0,0 +1,57 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import SyncButton from './sync-button'
const meta = {
title: 'Base/Button/SyncButton',
component: SyncButton,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Icon-only refresh button that surfaces a tooltip and is used for manual sync actions across the UI.',
},
},
},
tags: ['autodocs'],
argTypes: {
className: {
control: 'text',
description: 'Additional classes appended to the clickable container.',
},
popupContent: {
control: 'text',
description: 'Tooltip text shown on hover.',
},
onClick: {
control: false,
description: 'Triggered when the sync button is pressed.',
},
},
args: {
popupContent: 'Sync now',
onClick: () => console.log('Sync button clicked'),
},
} satisfies Meta<typeof SyncButton>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
className: 'bg-white/80 shadow-sm backdrop-blur-sm',
},
}
export const InHeader: Story = {
render: args => (
<div className="flex items-center gap-2 rounded-lg border border-divider-subtle bg-components-panel-bg p-3">
<span className="text-xs text-text-tertiary">Logs</span>
<div className="ml-auto flex items-center gap-2">
<SyncButton {...args} />
</div>
</div>
),
args: {
popupContent: 'Refresh logs',
},
}

View File

@ -1,11 +1,12 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import type { ChatItem } from '../../types'
import { markdownContent } from './__mocks__/markdownContent'
import { markdownContentSVG } from './__mocks__/markdownContentSVG'
import Answer from '.'
const meta = {
title: 'Base/Chat Answer',
title: 'Base/Chat/Chat Answer',
component: Answer,
parameters: {
layout: 'fullscreen',
@ -33,7 +34,7 @@ const mockedBaseChatItem = {
} satisfies ChatItem
const mockedWorkflowProcess = {
status: 'succeeded',
status: WorkflowRunningStatus.Succeeded,
tracing: [],
}

View File

@ -5,7 +5,7 @@ import Question from './question'
import { User } from '@/app/components/base/icons/src/public/avatar'
const meta = {
title: 'Base/Chat Question',
title: 'Base/Chat/Chat Question',
component: Question,
parameters: {
layout: 'centered',

View File

@ -237,7 +237,7 @@ const ChatWrapper = () => {
return (
<Chat
appData={appData}
appData={appData || undefined}
config={appConfig}
chatList={messageList}
isResponding={respondingState}

View File

@ -13,7 +13,7 @@ const createToggleItem = <T extends { id: string; checked: boolean }>(
}
const meta = {
title: 'Base/Checkbox',
title: 'Base/Input/Checkbox',
component: Checkbox,
parameters: {
layout: 'centered',

View File

@ -4,7 +4,7 @@ import Confirm from '.'
import Button from '../button'
const meta = {
title: 'Base/Confirm',
title: 'Base/Dialog/Confirm',
component: Confirm,
parameters: {
layout: 'centered',
@ -62,6 +62,14 @@ const meta = {
description: 'Whether clicking mask closes dialog',
},
},
args: {
onConfirm: () => {
console.log('✅ User clicked confirm')
},
onCancel: () => {
console.log('❌ User clicked cancel')
},
},
} satisfies Meta<typeof Confirm>
export default meta
@ -99,6 +107,7 @@ export const WarningDialog: Story = {
type: 'warning',
title: 'Delete Confirmation',
content: 'Are you sure you want to delete this project? This action cannot be undone.',
isShow: false,
},
}
@ -109,6 +118,7 @@ export const InfoDialog: Story = {
type: 'info',
title: 'Notice',
content: 'Your changes have been saved. Do you want to proceed to the next step?',
isShow: false,
},
}
@ -121,6 +131,7 @@ export const CustomButtonText: Story = {
content: 'You have unsaved changes. Are you sure you want to exit?',
confirmText: 'Discard Changes',
cancelText: 'Continue Editing',
isShow: false,
},
}
@ -132,6 +143,7 @@ export const LoadingState: Story = {
title: 'Deleting...',
content: 'Please wait while we delete the file...',
isLoading: true,
isShow: false,
},
}
@ -143,6 +155,7 @@ export const DisabledState: Story = {
title: 'Verification Required',
content: 'Please complete email verification before proceeding.',
isDisabled: true,
isShow: false,
},
}
@ -155,6 +168,7 @@ export const AlertStyle: Story = {
content: 'Your settings have been updated!',
showCancel: false,
confirmText: 'Got it',
isShow: false,
},
}
@ -167,6 +181,7 @@ export const DangerousAction: Story = {
content: 'This action will permanently delete your account and all associated data, including: all projects and files, collaboration history, and personal settings. This action cannot be reversed!',
confirmText: 'Delete My Account',
cancelText: 'Keep My Account',
isShow: false,
},
}
@ -178,6 +193,7 @@ export const NotMaskClosable: Story = {
title: 'Important Action',
content: 'This action requires your explicit choice. Clicking outside will not close this dialog.',
maskClosable: false,
isShow: false,
},
}
@ -195,5 +211,6 @@ export const Playground: Story = {
showConfirm: true,
showCancel: true,
maskClosable: true,
isShow: false,
},
}

View File

@ -7,7 +7,7 @@ import Tooltip from '../tooltip'
export type IConfirm = {
className?: string
isShow: boolean
type?: 'info' | 'warning'
type?: 'info' | 'warning' | 'danger'
title: string
content?: React.ReactNode
confirmText?: string | null

View File

@ -0,0 +1,110 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useEffect, useState } from 'react'
import ContentDialog from '.'
type Props = React.ComponentProps<typeof ContentDialog>
const meta = {
title: 'Base/Dialog/ContentDialog',
component: ContentDialog,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Sliding panel overlay used in the app detail view. Includes dimmed backdrop and animated entrance/exit transitions.',
},
},
},
tags: ['autodocs'],
argTypes: {
className: {
control: 'text',
description: 'Additional classes applied to the sliding panel container.',
},
show: {
control: 'boolean',
description: 'Controls visibility of the dialog.',
},
onClose: {
control: false,
description: 'Invoked when the overlay/backdrop is clicked.',
},
},
args: {
show: false,
},
} satisfies Meta<typeof ContentDialog>
export default meta
type Story = StoryObj<typeof meta>
const DemoWrapper = (props: Props) => {
const [open, setOpen] = useState(props.show)
useEffect(() => {
setOpen(props.show)
}, [props.show])
return (
<div className="relative h-[480px] w-full overflow-hidden bg-gray-100">
<div className="flex h-full items-center justify-center">
<button
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Open dialog
</button>
</div>
<ContentDialog
{...props}
show={open}
onClose={() => {
props.onClose?.()
setOpen(false)
}}
>
<div className="flex h-full flex-col space-y-4 bg-white p-6">
<h2 className="text-lg font-semibold text-gray-900">Plan summary</h2>
<p className="text-sm text-gray-600">
Use this area to present rich content for the selected run, configuration details, or
any supporting context.
</p>
<div className="flex-1 overflow-y-auto rounded-md border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500">
Scrollable placeholder content. Add domain-specific information, activity logs, or
editors in the real application.
</div>
<div className="flex justify-end gap-2 pt-4">
<button
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => setOpen(false)}
>
Cancel
</button>
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700">
Apply changes
</button>
</div>
</div>
</ContentDialog>
</div>
)
}
export const Default: Story = {
render: args => <DemoWrapper {...args} />,
}
export const NarrowPanel: Story = {
render: args => <DemoWrapper {...args} />,
args: {
className: 'max-w-[420px]',
},
parameters: {
docs: {
description: {
story: 'Applies a custom width class to show the dialog as a narrower information panel.',
},
},
},
}

View File

@ -0,0 +1,151 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useEffect, useState } from 'react'
import Dialog from '.'
const meta = {
title: 'Base/Dialog/Dialog',
component: Dialog,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Modal dialog built on Headless UI. Provides animated overlay, title slot, and optional footer region.',
},
},
},
tags: ['autodocs'],
argTypes: {
className: {
control: 'text',
description: 'Additional classes applied to the panel.',
},
titleClassName: {
control: 'text',
description: 'Extra classes for the title element.',
},
bodyClassName: {
control: 'text',
description: 'Extra classes for the content area.',
},
footerClassName: {
control: 'text',
description: 'Extra classes for the footer container.',
},
title: {
control: 'text',
description: 'Dialog title.',
},
show: {
control: 'boolean',
description: 'Controls visibility of the dialog.',
},
onClose: {
control: false,
description: 'Called when the dialog backdrop or close handler fires.',
},
},
args: {
title: 'Manage API Keys',
show: false,
},
} satisfies Meta<typeof Dialog>
export default meta
type Story = StoryObj<typeof meta>
const DialogDemo = (props: React.ComponentProps<typeof Dialog>) => {
const [open, setOpen] = useState(props.show)
useEffect(() => {
setOpen(props.show)
}, [props.show])
return (
<div className="relative flex h-[480px] items-center justify-center bg-gray-100">
<button
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Show dialog
</button>
<Dialog
{...props}
show={open}
onClose={() => {
props.onClose?.()
setOpen(false)
}}
>
<div className="space-y-4 text-sm text-gray-600">
<p>
Centralize API key management for collaborators. You can revoke, rotate, or generate new keys directly from this dialog.
</p>
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500">
This placeholder area represents a form or table that would live inside the dialog body.
</div>
</div>
</Dialog>
</div>
)
}
export const Default: Story = {
render: args => <DialogDemo {...args} />,
args: {
footer: (
<>
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50">
Cancel
</button>
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700">
Save changes
</button>
</>
),
},
}
export const WithoutFooter: Story = {
render: args => <DialogDemo {...args} />,
args: {
footer: undefined,
title: 'Read-only summary',
},
parameters: {
docs: {
description: {
story: 'Demonstrates the dialog when no footer actions are provided.',
},
},
},
}
export const CustomStyling: Story = {
render: args => <DialogDemo {...args} />,
args: {
className: 'max-w-[560px] bg-white/95 backdrop-blur',
bodyClassName: 'bg-gray-50 rounded-xl p-5',
footerClassName: 'justify-between px-4 pb-4 pt-4',
titleClassName: 'text-lg text-primary-600',
footer: (
<>
<span className="text-xs text-gray-400">Last synced 2 minutes ago</span>
<div className="flex gap-2">
<button className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50">
Close
</button>
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700">
Refresh data
</button>
</div>
</>
),
},
parameters: {
docs: {
description: {
story: 'Applies custom classes to the panel, body, title, and footer to match different surfaces.',
},
},
},
}

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import { InputNumber } from '.'
const meta = {
title: 'Base/InputNumber',
title: 'Base/Input/InputNumber',
component: InputNumber,
parameters: {
layout: 'centered',
@ -49,6 +49,11 @@ const meta = {
description: 'Default value when undefined',
},
},
args: {
onChange: (value) => {
console.log('Value changed:', value)
},
},
} satisfies Meta<typeof InputNumber>
export default meta
@ -196,7 +201,8 @@ const SizeComparisonDemo = () => {
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Font size picker
const FontSizePickerDemo = () => {
@ -228,7 +234,8 @@ const FontSizePickerDemo = () => {
export const FontSizePicker: Story = {
render: () => <FontSizePickerDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Quantity selector
const QuantitySelectorDemo = () => {
@ -268,7 +275,8 @@ const QuantitySelectorDemo = () => {
export const QuantitySelector: Story = {
render: () => <QuantitySelectorDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Timer settings
const TimerSettingsDemo = () => {
@ -324,7 +332,8 @@ const TimerSettingsDemo = () => {
export const TimerSettings: Story = {
render: () => <TimerSettingsDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Animation settings
const AnimationSettingsDemo = () => {
@ -380,7 +389,8 @@ const AnimationSettingsDemo = () => {
export const AnimationSettings: Story = {
render: () => <AnimationSettingsDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Temperature control
const TemperatureControlDemo = () => {
@ -420,7 +430,8 @@ const TemperatureControlDemo = () => {
export const TemperatureControl: Story = {
render: () => <TemperatureControlDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
export const Playground: Story = {

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import Input from '.'
const meta = {
title: 'Base/Input',
title: 'Base/Input/Input',
component: Input,
parameters: {
layout: 'centered',

View File

@ -122,7 +122,7 @@ const Flowchart = (props: FlowchartProps) => {
const containerRef = useRef<HTMLDivElement>(null)
const chartId = useRef(`mermaid-chart-${Math.random().toString(36).slice(2, 11)}`).current
const [isLoading, setIsLoading] = useState(true)
const renderTimeoutRef = useRef<NodeJS.Timeout>()
const renderTimeoutRef = useRef<NodeJS.Timeout | undefined>(undefined)
const [errMsg, setErrMsg] = useState('')
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
@ -187,7 +187,7 @@ const Flowchart = (props: FlowchartProps) => {
}, [])
// Update theme when prop changes, but allow internal override.
const prevThemeRef = useRef<string>()
const prevThemeRef = useRef<string | undefined>(undefined)
useEffect(() => {
// Only react if the theme prop from the outside has actually changed.
if (props.theme && props.theme !== prevThemeRef.current) {

View File

@ -0,0 +1,125 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import ModalLikeWrap from '.'
const meta = {
title: 'Base/Dialog/ModalLikeWrap',
component: ModalLikeWrap,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Compact “modal-like” card used in wizards. Provides header actions, optional back slot, and confirm/cancel buttons.',
},
},
},
tags: ['autodocs'],
argTypes: {
title: {
control: 'text',
description: 'Header title text.',
},
className: {
control: 'text',
description: 'Additional classes on the wrapper.',
},
beforeHeader: {
control: false,
description: 'Slot rendered before the header (commonly a back link).',
},
hideCloseBtn: {
control: 'boolean',
description: 'Hides the top-right close icon when true.',
},
children: {
control: false,
},
onClose: {
control: false,
},
onConfirm: {
control: false,
},
},
args: {
title: 'Create dataset field',
hideCloseBtn: false,
onClose: () => console.log('close'),
onConfirm: () => console.log('confirm'),
},
} satisfies Meta<typeof ModalLikeWrap>
export default meta
type Story = StoryObj<typeof meta>
const BaseContent = () => (
<div className="space-y-3 text-sm text-gray-600">
<p>
Describe the new field your dataset should collect. Provide a clear label and optional helper text.
</p>
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500">
Form inputs would be placed here in the real flow.
</div>
</div>
)
export const Default: Story = {
render: args => (
<ModalLikeWrap {...args}>
<BaseContent />
</ModalLikeWrap>
),
}
export const WithBackLink: Story = {
render: args => (
<ModalLikeWrap
{...args}
hideCloseBtn
beforeHeader={(
<button
className="mb-1 flex items-center gap-1 text-xs font-medium uppercase text-text-accent"
onClick={() => console.log('back')}
>
<span className="bg-text-accent/10 inline-block h-4 w-4 rounded text-center text-[10px] leading-4 text-text-accent">{'<'}</span>
Back
</button>
)}
>
<BaseContent />
</ModalLikeWrap>
),
args: {
title: 'Select metadata type',
},
parameters: {
docs: {
description: {
story: 'Demonstrates feeding content into `beforeHeader` while hiding the close button.',
},
},
},
}
export const CustomWidth: Story = {
render: args => (
<ModalLikeWrap
{...args}
className="w-[420px]"
>
<BaseContent />
<div className="mt-4 rounded-md bg-blue-50 p-3 text-xs text-blue-600">
Tip: metadata keys may only include letters, numbers, and underscores.
</div>
</ModalLikeWrap>
),
args: {
title: 'Advanced configuration',
},
parameters: {
docs: {
description: {
story: 'Applies extra width and helper messaging to emulate configuration panels.',
},
},
},
}

View File

@ -0,0 +1,133 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useEffect, useState } from 'react'
import Modal from '.'
const meta = {
title: 'Base/Dialog/Modal',
component: Modal,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Lightweight modal wrapper with optional header/description, close icon, and high-priority stacking for dropdown overlays.',
},
},
},
tags: ['autodocs'],
argTypes: {
className: {
control: 'text',
description: 'Extra classes applied to the modal panel.',
},
wrapperClassName: {
control: 'text',
description: 'Additional wrapper classes for the dialog.',
},
isShow: {
control: 'boolean',
description: 'Controls whether the modal is visible.',
},
title: {
control: 'text',
description: 'Heading displayed at the top of the modal.',
},
description: {
control: 'text',
description: 'Secondary text beneath the title.',
},
closable: {
control: 'boolean',
description: 'Whether the close icon should be shown.',
},
overflowVisible: {
control: 'boolean',
description: 'Allows content to overflow the modal panel.',
},
highPriority: {
control: 'boolean',
description: 'Lifts the modal above other high z-index elements like dropdowns.',
},
onClose: {
control: false,
description: 'Callback invoked when the modal requests to close.',
},
},
args: {
isShow: false,
title: 'Create new API key',
description: 'Generate a scoped key for this workspace. You can revoke it at any time.',
closable: true,
},
} satisfies Meta<typeof Modal>
export default meta
type Story = StoryObj<typeof meta>
const ModalDemo = (props: React.ComponentProps<typeof Modal>) => {
const [open, setOpen] = useState(props.isShow)
useEffect(() => {
setOpen(props.isShow)
}, [props.isShow])
return (
<div className="relative flex h-[480px] items-center justify-center bg-gray-100">
<button
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Show modal
</button>
<Modal
{...props}
isShow={open}
onClose={() => {
props.onClose?.()
setOpen(false)
}}
>
<div className="mt-6 space-y-4 text-sm text-gray-600">
<p>
Provide a descriptive name for this key so collaborators know its purpose. Restrict usage with scopes to limit access.
</p>
<div className="rounded-lg border border-dashed border-gray-200 bg-gray-50 p-4 text-xs text-gray-500">
Form fields and validation messaging would appear here. This placeholder keeps the story lightweight.
</div>
</div>
<div className="mt-8 flex justify-end gap-3">
<button
className="rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50"
onClick={() => setOpen(false)}
>
Cancel
</button>
<button className="rounded-md bg-primary-600 px-3 py-1.5 text-sm text-white hover:bg-primary-700">
Create key
</button>
</div>
</Modal>
</div>
)
}
export const Default: Story = {
render: args => <ModalDemo {...args} />,
}
export const HighPriorityOverflow: Story = {
render: args => <ModalDemo {...args} />,
args: {
highPriority: true,
overflowVisible: true,
description: 'Demonstrates the modal configured to sit above dropdowns while letting the body content overflow.',
className: 'max-w-[540px]',
},
parameters: {
docs: {
description: {
story: 'Shows the modal with `highPriority` and `overflowVisible` enabled, useful when nested within complex surfaces.',
},
},
},
}

View File

@ -0,0 +1,216 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useEffect, useState } from 'react'
import Modal from './modal'
const meta = {
title: 'Base/Dialog/RichModal',
component: Modal,
parameters: {
layout: 'fullscreen',
docs: {
description: {
component: 'Full-featured modal with header, subtitle, customizable footer buttons, and optional extra action.',
},
},
},
tags: ['autodocs'],
argTypes: {
size: {
control: 'radio',
options: ['sm', 'md'],
description: 'Defines the panel width.',
},
title: {
control: 'text',
description: 'Primary heading text.',
},
subTitle: {
control: 'text',
description: 'Secondary text below the title.',
},
confirmButtonText: {
control: 'text',
description: 'Label for the confirm button.',
},
cancelButtonText: {
control: 'text',
description: 'Label for the cancel button.',
},
showExtraButton: {
control: 'boolean',
description: 'Whether to render the extra button.',
},
extraButtonText: {
control: 'text',
description: 'Label for the extra button.',
},
extraButtonVariant: {
control: 'select',
options: ['primary', 'warning', 'secondary', 'secondary-accent', 'ghost', 'ghost-accent', 'tertiary'],
description: 'Visual style for the extra button.',
},
disabled: {
control: 'boolean',
description: 'Disables footer actions when true.',
},
footerSlot: {
control: false,
},
bottomSlot: {
control: false,
},
onClose: {
control: false,
description: 'Handler fired when the close icon or backdrop is clicked.',
},
onConfirm: {
control: false,
description: 'Handler fired when confirm is pressed.',
},
onCancel: {
control: false,
description: 'Handler fired when cancel is pressed.',
},
onExtraButtonClick: {
control: false,
description: 'Handler fired when the extra button is pressed.',
},
children: {
control: false,
},
},
args: {
size: 'sm',
title: 'Delete integration',
subTitle: 'Disabling this integration will revoke access tokens and webhooks.',
confirmButtonText: 'Delete integration',
cancelButtonText: 'Cancel',
showExtraButton: false,
extraButtonText: 'Disable temporarily',
extraButtonVariant: 'warning',
disabled: false,
onClose: () => console.log('Modal closed'),
onConfirm: () => console.log('Confirm pressed'),
onCancel: () => console.log('Cancel pressed'),
onExtraButtonClick: () => console.log('Extra button pressed'),
},
} satisfies Meta<typeof Modal>
export default meta
type Story = StoryObj<typeof meta>
type ModalProps = React.ComponentProps<typeof Modal>
const ModalDemo = (props: ModalProps) => {
const [open, setOpen] = useState(false)
useEffect(() => {
if (props.disabled && open)
setOpen(false)
}, [props.disabled, open])
const {
onClose,
onConfirm,
onCancel,
onExtraButtonClick,
children,
...rest
} = props
const handleClose = () => {
onClose?.()
setOpen(false)
}
const handleConfirm = () => {
onConfirm?.()
setOpen(false)
}
const handleCancel = () => {
onCancel?.()
setOpen(false)
}
const handleExtra = () => {
onExtraButtonClick?.()
}
return (
<div className="relative flex h-[480px] items-center justify-center bg-gray-100">
<button
className="rounded-md bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-primary-700"
onClick={() => setOpen(true)}
>
Show rich modal
</button>
{open && (
<Modal
{...rest}
onClose={handleClose}
onConfirm={handleConfirm}
onCancel={handleCancel}
onExtraButtonClick={handleExtra}
children={children ?? (
<div className="space-y-4 text-sm text-gray-600">
<p>
Removing integrations immediately stops workflow automations related to this connection.
Make sure no scheduled jobs depend on this integration before proceeding.
</p>
<ul className="list-disc space-y-1 pl-4 text-xs text-gray-500">
<li>All API credentials issued by this integration will be revoked.</li>
<li>Historical logs remain accessible for auditing.</li>
<li>You can re-enable the integration later with fresh credentials.</li>
</ul>
</div>
)}
/>
)}
</div>
)
}
export const Default: Story = {
render: args => <ModalDemo {...args} />,
}
export const WithExtraAction: Story = {
render: args => <ModalDemo {...args} />,
args: {
showExtraButton: true,
extraButtonVariant: 'secondary',
extraButtonText: 'Disable only',
footerSlot: (
<span className="text-xs text-gray-400">Last synced 5 minutes ago</span>
),
},
parameters: {
docs: {
description: {
story: 'Illustrates the optional extra button and footer slot for advanced workflows.',
},
},
},
}
export const MediumSized: Story = {
render: args => <ModalDemo {...args} />,
args: {
size: 'md',
subTitle: 'Use the larger width to surface forms with more fields or supporting descriptions.',
bottomSlot: (
<div className="border-t border-divider-subtle bg-components-panel-bg px-6 py-4 text-xs text-gray-500">
Need finer control? Configure automation rules in the integration settings page.
</div>
),
},
parameters: {
docs: {
description: {
story: 'Shows the medium sized panel and a populated `bottomSlot` for supplemental messaging.',
},
},
},
}

View File

@ -0,0 +1,67 @@
import type { Meta, StoryObj } from '@storybook/nextjs'
import { useEffect } from 'react'
import type { ComponentProps } from 'react'
import AudioBtn from '.'
import { ensureMockAudioManager } from '../../../../.storybook/utils/audio-player-manager.mock'
ensureMockAudioManager()
const StoryWrapper = (props: ComponentProps<typeof AudioBtn>) => {
useEffect(() => {
ensureMockAudioManager()
}, [])
return (
<div className="flex items-center justify-center space-x-3">
<AudioBtn {...props} />
<span className="text-xs text-gray-500">Audio toggle using ActionButton styling</span>
</div>
)
}
const meta = {
title: 'Base/Button/NewAudioButton',
component: AudioBtn,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Updated audio playback trigger styled with `ActionButton`. Behaves like the legacy audio button but adopts the new button design system.',
},
},
nextjs: {
appDirectory: true,
navigation: {
pathname: '/apps/demo-app/text-to-audio',
params: { appId: 'demo-app' },
},
},
},
argTypes: {
id: {
control: 'text',
description: 'Message identifier used by the audio request.',
},
value: {
control: 'text',
description: 'Prompt or response text that will be converted to speech.',
},
voice: {
control: 'text',
description: 'Voice profile for the generated speech.',
},
},
} satisfies Meta<typeof AudioBtn>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: args => <StoryWrapper {...args} />,
args: {
id: 'message-1',
value: 'Listen to the latest assistant message.',
voice: 'alloy',
},
}

View File

@ -1,5 +1,11 @@
import type { ButtonHTMLAttributes } from 'react'
type ElementProps = {
className?: string
children?: React.ReactNode
[key: string]: unknown
}
type IBasePaginationProps = {
currentPage: number
setCurrentPage: (page: number) => void
@ -31,7 +37,7 @@ type IPagination = IUsePagination & {
}
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
as?: React.ReactNode
as?: React.ReactElement<ElementProps>
children?: string | React.ReactNode
className?: string
dataTestId?: string
@ -39,9 +45,9 @@ type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
type PageButtonProps = ButtonProps & {
/**
* Provide a custom ReactNode (e.g. Next/Link)
* Provide a custom ReactElement (e.g. Next/Link)
*/
as?: React.ReactNode
as?: React.ReactElement<ElementProps>
activeClassName?: string
inactiveClassName?: string
dataTestIdActive?: string

View File

@ -25,7 +25,7 @@ const PromptEditorMock = ({ value, onChange, placeholder, editable, compact, cla
}
const meta = {
title: 'Base/PromptEditor',
title: 'Base/Input/PromptEditor',
component: PromptEditorMock,
parameters: {
layout: 'centered',

View File

@ -4,7 +4,7 @@ import { RiCloudLine, RiCpuLine, RiDatabase2Line, RiLightbulbLine, RiRocketLine,
import RadioCard from '.'
const meta = {
title: 'Base/RadioCard',
title: 'Base/Input/RadioCard',
component: RadioCard,
parameters: {
layout: 'centered',
@ -138,7 +138,8 @@ const WithConfigurationDemo = () => {
export const WithConfiguration: Story = {
render: () => <WithConfigurationDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Multiple cards selection
const MultipleCardsDemo = () => {
@ -190,7 +191,8 @@ const MultipleCardsDemo = () => {
export const MultipleCards: Story = {
render: () => <MultipleCardsDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Cloud provider selection
const CloudProviderSelectionDemo = () => {
@ -247,7 +249,8 @@ const CloudProviderSelectionDemo = () => {
export const CloudProviderSelection: Story = {
render: () => <CloudProviderSelectionDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Deployment strategy
const DeploymentStrategyDemo = () => {
@ -313,7 +316,8 @@ const DeploymentStrategyDemo = () => {
export const DeploymentStrategy: Story = {
render: () => <DeploymentStrategyDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Storage options
const StorageOptionsDemo = () => {
@ -388,7 +392,8 @@ const StorageOptionsDemo = () => {
export const StorageOptions: Story = {
render: () => <StorageOptionsDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - API authentication method
const APIAuthMethodDemo = () => {
@ -458,7 +463,8 @@ const APIAuthMethodDemo = () => {
export const APIAuthMethod: Story = {
render: () => <APIAuthMethodDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
const PlaygroundDemo = () => {
@ -501,4 +507,5 @@ const PlaygroundDemo = () => {
export const Playground: Story = {
render: () => <PlaygroundDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import Radio from '.'
const meta = {
title: 'Base/Radio',
title: 'Base/Input/Radio',
component: Radio,
parameters: {
layout: 'centered',

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import SearchInput from '.'
const meta = {
title: 'Base/SearchInput',
title: 'Base/Input/SearchInput',
component: SearchInput,
parameters: {
layout: 'centered',
@ -19,6 +19,10 @@ const meta = {
control: 'text',
description: 'Search input value',
},
onChange: {
action: 'changed',
description: 'Change handler',
},
placeholder: {
control: 'text',
description: 'Placeholder text',
@ -32,6 +36,11 @@ const meta = {
description: 'Additional CSS classes',
},
},
args: {
onChange: (v) => {
console.log('Search value changed:', v)
},
},
} satisfies Meta<typeof SearchInput>
export default meta
@ -66,6 +75,10 @@ export const Default: Story = {
args: {
placeholder: 'Search...',
white: false,
value: '',
onChange: (v) => {
console.log('Search value changed:', v)
},
},
}
@ -75,6 +88,7 @@ export const WhiteBackground: Story = {
args: {
placeholder: 'Search...',
white: true,
value: '',
},
}
@ -94,6 +108,7 @@ export const CustomPlaceholder: Story = {
args: {
placeholder: 'Search documents, files, and more...',
white: false,
value: '',
},
}
@ -156,7 +171,8 @@ const UserListSearchDemo = () => {
export const UserListSearch: Story = {
render: () => <UserListSearchDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Product search
const ProductSearchDemo = () => {
@ -209,7 +225,8 @@ const ProductSearchDemo = () => {
export const ProductSearch: Story = {
render: () => <ProductSearchDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Documentation search
const DocumentationSearchDemo = () => {
@ -271,7 +288,8 @@ const DocumentationSearchDemo = () => {
export const DocumentationSearch: Story = {
render: () => <DocumentationSearchDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Command palette
const CommandPaletteDemo = () => {
@ -330,7 +348,8 @@ const CommandPaletteDemo = () => {
export const CommandPalette: Story = {
render: () => <CommandPaletteDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Live search with results count
const LiveSearchWithCountDemo = () => {
@ -384,7 +403,8 @@ const LiveSearchWithCountDemo = () => {
export const LiveSearchWithCount: Story = {
render: () => <LiveSearchWithCountDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Size variations
const SizeVariationsDemo = () => {
@ -422,7 +442,8 @@ const SizeVariationsDemo = () => {
export const SizeVariations: Story = {
render: () => <SizeVariationsDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
export const Playground: Story = {

View File

@ -4,7 +4,7 @@ import Select, { PortalSelect, SimpleSelect } from '.'
import type { Item } from '.'
const meta = {
title: 'Base/Select',
title: 'Base/Input/Select',
component: SimpleSelect,
parameters: {
layout: 'centered',
@ -33,6 +33,11 @@ const meta = {
description: 'Hide check icon on selected item',
},
},
args: {
onSelect: (item) => {
console.log('Selected:', item)
},
},
} satisfies Meta<typeof SimpleSelect>
export default meta
@ -87,6 +92,7 @@ export const Default: Story = {
args: {
placeholder: 'Select a fruit...',
defaultValue: 'apple',
items: [],
},
}
@ -96,6 +102,7 @@ export const WithPlaceholder: Story = {
args: {
placeholder: 'Choose an option...',
defaultValue: '',
items: [],
},
}
@ -106,6 +113,7 @@ export const Disabled: Story = {
placeholder: 'Select a fruit...',
defaultValue: 'banana',
disabled: true,
items: [],
},
}
@ -116,6 +124,7 @@ export const NotClearable: Story = {
placeholder: 'Select a fruit...',
defaultValue: 'cherry',
notClearable: true,
items: [],
},
}
@ -126,6 +135,7 @@ export const HideChecked: Story = {
placeholder: 'Select a fruit...',
defaultValue: 'apple',
hideChecked: true,
items: [],
},
}
@ -153,7 +163,8 @@ const WithSearchDemo = () => {
export const WithSearch: Story = {
render: () => <WithSearchDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// PortalSelect
const PortalSelectVariantDemo = () => {
@ -179,7 +190,8 @@ const PortalSelectVariantDemo = () => {
export const PortalSelectVariant: Story = {
render: () => <PortalSelectVariantDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Custom render option
const CustomRenderOptionDemo = () => {
@ -215,7 +227,8 @@ const CustomRenderOptionDemo = () => {
export const CustomRenderOption: Story = {
render: () => <CustomRenderOptionDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Loading state
export const LoadingState: Story = {
@ -232,7 +245,8 @@ export const LoadingState: Story = {
</div>
)
},
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Form field
const FormFieldDemo = () => {
@ -297,7 +311,8 @@ const FormFieldDemo = () => {
export const FormField: Story = {
render: () => <FormFieldDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Filter selector
const FilterSelectorDemo = () => {
@ -359,7 +374,8 @@ const FilterSelectorDemo = () => {
export const FilterSelector: Story = {
render: () => <FilterSelectorDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Version selector with badge
const VersionSelectorDemo = () => {
@ -398,7 +414,8 @@ const VersionSelectorDemo = () => {
export const VersionSelector: Story = {
render: () => <VersionSelectorDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Settings dropdown
const SettingsDropdownDemo = () => {
@ -447,7 +464,8 @@ const SettingsDropdownDemo = () => {
export const SettingsDropdown: Story = {
render: () => <SettingsDropdownDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Comparison of variants
const VariantComparisonDemo = () => {
@ -504,7 +522,8 @@ const VariantComparisonDemo = () => {
export const VariantComparison: Story = {
render: () => <VariantComparisonDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
const PlaygroundDemo = () => {
@ -524,4 +543,5 @@ const PlaygroundDemo = () => {
export const Playground: Story = {
render: () => <PlaygroundDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import Slider from '.'
const meta = {
title: 'Base/Slider',
title: 'Base/Input/Slider',
component: Slider,
parameters: {
layout: 'centered',
@ -36,6 +36,11 @@ const meta = {
description: 'Disabled state',
},
},
args: {
onChange: (value) => {
console.log('Slider value:', value)
},
},
} satisfies Meta<typeof Slider>
export default meta
@ -157,7 +162,8 @@ const VolumeControlDemo = () => {
export const VolumeControl: Story = {
render: () => <VolumeControlDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Brightness control
const BrightnessControlDemo = () => {
@ -187,7 +193,8 @@ const BrightnessControlDemo = () => {
export const BrightnessControl: Story = {
render: () => <BrightnessControlDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Price range filter
const PriceRangeFilterDemo = () => {
@ -239,7 +246,8 @@ const PriceRangeFilterDemo = () => {
export const PriceRangeFilter: Story = {
render: () => <PriceRangeFilterDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Temperature selector
const TemperatureSelectorDemo = () => {
@ -279,7 +287,8 @@ const TemperatureSelectorDemo = () => {
export const TemperatureSelector: Story = {
render: () => <TemperatureSelectorDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Progress/completion slider
const ProgressSliderDemo = () => {
@ -325,7 +334,8 @@ const ProgressSliderDemo = () => {
export const ProgressSlider: Story = {
render: () => <ProgressSliderDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Zoom control
const ZoomControlDemo = () => {
@ -371,7 +381,8 @@ const ZoomControlDemo = () => {
export const ZoomControl: Story = {
render: () => <ZoomControlDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - AI model parameters
const AIModelParametersDemo = () => {
@ -445,7 +456,8 @@ const AIModelParametersDemo = () => {
export const AIModelParameters: Story = {
render: () => <AIModelParametersDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Image quality selector
const ImageQualitySelectorDemo = () => {
@ -488,7 +500,8 @@ const ImageQualitySelectorDemo = () => {
export const ImageQualitySelector: Story = {
render: () => <ImageQualitySelectorDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Multiple sliders
const MultipleSlidersDemo = () => {
@ -545,7 +558,8 @@ const MultipleSlidersDemo = () => {
export const MultipleSliders: Story = {
render: () => <MultipleSlidersDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
export const Playground: Story = {

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import Switch from '.'
const meta = {
title: 'Base/Switch',
title: 'Base/Input/Switch',
component: Switch,
parameters: {
layout: 'centered',

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import TagInput from '.'
const meta = {
title: 'Base/TagInput',
title: 'Base/Input/TagInput',
component: TagInput,
parameters: {
layout: 'centered',
@ -19,6 +19,10 @@ const meta = {
control: 'object',
description: 'Array of tag strings',
},
onChange: {
action: 'changed',
description: 'Change handler',
},
disableAdd: {
control: 'boolean',
description: 'Disable adding new tags',
@ -41,6 +45,11 @@ const meta = {
description: 'Require non-empty tags',
},
},
args: {
onChange: (items) => {
console.log('Tags updated:', items)
},
},
} satisfies Meta<typeof TagInput>
export default meta
@ -155,7 +164,8 @@ const SkillTagsDemo = () => {
export const SkillTags: Story = {
render: () => <SkillTagsDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Email tags
const EmailTagsDemo = () => {
@ -192,7 +202,8 @@ const EmailTagsDemo = () => {
export const EmailTags: Story = {
render: () => <EmailTagsDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Search filters
const SearchFiltersDemo = () => {
@ -246,7 +257,8 @@ const SearchFiltersDemo = () => {
export const SearchFilters: Story = {
render: () => <SearchFiltersDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Product categories
const ProductCategoriesDemo = () => {
@ -292,7 +304,8 @@ const ProductCategoriesDemo = () => {
export const ProductCategories: Story = {
render: () => <ProductCategoriesDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Keyword extraction
const KeywordExtractionDemo = () => {
@ -328,7 +341,8 @@ const KeywordExtractionDemo = () => {
export const KeywordExtraction: Story = {
render: () => <KeywordExtractionDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Tags with suggestions
const TagsWithSuggestionsDemo = () => {
@ -371,7 +385,8 @@ const TagsWithSuggestionsDemo = () => {
export const TagsWithSuggestions: Story = {
render: () => <TagsWithSuggestionsDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Stop sequences (Tab mode)
const StopSequencesDemo = () => {
@ -425,7 +440,8 @@ const StopSequencesDemo = () => {
export const StopSequences: Story = {
render: () => <StopSequencesDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Multi-language tags
const MultiLanguageTagsDemo = () => {
@ -461,7 +477,8 @@ const MultiLanguageTagsDemo = () => {
export const MultiLanguageTags: Story = {
render: () => <MultiLanguageTagsDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Validation showcase
const ValidationShowcaseDemo = () => {
@ -500,7 +517,8 @@ const ValidationShowcaseDemo = () => {
export const ValidationShowcase: Story = {
render: () => <ValidationShowcaseDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
export const Playground: Story = {

View File

@ -3,7 +3,7 @@ import { useState } from 'react'
import Textarea from '.'
const meta = {
title: 'Base/Textarea',
title: 'Base/Input/Textarea',
component: Textarea,
parameters: {
layout: 'centered',
@ -76,6 +76,7 @@ export const Default: Story = {
size: 'regular',
placeholder: 'Enter text...',
rows: 4,
value: '',
},
}
@ -86,6 +87,7 @@ export const SmallSize: Story = {
size: 'small',
placeholder: 'Small textarea...',
rows: 3,
value: '',
},
}
@ -96,6 +98,7 @@ export const LargeSize: Story = {
size: 'large',
placeholder: 'Large textarea...',
rows: 5,
value: '',
},
}
@ -175,7 +178,8 @@ const SizeComparisonDemo = () => {
export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// State comparison
const StateComparisonDemo = () => {
@ -216,7 +220,8 @@ const StateComparisonDemo = () => {
export const StateComparison: Story = {
render: () => <StateComparisonDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Comment form
const CommentFormDemo = () => {
@ -250,7 +255,8 @@ const CommentFormDemo = () => {
export const CommentForm: Story = {
render: () => <CommentFormDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Feedback form
const FeedbackFormDemo = () => {
@ -291,7 +297,8 @@ const FeedbackFormDemo = () => {
export const FeedbackForm: Story = {
render: () => <FeedbackFormDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Code snippet
const CodeSnippetDemo = () => {
@ -322,7 +329,8 @@ const CodeSnippetDemo = () => {
export const CodeSnippet: Story = {
render: () => <CodeSnippetDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Message composer
const MessageComposerDemo = () => {
@ -372,7 +380,8 @@ const MessageComposerDemo = () => {
export const MessageComposer: Story = {
render: () => <MessageComposerDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Bio editor
const BioEditorDemo = () => {
@ -408,7 +417,8 @@ const BioEditorDemo = () => {
export const BioEditor: Story = {
render: () => <BioEditorDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - JSON editor
const JSONEditorDemo = () => {
@ -472,7 +482,8 @@ const JSONEditorDemo = () => {
export const JSONEditor: Story = {
render: () => <JSONEditorDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Real-world example - Task description
const TaskDescriptionDemo = () => {
@ -520,7 +531,8 @@ const TaskDescriptionDemo = () => {
export const TaskDescription: Story = {
render: () => <TaskDescriptionDemo />,
}
parameters: { controls: { disable: true } },
} as unknown as Story
// Interactive playground
export const Playground: Story = {
@ -531,5 +543,6 @@ export const Playground: Story = {
rows: 4,
disabled: false,
destructive: false,
value: '',
},
}

View File

@ -29,7 +29,7 @@ const VoiceInputMock = ({ onConverted, onCancel }: any) => {
<div className="absolute inset-[1.5px] flex items-center overflow-hidden rounded-[10.5px] bg-primary-25 py-[14px] pl-[14.5px] pr-[6.5px]">
{/* Waveform visualization placeholder */}
<div className="absolute bottom-0 left-0 flex h-4 w-full items-end gap-[3px] px-2">
{new Array(40).fill().map((_, i) => (
{new Array(40).fill(0).map((_, i) => (
<div
key={i}
className="w-[2px] rounded-t bg-blue-200"
@ -81,7 +81,7 @@ const VoiceInputMock = ({ onConverted, onCancel }: any) => {
}
const meta = {
title: 'Base/VoiceInput',
title: 'Base/Input/VoiceInput',
component: VoiceInputMock,
parameters: {
layout: 'centered',

View File

@ -63,7 +63,7 @@ const ValidatedUserCard = withValidation(UserCard, userSchema)
const ValidatedProductCard = withValidation(ProductCard, productSchema)
const meta = {
title: 'Base/WithInputValidation',
title: 'Base/Input/WithInputValidation',
parameters: {
layout: 'centered',
docs: {

View File

@ -10,11 +10,21 @@ const Zendesk = () => {
const nonce = process.env.NODE_ENV === 'production' ? (headers() as unknown as UnsafeUnwrappedHeaders).get('x-nonce') ?? '' : ''
return (
<Script
nonce={nonce ?? undefined}
id="ze-snippet"
src={`https://static.zdassets.com/ekr/snippet.js?key=${ZENDESK_WIDGET_KEY}`}
/>
<>
<Script
nonce={nonce ?? undefined}
id="ze-snippet"
src={`https://static.zdassets.com/ekr/snippet.js?key=${ZENDESK_WIDGET_KEY}`}
/>
<Script nonce={nonce ?? undefined} id="ze-init">{`
(function () {
window.addEventListener('load', function () {
if (window.zE)
window.zE('messenger', 'hide')
})
})()
`}</Script>
</>
)
}

View File

@ -21,3 +21,13 @@ export const setZendeskConversationFields = (fields: ConversationField[], callba
if (!IS_CE_EDITION && window.zE)
window.zE('messenger:set', 'conversationFields', fields, callback)
}
export const setZendeskWidgetVisibility = (visible: boolean) => {
if (!IS_CE_EDITION && window.zE)
window.zE('messenger', visible ? 'show' : 'hide')
}
export const toggleZendeskWindow = (open: boolean) => {
if (!IS_CE_EDITION && window.zE)
window.zE('messenger', open ? 'open' : 'close')
}

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