Merge branch 'main' into feat/tool-plugin-oauth

This commit is contained in:
Harry 2025-07-14 11:28:10 +08:00
commit 06802afc94
37 changed files with 2545 additions and 325 deletions

View File

@ -54,7 +54,7 @@
<a href="./README_BN.md"><img alt="README in বাংলা" src="https://img.shields.io/badge/বাংলা-d9d9d9"></a>
</p>
Dify is an open-source LLM app development platform. Its intuitive interface combines agentic AI workflow, RAG pipeline, agent capabilities, model management, observability features, and more, allowing you to quickly move from prototype to production.
Dify is an open-source platform for developing LLM applications. Its intuitive interface combines agentic AI workflows, RAG pipelines, agent capabilities, model management, observability features, and moreallowing you to quickly move from prototype to production.
## Quick start

View File

@ -92,7 +92,8 @@ class AppMCPServerRefreshController(Resource):
raise NotFound()
server = (
db.session.query(AppMCPServer)
.filter(AppMCPServer.id == server_id and AppMCPServer.tenant_id == current_user.current_tenant_id)
.filter(AppMCPServer.id == server_id)
.filter(AppMCPServer.tenant_id == current_user.current_tenant_id)
.first()
)
if not server:

View File

@ -5,6 +5,8 @@ from base64 import b64encode
from collections.abc import Mapping
from typing import Any
from core.variables.utils import SegmentJSONEncoder
class TemplateTransformer(ABC):
_code_placeholder: str = "{{code}}"
@ -95,7 +97,7 @@ class TemplateTransformer(ABC):
@classmethod
def serialize_inputs(cls, inputs: Mapping[str, Any]) -> str:
inputs_json_str = json.dumps(inputs, ensure_ascii=False).encode()
inputs_json_str = json.dumps(inputs, ensure_ascii=False, cls=SegmentJSONEncoder).encode()
input_base64_encoded = b64encode(inputs_json_str).decode("utf-8")
return input_base64_encoded

View File

@ -14,9 +14,11 @@ class PassportService:
def verify(self, token):
try:
return jwt.decode(token, self.sk, algorithms=["HS256"])
except jwt.exceptions.ExpiredSignatureError:
raise Unauthorized("Token has expired.")
except jwt.exceptions.InvalidSignatureError:
raise Unauthorized("Invalid token signature.")
except jwt.exceptions.DecodeError:
raise Unauthorized("Invalid token.")
except jwt.exceptions.ExpiredSignatureError:
raise Unauthorized("Token has expired.")
except jwt.exceptions.PyJWTError: # Catch-all for other JWT errors
raise Unauthorized("Invalid token.")

View File

@ -108,7 +108,7 @@ dev = [
"faker~=32.1.0",
"lxml-stubs~=0.5.1",
"mypy~=1.16.0",
"ruff~=0.11.5",
"ruff~=0.12.3",
"pytest~=8.3.2",
"pytest-benchmark~=4.0.0",
"pytest-cov~=4.1.0",

View File

@ -29,7 +29,7 @@ class EnterpriseService:
raise ValueError("No data found.")
try:
# parse the UTC timestamp from the response
return datetime.fromisoformat(data.replace("Z", "+00:00"))
return datetime.fromisoformat(data)
except ValueError as e:
raise ValueError(f"Invalid date format: {data}") from e
@ -40,7 +40,7 @@ class EnterpriseService:
raise ValueError("No data found.")
try:
# parse the UTC timestamp from the response
return datetime.fromisoformat(data.replace("Z", "+00:00"))
return datetime.fromisoformat(data)
except ValueError as e:
raise ValueError(f"Invalid date format: {data}") from e

View File

@ -95,7 +95,7 @@ class WeightKeywordSetting(BaseModel):
class WeightModel(BaseModel):
weight_type: Optional[str] = None
weight_type: Optional[Literal["semantic_first", "keyword_first", "customized"]] = None
vector_setting: Optional[WeightVectorSetting] = None
keyword_setting: Optional[WeightKeywordSetting] = None

View File

@ -116,11 +116,11 @@ def test_execute_llm(flask_req_ctx):
mock_usage = LLMUsage(
prompt_tokens=30,
prompt_unit_price=Decimal("0.001"),
prompt_price_unit=Decimal("1000"),
prompt_price_unit=Decimal(1000),
prompt_price=Decimal("0.00003"),
completion_tokens=20,
completion_unit_price=Decimal("0.002"),
completion_price_unit=Decimal("1000"),
completion_price_unit=Decimal(1000),
completion_price=Decimal("0.00004"),
total_tokens=50,
total_price=Decimal("0.00007"),
@ -219,11 +219,11 @@ def test_execute_llm_with_jinja2(flask_req_ctx, setup_code_executor_mock):
mock_usage = LLMUsage(
prompt_tokens=30,
prompt_unit_price=Decimal("0.001"),
prompt_price_unit=Decimal("1000"),
prompt_price_unit=Decimal(1000),
prompt_price=Decimal("0.00003"),
completion_tokens=20,
completion_unit_price=Decimal("0.002"),
completion_price_unit=Decimal("1000"),
completion_price_unit=Decimal(1000),
completion_price=Decimal("0.00004"),
total_tokens=50,
total_price=Decimal("0.00007"),

View File

@ -0,0 +1,232 @@
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask, g
from flask_login import LoginManager, UserMixin
from libs.login import _get_user, current_user, login_required
class MockUser(UserMixin):
"""Mock user class for testing."""
def __init__(self, id: str, is_authenticated: bool = True):
self.id = id
self._is_authenticated = is_authenticated
@property
def is_authenticated(self):
return self._is_authenticated
class TestLoginRequired:
"""Test cases for login_required decorator."""
@pytest.fixture
def setup_app(self, app: Flask):
"""Set up Flask app with login manager."""
# Initialize login manager
login_manager = LoginManager()
login_manager.init_app(app)
# Mock unauthorized handler
login_manager.unauthorized = MagicMock(return_value="Unauthorized")
# Add a dummy user loader to prevent exceptions
@login_manager.user_loader
def load_user(user_id):
return None
return app
def test_authenticated_user_can_access_protected_view(self, setup_app: Flask):
"""Test that authenticated users can access protected views."""
@login_required
def protected_view():
return "Protected content"
with setup_app.test_request_context():
# Mock authenticated user
mock_user = MockUser("test_user", is_authenticated=True)
with patch("libs.login._get_user", return_value=mock_user):
result = protected_view()
assert result == "Protected content"
def test_unauthenticated_user_cannot_access_protected_view(self, setup_app: Flask):
"""Test that unauthenticated users are redirected."""
@login_required
def protected_view():
return "Protected content"
with setup_app.test_request_context():
# Mock unauthenticated user
mock_user = MockUser("test_user", is_authenticated=False)
with patch("libs.login._get_user", return_value=mock_user):
result = protected_view()
assert result == "Unauthorized"
setup_app.login_manager.unauthorized.assert_called_once()
def test_login_disabled_allows_unauthenticated_access(self, setup_app: Flask):
"""Test that LOGIN_DISABLED config bypasses authentication."""
@login_required
def protected_view():
return "Protected content"
with setup_app.test_request_context():
# Mock unauthenticated user and LOGIN_DISABLED
mock_user = MockUser("test_user", is_authenticated=False)
with patch("libs.login._get_user", return_value=mock_user):
with patch("libs.login.dify_config") as mock_config:
mock_config.LOGIN_DISABLED = True
result = protected_view()
assert result == "Protected content"
# Ensure unauthorized was not called
setup_app.login_manager.unauthorized.assert_not_called()
def test_options_request_bypasses_authentication(self, setup_app: Flask):
"""Test that OPTIONS requests are exempt from authentication."""
@login_required
def protected_view():
return "Protected content"
with setup_app.test_request_context(method="OPTIONS"):
# Mock unauthenticated user
mock_user = MockUser("test_user", is_authenticated=False)
with patch("libs.login._get_user", return_value=mock_user):
result = protected_view()
assert result == "Protected content"
# Ensure unauthorized was not called
setup_app.login_manager.unauthorized.assert_not_called()
def test_flask_2_compatibility(self, setup_app: Flask):
"""Test Flask 2.x compatibility with ensure_sync."""
@login_required
def protected_view():
return "Protected content"
# Mock Flask 2.x ensure_sync
setup_app.ensure_sync = MagicMock(return_value=lambda: "Synced content")
with setup_app.test_request_context():
mock_user = MockUser("test_user", is_authenticated=True)
with patch("libs.login._get_user", return_value=mock_user):
result = protected_view()
assert result == "Synced content"
setup_app.ensure_sync.assert_called_once()
def test_flask_1_compatibility(self, setup_app: Flask):
"""Test Flask 1.x compatibility without ensure_sync."""
@login_required
def protected_view():
return "Protected content"
# Remove ensure_sync to simulate Flask 1.x
if hasattr(setup_app, "ensure_sync"):
delattr(setup_app, "ensure_sync")
with setup_app.test_request_context():
mock_user = MockUser("test_user", is_authenticated=True)
with patch("libs.login._get_user", return_value=mock_user):
result = protected_view()
assert result == "Protected content"
class TestGetUser:
"""Test cases for _get_user function."""
def test_get_user_returns_user_from_g(self, app: Flask):
"""Test that _get_user returns user from g._login_user."""
mock_user = MockUser("test_user")
with app.test_request_context():
g._login_user = mock_user
user = _get_user()
assert user == mock_user
assert user.id == "test_user"
def test_get_user_loads_user_if_not_in_g(self, app: Flask):
"""Test that _get_user loads user if not already in g."""
mock_user = MockUser("test_user")
# Mock login manager
login_manager = MagicMock()
login_manager._load_user = MagicMock()
app.login_manager = login_manager
with app.test_request_context():
# Simulate _load_user setting g._login_user
def side_effect():
g._login_user = mock_user
login_manager._load_user.side_effect = side_effect
user = _get_user()
assert user == mock_user
login_manager._load_user.assert_called_once()
def test_get_user_returns_none_without_request_context(self, app: Flask):
"""Test that _get_user returns None outside request context."""
# Outside of request context
user = _get_user()
assert user is None
class TestCurrentUser:
"""Test cases for current_user proxy."""
def test_current_user_proxy_returns_authenticated_user(self, app: Flask):
"""Test that current_user proxy returns authenticated user."""
mock_user = MockUser("test_user", is_authenticated=True)
with app.test_request_context():
with patch("libs.login._get_user", return_value=mock_user):
assert current_user.id == "test_user"
assert current_user.is_authenticated is True
def test_current_user_proxy_returns_none_when_no_user(self, app: Flask):
"""Test that current_user proxy handles None user."""
with app.test_request_context():
with patch("libs.login._get_user", return_value=None):
# When _get_user returns None, accessing attributes should fail
# or current_user should evaluate to falsy
try:
# Try to access an attribute that would exist on a real user
_ = current_user.id
pytest.fail("Should have raised AttributeError")
except AttributeError:
# This is expected when current_user is None
pass
def test_current_user_proxy_thread_safety(self, app: Flask):
"""Test that current_user proxy is thread-safe."""
import threading
results = {}
def check_user_in_thread(user_id: str, index: int):
with app.test_request_context():
mock_user = MockUser(user_id)
with patch("libs.login._get_user", return_value=mock_user):
results[index] = current_user.id
# Create multiple threads with different users
threads = []
for i in range(5):
thread = threading.Thread(target=check_user_in_thread, args=(f"user_{i}", i))
threads.append(thread)
thread.start()
# Wait for all threads to complete
for thread in threads:
thread.join()
# Verify each thread got its own user
for i in range(5):
assert results[i] == f"user_{i}"

View File

@ -0,0 +1,205 @@
from datetime import UTC, datetime, timedelta
from unittest.mock import patch
import jwt
import pytest
from werkzeug.exceptions import Unauthorized
from libs.passport import PassportService
class TestPassportService:
"""Test PassportService JWT operations"""
@pytest.fixture
def passport_service(self):
"""Create PassportService instance with test secret key"""
with patch("libs.passport.dify_config") as mock_config:
mock_config.SECRET_KEY = "test-secret-key-for-testing"
return PassportService()
@pytest.fixture
def another_passport_service(self):
"""Create another PassportService instance with different secret key"""
with patch("libs.passport.dify_config") as mock_config:
mock_config.SECRET_KEY = "another-secret-key-for-testing"
return PassportService()
# Core functionality tests
def test_should_issue_and_verify_token(self, passport_service):
"""Test complete JWT lifecycle: issue and verify"""
payload = {"user_id": "123", "app_code": "test-app"}
token = passport_service.issue(payload)
# Verify token format
assert isinstance(token, str)
assert len(token.split(".")) == 3 # JWT format: header.payload.signature
# Verify token content
decoded = passport_service.verify(token)
assert decoded == payload
def test_should_handle_different_payload_types(self, passport_service):
"""Test issuing and verifying tokens with different payload types"""
test_cases = [
{"string": "value"},
{"number": 42},
{"float": 3.14},
{"boolean": True},
{"null": None},
{"array": [1, 2, 3]},
{"nested": {"key": "value"}},
{"unicode": "中文测试"},
{"emoji": "🔐"},
{}, # Empty payload
]
for payload in test_cases:
token = passport_service.issue(payload)
decoded = passport_service.verify(token)
assert decoded == payload
# Security tests
def test_should_reject_modified_token(self, passport_service):
"""Test that any modification to token invalidates it"""
token = passport_service.issue({"user": "test"})
# Test multiple modification points
test_positions = [0, len(token) // 3, len(token) // 2, len(token) - 1]
for pos in test_positions:
if pos < len(token) and token[pos] != ".":
# Change one character
tampered = token[:pos] + ("X" if token[pos] != "X" else "Y") + token[pos + 1 :]
with pytest.raises(Unauthorized):
passport_service.verify(tampered)
def test_should_reject_token_with_different_secret_key(self, passport_service, another_passport_service):
"""Test key isolation - token from one service should not work with another"""
payload = {"user_id": "123", "app_code": "test-app"}
token = passport_service.issue(payload)
with pytest.raises(Unauthorized) as exc_info:
another_passport_service.verify(token)
assert str(exc_info.value) == "401 Unauthorized: Invalid token signature."
def test_should_use_hs256_algorithm(self, passport_service):
"""Test that HS256 algorithm is used for signing"""
payload = {"test": "data"}
token = passport_service.issue(payload)
# Decode header without relying on JWT internals
# Use jwt.get_unverified_header which is a public API
header = jwt.get_unverified_header(token)
assert header["alg"] == "HS256"
def test_should_reject_token_with_wrong_algorithm(self, passport_service):
"""Test rejection of token signed with different algorithm"""
payload = {"user_id": "123"}
# Create token with different algorithm
with patch("libs.passport.dify_config") as mock_config:
mock_config.SECRET_KEY = "test-secret-key-for-testing"
# Create token with HS512 instead of HS256
wrong_alg_token = jwt.encode(payload, mock_config.SECRET_KEY, algorithm="HS512")
# Should fail because service expects HS256
# InvalidAlgorithmError is now caught by PyJWTError handler
with pytest.raises(Unauthorized) as exc_info:
passport_service.verify(wrong_alg_token)
assert str(exc_info.value) == "401 Unauthorized: Invalid token."
# Exception handling tests
def test_should_handle_invalid_tokens(self, passport_service):
"""Test handling of various invalid token formats"""
invalid_tokens = [
("not.a.token", "Invalid token."),
("invalid-jwt-format", "Invalid token."),
("xxx.yyy.zzz", "Invalid token."),
("a.b", "Invalid token."), # Missing signature
("", "Invalid token."), # Empty string
(" ", "Invalid token."), # Whitespace
(None, "Invalid token."), # None value
# Malformed base64
("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.INVALID_BASE64!@#$.signature", "Invalid token."),
]
for invalid_token, expected_message in invalid_tokens:
with pytest.raises(Unauthorized) as exc_info:
passport_service.verify(invalid_token)
assert expected_message in str(exc_info.value)
def test_should_reject_expired_token(self, passport_service):
"""Test rejection of expired token"""
past_time = datetime.now(UTC) - timedelta(hours=1)
payload = {"user_id": "123", "exp": past_time.timestamp()}
with patch("libs.passport.dify_config") as mock_config:
mock_config.SECRET_KEY = "test-secret-key-for-testing"
token = jwt.encode(payload, mock_config.SECRET_KEY, algorithm="HS256")
with pytest.raises(Unauthorized) as exc_info:
passport_service.verify(token)
assert str(exc_info.value) == "401 Unauthorized: Token has expired."
# Configuration tests
def test_should_handle_empty_secret_key(self):
"""Test behavior when SECRET_KEY is empty"""
with patch("libs.passport.dify_config") as mock_config:
mock_config.SECRET_KEY = ""
service = PassportService()
# Empty secret key should still work but is insecure
payload = {"test": "data"}
token = service.issue(payload)
decoded = service.verify(token)
assert decoded == payload
def test_should_handle_none_secret_key(self):
"""Test behavior when SECRET_KEY is None"""
with patch("libs.passport.dify_config") as mock_config:
mock_config.SECRET_KEY = None
service = PassportService()
payload = {"test": "data"}
# JWT library will raise TypeError when secret is None
with pytest.raises((TypeError, jwt.exceptions.InvalidKeyError)):
service.issue(payload)
# Boundary condition tests
def test_should_handle_large_payload(self, passport_service):
"""Test handling of large payload"""
# Test with 100KB instead of 1MB for faster tests
large_data = "x" * (100 * 1024)
payload = {"data": large_data}
token = passport_service.issue(payload)
decoded = passport_service.verify(token)
assert decoded["data"] == large_data
def test_should_handle_special_characters_in_payload(self, passport_service):
"""Test handling of special characters in payload"""
special_payloads = [
{"special": "!@#$%^&*()"},
{"quotes": 'He said "Hello"'},
{"backslash": "path\\to\\file"},
{"newline": "line1\nline2"},
{"unicode": "🔐🔑🛡️"},
{"mixed": "Test123!@#中文🔐"},
]
for payload in special_payloads:
token = passport_service.issue(payload)
decoded = passport_service.verify(token)
assert decoded == payload
def test_should_catch_generic_pyjwt_errors(self, passport_service):
"""Test that generic PyJWTError exceptions are caught and converted to Unauthorized"""
# Mock jwt.decode to raise a generic PyJWTError
with patch("libs.passport.jwt.decode") as mock_decode:
mock_decode.side_effect = jwt.exceptions.PyJWTError("Generic JWT error")
with pytest.raises(Unauthorized) as exc_info:
passport_service.verify("some-token")
assert str(exc_info.value) == "401 Unauthorized: Invalid token."

View File

@ -0,0 +1,59 @@
from unittest.mock import MagicMock
class ServiceDbTestHelper:
"""
Helper class for service database query tests.
"""
@staticmethod
def setup_db_query_filter_by_mock(mock_db, query_results):
"""
Smart database query mock that responds based on model type and query parameters.
Args:
mock_db: Mock database session
query_results: Dict mapping (model_name, filter_key, filter_value) to return value
Example: {('Account', 'email', 'test@example.com'): mock_account}
"""
def query_side_effect(model):
mock_query = MagicMock()
def filter_by_side_effect(**kwargs):
mock_filter_result = MagicMock()
def first_side_effect():
# Find matching result based on model and filter parameters
for (model_name, filter_key, filter_value), result in query_results.items():
if model.__name__ == model_name and filter_key in kwargs and kwargs[filter_key] == filter_value:
return result
return None
mock_filter_result.first.side_effect = first_side_effect
# Handle order_by calls for complex queries
def order_by_side_effect(*args, **kwargs):
mock_order_result = MagicMock()
def order_first_side_effect():
# Look for order_by results in the same query_results dict
for (model_name, filter_key, filter_value), result in query_results.items():
if (
model.__name__ == model_name
and filter_key == "order_by"
and filter_value == "first_available"
):
return result
return None
mock_order_result.first.side_effect = order_first_side_effect
return mock_order_result
mock_filter_result.order_by.side_effect = order_by_side_effect
return mock_filter_result
mock_query.filter_by.side_effect = filter_by_side_effect
return mock_query
mock_db.session.query.side_effect = query_side_effect

File diff suppressed because it is too large Load Diff

View File

@ -27,11 +27,11 @@ def create_mock_usage(prompt_tokens: int = 10, completion_tokens: int = 5) -> LL
return LLMUsage(
prompt_tokens=prompt_tokens,
prompt_unit_price=Decimal("0.001"),
prompt_price_unit=Decimal("1"),
prompt_price_unit=Decimal(1),
prompt_price=Decimal(str(prompt_tokens)) * Decimal("0.001"),
completion_tokens=completion_tokens,
completion_unit_price=Decimal("0.002"),
completion_price_unit=Decimal("1"),
completion_price_unit=Decimal(1),
completion_price=Decimal(str(completion_tokens)) * Decimal("0.002"),
total_tokens=prompt_tokens + completion_tokens,
total_price=Decimal(str(prompt_tokens)) * Decimal("0.001") + Decimal(str(completion_tokens)) * Decimal("0.002"),

40
api/uv.lock generated
View File

@ -1498,7 +1498,7 @@ dev = [
{ name = "pytest-cov", specifier = "~=4.1.0" },
{ name = "pytest-env", specifier = "~=1.1.3" },
{ name = "pytest-mock", specifier = "~=3.14.0" },
{ name = "ruff", specifier = "~=0.11.5" },
{ name = "ruff", specifier = "~=0.12.3" },
{ name = "scipy-stubs", specifier = ">=1.15.3.0" },
{ name = "types-aiofiles", specifier = "~=24.1.0" },
{ name = "types-beautifulsoup4", specifier = "~=4.12.0" },
@ -5088,27 +5088,27 @@ wheels = [
[[package]]
name = "ruff"
version = "0.11.13"
version = "0.12.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ed/da/9c6f995903b4d9474b39da91d2d626659af3ff1eeb43e9ae7c119349dba6/ruff-0.11.13.tar.gz", hash = "sha256:26fa247dc68d1d4e72c179e08889a25ac0c7ba4d78aecfc835d49cbfd60bf514", size = 4282054, upload-time = "2025-06-05T21:00:15.721Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/2a/43955b530c49684d3c38fcda18c43caf91e99204c2a065552528e0552d4f/ruff-0.12.3.tar.gz", hash = "sha256:f1b5a4b6668fd7b7ea3697d8d98857390b40c1320a63a178eee6be0899ea2d77", size = 4459341, upload-time = "2025-07-11T13:21:16.086Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/ce/a11d381192966e0b4290842cc8d4fac7dc9214ddf627c11c1afff87da29b/ruff-0.11.13-py3-none-linux_armv6l.whl", hash = "sha256:4bdfbf1240533f40042ec00c9e09a3aade6f8c10b6414cf11b519488d2635d46", size = 10292516, upload-time = "2025-06-05T20:59:32.944Z" },
{ url = "https://files.pythonhosted.org/packages/78/db/87c3b59b0d4e753e40b6a3b4a2642dfd1dcaefbff121ddc64d6c8b47ba00/ruff-0.11.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aef9c9ed1b5ca28bb15c7eac83b8670cf3b20b478195bd49c8d756ba0a36cf48", size = 11106083, upload-time = "2025-06-05T20:59:37.03Z" },
{ url = "https://files.pythonhosted.org/packages/77/79/d8cec175856ff810a19825d09ce700265f905c643c69f45d2b737e4a470a/ruff-0.11.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:53b15a9dfdce029c842e9a5aebc3855e9ab7771395979ff85b7c1dedb53ddc2b", size = 10436024, upload-time = "2025-06-05T20:59:39.741Z" },
{ url = "https://files.pythonhosted.org/packages/8b/5b/f6d94f2980fa1ee854b41568368a2e1252681b9238ab2895e133d303538f/ruff-0.11.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab153241400789138d13f362c43f7edecc0edfffce2afa6a68434000ecd8f69a", size = 10646324, upload-time = "2025-06-05T20:59:42.185Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9c/b4c2acf24ea4426016d511dfdc787f4ce1ceb835f3c5fbdbcb32b1c63bda/ruff-0.11.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6c51f93029d54a910d3d24f7dd0bb909e31b6cd989a5e4ac513f4eb41629f0dc", size = 10174416, upload-time = "2025-06-05T20:59:44.319Z" },
{ url = "https://files.pythonhosted.org/packages/f3/10/e2e62f77c65ede8cd032c2ca39c41f48feabedb6e282bfd6073d81bb671d/ruff-0.11.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1808b3ed53e1a777c2ef733aca9051dc9bf7c99b26ece15cb59a0320fbdbd629", size = 11724197, upload-time = "2025-06-05T20:59:46.935Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f0/466fe8469b85c561e081d798c45f8a1d21e0b4a5ef795a1d7f1a9a9ec182/ruff-0.11.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d28ce58b5ecf0f43c1b71edffabe6ed7f245d5336b17805803312ec9bc665933", size = 12511615, upload-time = "2025-06-05T20:59:49.534Z" },
{ url = "https://files.pythonhosted.org/packages/17/0e/cefe778b46dbd0cbcb03a839946c8f80a06f7968eb298aa4d1a4293f3448/ruff-0.11.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55e4bc3a77842da33c16d55b32c6cac1ec5fb0fbec9c8c513bdce76c4f922165", size = 12117080, upload-time = "2025-06-05T20:59:51.654Z" },
{ url = "https://files.pythonhosted.org/packages/5d/2c/caaeda564cbe103bed145ea557cb86795b18651b0f6b3ff6a10e84e5a33f/ruff-0.11.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:633bf2c6f35678c56ec73189ba6fa19ff1c5e4807a78bf60ef487b9dd272cc71", size = 11326315, upload-time = "2025-06-05T20:59:54.469Z" },
{ url = "https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9", size = 11555640, upload-time = "2025-06-05T20:59:56.986Z" },
{ url = "https://files.pythonhosted.org/packages/5d/d4/3d580c616316c7f07fb3c99dbecfe01fbaea7b6fd9a82b801e72e5de742a/ruff-0.11.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4a9ddd3ec62a9a89578c85842b836e4ac832d4a2e0bfaad3b02243f930ceafcc", size = 10507364, upload-time = "2025-06-05T20:59:59.154Z" },
{ url = "https://files.pythonhosted.org/packages/5a/dc/195e6f17d7b3ea6b12dc4f3e9de575db7983db187c378d44606e5d503319/ruff-0.11.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d237a496e0778d719efb05058c64d28b757c77824e04ffe8796c7436e26712b7", size = 10141462, upload-time = "2025-06-05T21:00:01.481Z" },
{ url = "https://files.pythonhosted.org/packages/f4/8e/39a094af6967faa57ecdeacb91bedfb232474ff8c3d20f16a5514e6b3534/ruff-0.11.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26816a218ca6ef02142343fd24c70f7cd8c5aa6c203bca284407adf675984432", size = 11121028, upload-time = "2025-06-05T21:00:04.06Z" },
{ url = "https://files.pythonhosted.org/packages/5a/c0/b0b508193b0e8a1654ec683ebab18d309861f8bd64e3a2f9648b80d392cb/ruff-0.11.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:51c3f95abd9331dc5b87c47ac7f376db5616041173826dfd556cfe3d4977f492", size = 11602992, upload-time = "2025-06-05T21:00:06.249Z" },
{ url = "https://files.pythonhosted.org/packages/7c/91/263e33ab93ab09ca06ce4f8f8547a858cc198072f873ebc9be7466790bae/ruff-0.11.13-py3-none-win32.whl", hash = "sha256:96c27935418e4e8e77a26bb05962817f28b8ef3843a6c6cc49d8783b5507f250", size = 10474944, upload-time = "2025-06-05T21:00:08.459Z" },
{ url = "https://files.pythonhosted.org/packages/46/f4/7c27734ac2073aae8efb0119cae6931b6fb48017adf048fdf85c19337afc/ruff-0.11.13-py3-none-win_amd64.whl", hash = "sha256:29c3189895a8a6a657b7af4e97d330c8a3afd2c9c8f46c81e2fc5a31866517e3", size = 11548669, upload-time = "2025-06-05T21:00:11.147Z" },
{ url = "https://files.pythonhosted.org/packages/ec/bf/b273dd11673fed8a6bd46032c0ea2a04b2ac9bfa9c628756a5856ba113b0/ruff-0.11.13-py3-none-win_arm64.whl", hash = "sha256:b4385285e9179d608ff1d2fb9922062663c658605819a6876d8beef0c30b7f3b", size = 10683928, upload-time = "2025-06-05T21:00:13.758Z" },
{ url = "https://files.pythonhosted.org/packages/e2/fd/b44c5115539de0d598d75232a1cc7201430b6891808df111b8b0506aae43/ruff-0.12.3-py3-none-linux_armv6l.whl", hash = "sha256:47552138f7206454eaf0c4fe827e546e9ddac62c2a3d2585ca54d29a890137a2", size = 10430499, upload-time = "2025-07-11T13:20:26.321Z" },
{ url = "https://files.pythonhosted.org/packages/43/c5/9eba4f337970d7f639a37077be067e4ec80a2ad359e4cc6c5b56805cbc66/ruff-0.12.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0a9153b000c6fe169bb307f5bd1b691221c4286c133407b8827c406a55282041", size = 11213413, upload-time = "2025-07-11T13:20:30.017Z" },
{ url = "https://files.pythonhosted.org/packages/e2/2c/fac3016236cf1fe0bdc8e5de4f24c76ce53c6dd9b5f350d902549b7719b2/ruff-0.12.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa6b24600cf3b750e48ddb6057e901dd5b9aa426e316addb2a1af185a7509882", size = 10586941, upload-time = "2025-07-11T13:20:33.046Z" },
{ url = "https://files.pythonhosted.org/packages/c5/0f/41fec224e9dfa49a139f0b402ad6f5d53696ba1800e0f77b279d55210ca9/ruff-0.12.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2506961bf6ead54887ba3562604d69cb430f59b42133d36976421bc8bd45901", size = 10783001, upload-time = "2025-07-11T13:20:35.534Z" },
{ url = "https://files.pythonhosted.org/packages/0d/ca/dd64a9ce56d9ed6cad109606ac014860b1c217c883e93bf61536400ba107/ruff-0.12.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c4faaff1f90cea9d3033cbbcdf1acf5d7fb11d8180758feb31337391691f3df0", size = 10269641, upload-time = "2025-07-11T13:20:38.459Z" },
{ url = "https://files.pythonhosted.org/packages/63/5c/2be545034c6bd5ce5bb740ced3e7014d7916f4c445974be11d2a406d5088/ruff-0.12.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40dced4a79d7c264389de1c59467d5d5cefd79e7e06d1dfa2c75497b5269a5a6", size = 11875059, upload-time = "2025-07-11T13:20:41.517Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d4/a74ef1e801ceb5855e9527dae105eaff136afcb9cc4d2056d44feb0e4792/ruff-0.12.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:0262d50ba2767ed0fe212aa7e62112a1dcbfd46b858c5bf7bbd11f326998bafc", size = 12658890, upload-time = "2025-07-11T13:20:44.442Z" },
{ url = "https://files.pythonhosted.org/packages/13/c8/1057916416de02e6d7c9bcd550868a49b72df94e3cca0aeb77457dcd9644/ruff-0.12.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12371aec33e1a3758597c5c631bae9a5286f3c963bdfb4d17acdd2d395406687", size = 12232008, upload-time = "2025-07-11T13:20:47.374Z" },
{ url = "https://files.pythonhosted.org/packages/f5/59/4f7c130cc25220392051fadfe15f63ed70001487eca21d1796db46cbcc04/ruff-0.12.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:560f13b6baa49785665276c963edc363f8ad4b4fc910a883e2625bdb14a83a9e", size = 11499096, upload-time = "2025-07-11T13:20:50.348Z" },
{ url = "https://files.pythonhosted.org/packages/d4/01/a0ad24a5d2ed6be03a312e30d32d4e3904bfdbc1cdbe63c47be9d0e82c79/ruff-0.12.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:023040a3499f6f974ae9091bcdd0385dd9e9eb4942f231c23c57708147b06311", size = 11688307, upload-time = "2025-07-11T13:20:52.945Z" },
{ url = "https://files.pythonhosted.org/packages/93/72/08f9e826085b1f57c9a0226e48acb27643ff19b61516a34c6cab9d6ff3fa/ruff-0.12.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:883d844967bffff5ab28bba1a4d246c1a1b2933f48cb9840f3fdc5111c603b07", size = 10661020, upload-time = "2025-07-11T13:20:55.799Z" },
{ url = "https://files.pythonhosted.org/packages/80/a0/68da1250d12893466c78e54b4a0ff381370a33d848804bb51279367fc688/ruff-0.12.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2120d3aa855ff385e0e562fdee14d564c9675edbe41625c87eeab744a7830d12", size = 10246300, upload-time = "2025-07-11T13:20:58.222Z" },
{ url = "https://files.pythonhosted.org/packages/6a/22/5f0093d556403e04b6fd0984fc0fb32fbb6f6ce116828fd54306a946f444/ruff-0.12.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6b16647cbb470eaf4750d27dddc6ebf7758b918887b56d39e9c22cce2049082b", size = 11263119, upload-time = "2025-07-11T13:21:01.503Z" },
{ url = "https://files.pythonhosted.org/packages/92/c9/f4c0b69bdaffb9968ba40dd5fa7df354ae0c73d01f988601d8fac0c639b1/ruff-0.12.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e1417051edb436230023575b149e8ff843a324557fe0a265863b7602df86722f", size = 11746990, upload-time = "2025-07-11T13:21:04.524Z" },
{ url = "https://files.pythonhosted.org/packages/fe/84/7cc7bd73924ee6be4724be0db5414a4a2ed82d06b30827342315a1be9e9c/ruff-0.12.3-py3-none-win32.whl", hash = "sha256:dfd45e6e926deb6409d0616078a666ebce93e55e07f0fb0228d4b2608b2c248d", size = 10589263, upload-time = "2025-07-11T13:21:07.148Z" },
{ url = "https://files.pythonhosted.org/packages/07/87/c070f5f027bd81f3efee7d14cb4d84067ecf67a3a8efb43aadfc72aa79a6/ruff-0.12.3-py3-none-win_amd64.whl", hash = "sha256:a946cf1e7ba3209bdef039eb97647f1c77f6f540e5845ec9c114d3af8df873e7", size = 11695072, upload-time = "2025-07-11T13:21:11.004Z" },
{ url = "https://files.pythonhosted.org/packages/e0/30/f3eaf6563c637b6e66238ed6535f6775480db973c836336e4122161986fc/ruff-0.12.3-py3-none-win_arm64.whl", hash = "sha256:5f9c7c9c8f84c2d7f27e93674d27136fbf489720251544c4da7fb3d742e011b1", size = 10805855, upload-time = "2025-07-11T13:21:13.547Z" },
]
[[package]]

View File

@ -9,8 +9,7 @@ import Button from '@/app/components/base/button'
import { changeWebAppPasswordWithToken } from '@/service/common'
import Toast from '@/app/components/base/toast'
import Input from '@/app/components/base/input'
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
import { validPassword } from '@/config'
const ChangePasswordForm = () => {
const { t } = useTranslation()

View File

@ -21,6 +21,7 @@ import { IS_CE_EDITION } from '@/config'
import Input from '@/app/components/base/input'
import PremiumBadge from '@/app/components/base/premium-badge'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { validPassword } from '@/config'
const titleClassName = `
system-sm-semibold text-text-secondary
@ -29,8 +30,6 @@ const descriptionClassName = `
mt-1 body-xs-regular text-text-tertiary
`
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
export default function AccountPage() {
const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()

View File

@ -308,13 +308,11 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
operations={operations}
/>
</div>
<div className='flex flex-1'>
<CardView
appId={appDetail.id}
isInPanel={true}
className='flex grow flex-col gap-2 overflow-auto px-2 py-1'
/>
</div>
<CardView
appId={appDetail.id}
isInPanel={true}
className='flex flex-1 flex-col gap-2 overflow-auto px-2 py-1'
/>
<Divider />
<div className='flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch border-t-[0.5px] border-divider-subtle p-2'>
<Button

View File

@ -101,7 +101,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
</div>
<Divider className='my-3' />
<div className="w-full flex-1 overflow-y-auto overflow-x-hidden px-3">
<div className="max-h-[200px] w-full overflow-y-auto overflow-x-hidden px-3">
{isSearching && <>
<div key={'category-search'} className='flex flex-col'>
<p className='system-xs-medium-uppercase mb-1 text-text-primary'>Search</p>
@ -170,7 +170,7 @@ const EmojiPickerInner: FC<IEmojiPickerInnerProps> = ({
'flex h-8 w-8 items-center justify-center rounded-lg p-1',
)
} style={{ background: color }}>
{selectedEmoji !== '' && <em-emoji id={selectedEmoji} />}
{selectedEmoji !== '' && <em-emoji id={selectedEmoji} />}
</div>
</div>
})}

View File

@ -15,7 +15,7 @@ import {
useWorkflowRun,
useWorkflowStartRun,
} from '../hooks'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
type WorkflowMainProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport'>
const WorkflowMain = ({
@ -64,7 +64,11 @@ const WorkflowMain = ({
handleWorkflowStartRunInChatflow,
handleWorkflowStartRunInWorkflow,
} = useWorkflowStartRun()
const { fetchInspectVars } = useSetWorkflowVarsWithValue()
const appId = useStore(s => s.appId)
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
flowId: appId,
...useConfigsMap(),
})
const {
hasNodeInspectVars,
hasSetInspectVar,

View File

@ -5,6 +5,6 @@ export * from './use-workflow-run'
export * from './use-workflow-start-run'
export * from './use-is-chat-mode'
export * from './use-workflow-refresh-draft'
export * from './use-fetch-workflow-inspect-vars'
export * from '../../workflow/hooks/use-fetch-workflow-inspect-vars'
export * from './use-inspect-vars-crud'
export * from './use-configs-map'

View File

@ -1,234 +1,16 @@
import { fetchNodeInspectVars } from '@/service/workflow'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import type { ValueSelector } from '@/app/components/workflow/types'
import type { VarInInspect } from '@/types/workflow'
import { VarInInspectType } from '@/types/workflow'
import {
useDeleteAllInspectorVars,
useDeleteInspectVar,
useDeleteNodeInspectorVars,
useEditInspectorVar,
useInvalidateConversationVarValues,
useInvalidateSysVarValues,
useResetConversationVar,
useResetToLastRunValue,
} from '@/service/use-workflow'
import { useCallback } from 'react'
import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import produce from 'immer'
import type { Node } from '@/app/components/workflow/types'
import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync'
import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync'
import { useStore } from '@/app/components/workflow/store'
import { useInspectVarsCrudCommon } from '../../workflow/hooks/use-inspect-vars-crud-common'
import { useConfigsMap } from './use-configs-map'
export const useInspectVarsCrud = () => {
const workflowStore = useWorkflowStore()
const appId = useStore(s => s.appId)
const { conversationVarsUrl, systemVarsUrl } = useConfigsMap()
const invalidateConversationVarValues = useInvalidateConversationVarValues(conversationVarsUrl)
const { mutateAsync: doResetConversationVar } = useResetConversationVar(appId)
const { mutateAsync: doResetToLastRunValue } = useResetToLastRunValue(appId)
const invalidateSysVarValues = useInvalidateSysVarValues(systemVarsUrl)
const { mutateAsync: doDeleteAllInspectorVars } = useDeleteAllInspectorVars(appId)
const { mutate: doDeleteNodeInspectorVars } = useDeleteNodeInspectorVars(appId)
const { mutate: doDeleteInspectVar } = useDeleteInspectVar(appId)
const { mutateAsync: doEditInspectorVar } = useEditInspectorVar(appId)
const { handleCancelNodeSuccessStatus } = useNodesInteractionsWithoutSync()
const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync()
const getNodeInspectVars = useCallback((nodeId: string) => {
const { nodesWithInspectVars } = workflowStore.getState()
const node = nodesWithInspectVars.find(node => node.nodeId === nodeId)
return node
}, [workflowStore])
const getVarId = useCallback((nodeId: string, varName: string) => {
const node = getNodeInspectVars(nodeId)
if (!node)
return undefined
const varId = node.vars.find((varItem) => {
return varItem.selector[1] === varName
})?.id
return varId
}, [getNodeInspectVars])
const getInspectVar = useCallback((nodeId: string, name: string): VarInInspect | undefined => {
const node = getNodeInspectVars(nodeId)
if (!node)
return undefined
const variable = node.vars.find((varItem) => {
return varItem.name === name
})
return variable
}, [getNodeInspectVars])
const hasSetInspectVar = useCallback((nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => {
const isEnv = isENV([nodeId])
if (isEnv) // always have value
return true
const isSys = isSystemVar([nodeId])
if (isSys)
return sysVars.some(varItem => varItem.selector?.[1]?.replace('sys.', '') === name)
const isChatVar = isConversationVar([nodeId])
if (isChatVar)
return conversationVars.some(varItem => varItem.selector?.[1] === name)
return getInspectVar(nodeId, name) !== undefined
}, [getInspectVar])
const hasNodeInspectVars = useCallback((nodeId: string) => {
return !!getNodeInspectVars(nodeId)
}, [getNodeInspectVars])
const fetchInspectVarValue = useCallback(async (selector: ValueSelector) => {
const {
appId,
setNodeInspectVars,
} = workflowStore.getState()
const nodeId = selector[0]
const isSystemVar = nodeId === 'sys'
const isConversationVar = nodeId === 'conversation'
if (isSystemVar) {
invalidateSysVarValues()
return
}
if (isConversationVar) {
invalidateConversationVarValues()
return
}
const vars = await fetchNodeInspectVars(appId, nodeId)
setNodeInspectVars(nodeId, vars)
}, [workflowStore, invalidateSysVarValues, invalidateConversationVarValues])
// after last run would call this
const appendNodeInspectVars = useCallback((nodeId: string, payload: VarInInspect[], allNodes: Node[]) => {
const {
nodesWithInspectVars,
setNodesWithInspectVars,
} = workflowStore.getState()
const nodes = produce(nodesWithInspectVars, (draft) => {
const nodeInfo = allNodes.find(node => node.id === nodeId)
if (nodeInfo) {
const index = draft.findIndex(node => node.nodeId === nodeId)
if (index === -1) {
draft.unshift({
nodeId,
nodeType: nodeInfo.data.type,
title: nodeInfo.data.title,
vars: payload,
nodePayload: nodeInfo.data,
})
}
else {
draft[index].vars = payload
// put the node to the topAdd commentMore actions
draft.unshift(draft.splice(index, 1)[0])
}
}
})
setNodesWithInspectVars(nodes)
handleCancelNodeSuccessStatus(nodeId)
}, [workflowStore, handleCancelNodeSuccessStatus])
const hasNodeInspectVar = useCallback((nodeId: string, varId: string) => {
const { nodesWithInspectVars } = workflowStore.getState()
const targetNode = nodesWithInspectVars.find(item => item.nodeId === nodeId)
if(!targetNode || !targetNode.vars)
return false
return targetNode.vars.some(item => item.id === varId)
}, [workflowStore])
const deleteInspectVar = useCallback(async (nodeId: string, varId: string) => {
const { deleteInspectVar } = workflowStore.getState()
if(hasNodeInspectVar(nodeId, varId)) {
await doDeleteInspectVar(varId)
deleteInspectVar(nodeId, varId)
}
}, [doDeleteInspectVar, workflowStore, hasNodeInspectVar])
const resetConversationVar = useCallback(async (varId: string) => {
await doResetConversationVar(varId)
invalidateConversationVarValues()
}, [doResetConversationVar, invalidateConversationVarValues])
const deleteNodeInspectorVars = useCallback(async (nodeId: string) => {
const { deleteNodeInspectVars } = workflowStore.getState()
if (hasNodeInspectVars(nodeId)) {
await doDeleteNodeInspectorVars(nodeId)
deleteNodeInspectVars(nodeId)
}
}, [doDeleteNodeInspectorVars, workflowStore, hasNodeInspectVars])
const deleteAllInspectorVars = useCallback(async () => {
const { deleteAllInspectVars } = workflowStore.getState()
await doDeleteAllInspectorVars()
await invalidateConversationVarValues()
await invalidateSysVarValues()
deleteAllInspectVars()
handleEdgeCancelRunningStatus()
}, [doDeleteAllInspectorVars, invalidateConversationVarValues, invalidateSysVarValues, workflowStore, handleEdgeCancelRunningStatus])
const editInspectVarValue = useCallback(async (nodeId: string, varId: string, value: any) => {
const { setInspectVarValue } = workflowStore.getState()
await doEditInspectorVar({
varId,
value,
})
setInspectVarValue(nodeId, varId, value)
if (nodeId === VarInInspectType.conversation)
invalidateConversationVarValues()
if (nodeId === VarInInspectType.system)
invalidateSysVarValues()
}, [doEditInspectorVar, invalidateConversationVarValues, invalidateSysVarValues, workflowStore])
const renameInspectVarName = useCallback(async (nodeId: string, oldName: string, newName: string) => {
const { renameInspectVarName } = workflowStore.getState()
const varId = getVarId(nodeId, oldName)
if (!varId)
return
const newSelector = [nodeId, newName]
await doEditInspectorVar({
varId,
name: newName,
})
renameInspectVarName(nodeId, varId, newSelector)
}, [doEditInspectorVar, getVarId, workflowStore])
const isInspectVarEdited = useCallback((nodeId: string, name: string) => {
const inspectVar = getInspectVar(nodeId, name)
if (!inspectVar)
return false
return inspectVar.edited
}, [getInspectVar])
const resetToLastRunVar = useCallback(async (nodeId: string, varId: string) => {
const { resetToLastRunVar } = workflowStore.getState()
const isSysVar = nodeId === 'sys'
const data = await doResetToLastRunValue(varId)
if(isSysVar)
invalidateSysVarValues()
else
resetToLastRunVar(nodeId, varId, data.value)
}, [doResetToLastRunValue, invalidateSysVarValues, workflowStore])
const configsMap = useConfigsMap()
const apis = useInspectVarsCrudCommon({
flowId: appId,
...configsMap,
})
return {
hasNodeInspectVars,
hasSetInspectVar,
fetchInspectVarValue,
editInspectVarValue,
renameInspectVarName,
appendNodeInspectVars,
deleteInspectVar,
deleteNodeInspectorVars,
deleteAllInspectorVars,
isInspectVarEdited,
resetToLastRunVar,
invalidateSysVarValues,
resetConversationVar,
invalidateConversationVarValues,
...apis,
}
}

View File

@ -20,7 +20,8 @@ import type { VersionHistory } from '@/types/workflow'
import { noop } from 'lodash-es'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { useSetWorkflowVarsWithValue } from './use-fetch-workflow-inspect-vars'
import { useSetWorkflowVarsWithValue } from '../../workflow/hooks/use-fetch-workflow-inspect-vars'
import { useConfigsMap } from './use-configs-map'
export const useWorkflowRun = () => {
const store = useStoreApi()
@ -32,7 +33,11 @@ export const useWorkflowRun = () => {
const pathname = usePathname()
const appId = useAppStore.getState().appDetail?.id
const invalidAllLastRun = useInvalidAllLastRun(appId as string)
const { fetchInspectVars } = useSetWorkflowVarsWithValue()
const configsMap = useConfigsMap()
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
flowId: appId as string,
...configsMap,
})
const {
handleWorkflowStarted,

View File

@ -6,12 +6,20 @@ import type { Node } from '@/app/components/workflow/types'
import { fetchAllInspectVars } from '@/service/workflow'
import { useInvalidateConversationVarValues, useInvalidateSysVarValues } from '@/service/use-workflow'
import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync'
import { useConfigsMap } from './use-configs-map'
export const useSetWorkflowVarsWithValue = () => {
type Params = {
flowId: string
conversationVarsUrl: string
systemVarsUrl: string
}
export const useSetWorkflowVarsWithValue = ({
flowId,
conversationVarsUrl,
systemVarsUrl,
}: Params) => {
const workflowStore = useWorkflowStore()
const store = useStoreApi()
const { conversationVarsUrl, systemVarsUrl } = useConfigsMap()
const invalidateConversationVarValues = useInvalidateConversationVarValues(conversationVarsUrl)
const invalidateSysVarValues = useInvalidateSysVarValues(systemVarsUrl)
const { handleCancelAllNodeSuccessStatus } = useNodesInteractionsWithoutSync()
@ -58,13 +66,12 @@ export const useSetWorkflowVarsWithValue = () => {
}, [workflowStore, store])
const fetchInspectVars = useCallback(async () => {
const { appId } = workflowStore.getState()
invalidateConversationVarValues()
invalidateSysVarValues()
const data = await fetchAllInspectVars(appId)
const data = await fetchAllInspectVars(flowId)
setInspectVarsToStore(data)
handleCancelAllNodeSuccessStatus() // to make sure clear node output show the unset status
}, [workflowStore, invalidateConversationVarValues, invalidateSysVarValues, setInspectVarsToStore, handleCancelAllNodeSuccessStatus])
}, [invalidateConversationVarValues, invalidateSysVarValues, flowId, setInspectVarsToStore, handleCancelAllNodeSuccessStatus])
return {
fetchInspectVars,
}

View File

@ -0,0 +1,240 @@
import { fetchNodeInspectVars } from '@/service/workflow'
import { useWorkflowStore } from '@/app/components/workflow/store'
import type { ValueSelector } from '@/app/components/workflow/types'
import type { VarInInspect } from '@/types/workflow'
import { VarInInspectType } from '@/types/workflow'
import {
useDeleteAllInspectorVars,
useDeleteInspectVar,
useDeleteNodeInspectorVars,
useEditInspectorVar,
useInvalidateConversationVarValues,
useInvalidateSysVarValues,
useResetConversationVar,
useResetToLastRunValue,
} from '@/service/use-workflow'
import { useCallback } from 'react'
import { isConversationVar, isENV, isSystemVar } from '@/app/components/workflow/nodes/_base/components/variable/utils'
import produce from 'immer'
import type { Node } from '@/app/components/workflow/types'
import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync'
import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync'
type Params = {
flowId: string
conversationVarsUrl: string
systemVarsUrl: string
}
export const useInspectVarsCrudCommon = ({
flowId,
conversationVarsUrl,
systemVarsUrl,
}: Params) => {
const workflowStore = useWorkflowStore()
const invalidateConversationVarValues = useInvalidateConversationVarValues(conversationVarsUrl!)
const { mutateAsync: doResetConversationVar } = useResetConversationVar(flowId)
const { mutateAsync: doResetToLastRunValue } = useResetToLastRunValue(flowId)
const invalidateSysVarValues = useInvalidateSysVarValues(systemVarsUrl!)
const { mutateAsync: doDeleteAllInspectorVars } = useDeleteAllInspectorVars(flowId)
const { mutate: doDeleteNodeInspectorVars } = useDeleteNodeInspectorVars(flowId)
const { mutate: doDeleteInspectVar } = useDeleteInspectVar(flowId)
const { mutateAsync: doEditInspectorVar } = useEditInspectorVar(flowId)
const { handleCancelNodeSuccessStatus } = useNodesInteractionsWithoutSync()
const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync()
const getNodeInspectVars = useCallback((nodeId: string) => {
const { nodesWithInspectVars } = workflowStore.getState()
const node = nodesWithInspectVars.find(node => node.nodeId === nodeId)
return node
}, [workflowStore])
const getVarId = useCallback((nodeId: string, varName: string) => {
const node = getNodeInspectVars(nodeId)
if (!node)
return undefined
const varId = node.vars.find((varItem) => {
return varItem.selector[1] === varName
})?.id
return varId
}, [getNodeInspectVars])
const getInspectVar = useCallback((nodeId: string, name: string): VarInInspect | undefined => {
const node = getNodeInspectVars(nodeId)
if (!node)
return undefined
const variable = node.vars.find((varItem) => {
return varItem.name === name
})
return variable
}, [getNodeInspectVars])
const hasSetInspectVar = useCallback((nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => {
const isEnv = isENV([nodeId])
if (isEnv) // always have value
return true
const isSys = isSystemVar([nodeId])
if (isSys)
return sysVars.some(varItem => varItem.selector?.[1]?.replace('sys.', '') === name)
const isChatVar = isConversationVar([nodeId])
if (isChatVar)
return conversationVars.some(varItem => varItem.selector?.[1] === name)
return getInspectVar(nodeId, name) !== undefined
}, [getInspectVar])
const hasNodeInspectVars = useCallback((nodeId: string) => {
return !!getNodeInspectVars(nodeId)
}, [getNodeInspectVars])
const fetchInspectVarValue = useCallback(async (selector: ValueSelector) => {
const {
appId,
setNodeInspectVars,
} = workflowStore.getState()
const nodeId = selector[0]
const isSystemVar = nodeId === 'sys'
const isConversationVar = nodeId === 'conversation'
if (isSystemVar) {
invalidateSysVarValues()
return
}
if (isConversationVar) {
invalidateConversationVarValues()
return
}
const vars = await fetchNodeInspectVars(appId, nodeId)
setNodeInspectVars(nodeId, vars)
}, [workflowStore, invalidateSysVarValues, invalidateConversationVarValues])
// after last run would call this
const appendNodeInspectVars = useCallback((nodeId: string, payload: VarInInspect[], allNodes: Node[]) => {
const {
nodesWithInspectVars,
setNodesWithInspectVars,
} = workflowStore.getState()
const nodes = produce(nodesWithInspectVars, (draft) => {
const nodeInfo = allNodes.find(node => node.id === nodeId)
if (nodeInfo) {
const index = draft.findIndex(node => node.nodeId === nodeId)
if (index === -1) {
draft.unshift({
nodeId,
nodeType: nodeInfo.data.type,
title: nodeInfo.data.title,
vars: payload,
nodePayload: nodeInfo.data,
})
}
else {
draft[index].vars = payload
// put the node to the topAdd commentMore actions
draft.unshift(draft.splice(index, 1)[0])
}
}
})
setNodesWithInspectVars(nodes)
handleCancelNodeSuccessStatus(nodeId)
}, [workflowStore, handleCancelNodeSuccessStatus])
const hasNodeInspectVar = useCallback((nodeId: string, varId: string) => {
const { nodesWithInspectVars } = workflowStore.getState()
const targetNode = nodesWithInspectVars.find(item => item.nodeId === nodeId)
if(!targetNode || !targetNode.vars)
return false
return targetNode.vars.some(item => item.id === varId)
}, [workflowStore])
const deleteInspectVar = useCallback(async (nodeId: string, varId: string) => {
const { deleteInspectVar } = workflowStore.getState()
if(hasNodeInspectVar(nodeId, varId)) {
await doDeleteInspectVar(varId)
deleteInspectVar(nodeId, varId)
}
}, [doDeleteInspectVar, workflowStore, hasNodeInspectVar])
const resetConversationVar = useCallback(async (varId: string) => {
await doResetConversationVar(varId)
invalidateConversationVarValues()
}, [doResetConversationVar, invalidateConversationVarValues])
const deleteNodeInspectorVars = useCallback(async (nodeId: string) => {
const { deleteNodeInspectVars } = workflowStore.getState()
if (hasNodeInspectVars(nodeId)) {
await doDeleteNodeInspectorVars(nodeId)
deleteNodeInspectVars(nodeId)
}
}, [doDeleteNodeInspectorVars, workflowStore, hasNodeInspectVars])
const deleteAllInspectorVars = useCallback(async () => {
const { deleteAllInspectVars } = workflowStore.getState()
await doDeleteAllInspectorVars()
await invalidateConversationVarValues()
await invalidateSysVarValues()
deleteAllInspectVars()
handleEdgeCancelRunningStatus()
}, [doDeleteAllInspectorVars, invalidateConversationVarValues, invalidateSysVarValues, workflowStore, handleEdgeCancelRunningStatus])
const editInspectVarValue = useCallback(async (nodeId: string, varId: string, value: any) => {
const { setInspectVarValue } = workflowStore.getState()
await doEditInspectorVar({
varId,
value,
})
setInspectVarValue(nodeId, varId, value)
if (nodeId === VarInInspectType.conversation)
invalidateConversationVarValues()
if (nodeId === VarInInspectType.system)
invalidateSysVarValues()
}, [doEditInspectorVar, invalidateConversationVarValues, invalidateSysVarValues, workflowStore])
const renameInspectVarName = useCallback(async (nodeId: string, oldName: string, newName: string) => {
const { renameInspectVarName } = workflowStore.getState()
const varId = getVarId(nodeId, oldName)
if (!varId)
return
const newSelector = [nodeId, newName]
await doEditInspectorVar({
varId,
name: newName,
})
renameInspectVarName(nodeId, varId, newSelector)
}, [doEditInspectorVar, getVarId, workflowStore])
const isInspectVarEdited = useCallback((nodeId: string, name: string) => {
const inspectVar = getInspectVar(nodeId, name)
if (!inspectVar)
return false
return inspectVar.edited
}, [getInspectVar])
const resetToLastRunVar = useCallback(async (nodeId: string, varId: string) => {
const { resetToLastRunVar } = workflowStore.getState()
const isSysVar = nodeId === 'sys'
const data = await doResetToLastRunValue(varId)
if(isSysVar)
invalidateSysVarValues()
else
resetToLastRunVar(nodeId, varId, data.value)
}, [doResetToLastRunValue, invalidateSysVarValues, workflowStore])
return {
hasNodeInspectVars,
hasSetInspectVar,
fetchInspectVarValue,
editInspectVarValue,
renameInspectVarName,
appendNodeInspectVars,
deleteInspectVar,
deleteNodeInspectorVars,
deleteAllInspectorVars,
isInspectVarEdited,
resetToLastRunVar,
invalidateSysVarValues,
resetConversationVar,
invalidateConversationVarValues,
}
}

View File

@ -242,7 +242,7 @@ const FormInputItem: FC<Props> = ({
<AppSelector
disabled={readOnly}
scope={scope || 'all'}
value={varInput?.value as any}
value={varInput as any}
onSelect={handleAppOrModelSelect}
/>
)}
@ -251,7 +251,7 @@ const FormInputItem: FC<Props> = ({
popupClassName='!w-[387px]'
isAdvancedMode
isInWorkflow
value={varInput?.value as any}
value={varInput}
setModel={handleAppOrModelSelect}
readonly={readOnly}
scope={scope}

View File

@ -612,6 +612,7 @@ const getIterationItemType = ({
}): VarType => {
const outputVarNodeId = valueSelector[0]
const isSystem = isSystemVar(valueSelector)
const isChatVar = isConversationVar(valueSelector)
const targetVar = isSystem ? beforeNodesOutputVars.find(v => v.isStartNode) : beforeNodesOutputVars.find(v => v.nodeId === outputVarNodeId)
@ -621,7 +622,7 @@ const getIterationItemType = ({
let arrayType: VarType = VarType.string
let curr: any = targetVar.vars
if (isSystem) {
if (isSystem || isChatVar) {
arrayType = curr.find((v: any) => v.variable === (valueSelector).join('.'))?.type
}
else {

View File

@ -83,18 +83,19 @@ const BasePanel: FC<BasePanelProps> = ({
const otherPanelWidth = useStore(s => s.otherPanelWidth)
const setNodePanelWidth = useStore(s => s.setNodePanelWidth)
const reservedCanvasWidth = 400 // Reserve the minimum visible width for the canvas
const maxNodePanelWidth = useMemo(() => {
if (!workflowCanvasWidth)
return 720
if (!otherPanelWidth)
return workflowCanvasWidth - 400
return workflowCanvasWidth - otherPanelWidth - 400
const available = workflowCanvasWidth - (otherPanelWidth || 0) - reservedCanvasWidth
return Math.max(available, 400)
}, [workflowCanvasWidth, otherPanelWidth])
const updateNodePanelWidth = useCallback((width: number) => {
// Ensure the width is within the min and max range
const newValue = Math.min(Math.max(width, 400), maxNodePanelWidth)
const newValue = Math.max(400, Math.min(width, maxNodePanelWidth))
localStorage.setItem('workflow-node-panel-width', `${newValue}`)
setNodePanelWidth(newValue)
}, [maxNodePanelWidth, setNodePanelWidth])
@ -118,8 +119,13 @@ const BasePanel: FC<BasePanelProps> = ({
useEffect(() => {
if (!workflowCanvasWidth)
return
if (workflowCanvasWidth - 400 <= nodePanelWidth + otherPanelWidth)
debounceUpdate(workflowCanvasWidth - 400 - otherPanelWidth)
// If the total width of the three exceeds the canvas, shrink the node panel to the available range (at least 400px)
const total = nodePanelWidth + otherPanelWidth + reservedCanvasWidth
if (total > workflowCanvasWidth) {
const target = Math.max(workflowCanvasWidth - otherPanelWidth - reservedCanvasWidth, 400)
debounceUpdate(target)
}
}, [nodePanelWidth, otherPanelWidth, workflowCanvasWidth, updateNodePanelWidth])
const { handleNodeSelect } = useNodesInteractions()

View File

@ -13,18 +13,22 @@ import { Edit03 } from '@/app/components/base/icons/src/vender/solid/general'
import Badge from '@/app/components/base/badge'
import ConfigVarModal from '@/app/components/app/configuration/config-var/config-modal'
import { noop } from 'lodash-es'
import cn from '@/utils/classnames'
type Props = {
className?: string
readonly: boolean
payload: InputVar
onChange?: (item: InputVar, moreInfo?: MoreInfo) => void
onRemove?: () => void
rightContent?: React.JSX.Element
varKeys?: string[]
showLegacyBadge?: boolean
showLegacyBadge?: boolean,
canDrag?: boolean,
}
const VarItem: FC<Props> = ({
className,
readonly,
payload,
onChange = noop,
@ -32,6 +36,7 @@ const VarItem: FC<Props> = ({
rightContent,
varKeys = [],
showLegacyBadge = false,
canDrag,
}) => {
const { t } = useTranslation()
@ -47,9 +52,9 @@ const VarItem: FC<Props> = ({
hideEditVarModal()
}, [onChange, hideEditVarModal])
return (
<div ref={ref} className='flex h-8 cursor-pointer items-center justify-between rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2.5 shadow-xs hover:shadow-md'>
<div ref={ref} className={cn('flex h-8 cursor-pointer items-center justify-between rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2.5 shadow-xs hover:shadow-md', className)}>
<div className='flex w-0 grow items-center space-x-1'>
<Variable02 className='h-3.5 w-3.5 text-text-accent' />
<Variable02 className={cn('h-3.5 w-3.5 text-text-accent', canDrag && 'group-hover:opacity-0')} />
<div title={payload.variable} className='max-w-[130px] shrink-0 truncate text-[13px] font-medium text-text-secondary'>{payload.variable}</div>
{payload.label && (<><div className='shrink-0 text-xs font-medium text-text-quaternary'>·</div>
<div title={payload.label as string} className='max-w-[130px] truncate text-[13px] font-medium text-text-tertiary'>{payload.label as string}</div>

View File

@ -1,10 +1,15 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import React, { useCallback, useMemo } from 'react'
import produce from 'immer'
import { useTranslation } from 'react-i18next'
import VarItem from './var-item'
import { ChangeType, type InputVar, type MoreInfo } from '@/app/components/workflow/types'
import { v4 as uuid4 } from 'uuid'
import { ReactSortable } from 'react-sortablejs'
import { RiDraggable } from '@remixicon/react'
import cn from '@/utils/classnames'
type Props = {
readonly: boolean
list: InputVar[]
@ -44,6 +49,16 @@ const VarList: FC<Props> = ({
}
}, [list, onChange])
const listWithIds = useMemo(() => list.map((item) => {
const id = uuid4()
return {
id,
variable: { ...item },
}
}), [list])
const varCount = list.length
if (list.length === 0) {
return (
<div className='flex h-[42px] items-center justify-center rounded-md bg-components-panel-bg text-xs font-normal leading-[18px] text-text-tertiary'>
@ -53,18 +68,39 @@ const VarList: FC<Props> = ({
}
return (
<div className='space-y-1'>
{list.map((item, index) => (
<VarItem
key={index}
readonly={readonly}
payload={item}
onChange={handleVarChange(index)}
onRemove={handleVarRemove(index)}
varKeys={list.map(item => item.variable)}
/>
))}
</div>
<ReactSortable
className='space-y-1'
list={listWithIds}
setList={(list) => { onChange(list.map(item => item.variable)) }}
handle='.handle'
ghostClass='opacity-50'
animation={150}
>
{list.map((item, index) => {
const canDrag = (() => {
if (readonly)
return false
return varCount > 1
})()
return (
<div key={index} className='group relative'>
<VarItem
className={cn(canDrag && 'handle')}
readonly={readonly}
payload={item}
onChange={handleVarChange(index)}
onRemove={handleVarRemove(index)}
varKeys={list.map(item => item.variable)}
canDrag={canDrag}
/>
{canDrag && <RiDraggable className={cn(
'handle absolute left-3 top-2.5 hidden h-3 w-3 cursor-pointer text-text-tertiary',
'group-hover:block',
)} />}
</div>
)
})}
</ReactSortable>
)
}
export default React.memo(VarList)

View File

@ -77,6 +77,30 @@ const Panel: FC<PanelProps> = ({
const isRestoring = useStore(s => s.isRestoring)
const showWorkflowVersionHistoryPanel = useStore(s => s.showWorkflowVersionHistoryPanel)
// widths used for adaptive layout
const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth)
const previewPanelWidth = useStore(s => s.previewPanelWidth)
const setPreviewPanelWidth = useStore(s => s.setPreviewPanelWidth)
// When a node is selected and the NodePanel appears, if the current width
// of preview/otherPanel is too large, it may result in the total width of
// the two panels exceeding the workflowCanvasWidth, causing the NodePanel
// to be pushed out. Here we check and, if necessary, reduce the previewPanelWidth
// to "workflowCanvasWidth - 400 (minimum NodePanel width) - 400 (minimum canvas space)",
// while still ensuring that previewPanelWidth ≥ 400.
useEffect(() => {
if (!selectedNode || !workflowCanvasWidth)
return
const reservedCanvasWidth = 400 // Reserve the minimum visible width for the canvas
const minNodePanelWidth = 400
const maxAllowed = Math.max(workflowCanvasWidth - reservedCanvasWidth - minNodePanelWidth, 400)
if (previewPanelWidth > maxAllowed)
setPreviewPanelWidth(maxAllowed)
}, [selectedNode, workflowCanvasWidth, previewPanelWidth, setPreviewPanelWidth])
const setRightPanelWidth = useStore(s => s.setRightPanelWidth)
const setOtherPanelWidth = useStore(s => s.setOtherPanelWidth)

View File

@ -32,6 +32,9 @@ const WorkflowPreview = () => {
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
const workflowRunningData = useStore(s => s.workflowRunningData)
const showInputsPanel = useStore(s => s.showInputsPanel)
const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth)
const panelWidth = useStore(s => s.previewPanelWidth)
const setPreviewPanelWidth = useStore(s => s.setPreviewPanelWidth)
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
const [currentTab, setCurrentTab] = useState<string>(showInputsPanel ? 'INPUT' : 'TRACING')
@ -49,7 +52,6 @@ const WorkflowPreview = () => {
switchTab('DETAIL')
}, [workflowRunningData])
const [panelWidth, setPanelWidth] = useState(420)
const [isResizing, setIsResizing] = useState(false)
const startResizing = useCallback((e: React.MouseEvent) => {
@ -64,10 +66,14 @@ const WorkflowPreview = () => {
const resize = useCallback((e: MouseEvent) => {
if (isResizing) {
const newWidth = window.innerWidth - e.clientX
if (newWidth > 420 && newWidth < 1024)
setPanelWidth(newWidth)
// width constraints: 400 <= width <= maxAllowed (canvas - reserved 400)
const reservedCanvasWidth = 400
const maxAllowed = workflowCanvasWidth ? (workflowCanvasWidth - reservedCanvasWidth) : 1024
if (newWidth >= 400 && newWidth <= maxAllowed)
setPreviewPanelWidth(newWidth)
}
}, [isResizing])
}, [isResizing, workflowCanvasWidth, setPreviewPanelWidth])
useEffect(() => {
window.addEventListener('mousemove', resize)
@ -79,9 +85,9 @@ const WorkflowPreview = () => {
}, [resize, stopResizing])
return (
<div className={`
relative flex h-full flex-col rounded-l-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl
`}
<div className={
'relative flex h-full flex-col rounded-l-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'
}
style={{ width: `${panelWidth}px` }}
>
<div

View File

@ -11,8 +11,7 @@ import Button from '@/app/components/base/button'
import { changePasswordWithToken, verifyForgotPasswordToken } from '@/service/common'
import Toast from '@/app/components/base/toast'
import Loading from '@/app/components/base/loading'
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
import { validPassword } from '@/config'
const ChangePasswordForm = () => {
const { t } = useTranslation()

View File

@ -18,8 +18,7 @@ import { fetchInitValidateStatus, fetchSetupStatus, setup } from '@/service/comm
import type { InitValidateStatusResponse, SetupStatusResponse } from '@/models/common'
import useDocumentTitle from '@/hooks/use-document-title'
import { useDocLink } from '@/context/i18n'
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
import { validPassword } from '@/config'
const accountFormSchema = z.object({
email: z

View File

@ -9,8 +9,7 @@ import Button from '@/app/components/base/button'
import { changePasswordWithToken } from '@/service/common'
import Toast from '@/app/components/base/toast'
import Input from '@/app/components/base/input'
const validPassword = /^(?=.*[a-zA-Z])(?=.*\d).{8,}$/
import { validPassword } from '@/config'
const ChangePasswordForm = () => {
const { t } = useTranslation()

View File

@ -16,6 +16,7 @@ import I18n from '@/context/i18n'
import { activateMember, invitationCheck } from '@/service/common'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { noop } from 'lodash-es'
export default function InviteSettingsPage() {
const { t } = useTranslation()
@ -88,8 +89,7 @@ export default function InviteSettingsPage() {
<div className='pb-4 pt-2'>
<h2 className='title-4xl-semi-bold'>{t('login.setYourAccount')}</h2>
</div>
<form action=''>
<form onSubmit={noop}>
<div className='mb-5'>
<label htmlFor="name" className="system-md-semibold my-2">
{t('login.name')}
@ -101,6 +101,13 @@ export default function InviteSettingsPage() {
value={name}
onChange={e => setName(e.target.value)}
placeholder={t('login.namePlaceholder') || ''}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
e.stopPropagation()
handleActivate()
}
}}
/>
</div>
</div>

56
web/config/index.spec.ts Normal file
View File

@ -0,0 +1,56 @@
import { validPassword } from './index'
describe('validPassword Tests', () => {
const passwordRegex = validPassword
// Valid passwords
test('Valid passwords: contains letter+digit, length ≥8', () => {
expect(passwordRegex.test('password1')).toBe(true)
expect(passwordRegex.test('PASSWORD1')).toBe(true)
expect(passwordRegex.test('12345678a')).toBe(true)
expect(passwordRegex.test('a1b2c3d4')).toBe(true)
expect(passwordRegex.test('VeryLongPassword123')).toBe(true)
expect(passwordRegex.test('short1')).toBe(false)
})
// Missing letter
test('Invalid passwords: missing letter', () => {
expect(passwordRegex.test('12345678')).toBe(false)
expect(passwordRegex.test('!@#$%^&*123')).toBe(false)
})
// Missing digit
test('Invalid passwords: missing digit', () => {
expect(passwordRegex.test('password')).toBe(false)
expect(passwordRegex.test('PASSWORD')).toBe(false)
expect(passwordRegex.test('AbCdEfGh')).toBe(false)
})
// Too short
test('Invalid passwords: less than 8 characters', () => {
expect(passwordRegex.test('pass1')).toBe(false)
expect(passwordRegex.test('abc123')).toBe(false)
expect(passwordRegex.test('1a')).toBe(false)
})
// Boundary test
test('Boundary test: exactly 8 characters', () => {
expect(passwordRegex.test('abc12345')).toBe(true)
expect(passwordRegex.test('1abcdefg')).toBe(true)
})
// Special characters
test('Special characters: non-whitespace special chars allowed', () => {
expect(passwordRegex.test('pass@123')).toBe(true)
expect(passwordRegex.test('p@$$w0rd')).toBe(true)
expect(passwordRegex.test('!1aBcDeF')).toBe(true)
})
// Contains whitespace
test('Invalid passwords: contains whitespace', () => {
expect(passwordRegex.test('pass word1')).toBe(false)
expect(passwordRegex.test('password1 ')).toBe(false)
expect(passwordRegex.test(' password1')).toBe(false)
expect(passwordRegex.test('pass\tword1')).toBe(false)
})
})

View File

@ -276,3 +276,5 @@ export const ENABLE_WEBSITE_FIRECRAWL = getBooleanConfig(process.env.NEXT_PUBLIC
export const ENABLE_WEBSITE_WATERCRAWL = getBooleanConfig(process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL, DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_WATERCRAWL, false)
export const VALUE_SELECTOR_DELIMITER = '@@@'
export const validPassword = /^(?=.*[a-zA-Z])(?=.*\d)\S{8,}$/