mirror of https://github.com/langgenius/dify.git
Merge remote-tracking branch 'origin/main' into feat/trigger
This commit is contained in:
commit
863b4f8fe9
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@ class AgentNodeData(BaseNodeData):
|
|||
|
||||
|
||||
class ParamsAutoGenerated(IntEnum):
|
||||
CLOSE = auto()
|
||||
OPEN = auto()
|
||||
CLOSE = 0
|
||||
OPEN = 1
|
||||
|
||||
|
||||
class AgentOldVersionModelFeatures(StrEnum):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ###
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.9.1"
|
||||
version = "1.9.2"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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": "🔧"}',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1305,7 +1305,7 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.9.1"
|
||||
version = "1.9.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "apscheduler" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# -----------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')!}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ const ChatWrapper = () => {
|
|||
|
||||
return (
|
||||
<Chat
|
||||
appData={appData}
|
||||
appData={appData || undefined}
|
||||
config={appConfig}
|
||||
chatList={messageList}
|
||||
isResponding={respondingState}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -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',
|
||||
},
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue