Compare commits

...
Author SHA1 Message Date
yyh 3ca098eaa5 migrate 2026-01-22 22:34:07 +08:00
yyh 03faf8e91b Merge remote-tracking branch 'origin/main' into refactor/local-storage
# Conflicts:
#	web/eslint-suppressions.json
2026-01-22 21:45:47 +08:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Claude Sonnet 4.5yyh
356a156f36 chore(i18n): sync translations with en-US (#31413)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-01-22 20:56:19 +08:00
lifandGitHub e2d7fe9c72 fix(web): use Array.from() for FileList to fix tsc type errors (#31398) 2026-01-22 19:51:24 +08:00
b9f718005c feat: frontend part of support try apps (#31287)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-01-22 18:16:37 +08:00
-LAN-andGitHub c575c34ca6 refactor: Move workflow node factory to app workflow (#31385)
Signed-off-by: -LAN- <laipz8200@outlook.com>
2026-01-22 18:08:21 +08:00
wangxiaoleiandGitHub a112caf5ec fix: use thread local isolation the context (#31410) 2026-01-22 18:02:54 +08:00
lifandGitHub 510a02286f fix(web): disable single tilde strikethrough in markdown rendering (#31400)
Signed-off-by: majiayu000 <1835304752@qq.com>
2026-01-22 16:33:17 +08:00
zyssyz123GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>hj24
515002a8ba feat: app trial (#26281)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: hj24 <mambahj24@gmail.com>
2026-01-22 15:42:54 +08:00
136618b567 fix: more doc link fix (#31395)
Co-authored-by: Riskey <36894937+RiskeyL@users.noreply.github.com>
2026-01-22 15:18:42 +08:00
036f6ef5ab fix: following docs link fix (#31390)
Co-authored-by: Riskey <36894937+RiskeyL@users.noreply.github.com>
2026-01-22 15:02:37 +08:00
zejiewangGitHubwangzejiegemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
811e43d0d4 fix: non-auto variable type params of agent node tool are not correctly parsed (#31128)
Co-authored-by: wangzejie <wangzejie@meicai.cn>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-01-22 14:43:21 +08:00
wangxiaoleiandGitHub 4e7c1f4f44 fix: fix visibilityState event handle (#31354) 2026-01-22 11:13:14 +08:00
wangxiaoleiandGitHub 211c57f7b6 fix: remove _try_resolve_user_from_request (#31360) 2026-01-21 21:19:11 +08:00
Stephen ZhouandGitHub 524ce14a68 test: enhance HitTestingPage tests with additional coverage for rende… (#31355) 2026-01-21 19:07:20 +08:00
Stephen ZhouandGitHub 1813b65acb chore: revert jsdom update (#31353) 2026-01-21 18:30:04 +08:00
-LAN-andGitHub 6452c5a7ac refactor(db): enforce non-null message annotation questions (#27915) 2026-01-21 17:54:59 +08:00
1d778d532a refactor(web): useClipboard hook to reduce duplication (#31308)
Signed-off-by: SherlockShemol <shemol@163.com>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-01-21 17:33:39 +08:00
Stephen ZhouandGitHub aa68966b55 test: fix test in #30849 (#31350) 2026-01-21 16:46:42 +08:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>Claude Sonnet 4.5yyh
117b6c65e4 chore(i18n): sync translations with en-US (#31342)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-01-21 16:35:12 +08:00
061feebd87 fix: check and update doc links (#30849)
Co-authored-by: Riskey <36894937+RiskeyL@users.noreply.github.com>
2026-01-21 16:31:48 +08:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
146ee4d3e9 chore(i18n): sync translations with en-US (#31332)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-01-21 16:15:58 +08:00
ed0e068a47 fix(i18n): update model provider tip to only mention OpenAI in English, Japanese, and Simplified Chinese translations (#31339)
Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
2026-01-21 15:47:49 +08:00
yyh 1cf3e599df fix: remove panelWidth prop in panel slice, use default value 420 in layout slice(single source) 2026-01-18 16:33:59 +08:00
yyh a85946d3e7 refactor(web): remove redundant nullish coalescing from storage calls
Function overloads in storage utility already guarantee non-null returns
when defaultValue is provided, making ?? fallbacks unnecessary.
2026-01-18 16:21:29 +08:00
yyh 9e4d3c75ae fix 2026-01-18 16:15:30 +08:00
yyh 70bea85624 refactor(web): improve storage utility type safety with function overloads
Add function overloads to getNumber and getBoolean so they return
non-nullable types when a defaultValue is provided. This eliminates
the need for non-null assertions at call sites.

- Remove unused persist-config.ts (zustand adapter not needed)
- Remove ! assertions from layout-slice.ts
2026-01-18 16:10:04 +08:00
yyh b75b7d6c61 fix: unused 2026-01-18 16:05:10 +08:00
yyh e819b804ba refactor(web): add SSR-safe localStorage utility and ESLint rules
Introduce centralized storage utilities to address SSR issues with direct
localStorage access in zustand slices and components. This adds ESLint
rules to prevent future regressions while preserving existing usages
via bulk suppressions.

- Add config/storage-keys.ts for centralized storage key definitions
- Add utils/storage.ts with SSR-safe get/set/remove operations
- Add workflow/store/persist-config.ts for zustand storage adapter
- Add no-restricted-globals and no-restricted-properties ESLint rules
- Migrate workflow slices and related components to use new utilities
2026-01-18 16:01:04 +08:00
369 changed files with 7140 additions and 1556 deletions
+1 -1
View File
@@ -125,7 +125,7 @@ jobs:
- name: Web type check
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web
run: pnpm run type-check:tsgo
run: pnpm run type-check
- name: Web dead code check
if: steps.changed-files.outputs.any_changed == 'true'
+249 -1
View File
@@ -27,7 +27,9 @@ ignore_imports =
core.workflow.nodes.iteration.iteration_node -> core.workflow.graph_events
core.workflow.nodes.loop.loop_node -> core.workflow.graph_events
core.workflow.nodes.node_factory -> core.workflow.graph
core.workflow.nodes.iteration.iteration_node -> core.app.workflow.node_factory
core.workflow.nodes.loop.loop_node -> core.app.workflow.node_factory
core.workflow.nodes.iteration.iteration_node -> core.workflow.graph_engine
core.workflow.nodes.iteration.iteration_node -> core.workflow.graph
core.workflow.nodes.iteration.iteration_node -> core.workflow.graph_engine.command_channels
@@ -57,6 +59,252 @@ ignore_imports =
core.workflow.graph_engine.manager -> extensions.ext_redis
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> extensions.ext_redis
[importlinter:contract:workflow-external-imports]
name = Workflow External Imports
type = forbidden
source_modules =
core.workflow
forbidden_modules =
configs
controllers
extensions
models
services
tasks
core.agent
core.app
core.base
core.callback_handler
core.datasource
core.db
core.entities
core.errors
core.extension
core.external_data_tool
core.file
core.helper
core.hosting_configuration
core.indexing_runner
core.llm_generator
core.logging
core.mcp
core.memory
core.model_manager
core.moderation
core.ops
core.plugin
core.prompt
core.provider_manager
core.rag
core.repositories
core.schemas
core.tools
core.trigger
core.variables
ignore_imports =
core.workflow.nodes.loop.loop_node -> core.app.workflow.node_factory
core.workflow.graph_engine.command_channels.redis_channel -> extensions.ext_redis
core.workflow.graph_engine.layers.observability -> configs
core.workflow.graph_engine.layers.observability -> extensions.otel.runtime
core.workflow.graph_engine.layers.persistence -> core.ops.ops_trace_manager
core.workflow.graph_engine.worker_management.worker_pool -> configs
core.workflow.nodes.agent.agent_node -> core.model_manager
core.workflow.nodes.agent.agent_node -> core.provider_manager
core.workflow.nodes.agent.agent_node -> core.tools.tool_manager
core.workflow.nodes.code.code_node -> core.helper.code_executor.code_executor
core.workflow.nodes.datasource.datasource_node -> models.model
core.workflow.nodes.datasource.datasource_node -> models.tools
core.workflow.nodes.datasource.datasource_node -> services.datasource_provider_service
core.workflow.nodes.document_extractor.node -> configs
core.workflow.nodes.document_extractor.node -> core.file.file_manager
core.workflow.nodes.document_extractor.node -> core.helper.ssrf_proxy
core.workflow.nodes.http_request.entities -> configs
core.workflow.nodes.http_request.executor -> configs
core.workflow.nodes.http_request.executor -> core.file.file_manager
core.workflow.nodes.http_request.node -> configs
core.workflow.nodes.http_request.node -> core.tools.tool_file_manager
core.workflow.nodes.iteration.iteration_node -> core.app.workflow.node_factory
core.workflow.nodes.knowledge_index.knowledge_index_node -> core.rag.index_processor.index_processor_factory
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.rag.datasource.retrieval_service
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.rag.retrieval.dataset_retrieval
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> models.dataset
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> services.feature_service
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.model_runtime.model_providers.__base.large_language_model
core.workflow.nodes.llm.llm_utils -> configs
core.workflow.nodes.llm.llm_utils -> core.app.entities.app_invoke_entities
core.workflow.nodes.llm.llm_utils -> core.file.models
core.workflow.nodes.llm.llm_utils -> core.model_manager
core.workflow.nodes.llm.llm_utils -> core.model_runtime.model_providers.__base.large_language_model
core.workflow.nodes.llm.llm_utils -> models.model
core.workflow.nodes.llm.llm_utils -> models.provider
core.workflow.nodes.llm.llm_utils -> services.credit_pool_service
core.workflow.nodes.llm.node -> core.tools.signature
core.workflow.nodes.template_transform.template_transform_node -> configs
core.workflow.nodes.tool.tool_node -> core.callback_handler.workflow_tool_callback_handler
core.workflow.nodes.tool.tool_node -> core.tools.tool_engine
core.workflow.nodes.tool.tool_node -> core.tools.tool_manager
core.workflow.workflow_entry -> configs
core.workflow.workflow_entry -> models.workflow
core.workflow.nodes.agent.agent_node -> core.agent.entities
core.workflow.nodes.agent.agent_node -> core.agent.plugin_entities
core.workflow.graph_engine.layers.persistence -> core.app.entities.app_invoke_entities
core.workflow.nodes.base.node -> core.app.entities.app_invoke_entities
core.workflow.nodes.knowledge_index.knowledge_index_node -> core.app.entities.app_invoke_entities
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.app.app_config.entities
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.app.entities.app_invoke_entities
core.workflow.nodes.llm.node -> core.app.entities.app_invoke_entities
core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.app.entities.app_invoke_entities
core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.prompt.advanced_prompt_transform
core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.prompt.simple_prompt_transform
core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.model_runtime.model_providers.__base.large_language_model
core.workflow.nodes.question_classifier.question_classifier_node -> core.app.entities.app_invoke_entities
core.workflow.nodes.question_classifier.question_classifier_node -> core.prompt.advanced_prompt_transform
core.workflow.nodes.question_classifier.question_classifier_node -> core.prompt.simple_prompt_transform
core.workflow.nodes.start.entities -> core.app.app_config.entities
core.workflow.nodes.start.start_node -> core.app.app_config.entities
core.workflow.workflow_entry -> core.app.apps.exc
core.workflow.workflow_entry -> core.app.entities.app_invoke_entities
core.workflow.workflow_entry -> core.app.workflow.node_factory
core.workflow.nodes.datasource.datasource_node -> core.datasource.datasource_manager
core.workflow.nodes.datasource.datasource_node -> core.datasource.utils.message_transformer
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.entities.agent_entities
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.entities.model_entities
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.model_manager
core.workflow.nodes.llm.llm_utils -> core.entities.provider_entities
core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.model_manager
core.workflow.nodes.question_classifier.question_classifier_node -> core.model_manager
core.workflow.node_events.node -> core.file
core.workflow.nodes.agent.agent_node -> core.file
core.workflow.nodes.datasource.datasource_node -> core.file
core.workflow.nodes.datasource.datasource_node -> core.file.enums
core.workflow.nodes.document_extractor.node -> core.file
core.workflow.nodes.http_request.executor -> core.file.enums
core.workflow.nodes.http_request.node -> core.file
core.workflow.nodes.http_request.node -> core.file.file_manager
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.file.models
core.workflow.nodes.list_operator.node -> core.file
core.workflow.nodes.llm.file_saver -> core.file
core.workflow.nodes.llm.llm_utils -> core.variables.segments
core.workflow.nodes.llm.node -> core.file
core.workflow.nodes.llm.node -> core.file.file_manager
core.workflow.nodes.llm.node -> core.file.models
core.workflow.nodes.loop.entities -> core.variables.types
core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.file
core.workflow.nodes.protocols -> core.file
core.workflow.nodes.question_classifier.question_classifier_node -> core.file.models
core.workflow.nodes.tool.tool_node -> core.file
core.workflow.nodes.tool.tool_node -> core.tools.utils.message_transformer
core.workflow.nodes.tool.tool_node -> models
core.workflow.nodes.trigger_webhook.node -> core.file
core.workflow.runtime.variable_pool -> core.file
core.workflow.runtime.variable_pool -> core.file.file_manager
core.workflow.system_variable -> core.file.models
core.workflow.utils.condition.processor -> core.file
core.workflow.utils.condition.processor -> core.file.file_manager
core.workflow.workflow_entry -> core.file.models
core.workflow.workflow_type_encoder -> core.file.models
core.workflow.nodes.agent.agent_node -> models.model
core.workflow.nodes.code.code_node -> core.helper.code_executor.code_node_provider
core.workflow.nodes.code.code_node -> core.helper.code_executor.javascript.javascript_code_provider
core.workflow.nodes.code.code_node -> core.helper.code_executor.python3.python3_code_provider
core.workflow.nodes.code.entities -> core.helper.code_executor.code_executor
core.workflow.nodes.datasource.datasource_node -> core.variables.variables
core.workflow.nodes.http_request.executor -> core.helper.ssrf_proxy
core.workflow.nodes.http_request.node -> core.helper.ssrf_proxy
core.workflow.nodes.llm.file_saver -> core.helper.ssrf_proxy
core.workflow.nodes.llm.node -> core.helper.code_executor
core.workflow.nodes.template_transform.template_renderer -> core.helper.code_executor.code_executor
core.workflow.nodes.llm.node -> core.llm_generator.output_parser.errors
core.workflow.nodes.llm.node -> core.llm_generator.output_parser.structured_output
core.workflow.nodes.llm.node -> core.model_manager
core.workflow.graph_engine.layers.persistence -> core.ops.entities.trace_entity
core.workflow.nodes.agent.entities -> core.prompt.entities.advanced_prompt_entities
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.prompt.simple_prompt_transform
core.workflow.nodes.llm.entities -> core.prompt.entities.advanced_prompt_entities
core.workflow.nodes.llm.llm_utils -> core.prompt.entities.advanced_prompt_entities
core.workflow.nodes.llm.node -> core.prompt.entities.advanced_prompt_entities
core.workflow.nodes.llm.node -> core.prompt.utils.prompt_message_util
core.workflow.nodes.parameter_extractor.entities -> core.prompt.entities.advanced_prompt_entities
core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.prompt.entities.advanced_prompt_entities
core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.prompt.utils.prompt_message_util
core.workflow.nodes.question_classifier.entities -> core.prompt.entities.advanced_prompt_entities
core.workflow.nodes.question_classifier.question_classifier_node -> core.prompt.utils.prompt_message_util
core.workflow.nodes.knowledge_index.entities -> core.rag.retrieval.retrieval_methods
core.workflow.nodes.knowledge_index.knowledge_index_node -> core.rag.retrieval.retrieval_methods
core.workflow.nodes.knowledge_index.knowledge_index_node -> models.dataset
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.rag.retrieval.retrieval_methods
core.workflow.nodes.llm.node -> models.dataset
core.workflow.nodes.agent.agent_node -> core.tools.utils.message_transformer
core.workflow.nodes.llm.file_saver -> core.tools.signature
core.workflow.nodes.llm.file_saver -> core.tools.tool_file_manager
core.workflow.nodes.tool.tool_node -> core.tools.errors
core.workflow.conversation_variable_updater -> core.variables
core.workflow.graph_engine.entities.commands -> core.variables.variables
core.workflow.nodes.agent.agent_node -> core.variables.segments
core.workflow.nodes.answer.answer_node -> core.variables
core.workflow.nodes.code.code_node -> core.variables.segments
core.workflow.nodes.code.code_node -> core.variables.types
core.workflow.nodes.code.entities -> core.variables.types
core.workflow.nodes.datasource.datasource_node -> core.variables.segments
core.workflow.nodes.document_extractor.node -> core.variables
core.workflow.nodes.document_extractor.node -> core.variables.segments
core.workflow.nodes.http_request.executor -> core.variables.segments
core.workflow.nodes.http_request.node -> core.variables.segments
core.workflow.nodes.iteration.iteration_node -> core.variables
core.workflow.nodes.iteration.iteration_node -> core.variables.segments
core.workflow.nodes.iteration.iteration_node -> core.variables.variables
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.variables
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> core.variables.segments
core.workflow.nodes.list_operator.node -> core.variables
core.workflow.nodes.list_operator.node -> core.variables.segments
core.workflow.nodes.llm.node -> core.variables
core.workflow.nodes.loop.loop_node -> core.variables
core.workflow.nodes.parameter_extractor.entities -> core.variables.types
core.workflow.nodes.parameter_extractor.exc -> core.variables.types
core.workflow.nodes.parameter_extractor.parameter_extractor_node -> core.variables.types
core.workflow.nodes.tool.tool_node -> core.variables.segments
core.workflow.nodes.tool.tool_node -> core.variables.variables
core.workflow.nodes.trigger_webhook.node -> core.variables.types
core.workflow.nodes.trigger_webhook.node -> core.variables.variables
core.workflow.nodes.variable_aggregator.entities -> core.variables.types
core.workflow.nodes.variable_aggregator.variable_aggregator_node -> core.variables.segments
core.workflow.nodes.variable_assigner.common.helpers -> core.variables
core.workflow.nodes.variable_assigner.common.helpers -> core.variables.consts
core.workflow.nodes.variable_assigner.common.helpers -> core.variables.types
core.workflow.nodes.variable_assigner.v1.node -> core.variables
core.workflow.nodes.variable_assigner.v2.helpers -> core.variables
core.workflow.nodes.variable_assigner.v2.node -> core.variables
core.workflow.nodes.variable_assigner.v2.node -> core.variables.consts
core.workflow.runtime.graph_runtime_state_protocol -> core.variables.segments
core.workflow.runtime.read_only_wrappers -> core.variables.segments
core.workflow.runtime.variable_pool -> core.variables
core.workflow.runtime.variable_pool -> core.variables.consts
core.workflow.runtime.variable_pool -> core.variables.segments
core.workflow.runtime.variable_pool -> core.variables.variables
core.workflow.utils.condition.processor -> core.variables
core.workflow.utils.condition.processor -> core.variables.segments
core.workflow.variable_loader -> core.variables
core.workflow.variable_loader -> core.variables.consts
core.workflow.workflow_type_encoder -> core.variables
core.workflow.graph_engine.manager -> extensions.ext_redis
core.workflow.nodes.agent.agent_node -> extensions.ext_database
core.workflow.nodes.datasource.datasource_node -> extensions.ext_database
core.workflow.nodes.knowledge_index.knowledge_index_node -> extensions.ext_database
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> extensions.ext_database
core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node -> extensions.ext_redis
core.workflow.nodes.llm.file_saver -> extensions.ext_database
core.workflow.nodes.llm.llm_utils -> extensions.ext_database
core.workflow.nodes.llm.node -> extensions.ext_database
core.workflow.nodes.tool.tool_node -> extensions.ext_database
core.workflow.workflow_entry -> extensions.otel.runtime
core.workflow.nodes.agent.agent_node -> models
core.workflow.nodes.base.node -> models.enums
core.workflow.nodes.llm.llm_utils -> models.provider_ids
core.workflow.nodes.llm.node -> models.model
core.workflow.workflow_entry -> models.enums
core.workflow.nodes.agent.agent_node -> services
core.workflow.nodes.tool.tool_node -> services
[importlinter:contract:rsc]
name = RSC
type = layers
+10
View File
@@ -965,6 +965,16 @@ class MailConfig(BaseSettings):
default=None,
)
ENABLE_TRIAL_APP: bool = Field(
description="Enable trial app",
default=False,
)
ENABLE_EXPLORE_BANNER: bool = Field(
description="Enable explore banner",
default=False,
)
class RagEtlConfig(BaseSettings):
"""
+14 -20
View File
@@ -3,6 +3,7 @@ Flask App Context - Flask implementation of AppContext interface.
"""
import contextvars
import threading
from collections.abc import Generator
from contextlib import contextmanager
from typing import Any, final
@@ -118,6 +119,7 @@ class FlaskExecutionContext:
self._context_vars = context_vars
self._user = user
self._flask_app = flask_app
self._local = threading.local()
@property
def app_context(self) -> FlaskAppContext:
@@ -136,47 +138,39 @@ class FlaskExecutionContext:
def __enter__(self) -> "FlaskExecutionContext":
"""Enter the Flask execution context."""
# Restore context variables
# Restore non-Flask context variables to avoid leaking Flask tokens across threads
for var, val in self._context_vars.items():
var.set(val)
# Save current user from g if available
saved_user = None
if hasattr(g, "_login_user"):
saved_user = g._login_user
# Enter Flask app context
self._cm = self._app_context.enter()
self._cm.__enter__()
cm = self._app_context.enter()
self._local.cm = cm
cm.__enter__()
# Restore user in new app context
if saved_user is not None:
g._login_user = saved_user
if self._user is not None:
g._login_user = self._user
return self
def __exit__(self, *args: Any) -> None:
"""Exit the Flask execution context."""
if hasattr(self, "_cm"):
self._cm.__exit__(*args)
cm = getattr(self._local, "cm", None)
if cm is not None:
cm.__exit__(*args)
@contextmanager
def enter(self) -> Generator[None, None, None]:
"""Enter Flask execution context as context manager."""
# Restore context variables
# Restore non-Flask context variables to avoid leaking Flask tokens across threads
for var, val in self._context_vars.items():
var.set(val)
# Save current user from g if available
saved_user = None
if hasattr(g, "_login_user"):
saved_user = g._login_user
# Enter Flask app context
with self._flask_app.app_context():
# Restore user in new app context
if saved_user is not None:
g._login_user = saved_user
if self._user is not None:
g._login_user = self._user
yield
+4
View File
@@ -107,10 +107,12 @@ from .datasets.rag_pipeline import (
# Import explore controllers
from .explore import (
banner,
installed_app,
parameter,
recommended_app,
saved_message,
trial,
)
# Import tag controllers
@@ -145,6 +147,7 @@ __all__ = [
"apikey",
"app",
"audio",
"banner",
"billing",
"bp",
"completion",
@@ -198,6 +201,7 @@ __all__ = [
"statistic",
"tags",
"tool_providers",
"trial",
"trigger_providers",
"version",
"website",
+108 -1
View File
@@ -15,7 +15,7 @@ from controllers.console.wraps import only_edition_cloud
from core.db.session_factory import session_factory
from extensions.ext_database import db
from libs.token import extract_access_token
from models.model import App, InstalledApp, RecommendedApp
from models.model import App, ExporleBanner, InstalledApp, RecommendedApp, TrialApp
P = ParamSpec("P")
R = TypeVar("R")
@@ -32,6 +32,8 @@ class InsertExploreAppPayload(BaseModel):
language: str = Field(...)
category: str = Field(...)
position: int = Field(...)
can_trial: bool = Field(default=False)
trial_limit: int = Field(default=0)
@field_validator("language")
@classmethod
@@ -39,11 +41,33 @@ class InsertExploreAppPayload(BaseModel):
return supported_language(value)
class InsertExploreBannerPayload(BaseModel):
category: str = Field(...)
title: str = Field(...)
description: str = Field(...)
img_src: str = Field(..., alias="img-src")
language: str = Field(default="en-US")
link: str = Field(...)
sort: int = Field(...)
@field_validator("language")
@classmethod
def validate_language(cls, value: str) -> str:
return supported_language(value)
model_config = {"populate_by_name": True}
console_ns.schema_model(
InsertExploreAppPayload.__name__,
InsertExploreAppPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
console_ns.schema_model(
InsertExploreBannerPayload.__name__,
InsertExploreBannerPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
def admin_required(view: Callable[P, R]):
@wraps(view)
@@ -109,6 +133,20 @@ class InsertExploreAppListApi(Resource):
)
db.session.add(recommended_app)
if payload.can_trial:
trial_app = db.session.execute(
select(TrialApp).where(TrialApp.app_id == payload.app_id)
).scalar_one_or_none()
if not trial_app:
db.session.add(
TrialApp(
app_id=payload.app_id,
tenant_id=app.tenant_id,
trial_limit=payload.trial_limit,
)
)
else:
trial_app.trial_limit = payload.trial_limit
app.is_public = True
db.session.commit()
@@ -123,6 +161,20 @@ class InsertExploreAppListApi(Resource):
recommended_app.category = payload.category
recommended_app.position = payload.position
if payload.can_trial:
trial_app = db.session.execute(
select(TrialApp).where(TrialApp.app_id == payload.app_id)
).scalar_one_or_none()
if not trial_app:
db.session.add(
TrialApp(
app_id=payload.app_id,
tenant_id=app.tenant_id,
trial_limit=payload.trial_limit,
)
)
else:
trial_app.trial_limit = payload.trial_limit
app.is_public = True
db.session.commit()
@@ -168,7 +220,62 @@ class InsertExploreAppApi(Resource):
for installed_app in installed_apps:
session.delete(installed_app)
trial_app = session.execute(
select(TrialApp).where(TrialApp.app_id == recommended_app.app_id)
).scalar_one_or_none()
if trial_app:
session.delete(trial_app)
db.session.delete(recommended_app)
db.session.commit()
return {"result": "success"}, 204
@console_ns.route("/admin/insert-explore-banner")
class InsertExploreBannerApi(Resource):
@console_ns.doc("insert_explore_banner")
@console_ns.doc(description="Insert an explore banner")
@console_ns.expect(console_ns.models[InsertExploreBannerPayload.__name__])
@console_ns.response(201, "Banner inserted successfully")
@only_edition_cloud
@admin_required
def post(self):
payload = InsertExploreBannerPayload.model_validate(console_ns.payload)
content = {
"category": payload.category,
"title": payload.title,
"description": payload.description,
"img-src": payload.img_src,
}
banner = ExporleBanner(
content=content,
link=payload.link,
sort=payload.sort,
language=payload.language,
)
db.session.add(banner)
db.session.commit()
return {"result": "success"}, 201
@console_ns.route("/admin/delete-explore-banner/<uuid:banner_id>")
class DeleteExploreBannerApi(Resource):
@console_ns.doc("delete_explore_banner")
@console_ns.doc(description="Delete an explore banner")
@console_ns.doc(params={"banner_id": "Banner ID to delete"})
@console_ns.response(204, "Banner deleted successfully")
@only_edition_cloud
@admin_required
def delete(self, banner_id):
banner = db.session.execute(select(ExporleBanner).where(ExporleBanner.id == banner_id)).scalar_one_or_none()
if not banner:
raise NotFound(f"Banner '{banner_id}' is not found")
db.session.delete(banner)
db.session.commit()
return {"result": "success"}, 204
+6
View File
@@ -115,3 +115,9 @@ class InvokeRateLimitError(BaseHTTPException):
error_code = "rate_limit_error"
description = "Rate Limit Error"
code = 429
class NeedAddIdsError(BaseHTTPException):
error_code = "need_add_ids"
description = "Need to add ids."
code = 400
+46
View File
@@ -23,6 +23,11 @@ def _load_app_model(app_id: str) -> App | None:
return app_model
def _load_app_model_with_trial(app_id: str) -> App | None:
app_model = db.session.query(App).where(App.id == app_id, App.status == "normal").first()
return app_model
def get_app_model(view: Callable[P, R] | None = None, *, mode: Union[AppMode, list[AppMode], None] = None):
def decorator(view_func: Callable[P1, R1]):
@wraps(view_func)
@@ -62,3 +67,44 @@ def get_app_model(view: Callable[P, R] | None = None, *, mode: Union[AppMode, li
return decorator
else:
return decorator(view)
def get_app_model_with_trial(view: Callable[P, R] | None = None, *, mode: Union[AppMode, list[AppMode], None] = None):
def decorator(view_func: Callable[P, R]):
@wraps(view_func)
def decorated_view(*args: P.args, **kwargs: P.kwargs):
if not kwargs.get("app_id"):
raise ValueError("missing app_id in path parameters")
app_id = kwargs.get("app_id")
app_id = str(app_id)
del kwargs["app_id"]
app_model = _load_app_model_with_trial(app_id)
if not app_model:
raise AppNotFoundError()
app_mode = AppMode.value_of(app_model.mode)
if mode is not None:
if isinstance(mode, list):
modes = mode
else:
modes = [mode]
if app_mode not in modes:
mode_values = {m.value for m in modes}
raise AppNotFoundError(f"App mode is not in the supported list: {mode_values}")
kwargs["app_model"] = app_model
return view_func(*args, **kwargs)
return decorated_view
if view is None:
return decorator
else:
return decorator(view)
+43
View File
@@ -0,0 +1,43 @@
from flask import request
from flask_restx import Resource
from controllers.console import api
from controllers.console.explore.wraps import explore_banner_enabled
from extensions.ext_database import db
from models.model import ExporleBanner
class BannerApi(Resource):
"""Resource for banner list."""
@explore_banner_enabled
def get(self):
"""Get banner list."""
language = request.args.get("language", "en-US")
# Build base query for enabled banners
base_query = db.session.query(ExporleBanner).where(ExporleBanner.status == "enabled")
# Try to get banners in the requested language
banners = base_query.where(ExporleBanner.language == language).order_by(ExporleBanner.sort).all()
# Fallback to en-US if no banners found and language is not en-US
if not banners and language != "en-US":
banners = base_query.where(ExporleBanner.language == "en-US").order_by(ExporleBanner.sort).all()
# Convert banners to serializable format
result = []
for banner in banners:
banner_data = {
"id": banner.id,
"content": banner.content, # Already parsed as JSON by SQLAlchemy
"link": banner.link,
"sort": banner.sort,
"status": banner.status,
"created_at": banner.created_at.isoformat() if banner.created_at else None,
}
result.append(banner_data)
return result
api.add_resource(BannerApi, "/explore/banners")
+22
View File
@@ -29,3 +29,25 @@ class AppAccessDeniedError(BaseHTTPException):
error_code = "access_denied"
description = "App access denied."
code = 403
class TrialAppNotAllowed(BaseHTTPException):
"""*403* `Trial App Not Allowed`
Raise if the user has reached the trial app limit.
"""
error_code = "trial_app_not_allowed"
code = 403
description = "the app is not allowed to be trial."
class TrialAppLimitExceeded(BaseHTTPException):
"""*403* `Trial App Limit Exceeded`
Raise if the user has exceeded the trial app limit.
"""
error_code = "trial_app_limit_exceeded"
code = 403
description = "The user has exceeded the trial app limit."
@@ -29,6 +29,7 @@ recommended_app_fields = {
"category": fields.String,
"position": fields.Integer,
"is_listed": fields.Boolean,
"can_trial": fields.Boolean,
}
recommended_app_list_fields = {
+512
View File
@@ -0,0 +1,512 @@
import logging
from typing import Any, cast
from flask import request
from flask_restx import Resource, marshal, marshal_with, reqparse
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services
from controllers.common.fields import Parameters as ParametersResponse
from controllers.common.fields import Site as SiteResponse
from controllers.console import api
from controllers.console.app.error import (
AppUnavailableError,
AudioTooLargeError,
CompletionRequestError,
ConversationCompletedError,
NeedAddIdsError,
NoAudioUploadedError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderNotSupportSpeechToTextError,
ProviderQuotaExceededError,
UnsupportedAudioTypeError,
)
from controllers.console.app.wraps import get_app_model_with_trial
from controllers.console.explore.error import (
AppSuggestedQuestionsAfterAnswerDisabledError,
NotChatAppError,
NotCompletionAppError,
NotWorkflowAppError,
)
from controllers.console.explore.wraps import TrialAppResource, trial_feature_enable
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
ModelCurrentlyNotSupportError,
ProviderTokenNotInitError,
QuotaExceededError,
)
from core.model_runtime.errors.invoke import InvokeError
from core.workflow.graph_engine.manager import GraphEngineManager
from extensions.ext_database import db
from fields.app_fields import app_detail_fields_with_site
from fields.dataset_fields import dataset_fields
from fields.workflow_fields import workflow_fields
from libs import helper
from libs.helper import uuid_value
from libs.login import current_user
from models import Account
from models.account import TenantStatus
from models.model import AppMode, Site
from models.workflow import Workflow
from services.app_generate_service import AppGenerateService
from services.app_service import AppService
from services.audio_service import AudioService
from services.dataset_service import DatasetService
from services.errors.audio import (
AudioTooLargeServiceError,
NoAudioUploadedServiceError,
ProviderNotSupportSpeechToTextServiceError,
UnsupportedAudioTypeServiceError,
)
from services.errors.conversation import ConversationNotExistsError
from services.errors.llm import InvokeRateLimitError
from services.errors.message import (
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.message_service import MessageService
from services.recommended_app_service import RecommendedAppService
logger = logging.getLogger(__name__)
class TrialAppWorkflowRunApi(TrialAppResource):
def post(self, trial_app):
"""
Run workflow
"""
app_model = trial_app
if not app_model:
raise NotWorkflowAppError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode != AppMode.WORKFLOW:
raise NotWorkflowAppError()
parser = reqparse.RequestParser()
parser.add_argument("inputs", type=dict, required=True, nullable=False, location="json")
parser.add_argument("files", type=list, required=False, location="json")
args = parser.parse_args()
assert current_user is not None
try:
app_id = app_model.id
user_id = current_user.id
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=True
)
RecommendedAppService.add_trial_app_record(app_id, user_id)
return helper.compact_generate_response(response)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except ValueError as e:
raise e
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
class TrialAppWorkflowTaskStopApi(TrialAppResource):
def post(self, trial_app, task_id: str):
"""
Stop workflow task
"""
app_model = trial_app
if not app_model:
raise NotWorkflowAppError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode != AppMode.WORKFLOW:
raise NotWorkflowAppError()
assert current_user is not None
# Stop using both mechanisms for backward compatibility
# Legacy stop flag mechanism (without user check)
AppQueueManager.set_stop_flag_no_user_check(task_id)
# New graph engine command channel mechanism
GraphEngineManager.send_stop_command(task_id)
return {"result": "success"}
class TrialChatApi(TrialAppResource):
@trial_feature_enable
def post(self, trial_app):
app_model = trial_app
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
parser = reqparse.RequestParser()
parser.add_argument("inputs", type=dict, required=True, location="json")
parser.add_argument("query", type=str, required=True, location="json")
parser.add_argument("files", type=list, required=False, location="json")
parser.add_argument("conversation_id", type=uuid_value, location="json")
parser.add_argument("parent_message_id", type=uuid_value, required=False, location="json")
parser.add_argument("retriever_from", type=str, required=False, default="explore_app", location="json")
args = parser.parse_args()
args["auto_generate_name"] = False
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=True
)
RecommendedAppService.add_trial_app_record(app_id, user_id)
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except ValueError as e:
raise e
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
class TrialMessageSuggestedQuestionApi(TrialAppResource):
@trial_feature_enable
def get(self, trial_app, message_id):
app_model = trial_app
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
message_id = str(message_id)
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model, user=current_user, message_id=message_id, invoke_from=InvokeFrom.EXPLORE
)
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
raise NotFound("Conversation not found")
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
return {"data": questions}
class TrialChatAudioApi(TrialAppResource):
@trial_feature_enable
def post(self, trial_app):
app_model = trial_app
file = request.files["file"]
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
response = AudioService.transcript_asr(app_model=app_model, file=file, end_user=None)
RecommendedAppService.add_trial_app_record(app_id, user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except NoAudioUploadedServiceError:
raise NoAudioUploadedError()
except AudioTooLargeServiceError as e:
raise AudioTooLargeError(str(e))
except UnsupportedAudioTypeServiceError:
raise UnsupportedAudioTypeError()
except ProviderNotSupportSpeechToTextServiceError:
raise ProviderNotSupportSpeechToTextError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
class TrialChatTextApi(TrialAppResource):
@trial_feature_enable
def post(self, trial_app):
app_model = trial_app
try:
parser = reqparse.RequestParser()
parser.add_argument("message_id", type=str, required=False, location="json")
parser.add_argument("voice", type=str, location="json")
parser.add_argument("text", type=str, location="json")
parser.add_argument("streaming", type=bool, location="json")
args = parser.parse_args()
message_id = args.get("message_id", None)
text = args.get("text", None)
voice = args.get("voice", None)
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
response = AudioService.transcript_tts(app_model=app_model, text=text, voice=voice, message_id=message_id)
RecommendedAppService.add_trial_app_record(app_id, user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except NoAudioUploadedServiceError:
raise NoAudioUploadedError()
except AudioTooLargeServiceError as e:
raise AudioTooLargeError(str(e))
except UnsupportedAudioTypeServiceError:
raise UnsupportedAudioTypeError()
except ProviderNotSupportSpeechToTextServiceError:
raise ProviderNotSupportSpeechToTextError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
class TrialCompletionApi(TrialAppResource):
@trial_feature_enable
def post(self, trial_app):
app_model = trial_app
if app_model.mode != "completion":
raise NotCompletionAppError()
parser = reqparse.RequestParser()
parser.add_argument("inputs", type=dict, required=True, location="json")
parser.add_argument("query", type=str, location="json", default="")
parser.add_argument("files", type=list, required=False, location="json")
parser.add_argument("response_mode", type=str, choices=["blocking", "streaming"], location="json")
parser.add_argument("retriever_from", type=str, required=False, default="explore_app", location="json")
args = parser.parse_args()
streaming = args["response_mode"] == "streaming"
args["auto_generate_name"] = False
try:
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=streaming
)
RecommendedAppService.add_trial_app_record(app_id, user_id)
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
class TrialSitApi(Resource):
"""Resource for trial app sites."""
@trial_feature_enable
@get_app_model_with_trial
def get(self, app_model):
"""Retrieve app site info.
Returns the site configuration for the application including theme, icons, and text.
"""
site = db.session.query(Site).where(Site.app_id == app_model.id).first()
if not site:
raise Forbidden()
assert app_model.tenant
if app_model.tenant.status == TenantStatus.ARCHIVE:
raise Forbidden()
return SiteResponse.model_validate(site).model_dump(mode="json")
class TrialAppParameterApi(Resource):
"""Resource for app variables."""
@trial_feature_enable
@get_app_model_with_trial
def get(self, app_model):
"""Retrieve app parameters."""
if app_model is None:
raise AppUnavailableError()
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow
if workflow is None:
raise AppUnavailableError()
features_dict = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app_model.app_model_config
if app_model_config is None:
raise AppUnavailableError()
features_dict = app_model_config.to_dict()
user_input_form = features_dict.get("user_input_form", [])
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
return ParametersResponse.model_validate(parameters).model_dump(mode="json")
class AppApi(Resource):
@trial_feature_enable
@get_app_model_with_trial
@marshal_with(app_detail_fields_with_site)
def get(self, app_model):
"""Get app detail"""
app_service = AppService()
app_model = app_service.get_app(app_model)
return app_model
class AppWorkflowApi(Resource):
@trial_feature_enable
@get_app_model_with_trial
@marshal_with(workflow_fields)
def get(self, app_model):
"""Get workflow detail"""
if not app_model.workflow_id:
raise AppUnavailableError()
workflow = (
db.session.query(Workflow)
.where(
Workflow.id == app_model.workflow_id,
)
.first()
)
return workflow
class DatasetListApi(Resource):
@trial_feature_enable
@get_app_model_with_trial
def get(self, app_model):
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
ids = request.args.getlist("ids")
tenant_id = app_model.tenant_id
if ids:
datasets, total = DatasetService.get_datasets_by_ids(ids, tenant_id)
else:
raise NeedAddIdsError()
data = cast(list[dict[str, Any]], marshal(datasets, dataset_fields))
response = {"data": data, "has_more": len(datasets) == limit, "limit": limit, "total": total, "page": page}
return response
api.add_resource(TrialChatApi, "/trial-apps/<uuid:app_id>/chat-messages", endpoint="trial_app_chat_completion")
api.add_resource(
TrialMessageSuggestedQuestionApi,
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
endpoint="trial_app_suggested_question",
)
api.add_resource(TrialChatAudioApi, "/trial-apps/<uuid:app_id>/audio-to-text", endpoint="trial_app_audio")
api.add_resource(TrialChatTextApi, "/trial-apps/<uuid:app_id>/text-to-audio", endpoint="trial_app_text")
api.add_resource(TrialCompletionApi, "/trial-apps/<uuid:app_id>/completion-messages", endpoint="trial_app_completion")
api.add_resource(TrialSitApi, "/trial-apps/<uuid:app_id>/site")
api.add_resource(TrialAppParameterApi, "/trial-apps/<uuid:app_id>/parameters", endpoint="trial_app_parameters")
api.add_resource(AppApi, "/trial-apps/<uuid:app_id>", endpoint="trial_app")
api.add_resource(TrialAppWorkflowRunApi, "/trial-apps/<uuid:app_id>/workflows/run", endpoint="trial_app_workflow_run")
api.add_resource(TrialAppWorkflowTaskStopApi, "/trial-apps/<uuid:app_id>/workflows/tasks/<string:task_id>/stop")
api.add_resource(AppWorkflowApi, "/trial-apps/<uuid:app_id>/workflows", endpoint="trial_app_workflow")
api.add_resource(DatasetListApi, "/trial-apps/<uuid:app_id>/datasets", endpoint="trial_app_datasets")
+68 -2
View File
@@ -2,14 +2,15 @@ from collections.abc import Callable
from functools import wraps
from typing import Concatenate, ParamSpec, TypeVar
from flask import abort
from flask_restx import Resource
from werkzeug.exceptions import NotFound
from controllers.console.explore.error import AppAccessDeniedError
from controllers.console.explore.error import AppAccessDeniedError, TrialAppLimitExceeded, TrialAppNotAllowed
from controllers.console.wraps import account_initialization_required
from extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from models import InstalledApp
from models import AccountTrialAppRecord, App, InstalledApp, TrialApp
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
@@ -71,6 +72,61 @@ def user_allowed_to_access_app(view: Callable[Concatenate[InstalledApp, P], R] |
return decorator
def trial_app_required(view: Callable[Concatenate[App, P], R] | None = None):
def decorator(view: Callable[Concatenate[App, P], R]):
@wraps(view)
def decorated(app_id: str, *args: P.args, **kwargs: P.kwargs):
current_user, _ = current_account_with_tenant()
trial_app = db.session.query(TrialApp).where(TrialApp.app_id == str(app_id)).first()
if trial_app is None:
raise TrialAppNotAllowed()
app = trial_app.app
if app is None:
raise TrialAppNotAllowed()
account_trial_app_record = (
db.session.query(AccountTrialAppRecord)
.where(AccountTrialAppRecord.account_id == current_user.id, AccountTrialAppRecord.app_id == app_id)
.first()
)
if account_trial_app_record:
if account_trial_app_record.count >= trial_app.trial_limit:
raise TrialAppLimitExceeded()
return view(app, *args, **kwargs)
return decorated
if view:
return decorator(view)
return decorator
def trial_feature_enable(view: Callable[..., R]) -> Callable[..., R]:
@wraps(view)
def decorated(*args, **kwargs):
features = FeatureService.get_system_features()
if not features.enable_trial_app:
abort(403, "Trial app feature is not enabled.")
return view(*args, **kwargs)
return decorated
def explore_banner_enabled(view: Callable[..., R]) -> Callable[..., R]:
@wraps(view)
def decorated(*args, **kwargs):
features = FeatureService.get_system_features()
if not features.enable_explore_banner:
abort(403, "Explore banner feature is not enabled.")
return view(*args, **kwargs)
return decorated
class InstalledAppResource(Resource):
# must be reversed if there are multiple decorators
@@ -80,3 +136,13 @@ class InstalledAppResource(Resource):
account_initialization_required,
login_required,
]
class TrialAppResource(Resource):
# must be reversed if there are multiple decorators
method_decorators = [
trial_app_required,
account_initialization_required,
login_required,
]
@@ -9,13 +9,13 @@ from core.app.entities.app_invoke_entities import (
InvokeFrom,
RagPipelineGenerateEntity,
)
from core.app.workflow.node_factory import DifyNodeFactory
from core.variables.variables import RAGPipelineVariable, RAGPipelineVariableInput
from core.workflow.entities.graph_init_params import GraphInitParams
from core.workflow.enums import WorkflowType
from core.workflow.graph import Graph
from core.workflow.graph_engine.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
from core.workflow.graph_events import GraphEngineEvent, GraphRunFailedEvent
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.repositories.workflow_execution_repository import WorkflowExecutionRepository
from core.workflow.repositories.workflow_node_execution_repository import WorkflowNodeExecutionRepository
from core.workflow.runtime import GraphRuntimeState, VariablePool
+1 -1
View File
@@ -25,6 +25,7 @@ from core.app.entities.queue_entities import (
QueueWorkflowStartedEvent,
QueueWorkflowSucceededEvent,
)
from core.app.workflow.node_factory import DifyNodeFactory
from core.workflow.entities import GraphInitParams
from core.workflow.graph import Graph
from core.workflow.graph_engine.layers.base import GraphEngineLayer
@@ -53,7 +54,6 @@ from core.workflow.graph_events import (
)
from core.workflow.graph_events.graph import GraphRunAbortedEvent
from core.workflow.nodes import NodeType
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
+3
View File
@@ -0,0 +1,3 @@
from .node_factory import DifyNodeFactory
__all__ = ["DifyNodeFactory"]
@@ -15,6 +15,7 @@ from core.workflow.nodes.base.node import Node
from core.workflow.nodes.code.code_node import CodeNode
from core.workflow.nodes.code.limits import CodeNodeLimits
from core.workflow.nodes.http_request.node import HttpRequestNode
from core.workflow.nodes.node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
from core.workflow.nodes.protocols import FileManagerProtocol, HttpClientProtocol
from core.workflow.nodes.template_transform.template_renderer import (
CodeExecutorJinja2TemplateRenderer,
@@ -23,8 +24,6 @@ from core.workflow.nodes.template_transform.template_renderer import (
from core.workflow.nodes.template_transform.template_transform_node import TemplateTransformNode
from libs.typing import is_str, is_str_dict
from .node_mapping import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING
if TYPE_CHECKING:
from core.workflow.entities import GraphInitParams
from core.workflow.runtime import GraphRuntimeState
+9
View File
@@ -1,5 +1,6 @@
import contextlib
import json
import logging
from collections.abc import Generator, Iterable
from copy import deepcopy
from datetime import UTC, datetime
@@ -36,6 +37,8 @@ from extensions.ext_database import db
from models.enums import CreatorUserRole
from models.model import Message, MessageFile
logger = logging.getLogger(__name__)
class ToolEngine:
"""
@@ -123,25 +126,31 @@ class ToolEngine:
# transform tool invoke message to get LLM friendly message
return plain_text, message_files, meta
except ToolProviderCredentialValidationError as e:
logger.error(e, exc_info=True)
error_response = "Please check your tool provider credentials"
agent_tool_callback.on_tool_error(e)
except (ToolNotFoundError, ToolNotSupportedError, ToolProviderNotFoundError) as e:
error_response = f"there is not a tool named {tool.entity.identity.name}"
logger.error(e, exc_info=True)
agent_tool_callback.on_tool_error(e)
except ToolParameterValidationError as e:
error_response = f"tool parameters validation error: {e}, please check your tool parameters"
agent_tool_callback.on_tool_error(e)
logger.error(e, exc_info=True)
except ToolInvokeError as e:
error_response = f"tool invoke error: {e}"
agent_tool_callback.on_tool_error(e)
logger.error(e, exc_info=True)
except ToolEngineInvokeError as e:
meta = e.meta
error_response = f"tool invoke error: {meta.error}"
agent_tool_callback.on_tool_error(e)
logger.error(e, exc_info=True)
return error_response, [], meta
except Exception as e:
error_response = f"unknown error: {e}"
agent_tool_callback.on_tool_error(e)
logger.error(e, exc_info=True)
return error_response, [], ToolInvokeMeta.error_instance(error_response)
-22
View File
@@ -20,7 +20,6 @@ from core.tools.entities.tool_entities import (
)
from core.tools.errors import ToolInvokeError
from factories.file_factory import build_from_mapping
from libs.login import current_user
from models import Account, Tenant
from models.model import App, EndUser
from models.workflow import Workflow
@@ -28,21 +27,6 @@ from models.workflow import Workflow
logger = logging.getLogger(__name__)
def _try_resolve_user_from_request() -> Account | EndUser | None:
"""
Try to resolve user from Flask request context.
Returns None if not in a request context or if user is not available.
"""
# Note: `current_user` is a LocalProxy. Never compare it with None directly.
# Use _get_current_object() to dereference the proxy
user = getattr(current_user, "_get_current_object", lambda: current_user)()
# Check if we got a valid user object
if user is not None and hasattr(user, "id"):
return user
return None
class WorkflowTool(Tool):
"""
Workflow tool.
@@ -223,12 +207,6 @@ class WorkflowTool(Tool):
Returns:
Account | EndUser | None: The resolved user object, or None if resolution fails.
"""
# Try to resolve user from request context first
user = _try_resolve_user_from_request()
if user is not None:
return user
# Fall back to database resolution
return self._resolve_user_from_database(user_id=user_id)
def _resolve_user_from_database(self, user_id: str) -> Account | EndUser | None:
@@ -3,6 +3,7 @@ Execution Context - Abstracted context management for workflow execution.
"""
import contextvars
import threading
from abc import ABC, abstractmethod
from collections.abc import Callable, Generator
from contextlib import AbstractContextManager, contextmanager
@@ -88,6 +89,7 @@ class ExecutionContext:
self._app_context = app_context
self._context_vars = context_vars
self._user = user
self._local = threading.local()
@property
def app_context(self) -> AppContext | None:
@@ -125,14 +127,16 @@ class ExecutionContext:
def __enter__(self) -> "ExecutionContext":
"""Enter the execution context."""
self._cm = self.enter()
self._cm.__enter__()
cm = self.enter()
self._local.cm = cm
cm.__enter__()
return self
def __exit__(self, *args: Any) -> None:
"""Exit the execution context."""
if hasattr(self, "_cm"):
self._cm.__exit__(*args)
cm = getattr(self._local, "cm", None)
if cm is not None:
cm.__exit__(*args)
class NullAppContext(AppContext):
+1 -2
View File
@@ -11,7 +11,6 @@ import time
from collections.abc import Sequence
from datetime import datetime
from typing import TYPE_CHECKING, final
from uuid import uuid4
from typing_extensions import override
@@ -113,7 +112,7 @@ class Worker(threading.Thread):
self._ready_queue.task_done()
except Exception as e:
error_event = NodeRunFailedEvent(
id=str(uuid4()),
id=node.execution_id,
node_id=node.id,
node_type=node.node_type,
in_iteration_id=None,
+12 -1
View File
@@ -235,7 +235,18 @@ class AgentNode(Node[AgentNodeData]):
0,
):
value_param = param.get("value", {})
params[key] = value_param.get("value", "") if value_param is not None else None
if value_param and value_param.get("type", "") == "variable":
variable_selector = value_param.get("value")
if not variable_selector:
raise ValueError("Variable selector is missing for a variable-type parameter.")
variable = variable_pool.get(variable_selector)
if variable is None:
raise AgentVariableNotFoundError(str(variable_selector))
params[key] = variable.value
else:
params[key] = value_param.get("value", "") if value_param is not None else None
else:
params[key] = None
parameters = params
+2 -6
View File
@@ -469,12 +469,8 @@ class Node(Generic[NodeDataT]):
import core.workflow.nodes as _nodes_pkg
for _, _modname, _ in pkgutil.walk_packages(_nodes_pkg.__path__, _nodes_pkg.__name__ + "."):
# Avoid importing modules that depend on the registry to prevent circular imports
# e.g. node_factory imports node_mapping which builds the mapping here.
if _modname in {
"core.workflow.nodes.node_factory",
"core.workflow.nodes.node_mapping",
}:
# Avoid importing modules that depend on the registry to prevent circular imports.
if _modname == "core.workflow.nodes.node_mapping":
continue
importlib.import_module(_modname)
@@ -588,11 +588,11 @@ class IterationNode(LLMUsageTrackingMixin, Node[IterationNodeData]):
def _create_graph_engine(self, index: int, item: object):
# Import dependencies
from core.app.workflow.node_factory import DifyNodeFactory
from core.workflow.entities import GraphInitParams
from core.workflow.graph import Graph
from core.workflow.graph_engine import GraphEngine
from core.workflow.graph_engine.command_channels import InMemoryChannel
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.runtime import GraphRuntimeState
# Create GraphInitParams from node attributes
+1 -1
View File
@@ -413,11 +413,11 @@ class LoopNode(LLMUsageTrackingMixin, Node[LoopNodeData]):
def _create_graph_engine(self, start_at: datetime, root_node_id: str):
# Import dependencies
from core.app.workflow.node_factory import DifyNodeFactory
from core.workflow.entities import GraphInitParams
from core.workflow.graph import Graph
from core.workflow.graph_engine import GraphEngine
from core.workflow.graph_engine.command_channels import InMemoryChannel
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.runtime import GraphRuntimeState
# Create GraphInitParams from node attributes
+1 -1
View File
@@ -7,6 +7,7 @@ from typing import Any
from configs import dify_config
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.workflow.node_factory import DifyNodeFactory
from core.file.models import File
from core.workflow.constants import ENVIRONMENT_VARIABLE_NODE_ID
from core.workflow.entities import GraphInitParams
@@ -19,7 +20,6 @@ from core.workflow.graph_engine.protocols.command_channel import CommandChannel
from core.workflow.graph_events import GraphEngineEvent, GraphNodeEventBase, GraphRunFailedEvent
from core.workflow.nodes import NodeType
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.nodes.node_mapping import NODE_TYPE_CLASSES_MAPPING
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
@@ -0,0 +1,60 @@
"""make message annotation question not nullable
Revision ID: 9e6fa5cbcd80
Revises: 03f8dcbc611e
Create Date: 2025-11-06 16:03:54.549378
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '9e6fa5cbcd80'
down_revision = '288345cd01d1'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
message_annotations = sa.table(
"message_annotations",
sa.column("id", sa.String),
sa.column("message_id", sa.String),
sa.column("question", sa.Text),
)
messages = sa.table(
"messages",
sa.column("id", sa.String),
sa.column("query", sa.Text),
)
update_question_from_message = (
sa.update(message_annotations)
.where(
sa.and_(
message_annotations.c.question.is_(None),
message_annotations.c.message_id.isnot(None),
)
)
.values(
question=sa.select(sa.func.coalesce(messages.c.query, ""))
.where(messages.c.id == message_annotations.c.message_id)
.scalar_subquery()
)
)
bind.execute(update_question_from_message)
fill_remaining_questions = (
sa.update(message_annotations)
.where(message_annotations.c.question.is_(None))
.values(question="")
)
bind.execute(fill_remaining_questions)
with op.batch_alter_table('message_annotations', schema=None) as batch_op:
batch_op.alter_column('question', existing_type=sa.TEXT(), nullable=False)
def downgrade():
with op.batch_alter_table('message_annotations', schema=None) as batch_op:
batch_op.alter_column('question', existing_type=sa.TEXT(), nullable=True)
@@ -0,0 +1,73 @@
"""add table explore banner and trial
Revision ID: f9f6d18a37f9
Revises: 9e6fa5cbcd80
Create Date: 2026-01-017 11:10:18.079355
"""
from alembic import op
import models as models
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'f9f6d18a37f9'
down_revision = '9e6fa5cbcd80'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('account_trial_app_records',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('account_id', models.types.StringUUID(), nullable=False),
sa.Column('app_id', models.types.StringUUID(), nullable=False),
sa.Column('count', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.PrimaryKeyConstraint('id', name='user_trial_app_pkey'),
sa.UniqueConstraint('account_id', 'app_id', name='unique_account_trial_app_record')
)
with op.batch_alter_table('account_trial_app_records', schema=None) as batch_op:
batch_op.create_index('account_trial_app_record_account_id_idx', ['account_id'], unique=False)
batch_op.create_index('account_trial_app_record_app_id_idx', ['app_id'], unique=False)
op.create_table('exporle_banners',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('content', sa.JSON(), nullable=False),
sa.Column('link', sa.String(length=255), nullable=False),
sa.Column('sort', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=255), server_default=sa.text("'enabled'::character varying"), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('language', sa.String(length=255), server_default=sa.text("'en-US'::character varying"), nullable=False),
sa.PrimaryKeyConstraint('id', name='exporler_banner_pkey')
)
op.create_table('trial_apps',
sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False),
sa.Column('app_id', models.types.StringUUID(), nullable=False),
sa.Column('tenant_id', models.types.StringUUID(), nullable=False),
sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False),
sa.Column('trial_limit', sa.Integer(), nullable=False),
sa.PrimaryKeyConstraint('id', name='trial_app_pkey'),
sa.UniqueConstraint('app_id', name='unique_trail_app_id')
)
with op.batch_alter_table('trial_apps', schema=None) as batch_op:
batch_op.create_index('trial_app_app_id_idx', ['app_id'], unique=False)
batch_op.create_index('trial_app_tenant_id_idx', ['tenant_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('trial_apps', schema=None) as batch_op:
batch_op.drop_index('trial_app_tenant_id_idx')
batch_op.drop_index('trial_app_app_id_idx')
op.drop_table('trial_apps')
op.drop_table('exporle_banners')
with op.batch_alter_table('account_trial_app_records', schema=None) as batch_op:
batch_op.drop_index('account_trial_app_record_app_id_idx')
batch_op.drop_index('account_trial_app_record_account_id_idx')
op.drop_table('account_trial_app_records')
# ### end Alembic commands ###
+6
View File
@@ -35,6 +35,7 @@ from .enums import (
WorkflowTriggerStatus,
)
from .model import (
AccountTrialAppRecord,
ApiRequest,
ApiToken,
App,
@@ -47,6 +48,7 @@ from .model import (
DatasetRetrieverResource,
DifySetup,
EndUser,
ExporleBanner,
IconType,
InstalledApp,
Message,
@@ -62,6 +64,7 @@ from .model import (
TagBinding,
TenantCreditPool,
TraceAppConfig,
TrialApp,
UploadFile,
)
from .oauth import DatasourceOauthParamConfig, DatasourceProvider
@@ -114,6 +117,7 @@ __all__ = [
"Account",
"AccountIntegrate",
"AccountStatus",
"AccountTrialAppRecord",
"ApiRequest",
"ApiToken",
"ApiToolProvider",
@@ -150,6 +154,7 @@ __all__ = [
"DocumentSegment",
"Embedding",
"EndUser",
"ExporleBanner",
"ExternalKnowledgeApis",
"ExternalKnowledgeBindings",
"IconType",
@@ -188,6 +193,7 @@ __all__ = [
"ToolLabelBinding",
"ToolModelInvoke",
"TraceAppConfig",
"TrialApp",
"TriggerOAuthSystemClient",
"TriggerOAuthTenantClient",
"TriggerSubscription",
+59 -1
View File
@@ -603,6 +603,64 @@ class InstalledApp(TypeBase):
return tenant
class TrialApp(Base):
__tablename__ = "trial_apps"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="trial_app_pkey"),
sa.Index("trial_app_app_id_idx", "app_id"),
sa.Index("trial_app_tenant_id_idx", "tenant_id"),
sa.UniqueConstraint("app_id", name="unique_trail_app_id"),
)
id = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()"))
app_id = mapped_column(StringUUID, nullable=False)
tenant_id = mapped_column(StringUUID, nullable=False)
created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
trial_limit = mapped_column(sa.Integer, nullable=False, default=3)
@property
def app(self) -> App | None:
app = db.session.query(App).where(App.id == self.app_id).first()
return app
class AccountTrialAppRecord(Base):
__tablename__ = "account_trial_app_records"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="user_trial_app_pkey"),
sa.Index("account_trial_app_record_account_id_idx", "account_id"),
sa.Index("account_trial_app_record_app_id_idx", "app_id"),
sa.UniqueConstraint("account_id", "app_id", name="unique_account_trial_app_record"),
)
id = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()"))
account_id = mapped_column(StringUUID, nullable=False)
app_id = mapped_column(StringUUID, nullable=False)
count = mapped_column(sa.Integer, nullable=False, default=0)
created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
@property
def app(self) -> App | None:
app = db.session.query(App).where(App.id == self.app_id).first()
return app
@property
def user(self) -> Account | None:
user = db.session.query(Account).where(Account.id == self.account_id).first()
return user
class ExporleBanner(Base):
__tablename__ = "exporle_banners"
__table_args__ = (sa.PrimaryKeyConstraint("id", name="exporler_banner_pkey"),)
id = mapped_column(StringUUID, server_default=sa.text("uuid_generate_v4()"))
content = mapped_column(sa.JSON, nullable=False)
link = mapped_column(String(255), nullable=False)
sort = mapped_column(sa.Integer, nullable=False)
status = mapped_column(sa.String(255), nullable=False, server_default=sa.text("'enabled'::character varying"))
created_at = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
language = mapped_column(String(255), nullable=False, server_default=sa.text("'en-US'::character varying"))
class OAuthProviderApp(TypeBase):
"""
Globally shared OAuth provider app information.
@@ -1423,7 +1481,7 @@ class MessageAnnotation(Base):
app_id: Mapped[str] = mapped_column(StringUUID)
conversation_id: Mapped[str | None] = mapped_column(StringUUID, sa.ForeignKey("conversations.id"))
message_id: Mapped[str | None] = mapped_column(StringUUID)
question: Mapped[str | None] = mapped_column(LongText, nullable=True)
question: Mapped[str] = mapped_column(LongText, nullable=False)
content: Mapped[str] = mapped_column(LongText, nullable=False)
hit_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("0"))
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
+11 -3
View File
@@ -209,8 +209,12 @@ class AppAnnotationService:
if not app:
raise NotFound("App not found")
question = args.get("question")
if question is None:
raise ValueError("'question' is required")
annotation = MessageAnnotation(
app_id=app.id, content=args["answer"], question=args["question"], account_id=current_user.id
app_id=app.id, content=args["answer"], question=question, account_id=current_user.id
)
db.session.add(annotation)
db.session.commit()
@@ -219,7 +223,7 @@ class AppAnnotationService:
if annotation_setting:
add_annotation_to_index_task.delay(
annotation.id,
args["question"],
question,
current_tenant_id,
app_id,
annotation_setting.collection_binding_id,
@@ -244,8 +248,12 @@ class AppAnnotationService:
if not annotation:
raise NotFound("Annotation not found")
question = args.get("question")
if question is None:
raise ValueError("'question' is required")
annotation.content = args["answer"]
annotation.question = args["question"]
annotation.question = question
db.session.commit()
# if annotation reply is enabled , add annotation to index
+4
View File
@@ -170,6 +170,8 @@ class SystemFeatureModel(BaseModel):
plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel()
enable_change_email: bool = True
plugin_manager: PluginManagerModel = PluginManagerModel()
enable_trial_app: bool = False
enable_explore_banner: bool = False
class FeatureService:
@@ -225,6 +227,8 @@ class FeatureService:
system_features.is_allow_register = dify_config.ALLOW_REGISTER
system_features.is_allow_create_workspace = dify_config.ALLOW_CREATE_WORKSPACE
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER
@classmethod
def _fulfill_params_from_env(cls, features: FeatureModel):
+38
View File
@@ -1,4 +1,7 @@
from configs import dify_config
from extensions.ext_database import db
from models.model import AccountTrialAppRecord, TrialApp
from services.feature_service import FeatureService
from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory
@@ -20,6 +23,15 @@ class RecommendedAppService:
)
)
if FeatureService.get_system_features().enable_trial_app:
apps = result["recommended_apps"]
for app in apps:
app_id = app["app_id"]
trial_app_model = db.session.query(TrialApp).where(TrialApp.app_id == app_id).first()
if trial_app_model:
app["can_trial"] = True
else:
app["can_trial"] = False
return result
@classmethod
@@ -32,4 +44,30 @@ class RecommendedAppService:
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
result: dict = retrieval_instance.get_recommend_app_detail(app_id)
if FeatureService.get_system_features().enable_trial_app:
app_id = result["id"]
trial_app_model = db.session.query(TrialApp).where(TrialApp.app_id == app_id).first()
if trial_app_model:
result["can_trial"] = True
else:
result["can_trial"] = False
return result
@classmethod
def add_trial_app_record(cls, app_id: str, account_id: str):
"""
Add trial app record.
:param app_id: app id
:return:
"""
account_trial_app_record = (
db.session.query(AccountTrialAppRecord)
.where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id)
.first()
)
if account_trial_app_record:
account_trial_app_record.count += 1
db.session.commit()
else:
db.session.add(AccountTrialAppRecord(app_id=app_id, count=1, account_id=account_id))
db.session.commit()
@@ -5,13 +5,13 @@ import pytest
from configs import dify_config
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.workflow.node_factory import DifyNodeFactory
from core.workflow.entities import GraphInitParams
from core.workflow.enums import WorkflowNodeExecutionStatus
from core.workflow.graph import Graph
from core.workflow.node_events import NodeRunResult
from core.workflow.nodes.code.code_node import CodeNode
from core.workflow.nodes.code.limits import CodeNodeLimits
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
from models.enums import UserFrom
@@ -5,11 +5,11 @@ from urllib.parse import urlencode
import pytest
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.workflow.node_factory import DifyNodeFactory
from core.workflow.entities import GraphInitParams
from core.workflow.enums import WorkflowNodeExecutionStatus
from core.workflow.graph import Graph
from core.workflow.nodes.http_request.node import HttpRequestNode
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
from models.enums import UserFrom
@@ -5,13 +5,13 @@ from collections.abc import Generator
from unittest.mock import MagicMock, patch
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.workflow.node_factory import DifyNodeFactory
from core.llm_generator.output_parser.structured_output import _parse_structured_output
from core.workflow.entities import GraphInitParams
from core.workflow.enums import WorkflowNodeExecutionStatus
from core.workflow.graph import Graph
from core.workflow.node_events import StreamCompletedEvent
from core.workflow.nodes.llm.node import LLMNode
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
from extensions.ext_database import db
@@ -4,11 +4,11 @@ import uuid
from unittest.mock import MagicMock
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.workflow.node_factory import DifyNodeFactory
from core.model_runtime.entities import AssistantPromptMessage
from core.workflow.entities import GraphInitParams
from core.workflow.enums import WorkflowNodeExecutionStatus
from core.workflow.graph import Graph
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.nodes.parameter_extractor.parameter_extractor_node import ParameterExtractorNode
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
@@ -4,10 +4,10 @@ import uuid
import pytest
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.workflow.node_factory import DifyNodeFactory
from core.workflow.entities import GraphInitParams
from core.workflow.enums import WorkflowNodeExecutionStatus
from core.workflow.graph import Graph
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.nodes.template_transform.template_transform_node import TemplateTransformNode
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
@@ -3,12 +3,12 @@ import uuid
from unittest.mock import MagicMock
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.workflow.node_factory import DifyNodeFactory
from core.tools.utils.configuration import ToolParameterConfigurationManager
from core.workflow.entities import GraphInitParams
from core.workflow.enums import WorkflowNodeExecutionStatus
from core.workflow.graph import Graph
from core.workflow.node_events import StreamCompletedEvent
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.nodes.tool.tool_node import ToolNode
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
@@ -220,6 +220,23 @@ class TestAnnotationService:
# Note: In this test, no annotation setting exists, so task should not be called
mock_external_service_dependencies["add_task"].delay.assert_not_called()
def test_insert_app_annotation_directly_requires_question(
self, db_session_with_containers, mock_external_service_dependencies
):
"""
Question must be provided when inserting annotations directly.
"""
fake = Faker()
app, _ = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
annotation_args = {
"question": None,
"answer": fake.text(max_nb_chars=200),
}
with pytest.raises(ValueError):
AppAnnotationService.insert_app_annotation_directly(annotation_args, app.id)
def test_insert_app_annotation_directly_app_not_found(
self, db_session_with_containers, mock_external_service_dependencies
):
@@ -1,6 +1,8 @@
"""Tests for execution context module."""
import contextvars
import threading
from contextlib import contextmanager
from typing import Any
from unittest.mock import MagicMock
@@ -149,6 +151,54 @@ class TestExecutionContext:
assert ctx.user == user
def test_thread_safe_context_manager(self):
"""Test shared ExecutionContext works across threads without token mismatch."""
test_var = contextvars.ContextVar("thread_safe_test_var")
class TrackingAppContext(AppContext):
def get_config(self, key: str, default: Any = None) -> Any:
return default
def get_extension(self, name: str) -> Any:
return None
@contextmanager
def enter(self):
token = test_var.set(threading.get_ident())
try:
yield
finally:
test_var.reset(token)
ctx = ExecutionContext(app_context=TrackingAppContext())
errors: list[Exception] = []
barrier = threading.Barrier(2)
def worker():
try:
for _ in range(20):
with ctx:
try:
barrier.wait()
barrier.wait()
except threading.BrokenBarrierError:
return
except Exception as exc:
errors.append(exc)
try:
barrier.abort()
except Exception:
pass
t1 = threading.Thread(target=worker)
t2 = threading.Thread(target=worker)
t1.start()
t2.start()
t1.join(timeout=5)
t2.join(timeout=5)
assert not errors
class TestIExecutionContextProtocol:
"""Test IExecutionContext protocol."""
@@ -7,9 +7,9 @@ requiring external services (LLM, Agent, Tool, Knowledge Retrieval, HTTP Request
from typing import TYPE_CHECKING, Any
from core.app.workflow.node_factory import DifyNodeFactory
from core.workflow.enums import NodeType
from core.workflow.nodes.base.node import Node
from core.workflow.nodes.node_factory import DifyNodeFactory
from .test_mock_nodes import (
MockAgentNode,
@@ -13,6 +13,7 @@ from unittest.mock import patch
from uuid import uuid4
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.workflow.node_factory import DifyNodeFactory
from core.workflow.entities import GraphInitParams
from core.workflow.enums import NodeType, WorkflowNodeExecutionStatus
from core.workflow.graph import Graph
@@ -26,7 +27,6 @@ from core.workflow.graph_events import (
)
from core.workflow.node_events import NodeRunResult, StreamCompletedEvent
from core.workflow.nodes.llm.node import LLMNode
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
from models.enums import UserFrom
@@ -19,6 +19,7 @@ from functools import lru_cache
from pathlib import Path
from typing import Any
from core.app.workflow.node_factory import DifyNodeFactory
from core.tools.utils.yaml_utils import _load_yaml_file
from core.variables import (
ArrayNumberVariable,
@@ -38,7 +39,6 @@ from core.workflow.graph_events import (
GraphRunStartedEvent,
GraphRunSucceededEvent,
)
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
@@ -3,11 +3,11 @@ import uuid
from unittest.mock import MagicMock
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.workflow.node_factory import DifyNodeFactory
from core.workflow.entities import GraphInitParams
from core.workflow.enums import WorkflowNodeExecutionStatus
from core.workflow.graph import Graph
from core.workflow.nodes.answer.answer_node import AnswerNode
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
from extensions.ext_database import db
@@ -5,6 +5,7 @@ from unittest.mock import MagicMock, Mock
import pytest
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.workflow.node_factory import DifyNodeFactory
from core.file import File, FileTransferMethod, FileType
from core.variables import ArrayFileSegment
from core.workflow.entities import GraphInitParams
@@ -12,7 +13,6 @@ from core.workflow.enums import WorkflowNodeExecutionStatus
from core.workflow.graph import Graph
from core.workflow.nodes.if_else.entities import IfElseNodeData
from core.workflow.nodes.if_else.if_else_node import IfElseNode
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.runtime import GraphRuntimeState, VariablePool
from core.workflow.system_variable import SystemVariable
from core.workflow.utils.condition.entities import Condition, SubCondition, SubVariableCondition
@@ -3,11 +3,11 @@ import uuid
from uuid import uuid4
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.workflow.node_factory import DifyNodeFactory
from core.variables import ArrayStringVariable, StringVariable
from core.workflow.entities import GraphInitParams
from core.workflow.graph import Graph
from core.workflow.graph_events.node import NodeRunSucceededEvent
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.nodes.variable_assigner.common import helpers as common_helpers
from core.workflow.nodes.variable_assigner.v1 import VariableAssignerNode
from core.workflow.nodes.variable_assigner.v1.node_data import WriteMode
@@ -3,10 +3,10 @@ import uuid
from uuid import uuid4
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.workflow.node_factory import DifyNodeFactory
from core.variables import ArrayStringVariable
from core.workflow.entities import GraphInitParams
from core.workflow.graph import Graph
from core.workflow.nodes.node_factory import DifyNodeFactory
from core.workflow.nodes.variable_assigner.v2 import VariableAssignerNode
from core.workflow.nodes.variable_assigner.v2.enums import InputType, Operation
from core.workflow.runtime import GraphRuntimeState, VariablePool
+1 -1
View File
@@ -138,7 +138,7 @@ This will help you determine the testing strategy. See [web/testing/testing.md](
## Documentation
Visit <https://docs.dify.ai/getting-started/readme> to view the full documentation.
Visit <https://docs.dify.ai> to view the full documentation.
## Community
@@ -23,12 +23,14 @@ import AppSideBar from '@/app/components/app-sidebar'
import { useStore } from '@/app/components/app/store'
import Loading from '@/app/components/base/loading'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { fetchAppDetailDirect } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import s from './style.module.css'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
@@ -108,7 +110,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
useEffect(() => {
if (appDetail) {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const localeMode = storage.get<string>(STORAGE_KEYS.APP.DETAIL_COLLAPSE) || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
// TODO: consider screen size and mode
@@ -5,7 +5,6 @@ import type { BlockEnum } from '@/app/components/workflow/types'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import type { App } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
@@ -16,8 +15,7 @@ import Loading from '@/app/components/base/loading'
import { ToastContext } from '@/app/components/base/toast'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
import { isTriggerNode } from '@/app/components/workflow/types'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useDocLink } from '@/context/i18n'
import { STORAGE_KEYS } from '@/config/storage-keys'
import {
fetchAppDetail,
updateAppSiteAccessToken,
@@ -27,6 +25,7 @@ import {
import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
import { storage } from '@/utils/storage'
export type ICardViewProps = {
appId: string
@@ -36,7 +35,6 @@ export type ICardViewProps = {
const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useContext(ToastContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
@@ -59,25 +57,13 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const shouldRenderAppCards = !isWorkflowApp || hasTriggerNode === false
const disableAppCards = !shouldRenderAppCards
const triggerDocUrl = docLink('/guides/workflow/node/start')
const buildTriggerModeMessage = useCallback((featureName: string) => (
<div className="flex flex-col gap-1">
<div className="text-xs text-text-secondary">
{t('overview.disableTooltip.triggerMode', { ns: 'appOverview', feature: featureName })}
</div>
<a
href={triggerDocUrl}
target="_blank"
rel="noopener noreferrer"
className="block cursor-pointer text-xs font-medium text-text-accent hover:underline"
onClick={(event) => {
event.stopPropagation()
}}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</a>
</div>
), [t, triggerDocUrl])
), [t])
const disableWebAppTooltip = disableAppCards
? buildTriggerModeMessage(t('overview.appInfo.title', { ns: 'appOverview' }))
@@ -141,7 +127,7 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
}) as Promise<App>,
)
if (!err)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
handleCallbackResult(err)
}
@@ -18,6 +18,7 @@ import { useStore } from '@/app/components/app/store'
import { PipelineFill, PipelineLine } from '@/app/components/base/icons/src/vender/pipeline'
import Loading from '@/app/components/base/loading'
import ExtraInfo from '@/app/components/datasets/extra-info'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import DatasetDetailContext from '@/context/dataset-detail'
import { useEventEmitterContextContext } from '@/context/event-emitter'
@@ -25,6 +26,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
export type IAppDetailLayoutProps = {
children: React.ReactNode
@@ -40,7 +42,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const pathname = usePathname()
const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const workflowCanvasMaximize = storage.getBoolean(STORAGE_KEYS.WORKFLOW.CANVAS_MAXIMIZE, false)
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()
@@ -110,7 +112,7 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand)
useEffect(() => {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const localeMode = storage.get<string>(STORAGE_KEYS.APP.DETAIL_COLLAPSE) || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
}, [isMobile, setAppSidebarExpand])
@@ -8,12 +8,14 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useLocale } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title'
import { sendResetPasswordCode } from '@/service/common'
import { storage } from '@/utils/storage'
export default function CheckCode() {
const { t } = useTranslation()
@@ -41,7 +43,7 @@ export default function CheckCode() {
setIsLoading(true)
const res = await sendResetPasswordCode(email, locale)
if (res.result === 'success') {
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS)
const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(res.data))
params.set('email', encodeURIComponent(email))
@@ -5,10 +5,12 @@ import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useLocale } from '@/context/i18n'
import { sendWebAppEMailLoginCode } from '@/service/common'
import { storage } from '@/utils/storage'
export default function MailAndCodeAuth() {
const { t } = useTranslation()
@@ -36,7 +38,7 @@ export default function MailAndCodeAuth() {
setIsLoading(true)
const ret = await sendWebAppEMailLoginCode(email, locale)
if (ret.result === 'success') {
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS)
const params = new URLSearchParams(searchParams)
params.set('email', encodeURIComponent(email))
params.set('token', encodeURIComponent(ret.data))
@@ -10,6 +10,7 @@ import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast'
import { STORAGE_KEYS } from '@/config/storage-keys'
import {
checkEmailExisted,
resetEmail,
@@ -18,6 +19,7 @@ import {
} from '@/service/common'
import { useLogout } from '@/service/use-common'
import { asyncRunSafe } from '@/utils'
import { storage } from '@/utils/storage'
type Props = {
show: boolean
@@ -172,7 +174,7 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS)
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')
+3 -1
View File
@@ -10,9 +10,11 @@ import { resetUser } from '@/app/components/base/amplitude/utils'
import Avatar from '@/app/components/base/avatar'
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
import PremiumBadge from '@/app/components/base/premium-badge'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { useLogout } from '@/service/use-common'
import { storage } from '@/utils/storage'
export type IAppSelector = {
isMobile: boolean
@@ -28,7 +30,7 @@ export default function AppSelector() {
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
storage.remove(STORAGE_KEYS.CONFIG.SETUP_STATUS)
resetUser()
// Tokens are now stored in cookies and cleared by backend
@@ -2,7 +2,9 @@
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CustomDialog from '@/app/components/base/dialog'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { storage } from '@/utils/storage'
import CheckEmail from './components/check-email'
import FeedBack from './components/feed-back'
import VerifyEmail from './components/verify-email'
@@ -21,7 +23,7 @@ export default function DeleteAccount(props: DeleteAccountProps) {
const handleEmailCheckSuccess = useCallback(async () => {
try {
setShowVerifyEmail(true)
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
storage.set(STORAGE_KEYS.UI.COUNTDOWN_LEFT_TIME, COUNT_DOWN_TIME_MS)
}
catch (error) { console.error(error) }
}, [])
+4 -3
View File
@@ -17,11 +17,12 @@ import Button from '@/app/components/base/button'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useIsLogin } from '@/service/use-common'
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
import { storage } from '@/utils/storage'
import {
OAUTH_AUTHORIZE_PENDING_KEY,
OAUTH_AUTHORIZE_PENDING_TTL,
REDIRECT_URL_KEY,
} from './constants'
@@ -31,7 +32,7 @@ function setItemWithExpiry(key: string, value: string, ttl: number) {
value,
expiry: dayjs().add(ttl, 'seconds').unix(),
}
localStorage.setItem(key, JSON.stringify(item))
storage.set(key, JSON.stringify(item))
}
function buildReturnUrl(pathname: string, search: string) {
@@ -86,7 +87,7 @@ export default function OAuthAuthorize() {
const onLoginSwitchClick = () => {
try {
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`)
setItemWithExpiry(OAUTH_AUTHORIZE_PENDING_KEY, returnUrl, OAUTH_AUTHORIZE_PENDING_TTL)
setItemWithExpiry(STORAGE_KEYS.AUTH.OAUTH_AUTHORIZE_PENDING, returnUrl, OAUTH_AUTHORIZE_PENDING_TTL)
router.push(`/signin?${REDIRECT_URL_KEY}=${encodeURIComponent(returnUrl)}`)
}
catch {
+5 -2
View File
@@ -7,13 +7,16 @@ import { parseAsString, useQueryState } from 'nuqs'
import { useCallback, useEffect, useState } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
} from '@/app/education-apply/constants'
import { LEGACY_KEY_MIGRATIONS, STORAGE_KEYS } from '@/config/storage-keys'
import { sendGAEvent } from '@/utils/gtag'
import { fetchSetupStatusWithCache } from '@/utils/setup-status'
import { storage } from '@/utils/storage'
import { resolvePostLoginRedirect } from '../signin/utils/post-login-redirect'
import { trackEvent } from './base/amplitude'
storage.runMigrations(LEGACY_KEY_MIGRATIONS)
type AppInitializerProps = {
children: ReactNode
}
@@ -75,7 +78,7 @@ export const AppInitializer = ({
}
if (action === EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION)
localStorage.setItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM, 'yes')
storage.set(STORAGE_KEYS.EDUCATION.VERIFYING, 'yes')
try {
const isFinished = await isSetupFinished()
+3 -2
View File
@@ -22,7 +22,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import { ToastContext } from '@/app/components/base/toast'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
@@ -31,6 +31,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AppIcon from '../base/app-icon'
import AppOperations from './app-operations'
@@ -128,7 +129,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
type: 'success',
message: t('newApp.appCreated', { ns: 'app' }),
})
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
onPlanInfoChanged()
getRedirection(true, newApp, replace)
}
+4 -2
View File
@@ -5,9 +5,11 @@ import * as React from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import Divider from '../base/divider'
import { getKeyboardKeyCodeBySystem } from '../workflow/utils'
import AppInfo from './app-info'
@@ -53,7 +55,7 @@ const AppDetailNav = ({
const pathname = usePathname()
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const workflowCanvasMaximize = storage.getBoolean(STORAGE_KEYS.WORKFLOW.CANVAS_MAXIMIZE, false)
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()
@@ -64,7 +66,7 @@ const AppDetailNav = ({
useEffect(() => {
if (appSidebarExpand) {
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
storage.set(STORAGE_KEYS.APP.DETAIL_COLLAPSE, appSidebarExpand)
setAppSidebarExpand(appSidebarExpand)
}
}, [appSidebarExpand, setAppSidebarExpand])
@@ -48,7 +48,7 @@ const CSVUploader: FC<Props> = ({
setDragging(false)
if (!e.dataTransfer)
return
const files = [...e.dataTransfer.files]
const files = Array.from(e.dataTransfer.files)
if (files.length > 1) {
notify({ type: 'error', message: t('stepOne.uploader.validation.count', { ns: 'datasetCreation' }) })
return
@@ -1,12 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import HistoryPanel from './history-panel'
const mockDocLink = vi.fn(() => 'doc-link')
vi.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,
}))
vi.mock('@/app/components/app/configuration/base/operation-btn', () => ({
default: ({ onClick }: { onClick: () => void }) => (
<button type="button" data-testid="edit-button" onClick={onClick}>
@@ -24,12 +18,10 @@ describe('HistoryPanel', () => {
vi.clearAllMocks()
})
it('should render warning content and link when showWarning is true', () => {
it('should render warning content when showWarning is true', () => {
render(<HistoryPanel showWarning onShowEditModal={vi.fn()} />)
expect(screen.getByText('appDebug.feature.conversationHistory.tip')).toBeInTheDocument()
const link = screen.getByText('appDebug.feature.conversationHistory.learnMore')
expect(link).toHaveAttribute('href', 'doc-link')
})
it('should hide warning when showWarning is false', () => {
@@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
import Panel from '@/app/components/app/configuration/base/feature-panel'
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { useDocLink } from '@/context/i18n'
type Props = {
showWarning: boolean
@@ -17,8 +16,6 @@ const HistoryPanel: FC<Props> = ({
onShowEditModal,
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
return (
<Panel
className="mt-2"
@@ -45,14 +42,6 @@ const HistoryPanel: FC<Props> = ({
<div className="flex justify-between rounded-b-xl bg-background-section-burn px-3 py-2 text-xs text-text-secondary">
<div>
{t('feature.conversationHistory.tip', { ns: 'appDebug' })}
<a
href={docLink('/learn-more/extended-reading/what-is-llmops', { 'zh-Hans': '/learn-more/extended-reading/prompt-engineering/README' })}
target="_blank"
rel="noopener noreferrer"
className="text-[#155EEF]"
>
{t('feature.conversationHistory.learnMore', { ns: 'appDebug' })}
</a>
</div>
</div>
)}
@@ -271,9 +271,9 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
</div>
)}
{hasVar && (
<div className="mt-1 px-3 pb-3">
<div className={cn('mt-1 grid px-3 pb-3')}>
<ReactSortable
className="space-y-1"
className={cn('grid-col-1 grid space-y-1', readonly && 'grid-cols-2 gap-1 space-y-0')}
list={promptVariablesWithIds}
setList={(list) => { onPromptVariablesChange?.(list.map(item => item.variable)) }}
handle=".handle"
@@ -39,7 +39,7 @@ const VarItem: FC<ItemProps> = ({
const [isDeleting, setIsDeleting] = useState(false)
return (
<div className={cn('group relative mb-1 flex h-[34px] w-full items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 pr-3 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover', readonly && 'cursor-not-allowed opacity-30', className)}>
<div className={cn('group relative mb-1 flex h-[34px] w-full items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 pr-3 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover', readonly && 'cursor-not-allowed', className)}>
<VarIcon className={cn('mr-1 h-4 w-4 shrink-0 text-text-accent', canDrag && 'group-hover:opacity-0')} />
{canDrag && (
<RiDraggable className="absolute left-3 top-3 hidden h-3 w-3 cursor-pointer text-text-tertiary group-hover:block" />
@@ -1,5 +1,6 @@
'use client'
import type { FC } from 'react'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer'
import * as React from 'react'
import { useCallback } from 'react'
@@ -10,14 +11,17 @@ import { useFeatures, useFeaturesStore } from '@/app/components/base/features/ho
import { Vision } from '@/app/components/base/icons/src/vender/features'
import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip'
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
// import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import ConfigContext from '@/context/debug-configuration'
import { Resolution } from '@/types/app'
import { cn } from '@/utils/classnames'
import ParamConfig from './param-config'
const ConfigVision: FC = () => {
const { t } = useTranslation()
const { isShowVisionConfig, isAllowVideoUpload } = useContext(ConfigContext)
const { isShowVisionConfig, isAllowVideoUpload, readonly } = useContext(ConfigContext)
const file = useFeatures(s => s.features.file)
const featuresStore = useFeaturesStore()
@@ -54,7 +58,7 @@ const ConfigVision: FC = () => {
setFeatures(newFeatures)
}, [featuresStore, isAllowVideoUpload])
if (!isShowVisionConfig)
if (!isShowVisionConfig || (readonly && !isImageEnabled))
return null
return (
@@ -75,37 +79,55 @@ const ConfigVision: FC = () => {
/>
</div>
<div className="flex shrink-0 items-center">
{/* <div className='mr-2 flex items-center gap-0.5'>
<div className='text-text-tertiary system-xs-medium-uppercase'>{t('appDebug.vision.visionSettings.resolution')}</div>
<Tooltip
popupContent={
<div className='w-[180px]' >
{t('appDebug.vision.visionSettings.resolutionTooltip').split('\n').map(item => (
<div key={item}>{item}</div>
))}
</div>
}
/>
</div> */}
{/* <div className='flex items-center gap-1'>
<OptionCard
title={t('appDebug.vision.visionSettings.high')}
selected={file?.image?.detail === Resolution.high}
onSelect={() => handleChange(Resolution.high)}
/>
<OptionCard
title={t('appDebug.vision.visionSettings.low')}
selected={file?.image?.detail === Resolution.low}
onSelect={() => handleChange(Resolution.low)}
/>
</div> */}
<ParamConfig />
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-regular"></div>
<Switch
defaultValue={isImageEnabled}
onChange={handleChange}
size="md"
/>
{readonly
? (
<>
<div className="mr-2 flex items-center gap-0.5">
<div className="system-xs-medium-uppercase text-text-tertiary">{t('vision.visionSettings.resolution', { ns: 'appDebug' })}</div>
<Tooltip
popupContent={(
<div className="w-[180px]">
{t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
<div key={item}>{item}</div>
))}
</div>
)}
/>
</div>
<div className="flex items-center gap-1">
<OptionCard
title={t('vision.visionSettings.high', { ns: 'appDebug' })}
selected={file?.image?.detail === Resolution.high}
onSelect={noop}
className={cn(
'cursor-not-allowed rounded-lg px-3 hover:shadow-none',
file?.image?.detail !== Resolution.high && 'hover:border-components-option-card-option-border',
)}
/>
<OptionCard
title={t('vision.visionSettings.low', { ns: 'appDebug' })}
selected={file?.image?.detail === Resolution.low}
onSelect={noop}
className={cn(
'cursor-not-allowed rounded-lg px-3 hover:shadow-none',
file?.image?.detail !== Resolution.low && 'hover:border-components-option-card-option-border',
)}
/>
</div>
</>
)
: (
<>
<ParamConfig />
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-regular"></div>
<Switch
defaultValue={isImageEnabled}
onChange={handleChange}
size="md"
/>
</>
)}
</div>
</div>
)
@@ -40,7 +40,7 @@ type AgentToolWithMoreInfo = AgentTool & { icon: any, collection?: Collection }
const AgentTools: FC = () => {
const { t } = useTranslation()
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
const { modelConfig, setModelConfig } = useContext(ConfigContext)
const { readonly, modelConfig, setModelConfig } = useContext(ConfigContext)
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
@@ -168,10 +168,10 @@ const AgentTools: FC = () => {
{tools.filter(item => !!item.enabled).length}
/
{tools.length}
&nbsp;
&nbsp;
{t('agent.tools.enabled', { ns: 'appDebug' })}
</div>
{tools.length < MAX_TOOLS_NUM && (
{tools.length < MAX_TOOLS_NUM && !readonly && (
<>
<div className="ml-3 mr-1 h-3.5 w-px bg-divider-regular"></div>
<ToolPicker
@@ -189,7 +189,7 @@ const AgentTools: FC = () => {
</div>
)}
>
<div className="grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2">
<div className={cn('grid grid-cols-1 items-center gap-1 2xl:grid-cols-2', readonly && 'cursor-not-allowed grid-cols-2')}>
{tools.map((item: AgentTool & { icon: any, collection?: Collection }, index) => (
<div
key={index}
@@ -214,7 +214,7 @@ const AgentTools: FC = () => {
>
<span className="system-xs-medium pr-1.5 text-text-secondary">{getProviderShowName(item)}</span>
<span className="text-text-tertiary">{item.tool_label}</span>
{!item.isDeleted && (
{!item.isDeleted && !readonly && (
<Tooltip
popupContent={(
<div className="w-[180px]">
@@ -259,7 +259,7 @@ const AgentTools: FC = () => {
</div>
</div>
)}
{!item.isDeleted && (
{!item.isDeleted && !readonly && (
<div className="mr-2 hidden items-center gap-1 group-hover:flex">
{!item.notAuthor && (
<Tooltip
@@ -298,7 +298,7 @@ const AgentTools: FC = () => {
{!item.notAuthor && (
<Switch
defaultValue={item.isDeleted ? false : item.enabled}
disabled={item.isDeleted}
disabled={item.isDeleted || readonly}
size="md"
onChange={(enabled) => {
const newModelConfig = produce(modelConfig, (draft) => {
@@ -312,6 +312,7 @@ const AgentTools: FC = () => {
{item.notAuthor && (
<Button
variant="secondary"
disabled={readonly}
size="small"
onClick={() => {
setCurrentTool(item)
@@ -30,8 +30,10 @@ import { ModelTypeEnum } from '@/app/components/header/account-setting/model-pro
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { generateBasicAppFirstTimeRule, generateRule } from '@/service/debug'
import { useGenerateRuleTemplate } from '@/service/use-apps'
import { storage } from '@/utils/storage'
import IdeaOutput from './idea-output'
import InstructionEditorInBasic from './instruction-editor'
import InstructionEditorInWorkflow from './instruction-editor-in-workflow'
@@ -83,9 +85,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
onFinished,
}) => {
const { t } = useTranslation()
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
@@ -178,9 +178,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
useEffect(() => {
if (defaultModel) {
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
if (localModel) {
setModel(localModel)
}
@@ -209,7 +207,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
mode: newValue.mode as ModelModeType,
}
setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
@@ -218,7 +216,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
completion_params: newParams as CompletionParams,
}
setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel])
const onGenerate = async () => {
@@ -17,8 +17,10 @@ import Toast from '@/app/components/base/toast'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { generateRule } from '@/service/debug'
import { useGenerateRuleTemplate } from '@/service/use-apps'
import { storage } from '@/utils/storage'
import { languageMap } from '../../../../workflow/nodes/_base/components/editor/code-editor/index'
import IdeaOutput from '../automatic/idea-output'
import InstructionEditor from '../automatic/instruction-editor-in-workflow'
@@ -62,9 +64,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
presence_penalty: 0,
frequency_penalty: 0,
}
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
const [model, setModel] = React.useState<Model>(localModel || {
name: '',
provider: '',
@@ -115,7 +115,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
mode: newValue.mode as ModelModeType,
}
setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel])
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
@@ -124,7 +124,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
completion_params: newParams as CompletionParams,
}
setModel(newModel)
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
storage.set(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL, newModel)
}, [model, setModel])
const onGenerate = async () => {
@@ -168,9 +168,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
useEffect(() => {
if (defaultModel) {
const localModel = localStorage.getItem('auto-gen-model')
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
: null
const localModel = storage.get<Model>(STORAGE_KEYS.CONFIG.AUTO_GEN_MODEL)
if (localModel) {
setModel({
...localModel,
@@ -17,7 +17,7 @@ const ConfigAudio: FC = () => {
const { t } = useTranslation()
const file = useFeatures(s => s.features.file)
const featuresStore = useFeaturesStore()
const { isShowAudioConfig } = useContext(ConfigContext)
const { isShowAudioConfig, readonly } = useContext(ConfigContext)
const isAudioEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.audio) ?? false
@@ -45,7 +45,7 @@ const ConfigAudio: FC = () => {
setFeatures(newFeatures)
}, [featuresStore])
if (!isShowAudioConfig)
if (!isShowAudioConfig || (readonly && !isAudioEnabled))
return null
return (
@@ -65,14 +65,16 @@ const ConfigAudio: FC = () => {
)}
/>
</div>
<div className="flex shrink-0 items-center">
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
<Switch
defaultValue={isAudioEnabled}
onChange={handleChange}
size="md"
/>
</div>
{!readonly && (
<div className="flex shrink-0 items-center">
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
<Switch
defaultValue={isAudioEnabled}
onChange={handleChange}
size="md"
/>
</div>
)}
</div>
)
}
@@ -17,7 +17,7 @@ const ConfigDocument: FC = () => {
const { t } = useTranslation()
const file = useFeatures(s => s.features.file)
const featuresStore = useFeaturesStore()
const { isShowDocumentConfig } = useContext(ConfigContext)
const { isShowDocumentConfig, readonly } = useContext(ConfigContext)
const isDocumentEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.document) ?? false
@@ -45,7 +45,7 @@ const ConfigDocument: FC = () => {
setFeatures(newFeatures)
}, [featuresStore])
if (!isShowDocumentConfig)
if (!isShowDocumentConfig || (readonly && !isDocumentEnabled))
return null
return (
@@ -65,14 +65,16 @@ const ConfigDocument: FC = () => {
)}
/>
</div>
<div className="flex shrink-0 items-center">
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
<Switch
defaultValue={isDocumentEnabled}
onChange={handleChange}
size="md"
/>
</div>
{!readonly && (
<div className="flex shrink-0 items-center">
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
<Switch
defaultValue={isDocumentEnabled}
onChange={handleChange}
size="md"
/>
</div>
)}
</div>
)
}
@@ -18,6 +18,7 @@ import ConfigDocument from './config-document'
const Config: FC = () => {
const {
readonly,
mode,
isAdvancedMode,
modelModeType,
@@ -27,6 +28,7 @@ const Config: FC = () => {
modelConfig,
setModelConfig,
setPrevPromptConfig,
dataSets,
} = useContext(ConfigContext)
const isChatApp = [AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes(mode)
const formattingChangedDispatcher = useFormattingChangedDispatcher()
@@ -65,19 +67,27 @@ const Config: FC = () => {
promptTemplate={promptTemplate}
promptVariables={promptVariables}
onChange={handlePromptChange}
readonly={readonly}
/>
{/* Variables */}
<ConfigVar
promptVariables={promptVariables}
onPromptVariablesChange={handlePromptVariablesNameChange}
/>
{!(readonly && promptVariables.length === 0) && (
<ConfigVar
promptVariables={promptVariables}
onPromptVariablesChange={handlePromptVariablesNameChange}
readonly={readonly}
/>
)}
{/* Dataset */}
<DatasetConfig />
{!(readonly && dataSets.length === 0) && (
<DatasetConfig
readonly={readonly}
hideMetadataFilter={readonly}
/>
)}
{/* Tools */}
{isAgent && (
{isAgent && !(readonly && modelConfig.agentConfig.tools.length === 0) && (
<AgentTools />
)}
@@ -88,7 +98,7 @@ const Config: FC = () => {
<ConfigAudio />
{/* Chat History */}
{isAdvancedMode && isChatApp && modelModeType === ModelModeType.completion && (
{!readonly && isAdvancedMode && isChatApp && modelModeType === ModelModeType.completion && (
<HistoryPanel
showWarning={!hasSetBlockStatus.history}
onShowEditModal={showHistoryModal}
@@ -183,7 +183,7 @@ describe('dataset-config/card-item', () => {
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' }))
})
await waitFor(() => {
expect(screen.getByText('Mock settings modal')).not.toBeVisible()
expect(screen.queryByText('Mock settings modal')).not.toBeInTheDocument()
})
})
@@ -30,6 +30,7 @@ const Item: FC<ItemProps> = ({
config,
onSave,
onRemove,
readonly = false,
editable = true,
}) => {
const media = useBreakpoints()
@@ -56,6 +57,7 @@ const Item: FC<ItemProps> = ({
<div className={cn(
'group relative mb-1 flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover',
isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover',
readonly && 'cursor-not-allowed',
)}
>
<div className="flex w-0 grow items-center space-x-1.5">
@@ -70,7 +72,7 @@ const Item: FC<ItemProps> = ({
</div>
<div className="ml-2 hidden shrink-0 items-center space-x-1 group-hover:flex">
{
editable && (
editable && !readonly && (
<ActionButton
onClick={(e) => {
e.stopPropagation()
@@ -81,14 +83,18 @@ const Item: FC<ItemProps> = ({
</ActionButton>
)
}
<ActionButton
onClick={() => onRemove(config.id)}
state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default}
onMouseEnter={() => setIsDeleting(true)}
onMouseLeave={() => setIsDeleting(false)}
>
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary', isDeleting && 'text-text-destructive')} />
</ActionButton>
{
!readonly && (
<ActionButton
onClick={() => onRemove(config.id)}
state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default}
onMouseEnter={() => setIsDeleting(true)}
onMouseLeave={() => setIsDeleting(false)}
>
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary', isDeleting && 'text-text-destructive')} />
</ActionButton>
)
}
</div>
{
!!config.indexing_technique && (
@@ -107,11 +113,13 @@ const Item: FC<ItemProps> = ({
)
}
<Drawer isOpen={showSettingsModal} onClose={() => setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassName="mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl">
<SettingsModal
currentDataset={config}
onCancel={() => setShowSettingsModal(false)}
onSave={handleSave}
/>
{showSettingsModal && (
<SettingsModal
currentDataset={config}
onCancel={() => setShowSettingsModal(false)}
onSave={handleSave}
/>
)}
</Drawer>
</div>
)
@@ -30,6 +30,7 @@ import {
import { useSelector as useAppContextSelector } from '@/context/app-context'
import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { hasEditPermissionForDataset } from '@/utils/permission'
import FeaturePanel from '../base/feature-panel'
import OperationBtn from '../base/operation-btn'
@@ -38,7 +39,11 @@ import CardItem from './card-item'
import ContextVar from './context-var'
import ParamsConfig from './params-config'
const DatasetConfig: FC = () => {
type Props = {
readonly?: boolean
hideMetadataFilter?: boolean
}
const DatasetConfig: FC<Props> = ({ readonly, hideMetadataFilter }) => {
const { t } = useTranslation()
const userProfile = useAppContextSelector(s => s.userProfile)
const {
@@ -259,17 +264,19 @@ const DatasetConfig: FC = () => {
className="mt-2"
title={t('feature.dataSet.title', { ns: 'appDebug' })}
headerRight={(
<div className="flex items-center gap-1">
{!isAgent && <ParamsConfig disabled={!hasData} selectedDatasets={dataSet} />}
<OperationBtn type="add" onClick={showSelectDataSet} />
</div>
!readonly && (
<div className="flex items-center gap-1">
{!isAgent && <ParamsConfig disabled={!hasData} selectedDatasets={dataSet} />}
<OperationBtn type="add" onClick={showSelectDataSet} />
</div>
)
)}
hasHeaderBottomBorder={!hasData}
noBodySpacing
>
{hasData
? (
<div className="mt-1 flex flex-wrap justify-between px-3 pb-3">
<div className={cn('mt-1 grid grid-cols-1 px-3 pb-3', readonly && 'grid-cols-2 gap-1')}>
{formattedDataset.map(item => (
<CardItem
key={item.id}
@@ -277,6 +284,7 @@ const DatasetConfig: FC = () => {
onRemove={onRemove}
onSave={handleSave}
editable={item.editable}
readonly={readonly}
/>
))}
</div>
@@ -287,27 +295,29 @@ const DatasetConfig: FC = () => {
</div>
)}
<div className="border-t border-t-divider-subtle py-2">
<MetadataFilter
metadataList={metadataList}
selectedDatasetsLoaded
metadataFilterMode={datasetConfigs.metadata_filtering_mode}
metadataFilteringConditions={datasetConfigs.metadata_filtering_conditions}
handleAddCondition={handleAddCondition}
handleMetadataFilterModeChange={handleMetadataFilterModeChange}
handleRemoveCondition={handleRemoveCondition}
handleToggleConditionLogicalOperator={handleToggleConditionLogicalOperator}
handleUpdateCondition={handleUpdateCondition}
metadataModelConfig={datasetConfigs.metadata_model_config}
handleMetadataModelChange={handleMetadataModelChange}
handleMetadataCompletionParamsChange={handleMetadataCompletionParamsChange}
isCommonVariable
availableCommonStringVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.string || item.type === MetadataFilteringVariableType.select)}
availableCommonNumberVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.number)}
/>
</div>
{!hideMetadataFilter && (
<div className="border-t border-t-divider-subtle py-2">
<MetadataFilter
metadataList={metadataList}
selectedDatasetsLoaded
metadataFilterMode={datasetConfigs.metadata_filtering_mode}
metadataFilteringConditions={datasetConfigs.metadata_filtering_conditions}
handleAddCondition={handleAddCondition}
handleMetadataFilterModeChange={handleMetadataFilterModeChange}
handleRemoveCondition={handleRemoveCondition}
handleToggleConditionLogicalOperator={handleToggleConditionLogicalOperator}
handleUpdateCondition={handleUpdateCondition}
metadataModelConfig={datasetConfigs.metadata_model_config}
handleMetadataModelChange={handleMetadataModelChange}
handleMetadataCompletionParamsChange={handleMetadataCompletionParamsChange}
isCommonVariable
availableCommonStringVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.string || item.type === MetadataFilteringVariableType.select)}
availableCommonNumberVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.number)}
/>
</div>
)}
{mode === AppModeEnum.COMPLETION && dataSet.length > 0 && (
{!readonly && mode === AppModeEnum.COMPLETION && dataSet.length > 0 && (
<ContextVar
value={selectedContextVar?.key}
options={promptVariablesToSelect}
@@ -1,5 +1,6 @@
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { IndexingType } from '@/app/components/datasets/create/step-two'
@@ -237,15 +238,15 @@ describe('RetrievalSection', () => {
retrievalConfig={retrievalConfig}
showMultiModalTip
onRetrievalConfigChange={vi.fn()}
docLink={docLink}
docLink={docLink as unknown as (path?: DocPathWithoutLang) => string}
/>,
)
// Assert
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
const learnMoreLink = screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' })
expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')
expect(docLink).toHaveBeenCalledWith('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')
expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example/use-dify/knowledge/create-knowledge/setting-indexing-methods')
expect(docLink).toHaveBeenCalledWith('/use-dify/knowledge/create-knowledge/setting-indexing-methods')
})
it('propagates retrieval config changes for economical indexing', async () => {
@@ -263,7 +264,7 @@ describe('RetrievalSection', () => {
retrievalConfig={createRetrievalConfig()}
showMultiModalTip={false}
onRetrievalConfigChange={handleRetrievalChange}
docLink={path => path}
docLink={path => path || ''}
/>,
)
const [topKIncrement] = screen.getAllByLabelText('increment')
@@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { RiCloseLine } from '@remixicon/react'
import Divider from '@/app/components/base/divider'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
@@ -84,7 +85,7 @@ type InternalRetrievalSectionProps = CommonSectionProps & {
retrievalConfig: RetrievalConfig
showMultiModalTip: boolean
onRetrievalConfigChange: (value: RetrievalConfig) => void
docLink: (path: string) => string
docLink: (path?: DocPathWithoutLang) => string
}
const InternalRetrievalSection: FC<InternalRetrievalSectionProps> = ({
@@ -102,7 +103,7 @@ const InternalRetrievalSection: FC<InternalRetrievalSectionProps> = ({
<div>
<div className="system-sm-semibold text-text-secondary">{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div>
<div className="text-xs font-normal leading-[18px] text-text-tertiary">
<a target="_blank" rel="noopener noreferrer" href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')} className="text-text-accent">{t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })}</a>
<a target="_blank" rel="noopener noreferrer" href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')} className="text-text-accent">{t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })}</a>
{t('form.retrievalSetting.description', { ns: 'datasetSettings' })}
</div>
</div>
@@ -18,7 +18,7 @@ const ChatUserInput = ({
inputs,
}: Props) => {
const { t } = useTranslation()
const { modelConfig, setInputs } = useContext(ConfigContext)
const { modelConfig, setInputs, readonly } = useContext(ConfigContext)
const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => {
return key && key?.trim() && name && name?.trim()
@@ -88,6 +88,7 @@ const ChatUserInput = ({
placeholder={name}
autoFocus={index === 0}
maxLength={max_length}
readOnly={readonly}
/>
)}
{type === 'paragraph' && (
@@ -96,6 +97,7 @@ const ChatUserInput = ({
placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
readOnly={readonly}
/>
)}
{type === 'select' && (
@@ -105,6 +107,7 @@ const ChatUserInput = ({
onSelect={(i) => { handleInputValueChange(key, i.value as string) }}
items={(options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
disabled={readonly}
/>
)}
{type === 'number' && (
@@ -115,6 +118,7 @@ const ChatUserInput = ({
placeholder={name}
autoFocus={index === 0}
maxLength={max_length}
readOnly={readonly}
/>
)}
{type === 'checkbox' && (
@@ -123,6 +127,7 @@ const ChatUserInput = ({
value={!!inputs[key]}
required={required}
onChange={(value) => { handleInputValueChange(key, value) }}
readonly={readonly}
/>
)}
</div>
@@ -15,6 +15,7 @@ import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
import { AppSourceType } from '@/service/share'
import { promptVariablesToUserInputsForm } from '@/utils/model-config'
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
@@ -130,11 +131,11 @@ const TextGenerationItem: FC<TextGenerationItemProps> = ({
return (
<TextGeneration
appSourceType={AppSourceType.webApp}
className="flex h-full flex-col overflow-y-auto border-none"
content={completion}
isLoading={!completion && isResponding}
isResponding={isResponding}
isInstalledApp={false}
siteInfo={null}
messageId={messageId}
isError={false}
@@ -39,6 +39,7 @@ const DebugWithSingleModel = (
) => {
const { userProfile } = useAppContext()
const {
readonly,
modelConfig,
appId,
inputs,
@@ -150,6 +151,7 @@ const DebugWithSingleModel = (
return (
<Chat
readonly={readonly}
config={config}
chatList={chatList}
isResponding={isResponding}
@@ -14,27 +14,20 @@ import {
} from 'react'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import {
AgentStrategy,
} from '@/types/app'
import { promptVariablesToUserInputsForm } from '@/utils/model-config'
import { storage } from '@/utils/storage'
import { ORCHESTRATE_CHANGED } from './types'
export const useDebugWithSingleOrMultipleModel = (appId: string) => {
const localeDebugWithSingleOrMultipleModelConfigs = localStorage.getItem('app-debug-with-single-or-multiple-models')
const localeDebugWithSingleOrMultipleModelConfigs = storage.get<DebugWithSingleOrMultipleModelConfigs>(STORAGE_KEYS.CONFIG.DEBUG_MODELS)
const debugWithSingleOrMultipleModelConfigs = useRef<DebugWithSingleOrMultipleModelConfigs>({})
if (localeDebugWithSingleOrMultipleModelConfigs) {
try {
debugWithSingleOrMultipleModelConfigs.current = JSON.parse(localeDebugWithSingleOrMultipleModelConfigs) || {}
}
catch (e) {
console.error(e)
}
}
const debugWithSingleOrMultipleModelConfigs = useRef<DebugWithSingleOrMultipleModelConfigs>(localeDebugWithSingleOrMultipleModelConfigs || {})
const [
debugWithMultipleModel,
@@ -55,7 +48,7 @@ export const useDebugWithSingleOrMultipleModel = (appId: string) => {
configs: modelConfigs,
}
debugWithSingleOrMultipleModelConfigs.current[appId] = value
localStorage.setItem('app-debug-with-single-or-multiple-models', JSON.stringify(debugWithSingleOrMultipleModelConfigs.current))
storage.set(STORAGE_KEYS.CONFIG.DEBUG_MODELS, debugWithSingleOrMultipleModelConfigs.current)
setDebugWithMultipleModel(value.multiple)
setMultipleModelConfigs(value.configs)
}, [appId])
@@ -38,6 +38,7 @@ import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
import { sendCompletionMessage } from '@/service/debug'
import { AppSourceType } from '@/service/share'
import { AppModeEnum, ModelModeType, TransferMethod } from '@/types/app'
import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config'
import GroupName from '../base/group-name'
@@ -72,6 +73,7 @@ const Debug: FC<IDebug> = ({
}) => {
const { t } = useTranslation()
const {
readonly,
appId,
mode,
modelModeType,
@@ -416,25 +418,33 @@ const Debug: FC<IDebug> = ({
}
{mode !== AppModeEnum.COMPLETION && (
<>
<TooltipPlus
popupContent={t('operation.refresh', { ns: 'common' })}
>
<ActionButton onClick={clearConversation}>
<RefreshCcw01 className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
{varList.length > 0 && (
<div className="relative ml-1 mr-2">
{
!readonly && (
<TooltipPlus
popupContent={t('panel.userInputField', { ns: 'workflow' })}
popupContent={t('operation.refresh', { ns: 'common' })}
>
<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => setExpanded(!expanded)}>
<RiEqualizer2Line className="h-4 w-4" />
<ActionButton onClick={clearConversation}>
<RefreshCcw01 className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
{expanded && <div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />}
</div>
)}
)
}
{
varList.length > 0 && (
<div className="relative ml-1 mr-2">
<TooltipPlus
popupContent={t('panel.userInputField', { ns: 'workflow' })}
>
<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => !readonly && setExpanded(!expanded)}>
<RiEqualizer2Line className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
{expanded && <div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />}
</div>
)
}
</>
)}
</div>
@@ -444,19 +454,21 @@ const Debug: FC<IDebug> = ({
<ChatUserInput inputs={inputs} />
</div>
)}
{mode === AppModeEnum.COMPLETION && (
<PromptValuePanel
appType={mode as AppModeEnum}
onSend={handleSendTextCompletion}
inputs={inputs}
visionConfig={{
...features.file! as VisionSettings,
transfer_methods: features.file!.allowed_file_upload_methods || [],
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
}}
onVisionFilesChange={setCompletionFiles}
/>
)}
{
mode === AppModeEnum.COMPLETION && (
<PromptValuePanel
appType={mode as AppModeEnum}
onSend={handleSendTextCompletion}
inputs={inputs}
visionConfig={{
...features.file! as VisionSettings,
transfer_methods: features.file!.allowed_file_upload_methods || [],
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
}}
onVisionFilesChange={setCompletionFiles}
/>
)
}
</div>
{
debugWithMultipleModel && (
@@ -510,12 +522,12 @@ const Debug: FC<IDebug> = ({
<div className="mx-4 mt-3"><GroupName name={t('result', { ns: 'appDebug' })} /></div>
<div className="mx-3 mb-8">
<TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2"
content={completionRes}
isLoading={!completionRes && isResponding}
isShowTextToSpeech={textToSpeechConfig.enabled && !!text2speechDefaultModel}
isResponding={isResponding}
isInstalledApp={false}
messageId={messageId}
isError={false}
onRetry={noop}
@@ -550,13 +562,15 @@ const Debug: FC<IDebug> = ({
</div>
)
}
{isShowFormattingChangeConfirm && (
<FormattingChanged
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
)}
{!isAPIKeySet && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
{
isShowFormattingChangeConfirm && (
<FormattingChanged
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
)
}
{!isAPIKeySet && !readonly && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
</>
)
}
@@ -40,7 +40,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
onVisionFilesChange,
}) => {
const { t } = useTranslation()
const { modelModeType, modelConfig, setInputs, mode, isAdvancedMode, completionPromptConfig, chatPromptConfig } = useContext(ConfigContext)
const { readonly, modelModeType, modelConfig, setInputs, mode, isAdvancedMode, completionPromptConfig, chatPromptConfig } = useContext(ConfigContext)
const [userInputFieldCollapse, setUserInputFieldCollapse] = useState(false)
const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => {
return key && key?.trim() && name && name?.trim()
@@ -78,12 +78,12 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
if (isAdvancedMode) {
if (modelModeType === ModelModeType.chat)
return chatPromptConfig.prompt.every(({ text }) => !text)
return chatPromptConfig?.prompt.every(({ text }) => !text)
return !completionPromptConfig.prompt?.text
}
else { return !modelConfig.configs.prompt_template }
}, [chatPromptConfig.prompt, completionPromptConfig.prompt?.text, isAdvancedMode, mode, modelConfig.configs.prompt_template, modelModeType])
}, [chatPromptConfig?.prompt, completionPromptConfig.prompt?.text, isAdvancedMode, mode, modelConfig.configs.prompt_template, modelModeType])
const handleInputValueChange = (key: string, value: string | boolean) => {
if (!(key in promptVariableObj))
@@ -142,6 +142,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
placeholder={name}
autoFocus={index === 0}
maxLength={max_length}
readOnly={readonly}
/>
)}
{type === 'paragraph' && (
@@ -150,6 +151,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
readOnly={readonly}
/>
)}
{type === 'select' && (
@@ -160,6 +162,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
items={(options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
bgClassName="bg-gray-50"
disabled={readonly}
/>
)}
{type === 'number' && (
@@ -170,6 +173,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
placeholder={name}
autoFocus={index === 0}
maxLength={max_length}
readOnly={readonly}
/>
)}
{type === 'checkbox' && (
@@ -178,6 +182,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
value={!!inputs[key]}
required={required}
onChange={(value) => { handleInputValueChange(key, value) }}
readonly={readonly}
/>
)}
</div>
@@ -196,6 +201,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
url: fileItem.url,
upload_file_id: fileItem.fileId,
})))}
disabled={readonly}
/>
</div>
</div>
@@ -204,12 +210,12 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
)}
{!userInputFieldCollapse && (
<div className="flex justify-between border-t border-divider-subtle p-4 pt-3">
<Button className="w-[72px]" onClick={onClear}>{t('operation.clear', { ns: 'common' })}</Button>
<Button className="w-[72px]" disabled={readonly} onClick={onClear}>{t('operation.clear', { ns: 'common' })}</Button>
{canNotRun && (
<Tooltip popupContent={t('otherError.promptNoBeEmpty', { ns: 'appDebug' })}>
<Button
variant="primary"
disabled={canNotRun}
disabled={canNotRun || readonly}
onClick={() => onSend?.()}
className="w-[96px]"
>
@@ -221,7 +227,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
{!canNotRun && (
<Button
variant="primary"
disabled={canNotRun}
disabled={canNotRun || readonly}
onClick={() => onSend?.()}
className="w-[96px]"
>
@@ -237,6 +243,8 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
showFileUpload={false}
isChatMode={appType !== AppModeEnum.COMPLETION}
onFeatureBarClick={setShowAppConfigureFeaturesModal}
disabled={readonly}
hideEditEntrance={readonly}
/>
</div>
</>
@@ -240,7 +240,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
<div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary">
{t('apiBasedExtension.selector.title', { ns: 'common' })}
<a
href={docLink('/guides/extension/api-based-extension/README')}
href={docLink('/use-dify/workspace/api-extension/api-extension')}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center text-xs font-normal text-text-tertiary hover:text-text-accent"
@@ -10,6 +10,7 @@ vi.mock('@heroicons/react/20/solid', () => ({
}))
const mockApp: App = {
can_trial: true,
app: {
id: 'test-app-id',
mode: AppModeEnum.CHAT,
@@ -1,9 +1,14 @@
'use client'
import type { App } from '@/models/explore'
import { PlusIcon } from '@heroicons/react/20/solid'
import { RiInformation2Line } from '@remixicon/react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import AppListContext from '@/context/app-list-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { cn } from '@/utils/classnames'
import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
@@ -20,6 +25,14 @@ const AppCard = ({
}: AppCardProps) => {
const { t } = useTranslation()
const { app: appBasicInfo } = app
const { systemFeatures } = useGlobalPublicStore()
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel)
const showTryAPPPanel = useCallback((appId: string) => {
return () => {
setShowTryAppPanel?.(true, { appId, app })
}
}, [setShowTryAppPanel, app.category])
return (
<div className={cn('group relative flex h-[132px] cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 shadow-xs hover:shadow-lg')}>
<div className="flex shrink-0 grow-0 items-center gap-3 pb-2">
@@ -51,11 +64,17 @@ const AppCard = ({
</div>
{canCreate && (
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
<div className={cn('flex h-8 w-full items-center space-x-2')}>
<Button variant="primary" className="grow" onClick={() => onCreate()}>
<div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', isTrialApp && 'grid-cols-2')}>
<Button variant="primary" onClick={() => onCreate()}>
<PlusIcon className="mr-1 h-4 w-4" />
<span className="text-xs">{t('newApp.useTemplate', { ns: 'app' })}</span>
</Button>
{isTrialApp && (
<Button onClick={showTryAPPPanel(app.app_id)}>
<RiInformation2Line className="mr-1 size-4" />
<span>{t('appCard.try', { ns: 'explore' })}</span>
</Button>
)}
</div>
</div>
)}
@@ -16,7 +16,7 @@ import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { DSLImportMode } from '@/models/app'
import { importDSL } from '@/service/apps'
@@ -25,6 +25,7 @@ import { useExploreAppList } from '@/service/use-explore'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AppCard from '../app-card'
import Sidebar, { AppCategories, AppCategoryLabel } from './sidebar'
@@ -145,7 +146,7 @@ const Apps = ({
onSuccess()
if (app.app_id)
await handleCheckPluginDependencies(app.app_id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
getRedirection(isCurrentWorkspaceEditor, { id: app.app_id!, mode }, push)
}
catch {
@@ -4,7 +4,6 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { trackEvent } from '@/app/components/base/amplitude'
import { ToastContext } from '@/app/components/base/toast'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { createApp } from '@/service/apps'
@@ -12,6 +11,8 @@ import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import CreateAppModal from './index'
const NEED_REFRESH_APP_LIST_KEY_PREFIXED = 'v1:needRefreshAppList'
vi.mock('ahooks', () => ({
useDebounceFn: (fn: (...args: any[]) => any) => {
const run = (...args: any[]) => fn(...args)
@@ -142,7 +143,7 @@ describe('CreateAppModal', () => {
expect(mockNotify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' })
expect(onSuccess).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
await waitFor(() => expect(mockSetItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY, '1'))
await waitFor(() => expect(mockSetItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY_PREFIXED, '1'))
await waitFor(() => expect(mockGetRedirection).toHaveBeenCalledWith(true, mockApp, mockPush))
})
@@ -5,7 +5,6 @@ import { RiArrowRightLine, RiArrowRightSLine, RiCommandLine, RiCornerDownLeftLin
import { useDebounceFn, useKeyPress } from 'ahooks'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -20,15 +19,15 @@ import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { ToastContext } from '@/app/components/base/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import useTheme from '@/hooks/use-theme'
import { createApp } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import { basePath } from '@/utils/var'
import AppIconPicker from '../../base/app-icon-picker'
@@ -93,7 +92,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
notify({ type: 'success', message: t('newApp.appCreated', { ns: 'app' }) })
onSuccess()
onClose()
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
getRedirection(isCurrentWorkspaceEditor, app, push)
}
catch (e: any) {
@@ -346,41 +345,26 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
function AppPreview({ mode }: { mode: AppModeEnum }) {
const { t } = useTranslation()
const docLink = useDocLink()
const modeToPreviewInfoMap = {
[AppModeEnum.CHAT]: {
title: t('types.chatbot', { ns: 'app' }),
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
link: docLink('/guides/application-orchestrate/chatbot-application'),
},
[AppModeEnum.ADVANCED_CHAT]: {
title: t('types.advanced', { ns: 'app' }),
description: t('newApp.advancedUserDescription', { ns: 'app' }),
link: docLink('/guides/workflow/README', {
'zh-Hans': '/guides/workflow/readme',
'ja-JP': '/guides/workflow/concepts',
}),
},
[AppModeEnum.AGENT_CHAT]: {
title: t('types.agent', { ns: 'app' }),
description: t('newApp.agentUserDescription', { ns: 'app' }),
link: docLink('/guides/application-orchestrate/agent'),
},
[AppModeEnum.COMPLETION]: {
title: t('newApp.completeApp', { ns: 'app' }),
description: t('newApp.completionUserDescription', { ns: 'app' }),
link: docLink('/guides/application-orchestrate/text-generator', {
'zh-Hans': '/guides/application-orchestrate/readme',
'ja-JP': '/guides/application-orchestrate/README',
}),
},
[AppModeEnum.WORKFLOW]: {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
link: docLink('/guides/workflow/README', {
'zh-Hans': '/guides/workflow/readme',
'ja-JP': '/guides/workflow/concepts',
}),
},
}
const previewInfo = modeToPreviewInfoMap[mode]
@@ -389,7 +373,6 @@ function AppPreview({ mode }: { mode: AppModeEnum }) {
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>
<div className="system-xs-regular mt-1 min-h-8 max-w-96 text-text-tertiary">
<span>{previewInfo.description}</span>
{previewInfo.link && <Link target="_blank" href={previewInfo.link} className="ml-1 text-text-accent">{t('newApp.learnMore', { ns: 'app' })}</Link>}
</div>
</div>
)
@@ -15,7 +15,7 @@ import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import {
@@ -28,6 +28,7 @@ import {
} from '@/service/apps'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import Uploader from './uploader'
type CreateFromDSLModalProps = {
@@ -130,7 +131,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
message: t(status === DSLImportStatus.COMPLETED ? 'newApp.appCreated' : 'newApp.caution', { ns: 'app' }),
children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('newApp.appCreateDSLWarning', { ns: 'app' }),
})
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
if (app_id)
await handleCheckPluginDependencies(app_id)
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
@@ -190,7 +191,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
})
if (app_id)
await handleCheckPluginDependencies(app_id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
getRedirection(isCurrentWorkspaceEditor, { id: app_id!, mode: app_mode }, push)
}
else if (status === DSLImportStatus.FAILED) {
@@ -58,7 +58,7 @@ const Uploader: FC<Props> = ({
setDragging(false)
if (!e.dataTransfer)
return
const files = [...e.dataTransfer.files]
const files = Array.from(e.dataTransfer.files)
if (files.length > 1) {
notify({ type: 'error', message: t('stepOne.uploader.validation.count', { ns: 'datasetCreation' }) })
return
+2 -1
View File
@@ -39,6 +39,7 @@ import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp'
import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log'
import { AppSourceType } from '@/service/share'
import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log'
import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
@@ -638,12 +639,12 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
</div>
</div>
<TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2"
content={detail.message.answer}
messageId={detail.message.id}
isError={false}
onRetry={noop}
isInstalledApp={false}
supportFeedback
feedback={detail.message.feedbacks.find((item: any) => item.from_source === 'admin')}
onFeedback={feedback => onFeedback(detail.message.id, feedback)}
+1 -1
View File
@@ -245,7 +245,7 @@ function AppCard({
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/guides/workflow/node/user-input'), '_blank')}
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>
@@ -305,7 +305,7 @@ describe('CustomizeModal', () => {
// Assert
expect(mockWindowOpen).toHaveBeenCalledTimes(1)
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining('/guides/application-publishing/developing-with-apis'),
expect.stringContaining('/use-dify/publish/developing-with-apis'),
'_blank',
)
})
@@ -118,7 +118,7 @@ const CustomizeModal: FC<IShareLinkProps> = ({
className="mt-2"
onClick={() =>
window.open(
docLink('/guides/application-publishing/developing-with-apis'),
docLink('/use-dify/publish/developing-with-apis'),
'_blank',
)}
>
@@ -23,7 +23,6 @@ import Textarea from '@/app/components/base/textarea'
import { useToastContext } from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useDocLink } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { languages } from '@/i18n-config/language'
@@ -100,7 +99,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
const [language, setLanguage] = useState(default_language)
const [saveLoading, setSaveLoading] = useState(false)
const { t } = useTranslation()
const docLink = useDocLink()
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [appIcon, setAppIcon] = useState<AppIconSelection>(
@@ -240,16 +238,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div>
<div className="system-xs-regular mt-0.5 text-text-tertiary">
<span>{t(`${prefixSettings}.modalTip`, { ns: 'appOverview' })}</span>
<Link
href={docLink('/guides/application-publishing/launch-your-webapp-quickly/README', {
'zh-Hans': '/guides/application-publishing/launch-your-webapp-quickly/readme',
})}
target="_blank"
rel="noopener noreferrer"
className="text-text-accent"
>
{t('operation.learnMore', { ns: 'common' })}
</Link>
</div>
</div>
{/* form body */}
@@ -208,7 +208,7 @@ function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) {
{t('overview.triggerInfo.triggerStatusDescription', { ns: 'appOverview' })}
{' '}
<Link
href={docLink('/guides/workflow/node/trigger')}
href={docLink('/use-dify/nodes/trigger/overview')}
target="_blank"
rel="noopener noreferrer"
className="text-text-accent hover:underline"
@@ -5,10 +5,11 @@ import * as React from 'react'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast'
import { Plan } from '@/app/components/billing/type'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { AppModeEnum } from '@/types/app'
import SwitchAppModal from './index'
const NEED_REFRESH_APP_LIST_KEY_PREFIXED = 'v1:needRefreshAppList'
const mockPush = vi.fn()
const mockReplace = vi.fn()
vi.mock('next/navigation', () => ({
@@ -257,7 +258,7 @@ describe('SwitchAppModal', () => {
expect(onSuccess).toHaveBeenCalledTimes(1)
expect(onClose).toHaveBeenCalledTimes(1)
expect(notify).toHaveBeenCalledWith({ type: 'success', message: 'app.newApp.appCreated' })
expect(localStorage.setItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY, '1')
expect(localStorage.setItem).toHaveBeenCalledWith(NEED_REFRESH_APP_LIST_KEY_PREFIXED, '1')
expect(mockPush).toHaveBeenCalledWith('/app/new-app-001/workflow')
expect(mockReplace).not.toHaveBeenCalled()
})
@@ -17,13 +17,14 @@ import Input from '@/app/components/base/input'
import Modal from '@/app/components/base/modal'
import { ToastContext } from '@/app/components/base/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { STORAGE_KEYS } from '@/config/storage-keys'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { deleteApp, switchApp } from '@/service/apps'
import { AppModeEnum } from '@/types/app'
import { getRedirection } from '@/utils/app-redirection'
import { cn } from '@/utils/classnames'
import { storage } from '@/utils/storage'
import AppIconPicker from '../../base/app-icon-picker'
type SwitchAppModalProps = {
@@ -73,7 +74,7 @@ const SwitchAppModal = ({ show, appDetail, inAppDetail = false, onSuccess, onClo
setAppDetail()
if (removeOriginal)
await deleteApp(appDetail.id)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
storage.set(STORAGE_KEYS.APP.NEED_REFRESH_LIST, '1')
getRedirection(
isCurrentWorkspaceEditor,
{

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