Compare commits

..

119 Commits

Author SHA1 Message Date
JzoNg
81baeae5c4 fix(web): evaluation workflow switch 2026-04-03 18:22:44 +08:00
JzoNg
a3010bdc0b Merge branch 'main' into jzh 2026-04-03 18:05:54 +08:00
Coding On Star
83d4176785 test: add unit tests for app store and annotation components, enhancing coverage for state management and UI interactions (#34510)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 09:09:59 +00:00
JzoNg
8133e550ed chore: fix pre-hook of web 2026-04-03 16:21:32 +08:00
JzoNg
2bb0eab636 chore(web): mapping row refactor 2026-04-03 16:10:41 +08:00
JzoNg
5311b5d00d feat(web): available evaluation workflow selector 2026-04-03 16:06:33 +08:00
JzoNg
9b02ccdd12 Merge branch 'main' into jzh 2026-04-03 15:15:11 +08:00
JzoNg
231783eebe chore(web): fix lint 2026-04-03 15:13:52 +08:00
yyh
c94951b2f8 refactor(web): migrate notion page selectors to tanstack virtual (#34508)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 07:03:12 +00:00
Matt Van Horn
a9cf8f6c5d refactor(web): replace react-syntax-highlighter with shiki (#33473)
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 06:40:26 +00:00
JzoNg
756606f478 feat(web): hide card view in evaluation 2026-04-03 14:39:41 +08:00
YBoy
64ddec0d67 refactor(api): type annotation service dicts with TypedDict (#34482)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-04-03 06:25:52 +00:00
JzoNg
6651c1c5da feat(web): workflow switch 2026-04-03 14:22:50 +08:00
Renzo
da3b0caf5e refactor: select in account_service (RegisterService class) (#34500)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/amd64, ubuntu-latest, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/amd64, ubuntu-latest, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Skip Duplicate Checks (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / Run API Tests (push) Has been cancelled
Main CI Pipeline / Skip API Tests (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Run Web Tests (push) Has been cancelled
Main CI Pipeline / Skip Web Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Run Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Skip Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / Run VDB Tests (push) Has been cancelled
Main CI Pipeline / Skip VDB Tests (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / Run DB Migration Test (push) Has been cancelled
Main CI Pipeline / Skip DB Migration Test (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 06:21:26 +00:00
JzoNg
61e257b2a8 feat(web): app switch api 2026-04-03 13:56:00 +08:00
Stephen Zhou
4fedd43af5 chore: update code-inspector-plugin to 1.5.1 (#34506) 2026-04-03 05:34:03 +00:00
yyh
a263f28e19 fix(web): restore ui select public exports (#34501) 2026-04-03 04:42:02 +00:00
Stephen Zhou
d53862f135 chore: override lodash (#34502) 2026-04-03 04:40:46 +00:00
Renzo
608958de1c refactor: select in external_knowledge_service (#34493)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 03:42:16 +00:00
Renzo
7eb632eb34 refactor: select in rag_pipeline (#34495)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 03:42:01 +00:00
Renzo
33d4fd357c refactor: select in account_service (AccountService class) (#34496) 2026-04-03 03:41:46 +00:00
agenthaulk
e55bd61c17 refactor: replace useContext with use in selected batch (#34450) 2026-04-03 03:37:35 +00:00
JzoNg
3ac4caf735 Merge branch 'main' into jzh 2026-04-03 11:28:22 +08:00
Stephen Zhou
f2fc213d52 chore: update deps (#34487) 2026-04-03 03:26:49 +00:00
YBoy
f814579ed2 test: migrate service_api dataset controller tests to testcontainers (#34423)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 02:28:47 +00:00
YBoy
71d299d0d3 refactor(api): type hit testing retrieve responses with TypedDict (#34484) 2026-04-03 02:25:30 +00:00
YBoy
e178451d04 refactor(api): type log identity dict with IdentityDict TypedDict (#34485) 2026-04-03 02:25:02 +00:00
YBoy
9a6222f245 refactor(api): type webhook data extraction with RawWebhookDataDict TypedDict (#34486) 2026-04-03 02:24:17 +00:00
YBoy
affe5ed30b refactor(api): type get_knowledge_rate_limit with KnowledgeRateLimitD… (#34483) 2026-04-03 02:23:32 +00:00
wangxiaolei
4cc5401d7e fix: fix import dsl failed (#34492)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-03 02:08:21 +00:00
Stephen Zhou
36e840cd87 chore: knip fix (#34481)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/amd64, ubuntu-latest, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/amd64, ubuntu-latest, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Skip Duplicate Checks (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / Run API Tests (push) Has been cancelled
Main CI Pipeline / Skip API Tests (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Run Web Tests (push) Has been cancelled
Main CI Pipeline / Skip Web Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Run Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Skip Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / Run VDB Tests (push) Has been cancelled
Main CI Pipeline / Skip VDB Tests (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / Run DB Migration Test (push) Has been cancelled
Main CI Pipeline / Skip DB Migration Test (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-04-02 15:03:42 +00:00
Tim Ren
985b41c40b fix(security): add tenant_id validation to prevent IDOR in data source binding (#34456)
Some checks failed
autofix.ci / autofix (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/amd64, ubuntu-latest, build-api-amd64) (push) Has been cancelled
Build and Push API & Web / build (api, {{defaultContext}}:api, Dockerfile, DIFY_API_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-api-arm64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/amd64, ubuntu-latest, build-web-amd64) (push) Has been cancelled
Build and Push API & Web / build (web, {{defaultContext}}, web/Dockerfile, DIFY_WEB_IMAGE_NAME, linux/arm64, ubuntu-24.04-arm, build-web-arm64) (push) Has been cancelled
Build and Push API & Web / create-manifest (api, DIFY_API_IMAGE_NAME, merge-api-images) (push) Has been cancelled
Build and Push API & Web / create-manifest (web, DIFY_WEB_IMAGE_NAME, merge-web-images) (push) Has been cancelled
Main CI Pipeline / Skip Duplicate Checks (push) Has been cancelled
Main CI Pipeline / Check Changed Files (push) Has been cancelled
Main CI Pipeline / Run API Tests (push) Has been cancelled
Main CI Pipeline / Skip API Tests (push) Has been cancelled
Main CI Pipeline / API Tests (push) Has been cancelled
Main CI Pipeline / Run Web Tests (push) Has been cancelled
Main CI Pipeline / Skip Web Tests (push) Has been cancelled
Main CI Pipeline / Web Tests (push) Has been cancelled
Main CI Pipeline / Run Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Skip Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Web Full-Stack E2E (push) Has been cancelled
Main CI Pipeline / Style Check (push) Has been cancelled
Main CI Pipeline / Run VDB Tests (push) Has been cancelled
Main CI Pipeline / Skip VDB Tests (push) Has been cancelled
Main CI Pipeline / VDB Tests (push) Has been cancelled
Main CI Pipeline / Run DB Migration Test (push) Has been cancelled
Main CI Pipeline / Skip DB Migration Test (push) Has been cancelled
Main CI Pipeline / DB Migration Test (push) Has been cancelled
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:17:02 +00:00
lif
2e29ac2829 fix: remove redundant cast in MCP base session (#34461)
Signed-off-by: majiayu000 <1835304752@qq.com>
2026-04-02 12:36:21 +00:00
Renzo
dbfb474eab refactor: select in workflow_tools_manage_service (#34477) 2026-04-02 12:35:04 +00:00
Renzo
d243de26ec refactor: select in metadata_service (#34479) 2026-04-02 12:34:38 +00:00
Stephen Zhou
894826771a chore: clean up useless tailwind reference (#34478) 2026-04-02 11:45:19 +00:00
JzoNg
268ae1751d Merge branch 'main' into jzh 2026-04-01 09:26:13 +08:00
JzoNg
015cbf850b Merge branch 'main' into jzh 2026-03-31 18:08:24 +08:00
JzoNg
873e13c2fb feat(web): support select node in metric card 2026-03-31 18:07:52 +08:00
JzoNg
688bf7e7a1 feat(web): metric card style 2026-03-31 17:43:56 +08:00
JzoNg
a6ffff3b39 fix(web): fix style of metric selector 2026-03-31 17:22:07 +08:00
JzoNg
023fc55bd5 fix(web): empty state of metric 2026-03-31 17:11:44 +08:00
JzoNg
351b909a53 feat(web): metric card 2026-03-31 17:00:37 +08:00
JzoNg
6bec4f65c9 refactor(web): metric section refactor 2026-03-31 16:28:48 +08:00
JzoNg
74f87ce152 Merge branch 'main' into jzh 2026-03-31 16:13:04 +08:00
JzoNg
92c472ccc7 Merge branch 'main' into jzh 2026-03-30 15:40:23 +08:00
JzoNg
b92b8becd1 feat(web): metric selector 2026-03-30 15:39:52 +08:00
JzoNg
23d0d6a65d chore(web): i18n of metrics 2026-03-30 14:20:43 +08:00
JzoNg
1660067d6e feat(web): judgement model selector 2026-03-30 14:03:37 +08:00
JzoNg
0642475b85 Merge branch 'main' into jzh 2026-03-30 13:30:10 +08:00
JzoNg
8cb634c9bc feat(web): evaluation layout 2026-03-30 11:27:06 +08:00
JzoNg
768b41c3cf Merge branch 'main' into jzh 2026-03-30 11:07:42 +08:00
JzoNg
ca88516d54 refactor(web): refactor evaluation page 2026-03-30 11:06:41 +08:00
JzoNg
871a2a149f refactor(web): split snippet index 2026-03-30 10:32:59 +08:00
JzoNg
60e381eff0 Merge branch 'main' into jzh 2026-03-30 09:48:58 +08:00
JzoNg
768b3eb6f9 feat(web): test run of snippet 2026-03-29 20:55:11 +08:00
JzoNg
2f88da4a6d feat(web): add variable inspect for snippet 2026-03-29 20:23:24 +08:00
JzoNg
a8cdf6964c feat(web): test run button 2026-03-29 20:02:59 +08:00
JzoNg
985c3db4fd feat(web): snippet input field panel layout 2026-03-29 18:02:27 +08:00
JzoNg
9636472db7 refactor(web): snippet main 2026-03-29 17:50:30 +08:00
JzoNg
0ad268aa7d feat(web): snippet publish 2026-03-29 17:29:37 +08:00
JzoNg
a4ea33167d feat(web): block selector in snippet 2026-03-29 17:01:32 +08:00
JzoNg
0f13aabea8 feat(web): input fields in snippet 2026-03-29 16:31:38 +08:00
JzoNg
1e76ef5ccb chore(web): ignore system vars & conversation vars in rag-pipeline and snippet 2026-03-29 15:56:24 +08:00
JzoNg
e6e3229d17 feat(web): input field button style 2026-03-29 15:45:05 +08:00
JzoNg
dccf8e723a feat(web): snippet version panel 2026-03-29 15:26:59 +08:00
JzoNg
c41ba7d627 feat(web): snippet header in graph 2026-03-29 15:02:34 +08:00
JzoNg
a6e9316de3 Merge branch 'main' into jzh 2026-03-29 14:07:49 +08:00
JzoNg
559d326cbd chore(web): mock data of snippet 2026-03-27 17:24:01 +08:00
JzoNg
abedf2506f Merge branch 'main' into jzh 2026-03-27 17:01:27 +08:00
JzoNg
d01428b5bc feat(web): snippet graph draft sync 2026-03-27 16:02:47 +08:00
JzoNg
0de1f17e5c Merge branch 'main' into jzh 2026-03-27 15:23:49 +08:00
JzoNg
17d07a5a43 feat(web): init snippet graph 2026-03-27 15:23:03 +08:00
JzoNg
3bdbea99a3 Merge branch 'main' into jzh 2026-03-27 14:04:10 +08:00
JzoNg
b7683aedb1 Merge branch 'main' into jzh 2026-03-26 21:38:48 +08:00
JzoNg
515036e758 test(web): add tests for snippets 2026-03-26 21:38:22 +08:00
JzoNg
22b382527f feat(web): add snippet to workflow 2026-03-26 21:26:29 +08:00
JzoNg
2cfe4b5b86 feat(web): snippet graph data fetching 2026-03-26 21:11:09 +08:00
JzoNg
6876c8041c feat(web): snippet list data fetching in block selector 2026-03-26 20:58:42 +08:00
JzoNg
7de45584ce refactor: snippets list 2026-03-26 20:41:51 +08:00
JzoNg
5572d7c7e8 Merge branch 'main' into jzh 2026-03-26 20:10:47 +08:00
JzoNg
db0a2fe52e Merge branch 'main' into jzh 2026-03-26 16:29:44 +08:00
JzoNg
f0ae8d6167 fix(web): unused imports caused by merge 2026-03-26 16:28:56 +08:00
JzoNg
2514e181ba Merge branch 'main' into jzh 2026-03-26 16:16:10 +08:00
JzoNg
be2e6e9a14 Merge branch 'main' into jzh 2026-03-26 14:23:29 +08:00
JzoNg
875e2eac1b Merge branch 'main' into jzh 2026-03-26 08:38:57 +08:00
JzoNg
c3c73ceb1f Merge branch 'main' into jzh 2026-03-25 23:02:18 +08:00
JzoNg
6318bf0a2a feat(web): create snippet from workflow 2026-03-25 22:57:48 +08:00
JzoNg
5e1f252046 feat(web): selection context menu style update 2026-03-25 22:36:27 +08:00
JzoNg
df3b960505 fix(web): position of selection context menu in workflow graph 2026-03-25 22:02:50 +08:00
JzoNg
26bc108bf1 chore(web): tests for snippet info 2026-03-25 21:35:36 +08:00
JzoNg
a5cff32743 feat(web): snippet info operations 2026-03-25 21:29:06 +08:00
JzoNg
d418dd8eec Merge branch 'main' into jzh 2026-03-25 20:17:32 +08:00
JzoNg
61702fe346 Merge branch 'main' into jzh 2026-03-25 18:17:03 +08:00
JzoNg
43f0c780c3 Merge branch 'main' into jzh 2026-03-25 15:30:21 +08:00
JzoNg
30ebf2bfa9 Merge branch 'main' into jzh 2026-03-24 07:25:22 +08:00
JzoNg
7e3027b5f7 feat(web): snippet card usage info 2026-03-23 17:02:00 +08:00
JzoNg
b3acf83090 Merge branch 'main' into jzh 2026-03-23 16:46:26 +08:00
JzoNg
36c3d6e48a feat(web): snippet list fetching & display 2026-03-23 16:37:05 +08:00
JzoNg
f782ac6b3c feat(web): create snippets by DSL import 2026-03-23 14:55:36 +08:00
JzoNg
feef2dd1fa feat(web): add snippet creation dialog flow 2026-03-23 11:29:41 +08:00
JzoNg
a716d8789d refactor: extract snippet list components 2026-03-23 10:48:15 +08:00
JzoNg
6816f89189 Merge branch 'main' into jzh 2026-03-23 10:13:45 +08:00
JzoNg
bfcac64a9d Merge branch 'main' into jzh 2026-03-20 15:33:49 +08:00
JzoNg
664eb601a2 feat(web): add api of snippet worfklows 2026-03-20 15:29:53 +08:00
JzoNg
8e5cc4e0aa feat(web): add evaluation api 2026-03-20 15:23:03 +08:00
JzoNg
9f28575903 feat(web): add snippets api 2026-03-20 15:11:33 +08:00
JzoNg
4b9a26a5e6 Merge branch 'main' into jzh 2026-03-20 14:01:34 +08:00
JzoNg
7b85adf1cc Merge branch 'main' into jzh 2026-03-20 10:46:45 +08:00
JzoNg
c964708ebe Merge branch 'main' into jzh 2026-03-18 18:07:20 +08:00
JzoNg
883eb498c0 Merge branch 'main' into jzh 2026-03-18 17:40:51 +08:00
JzoNg
4d3738d225 Merge branch 'main' into feat/evaluation-fe 2026-03-17 10:42:44 +08:00
JzoNg
dd0dee739d Merge branch 'main' into jzh 2026-03-16 15:43:20 +08:00
zxhlyh
4d19914fcb Merge branch 'main' into feat/evaluation-fe 2026-03-16 10:47:37 +08:00
zxhlyh
887c7710e9 feat: evaluation 2026-03-16 10:46:33 +08:00
zxhlyh
7a722773c7 feat: snippet canvas 2026-03-13 17:45:04 +08:00
zxhlyh
a763aff58b feat: snippets list 2026-03-13 16:12:42 +08:00
zxhlyh
c1011f4e5c feat: add to snippet 2026-03-13 14:29:59 +08:00
zxhlyh
f7afa103a5 feat: select snippets 2026-03-13 13:43:29 +08:00
813 changed files with 34245 additions and 12788 deletions

View File

@@ -77,7 +77,7 @@ if $web_modified; then
fi
cd ./web || exit 1
vp staged
pnpm exec vp staged
if $web_ts_modified; then
echo "Running TypeScript type-check:tsgo"
@@ -89,6 +89,12 @@ if $web_modified; then
echo "No staged TypeScript changes detected, skipping type-check:tsgo"
fi
echo "Running knip"
if ! pnpm run knip; then
echo "Knip check failed. Please run 'pnpm run knip' to fix the errors."
exit 1
fi
echo "Running unit tests check"
modified_files=$(git diff --cached --name-only -- utils | grep -v '\.spec\.ts$' || true)

View File

@@ -1,6 +1,6 @@
from flask_restx import Resource, fields, marshal_with
from pydantic import BaseModel, Field
from sqlalchemy.orm import sessionmaker
from sqlalchemy.orm import Session, sessionmaker
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
@@ -71,7 +71,7 @@ class AppImportApi(Resource):
args = AppImportPayload.model_validate(console_ns.payload)
# Create service with session
with sessionmaker(db.engine).begin() as session:
with Session(db.engine) as session:
import_service = AppDslService(session)
# Import app
account = current_user

View File

@@ -158,10 +158,11 @@ class DataSourceApi(Resource):
@login_required
@account_initialization_required
def patch(self, binding_id, action: Literal["enable", "disable"]):
_, current_tenant_id = current_account_with_tenant()
binding_id = str(binding_id)
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
data_source_binding = session.execute(
select(DataSourceOauthBinding).filter_by(id=binding_id)
select(DataSourceOauthBinding).filter_by(id=binding_id, tenant_id=current_tenant_id)
).scalar_one_or_none()
if data_source_binding is None:
raise NotFound("Data source binding not found.")

View File

@@ -7,7 +7,7 @@ from werkzeug.exceptions import NotFound, RequestEntityTooLarge
from controllers.trigger import bp
from core.trigger.debug.event_bus import TriggerDebugEventBus
from core.trigger.debug.events import WebhookDebugEvent, build_webhook_pool_key
from services.trigger.webhook_service import WebhookService
from services.trigger.webhook_service import RawWebhookDataDict, WebhookService
logger = logging.getLogger(__name__)
@@ -23,6 +23,7 @@ def _prepare_webhook_execution(webhook_id: str, is_debug: bool = False):
webhook_id, is_debug=is_debug
)
webhook_data: RawWebhookDataDict
try:
# Use new unified extraction and validation
webhook_data = WebhookService.extract_and_validate_webhook_data(webhook_trigger, node_config)

View File

@@ -3,13 +3,19 @@
import logging
import traceback
from datetime import UTC, datetime
from typing import Any
from typing import Any, TypedDict
import orjson
from configs import dify_config
class IdentityDict(TypedDict, total=False):
tenant_id: str
user_id: str
user_type: str
class StructuredJSONFormatter(logging.Formatter):
"""
JSON log formatter following the specified schema:
@@ -84,7 +90,7 @@ class StructuredJSONFormatter(logging.Formatter):
return log_dict
def _extract_identity(self, record: logging.LogRecord) -> dict[str, str] | None:
def _extract_identity(self, record: logging.LogRecord) -> IdentityDict | None:
tenant_id = getattr(record, "tenant_id", None)
user_id = getattr(record, "user_id", None)
user_type = getattr(record, "user_type", None)
@@ -92,7 +98,7 @@ class StructuredJSONFormatter(logging.Formatter):
if not any([tenant_id, user_id, user_type]):
return None
identity: dict[str, str] = {}
identity: IdentityDict = {}
if tenant_id:
identity["tenant_id"] = tenant_id
if user_id:

View File

@@ -4,7 +4,7 @@ from collections.abc import Callable
from concurrent.futures import Future, ThreadPoolExecutor, TimeoutError
from datetime import timedelta
from types import TracebackType
from typing import Any, Self, cast
from typing import Any, Self
from httpx import HTTPStatusError
from pydantic import BaseModel
@@ -338,12 +338,11 @@ class BaseSession[
validated_request = self._receive_request_type.model_validate(
message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True)
)
validated_request = cast(ReceiveRequestT, validated_request)
responder = RequestResponder[ReceiveRequestT, SendResultT](
request_id=message.message.root.id,
request_meta=validated_request.root.params.meta if validated_request.root.params else None,
request=validated_request,
request=validated_request, # type: ignore[arg-type] # mypy can't narrow constrained TypeVar from model_validate
session=self,
on_complete=lambda r: self._in_flight.pop(r.request_id, None),
)
@@ -359,15 +358,14 @@ class BaseSession[
notification = self._receive_notification_type.model_validate(
message.message.root.model_dump(by_alias=True, mode="json", exclude_none=True)
)
notification = cast(ReceiveNotificationT, notification)
# Handle cancellation notifications
if isinstance(notification.root, CancelledNotification):
cancelled_id = notification.root.params.requestId
if cancelled_id in self._in_flight:
self._in_flight[cancelled_id].cancel()
else:
self._received_notification(notification)
self._handle_incoming(notification)
self._received_notification(notification) # type: ignore[arg-type]
self._handle_incoming(notification) # type: ignore[arg-type]
except Exception as e:
# For other validation errors, log and continue
logger.warning("Failed to validate notification: %s. Message was: %s", e, message.message.root)

View File

@@ -8,7 +8,7 @@ from hashlib import sha256
from typing import Any, TypedDict, cast
from pydantic import BaseModel, TypeAdapter
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
@@ -144,22 +144,26 @@ class AccountService:
@staticmethod
def load_user(user_id: str) -> None | Account:
account = db.session.query(Account).filter_by(id=user_id).first()
account = db.session.get(Account, user_id)
if not account:
return None
if account.status == AccountStatus.BANNED:
raise Unauthorized("Account is banned.")
current_tenant = db.session.query(TenantAccountJoin).filter_by(account_id=account.id, current=True).first()
current_tenant = db.session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.account_id == account.id, TenantAccountJoin.current == True)
.limit(1)
)
if current_tenant:
account.set_tenant_id(current_tenant.tenant_id)
else:
available_ta = (
db.session.query(TenantAccountJoin)
.filter_by(account_id=account.id)
available_ta = db.session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.account_id == account.id)
.order_by(TenantAccountJoin.id.asc())
.first()
.limit(1)
)
if not available_ta:
return None
@@ -195,7 +199,7 @@ class AccountService:
def authenticate(email: str, password: str, invite_token: str | None = None) -> Account:
"""authenticate account with email and password"""
account = db.session.query(Account).filter_by(email=email).first()
account = db.session.scalar(select(Account).where(Account.email == email).limit(1))
if not account:
raise AccountPasswordError("Invalid email or password.")
@@ -371,8 +375,10 @@ class AccountService:
"""Link account integrate"""
try:
# Query whether there is an existing binding record for the same provider
account_integrate: AccountIntegrate | None = (
db.session.query(AccountIntegrate).filter_by(account_id=account.id, provider=provider).first()
account_integrate: AccountIntegrate | None = db.session.scalar(
select(AccountIntegrate)
.where(AccountIntegrate.account_id == account.id, AccountIntegrate.provider == provider)
.limit(1)
)
if account_integrate:
@@ -416,7 +422,9 @@ class AccountService:
def update_account_email(account: Account, email: str) -> Account:
"""Update account email"""
account.email = email
account_integrate = db.session.query(AccountIntegrate).filter_by(account_id=account.id).first()
account_integrate = db.session.scalar(
select(AccountIntegrate).where(AccountIntegrate.account_id == account.id).limit(1)
)
if account_integrate:
db.session.delete(account_integrate)
db.session.add(account)
@@ -818,7 +826,7 @@ class AccountService:
)
)
account = db.session.query(Account).where(Account.email == email).first()
account = db.session.scalar(select(Account).where(Account.email == email).limit(1))
if not account:
return None
@@ -1018,7 +1026,7 @@ class AccountService:
@staticmethod
def check_email_unique(email: str) -> bool:
return db.session.query(Account).filter_by(email=email).first() is None
return db.session.scalar(select(Account).where(Account.email == email).limit(1)) is None
class TenantService:
@@ -1384,10 +1392,10 @@ class RegisterService:
db.session.add(dify_setup)
db.session.commit()
except Exception as e:
db.session.query(DifySetup).delete()
db.session.query(TenantAccountJoin).delete()
db.session.query(Account).delete()
db.session.query(Tenant).delete()
db.session.execute(delete(DifySetup))
db.session.execute(delete(TenantAccountJoin))
db.session.execute(delete(Account))
db.session.execute(delete(Tenant))
db.session.commit()
logger.exception("Setup account failed, email: %s, name: %s", email, name)
@@ -1488,7 +1496,11 @@ class RegisterService:
TenantService.switch_tenant(account, tenant.id)
else:
TenantService.check_member_permission(tenant, inviter, account, "add")
ta = db.session.query(TenantAccountJoin).filter_by(tenant_id=tenant.id, account_id=account.id).first()
ta = db.session.scalar(
select(TenantAccountJoin)
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == account.id)
.limit(1)
)
if not ta:
TenantService.create_tenant_member(tenant, account, role)
@@ -1545,21 +1557,18 @@ class RegisterService:
if not invitation_data:
return None
tenant = (
db.session.query(Tenant)
.where(Tenant.id == invitation_data["workspace_id"], Tenant.status == "normal")
.first()
tenant = db.session.scalar(
select(Tenant).where(Tenant.id == invitation_data["workspace_id"], Tenant.status == "normal").limit(1)
)
if not tenant:
return None
tenant_account = (
db.session.query(Account, TenantAccountJoin.role)
tenant_account = db.session.execute(
select(Account, TenantAccountJoin.role)
.join(TenantAccountJoin, Account.id == TenantAccountJoin.account_id)
.where(Account.email == invitation_data["email"], TenantAccountJoin.tenant_id == tenant.id)
.first()
)
).first()
if not tenant_account:
return None

View File

@@ -4,6 +4,8 @@ import uuid
import pandas as pd
logger = logging.getLogger(__name__)
from typing import TypedDict
from sqlalchemy import or_, select
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import NotFound
@@ -23,6 +25,27 @@ from tasks.annotation.enable_annotation_reply_task import enable_annotation_repl
from tasks.annotation.update_annotation_to_index_task import update_annotation_to_index_task
class AnnotationJobStatusDict(TypedDict):
job_id: str
job_status: str
class EmbeddingModelDict(TypedDict):
embedding_provider_name: str
embedding_model_name: str
class AnnotationSettingDict(TypedDict):
id: str
enabled: bool
score_threshold: float
embedding_model: EmbeddingModelDict | dict
class AnnotationSettingDisabledDict(TypedDict):
enabled: bool
class AppAnnotationService:
@classmethod
def up_insert_app_annotation_from_message(cls, args: dict, app_id: str) -> MessageAnnotation:
@@ -85,7 +108,7 @@ class AppAnnotationService:
return annotation
@classmethod
def enable_app_annotation(cls, args: dict, app_id: str):
def enable_app_annotation(cls, args: dict, app_id: str) -> AnnotationJobStatusDict:
enable_app_annotation_key = f"enable_app_annotation_{str(app_id)}"
cache_result = redis_client.get(enable_app_annotation_key)
if cache_result is not None:
@@ -109,7 +132,7 @@ class AppAnnotationService:
return {"job_id": job_id, "job_status": "waiting"}
@classmethod
def disable_app_annotation(cls, app_id: str):
def disable_app_annotation(cls, app_id: str) -> AnnotationJobStatusDict:
_, current_tenant_id = current_account_with_tenant()
disable_app_annotation_key = f"disable_app_annotation_{str(app_id)}"
cache_result = redis_client.get(disable_app_annotation_key)
@@ -567,7 +590,7 @@ class AppAnnotationService:
db.session.commit()
@classmethod
def get_app_annotation_setting_by_app_id(cls, app_id: str):
def get_app_annotation_setting_by_app_id(cls, app_id: str) -> AnnotationSettingDict | AnnotationSettingDisabledDict:
_, current_tenant_id = current_account_with_tenant()
# get app info
app = (
@@ -602,7 +625,9 @@ class AppAnnotationService:
return {"enabled": False}
@classmethod
def update_app_annotation_setting(cls, app_id: str, annotation_setting_id: str, args: dict):
def update_app_annotation_setting(
cls, app_id: str, annotation_setting_id: str, args: dict
) -> AnnotationSettingDict:
current_user, current_tenant_id = current_account_with_tenant()
# get app info
app = (

View File

@@ -32,6 +32,11 @@ class SubscriptionPlan(TypedDict):
expiration_date: int
class KnowledgeRateLimitDict(TypedDict):
limit: int
subscription_plan: str
class BillingService:
base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL")
secret_key = os.environ.get("BILLING_API_SECRET_KEY", "BILLING_API_SECRET_KEY")
@@ -58,7 +63,7 @@ class BillingService:
return usage_info
@classmethod
def get_knowledge_rate_limit(cls, tenant_id: str):
def get_knowledge_rate_limit(cls, tenant_id: str) -> KnowledgeRateLimitDict:
params = {"tenant_id": tenant_id}
knowledge_rate_limit = cls._send_request("GET", "/subscription/knowledge-rate-limit", params=params)

View File

@@ -5,7 +5,7 @@ from urllib.parse import urlparse
import httpx
from graphon.nodes.http_request.exc import InvalidHttpMethodError
from sqlalchemy import select
from sqlalchemy import func, select
from constants import HIDDEN_VALUE
from core.helper import ssrf_proxy
@@ -103,8 +103,10 @@ class ExternalDatasetService:
@staticmethod
def get_external_knowledge_api(external_knowledge_api_id: str, tenant_id: str) -> ExternalKnowledgeApis:
external_knowledge_api: ExternalKnowledgeApis | None = (
db.session.query(ExternalKnowledgeApis).filter_by(id=external_knowledge_api_id, tenant_id=tenant_id).first()
external_knowledge_api: ExternalKnowledgeApis | None = db.session.scalar(
select(ExternalKnowledgeApis)
.where(ExternalKnowledgeApis.id == external_knowledge_api_id, ExternalKnowledgeApis.tenant_id == tenant_id)
.limit(1)
)
if external_knowledge_api is None:
raise ValueError("api template not found")
@@ -112,8 +114,10 @@ class ExternalDatasetService:
@staticmethod
def update_external_knowledge_api(tenant_id, user_id, external_knowledge_api_id, args) -> ExternalKnowledgeApis:
external_knowledge_api: ExternalKnowledgeApis | None = (
db.session.query(ExternalKnowledgeApis).filter_by(id=external_knowledge_api_id, tenant_id=tenant_id).first()
external_knowledge_api: ExternalKnowledgeApis | None = db.session.scalar(
select(ExternalKnowledgeApis)
.where(ExternalKnowledgeApis.id == external_knowledge_api_id, ExternalKnowledgeApis.tenant_id == tenant_id)
.limit(1)
)
if external_knowledge_api is None:
raise ValueError("api template not found")
@@ -132,8 +136,10 @@ class ExternalDatasetService:
@staticmethod
def delete_external_knowledge_api(tenant_id: str, external_knowledge_api_id: str):
external_knowledge_api = (
db.session.query(ExternalKnowledgeApis).filter_by(id=external_knowledge_api_id, tenant_id=tenant_id).first()
external_knowledge_api = db.session.scalar(
select(ExternalKnowledgeApis)
.where(ExternalKnowledgeApis.id == external_knowledge_api_id, ExternalKnowledgeApis.tenant_id == tenant_id)
.limit(1)
)
if external_knowledge_api is None:
raise ValueError("api template not found")
@@ -144,9 +150,12 @@ class ExternalDatasetService:
@staticmethod
def external_knowledge_api_use_check(external_knowledge_api_id: str) -> tuple[bool, int]:
count = (
db.session.query(ExternalKnowledgeBindings)
.filter_by(external_knowledge_api_id=external_knowledge_api_id)
.count()
db.session.scalar(
select(func.count(ExternalKnowledgeBindings.id)).where(
ExternalKnowledgeBindings.external_knowledge_api_id == external_knowledge_api_id
)
)
or 0
)
if count > 0:
return True, count
@@ -154,8 +163,10 @@ class ExternalDatasetService:
@staticmethod
def get_external_knowledge_binding_with_dataset_id(tenant_id: str, dataset_id: str) -> ExternalKnowledgeBindings:
external_knowledge_binding: ExternalKnowledgeBindings | None = (
db.session.query(ExternalKnowledgeBindings).filter_by(dataset_id=dataset_id, tenant_id=tenant_id).first()
external_knowledge_binding: ExternalKnowledgeBindings | None = db.session.scalar(
select(ExternalKnowledgeBindings)
.where(ExternalKnowledgeBindings.dataset_id == dataset_id, ExternalKnowledgeBindings.tenant_id == tenant_id)
.limit(1)
)
if not external_knowledge_binding:
raise ValueError("external knowledge binding not found")
@@ -163,8 +174,10 @@ class ExternalDatasetService:
@staticmethod
def document_create_args_validate(tenant_id: str, external_knowledge_api_id: str, process_parameter: dict):
external_knowledge_api = (
db.session.query(ExternalKnowledgeApis).filter_by(id=external_knowledge_api_id, tenant_id=tenant_id).first()
external_knowledge_api = db.session.scalar(
select(ExternalKnowledgeApis)
.where(ExternalKnowledgeApis.id == external_knowledge_api_id, ExternalKnowledgeApis.tenant_id == tenant_id)
.limit(1)
)
if external_knowledge_api is None or external_knowledge_api.settings is None:
raise ValueError("api template not found")
@@ -238,12 +251,17 @@ class ExternalDatasetService:
@staticmethod
def create_external_dataset(tenant_id: str, user_id: str, args: dict) -> Dataset:
# check if dataset name already exists
if db.session.query(Dataset).filter_by(name=args.get("name"), tenant_id=tenant_id).first():
if db.session.scalar(
select(Dataset).where(Dataset.name == args.get("name"), Dataset.tenant_id == tenant_id).limit(1)
):
raise DatasetNameDuplicateError(f"Dataset with name {args.get('name')} already exists.")
external_knowledge_api = (
db.session.query(ExternalKnowledgeApis)
.filter_by(id=args.get("external_knowledge_api_id"), tenant_id=tenant_id)
.first()
external_knowledge_api = db.session.scalar(
select(ExternalKnowledgeApis)
.where(
ExternalKnowledgeApis.id == args.get("external_knowledge_api_id"),
ExternalKnowledgeApis.tenant_id == tenant_id,
)
.limit(1)
)
if external_knowledge_api is None:
@@ -286,16 +304,18 @@ class ExternalDatasetService:
external_retrieval_parameters: dict,
metadata_condition: MetadataCondition | None = None,
):
external_knowledge_binding = (
db.session.query(ExternalKnowledgeBindings).filter_by(dataset_id=dataset_id, tenant_id=tenant_id).first()
external_knowledge_binding = db.session.scalar(
select(ExternalKnowledgeBindings)
.where(ExternalKnowledgeBindings.dataset_id == dataset_id, ExternalKnowledgeBindings.tenant_id == tenant_id)
.limit(1)
)
if not external_knowledge_binding:
raise ValueError("external knowledge binding not found")
external_knowledge_api = (
db.session.query(ExternalKnowledgeApis)
.filter_by(id=external_knowledge_binding.external_knowledge_api_id)
.first()
external_knowledge_api = db.session.scalar(
select(ExternalKnowledgeApis)
.where(ExternalKnowledgeApis.id == external_knowledge_binding.external_knowledge_api_id)
.limit(1)
)
if external_knowledge_api is None or external_knowledge_api.settings is None:
raise ValueError("external api template not found")

View File

@@ -1,7 +1,7 @@
import json
import logging
import time
from typing import Any
from typing import Any, TypedDict
from graphon.model_runtime.entities import LLMMode
@@ -18,6 +18,16 @@ from models.enums import CreatorUserRole, DatasetQuerySource
logger = logging.getLogger(__name__)
class QueryDict(TypedDict):
content: str
class RetrieveResponseDict(TypedDict):
query: QueryDict
records: list[dict[str, Any]]
default_retrieval_model = {
"search_method": RetrievalMethod.SEMANTIC_SEARCH,
"reranking_enable": False,
@@ -150,7 +160,7 @@ class HitTestingService:
return dict(cls.compact_external_retrieve_response(dataset, query, all_documents))
@classmethod
def compact_retrieve_response(cls, query: str, documents: list[Document]) -> dict[Any, Any]:
def compact_retrieve_response(cls, query: str, documents: list[Document]) -> RetrieveResponseDict:
records = RetrievalService.format_retrieval_documents(documents)
return {
@@ -161,7 +171,7 @@ class HitTestingService:
}
@classmethod
def compact_external_retrieve_response(cls, dataset: Dataset, query: str, documents: list) -> dict[Any, Any]:
def compact_external_retrieve_response(cls, dataset: Dataset, query: str, documents: list) -> RetrieveResponseDict:
records = []
if dataset.provider == "external":
for document in documents:

View File

@@ -1,6 +1,8 @@
import copy
import logging
from sqlalchemy import delete, func, select
from core.rag.index_processor.constant.built_in_field import BuiltInField, MetadataDataSource
from extensions.ext_database import db
from extensions.ext_redis import redis_client
@@ -25,10 +27,14 @@ class MetadataService:
raise ValueError("Metadata name cannot exceed 255 characters.")
current_user, current_tenant_id = current_account_with_tenant()
# check if metadata name already exists
if (
db.session.query(DatasetMetadata)
.filter_by(tenant_id=current_tenant_id, dataset_id=dataset_id, name=metadata_args.name)
.first()
if db.session.scalar(
select(DatasetMetadata)
.where(
DatasetMetadata.tenant_id == current_tenant_id,
DatasetMetadata.dataset_id == dataset_id,
DatasetMetadata.name == metadata_args.name,
)
.limit(1)
):
raise ValueError("Metadata name already exists.")
for field in BuiltInField:
@@ -54,10 +60,14 @@ class MetadataService:
lock_key = f"dataset_metadata_lock_{dataset_id}"
# check if metadata name already exists
current_user, current_tenant_id = current_account_with_tenant()
if (
db.session.query(DatasetMetadata)
.filter_by(tenant_id=current_tenant_id, dataset_id=dataset_id, name=name)
.first()
if db.session.scalar(
select(DatasetMetadata)
.where(
DatasetMetadata.tenant_id == current_tenant_id,
DatasetMetadata.dataset_id == dataset_id,
DatasetMetadata.name == name,
)
.limit(1)
):
raise ValueError("Metadata name already exists.")
for field in BuiltInField:
@@ -65,7 +75,11 @@ class MetadataService:
raise ValueError("Metadata name already exists in Built-in fields.")
try:
MetadataService.knowledge_base_metadata_lock_check(dataset_id, None)
metadata = db.session.query(DatasetMetadata).filter_by(id=metadata_id, dataset_id=dataset_id).first()
metadata = db.session.scalar(
select(DatasetMetadata)
.where(DatasetMetadata.id == metadata_id, DatasetMetadata.dataset_id == dataset_id)
.limit(1)
)
if metadata is None:
raise ValueError("Metadata not found.")
old_name = metadata.name
@@ -74,9 +88,9 @@ class MetadataService:
metadata.updated_at = naive_utc_now()
# update related documents
dataset_metadata_bindings = (
db.session.query(DatasetMetadataBinding).filter_by(metadata_id=metadata_id).all()
)
dataset_metadata_bindings = db.session.scalars(
select(DatasetMetadataBinding).where(DatasetMetadataBinding.metadata_id == metadata_id)
).all()
if dataset_metadata_bindings:
document_ids = [binding.document_id for binding in dataset_metadata_bindings]
documents = DocumentService.get_document_by_ids(document_ids)
@@ -101,15 +115,19 @@ class MetadataService:
lock_key = f"dataset_metadata_lock_{dataset_id}"
try:
MetadataService.knowledge_base_metadata_lock_check(dataset_id, None)
metadata = db.session.query(DatasetMetadata).filter_by(id=metadata_id, dataset_id=dataset_id).first()
metadata = db.session.scalar(
select(DatasetMetadata)
.where(DatasetMetadata.id == metadata_id, DatasetMetadata.dataset_id == dataset_id)
.limit(1)
)
if metadata is None:
raise ValueError("Metadata not found.")
db.session.delete(metadata)
# deal related documents
dataset_metadata_bindings = (
db.session.query(DatasetMetadataBinding).filter_by(metadata_id=metadata_id).all()
)
dataset_metadata_bindings = db.session.scalars(
select(DatasetMetadataBinding).where(DatasetMetadataBinding.metadata_id == metadata_id)
).all()
if dataset_metadata_bindings:
document_ids = [binding.document_id for binding in dataset_metadata_bindings]
documents = DocumentService.get_document_by_ids(document_ids)
@@ -224,16 +242,23 @@ class MetadataService:
# deal metadata binding (in the same transaction as the doc_metadata update)
if not operation.partial_update:
db.session.query(DatasetMetadataBinding).filter_by(document_id=operation.document_id).delete()
db.session.execute(
delete(DatasetMetadataBinding).where(
DatasetMetadataBinding.document_id == operation.document_id
)
)
current_user, current_tenant_id = current_account_with_tenant()
for metadata_value in operation.metadata_list:
# check if binding already exists
if operation.partial_update:
existing_binding = (
db.session.query(DatasetMetadataBinding)
.filter_by(document_id=operation.document_id, metadata_id=metadata_value.id)
.first()
existing_binding = db.session.scalar(
select(DatasetMetadataBinding)
.where(
DatasetMetadataBinding.document_id == operation.document_id,
DatasetMetadataBinding.metadata_id == metadata_value.id,
)
.limit(1)
)
if existing_binding:
continue
@@ -275,9 +300,13 @@ class MetadataService:
"id": item.get("id"),
"name": item.get("name"),
"type": item.get("type"),
"count": db.session.query(DatasetMetadataBinding)
.filter_by(metadata_id=item.get("id"), dataset_id=dataset.id)
.count(),
"count": db.session.scalar(
select(func.count(DatasetMetadataBinding.id)).where(
DatasetMetadataBinding.metadata_id == item.get("id"),
DatasetMetadataBinding.dataset_id == dataset.id,
)
)
or 0,
}
for item in dataset.doc_metadata or []
if item.get("id") != "built-in"

View File

@@ -156,27 +156,27 @@ class RagPipelineService:
:param template_id: template id
:param template_info: template info
"""
customized_template: PipelineCustomizedTemplate | None = (
db.session.query(PipelineCustomizedTemplate)
customized_template: PipelineCustomizedTemplate | None = db.session.scalar(
select(PipelineCustomizedTemplate)
.where(
PipelineCustomizedTemplate.id == template_id,
PipelineCustomizedTemplate.tenant_id == current_user.current_tenant_id,
)
.first()
.limit(1)
)
if not customized_template:
raise ValueError("Customized pipeline template not found.")
# check template name is exist
template_name = template_info.name
if template_name:
template = (
db.session.query(PipelineCustomizedTemplate)
template = db.session.scalar(
select(PipelineCustomizedTemplate)
.where(
PipelineCustomizedTemplate.name == template_name,
PipelineCustomizedTemplate.tenant_id == current_user.current_tenant_id,
PipelineCustomizedTemplate.id != template_id,
)
.first()
.limit(1)
)
if template:
raise ValueError("Template name is already exists")
@@ -192,13 +192,13 @@ class RagPipelineService:
"""
Delete customized pipeline template.
"""
customized_template: PipelineCustomizedTemplate | None = (
db.session.query(PipelineCustomizedTemplate)
customized_template: PipelineCustomizedTemplate | None = db.session.scalar(
select(PipelineCustomizedTemplate)
.where(
PipelineCustomizedTemplate.id == template_id,
PipelineCustomizedTemplate.tenant_id == current_user.current_tenant_id,
)
.first()
.limit(1)
)
if not customized_template:
raise ValueError("Customized pipeline template not found.")
@@ -210,14 +210,14 @@ class RagPipelineService:
Get draft workflow
"""
# fetch draft workflow by rag pipeline
workflow = (
db.session.query(Workflow)
workflow = db.session.scalar(
select(Workflow)
.where(
Workflow.tenant_id == pipeline.tenant_id,
Workflow.app_id == pipeline.id,
Workflow.version == "draft",
)
.first()
.limit(1)
)
# return draft workflow
@@ -232,28 +232,28 @@ class RagPipelineService:
return None
# fetch published workflow by workflow_id
workflow = (
db.session.query(Workflow)
workflow = db.session.scalar(
select(Workflow)
.where(
Workflow.tenant_id == pipeline.tenant_id,
Workflow.app_id == pipeline.id,
Workflow.id == pipeline.workflow_id,
)
.first()
.limit(1)
)
return workflow
def get_published_workflow_by_id(self, pipeline: Pipeline, workflow_id: str) -> Workflow | None:
"""Fetch a published workflow snapshot by ID for restore operations."""
workflow = (
db.session.query(Workflow)
workflow = db.session.scalar(
select(Workflow)
.where(
Workflow.tenant_id == pipeline.tenant_id,
Workflow.app_id == pipeline.id,
Workflow.id == workflow_id,
)
.first()
.limit(1)
)
if workflow and workflow.version == Workflow.VERSION_DRAFT:
raise IsDraftWorkflowError("source workflow must be published")
@@ -974,7 +974,7 @@ class RagPipelineService:
if invoke_from.value == InvokeFrom.PUBLISHED_PIPELINE:
document_id = get_system_segment(variable_pool, SystemVariableKey.DOCUMENT_ID)
if document_id:
document = db.session.query(Document).where(Document.id == document_id.value).first()
document = db.session.get(Document, document_id.value)
if document:
document.indexing_status = IndexingStatus.ERROR
document.error = error
@@ -1178,12 +1178,12 @@ class RagPipelineService:
"""
Publish customized pipeline template
"""
pipeline = db.session.query(Pipeline).where(Pipeline.id == pipeline_id).first()
pipeline = db.session.get(Pipeline, pipeline_id)
if not pipeline:
raise ValueError("Pipeline not found")
if not pipeline.workflow_id:
raise ValueError("Pipeline workflow not found")
workflow = db.session.query(Workflow).where(Workflow.id == pipeline.workflow_id).first()
workflow = db.session.get(Workflow, pipeline.workflow_id)
if not workflow:
raise ValueError("Workflow not found")
with Session(db.engine) as session:
@@ -1194,21 +1194,21 @@ class RagPipelineService:
# check template name is exist
template_name = args.get("name")
if template_name:
template = (
db.session.query(PipelineCustomizedTemplate)
template = db.session.scalar(
select(PipelineCustomizedTemplate)
.where(
PipelineCustomizedTemplate.name == template_name,
PipelineCustomizedTemplate.tenant_id == pipeline.tenant_id,
)
.first()
.limit(1)
)
if template:
raise ValueError("Template name is already exists")
max_position = (
db.session.query(func.max(PipelineCustomizedTemplate.position))
.where(PipelineCustomizedTemplate.tenant_id == pipeline.tenant_id)
.scalar()
max_position = db.session.scalar(
select(func.max(PipelineCustomizedTemplate.position)).where(
PipelineCustomizedTemplate.tenant_id == pipeline.tenant_id
)
)
from services.rag_pipeline.rag_pipeline_dsl_service import RagPipelineDslService
@@ -1239,13 +1239,14 @@ class RagPipelineService:
def is_workflow_exist(self, pipeline: Pipeline) -> bool:
return (
db.session.query(Workflow)
.where(
Workflow.tenant_id == pipeline.tenant_id,
Workflow.app_id == pipeline.id,
Workflow.version == Workflow.VERSION_DRAFT,
db.session.scalar(
select(func.count(Workflow.id)).where(
Workflow.tenant_id == pipeline.tenant_id,
Workflow.app_id == pipeline.id,
Workflow.version == Workflow.VERSION_DRAFT,
)
)
.count()
or 0
) > 0
def get_node_last_run(
@@ -1353,11 +1354,11 @@ class RagPipelineService:
def get_recommended_plugins(self, type: str) -> dict:
# Query active recommended plugins
query = db.session.query(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True)
stmt = select(PipelineRecommendedPlugin).where(PipelineRecommendedPlugin.active == True)
if type and type != "all":
query = query.where(PipelineRecommendedPlugin.type == type)
stmt = stmt.where(PipelineRecommendedPlugin.type == type)
pipeline_recommended_plugins = query.order_by(PipelineRecommendedPlugin.position.asc()).all()
pipeline_recommended_plugins = db.session.scalars(stmt.order_by(PipelineRecommendedPlugin.position.asc())).all()
if not pipeline_recommended_plugins:
return {
@@ -1396,14 +1397,12 @@ class RagPipelineService:
"""
Retry error document
"""
document_pipeline_execution_log = (
db.session.query(DocumentPipelineExecutionLog)
.where(DocumentPipelineExecutionLog.document_id == document.id)
.first()
document_pipeline_execution_log = db.session.scalar(
select(DocumentPipelineExecutionLog).where(DocumentPipelineExecutionLog.document_id == document.id).limit(1)
)
if not document_pipeline_execution_log:
raise ValueError("Document pipeline execution log not found")
pipeline = db.session.query(Pipeline).where(Pipeline.id == document_pipeline_execution_log.pipeline_id).first()
pipeline = db.session.get(Pipeline, document_pipeline_execution_log.pipeline_id)
if not pipeline:
raise ValueError("Pipeline not found")
# convert to app config
@@ -1432,23 +1431,23 @@ class RagPipelineService:
"""
Get datasource plugins
"""
dataset: Dataset | None = (
db.session.query(Dataset)
dataset: Dataset | None = db.session.scalar(
select(Dataset)
.where(
Dataset.id == dataset_id,
Dataset.tenant_id == tenant_id,
)
.first()
.limit(1)
)
if not dataset:
raise ValueError("Dataset not found")
pipeline: Pipeline | None = (
db.session.query(Pipeline)
pipeline: Pipeline | None = db.session.scalar(
select(Pipeline)
.where(
Pipeline.id == dataset.pipeline_id,
Pipeline.tenant_id == tenant_id,
)
.first()
.limit(1)
)
if not pipeline:
raise ValueError("Pipeline not found")
@@ -1530,23 +1529,23 @@ class RagPipelineService:
"""
Get pipeline
"""
dataset: Dataset | None = (
db.session.query(Dataset)
dataset: Dataset | None = db.session.scalar(
select(Dataset)
.where(
Dataset.id == dataset_id,
Dataset.tenant_id == tenant_id,
)
.first()
.limit(1)
)
if not dataset:
raise ValueError("Dataset not found")
pipeline: Pipeline | None = (
db.session.query(Pipeline)
pipeline: Pipeline | None = db.session.scalar(
select(Pipeline)
.where(
Pipeline.id == dataset.pipeline_id,
Pipeline.tenant_id == tenant_id,
)
.first()
.limit(1)
)
if not pipeline:
raise ValueError("Pipeline not found")

View File

@@ -3,7 +3,7 @@ import logging
from datetime import datetime
from graphon.model_runtime.utils.encoders import jsonable_encoder
from sqlalchemy import or_, select
from sqlalchemy import delete, or_, select
from sqlalchemy.orm import Session
from core.tools.__base.tool_provider import ToolProviderController
@@ -42,20 +42,22 @@ class WorkflowToolManageService:
labels: list[str] | None = None,
):
# check if the name is unique
existing_workflow_tool_provider = (
db.session.query(WorkflowToolProvider)
existing_workflow_tool_provider = db.session.scalar(
select(WorkflowToolProvider)
.where(
WorkflowToolProvider.tenant_id == tenant_id,
# name or app_id
or_(WorkflowToolProvider.name == name, WorkflowToolProvider.app_id == workflow_app_id),
)
.first()
.limit(1)
)
if existing_workflow_tool_provider is not None:
raise ValueError(f"Tool with name {name} or app_id {workflow_app_id} already exists")
app: App | None = db.session.query(App).where(App.id == workflow_app_id, App.tenant_id == tenant_id).first()
app: App | None = db.session.scalar(
select(App).where(App.id == workflow_app_id, App.tenant_id == tenant_id).limit(1)
)
if app is None:
raise ValueError(f"App {workflow_app_id} not found")
@@ -122,30 +124,30 @@ class WorkflowToolManageService:
:return: the updated tool
"""
# check if the name is unique
existing_workflow_tool_provider = (
db.session.query(WorkflowToolProvider)
existing_workflow_tool_provider = db.session.scalar(
select(WorkflowToolProvider)
.where(
WorkflowToolProvider.tenant_id == tenant_id,
WorkflowToolProvider.name == name,
WorkflowToolProvider.id != workflow_tool_id,
)
.first()
.limit(1)
)
if existing_workflow_tool_provider is not None:
raise ValueError(f"Tool with name {name} already exists")
workflow_tool_provider: WorkflowToolProvider | None = (
db.session.query(WorkflowToolProvider)
workflow_tool_provider: WorkflowToolProvider | None = db.session.scalar(
select(WorkflowToolProvider)
.where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id)
.first()
.limit(1)
)
if workflow_tool_provider is None:
raise ValueError(f"Tool {workflow_tool_id} not found")
app: App | None = (
db.session.query(App).where(App.id == workflow_tool_provider.app_id, App.tenant_id == tenant_id).first()
app: App | None = db.session.scalar(
select(App).where(App.id == workflow_tool_provider.app_id, App.tenant_id == tenant_id).limit(1)
)
if app is None:
@@ -234,9 +236,11 @@ class WorkflowToolManageService:
:param tenant_id: the tenant id
:param workflow_tool_id: the workflow tool id
"""
db.session.query(WorkflowToolProvider).where(
WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id
).delete()
db.session.execute(
delete(WorkflowToolProvider).where(
WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id
)
)
db.session.commit()
@@ -251,10 +255,10 @@ class WorkflowToolManageService:
:param workflow_tool_id: the workflow tool id
:return: the tool
"""
db_tool: WorkflowToolProvider | None = (
db.session.query(WorkflowToolProvider)
db_tool: WorkflowToolProvider | None = db.session.scalar(
select(WorkflowToolProvider)
.where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id)
.first()
.limit(1)
)
return cls._get_workflow_tool(tenant_id, db_tool)
@@ -267,10 +271,10 @@ class WorkflowToolManageService:
:param workflow_app_id: the workflow app id
:return: the tool
"""
db_tool: WorkflowToolProvider | None = (
db.session.query(WorkflowToolProvider)
db_tool: WorkflowToolProvider | None = db.session.scalar(
select(WorkflowToolProvider)
.where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.app_id == workflow_app_id)
.first()
.limit(1)
)
return cls._get_workflow_tool(tenant_id, db_tool)
@@ -284,8 +288,8 @@ class WorkflowToolManageService:
if db_tool is None:
raise ValueError("Tool not found")
workflow_app: App | None = (
db.session.query(App).where(App.id == db_tool.app_id, App.tenant_id == db_tool.tenant_id).first()
workflow_app: App | None = db.session.scalar(
select(App).where(App.id == db_tool.app_id, App.tenant_id == db_tool.tenant_id).limit(1)
)
if workflow_app is None:
@@ -331,10 +335,10 @@ class WorkflowToolManageService:
:param workflow_tool_id: the workflow tool id
:return: the list of tools
"""
db_tool: WorkflowToolProvider | None = (
db.session.query(WorkflowToolProvider)
db_tool: WorkflowToolProvider | None = db.session.scalar(
select(WorkflowToolProvider)
.where(WorkflowToolProvider.tenant_id == tenant_id, WorkflowToolProvider.id == workflow_tool_id)
.first()
.limit(1)
)
if db_tool is None:

View File

@@ -3,7 +3,7 @@ import logging
import mimetypes
import secrets
from collections.abc import Callable, Mapping, Sequence
from typing import Any
from typing import Any, TypedDict
import orjson
from flask import request
@@ -50,6 +50,14 @@ logger = logging.getLogger(__name__)
_file_access_controller = DatabaseFileAccessController()
class RawWebhookDataDict(TypedDict):
method: str
headers: dict[str, str]
query_params: dict[str, str]
body: dict[str, Any]
files: dict[str, Any]
class WebhookService:
"""Service for handling webhook operations."""
@@ -145,7 +153,7 @@ class WebhookService:
@classmethod
def extract_and_validate_webhook_data(
cls, webhook_trigger: WorkflowWebhookTrigger, node_config: NodeConfigDict
) -> dict[str, Any]:
) -> RawWebhookDataDict:
"""Extract and validate webhook data in a single unified process.
Args:
@@ -173,7 +181,7 @@ class WebhookService:
return processed_data
@classmethod
def extract_webhook_data(cls, webhook_trigger: WorkflowWebhookTrigger) -> dict[str, Any]:
def extract_webhook_data(cls, webhook_trigger: WorkflowWebhookTrigger) -> RawWebhookDataDict:
"""Extract raw data from incoming webhook request without type conversion.
Args:
@@ -189,7 +197,7 @@ class WebhookService:
"""
cls._validate_content_length()
data = {
data: RawWebhookDataDict = {
"method": request.method,
"headers": dict(request.headers),
"query_params": dict(request.args),
@@ -223,7 +231,7 @@ class WebhookService:
return data
@classmethod
def _process_and_validate_data(cls, raw_data: dict[str, Any], node_data: WebhookData) -> dict[str, Any]:
def _process_and_validate_data(cls, raw_data: RawWebhookDataDict, node_data: WebhookData) -> RawWebhookDataDict:
"""Process and validate webhook data according to node configuration.
Args:
@@ -664,7 +672,7 @@ class WebhookService:
raise ValueError(f"Required header missing: {header_name}")
@classmethod
def _validate_http_metadata(cls, webhook_data: dict[str, Any], node_data: WebhookData) -> dict[str, Any]:
def _validate_http_metadata(cls, webhook_data: RawWebhookDataDict, node_data: WebhookData) -> dict[str, Any]:
"""Validate HTTP method and content-type.
Args:
@@ -729,7 +737,7 @@ class WebhookService:
return False
@classmethod
def build_workflow_inputs(cls, webhook_data: dict[str, Any]) -> dict[str, Any]:
def build_workflow_inputs(cls, webhook_data: RawWebhookDataDict) -> dict[str, Any]:
"""Construct workflow inputs payload from webhook data.
Args:
@@ -747,7 +755,7 @@ class WebhookService:
@classmethod
def trigger_workflow_execution(
cls, webhook_trigger: WorkflowWebhookTrigger, webhook_data: dict[str, Any], workflow: Workflow
cls, webhook_trigger: WorkflowWebhookTrigger, webhook_data: RawWebhookDataDict, workflow: Workflow
) -> None:
"""Trigger workflow execution via AsyncWorkflowService.

View File

@@ -401,10 +401,7 @@ class TestMetadataServiceCreateMetadata:
metadata_args = MetadataTestDataFactory.create_metadata_args_mock(name="category", metadata_type="string")
# Mock query to return None (no existing metadata with same name)
mock_query = Mock()
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db_session.query.return_value = mock_query
mock_db_session.scalar.return_value = None
# Mock BuiltInField enum iteration
with patch("services.metadata_service.BuiltInField") as mock_builtin:
@@ -417,10 +414,6 @@ class TestMetadataServiceCreateMetadata:
assert result is not None
assert isinstance(result, DatasetMetadata)
# Verify query was made to check for duplicates
mock_db_session.query.assert_called()
mock_query.filter_by.assert_called()
# Verify metadata was added and committed
mock_db_session.add.assert_called_once()
mock_db_session.commit.assert_called_once()
@@ -468,10 +461,7 @@ class TestMetadataServiceCreateMetadata:
# Mock existing metadata with same name
existing_metadata = MetadataTestDataFactory.create_metadata_mock(name="category")
mock_query = Mock()
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = existing_metadata
mock_db_session.query.return_value = mock_query
mock_db_session.scalar.return_value = existing_metadata
# Act & Assert
with pytest.raises(ValueError, match="Metadata name already exists"):
@@ -500,10 +490,7 @@ class TestMetadataServiceCreateMetadata:
)
# Mock query to return None (no duplicate in database)
mock_query = Mock()
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db_session.query.return_value = mock_query
mock_db_session.scalar.return_value = None
# Mock BuiltInField to include the conflicting name
with patch("services.metadata_service.BuiltInField") as mock_builtin:
@@ -597,27 +584,11 @@ class TestMetadataServiceUpdateMetadataName:
existing_metadata = MetadataTestDataFactory.create_metadata_mock(metadata_id=metadata_id, name="category")
# Mock query for duplicate check (no duplicate)
mock_query = Mock()
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db_session.query.return_value = mock_query
# Mock metadata retrieval
def query_side_effect(model):
if model == DatasetMetadata:
mock_meta_query = Mock()
mock_meta_query.filter_by.return_value = mock_meta_query
mock_meta_query.first.return_value = existing_metadata
return mock_meta_query
return mock_query
mock_db_session.query.side_effect = query_side_effect
# Mock scalar calls: first for duplicate check (None), second for metadata retrieval
mock_db_session.scalar.side_effect = [None, existing_metadata]
# Mock no metadata bindings (no documents to update)
mock_binding_query = Mock()
mock_binding_query.filter_by.return_value = mock_binding_query
mock_binding_query.all.return_value = []
mock_db_session.scalars.return_value.all.return_value = []
# Mock BuiltInField enum
with patch("services.metadata_service.BuiltInField") as mock_builtin:
@@ -655,22 +626,8 @@ class TestMetadataServiceUpdateMetadataName:
metadata_id = "non-existent-metadata"
new_name = "updated_category"
# Mock query for duplicate check (no duplicate)
mock_query = Mock()
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db_session.query.return_value = mock_query
# Mock metadata retrieval to return None
def query_side_effect(model):
if model == DatasetMetadata:
mock_meta_query = Mock()
mock_meta_query.filter_by.return_value = mock_meta_query
mock_meta_query.first.return_value = None # Not found
return mock_meta_query
return mock_query
mock_db_session.query.side_effect = query_side_effect
# Mock scalar calls: first for duplicate check (None), second for metadata retrieval (None = not found)
mock_db_session.scalar.side_effect = [None, None]
# Mock BuiltInField enum
with patch("services.metadata_service.BuiltInField") as mock_builtin:
@@ -746,15 +703,10 @@ class TestMetadataServiceDeleteMetadata:
existing_metadata = MetadataTestDataFactory.create_metadata_mock(metadata_id=metadata_id, name="category")
# Mock metadata retrieval
mock_query = Mock()
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = existing_metadata
mock_db_session.query.return_value = mock_query
mock_db_session.scalar.return_value = existing_metadata
# Mock no metadata bindings (no documents to update)
mock_binding_query = Mock()
mock_binding_query.filter_by.return_value = mock_binding_query
mock_binding_query.all.return_value = []
mock_db_session.scalars.return_value.all.return_value = []
# Act
result = MetadataService.delete_metadata(dataset_id, metadata_id)
@@ -788,10 +740,7 @@ class TestMetadataServiceDeleteMetadata:
metadata_id = "non-existent-metadata"
# Mock metadata retrieval to return None
mock_query = Mock()
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db_session.query.return_value = mock_query
mock_db_session.scalar.return_value = None
# Act & Assert
with pytest.raises(ValueError, match="Metadata not found"):
@@ -1013,10 +962,7 @@ class TestMetadataServiceGetDatasetMetadatas:
)
# Mock usage count queries
mock_query = Mock()
mock_query.filter_by.return_value = mock_query
mock_query.count.return_value = 5 # 5 documents use this metadata
mock_db_session.query.return_value = mock_query
mock_db_session.scalar.return_value = 5 # 5 documents use this metadata
# Act
result = MetadataService.get_dataset_metadatas(dataset)

View File

@@ -292,7 +292,7 @@ class TestExternalDatasetServiceCrudExternalKnowledgeApi:
"""
api = Mock(spec=ExternalKnowledgeApis)
mock_db_session.query.return_value.filter_by.return_value.first.return_value = api
mock_db_session.scalar.return_value = api
result = ExternalDatasetService.get_external_knowledge_api("api-id", "tenant-id")
assert result is api
@@ -302,7 +302,7 @@ class TestExternalDatasetServiceCrudExternalKnowledgeApi:
When the record is absent, a ``ValueError`` is raised.
"""
mock_db_session.query.return_value.filter_by.return_value.first.return_value = None
mock_db_session.scalar.return_value = None
with pytest.raises(ValueError, match="api template not found"):
ExternalDatasetService.get_external_knowledge_api("missing-id", "tenant-id")
@@ -320,7 +320,7 @@ class TestExternalDatasetServiceCrudExternalKnowledgeApi:
existing_api = Mock(spec=ExternalKnowledgeApis)
existing_api.settings_dict = {"api_key": "stored-key"}
existing_api.settings = '{"api_key":"stored-key"}'
mock_db_session.query.return_value.filter_by.return_value.first.return_value = existing_api
mock_db_session.scalar.return_value = existing_api
args = {
"name": "New Name",
@@ -340,7 +340,7 @@ class TestExternalDatasetServiceCrudExternalKnowledgeApi:
Updating a nonexistent API template should raise ``ValueError``.
"""
mock_db_session.query.return_value.filter_by.return_value.first.return_value = None
mock_db_session.scalar.return_value = None
with pytest.raises(ValueError, match="api template not found"):
ExternalDatasetService.update_external_knowledge_api(
@@ -356,7 +356,7 @@ class TestExternalDatasetServiceCrudExternalKnowledgeApi:
"""
api = Mock(spec=ExternalKnowledgeApis)
mock_db_session.query.return_value.filter_by.return_value.first.return_value = api
mock_db_session.scalar.return_value = api
ExternalDatasetService.delete_external_knowledge_api("tenant-1", "api-1")
@@ -368,7 +368,7 @@ class TestExternalDatasetServiceCrudExternalKnowledgeApi:
Deletion of a missing template should raise ``ValueError``.
"""
mock_db_session.query.return_value.filter_by.return_value.first.return_value = None
mock_db_session.scalar.return_value = None
with pytest.raises(ValueError, match="api template not found"):
ExternalDatasetService.delete_external_knowledge_api("tenant-1", "missing")
@@ -394,7 +394,7 @@ class TestExternalDatasetServiceUsageAndBindings:
When there are bindings, ``external_knowledge_api_use_check`` returns True and count.
"""
mock_db_session.query.return_value.filter_by.return_value.count.return_value = 3
mock_db_session.scalar.return_value = 3
in_use, count = ExternalDatasetService.external_knowledge_api_use_check("api-1")
@@ -406,7 +406,7 @@ class TestExternalDatasetServiceUsageAndBindings:
Zero bindings should return ``(False, 0)``.
"""
mock_db_session.query.return_value.filter_by.return_value.count.return_value = 0
mock_db_session.scalar.return_value = 0
in_use, count = ExternalDatasetService.external_knowledge_api_use_check("api-1")
@@ -419,7 +419,7 @@ class TestExternalDatasetServiceUsageAndBindings:
"""
binding = Mock(spec=ExternalKnowledgeBindings)
mock_db_session.query.return_value.filter_by.return_value.first.return_value = binding
mock_db_session.scalar.return_value = binding
result = ExternalDatasetService.get_external_knowledge_binding_with_dataset_id("tenant-1", "ds-1")
assert result is binding
@@ -429,7 +429,7 @@ class TestExternalDatasetServiceUsageAndBindings:
Missing binding should result in a ``ValueError``.
"""
mock_db_session.query.return_value.filter_by.return_value.first.return_value = None
mock_db_session.scalar.return_value = None
with pytest.raises(ValueError, match="external knowledge binding not found"):
ExternalDatasetService.get_external_knowledge_binding_with_dataset_id("tenant-1", "ds-1")
@@ -460,7 +460,7 @@ class TestExternalDatasetServiceDocumentCreateArgsValidate:
'[{"document_process_setting":[{"name":"foo","required":true},{"name":"bar","required":false}]}]'
)
# Raw string; the service itself calls json.loads on it
mock_db_session.query.return_value.filter_by.return_value.first.return_value = external_api
mock_db_session.scalar.return_value = external_api
process_parameter = {"foo": "value", "bar": "optional"}
@@ -474,7 +474,7 @@ class TestExternalDatasetServiceDocumentCreateArgsValidate:
When the referenced API template is missing, a ``ValueError`` is raised.
"""
mock_db_session.query.return_value.filter_by.return_value.first.return_value = None
mock_db_session.scalar.return_value = None
with pytest.raises(ValueError, match="api template not found"):
ExternalDatasetService.document_create_args_validate("tenant-1", "missing", {})
@@ -488,7 +488,7 @@ class TestExternalDatasetServiceDocumentCreateArgsValidate:
external_api.settings = (
'[{"document_process_setting":[{"name":"foo","required":true},{"name":"bar","required":false}]}]'
)
mock_db_session.query.return_value.filter_by.return_value.first.return_value = external_api
mock_db_session.scalar.return_value = external_api
process_parameter = {"bar": "present"} # missing "foo"
@@ -702,7 +702,7 @@ class TestExternalDatasetServiceCreateExternalDataset:
}
# No existing dataset with same name.
mock_db_session.query.return_value.filter_by.return_value.first.side_effect = [
mock_db_session.scalar.side_effect = [
None, # duplicatename check
Mock(spec=ExternalKnowledgeApis), # external knowledge api
]
@@ -724,7 +724,7 @@ class TestExternalDatasetServiceCreateExternalDataset:
"""
existing_dataset = Mock(spec=Dataset)
mock_db_session.query.return_value.filter_by.return_value.first.return_value = existing_dataset
mock_db_session.scalar.return_value = existing_dataset
args = {
"name": "Existing",
@@ -744,7 +744,7 @@ class TestExternalDatasetServiceCreateExternalDataset:
"""
# First call: duplicate name check not found.
mock_db_session.query.return_value.filter_by.return_value.first.side_effect = [
mock_db_session.scalar.side_effect = [
None,
None, # external knowledge api lookup
]
@@ -763,8 +763,10 @@ class TestExternalDatasetServiceCreateExternalDataset:
``external_knowledge_id`` and ``external_knowledge_api_id`` are mandatory.
"""
# duplicate name check
mock_db_session.query.return_value.filter_by.return_value.first.side_effect = [
# duplicate name check — two calls to create_external_dataset, each does 2 scalar calls
mock_db_session.scalar.side_effect = [
None,
Mock(spec=ExternalKnowledgeApis),
None,
Mock(spec=ExternalKnowledgeApis),
]
@@ -826,7 +828,7 @@ class TestExternalDatasetServiceFetchExternalKnowledgeRetrieval:
api.settings = '{"endpoint":"https://example.com","api_key":"secret"}'
# First query: binding; second query: api.
mock_db_session.query.return_value.filter_by.return_value.first.side_effect = [
mock_db_session.scalar.side_effect = [
binding,
api,
]
@@ -861,7 +863,7 @@ class TestExternalDatasetServiceFetchExternalKnowledgeRetrieval:
Missing binding should raise ``ValueError``.
"""
mock_db_session.query.return_value.filter_by.return_value.first.return_value = None
mock_db_session.scalar.return_value = None
with pytest.raises(ValueError, match="external knowledge binding not found"):
ExternalDatasetService.fetch_external_knowledge_retrieval(
@@ -878,7 +880,7 @@ class TestExternalDatasetServiceFetchExternalKnowledgeRetrieval:
"""
binding = ExternalDatasetTestDataFactory.create_external_binding()
mock_db_session.query.return_value.filter_by.return_value.first.side_effect = [
mock_db_session.scalar.side_effect = [
binding,
None,
]
@@ -901,7 +903,7 @@ class TestExternalDatasetServiceFetchExternalKnowledgeRetrieval:
api = Mock(spec=ExternalKnowledgeApis)
api.settings = '{"endpoint":"https://example.com","api_key":"secret"}'
mock_db_session.query.return_value.filter_by.return_value.first.side_effect = [
mock_db_session.scalar.side_effect = [
binding,
api,
]

View File

@@ -117,9 +117,7 @@ def test_get_all_published_workflow_applies_limit_and_has_more(rag_pipeline_serv
def test_get_pipeline_raises_when_dataset_not_found(mocker, rag_pipeline_service) -> None:
first_query = mocker.Mock()
first_query.where.return_value.first.return_value = None
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=first_query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=None)
with pytest.raises(ValueError, match="Dataset not found"):
rag_pipeline_service.get_pipeline("tenant-1", "dataset-1")
@@ -131,12 +129,8 @@ def test_get_pipeline_raises_when_dataset_not_found(mocker, rag_pipeline_service
def test_update_customized_pipeline_template_success(mocker) -> None:
template = SimpleNamespace(name="old", description="old", icon={}, updated_by=None)
# First query finds the template, second query (duplicate check) returns None
query_mock_1 = mocker.Mock()
query_mock_1.where.return_value.first.return_value = template
query_mock_2 = mocker.Mock()
query_mock_2.where.return_value.first.return_value = None
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", side_effect=[query_mock_1, query_mock_2])
# First scalar finds the template, second scalar (duplicate check) returns None
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[template, None])
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit")
mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1"))
@@ -152,9 +146,7 @@ def test_update_customized_pipeline_template_success(mocker) -> None:
def test_update_customized_pipeline_template_not_found(mocker) -> None:
query_mock = mocker.Mock()
query_mock.where.return_value.first.return_value = None
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query_mock)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=None)
mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1"))
info = PipelineTemplateInfoEntity(name="x", description="d", icon_info=IconInfo(icon="i"))
@@ -166,9 +158,7 @@ def test_update_customized_pipeline_template_duplicate_name(mocker) -> None:
template = SimpleNamespace(name="old", description="old", icon={}, updated_by=None)
duplicate = SimpleNamespace(name="dup")
query_mock = mocker.Mock()
query_mock.where.return_value.first.side_effect = [template, duplicate]
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query_mock)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[template, duplicate])
mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1"))
info = PipelineTemplateInfoEntity(name="dup", description="d", icon_info=IconInfo(icon="i"))
@@ -181,9 +171,7 @@ def test_update_customized_pipeline_template_duplicate_name(mocker) -> None:
def test_delete_customized_pipeline_template_success(mocker) -> None:
template = SimpleNamespace(id="tpl-1")
query_mock = mocker.Mock()
query_mock.where.return_value.first.return_value = template
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query_mock)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=template)
delete_mock = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.delete")
commit_mock = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit")
@@ -196,9 +184,7 @@ def test_delete_customized_pipeline_template_success(mocker) -> None:
def test_delete_customized_pipeline_template_not_found(mocker) -> None:
query_mock = mocker.Mock()
query_mock.where.return_value.first.return_value = None
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query_mock)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=None)
mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1"))
with pytest.raises(ValueError, match="Customized pipeline template not found"):
@@ -397,18 +383,14 @@ def test_get_rag_pipeline_workflow_run_delegates(mocker, rag_pipeline_service) -
def test_is_workflow_exist_returns_true_when_draft_exists(mocker, rag_pipeline_service) -> None:
query_mock = mocker.Mock()
query_mock.where.return_value.count.return_value = 1
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query_mock)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=1)
pipeline = SimpleNamespace(tenant_id="t1", id="p1")
assert rag_pipeline_service.is_workflow_exist(pipeline) is True
def test_is_workflow_exist_returns_false_when_no_draft(mocker, rag_pipeline_service) -> None:
query_mock = mocker.Mock()
query_mock.where.return_value.count.return_value = 0
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query_mock)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=0)
pipeline = SimpleNamespace(tenant_id="t1", id="p1")
assert rag_pipeline_service.is_workflow_exist(pipeline) is False
@@ -738,8 +720,7 @@ def test_get_second_step_parameters_success(mocker, rag_pipeline_service) -> Non
def test_publish_customized_pipeline_template_success(mocker, rag_pipeline_service) -> None:
from models.dataset import Dataset, Pipeline, PipelineCustomizedTemplate
from models.workflow import Workflow
from models.dataset import Pipeline
# 1. Setup mocks
pipeline = mocker.Mock(spec=Pipeline)
@@ -754,36 +735,15 @@ def test_publish_customized_pipeline_template_success(mocker, rag_pipeline_servi
# Mock db itself to avoid app context errors
mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db")
# Improved mocking for session.query
def mock_query_side_effect(model):
m = mocker.Mock()
if model == Pipeline:
m.where.return_value.first.return_value = pipeline
elif model == Workflow:
m.where.return_value.first.return_value = workflow
elif model == PipelineCustomizedTemplate:
m.where.return_value.first.return_value = None
elif model == Dataset:
m.where.return_value.first.return_value = mocker.Mock()
else:
# For func.max cases
m.where.return_value.scalar.return_value = 5
m.where.return_value.first.return_value = mocker.Mock()
return m
mock_db.session.query.side_effect = mock_query_side_effect
# Mock get() for Pipeline and Workflow PK lookups
mock_db.session.get.side_effect = [pipeline, workflow]
# Mock scalar() for template name check (None) and max position (5)
mock_db.session.scalar.side_effect = [None, 5]
# Mock retrieve_dataset
dataset = mocker.Mock()
pipeline.retrieve_dataset.return_value = dataset
# Mock max position
mocker.patch("services.rag_pipeline.rag_pipeline.func.max", return_value=1)
mocker.patch(
"services.rag_pipeline.rag_pipeline.db.session.query.return_value.where.return_value.scalar",
return_value=5,
)
# Mock RagPipelineDslService
mock_dsl_service = mocker.Mock()
mock_dsl_service.export_rag_pipeline_dsl.return_value = {"dsl": "content"}
@@ -839,9 +799,7 @@ def test_get_datasource_plugins_success(mocker, rag_pipeline_service) -> None:
workflow.rag_pipeline_variables = []
# Mock queries
mock_query = mocker.Mock()
mock_query.where.return_value.first.side_effect = [dataset, pipeline]
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=mock_query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline])
mocker.patch.object(rag_pipeline_service, "get_published_workflow", return_value=workflow)
@@ -881,11 +839,9 @@ def test_retry_error_document_success(mocker, rag_pipeline_service) -> None:
workflow = mocker.Mock()
# Mock queries
mock_query = mocker.Mock()
# Log lookup, then Pipeline lookup
mock_query.where.return_value.first.side_effect = [log, pipeline]
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=mock_query)
# Mock queries: Log lookup via scalar, Pipeline lookup via get
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=log)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=pipeline)
mocker.patch.object(rag_pipeline_service, "get_published_workflow", return_value=workflow)
@@ -913,7 +869,7 @@ def test_set_datasource_variables_success(mocker, rag_pipeline_service) -> None:
# Mock db aggressively
mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db")
mock_db.engine = mocker.Mock()
mock_db.session.query.return_value.where.return_value.first.return_value = mocker.Mock()
mock_db.session.scalar.return_value = mocker.Mock()
pipeline = mocker.Mock(spec=Pipeline)
pipeline.id = "p-1"
@@ -976,7 +932,7 @@ def test_get_draft_workflow_success(mocker, rag_pipeline_service) -> None:
workflow = mocker.Mock(spec=Workflow)
mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db")
mock_db.session.query.return_value.where.return_value.first.return_value = workflow
mock_db.session.scalar.return_value = workflow
# 2. Run test
result = rag_pipeline_service.get_draft_workflow(pipeline)
@@ -998,7 +954,7 @@ def test_get_published_workflow_success(mocker, rag_pipeline_service) -> None:
workflow = mocker.Mock(spec=Workflow)
mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db")
mock_db.session.query.return_value.where.return_value.first.return_value = workflow
mock_db.session.scalar.return_value = workflow
# 2. Run test
result = rag_pipeline_service.get_published_workflow(pipeline)
@@ -1319,11 +1275,8 @@ def test_get_rag_pipeline_workflow_run_node_executions_returns_sorted_executions
def test_get_recommended_plugins_returns_empty_when_no_active_plugins(mocker, rag_pipeline_service) -> None:
query = mocker.Mock()
query.where.return_value = query
query.order_by.return_value.all.return_value = []
mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db")
mock_db.session.query.return_value = query
mock_db.session.scalars.return_value.all.return_value = []
result = rag_pipeline_service.get_recommended_plugins("all")
@@ -1336,11 +1289,8 @@ def test_get_recommended_plugins_returns_empty_when_no_active_plugins(mocker, ra
def test_get_recommended_plugins_returns_installed_and_uninstalled(mocker, rag_pipeline_service) -> None:
plugin_a = SimpleNamespace(plugin_id="plugin-a")
plugin_b = SimpleNamespace(plugin_id="plugin-b")
query = mocker.Mock()
query.where.return_value = query
query.order_by.return_value.all.return_value = [plugin_a, plugin_b]
mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db")
mock_db.session.query.return_value = query
mock_db.session.scalars.return_value.all.return_value = [plugin_a, plugin_b]
mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1"))
mocker.patch(
"services.rag_pipeline.rag_pipeline.BuiltinToolManageService.list_builtin_tools",
@@ -1568,9 +1518,7 @@ def test_get_second_step_parameters_filters_first_step_variables(mocker, rag_pip
def test_retry_error_document_raises_when_execution_log_not_found(mocker, rag_pipeline_service) -> None:
query = mocker.Mock()
query.where.return_value.first.return_value = None
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=None)
with pytest.raises(ValueError, match="Document pipeline execution log not found"):
rag_pipeline_service.retry_error_document(
@@ -1581,9 +1529,7 @@ def test_retry_error_document_raises_when_execution_log_not_found(mocker, rag_pi
def test_get_datasource_plugins_raises_when_workflow_not_found(mocker, rag_pipeline_service) -> None:
dataset = SimpleNamespace(pipeline_id="p1")
pipeline = SimpleNamespace(id="p1", tenant_id="t1")
query = mocker.Mock()
query.where.return_value.first.side_effect = [dataset, pipeline]
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline])
mocker.patch.object(rag_pipeline_service, "get_published_workflow", return_value=None)
with pytest.raises(ValueError, match="Pipeline or workflow not found"):
@@ -1656,8 +1602,7 @@ def test_handle_node_run_result_marks_document_error_for_published_invoke(mocker
document = SimpleNamespace(indexing_status="waiting", error=None)
query = mocker.Mock()
query.where.return_value.first.return_value = document
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=document)
add_mock = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.add")
commit_mock = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit")
@@ -1712,9 +1657,7 @@ def test_run_datasource_node_preview_raises_for_unsupported_provider(mocker, rag
def test_publish_customized_pipeline_template_raises_for_missing_pipeline(mocker, rag_pipeline_service) -> None:
query = mocker.Mock()
query.where.return_value.first.return_value = None
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=None)
with pytest.raises(ValueError, match="Pipeline not found"):
rag_pipeline_service.publish_customized_pipeline_template("p1", {})
@@ -1722,9 +1665,7 @@ def test_publish_customized_pipeline_template_raises_for_missing_pipeline(mocker
def test_publish_customized_pipeline_template_raises_for_missing_workflow_id(mocker, rag_pipeline_service) -> None:
pipeline = SimpleNamespace(id="p1", tenant_id="t1", workflow_id=None)
query = mocker.Mock()
query.where.return_value.first.return_value = pipeline
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=pipeline)
with pytest.raises(ValueError, match="Pipeline workflow not found"):
rag_pipeline_service.publish_customized_pipeline_template("p1", {"name": "template-name"})
@@ -1732,8 +1673,7 @@ def test_publish_customized_pipeline_template_raises_for_missing_workflow_id(moc
def test_get_pipeline_raises_when_dataset_missing(mocker, rag_pipeline_service) -> None:
query = mocker.Mock()
query.where.return_value.first.return_value = None
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=None)
with pytest.raises(ValueError, match="Dataset not found"):
rag_pipeline_service.get_pipeline("t1", "d1")
@@ -1742,8 +1682,7 @@ def test_get_pipeline_raises_when_dataset_missing(mocker, rag_pipeline_service)
def test_get_pipeline_raises_when_pipeline_missing(mocker, rag_pipeline_service) -> None:
dataset = SimpleNamespace(pipeline_id="p1")
query = mocker.Mock()
query.where.return_value.first.side_effect = [dataset, None]
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, None])
with pytest.raises(ValueError, match="Pipeline not found"):
rag_pipeline_service.get_pipeline("t1", "d1")
@@ -1783,8 +1722,7 @@ def test_get_pipeline_templates_builtin_en_us_no_fallback(mocker) -> None:
def test_update_customized_pipeline_template_commits_when_name_empty(mocker) -> None:
template = SimpleNamespace(name="old", description="old", icon={}, updated_by=None)
query = mocker.Mock()
query.where.return_value.first.return_value = template
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=template)
commit = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit")
mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1"))
@@ -2011,8 +1949,7 @@ def test_run_free_workflow_node_delegates_to_handle_result(mocker, rag_pipeline_
def test_publish_customized_pipeline_template_raises_when_workflow_missing(mocker, rag_pipeline_service) -> None:
pipeline = SimpleNamespace(id="p1", tenant_id="t1", workflow_id="wf-1")
query = mocker.Mock()
query.where.return_value.first.side_effect = [pipeline, None]
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", side_effect=[pipeline, None])
with pytest.raises(ValueError, match="Workflow not found"):
rag_pipeline_service.publish_customized_pipeline_template("p1", {})
@@ -2021,11 +1958,9 @@ def test_publish_customized_pipeline_template_raises_when_workflow_missing(mocke
def test_publish_customized_pipeline_template_raises_when_dataset_missing(mocker, rag_pipeline_service) -> None:
pipeline = SimpleNamespace(id="p1", tenant_id="t1", workflow_id="wf-1")
workflow = SimpleNamespace(id="wf-1")
query = mocker.Mock()
query.where.return_value.first.side_effect = [pipeline, workflow]
mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db")
mock_db.engine = mocker.Mock()
mock_db.session.query.return_value = query
mock_db.session.get.side_effect = [pipeline, workflow]
session_ctx = mocker.MagicMock()
session_ctx.__enter__.return_value = SimpleNamespace()
session_ctx.__exit__.return_value = False
@@ -2038,11 +1973,8 @@ def test_publish_customized_pipeline_template_raises_when_dataset_missing(mocker
def test_get_recommended_plugins_skips_manifest_when_missing(mocker, rag_pipeline_service) -> None:
plugin = SimpleNamespace(plugin_id="plugin-a")
query = mocker.Mock()
query.where.return_value = query
query.order_by.return_value.all.return_value = [plugin]
mock_db = mocker.patch("services.rag_pipeline.rag_pipeline.db")
mock_db.session.query.return_value = query
mock_db.session.scalars.return_value.all.return_value = [plugin]
mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1"))
mocker.patch("services.rag_pipeline.rag_pipeline.BuiltinToolManageService.list_builtin_tools", return_value=[])
mocker.patch("services.rag_pipeline.rag_pipeline.marketplace.batch_fetch_plugin_by_ids", return_value=[])
@@ -2056,8 +1988,8 @@ def test_get_recommended_plugins_skips_manifest_when_missing(mocker, rag_pipelin
def test_retry_error_document_raises_when_pipeline_missing(mocker, rag_pipeline_service) -> None:
exec_log = SimpleNamespace(pipeline_id="p1")
query = mocker.Mock()
query.where.return_value.first.side_effect = [exec_log, None]
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=exec_log)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=None)
with pytest.raises(ValueError, match="Pipeline not found"):
rag_pipeline_service.retry_error_document(
@@ -2069,8 +2001,8 @@ def test_retry_error_document_raises_when_workflow_missing(mocker, rag_pipeline_
exec_log = SimpleNamespace(pipeline_id="p1")
pipeline = SimpleNamespace(id="p1")
query = mocker.Mock()
query.where.return_value.first.side_effect = [exec_log, pipeline]
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=exec_log)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=pipeline)
mocker.patch.object(rag_pipeline_service, "get_published_workflow", return_value=None)
with pytest.raises(ValueError, match="Workflow not found"):
@@ -2086,8 +2018,7 @@ def test_get_datasource_plugins_returns_empty_for_non_datasource_nodes(mocker, r
graph_dict={"nodes": [{"id": "n1", "data": {"type": "start"}}]}, rag_pipeline_variables=[]
)
query = mocker.Mock()
query.where.return_value.first.side_effect = [dataset, pipeline]
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline])
mocker.patch.object(rag_pipeline_service, "get_published_workflow", return_value=workflow)
assert rag_pipeline_service.get_datasource_plugins("t1", "d1", True) == []
@@ -2250,8 +2181,7 @@ def test_get_datasource_plugins_handles_empty_datasource_data_and_non_published(
rag_pipeline_variables=[{"variable": "v1", "belong_to_node_id": "shared"}],
)
query = mocker.Mock()
query.where.return_value.first.side_effect = [dataset, pipeline]
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline])
mocker.patch.object(rag_pipeline_service, "get_draft_workflow", return_value=workflow)
mocker.patch(
"services.rag_pipeline.rag_pipeline.DatasourceProviderService.list_datasource_credentials", return_value=[]
@@ -2291,8 +2221,7 @@ def test_get_datasource_plugins_extracts_user_inputs_and_credentials(mocker, rag
],
)
query = mocker.Mock()
query.where.return_value.first.side_effect = [dataset, pipeline]
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline])
mocker.patch.object(rag_pipeline_service, "get_published_workflow", return_value=workflow)
mocker.patch(
"services.rag_pipeline.rag_pipeline.DatasourceProviderService.list_datasource_credentials",
@@ -2310,8 +2239,7 @@ def test_get_pipeline_returns_pipeline_when_found(mocker, rag_pipeline_service)
dataset = SimpleNamespace(pipeline_id="p1")
pipeline = SimpleNamespace(id="p1")
query = mocker.Mock()
query.where.return_value.first.side_effect = [dataset, pipeline]
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.query", return_value=query)
mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline])
result = rag_pipeline_service.get_pipeline("t1", "d1")

View File

@@ -173,9 +173,7 @@ class TestAccountService:
# Setup test data
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
# Setup smart database query mock
query_results = {("Account", "email", "test@example.com"): mock_account}
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
mock_db_dependencies["db"].session.scalar.return_value = mock_account
mock_password_dependencies["compare_password"].return_value = True
@@ -188,9 +186,7 @@ class TestAccountService:
def test_authenticate_account_not_found(self, mock_db_dependencies):
"""Test authentication when account does not exist."""
# Setup smart database query mock - no matching results
query_results = {("Account", "email", "notfound@example.com"): None}
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
mock_db_dependencies["db"].session.scalar.return_value = None
# Execute test and verify exception
self._assert_exception_raised(
@@ -202,9 +198,7 @@ class TestAccountService:
# Setup test data
mock_account = TestAccountAssociatedDataFactory.create_account_mock(status="banned")
# Setup smart database query mock
query_results = {("Account", "email", "banned@example.com"): mock_account}
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
mock_db_dependencies["db"].session.scalar.return_value = mock_account
# Execute test and verify exception
self._assert_exception_raised(AccountLoginError, AccountService.authenticate, "banned@example.com", "password")
@@ -214,9 +208,7 @@ class TestAccountService:
# Setup test data
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
# Setup smart database query mock
query_results = {("Account", "email", "test@example.com"): mock_account}
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
mock_db_dependencies["db"].session.scalar.return_value = mock_account
mock_password_dependencies["compare_password"].return_value = False
@@ -230,9 +222,7 @@ class TestAccountService:
# Setup test data
mock_account = TestAccountAssociatedDataFactory.create_account_mock(status="pending")
# Setup smart database query mock
query_results = {("Account", "email", "pending@example.com"): mock_account}
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
mock_db_dependencies["db"].session.scalar.return_value = mock_account
mock_password_dependencies["compare_password"].return_value = True
@@ -422,12 +412,8 @@ class TestAccountService:
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
mock_tenant_join = TestAccountAssociatedDataFactory.create_tenant_join_mock()
# Setup smart database query mock
query_results = {
("Account", "id", "user-123"): mock_account,
("TenantAccountJoin", "account_id", "user-123"): mock_tenant_join,
}
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
mock_db_dependencies["db"].session.get.return_value = mock_account
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant_join
# Mock datetime
with patch("services.account_service.datetime") as mock_datetime:
@@ -444,9 +430,7 @@ class TestAccountService:
def test_load_user_not_found(self, mock_db_dependencies):
"""Test user loading when user does not exist."""
# Setup smart database query mock - no matching results
query_results = {("Account", "id", "non-existent-user"): None}
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
mock_db_dependencies["db"].session.get.return_value = None
# Execute test
result = AccountService.load_user("non-existent-user")
@@ -459,9 +443,7 @@ class TestAccountService:
# Setup test data
mock_account = TestAccountAssociatedDataFactory.create_account_mock(status="banned")
# Setup smart database query mock
query_results = {("Account", "id", "user-123"): mock_account}
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
mock_db_dependencies["db"].session.get.return_value = mock_account
# Execute test and verify exception
self._assert_exception_raised(
@@ -476,13 +458,9 @@ class TestAccountService:
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
mock_available_tenant = TestAccountAssociatedDataFactory.create_tenant_join_mock(current=False)
# Setup smart database query mock for complex scenario
query_results = {
("Account", "id", "user-123"): mock_account,
("TenantAccountJoin", "account_id", "user-123"): None, # No current tenant
("TenantAccountJoin", "order_by", "first_available"): mock_available_tenant, # First available tenant
}
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
mock_db_dependencies["db"].session.get.return_value = mock_account
# First scalar: current tenant (None), second scalar: available tenant
mock_db_dependencies["db"].session.scalar.side_effect = [None, mock_available_tenant]
# Mock datetime
with patch("services.account_service.datetime") as mock_datetime:
@@ -503,13 +481,9 @@ class TestAccountService:
# Setup test data
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
# Setup smart database query mock for no tenants scenario
query_results = {
("Account", "id", "user-123"): mock_account,
("TenantAccountJoin", "account_id", "user-123"): None, # No current tenant
("TenantAccountJoin", "order_by", "first_available"): None, # No available tenants
}
ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results)
mock_db_dependencies["db"].session.get.return_value = mock_account
# First scalar: current tenant (None), second scalar: available tenant (None)
mock_db_dependencies["db"].session.scalar.side_effect = [None, None]
# Mock datetime
with patch("services.account_service.datetime") as mock_datetime:
@@ -1060,7 +1034,7 @@ class TestRegisterService:
)
# Verify rollback operations were called
mock_db_dependencies["db"].session.query.assert_called()
mock_db_dependencies["db"].session.execute.assert_called()
# ==================== Registration Tests ====================
@@ -1625,10 +1599,8 @@ class TestRegisterService:
mock_session_class.return_value.__exit__.return_value = None
mock_lookup.return_value = mock_existing_account
# Mock the db.session.query for TenantAccountJoin
mock_db_query = MagicMock()
mock_db_query.filter_by.return_value.first.return_value = None # No existing member
mock_db_dependencies["db"].session.query.return_value = mock_db_query
# Mock scalar for TenantAccountJoin lookup - no existing member
mock_db_dependencies["db"].session.scalar.return_value = None
# Mock TenantService methods
with (
@@ -1803,14 +1775,9 @@ class TestRegisterService:
}
mock_get_invitation_by_token.return_value = invitation_data
# Mock database queries - complex query mocking
mock_query1 = MagicMock()
mock_query1.where.return_value.first.return_value = mock_tenant
mock_query2 = MagicMock()
mock_query2.join.return_value.where.return_value.first.return_value = (mock_account, "normal")
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
# Mock scalar for tenant lookup, execute for account+role lookup
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal")
# Execute test
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
@@ -1842,10 +1809,8 @@ class TestRegisterService:
}
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
# Mock database queries - no tenant found
mock_query = MagicMock()
mock_query.filter.return_value.first.return_value = None
mock_db_dependencies["db"].session.query.return_value = mock_query
# Mock scalar for tenant lookup - not found
mock_db_dependencies["db"].session.scalar.return_value = None
# Execute test
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
@@ -1868,14 +1833,9 @@ class TestRegisterService:
}
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
# Mock database queries
mock_query1 = MagicMock()
mock_query1.filter.return_value.first.return_value = mock_tenant
mock_query2 = MagicMock()
mock_query2.join.return_value.where.return_value.first.return_value = None # No account found
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
# Mock scalar for tenant, execute for account+role
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
mock_db_dependencies["db"].session.execute.return_value.first.return_value = None # No account found
# Execute test
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")
@@ -1901,14 +1861,9 @@ class TestRegisterService:
}
mock_redis_dependencies.get.return_value = json.dumps(invitation_data).encode()
# Mock database queries
mock_query1 = MagicMock()
mock_query1.filter.return_value.first.return_value = mock_tenant
mock_query2 = MagicMock()
mock_query2.join.return_value.where.return_value.first.return_value = (mock_account, "normal")
mock_db_dependencies["db"].session.query.side_effect = [mock_query1, mock_query2]
# Mock scalar for tenant, execute for account+role
mock_db_dependencies["db"].session.scalar.return_value = mock_tenant
mock_db_dependencies["db"].session.execute.return_value.first.return_value = (mock_account, "normal")
# Execute test
result = RegisterService.get_invitation_if_token_valid("tenant-456", "test@example.com", "token-123")

View File

@@ -799,10 +799,7 @@ class TestExternalDatasetServiceGetAPI:
api_id = "api-123"
expected_api = factory.create_external_knowledge_api_mock(api_id=api_id)
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = expected_api
mock_db.session.scalar.return_value = expected_api
# Act
tenant_id = "tenant-123"
@@ -810,16 +807,12 @@ class TestExternalDatasetServiceGetAPI:
# Assert
assert result.id == api_id
mock_query.filter_by.assert_called_once_with(id=api_id, tenant_id=tenant_id)
@patch("services.external_knowledge_service.db")
def test_get_external_knowledge_api_not_found(self, mock_db, factory):
"""Test error when API is not found."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with pytest.raises(ValueError, match="api template not found"):
@@ -848,10 +841,7 @@ class TestExternalDatasetServiceUpdateAPI:
"settings": {"endpoint": "https://new.example.com", "api_key": "new-key"},
}
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = existing_api
mock_db.session.scalar.return_value = existing_api
# Act
result = ExternalDatasetService.update_external_knowledge_api(tenant_id, user_id, api_id, args)
@@ -881,10 +871,7 @@ class TestExternalDatasetServiceUpdateAPI:
"settings": {"endpoint": "https://api.example.com", "api_key": HIDDEN_VALUE},
}
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = existing_api
mock_db.session.scalar.return_value = existing_api
# Act
result = ExternalDatasetService.update_external_knowledge_api(tenant_id, "user-123", api_id, args)
@@ -897,10 +884,7 @@ class TestExternalDatasetServiceUpdateAPI:
def test_update_external_knowledge_api_not_found(self, mock_db, factory):
"""Test error when API is not found."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db.session.scalar.return_value = None
args = {"name": "Updated API"}
@@ -912,10 +896,7 @@ class TestExternalDatasetServiceUpdateAPI:
def test_update_external_knowledge_api_tenant_mismatch(self, mock_db, factory):
"""Test error when tenant ID doesn't match."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db.session.scalar.return_value = None
args = {"name": "Updated API"}
@@ -934,10 +915,7 @@ class TestExternalDatasetServiceUpdateAPI:
args = {"name": "New Name Only"}
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = existing_api
mock_db.session.scalar.return_value = existing_api
# Act
result = ExternalDatasetService.update_external_knowledge_api("tenant-123", "user-123", "api-123", args)
@@ -958,10 +936,7 @@ class TestExternalDatasetServiceDeleteAPI:
existing_api = factory.create_external_knowledge_api_mock(api_id=api_id, tenant_id=tenant_id)
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = existing_api
mock_db.session.scalar.return_value = existing_api
# Act
ExternalDatasetService.delete_external_knowledge_api(tenant_id, api_id)
@@ -974,10 +949,7 @@ class TestExternalDatasetServiceDeleteAPI:
def test_delete_external_knowledge_api_not_found(self, mock_db, factory):
"""Test error when API is not found."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with pytest.raises(ValueError, match="api template not found"):
@@ -987,10 +959,7 @@ class TestExternalDatasetServiceDeleteAPI:
def test_delete_external_knowledge_api_tenant_mismatch(self, mock_db, factory):
"""Test error when tenant ID doesn't match."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with pytest.raises(ValueError, match="api template not found"):
@@ -1006,10 +975,7 @@ class TestExternalDatasetServiceAPIUseCheck:
# Arrange
api_id = "api-123"
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.count.return_value = 1
mock_db.session.scalar.return_value = 1
# Act
in_use, count = ExternalDatasetService.external_knowledge_api_use_check(api_id)
@@ -1024,10 +990,7 @@ class TestExternalDatasetServiceAPIUseCheck:
# Arrange
api_id = "api-123"
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.count.return_value = 10
mock_db.session.scalar.return_value = 10
# Act
in_use, count = ExternalDatasetService.external_knowledge_api_use_check(api_id)
@@ -1042,10 +1005,7 @@ class TestExternalDatasetServiceAPIUseCheck:
# Arrange
api_id = "api-123"
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.count.return_value = 0
mock_db.session.scalar.return_value = 0
# Act
in_use, count = ExternalDatasetService.external_knowledge_api_use_check(api_id)
@@ -1067,10 +1027,7 @@ class TestExternalDatasetServiceGetBinding:
expected_binding = factory.create_external_knowledge_binding_mock(tenant_id=tenant_id, dataset_id=dataset_id)
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = expected_binding
mock_db.session.scalar.return_value = expected_binding
# Act
result = ExternalDatasetService.get_external_knowledge_binding_with_dataset_id(tenant_id, dataset_id)
@@ -1083,10 +1040,7 @@ class TestExternalDatasetServiceGetBinding:
def test_get_external_knowledge_binding_not_found(self, mock_db, factory):
"""Test error when binding is not found."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with pytest.raises(ValueError, match="external knowledge binding not found"):
@@ -1113,10 +1067,7 @@ class TestExternalDatasetServiceDocumentValidate:
api = factory.create_external_knowledge_api_mock(api_id=api_id, settings=[settings])
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = api
mock_db.session.scalar.return_value = api
process_parameter = {"param1": "value1", "param2": "value2"}
@@ -1134,10 +1085,7 @@ class TestExternalDatasetServiceDocumentValidate:
api = factory.create_external_knowledge_api_mock(api_id=api_id, settings=[settings])
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = api
mock_db.session.scalar.return_value = api
process_parameter = {}
@@ -1149,10 +1097,7 @@ class TestExternalDatasetServiceDocumentValidate:
def test_document_create_args_validate_api_not_found(self, mock_db, factory):
"""Test validation fails when API is not found."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with pytest.raises(ValueError, match="api template not found"):
@@ -1165,10 +1110,7 @@ class TestExternalDatasetServiceDocumentValidate:
settings = {}
api = factory.create_external_knowledge_api_mock(settings=[settings])
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = api
mock_db.session.scalar.return_value = api
# Act & Assert - should not raise
ExternalDatasetService.document_create_args_validate("tenant-123", "api-123", {})
@@ -1186,10 +1128,7 @@ class TestExternalDatasetServiceDocumentValidate:
api = factory.create_external_knowledge_api_mock(settings=[settings])
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = api
mock_db.session.scalar.return_value = api
process_parameter = {"required_param": "value"}
@@ -1498,24 +1437,7 @@ class TestExternalDatasetServiceCreateDataset:
api = factory.create_external_knowledge_api_mock(api_id="api-123")
# Mock database queries
mock_dataset_query = MagicMock()
mock_api_query = MagicMock()
def query_side_effect(model):
if model == Dataset:
return mock_dataset_query
elif model == ExternalKnowledgeApis:
return mock_api_query
return MagicMock()
mock_db.session.query.side_effect = query_side_effect
mock_dataset_query.filter_by.return_value = mock_dataset_query
mock_dataset_query.first.return_value = None
mock_api_query.filter_by.return_value = mock_api_query
mock_api_query.first.return_value = api
mock_db.session.scalar.side_effect = [None, api]
# Act
result = ExternalDatasetService.create_external_dataset(tenant_id, user_id, args)
@@ -1534,10 +1456,7 @@ class TestExternalDatasetServiceCreateDataset:
# Arrange
existing_dataset = factory.create_dataset_mock(name="Duplicate Dataset")
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = existing_dataset
mock_db.session.scalar.return_value = existing_dataset
args = {"name": "Duplicate Dataset"}
@@ -1549,23 +1468,7 @@ class TestExternalDatasetServiceCreateDataset:
def test_create_external_dataset_api_not_found_error(self, mock_db, factory):
"""Test error when external knowledge API is not found."""
# Arrange
mock_dataset_query = MagicMock()
mock_api_query = MagicMock()
def query_side_effect(model):
if model == Dataset:
return mock_dataset_query
elif model == ExternalKnowledgeApis:
return mock_api_query
return MagicMock()
mock_db.session.query.side_effect = query_side_effect
mock_dataset_query.filter_by.return_value = mock_dataset_query
mock_dataset_query.first.return_value = None
mock_api_query.filter_by.return_value = mock_api_query
mock_api_query.first.return_value = None
mock_db.session.scalar.side_effect = [None, None]
args = {"name": "Test Dataset", "external_knowledge_api_id": "nonexistent-api"}
@@ -1579,23 +1482,7 @@ class TestExternalDatasetServiceCreateDataset:
# Arrange
api = factory.create_external_knowledge_api_mock()
mock_dataset_query = MagicMock()
mock_api_query = MagicMock()
def query_side_effect(model):
if model == Dataset:
return mock_dataset_query
elif model == ExternalKnowledgeApis:
return mock_api_query
return MagicMock()
mock_db.session.query.side_effect = query_side_effect
mock_dataset_query.filter_by.return_value = mock_dataset_query
mock_dataset_query.first.return_value = None
mock_api_query.filter_by.return_value = mock_api_query
mock_api_query.first.return_value = api
mock_db.session.scalar.side_effect = [None, api]
args = {"name": "Test Dataset", "external_knowledge_api_id": "api-123"}
@@ -1609,23 +1496,7 @@ class TestExternalDatasetServiceCreateDataset:
# Arrange
api = factory.create_external_knowledge_api_mock()
mock_dataset_query = MagicMock()
mock_api_query = MagicMock()
def query_side_effect(model):
if model == Dataset:
return mock_dataset_query
elif model == ExternalKnowledgeApis:
return mock_api_query
return MagicMock()
mock_db.session.query.side_effect = query_side_effect
mock_dataset_query.filter_by.return_value = mock_dataset_query
mock_dataset_query.first.return_value = None
mock_api_query.filter_by.return_value = mock_api_query
mock_api_query.first.return_value = api
mock_db.session.scalar.side_effect = [None, api]
args = {"name": "Test Dataset", "external_knowledge_id": "knowledge-123"}
@@ -1651,23 +1522,7 @@ class TestExternalDatasetServiceFetchRetrieval:
)
api = factory.create_external_knowledge_api_mock(api_id="api-123")
mock_binding_query = MagicMock()
mock_api_query = MagicMock()
def query_side_effect(model):
if model == ExternalKnowledgeBindings:
return mock_binding_query
elif model == ExternalKnowledgeApis:
return mock_api_query
return MagicMock()
mock_db.session.query.side_effect = query_side_effect
mock_binding_query.filter_by.return_value = mock_binding_query
mock_binding_query.first.return_value = binding
mock_api_query.filter_by.return_value = mock_api_query
mock_api_query.first.return_value = api
mock_db.session.scalar.side_effect = [binding, api]
mock_response = MagicMock()
mock_response.status_code = 200
@@ -1695,10 +1550,7 @@ class TestExternalDatasetServiceFetchRetrieval:
def test_fetch_external_knowledge_retrieval_binding_not_found_error(self, mock_db, factory):
"""Test error when external knowledge binding is not found."""
# Arrange
mock_query = MagicMock()
mock_db.session.query.return_value = mock_query
mock_query.filter_by.return_value = mock_query
mock_query.first.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with pytest.raises(ValueError, match="external knowledge binding not found"):
@@ -1712,23 +1564,7 @@ class TestExternalDatasetServiceFetchRetrieval:
binding = factory.create_external_knowledge_binding_mock()
api = factory.create_external_knowledge_api_mock()
mock_binding_query = MagicMock()
mock_api_query = MagicMock()
def query_side_effect(model):
if model == ExternalKnowledgeBindings:
return mock_binding_query
elif model == ExternalKnowledgeApis:
return mock_api_query
return MagicMock()
mock_db.session.query.side_effect = query_side_effect
mock_binding_query.filter_by.return_value = mock_binding_query
mock_binding_query.first.return_value = binding
mock_api_query.filter_by.return_value = mock_api_query
mock_api_query.first.return_value = api
mock_db.session.scalar.side_effect = [binding, api]
mock_response = MagicMock()
mock_response.status_code = 200
@@ -1751,23 +1587,7 @@ class TestExternalDatasetServiceFetchRetrieval:
binding = factory.create_external_knowledge_binding_mock()
api = factory.create_external_knowledge_api_mock()
mock_binding_query = MagicMock()
mock_api_query = MagicMock()
def query_side_effect(model):
if model == ExternalKnowledgeBindings:
return mock_binding_query
elif model == ExternalKnowledgeApis:
return mock_api_query
return MagicMock()
mock_db.session.query.side_effect = query_side_effect
mock_binding_query.filter_by.return_value = mock_binding_query
mock_binding_query.first.return_value = binding
mock_api_query.filter_by.return_value = mock_api_query
mock_api_query.first.return_value = api
mock_db.session.scalar.side_effect = [binding, api]
mock_response = MagicMock()
mock_response.status_code = 200
@@ -1799,23 +1619,7 @@ class TestExternalDatasetServiceFetchRetrieval:
binding = factory.create_external_knowledge_binding_mock()
api = factory.create_external_knowledge_api_mock()
mock_binding_query = MagicMock()
mock_api_query = MagicMock()
def query_side_effect(model):
if model == ExternalKnowledgeBindings:
return mock_binding_query
elif model == ExternalKnowledgeApis:
return mock_api_query
return MagicMock()
mock_db.session.query.side_effect = query_side_effect
mock_binding_query.filter_by.return_value = mock_binding_query
mock_binding_query.first.return_value = binding
mock_api_query.filter_by.return_value = mock_api_query
mock_api_query.first.return_value = api
mock_db.session.scalar.side_effect = [binding, api]
mock_response = MagicMock()
mock_response.status_code = 500
@@ -1856,23 +1660,7 @@ class TestExternalDatasetServiceFetchRetrieval:
)
api = factory.create_external_knowledge_api_mock(api_id="api-123")
mock_binding_query = MagicMock()
mock_api_query = MagicMock()
def query_side_effect(model):
if model == ExternalKnowledgeBindings:
return mock_binding_query
elif model == ExternalKnowledgeApis:
return mock_api_query
return MagicMock()
mock_db.session.query.side_effect = query_side_effect
mock_binding_query.filter_by.return_value = mock_binding_query
mock_binding_query.first.return_value = binding
mock_api_query.filter_by.return_value = mock_api_query
mock_api_query.first.return_value = api
mock_db.session.scalar.side_effect = [binding, api]
mock_response = MagicMock()
mock_response.status_code = status_code
@@ -1891,23 +1679,7 @@ class TestExternalDatasetServiceFetchRetrieval:
binding = factory.create_external_knowledge_binding_mock()
api = factory.create_external_knowledge_api_mock()
mock_binding_query = MagicMock()
mock_api_query = MagicMock()
def query_side_effect(model):
if model == ExternalKnowledgeBindings:
return mock_binding_query
elif model == ExternalKnowledgeApis:
return mock_api_query
return MagicMock()
mock_db.session.query.side_effect = query_side_effect
mock_binding_query.filter_by.return_value = mock_binding_query
mock_binding_query.first.return_value = binding
mock_api_query.filter_by.return_value = mock_api_query
mock_api_query.first.return_value = api
mock_db.session.scalar.side_effect = [binding, api]
mock_response = MagicMock()
mock_response.status_code = 503

View File

@@ -5,7 +5,6 @@
"prepare": "vp config"
},
"devDependencies": {
"taze": "catalog:",
"vite-plus": "catalog:"
},
"engines": {

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.33317 3.33333H7.33317V12.6667H5.33317V14H10.6665V12.6667H8.6665V3.33333H10.6665V2H5.33317V3.33333ZM1.33317 4.66667C0.964984 4.66667 0.666504 4.96515 0.666504 5.33333V10.6667C0.666504 11.0349 0.964984 11.3333 1.33317 11.3333H5.33317V10H1.99984V6H5.33317V4.66667H1.33317ZM10.6665 6H13.9998V10H10.6665V11.3333H14.6665C15.0347 11.3333 15.3332 11.0349 15.3332 10.6667V5.33333C15.3332 4.96515 15.0347 4.66667 14.6665 4.66667H10.6665V6Z" fill="#354052"/>
</svg>

After

Width:  |  Height:  |  Size: 563 B

3079
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,9 @@
catalogMode: prefer
trustPolicy: no-downgrade
minimumReleaseAge: 2880
trustPolicyExclude:
- chokidar@4.0.3
- reselect@5.1.1
- semver@6.3.1
blockExoticSubdeps: true
strictDepBuilds: true
allowBuilds:
@@ -23,7 +27,7 @@ overrides:
array.prototype.flatmap: npm:@nolyfill/array.prototype.flatmap@^1.0.44
array.prototype.tosorted: npm:@nolyfill/array.prototype.tosorted@^1.0.44
assert: npm:@nolyfill/assert@^1.0.26
brace-expansion@<2.0.2: 2.0.2
brace-expansion@>=2.0.0 <2.0.3: 2.0.3
canvas: ^3.2.2
devalue@<5.3.2: 5.3.2
dompurify@>=3.1.3 <=3.3.1: 3.3.2
@@ -37,6 +41,8 @@ overrides:
is-generator-function: npm:@nolyfill/is-generator-function@^1.0.44
is-typed-array: npm:@nolyfill/is-typed-array@^1.0.44
isarray: npm:@nolyfill/isarray@^1.0.44
lodash@>=4.0.0 <= 4.17.23: 4.18.0
lodash-es@>=4.0.0 <= 4.17.23: 4.18.0
object.assign: npm:@nolyfill/object.assign@^1.0.44
object.entries: npm:@nolyfill/object.entries@^1.0.44
object.fromentries: npm:@nolyfill/object.fromentries@^1.0.44
@@ -64,15 +70,15 @@ overrides:
tar@<=7.5.10: 7.5.11
typed-array-buffer: npm:@nolyfill/typed-array-buffer@^1.0.44
undici@>=7.0.0 <7.24.0: 7.24.0
vite: npm:@voidzero-dev/vite-plus-core@0.1.14
vitest: npm:@voidzero-dev/vite-plus-test@0.1.14
vite: npm:@voidzero-dev/vite-plus-core@0.1.15
vitest: npm:@voidzero-dev/vite-plus-test@0.1.15
which-typed-array: npm:@nolyfill/which-typed-array@^1.0.44
yaml@>=2.0.0 <2.8.3: 2.8.3
yauzl@<3.2.1: 3.2.1
catalog:
"@amplitude/analytics-browser": 2.38.0
"@amplitude/plugin-session-replay-browser": 1.27.5
"@antfu/eslint-config": 7.7.3
"@amplitude/analytics-browser": 2.38.1
"@amplitude/plugin-session-replay-browser": 1.27.6
"@antfu/eslint-config": 8.0.0
"@base-ui/react": 1.3.0
"@chromatic-com/storybook": 5.1.1
"@cucumber/cucumber": 12.7.0
@@ -84,7 +90,7 @@ catalog:
"@formatjs/intl-localematcher": 0.8.2
"@headlessui/react": 2.2.9
"@heroicons/react": 2.2.0
"@hono/node-server": 1.19.11
"@hono/node-server": 1.19.12
"@iconify-json/heroicons": 1.2.3
"@iconify-json/ri": 1.2.10
"@lexical/code": 0.42.0
@@ -98,34 +104,35 @@ catalog:
"@mdx-js/react": 3.1.1
"@mdx-js/rollup": 3.1.1
"@monaco-editor/react": 4.7.0
"@next/eslint-plugin-next": 16.2.1
"@next/mdx": 16.2.1
"@next/eslint-plugin-next": 16.2.2
"@next/mdx": 16.2.2
"@orpc/client": 1.13.13
"@orpc/contract": 1.13.13
"@orpc/openapi-client": 1.13.13
"@orpc/tanstack-query": 1.13.13
"@playwright/test": 1.58.2
"@playwright/test": 1.59.1
"@remixicon/react": 4.9.0
"@rgrove/parse-xml": 4.2.0
"@sentry/react": 10.46.0
"@storybook/addon-docs": 10.3.3
"@storybook/addon-links": 10.3.3
"@storybook/addon-onboarding": 10.3.3
"@storybook/addon-themes": 10.3.3
"@storybook/nextjs-vite": 10.3.3
"@storybook/react": 10.3.3
"@sentry/react": 10.47.0
"@storybook/addon-docs": 10.3.4
"@storybook/addon-links": 10.3.4
"@storybook/addon-onboarding": 10.3.4
"@storybook/addon-themes": 10.3.4
"@storybook/nextjs-vite": 10.3.4
"@storybook/react": 10.3.4
"@streamdown/math": 1.0.2
"@svgdotjs/svg.js": 3.2.5
"@t3-oss/env-nextjs": 0.13.11
"@tailwindcss/postcss": 4.2.2
"@tailwindcss/typography": 0.5.19
"@tailwindcss/vite": 4.2.2
"@tanstack/eslint-plugin-query": 5.95.2
"@tanstack/react-devtools": 0.10.0
"@tanstack/react-form": 1.28.5
"@tanstack/react-form-devtools": 0.2.19
"@tanstack/react-query": 5.95.2
"@tanstack/react-query-devtools": 5.95.2
"@tanstack/eslint-plugin-query": 5.96.1
"@tanstack/react-devtools": 0.10.1
"@tanstack/react-form": 1.28.6
"@tanstack/react-form-devtools": 0.2.20
"@tanstack/react-query": 5.96.1
"@tanstack/react-query-devtools": 5.96.1
"@tanstack/react-virtual": 3.13.23
"@testing-library/dom": 10.4.1
"@testing-library/jest-dom": 6.9.1
"@testing-library/react": 16.3.2
@@ -141,15 +148,13 @@ catalog:
"@types/qs": 6.15.0
"@types/react": 19.2.14
"@types/react-dom": 19.2.3
"@types/react-syntax-highlighter": 15.5.13
"@types/react-window": 1.8.8
"@types/sortablejs": 1.15.9
"@typescript-eslint/eslint-plugin": 8.57.2
"@typescript-eslint/parser": 8.57.2
"@typescript/native-preview": 7.0.0-dev.20260329.1
"@typescript-eslint/eslint-plugin": 8.58.0
"@typescript-eslint/parser": 8.58.0
"@typescript/native-preview": 7.0.0-dev.20260401.1
"@vitejs/plugin-react": 6.0.1
"@vitejs/plugin-rsc": 0.5.21
"@vitest/coverage-v8": 4.1.1
"@vitest/coverage-v8": 4.1.2
abcjs: 6.6.2
agentation: 3.0.2
ahooks: 3.9.7
@@ -157,7 +162,7 @@ catalog:
class-variance-authority: 0.7.1
clsx: 2.1.1
cmdk: 1.1.1
code-inspector-plugin: 1.4.5
code-inspector-plugin: 1.5.1
copy-to-clipboard: 3.3.3
cron-parser: 5.5.0
dayjs: 1.11.20
@@ -174,19 +179,19 @@ catalog:
eslint-markdown: 0.6.0
eslint-plugin-better-tailwindcss: 4.3.2
eslint-plugin-hyoban: 0.14.1
eslint-plugin-markdown-preferences: 0.40.3
eslint-plugin-markdown-preferences: 0.41.0
eslint-plugin-no-barrel-files: 1.2.2
eslint-plugin-react-hooks: 7.0.1
eslint-plugin-react-refresh: 0.5.2
eslint-plugin-sonarjs: 4.0.2
eslint-plugin-storybook: 10.3.3
eslint-plugin-storybook: 10.3.4
fast-deep-equal: 3.1.3
foxact: 0.3.0
happy-dom: 20.8.9
hono: 4.12.9
hast-util-to-jsx-runtime: 2.3.6
hono: 4.12.10
html-entities: 2.6.0
html-to-image: 1.11.13
i18next: 25.10.10
i18next: 26.0.3
i18next-resources-to-backend: 1.2.1
iconify-import-svg: 0.1.2
immer: 11.1.4
@@ -196,15 +201,15 @@ catalog:
js-yaml: 4.1.1
jsonschema: 1.5.0
katex: 0.16.44
knip: 6.1.0
knip: 6.2.0
ky: 1.14.3
lamejs: 1.2.1
lexical: 0.42.0
mermaid: 11.13.0
mermaid: 11.14.0
mime: 4.1.0
mitt: 3.0.1
negotiator: 1.0.0
next: 16.2.1
next: 16.2.2
next-themes: 0.4.6
nuqs: 2.8.9
pinyin-pro: 3.28.0
@@ -217,42 +222,39 @@ catalog:
react-dom: 19.2.4
react-easy-crop: 5.5.7
react-hotkeys-hook: 5.2.4
react-i18next: 16.6.6
react-i18next: 17.0.2
react-multi-email: 1.0.25
react-papaparse: 4.4.0
react-pdf-highlighter: 8.0.0-rc.0
react-server-dom-webpack: 19.2.4
react-sortablejs: 6.1.4
react-syntax-highlighter: 15.6.6
react-textarea-autosize: 8.5.9
react-window: 1.8.11
reactflow: 11.11.4
remark-breaks: 4.0.0
remark-directive: 4.0.0
sass: 1.98.0
scheduler: 0.27.0
sharp: 0.34.5
shiki: 4.0.2
sortablejs: 1.15.7
std-semver: 1.0.8
storybook: 10.3.3
storybook: 10.3.4
streamdown: 2.5.0
string-ts: 2.3.1
tailwind-merge: 3.5.0
tailwindcss: 4.2.2
taze: 19.10.0
tldts: 7.0.27
tsup: ^8.5.1
tsdown: 0.21.7
tsx: 4.21.0
typescript: 5.9.3
typescript: 6.0.2
uglify-js: 3.19.3
unist-util-visit: 5.1.0
use-context-selector: 2.0.0
uuid: 13.0.0
vinext: 0.0.38
vite: npm:@voidzero-dev/vite-plus-core@0.1.14
vinext: 0.0.39
vite: npm:@voidzero-dev/vite-plus-core@0.1.15
vite-plugin-inspect: 12.0.0-beta.1
vite-plus: 0.1.14
vitest: npm:@voidzero-dev/vite-plus-test@0.1.14
vite-plus: 0.1.15
vitest: npm:@voidzero-dev/vite-plus-test@0.1.15
vitest-canvas-mock: 1.1.4
zod: 4.3.6
zundo: 2.3.0

View File

@@ -45,12 +45,12 @@
"homepage": "https://dify.ai",
"license": "MIT",
"scripts": {
"build": "tsup",
"build": "vp pack",
"lint": "eslint",
"lint:fix": "eslint --fix",
"type-check": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test": "vp test",
"test:coverage": "vp test --coverage",
"publish:check": "./scripts/publish.sh --dry-run",
"publish:npm": "./scripts/publish.sh"
},
@@ -61,8 +61,8 @@
"@typescript-eslint/parser": "catalog:",
"@vitest/coverage-v8": "catalog:",
"eslint": "catalog:",
"tsup": "catalog:",
"typescript": "catalog:",
"vite-plus": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -11,7 +11,8 @@
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}

View File

@@ -1,12 +0,0 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
splitting: false,
treeshake: true,
outDir: "dist",
});

View File

@@ -1,6 +1,17 @@
import { defineConfig } from "vitest/config";
import { defineConfig } from "vite-plus";
export default defineConfig({
pack: {
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
// splitting: false,
treeshake: true,
outDir: "dist",
target: false,
},
test: {
environment: "node",
include: ["**/*.test.ts"],

View File

@@ -1,15 +0,0 @@
import { defineConfig } from 'taze'
export default defineConfig({
exclude: [
// We are going to replace these
'react-syntax-highlighter',
'react-window',
'@types/react-window',
// We can not upgrade these yet
'typescript',
],
maturityPeriod: 2,
})

View File

@@ -0,0 +1,36 @@
import { vi } from 'vitest'
const mockVirtualizer = ({
count,
estimateSize,
}: {
count: number
estimateSize?: (index: number) => number
}) => {
const getSize = (index: number) => estimateSize?.(index) ?? 0
return {
getTotalSize: () => Array.from({ length: count }).reduce<number>((total, _, index) => total + getSize(index), 0),
getVirtualItems: () => {
let start = 0
return Array.from({ length: count }).map((_, index) => {
const size = getSize(index)
const virtualItem = {
end: start + size,
index,
key: index,
size,
start,
}
start += size
return virtualItem
})
},
measureElement: vi.fn(),
scrollToIndex: vi.fn(),
}
}
export { mockVirtualizer as useVirtualizer }

View File

@@ -0,0 +1,11 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ appId: string }>
}) => {
const { appId } = await props.params
return <Evaluation resourceType="workflow" resourceId={appId} />
}
export default Page

View File

@@ -7,6 +7,8 @@ import {
RiDashboard2Line,
RiFileList3Fill,
RiFileList3Line,
RiFlaskFill,
RiFlaskLine,
RiTerminalBoxFill,
RiTerminalBoxLine,
RiTerminalWindowFill,
@@ -35,7 +37,7 @@ const TagManagementModal = dynamic(() => import('@/app/components/base/tag-manag
ssr: false,
})
export type IAppDetailLayoutProps = {
type IAppDetailLayoutProps = {
children: React.ReactNode
appId: string
}
@@ -67,40 +69,47 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
...(isCurrentWorkspaceEditor
? [{
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
}]
: []
),
{
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(isCurrentWorkspaceEditor
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
}]
: []
),
{
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
]
const navConfig = []
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: t('appMenus.promptEng', { ns: 'common' }),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
})
navConfig.push({
name: t('appMenus.evaluation', { ns: 'common' }),
href: `/app/${appId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
})
}
navConfig.push({
name: t('appMenus.apiAccess', { ns: 'common' }),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
})
if (isCurrentWorkspaceEditor) {
navConfig.push({
name: mode !== AppModeEnum.WORKFLOW
? t('appMenus.logAndAnn', { ns: 'common' })
: t('appMenus.logs', { ns: 'common' }),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
})
}
navConfig.push({
name: t('appMenus.overview', { ns: 'common' }),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
})
return navConfig
}, [t])

View File

@@ -25,7 +25,7 @@ import { useAppWorkflow } from '@/service/use-workflow'
import { AppModeEnum } from '@/types/app'
import { asyncRunSafe } from '@/utils'
export type ICardViewProps = {
type ICardViewProps = {
appId: string
isInPanel?: boolean
className?: string

View File

@@ -27,7 +27,7 @@ const TIME_PERIOD_MAPPING: { value: number, name: TimePeriodName }[] = [
const queryDateFormat = 'YYYY-MM-DD HH:mm'
export type IChartViewProps = {
type IChartViewProps = {
appId: string
headerRight: React.ReactNode
}

View File

@@ -1,5 +1,3 @@
@reference "../../../../styles/globals.css";
.app {
flex-grow: 1;
height: 0;

View File

@@ -1,11 +0,0 @@
@reference "../../../../../styles/globals.css";
.logTable td {
padding: 7px 8px;
box-sizing: border-box;
max-width: 200px;
}
.pagination li {
list-style: none;
}

View File

@@ -0,0 +1,11 @@
import Evaluation from '@/app/components/evaluation'
const Page = async (props: {
params: Promise<{ datasetId: string }>
}) => {
const { datasetId } = await props.params
return <Evaluation resourceType="pipeline" resourceId={datasetId} />
}
export default Page

View File

@@ -6,6 +6,8 @@ import {
RiEqualizer2Line,
RiFileTextFill,
RiFileTextLine,
RiFlaskFill,
RiFlaskLine,
RiFocus2Fill,
RiFocus2Line,
} from '@remixicon/react'
@@ -26,7 +28,7 @@ import { usePathname } from '@/next/navigation'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import { cn } from '@/utils/classnames'
export type IAppDetailLayoutProps = {
type IAppDetailLayoutProps = {
children: React.ReactNode
datasetId: string
}
@@ -86,20 +88,30 @@ const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
]
if (datasetRes?.provider !== 'external') {
baseNavigation.unshift({
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
})
baseNavigation.unshift({
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
})
return [
{
name: t('datasetMenus.documents', { ns: 'common' }),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
},
{
name: t('datasetMenus.pipeline', { ns: 'common' }),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
},
{
name: t('datasetMenus.evaluation', { ns: 'common' }),
href: `/datasets/${datasetId}/evaluation`,
icon: RiFlaskLine,
selectedIcon: RiFlaskFill,
disabled: false,
},
...baseNavigation,
]
}
return baseNavigation

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
import AmplitudeProvider from '@/app/components/base/amplitude'
@@ -13,6 +14,7 @@ import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import PartnerStack from '../components/billing/partner-stack'
import Splash from '../components/splash'
import RoleRouteGuard from './role-route-guard'
const Layout = ({ children }: { children: ReactNode }) => {
@@ -35,6 +37,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<Splash />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>

View File

@@ -1,21 +1,33 @@
'use client'
import type { ReactNode } from 'react'
import { useEffect } from 'react'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { redirect, usePathname } from '@/next/navigation'
import { usePathname, useRouter } from '@/next/navigation'
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const
const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const
const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`)
export default function RoleRouteGuard({ children }: { children: ReactNode }) {
const { isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace } = useAppContext()
const pathname = usePathname()
const router = useRouter()
const shouldGuardRoute = datasetOperatorRedirectRoutes.some(route => isPathUnderRoute(pathname, route))
const shouldRedirect = shouldGuardRoute && !isLoadingCurrentWorkspace && isCurrentWorkspaceDatasetOperator
useEffect(() => {
if (shouldRedirect)
router.replace('/datasets')
}, [shouldRedirect, router])
// Block rendering only for guarded routes to avoid permission flicker.
if (shouldGuardRoute && isLoadingCurrentWorkspace)
return <Loading type="app" />
if (shouldRedirect)
return redirect('/datasets')
return null
return <>{children}</>
}

View File

@@ -0,0 +1,11 @@
import SnippetEvaluationPage from '@/app/components/snippets/snippet-evaluation-page'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetEvaluationPage snippetId={snippetId} />
}
export default Page

View File

@@ -0,0 +1,11 @@
import SnippetPage from '@/app/components/snippets'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
return <SnippetPage snippetId={snippetId} />
}
export default Page

View File

@@ -0,0 +1,21 @@
import Page from './page'
const mockRedirect = vi.fn()
vi.mock('next/navigation', () => ({
redirect: (path: string) => mockRedirect(path),
}))
describe('snippet detail redirect page', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should redirect legacy snippet detail routes to orchestrate', async () => {
await Page({
params: Promise.resolve({ snippetId: 'snippet-1' }),
})
expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate')
})
})

View File

@@ -0,0 +1,11 @@
import { redirect } from 'next/navigation'
const Page = async (props: {
params: Promise<{ snippetId: string }>
}) => {
const { snippetId } = await props.params
redirect(`/snippets/${snippetId}/orchestrate`)
}
export default Page

View File

@@ -0,0 +1,7 @@
import Apps from '@/app/components/apps'
const SnippetsPage = () => {
return <Apps pageType="snippets" />
}
export default SnippetsPage

View File

@@ -13,10 +13,6 @@ import { useProviderContext } from '@/context/provider-context'
import { useRouter } from '@/next/navigation'
import { useLogout, useUserProfile } from '@/service/use-common'
export type IAppSelector = {
isMobile: boolean
}
export default function AppSelector() {
const router = useRouter()
const { t } = useTranslation()

View File

@@ -3,7 +3,7 @@
import type { ReactNode } from 'react'
import Cookies from 'js-cookie'
import { parseAsBoolean, useQueryState } from 'nuqs'
import { useCallback, useEffect } from 'react'
import { useCallback, useEffect, useState } from 'react'
import {
EDUCATION_VERIFY_URL_SEARCHPARAMS_ACTION,
EDUCATION_VERIFYING_LOCALSTORAGE_ITEM,
@@ -25,6 +25,7 @@ export const AppInitializer = ({
const searchParams = useSearchParams()
// Tokens are now stored in cookies, no need to check localStorage
const pathname = usePathname()
const [init, setInit] = useState(false)
const [oauthNewUser] = useQueryState(
'oauth_new_user',
parseAsBoolean.withOptions({ history: 'replace' }),
@@ -86,7 +87,10 @@ export const AppInitializer = ({
const redirectUrl = resolvePostLoginRedirect()
if (redirectUrl) {
location.replace(redirectUrl)
return
}
setInit(true)
}
catch {
router.replace('/signin')
@@ -94,5 +98,5 @@ export const AppInitializer = ({
})()
}, [isSetupFinished, router, pathname, searchParams, oauthNewUser])
return children
return init ? children : null
}

View File

@@ -165,6 +165,21 @@ describe('AppDetailNav', () => {
)
expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument()
})
it('should render custom header and navigation when provided', () => {
render(
<AppDetailNav
navigation={navigation}
renderHeader={mode => <div data-testid="custom-header" data-mode={mode} />}
renderNavigation={mode => <div data-testid="custom-navigation" data-mode={mode} />}
/>,
)
expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand')
expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand')
expect(screen.queryByTestId('app-info')).not.toBeInTheDocument()
expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument()
})
})
describe('Workflow canvas mode', () => {

View File

@@ -2,7 +2,7 @@ import type { App, AppSSO } from '@/types/app'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { AppModeEnum } from '@/types/app'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import AppInfoDetailPanel from '../app-info-detail-panel'
vi.mock('../../../base/app-icon', () => ({
@@ -135,6 +135,17 @@ describe('AppInfoDetailPanel', () => {
expect(cardView).toHaveAttribute('data-app-id', 'app-1')
})
it('should not render CardView when app type is evaluation', () => {
render(
<AppInfoDetailPanel
{...defaultProps}
appDetail={createAppDetail({ type: AppTypeEnum.EVALUATION })}
/>,
)
expect(screen.queryByTestId('card-view')).not.toBeInTheDocument()
})
it('should render app icon with large size', () => {
render(<AppInfoDetailPanel {...defaultProps} />)
const icon = screen.getByTestId('app-icon')

View File

@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import Button from '@/app/components/base/button'
import ContentDialog from '@/app/components/base/content-dialog'
import { AppModeEnum } from '@/types/app'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import AppIcon from '../../base/app-icon'
import { getAppModeLabel } from './app-mode-labels'
import AppOperations from './app-operations'
@@ -97,7 +97,7 @@ const AppInfoDetailPanel = ({
<ContentDialog
show={show}
onClose={onClose}
className="absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl p-0!"
className="absolute top-2 bottom-2 left-2 flex w-[420px] flex-col rounded-2xl p-0!"
>
<div className="flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4">
<div className="flex items-center gap-3 self-stretch">
@@ -109,14 +109,14 @@ const AppInfoDetailPanel = ({
imageUrl={appDetail.icon_url}
/>
<div className="flex flex-1 flex-col items-start justify-center overflow-hidden">
<div className="w-full truncate text-text-secondary system-md-semibold">{appDetail.name}</div>
<div className="text-text-tertiary system-2xs-medium-uppercase">
<div className="w-full truncate system-md-semibold text-text-secondary">{appDetail.name}</div>
<div className="system-2xs-medium-uppercase text-text-tertiary">
{getAppModeLabel(appDetail.mode, t)}
</div>
</div>
</div>
{appDetail.description && (
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal wrap-break-word text-text-tertiary system-xs-regular">
<div className="overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto system-xs-regular wrap-break-word whitespace-normal text-text-tertiary">
{appDetail.description}
</div>
)}
@@ -126,11 +126,13 @@ const AppInfoDetailPanel = ({
secondaryOperations={secondaryOperations}
/>
</div>
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
{appDetail.type !== AppTypeEnum.EVALUATION && (
<CardView
appId={appDetail.id}
isInPanel={true}
className="flex flex-1 flex-col gap-2 overflow-auto px-2 py-1"
/>
)}
{switchOperation && (
<div className="flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2">
<Button
@@ -140,7 +142,7 @@ const AppInfoDetailPanel = ({
onClick={switchOperation.onClick}
>
{switchOperation.icon}
<span className="text-text-tertiary system-sm-medium">{switchOperation.title}</span>
<span className="system-sm-medium text-text-tertiary">{switchOperation.title}</span>
</Button>
</div>
)}

View File

@@ -5,7 +5,7 @@ import AppInfoModals from './app-info-modals'
import AppInfoTrigger from './app-info-trigger'
import { useAppInfoActions } from './use-app-info-actions'
export type IAppInfoProps = {
type IAppInfoProps = {
expand: boolean
onlyShowDetail?: boolean
openState?: boolean

View File

@@ -7,7 +7,7 @@ import {
import Tooltip from '@/app/components/base/tooltip'
import AppIcon from '../base/app-icon'
export type IAppBasicProps = {
type IAppBasicProps = {
iconType?: 'app' | 'api' | 'dataset' | 'webapp' | 'notion'
icon?: string
icon_background?: string | null

View File

@@ -17,7 +17,7 @@ import DatasetSidebarDropdown from './dataset-sidebar-dropdown'
import NavLink from './nav-link'
import ToggleButton from './toggle-button'
export type IAppDetailNavProps = {
type IAppDetailNavProps = {
iconType?: 'app' | 'dataset'
navigation: Array<{
name: string
@@ -27,12 +27,16 @@ export type IAppDetailNavProps = {
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
renderHeader?: (modeState: string) => React.ReactNode
renderNavigation?: (modeState: string) => React.ReactNode
}
const AppDetailNav = ({
navigation,
extraInfo,
iconType = 'app',
renderHeader,
renderNavigation,
}: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
@@ -104,10 +108,11 @@ const AppDetailNav = ({
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
{renderHeader?.(appSidebarExpand)}
{!renderHeader && iconType === 'app' && (
<AppInfo expand={expand} />
)}
{iconType !== 'app' && (
{!renderHeader && iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
@@ -136,7 +141,8 @@ const AppDetailNav = ({
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
{renderNavigation?.(appSidebarExpand)}
{!renderNavigation && navigation.map((item, index) => {
return (
<NavLink
key={index}

View File

@@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => {
expect(iconWrapper).toHaveClass('-ml-1')
})
})
describe('Button Mode', () => {
it('should render as an interactive button when href is omitted', () => {
const onClick = vi.fn()
render(<NavLink {...mockProps} href={undefined} active={true} onClick={onClick} />)
const buttonElement = screen.getByText('Orchestrate').closest('button')
expect(buttonElement).not.toBeNull()
expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active')
expect(buttonElement).toHaveClass('text-text-accent-light-mode-only')
buttonElement?.click()
expect(onClick).toHaveBeenCalledTimes(1)
})
})
})

View File

@@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType<
export type NavLinkProps = {
name: string
href: string
href?: string
iconMap: {
selected: NavIcon
normal: NavIcon
}
mode?: string
disabled?: boolean
active?: boolean
onClick?: () => void
}
const NavLink = ({
@@ -29,6 +31,8 @@ const NavLink = ({
iconMap,
mode = 'expand',
disabled = false,
active,
onClick,
}: NavLinkProps) => {
const segment = useSelectedLayoutSegment()
const formattedSegment = (() => {
@@ -39,8 +43,11 @@ const NavLink = ({
return res
})()
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const linkClassName = cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
const renderIcon = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>
@@ -70,13 +77,32 @@ const NavLink = ({
)
}
if (!href) {
return (
<button
key={name}
type="button"
className={linkClassName}
title={mode === 'collapse' ? name : ''}
onClick={onClick}
>
{renderIcon()}
<span
className={cn('overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out', mode === 'expand'
? 'ml-2 max-w-none opacity-100'
: 'ml-0 max-w-0 opacity-0')}
>
{name}
</span>
</button>
)
}
return (
<Link
key={name}
href={href}
className={cn(isActive
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')}
className={linkClassName}
title={mode === 'collapse' ? name : ''}
>
{renderIcon()}

View File

@@ -0,0 +1,285 @@
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { CreateSnippetDialogPayload } from '@/app/components/workflow/create-snippet-dialog'
import type { SnippetDetail } from '@/models/snippet'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import SnippetInfoDropdown from '../dropdown'
const mockReplace = vi.fn()
const mockDownloadBlob = vi.fn()
const mockToastSuccess = vi.fn()
const mockToastError = vi.fn()
const mockUpdateMutate = vi.fn()
const mockExportMutateAsync = vi.fn()
const mockDeleteMutate = vi.fn()
let mockDropdownOpen = false
let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
replace: mockReplace,
}),
}))
vi.mock('@/utils/download', () => ({
downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
success: (...args: unknown[]) => mockToastSuccess(...args),
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@/app/components/base/ui/dropdown-menu', () => ({
DropdownMenu: ({
open,
onOpenChange,
children,
}: {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
}) => {
mockDropdownOpen = !!open
mockDropdownOnOpenChange = onOpenChange
return <div>{children}</div>
},
DropdownMenuTrigger: ({
children,
className,
}: {
children: React.ReactNode
className?: string
}) => (
<button
type="button"
className={className}
onClick={() => mockDropdownOnOpenChange?.(!mockDropdownOpen)}
>
{children}
</button>
),
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => (
mockDropdownOpen ? <div>{children}</div> : null
),
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode
onClick?: () => void
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
}))
vi.mock('@/service/use-snippets', () => ({
useUpdateSnippetMutation: () => ({
mutate: mockUpdateMutate,
isPending: false,
}),
useExportSnippetMutation: () => ({
mutateAsync: mockExportMutateAsync,
isPending: false,
}),
useDeleteSnippetMutation: () => ({
mutate: mockDeleteMutate,
isPending: false,
}),
}))
type MockCreateSnippetDialogProps = {
isOpen: boolean
title?: string
confirmText?: string
initialValue?: {
name?: string
description?: string
icon?: AppIconSelection
}
onClose: () => void
onConfirm: (payload: CreateSnippetDialogPayload) => void
}
vi.mock('@/app/components/workflow/create-snippet-dialog', () => ({
default: ({
isOpen,
title,
confirmText,
initialValue,
onClose,
onConfirm,
}: MockCreateSnippetDialogProps) => {
if (!isOpen)
return null
return (
<div data-testid="create-snippet-dialog">
<div>{title}</div>
<div>{confirmText}</div>
<div>{initialValue?.name}</div>
<div>{initialValue?.description}</div>
<button
type="button"
onClick={() => onConfirm({
name: 'Updated snippet',
description: 'Updated description',
icon: {
type: 'emoji',
icon: '✨',
background: '#FFFFFF',
},
graph: {
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
},
})}
>
submit-edit
</button>
<button type="button" onClick={onClose}>close-edit</button>
</div>
)
},
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
author: 'Dify',
updatedAt: '2026-03-25 10:00',
usage: '12',
icon: '🤖',
iconBackground: '#F0FDF9',
status: undefined,
}
describe('SnippetInfoDropdown', () => {
beforeEach(() => {
vi.clearAllMocks()
mockDropdownOpen = false
mockDropdownOnOpenChange = undefined
})
// Rendering coverage for the menu trigger itself.
describe('Rendering', () => {
it('should render the dropdown trigger button', () => {
render(<SnippetInfoDropdown snippet={mockSnippet} />)
expect(screen.getByRole('button')).toBeInTheDocument()
})
})
// Edit flow should seed the dialog with current snippet info and submit updates.
describe('Edit Snippet', () => {
it('should open the edit dialog and submit snippet updates', async () => {
const user = userEvent.setup()
mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.editInfo'))
expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument()
expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument()
expect(screen.getByText('common.operation.save')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'submit-edit' }))
expect(mockUpdateMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
body: {
name: 'Updated snippet',
description: 'Updated description',
icon_info: {
icon: '✨',
icon_type: 'emoji',
icon_background: '#FFFFFF',
icon_url: undefined,
},
},
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone')
})
})
// Export should call the export hook and download the returned YAML blob.
describe('Export Snippet', () => {
it('should export and download the snippet yaml', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockResolvedValue('yaml: content')
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id })
})
expect(mockDownloadBlob).toHaveBeenCalledWith({
data: expect.any(Blob),
fileName: `${mockSnippet.name}.yml`,
})
})
it('should show an error toast when export fails', async () => {
const user = userEvent.setup()
mockExportMutateAsync.mockRejectedValue(new Error('export failed'))
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.exportSnippet'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed')
})
})
})
// Delete should require confirmation and redirect after a successful mutation.
describe('Delete Snippet', () => {
it('should confirm deletion and redirect to the snippets list', async () => {
const user = userEvent.setup()
mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => {
options?.onSuccess?.()
})
render(<SnippetInfoDropdown snippet={mockSnippet} />)
await user.click(screen.getByRole('button'))
await user.click(screen.getByText('snippet.menu.deleteSnippet'))
expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument()
expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' }))
expect(mockDeleteMutate).toHaveBeenCalledWith({
params: { snippetId: mockSnippet.id },
}, expect.objectContaining({
onSuccess: expect.any(Function),
onError: expect.any(Function),
}))
expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted')
expect(mockReplace).toHaveBeenCalledWith('/snippets')
})
})
})

View File

@@ -0,0 +1,62 @@
import type { SnippetDetail } from '@/models/snippet'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import SnippetInfo from '..'
vi.mock('../dropdown', () => ({
default: () => <div data-testid="snippet-info-dropdown" />,
}))
const mockSnippet: SnippetDetail = {
id: 'snippet-1',
name: 'Social Media Repurposer',
description: 'Turn one blog post into multiple social media variations.',
author: 'Dify',
updatedAt: '2026-03-25 10:00',
usage: '12',
icon: '🤖',
iconBackground: '#F0FDF9',
status: undefined,
}
describe('SnippetInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
})
// Rendering tests for the collapsed and expanded sidebar header states.
describe('Rendering', () => {
it('should render the expanded snippet details and dropdown when expand is true', () => {
render(<SnippetInfo expand={true} snippet={mockSnippet} />)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument()
expect(screen.getByText(mockSnippet.description)).toBeInTheDocument()
expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument()
})
it('should hide the expanded-only content when expand is false', () => {
render(<SnippetInfo expand={false} snippet={mockSnippet} />)
expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument()
expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument()
})
})
// Edge cases around optional snippet fields should not break the header layout.
describe('Edge Cases', () => {
it('should omit the description block when the snippet has no description', () => {
render(
<SnippetInfo
expand={true}
snippet={{ ...mockSnippet, description: '' }}
/>,
)
expect(screen.getByText(mockSnippet.name)).toBeInTheDocument()
expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,197 @@
'use client'
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import {
AlertDialog,
AlertDialogActions,
AlertDialogCancelButton,
AlertDialogConfirmButton,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from '@/app/components/base/ui/alert-dialog'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/app/components/base/ui/dropdown-menu'
import { toast } from '@/app/components/base/ui/toast'
import CreateSnippetDialog from '@/app/components/workflow/create-snippet-dialog'
import { useRouter } from '@/next/navigation'
import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets'
import { cn } from '@/utils/classnames'
import { downloadBlob } from '@/utils/download'
type SnippetInfoDropdownProps = {
snippet: SnippetDetail
}
const FALLBACK_ICON: AppIconSelection = {
type: 'emoji',
icon: '🤖',
background: '#FFEAD5',
}
const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => {
const { t } = useTranslation('snippet')
const { replace } = useRouter()
const [open, setOpen] = React.useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false)
const updateSnippetMutation = useUpdateSnippetMutation()
const exportSnippetMutation = useExportSnippetMutation()
const deleteSnippetMutation = useDeleteSnippetMutation()
const initialValue = React.useMemo(() => ({
name: snippet.name,
description: snippet.description,
icon: snippet.icon
? {
type: 'emoji' as const,
icon: snippet.icon,
background: snippet.iconBackground || FALLBACK_ICON.background,
}
: FALLBACK_ICON,
}), [snippet.description, snippet.icon, snippet.iconBackground, snippet.name])
const handleOpenEditDialog = React.useCallback(() => {
setOpen(false)
setIsEditDialogOpen(true)
}, [])
const handleExportSnippet = React.useCallback(async () => {
setOpen(false)
try {
const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id })
const file = new Blob([data], { type: 'application/yaml' })
downloadBlob({ data: file, fileName: `${snippet.name}.yml` })
}
catch {
toast.error(t('exportFailed'))
}
}, [exportSnippetMutation, snippet.id, snippet.name, t])
const handleEditSnippet = React.useCallback(async ({ name, description, icon }: {
name: string
description: string
icon: AppIconSelection
}) => {
updateSnippetMutation.mutate({
params: { snippetId: snippet.id },
body: {
name,
description: description || undefined,
icon_info: {
icon: icon.type === 'emoji' ? icon.icon : icon.fileId,
icon_type: icon.type,
icon_background: icon.type === 'emoji' ? icon.background : undefined,
icon_url: icon.type === 'image' ? icon.url : undefined,
},
},
}, {
onSuccess: () => {
toast.success(t('editDone'))
setIsEditDialogOpen(false)
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('editFailed'))
},
})
}, [snippet.id, t, updateSnippetMutation])
const handleDeleteSnippet = React.useCallback(() => {
deleteSnippetMutation.mutate({
params: { snippetId: snippet.id },
}, {
onSuccess: () => {
toast.success(t('deleted'))
setIsDeleteDialogOpen(false)
replace('/snippets')
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : t('deleteFailed'))
},
})
}, [deleteSnippetMutation, replace, snippet.id, t])
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn('action-btn action-btn-m size-6 rounded-md text-text-tertiary', open && 'bg-state-base-hover text-text-secondary')}
>
<span aria-hidden className="i-ri-more-fill size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
placement="bottom-end"
sideOffset={4}
popupClassName="w-[180px] p-1"
>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleOpenEditDialog}>
<span aria-hidden className="i-ri-edit-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.editInfo')}</span>
</DropdownMenuItem>
<DropdownMenuItem className="mx-0 gap-2" onClick={handleExportSnippet}>
<span aria-hidden className="i-ri-download-2-line size-4 shrink-0 text-text-tertiary" />
<span className="grow">{t('menu.exportSnippet')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator className="!my-1 bg-divider-subtle" />
<DropdownMenuItem
className="mx-0 gap-2"
destructive
onClick={() => {
setOpen(false)
setIsDeleteDialogOpen(true)
}}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
<span className="grow">{t('menu.deleteSnippet')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{isEditDialogOpen && (
<CreateSnippetDialog
isOpen={isEditDialogOpen}
initialValue={initialValue}
title={t('editDialogTitle')}
confirmText={t('operation.save', { ns: 'common' })}
isSubmitting={updateSnippetMutation.isPending}
onClose={() => setIsEditDialogOpen(false)}
onConfirm={handleEditSnippet}
/>
)}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent className="w-[400px]">
<div className="space-y-2 p-6">
<AlertDialogTitle className="text-text-primary title-lg-semi-bold">
{t('deleteConfirmTitle')}
</AlertDialogTitle>
<AlertDialogDescription className="text-text-tertiary system-sm-regular">
{t('deleteConfirmContent')}
</AlertDialogDescription>
</div>
<AlertDialogActions className="pt-0">
<AlertDialogCancelButton>
{t('operation.cancel', { ns: 'common' })}
</AlertDialogCancelButton>
<AlertDialogConfirmButton
loading={deleteSnippetMutation.isPending}
onClick={handleDeleteSnippet}
>
{t('menu.deleteSnippet')}
</AlertDialogConfirmButton>
</AlertDialogActions>
</AlertDialogContent>
</AlertDialog>
</>
)
}
export default React.memo(SnippetInfoDropdown)

View File

@@ -0,0 +1,55 @@
'use client'
import type { SnippetDetail } from '@/models/snippet'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '@/app/components/base/app-icon'
import { cn } from '@/utils/classnames'
import SnippetInfoDropdown from './dropdown'
type SnippetInfoProps = {
expand: boolean
snippet: SnippetDetail
}
const SnippetInfo = ({
expand,
snippet,
}: SnippetInfoProps) => {
const { t } = useTranslation('snippet')
return (
<div className={cn('flex flex-col', expand ? 'px-2 pb-1 pt-2' : 'p-1')}>
<div className={cn('flex flex-col', expand ? 'gap-2 rounded-xl p-2' : '')}>
<div className={cn('flex', expand ? 'items-center justify-between' : 'items-start gap-3')}>
<div className={cn('shrink-0', !expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType="emoji"
icon={snippet.icon}
background={snippet.iconBackground}
/>
</div>
{expand && <SnippetInfoDropdown snippet={snippet} />}
</div>
{expand && (
<div className="min-w-0">
<div className="truncate text-text-secondary system-md-semibold">
{snippet.name}
</div>
<div className="pt-1 text-text-tertiary system-2xs-medium-uppercase">
{t('typeLabel')}
</div>
</div>
)}
{expand && snippet.description && (
<p className="line-clamp-3 break-words text-text-tertiary system-xs-regular">
{snippet.description}
</p>
)}
</div>
</div>
)
}
export default React.memo(SnippetInfo)

View File

@@ -0,0 +1,69 @@
import { useStore } from '../store'
const resetStore = () => {
useStore.setState({
appDetail: undefined,
appSidebarExpand: '',
currentLogItem: undefined,
currentLogModalActiveTab: 'DETAIL',
showPromptLogModal: false,
showAgentLogModal: false,
showMessageLogModal: false,
showAppConfigureFeaturesModal: false,
})
}
describe('app store', () => {
beforeEach(() => {
resetStore()
})
it('should expose the default state', () => {
expect(useStore.getState()).toEqual(expect.objectContaining({
appDetail: undefined,
appSidebarExpand: '',
currentLogItem: undefined,
currentLogModalActiveTab: 'DETAIL',
showPromptLogModal: false,
showAgentLogModal: false,
showMessageLogModal: false,
showAppConfigureFeaturesModal: false,
}))
})
it('should update every mutable field through its actions', () => {
const appDetail = { id: 'app-1' } as ReturnType<typeof useStore.getState>['appDetail']
const currentLogItem = { id: 'message-1' } as ReturnType<typeof useStore.getState>['currentLogItem']
useStore.getState().setAppDetail(appDetail)
useStore.getState().setAppSidebarExpand('logs')
useStore.getState().setCurrentLogItem(currentLogItem)
useStore.getState().setCurrentLogModalActiveTab('MESSAGE')
useStore.getState().setShowPromptLogModal(true)
useStore.getState().setShowAgentLogModal(true)
useStore.getState().setShowAppConfigureFeaturesModal(true)
expect(useStore.getState()).toEqual(expect.objectContaining({
appDetail,
appSidebarExpand: 'logs',
currentLogItem,
currentLogModalActiveTab: 'MESSAGE',
showPromptLogModal: true,
showAgentLogModal: true,
showAppConfigureFeaturesModal: true,
}))
})
it('should reset the active tab when the message log modal closes', () => {
useStore.getState().setCurrentLogModalActiveTab('TRACE')
useStore.getState().setShowMessageLogModal(true)
expect(useStore.getState().showMessageLogModal).toBe(true)
expect(useStore.getState().currentLogModalActiveTab).toBe('TRACE')
useStore.getState().setShowMessageLogModal(false)
expect(useStore.getState().showMessageLogModal).toBe(false)
expect(useStore.getState().currentLogModalActiveTab).toBe('DETAIL')
})
})

View File

@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import BatchAction from './batch-action'
import BatchAction from '../batch-action'
describe('BatchAction', () => {
const baseProps = {

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import EmptyElement from './empty-element'
import EmptyElement from '../empty-element'
describe('EmptyElement', () => {
it('should render the empty state copy and supporting icon', () => {

View File

@@ -1,12 +1,12 @@
import type { UseQueryResult } from '@tanstack/react-query'
import type { Mock } from 'vitest'
import type { QueryParam } from './filter'
import type { QueryParam } from '../filter'
import type { AnnotationsCountResponse } from '@/models/log'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import * as useLogModule from '@/service/use-log'
import Filter from './filter'
import Filter from '../filter'
vi.mock('@/service/use-log')

View File

@@ -1,5 +1,6 @@
/* eslint-disable ts/no-explicit-any */
import type { Mock } from 'vitest'
import type { AnnotationItem } from './type'
import type { AnnotationItem } from '../type'
import type { App } from '@/types/app'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
@@ -9,13 +10,16 @@ import {
addAnnotation,
delAnnotation,
delAnnotations,
editAnnotation,
fetchAnnotationConfig,
fetchAnnotationList,
queryAnnotationJobStatus,
updateAnnotationScore,
updateAnnotationStatus,
} from '@/service/annotation'
import { AppModeEnum } from '@/types/app'
import Annotation from './index'
import { JobStatus } from './type'
import Annotation from '../index'
import { AnnotationEnableStatus, JobStatus } from '../type'
vi.mock('ahooks', () => ({
useDebounce: (value: any) => value,
@@ -37,29 +41,32 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('./filter', () => ({
vi.mock('../filter', () => ({
default: ({ children }: { children: React.ReactNode }) => (
<div data-testid="filter">{children}</div>
),
}))
vi.mock('./empty-element', () => ({
vi.mock('../empty-element', () => ({
default: () => <div data-testid="empty-element" />,
}))
vi.mock('./header-opts', () => ({
vi.mock('../header-opts', () => ({
default: (props: any) => (
<div data-testid="header-opts">
<button data-testid="trigger-add" onClick={() => props.onAdd({ question: 'new question', answer: 'new answer' })}>
add
</button>
<button data-testid="trigger-added" onClick={() => props.onAdded()}>
added
</button>
</div>
),
}))
let latestListProps: any
vi.mock('./list', () => ({
vi.mock('../list', () => ({
default: (props: any) => {
latestListProps = props
if (!props.list.length)
@@ -74,7 +81,7 @@ vi.mock('./list', () => ({
},
}))
vi.mock('./view-annotation-modal', () => ({
vi.mock('../view-annotation-modal', () => ({
default: (props: any) => {
if (!props.isShow)
return null
@@ -82,14 +89,40 @@ vi.mock('./view-annotation-modal', () => ({
<div data-testid="view-modal">
<div>{props.item.question}</div>
<button data-testid="view-modal-remove" onClick={props.onRemove}>remove</button>
<button data-testid="view-modal-save" onClick={() => props.onSave('Edited question', 'Edited answer')}>save</button>
<button data-testid="view-modal-close" onClick={props.onHide}>close</button>
</div>
)
},
}))
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => ({ default: (props: any) => props.isShow ? <div data-testid="config-modal" /> : null }))
vi.mock('@/app/components/billing/annotation-full/modal', () => ({ default: (props: any) => props.show ? <div data-testid="annotation-full-modal" /> : null }))
vi.mock('@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal', () => ({
default: (props: any) => props.isShow
? (
<div data-testid="config-modal">
<button
data-testid="config-save"
onClick={() => props.onSave({
embedding_model_name: 'next-model',
embedding_provider_name: 'next-provider',
}, 0.7)}
>
save-config
</button>
<button data-testid="config-hide" onClick={props.onHide}>hide-config</button>
</div>
)
: null,
}))
vi.mock('@/app/components/billing/annotation-full/modal', () => ({
default: (props: any) => props.show
? (
<div data-testid="annotation-full-modal">
<button data-testid="hide-annotation-full-modal" onClick={props.onHide}>hide-full</button>
</div>
)
: null,
}))
const mockNotify = vi.fn()
vi.spyOn(toast, 'success').mockImplementation((message, options) => {
@@ -111,9 +144,12 @@ vi.spyOn(toast, 'info').mockImplementation((message, options) => {
const addAnnotationMock = addAnnotation as Mock
const delAnnotationMock = delAnnotation as Mock
const delAnnotationsMock = delAnnotations as Mock
const editAnnotationMock = editAnnotation as Mock
const fetchAnnotationConfigMock = fetchAnnotationConfig as Mock
const fetchAnnotationListMock = fetchAnnotationList as Mock
const queryAnnotationJobStatusMock = queryAnnotationJobStatus as Mock
const updateAnnotationScoreMock = updateAnnotationScore as Mock
const updateAnnotationStatusMock = updateAnnotationStatus as Mock
const useProviderContextMock = useProviderContext as Mock
const appDetail = {
@@ -146,6 +182,9 @@ describe('Annotation', () => {
})
fetchAnnotationListMock.mockResolvedValue({ data: [], total: 0 })
queryAnnotationJobStatusMock.mockResolvedValue({ job_status: JobStatus.completed })
updateAnnotationStatusMock.mockResolvedValue({ job_id: 'job-1' })
updateAnnotationScoreMock.mockResolvedValue(undefined)
editAnnotationMock.mockResolvedValue(undefined)
useProviderContextMock.mockReturnValue({
plan: {
usage: { annotatedResponse: 0 },
@@ -251,4 +290,166 @@ describe('Annotation', () => {
expect(latestListProps.selectedIds).toEqual([annotation.id])
})
})
it('should show the annotation-full modal when enabling annotations exceeds the plan quota', async () => {
useProviderContextMock.mockReturnValue({
plan: {
usage: { annotatedResponse: 10 },
total: { annotatedResponse: 10 },
},
enableBilling: true,
})
renderComponent()
const toggle = await screen.findByRole('switch')
fireEvent.click(toggle)
expect(screen.getByTestId('annotation-full-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('hide-annotation-full-modal'))
expect(screen.queryByTestId('annotation-full-modal')).not.toBeInTheDocument()
})
it('should disable annotations and refetch config after the async job completes', async () => {
fetchAnnotationConfigMock.mockResolvedValueOnce({
id: 'config-id',
enabled: true,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
},
score_threshold: 0.5,
}).mockResolvedValueOnce({
id: 'config-id',
enabled: false,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
},
score_threshold: 0.5,
})
renderComponent()
const toggle = await screen.findByRole('switch')
await waitFor(() => {
expect(toggle).toHaveAttribute('aria-checked', 'true')
})
fireEvent.click(toggle)
await waitFor(() => {
expect(updateAnnotationStatusMock).toHaveBeenCalledWith(
appDetail.id,
AnnotationEnableStatus.disable,
expect.objectContaining({
embedding_model_name: 'model',
embedding_provider_name: 'provider',
}),
0.5,
)
expect(queryAnnotationJobStatusMock).toHaveBeenCalledWith(appDetail.id, AnnotationEnableStatus.disable, 'job-1')
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
message: 'common.api.actionSuccess',
type: 'success',
}))
})
})
it('should save annotation config changes and update the score when the modal confirms', async () => {
fetchAnnotationConfigMock.mockResolvedValue({
id: 'config-id',
enabled: false,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
},
score_threshold: 0.5,
})
renderComponent()
const toggle = await screen.findByRole('switch')
fireEvent.click(toggle)
expect(screen.getByTestId('config-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('config-save'))
await waitFor(() => {
expect(updateAnnotationStatusMock).toHaveBeenCalledWith(
appDetail.id,
AnnotationEnableStatus.enable,
{
embedding_model_name: 'next-model',
embedding_provider_name: 'next-provider',
},
0.7,
)
expect(updateAnnotationScoreMock).toHaveBeenCalledWith(appDetail.id, 'config-id', 0.7)
expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({
message: 'common.api.actionSuccess',
type: 'success',
}))
})
})
it('should refresh the list from the header shortcut and allow saving or closing the view modal', async () => {
const annotation = createAnnotation()
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
renderComponent()
await screen.findByTestId('list')
fireEvent.click(screen.getByTestId('list-view'))
fireEvent.click(screen.getByTestId('view-modal-save'))
await waitFor(() => {
expect(editAnnotationMock).toHaveBeenCalledWith(appDetail.id, annotation.id, {
question: 'Edited question',
answer: 'Edited answer',
})
})
fireEvent.click(screen.getByTestId('view-modal-close'))
expect(screen.queryByTestId('view-modal')).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId('trigger-added'))
expect(fetchAnnotationListMock).toHaveBeenCalled()
})
it('should clear selections on cancel and hide the config modal when requested', async () => {
const annotation = createAnnotation()
fetchAnnotationConfigMock.mockResolvedValue({
id: 'config-id',
enabled: true,
embedding_model: {
embedding_model_name: 'model',
embedding_provider_name: 'provider',
},
score_threshold: 0.5,
})
fetchAnnotationListMock.mockResolvedValue({ data: [annotation], total: 1 })
renderComponent()
await screen.findByTestId('list')
await act(async () => {
latestListProps.onSelectedIdsChange([annotation.id])
})
await act(async () => {
latestListProps.onCancel()
})
expect(latestListProps.selectedIds).toEqual([])
const configButton = document.querySelector('.action-btn') as HTMLButtonElement
fireEvent.click(configButton)
expect(await screen.findByTestId('config-modal')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('config-hide'))
expect(screen.queryByTestId('config-modal')).not.toBeInTheDocument()
})
})

View File

@@ -1,7 +1,7 @@
import type { AnnotationItem } from './type'
import type { AnnotationItem } from '../type'
import { fireEvent, render, screen, within } from '@testing-library/react'
import * as React from 'react'
import List from './list'
import List from '../list'
const mockFormatTime = vi.fn(() => 'formatted-time')

View File

@@ -2,7 +2,7 @@ import type { Mock } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useProviderContext } from '@/context/provider-context'
import AddAnnotationModal from './index'
import AddAnnotationModal from '../index'
vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import EditItem, { EditItemType } from './index'
import EditItem, { EditItemType } from '../index'
describe('AddAnnotationModal/EditItem', () => {
it('should render query inputs with user avatar and placeholder strings', () => {

View File

@@ -1,10 +1,11 @@
/* eslint-disable ts/no-explicit-any */
import type { Mock } from 'vitest'
import type { Locale } from '@/i18n-config'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import CSVDownload from './csv-downloader'
import CSVDownload from '../csv-downloader'
const downloaderProps: any[] = []

View File

@@ -1,7 +1,7 @@
import type { Props } from './csv-uploader'
import type { Props } from '../csv-uploader'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import CSVUploader from './csv-uploader'
import CSVUploader from '../csv-uploader'
const toastMocks = vi.hoisted(() => ({
notify: vi.fn(),
@@ -75,6 +75,20 @@ describe('CSVUploader', () => {
expect(dropZone.className).not.toContain('border-components-dropzone-border-accent')
})
it('should handle drag over and clear dragging state when leaving through the overlay', () => {
renderComponent()
const { dropZone, dropContainer } = getDropElements()
fireEvent.dragEnter(dropContainer)
const dragLayer = dropContainer.querySelector('.absolute') as HTMLDivElement
fireEvent.dragOver(dropContainer)
fireEvent.dragLeave(dragLayer)
expect(dropZone.className).not.toContain('border-components-dropzone-border-accent')
expect(dropZone.className).not.toContain('bg-components-dropzone-bg-accent')
})
it('should ignore drop events without dataTransfer', () => {
renderComponent()
const { dropContainer } = getDropElements()

View File

@@ -1,10 +1,10 @@
import type { Mock } from 'vitest'
import type { IBatchModalProps } from './index'
import type { IBatchModalProps } from '../index'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { useProviderContext } from '@/context/provider-context'
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
import BatchModal, { ProcessStatus } from './index'
import BatchModal, { ProcessStatus } from '../index'
vi.mock('@/service/annotation', () => ({
annotationBatchImport: vi.fn(),
@@ -15,13 +15,13 @@ vi.mock('@/context/provider-context', () => ({
useProviderContext: vi.fn(),
}))
vi.mock('./csv-downloader', () => ({
vi.mock('../csv-downloader', () => ({
default: () => <div data-testid="csv-downloader-stub" />,
}))
let lastUploadedFile: File | undefined
vi.mock('./csv-uploader', () => ({
vi.mock('../csv-uploader', () => ({
default: ({ file, updateFile }: { file?: File, updateFile: (file?: File) => void }) => (
<div>
<button

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import ClearAllAnnotationsConfirmModal from './index'
import ClearAllAnnotationsConfirmModal from '../index'
vi.mock('react-i18next', () => ({
useTranslation: () => ({

View File

@@ -1,7 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { toast } from '@/app/components/base/ui/toast'
import EditAnnotationModal from './index'
import EditAnnotationModal from '../index'
const { mockAddAnnotation, mockEditAnnotation } = vi.hoisted(() => ({
mockAddAnnotation: vi.fn(),
@@ -51,11 +51,6 @@ describe('EditAnnotationModal', () => {
onRemove: vi.fn(),
}
afterAll(() => {
toastSuccessSpy.mockRestore()
toastErrorSpy.mockRestore()
})
beforeEach(() => {
vi.clearAllMocks()
mockAddAnnotation.mockResolvedValue({
@@ -65,6 +60,11 @@ describe('EditAnnotationModal', () => {
mockEditAnnotation.mockResolvedValue({})
})
afterAll(() => {
toastSuccessSpy.mockRestore()
toastErrorSpy.mockRestore()
})
// Rendering tests (REQUIRED)
describe('Rendering', () => {
it('should render modal when isShow is true', () => {

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import EditItem, { EditItemType, EditTitle } from './index'
import EditItem, { EditItemType, EditTitle } from '../index'
describe('EditTitle', () => {
it('should render title content correctly', () => {

View File

@@ -1,6 +1,7 @@
/* eslint-disable ts/no-explicit-any */
import type { ComponentProps } from 'react'
import type { Mock } from 'vitest'
import type { AnnotationItemBasic } from '../type'
import type { AnnotationItemBasic } from '../../type'
import type { Locale } from '@/i18n-config'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
@@ -8,7 +9,7 @@ import * as React from 'react'
import { useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { clearAllAnnotations, fetchExportAnnotationList } from '@/service/annotation'
import HeaderOptions from './index'
import HeaderOptions from '../index'
vi.mock('@headlessui/react', () => {
type PopoverContextValue = { open: boolean, setOpen: (open: boolean) => void }

View File

@@ -1,6 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import RemoveAnnotationConfirmModal from './index'
import RemoveAnnotationConfirmModal from '../index'
vi.mock('react-i18next', () => ({
useTranslation: () => ({

View File

@@ -39,7 +39,5 @@ export enum AnnotationEnableStatus {
}
export enum JobStatus {
waiting = 'waiting',
processing = 'processing',
completed = 'completed',
}

View File

@@ -1,9 +1,9 @@
import type { Mock } from 'vitest'
import type { AnnotationItem, HitHistoryItem } from '../type'
import type { AnnotationItem, HitHistoryItem } from '../../type'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { fetchHitHistoryList } from '@/service/annotation'
import ViewAnnotationModal from './index'
import ViewAnnotationModal from '../index'
const mockFormatTime = vi.fn(() => 'formatted-time')
@@ -17,7 +17,7 @@ vi.mock('@/service/annotation', () => ({
fetchHitHistoryList: vi.fn(),
}))
vi.mock('../edit-annotation-modal/edit-item', () => {
vi.mock('../../edit-annotation-modal/edit-item', () => {
const EditItemType = {
Query: 'query',
Answer: 'answer',

View File

@@ -1,3 +1,4 @@
/* eslint-disable ts/no-explicit-any */
import type { AccessControlAccount, AccessControlGroup, Subject } from '@/models/access-control'
import type { App } from '@/types/app'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
@@ -5,11 +6,11 @@ import userEvent from '@testing-library/user-event'
import { toast } from '@/app/components/base/ui/toast'
import useAccessControlStore from '@/context/access-control-store'
import { AccessMode, SubjectType } from '@/models/access-control'
import AccessControlDialog from './access-control-dialog'
import AccessControlItem from './access-control-item'
import AddMemberOrGroupDialog from './add-member-or-group-pop'
import AccessControl from './index'
import SpecificGroupsOrMembers from './specific-groups-or-members'
import AccessControlDialog from '../access-control-dialog'
import AccessControlItem from '../access-control-item'
import AddMemberOrGroupDialog from '../add-member-or-group-pop'
import AccessControl from '../index'
import SpecificGroupsOrMembers from '../specific-groups-or-members'
const mockUseAppWhiteListSubjects = vi.fn()
const mockUseSearchForWhiteListCandidates = vi.fn()
@@ -18,6 +19,9 @@ const mockUseUpdateAccessMode = vi.fn(() => ({
isPending: false,
mutateAsync: mockMutateAsync,
}))
const intersectionObserverMocks = vi.hoisted(() => ({
callback: null as null | ((entries: Array<{ isIntersecting: boolean }>) => void),
}))
vi.mock('@/context/app-context', () => ({
useSelector: <T,>(selector: (value: { userProfile: { email: string, id?: string, name?: string, avatar?: string, avatar_url?: string, is_password_set?: boolean } }) => T) => selector({
@@ -105,6 +109,10 @@ const memberSubject: Subject = {
beforeAll(() => {
class MockIntersectionObserver {
constructor(callback: (entries: Array<{ isIntersecting: boolean }>) => void) {
intersectionObserverMocks.callback = callback
}
observe = vi.fn(() => undefined)
disconnect = vi.fn(() => undefined)
unobserve = vi.fn(() => undefined)
@@ -281,6 +289,39 @@ describe('AddMemberOrGroupDialog', () => {
expect(useAccessControlStore.getState().specificMembers).toEqual([baseMember])
})
it('should update the keyword, fetch the next page, and support deselection and breadcrumb reset', async () => {
const fetchNextPage = vi.fn()
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,
isFetchingNextPage: true,
fetchNextPage,
data: { pages: [{ currPage: 1, subjects: [groupSubject, memberSubject], hasMore: true }] },
})
const user = userEvent.setup()
render(<AddMemberOrGroupDialog />)
await user.click(screen.getByText('common.operation.add'))
await user.type(screen.getByPlaceholderText('app.accessControlDialog.operateGroupAndMember.searchPlaceholder'), 'Group')
expect(document.querySelector('.spin-animation')).toBeInTheDocument()
const groupCheckbox = screen.getByText(baseGroup.name).closest('div')?.previousElementSibling as HTMLElement
fireEvent.click(groupCheckbox)
fireEvent.click(groupCheckbox)
const memberCheckbox = screen.getByText(baseMember.name).parentElement?.previousElementSibling as HTMLElement
fireEvent.click(memberCheckbox)
fireEvent.click(memberCheckbox)
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.expand'))
fireEvent.click(screen.getByText('app.accessControlDialog.operateGroupAndMember.allMembers'))
expect(useAccessControlStore.getState().specificGroups).toEqual([])
expect(useAccessControlStore.getState().specificMembers).toEqual([])
expect(useAccessControlStore.getState().selectedGroupsForBreadcrumb).toEqual([])
expect(fetchNextPage).not.toHaveBeenCalled()
})
it('should show empty state when no candidates are returned', async () => {
mockUseSearchForWhiteListCandidates.mockReturnValue({
isLoading: false,

View File

@@ -0,0 +1,140 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import FeaturesWrappedAppPublisher from '../features-wrapper'
const mockSetFeatures = vi.fn()
const mockOnPublish = vi.fn()
const mockAppPublisherProps = vi.hoisted(() => ({
current: null as null | Record<string, any>,
}))
const mockFeatures = {
moreLikeThis: { enabled: false },
opening: { enabled: false, opening_statement: '', suggested_questions: [] as string[] },
moderation: { enabled: false },
speech2text: { enabled: false },
text2speech: { enabled: false },
suggested: { enabled: false },
citation: { enabled: false },
annotationReply: { enabled: false },
file: {
image: {
detail: 'high',
enabled: false,
number_limits: 3,
transfer_methods: ['local_file', 'remote_url'],
},
enabled: false,
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: ['local_file'],
number_limits: 3,
},
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/app/app-publisher', () => ({
default: (props: Record<string, any>) => {
mockAppPublisherProps.current = props
return (
<div>
<button onClick={() => props.onPublish?.({ id: 'model-1' })}>publish-through-wrapper</button>
<button onClick={() => props.onRestore?.()}>restore-through-wrapper</button>
</div>
)
},
}))
vi.mock('@/app/components/base/features/hooks', () => ({
useFeatures: (selector: (state: { features: typeof mockFeatures }) => unknown) => selector({ features: mockFeatures }),
useFeaturesStore: () => ({
getState: () => ({
features: mockFeatures,
setFeatures: mockSetFeatures,
}),
}),
}))
describe('FeaturesWrappedAppPublisher', () => {
const publishedConfig = {
modelConfig: {
more_like_this: { enabled: true },
opening_statement: 'Hello there',
suggested_questions: ['Q1'],
sensitive_word_avoidance: { enabled: true },
speech_to_text: { enabled: true },
text_to_speech: { enabled: true },
suggested_questions_after_answer: { enabled: true },
retriever_resource: { enabled: true },
annotation_reply: { enabled: true },
file_upload: {
enabled: true,
image: {
enabled: true,
detail: 'low',
number_limits: 5,
transfer_methods: ['remote_url'],
},
allowed_file_types: ['image'],
allowed_file_extensions: ['.jpg'],
allowed_file_upload_methods: ['remote_url'],
number_limits: 5,
},
resetAppConfig: vi.fn(),
},
}
beforeEach(() => {
vi.clearAllMocks()
mockAppPublisherProps.current = null
})
it('should pass current features through to onPublish', async () => {
render(
<FeaturesWrappedAppPublisher
publishedConfig={publishedConfig as any}
onPublish={mockOnPublish}
/>,
)
fireEvent.click(screen.getByText('publish-through-wrapper'))
await waitFor(() => {
expect(mockOnPublish).toHaveBeenCalledWith({ id: 'model-1' }, mockFeatures)
})
})
it('should restore published features after confirmation', async () => {
render(
<FeaturesWrappedAppPublisher
publishedConfig={publishedConfig as any}
/>,
)
fireEvent.click(screen.getByText('restore-through-wrapper'))
fireEvent.click(screen.getByRole('button', { name: 'operation.confirm' }))
await waitFor(() => {
expect(publishedConfig.modelConfig.resetAppConfig).toHaveBeenCalledTimes(1)
expect(mockSetFeatures).toHaveBeenCalledWith(expect.objectContaining({
moreLikeThis: { enabled: true },
opening: {
enabled: true,
opening_statement: 'Hello there',
suggested_questions: ['Q1'],
},
moderation: { enabled: true },
speech2text: { enabled: true },
text2speech: { enabled: true },
suggested: { enabled: true },
citation: { enabled: true },
annotationReply: { enabled: true },
}))
})
})
})

View File

@@ -0,0 +1,520 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import * as React from 'react'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import AppPublisher from '../index'
const mockOnPublish = vi.fn()
const mockOnToggle = vi.fn()
const mockSetAppDetail = vi.fn()
const mockTrackEvent = vi.fn()
const mockRefetch = vi.fn()
const mockOpenAsyncWindow = vi.fn()
const mockFetchInstalledAppList = vi.fn()
const mockFetchAppDetailDirect = vi.fn()
const mockToastError = vi.fn()
const mockConvertWorkflowType = vi.fn()
const sectionProps = vi.hoisted(() => ({
summary: null as null | Record<string, any>,
access: null as null | Record<string, any>,
actions: null as null | Record<string, any>,
}))
const ahooksMocks = vi.hoisted(() => ({
keyPressHandlers: [] as Array<(event: { preventDefault: () => void }) => void>,
}))
let mockAppDetail: Record<string, any> | null = null
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('ahooks', async () => {
return {
useKeyPress: (_keys: unknown, handler: (event: { preventDefault: () => void }) => void) => {
ahooksMocks.keyPressHandlers.push(handler)
},
}
})
vi.mock('@/app/components/app/store', () => ({
useStore: (selector: (state: { appDetail: Record<string, any> | null, setAppDetail: typeof mockSetAppDetail }) => unknown) => selector({
appDetail: mockAppDetail,
setAppDetail: mockSetAppDetail,
}),
}))
vi.mock('@/context/global-public-context', () => ({
useGlobalPublicStore: (selector: (state: { systemFeatures: { webapp_auth: { enabled: boolean } } }) => unknown) => selector({
systemFeatures: {
webapp_auth: {
enabled: true,
},
},
}),
}))
vi.mock('@/hooks/use-format-time-from-now', () => ({
useFormatTimeFromNow: () => ({
formatTimeFromNow: () => 'moments ago',
}),
}))
vi.mock('@/hooks/use-async-window-open', () => ({
useAsyncWindowOpen: () => mockOpenAsyncWindow,
}))
vi.mock('@/service/access-control', () => ({
useGetUserCanAccessApp: () => ({
data: { result: true },
isLoading: false,
refetch: mockRefetch,
}),
useAppWhiteListSubjects: () => ({
data: { groups: [], members: [] },
isLoading: false,
}),
}))
vi.mock('@/service/explore', () => ({
fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args),
}))
vi.mock('@/service/apps', () => ({
fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args),
}))
vi.mock('@/service/use-apps', () => ({
useConvertWorkflowTypeMutation: () => ({
mutateAsync: (...args: unknown[]) => mockConvertWorkflowType(...args),
isPending: false,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: (...args: unknown[]) => mockToastError(...args),
},
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/app/components/app/overview/embedded', () => ({
default: ({ isShow, onClose }: { isShow: boolean, onClose: () => void }) => (isShow
? (
<div data-testid="embedded-modal">
embedded modal
<button onClick={onClose}>close-embedded-modal</button>
</div>
)
: null),
}))
vi.mock('../../app-access-control', () => ({
default: ({ onConfirm, onClose }: { onConfirm: () => Promise<void>, onClose: () => void }) => (
<div data-testid="access-control">
<button onClick={() => void onConfirm()}>confirm-access-control</button>
<button onClick={onClose}>close-access-control</button>
</div>
),
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const ReactModule = await vi.importActual<typeof import('react')>('react')
const OpenContext = ReactModule.createContext(false)
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext value={open}>
<div>{children}</div>
</OpenContext>
),
PortalToFollowElemTrigger: ({ children, onClick }: { children: React.ReactNode, onClick?: () => void }) => (
<div onClick={onClick}>{children}</div>
),
PortalToFollowElemContent: ({ children }: { children: React.ReactNode }) => {
const open = ReactModule.use(OpenContext)
return open ? <div>{children}</div> : null
},
}
})
vi.mock('../sections', () => ({
PublisherSummarySection: (props: Record<string, any>) => {
sectionProps.summary = props
return (
<div>
<button onClick={() => void props.handlePublish()}>publisher-summary-publish</button>
<button onClick={() => void props.handleRestore()}>publisher-summary-restore</button>
<button onClick={() => void props.onWorkflowTypeSwitch()}>publisher-switch-workflow-type</button>
</div>
)
},
PublisherAccessSection: (props: Record<string, any>) => {
sectionProps.access = props
return <button onClick={props.onClick}>publisher-access-control</button>
},
PublisherActionsSection: (props: Record<string, any>) => {
sectionProps.actions = props
return (
<div>
<button onClick={props.handleEmbed}>publisher-embed</button>
<button onClick={() => void props.handleOpenInExplore()}>publisher-open-in-explore</button>
</div>
)
},
}))
describe('AppPublisher', () => {
beforeEach(() => {
vi.clearAllMocks()
ahooksMocks.keyPressHandlers.length = 0
sectionProps.summary = null
sectionProps.access = null
sectionProps.actions = null
mockAppDetail = {
id: 'app-1',
name: 'Demo App',
mode: AppModeEnum.CHAT,
access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS,
type: AppTypeEnum.WORKFLOW,
site: {
app_base_url: 'https://example.com',
access_token: 'token-1',
},
}
mockFetchInstalledAppList.mockResolvedValue({
installed_apps: [{ id: 'installed-1' }],
})
mockFetchAppDetailDirect.mockResolvedValue({
id: 'app-1',
access_mode: AccessMode.PUBLIC,
})
mockConvertWorkflowType.mockResolvedValue({})
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>) => {
await resolver()
})
})
it('should open the publish popover and refetch access permission data', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
onToggle={mockOnToggle}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
expect(mockOnToggle).toHaveBeenCalledWith(true)
await waitFor(() => {
expect(mockRefetch).toHaveBeenCalledTimes(1)
})
})
it('should publish and track the publish event', async () => {
mockOnPublish.mockResolvedValue(undefined)
render(
<AppPublisher
publishedAt={Date.now()}
onPublish={mockOnPublish}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-summary-publish'))
await waitFor(() => {
expect(mockOnPublish).toHaveBeenCalledTimes(1)
expect(mockTrackEvent).toHaveBeenCalledWith('app_published_time', expect.objectContaining({
action_mode: 'app',
app_id: 'app-1',
app_name: 'Demo App',
}))
})
})
it('should open the embedded modal from the actions section', () => {
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-embed'))
expect(screen.getByTestId('embedded-modal')).toBeInTheDocument()
})
it('should close embedded and access control panels through child callbacks', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-embed'))
fireEvent.click(screen.getByText('close-embedded-modal'))
expect(screen.queryByTestId('embedded-modal')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-access-control'))
expect(screen.getByTestId('access-control')).toBeInTheDocument()
fireEvent.click(screen.getByText('close-access-control'))
expect(screen.queryByTestId('access-control')).not.toBeInTheDocument()
})
it('should refresh app detail after access control confirmation', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-access-control'))
expect(screen.getByTestId('access-control')).toBeInTheDocument()
fireEvent.click(screen.getByText('confirm-access-control'))
await waitFor(() => {
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith({
id: 'app-1',
access_mode: AccessMode.PUBLIC,
})
})
})
it('should open the installed explore page through the async window helper', async () => {
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-open-in-explore'))
await waitFor(() => {
expect(mockOpenAsyncWindow).toHaveBeenCalledTimes(1)
expect(mockFetchInstalledAppList).toHaveBeenCalledWith('app-1')
expect(sectionProps.actions?.appURL).toBe(`https://example.com${basePath}/chat/token-1`)
})
})
it('should ignore the trigger when the publish button is disabled', () => {
render(
<AppPublisher
disabled
publishedAt={Date.now()}
onToggle={mockOnToggle}
/>,
)
fireEvent.click(screen.getByText('common.publish').parentElement?.parentElement as HTMLElement)
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
expect(mockOnToggle).not.toHaveBeenCalled()
})
it('should publish from the keyboard shortcut and restore the popover state', async () => {
const preventDefault = vi.fn()
const onRestore = vi.fn().mockResolvedValue(undefined)
mockOnPublish.mockResolvedValue(undefined)
render(
<AppPublisher
publishedAt={Date.now()}
onPublish={mockOnPublish}
onRestore={onRestore}
/>,
)
ahooksMocks.keyPressHandlers[0]({ preventDefault })
await waitFor(() => {
expect(preventDefault).toHaveBeenCalled()
expect(mockOnPublish).toHaveBeenCalledTimes(1)
})
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-summary-restore'))
await waitFor(() => {
expect(onRestore).toHaveBeenCalledTimes(1)
})
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
})
it('should keep the popover open when restore fails and reset published state after publish failures', async () => {
const preventDefault = vi.fn()
const onRestore = vi.fn().mockRejectedValue(new Error('restore failed'))
mockOnPublish.mockRejectedValueOnce(new Error('publish failed'))
render(
<AppPublisher
publishedAt={Date.now()}
onPublish={mockOnPublish}
onRestore={onRestore}
/>,
)
ahooksMocks.keyPressHandlers[0]({ preventDefault })
await waitFor(() => {
expect(preventDefault).toHaveBeenCalled()
expect(mockOnPublish).toHaveBeenCalledTimes(1)
})
expect(mockTrackEvent).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-summary-restore'))
await waitFor(() => {
expect(onRestore).toHaveBeenCalledTimes(1)
})
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
})
it('should report missing explore installations', async () => {
mockFetchInstalledAppList.mockResolvedValueOnce({
installed_apps: [],
})
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
try {
await resolver()
}
catch (error) {
options.onError(error as Error)
}
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-open-in-explore'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('No app found in Explore')
})
})
it('should report explore errors when the app cannot be opened', async () => {
mockAppDetail = {
...mockAppDetail,
id: undefined,
}
mockOpenAsyncWindow.mockImplementation(async (resolver: () => Promise<string>, options: { onError: (error: Error) => void }) => {
try {
await resolver()
}
catch (error) {
options.onError(error as Error)
}
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-open-in-explore'))
await waitFor(() => {
expect(mockToastError).toHaveBeenCalledWith('App not found')
})
})
it('should keep access control open when app detail is unavailable during confirmation', async () => {
mockAppDetail = null
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-access-control'))
fireEvent.click(screen.getByText('confirm-access-control'))
await waitFor(() => {
expect(mockFetchAppDetailDirect).not.toHaveBeenCalled()
})
expect(screen.getByTestId('access-control')).toBeInTheDocument()
})
it('should switch workflow type, refresh app detail, and close the popover for published apps', async () => {
mockFetchAppDetailDirect.mockResolvedValueOnce({
id: 'app-1',
type: AppTypeEnum.EVALUATION,
})
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
fireEvent.click(screen.getByText('publisher-switch-workflow-type'))
await waitFor(() => {
expect(mockConvertWorkflowType).toHaveBeenCalledWith({
params: { appId: 'app-1' },
query: { target_type: AppTypeEnum.EVALUATION },
})
expect(mockFetchAppDetailDirect).toHaveBeenCalledWith({ url: '/apps', id: 'app-1' })
expect(mockSetAppDetail).toHaveBeenCalledWith({
id: 'app-1',
type: AppTypeEnum.EVALUATION,
})
})
expect(screen.queryByText('publisher-summary-publish')).not.toBeInTheDocument()
})
it('should hide access and actions sections for evaluation workflow apps', () => {
mockAppDetail = {
...mockAppDetail,
type: AppTypeEnum.EVALUATION,
}
render(
<AppPublisher
publishedAt={Date.now()}
/>,
)
fireEvent.click(screen.getByText('common.publish'))
expect(screen.getByText('publisher-summary-publish')).toBeInTheDocument()
expect(screen.queryByText('publisher-access-control')).not.toBeInTheDocument()
expect(screen.queryByText('publisher-embed')).not.toBeInTheDocument()
expect(sectionProps.summary?.workflowTypeSwitchConfig).toEqual({
targetType: AppTypeEnum.WORKFLOW,
publishLabelKey: 'common.publishAsStandardWorkflow',
switchLabelKey: 'common.switchToStandardWorkflow',
tipKey: 'common.switchToStandardWorkflowTip',
})
})
})

View File

@@ -0,0 +1,110 @@
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import PublishWithMultipleModel from '../publish-with-multiple-model'
const mockUseProviderContext = vi.fn()
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => mockUseProviderContext(),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('../../header/account-setting/model-provider-page/model-icon', () => ({
default: ({ modelName }: { modelName: string }) => <span data-testid="model-icon">{modelName}</span>,
}))
vi.mock('@/app/components/base/portal-to-follow-elem', async () => {
const ReactModule = await vi.importActual<typeof import('react')>('react')
const OpenContext = ReactModule.createContext(false)
return {
PortalToFollowElem: ({ children, open }: { children: React.ReactNode, open: boolean }) => (
<OpenContext.Provider value={open}>
<div data-testid="portal-root">{children}</div>
</OpenContext.Provider>
),
PortalToFollowElemTrigger: ({ children, onClick, className }: { children: React.ReactNode, onClick?: () => void, className?: string }) => (
<div className={className} onClick={onClick}>
{children}
</div>
),
PortalToFollowElemContent: ({ children, className }: { children: React.ReactNode, className?: string }) => {
const open = ReactModule.useContext(OpenContext)
return open ? <div className={className}>{children}</div> : null
},
}
})
describe('PublishWithMultipleModel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseProviderContext.mockReturnValue({
textGenerationModelList: [
{
provider: 'openai',
models: [
{
model: 'gpt-4o',
label: {
en_US: 'GPT-4o',
},
},
],
},
],
})
})
it('should disable the trigger when no valid model configuration is available', () => {
render(
<PublishWithMultipleModel
multipleModelConfigs={[
{
id: 'config-1',
provider: 'anthropic',
model: 'claude-3',
parameters: {},
},
]}
onSelect={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: 'operation.applyConfig' })).toBeDisabled()
expect(screen.queryByText('publishAs')).not.toBeInTheDocument()
})
it('should open matching model options and call onSelect', () => {
const handleSelect = vi.fn()
const modelConfig = {
id: 'config-1',
provider: 'openai',
model: 'gpt-4o',
parameters: { temperature: 0.7 },
}
render(
<PublishWithMultipleModel
multipleModelConfigs={[modelConfig]}
onSelect={handleSelect}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'operation.applyConfig' }))
expect(screen.getByText('publishAs')).toBeInTheDocument()
fireEvent.click(screen.getByText('GPT-4o'))
expect(handleSelect).toHaveBeenCalledWith(expect.objectContaining(modelConfig))
})
})

View File

@@ -0,0 +1,308 @@
/* eslint-disable ts/no-explicit-any */
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { AccessModeDisplay, PublisherAccessSection, PublisherActionsSection, PublisherSummarySection } from '../sections'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('../publish-with-multiple-model', () => ({
default: ({ onSelect }: { onSelect: (item: Record<string, unknown>) => void }) => (
<button type="button" onClick={() => onSelect({ model: 'gpt-4o' })}>publish-multiple-model</button>
),
}))
vi.mock('../suggested-action', () => ({
default: ({ children, onClick, link, disabled }: { children: ReactNode, onClick?: () => void, link?: string, disabled?: boolean }) => (
<button type="button" data-link={link} disabled={disabled} onClick={onClick}>{children}</button>
),
}))
vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
default: (props: Record<string, unknown>) => (
<div>
workflow-tool-configure
<span>{String(props.disabledReason || '')}</span>
</div>
),
}))
describe('app-publisher sections', () => {
it('should render restore controls for published chat apps', () => {
const handleRestore = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '3 minutes ago'}
handlePublish={vi.fn()}
handleRestore={handleRestore}
isChatApp
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={Date.now()}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
fireEvent.click(screen.getByText('common.restore'))
expect(handleRestore).toHaveBeenCalled()
})
it('should expose the access control warning when subjects are missing', () => {
render(
<PublisherAccessSection
enabled
isAppAccessSet={false}
isLoading={false}
accessMode={AccessMode.SPECIFIC_GROUPS_MEMBERS}
onClick={vi.fn()}
/>,
)
expect(screen.getByText('publishApp.notSet')).toBeInTheDocument()
expect(screen.getByText('publishApp.notSetDesc')).toBeInTheDocument()
})
it('should render the publish update action when the draft has not been published yet', () => {
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
expect(screen.getByText('common.publishUpdate')).toBeInTheDocument()
})
it('should render multiple-model publishing', () => {
const handlePublish = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={handlePublish}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[{ id: '1' } as any]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
fireEvent.click(screen.getByText('publish-multiple-model'))
expect(handlePublish).toHaveBeenCalledWith({ model: 'gpt-4o' })
})
it('should render the upgrade hint when the start node limit is exceeded', () => {
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={vi.fn()}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded
upgradeHighlightStyle={{}}
workflowTypeSwitchDisabled={false}
/>,
)
expect(screen.getByText('publishLimit.startNodeDesc')).toBeInTheDocument()
})
it('should render workflow type switch action and call switch handler', () => {
const onWorkflowTypeSwitch = vi.fn()
render(
<PublisherSummarySection
debugWithMultipleModel={false}
draftUpdatedAt={Date.now()}
formatTimeFromNow={() => '1 minute ago'}
handlePublish={vi.fn()}
handleRestore={vi.fn()}
isChatApp={false}
multipleModelConfigs={[]}
onWorkflowTypeSwitch={onWorkflowTypeSwitch}
publishDisabled={false}
published={false}
publishedAt={undefined}
publishShortcut={['ctrl', '⇧', 'P']}
startNodeLimitExceeded={false}
upgradeHighlightStyle={{}}
workflowTypeSwitchConfig={{
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
}}
workflowTypeSwitchDisabled={false}
/>,
)
fireEvent.click(screen.getByText('common.publishAsEvaluationWorkflow'))
expect(onWorkflowTypeSwitch).toHaveBeenCalledTimes(1)
})
it('should render loading access state and access mode labels when enabled', () => {
const { rerender } = render(
<PublisherAccessSection
enabled
isAppAccessSet
isLoading
accessMode={AccessMode.PUBLIC}
onClick={vi.fn()}
/>,
)
expect(document.querySelector('.spin-animation')).toBeInTheDocument()
rerender(
<PublisherAccessSection
enabled
isAppAccessSet
isLoading={false}
accessMode={AccessMode.PUBLIC}
onClick={vi.fn()}
/>,
)
expect(screen.getByText('accessControlDialog.accessItems.anyone')).toBeInTheDocument()
expect(render(<AccessModeDisplay />).container).toBeEmptyDOMElement()
})
it('should render workflow actions, batch run links, and workflow tool configuration', () => {
const handleOpenInExplore = vi.fn()
const handleEmbed = vi.fn()
const { rerender } = render(
<PublisherActionsSection
appDetail={{
id: 'workflow-app',
mode: AppModeEnum.WORKFLOW,
icon: '⚙️',
icon_type: 'emoji',
icon_background: '#fff',
name: 'Workflow App',
description: 'Workflow description',
}}
appURL="https://example.com/app"
disabledFunctionButton={false}
disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode={false}
inputs={[]}
missingStartNode={false}
onRefreshData={vi.fn()}
outputs={[]}
published={true}
publishedAt={Date.now()}
toolPublished
workflowToolAvailable={false}
workflowToolMessage="workflow-disabled"
/>,
)
expect(screen.getByText('common.batchRunApp')).toHaveAttribute('data-link', 'https://example.com/app?mode=batch')
fireEvent.click(screen.getByText('common.openInExplore'))
expect(handleOpenInExplore).toHaveBeenCalled()
expect(screen.getByText('workflow-tool-configure')).toBeInTheDocument()
expect(screen.getByText('workflow-disabled')).toBeInTheDocument()
rerender(
<PublisherActionsSection
appDetail={{
id: 'chat-app',
mode: AppModeEnum.CHAT,
name: 'Chat App',
}}
appURL="https://example.com/app?foo=bar"
disabledFunctionButton
disabledFunctionTooltip="disabled"
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode={false}
inputs={[]}
missingStartNode
onRefreshData={vi.fn()}
outputs={[]}
published={false}
publishedAt={Date.now()}
toolPublished={false}
workflowToolAvailable
/>,
)
fireEvent.click(screen.getByText('common.embedIntoSite'))
expect(handleEmbed).toHaveBeenCalled()
expect(screen.getByText('common.accessAPIReference')).toBeDisabled()
rerender(
<PublisherActionsSection
appDetail={{ id: 'trigger-app', mode: AppModeEnum.WORKFLOW }}
appURL="https://example.com/app"
disabledFunctionButton={false}
handleEmbed={handleEmbed}
handleOpenInExplore={handleOpenInExplore}
handlePublish={vi.fn()}
hasHumanInputNode={false}
hasTriggerNode
inputs={[]}
missingStartNode={false}
outputs={[]}
published={false}
publishedAt={undefined}
toolPublished={false}
workflowToolAvailable
/>,
)
expect(screen.queryByText('common.runApp')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,49 @@
import type { MouseEvent as ReactMouseEvent } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import SuggestedAction from '../suggested-action'
describe('SuggestedAction', () => {
it('should render an enabled external link', () => {
render(
<SuggestedAction link="https://example.com/docs">
Open docs
</SuggestedAction>,
)
const link = screen.getByRole('link', { name: 'Open docs' })
expect(link).toHaveAttribute('href', 'https://example.com/docs')
expect(link).toHaveAttribute('target', '_blank')
})
it('should block clicks when disabled', () => {
const handleClick = vi.fn()
render(
<SuggestedAction link="https://example.com/docs" disabled onClick={handleClick}>
Disabled action
</SuggestedAction>,
)
const link = screen.getByText('Disabled action').closest('a') as HTMLAnchorElement
fireEvent.click(link)
expect(link).not.toHaveAttribute('href')
expect(handleClick).not.toHaveBeenCalled()
})
it('should forward click events when enabled', () => {
const handleClick = vi.fn((event: ReactMouseEvent<HTMLAnchorElement>) => {
event.preventDefault()
})
render(
<SuggestedAction link="https://example.com/docs" onClick={handleClick}>
Enabled action
</SuggestedAction>,
)
fireEvent.click(screen.getByRole('link', { name: 'Enabled action' }))
expect(handleClick).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,70 @@
import type { TFunction } from 'i18next'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import {
getDisabledFunctionTooltip,
getPublisherAppMode,
getPublisherAppUrl,
isPublisherAccessConfigured,
} from '../utils'
describe('app-publisher utils', () => {
describe('getPublisherAppMode', () => {
it('should normalize chat-like apps to chat mode', () => {
expect(getPublisherAppMode(AppModeEnum.AGENT_CHAT)).toBe(AppModeEnum.CHAT)
})
it('should keep completion mode unchanged', () => {
expect(getPublisherAppMode(AppModeEnum.COMPLETION)).toBe(AppModeEnum.COMPLETION)
})
})
describe('getPublisherAppUrl', () => {
it('should build the published app url from site info', () => {
expect(getPublisherAppUrl({
appBaseUrl: 'https://example.com',
accessToken: 'token-1',
mode: AppModeEnum.CHAT,
})).toBe(`https://example.com${basePath}/chat/token-1`)
})
})
describe('isPublisherAccessConfigured', () => {
it('should require members or groups for specific access mode', () => {
expect(isPublisherAccessConfigured(
{ access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS },
{ groups: [], members: [] },
)).toBe(false)
})
it('should treat public access as configured', () => {
expect(isPublisherAccessConfigured(
{ access_mode: AccessMode.PUBLIC },
{ groups: [], members: [] },
)).toBe(true)
})
})
describe('getDisabledFunctionTooltip', () => {
const t = ((key: string) => key) as unknown as TFunction
it('should prioritize the unpublished hint', () => {
expect(getDisabledFunctionTooltip({
t,
publishedAt: undefined,
missingStartNode: false,
noAccessPermission: false,
})).toBe('notPublishedYet')
})
it('should return the access error when the app is published but blocked', () => {
expect(getDisabledFunctionTooltip({
t,
publishedAt: Date.now(),
missingStartNode: false,
noAccessPermission: true,
})).toBe('noAccessPermission')
})
})
})

View File

@@ -0,0 +1,128 @@
/* eslint-disable ts/no-explicit-any */
import { fireEvent, render, screen } from '@testing-library/react'
import { toast } from '@/app/components/base/ui/toast'
import VersionInfoModal from '../version-info-modal'
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
describe('VersionInfoModal', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should prefill the fields from the current version info', () => {
render(
<VersionInfoModal
isOpen
versionInfo={{
id: 'version-1',
marked_name: 'Release 1',
marked_comment: 'Initial release',
} as any}
onClose={vi.fn()}
onPublish={vi.fn()}
/>,
)
expect(screen.getByDisplayValue('Release 1')).toBeInTheDocument()
expect(screen.getByDisplayValue('Initial release')).toBeInTheDocument()
})
it('should reject overlong titles', () => {
const handlePublish = vi.fn()
render(
<VersionInfoModal
isOpen
onClose={vi.fn()}
onPublish={handlePublish}
/>,
)
const [titleInput] = screen.getAllByRole('textbox')
fireEvent.change(titleInput, { target: { value: 'a'.repeat(16) } })
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.titleLengthLimit')
expect(handlePublish).not.toHaveBeenCalled()
})
it('should publish valid values and close the modal', () => {
const handlePublish = vi.fn()
const handleClose = vi.fn()
render(
<VersionInfoModal
isOpen
versionInfo={{
id: 'version-2',
marked_name: 'Old title',
marked_comment: 'Old notes',
} as any}
onClose={handleClose}
onPublish={handlePublish}
/>,
)
const [titleInput, notesInput] = screen.getAllByRole('textbox')
fireEvent.change(titleInput, { target: { value: 'Release 2' } })
fireEvent.change(notesInput, { target: { value: 'Updated notes' } })
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
expect(handlePublish).toHaveBeenCalledWith({
title: 'Release 2',
releaseNotes: 'Updated notes',
id: 'version-2',
})
expect(handleClose).toHaveBeenCalledTimes(1)
})
it('should validate release note length and clear previous errors before publishing', () => {
const handlePublish = vi.fn()
const handleClose = vi.fn()
render(
<VersionInfoModal
isOpen
versionInfo={{
id: 'version-3',
marked_name: 'Old title',
marked_comment: 'Old notes',
} as any}
onClose={handleClose}
onPublish={handlePublish}
/>,
)
const [titleInput, notesInput] = screen.getAllByRole('textbox')
fireEvent.change(titleInput, { target: { value: 'a'.repeat(16) } })
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.titleLengthLimit')
fireEvent.change(titleInput, { target: { value: 'Release 3' } })
fireEvent.change(notesInput, { target: { value: 'b'.repeat(101) } })
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
expect(toast.error).toHaveBeenCalledWith('versionHistory.editField.releaseNotesLengthLimit')
fireEvent.change(notesInput, { target: { value: 'Stable release notes' } })
fireEvent.click(screen.getByRole('button', { name: 'common.publish' }))
expect(handlePublish).toHaveBeenCalledWith({
title: 'Release 3',
releaseNotes: 'Stable release notes',
id: 'version-3',
})
expect(handleClose).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,7 +1,7 @@
import type { ModelAndParameter } from '../configuration/debug/types'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { I18nKeysByPrefix } from '@/types/i18n'
import type { PublishWorkflowParams } from '@/types/workflow'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow'
import { useKeyPress } from 'ahooks'
import {
memo,
@@ -15,15 +15,11 @@ import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { appDefaultIconBackground } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
@@ -31,56 +27,22 @@ import { AccessMode } from '@/models/access-control'
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
import { fetchAppDetailDirect } from '@/service/apps'
import { fetchInstalledAppList } from '@/service/explore'
import { AppModeEnum } from '@/types/app'
import { useConvertWorkflowTypeMutation } from '@/service/use-apps'
import { AppModeEnum, AppTypeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import Divider from '../../base/divider'
import Loading from '../../base/loading'
import Tooltip from '../../base/tooltip'
import { toast } from '../../base/ui/toast'
import ShortcutsName from '../../workflow/shortcuts-name'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import AccessControl from '../app-access-control'
import PublishWithMultipleModel from './publish-with-multiple-model'
import SuggestedAction from './suggested-action'
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
[AccessMode.ORGANIZATION]: {
label: 'organization',
icon: 'i-ri-building-line',
},
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
label: 'specific',
icon: 'i-ri-lock-line',
},
[AccessMode.PUBLIC]: {
label: 'anyone',
icon: 'i-ri-global-line',
},
[AccessMode.EXTERNAL_MEMBERS]: {
label: 'external',
icon: 'i-ri-verified-badge-line',
},
}
const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => {
const { t } = useTranslation()
if (!mode || !ACCESS_MODE_MAP[mode])
return null
const { icon, label } = ACCESS_MODE_MAP[mode]
return (
<>
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
<div className="grow truncate">
<span className="text-text-secondary system-sm-medium">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
</div>
</>
)
}
import {
PublisherAccessSection,
PublisherActionsSection,
PublisherSummarySection,
} from './sections'
import {
getDisabledFunctionTooltip,
getPublisherAppUrl,
isPublisherAccessConfigured,
} from './utils'
export type AppPublisherProps = {
disabled?: boolean
@@ -108,6 +70,32 @@ export type AppPublisherProps = {
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'>
const WORKFLOW_TYPE_SWITCH_CONFIG: Record<WorkflowTypeConversionTarget, {
targetType: WorkflowTypeConversionTarget
publishLabelKey: WorkflowTypeSwitchLabelKey
switchLabelKey: WorkflowTypeSwitchLabelKey
tipKey: WorkflowTypeSwitchLabelKey
}> = {
workflow: {
targetType: 'evaluation',
publishLabelKey: 'common.publishAsEvaluationWorkflow',
switchLabelKey: 'common.switchToEvaluationWorkflow',
tipKey: 'common.switchToEvaluationWorkflowTip',
},
evaluation: {
targetType: 'workflow',
publishLabelKey: 'common.publishAsStandardWorkflow',
switchLabelKey: 'common.switchToStandardWorkflow',
tipKey: 'common.switchToStandardWorkflowTip',
},
} as const
const isWorkflowTypeConversionTarget = (type?: AppTypeEnum): type is WorkflowTypeConversionTarget => {
return type === 'workflow' || type === 'evaluation'
}
const AppPublisher = ({
disabled = false,
publishDisabled = false,
@@ -142,33 +130,34 @@ const AppPublisher = ({
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const { formatTimeFromNow } = useFormatTimeFromNow()
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
const { mutateAsync: convertWorkflowType, isPending: isConvertingWorkflowType } = useConvertWorkflowTypeMutation()
const appMode = (appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appDetail.mode
const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}`
const appURL = getPublisherAppUrl({ appBaseUrl: appBaseURL, accessToken, mode: appDetail?.mode })
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
const workflowTypeSwitchConfig = isWorkflowTypeConversionTarget(appDetail?.type)
? WORKFLOW_TYPE_SWITCH_CONFIG[appDetail.type]
: undefined
const isEvaluationWorkflowType = appDetail?.type === AppTypeEnum.EVALUATION
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
const openAsyncWindow = useAsyncWindowOpen()
const isAppAccessSet = useMemo(() => {
if (appDetail && appAccessSubjects) {
return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
}
return true
}, [appAccessSubjects, appDetail])
const isAppAccessSet = useMemo(() => isPublisherAccessConfigured(appDetail, appAccessSubjects), [appAccessSubjects, appDetail])
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
const noAccessPermission = useMemo(() => Boolean(
systemFeatures.webapp_auth.enabled
&& appDetail
&& appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS
&& !userCanAccessApp?.result,
), [systemFeatures, appDetail, userCanAccessApp])
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
const disabledFunctionTooltip = useMemo(() => {
if (!publishedAt)
return t('notPublishedYet', { ns: 'app' })
if (missingStartNode)
return t('noUserInputNode', { ns: 'app' })
if (noAccessPermission)
return t('noAccessPermission', { ns: 'app' })
}, [missingStartNode, noAccessPermission, publishedAt, t])
const disabledFunctionTooltip = useMemo(() => getDisabledFunctionTooltip({
t,
publishedAt,
missingStartNode,
noAccessPermission,
}), [missingStartNode, noAccessPermission, publishedAt, t])
useEffect(() => {
if (systemFeatures.webapp_auth.enabled && open && appDetail)
@@ -236,6 +225,35 @@ const AppPublisher = ({
}
}, [appDetail, setAppDetail])
const handleWorkflowTypeSwitch = useCallback(async () => {
if (!appDetail?.id || !workflowTypeSwitchConfig)
return
try {
await convertWorkflowType({
params: {
appId: appDetail.id,
},
query: {
target_type: workflowTypeSwitchConfig.targetType,
},
})
if (!publishedAt)
await handlePublish()
const latestAppDetail = await fetchAppDetailDirect({
url: '/apps',
id: appDetail.id,
})
setAppDetail(latestAppDetail)
if (publishedAt)
setOpen(false)
}
catch { }
}, [appDetail?.id, convertWorkflowType, handlePublish, publishedAt, setAppDetail, workflowTypeSwitchConfig])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()
if (publishDisabled || published)
@@ -244,9 +262,9 @@ const AppPublisher = ({
}, { exactMatch: true, useCapture: true })
const hasPublishedVersion = !!publishedAt
const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable
const workflowToolMessage = workflowToolDisabled ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined
const showStartNodeLimitHint = Boolean(startNodeLimitExceeded)
const workflowToolMessage = !hasPublishedVersion || !workflowToolAvailable
? t('common.workflowAsToolDisabledHint', { ns: 'workflow' })
: undefined
const upgradeHighlightStyle = useMemo(() => ({
background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
WebkitBackgroundClip: 'text',
@@ -268,7 +286,7 @@ const AppPublisher = ({
<PortalToFollowElemTrigger onClick={handleTrigger}>
<Button
variant="primary"
className="py-2 pl-3 pr-2"
className="py-2 pr-2 pl-3"
disabled={disabled}
>
{t('common.publish', { ns: 'workflow' })}
@@ -277,199 +295,58 @@ const AppPublisher = ({
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-11">
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
<div className="p-4 pt-3">
<div className="flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
{publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })}
</div>
{publishedAt
? (
<div className="flex items-center justify-between">
<div className="flex items-center text-text-secondary system-sm-medium">
{t('common.publishedAt', { ns: 'workflow' })}
{' '}
{formatTimeFromNow(publishedAt)}
</div>
{isChatApp && (
<Button
variant="secondary-accent"
size="small"
onClick={handleRestore}
disabled={published}
>
{t('common.restore', { ns: 'workflow' })}
</Button>
)}
</div>
)
: (
<div className="flex items-center text-text-secondary system-sm-medium">
{t('common.autoSaved', { ns: 'workflow' })}
{' '}
·
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
</div>
)}
{debugWithMultipleModel
? (
<PublishWithMultipleModel
multipleModelConfigs={multipleModelConfigs}
onSelect={item => handlePublish(item)}
// textGenerationModelList={textGenerationModelList}
/>
)
: (
<>
<Button
variant="primary"
className="mt-3 w-full"
onClick={() => handlePublish()}
disabled={publishDisabled || published}
>
{
published
? t('common.published', { ns: 'workflow' })
: (
<div className="flex gap-1">
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
<ShortcutsName keys={PUBLISH_SHORTCUT} bgColor="white" />
</div>
)
}
</Button>
{showStartNodeLimitHint && (
<div className="mt-3 flex flex-col items-stretch">
<p
className="text-sm font-semibold leading-5 text-transparent"
style={upgradeHighlightStyle}
>
<span className="block">{t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })}</span>
<span className="block">{t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })}</span>
</p>
<p className="mt-1 text-xs leading-4 text-text-secondary">
{t('publishLimit.startNodeDesc', { ns: 'workflow' })}
</p>
<UpgradeBtn
isShort
className="mb-[12px] mt-[9px] h-[32px] w-[93px] self-start"
/>
</div>
)}
</>
)}
</div>
{(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))
? <div className="py-2"><Loading /></div>
: (
<>
<Divider className="my-0" />
{systemFeatures.webapp_auth.enabled && (
<div className="p-4 pt-3">
<div className="flex h-6 items-center">
<p className="text-text-tertiary system-xs-medium">{t('publishApp.title', { ns: 'app' })}</p>
</div>
<div
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent"
onClick={() => {
setShowAppAccessControl(true)
}}
>
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
<AccessModeDisplay mode={appDetail?.access_mode} />
</div>
{!isAppAccessSet && <p className="shrink-0 text-text-tertiary system-xs-regular">{t('publishApp.notSet', { ns: 'app' })}</p>}
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
</div>
</div>
{!isAppAccessSet && <p className="mt-1 text-text-warning system-xs-regular">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
</div>
)}
{
// Hide run/batch run app buttons when there is a trigger node.
!hasTriggerNode && (
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
link={appURL}
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
>
{t('common.runApp', { ns: 'workflow' })}
</SuggestedAction>
</Tooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
? (
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
>
{t('common.batchRunApp', { ns: 'workflow' })}
</SuggestedAction>
</Tooltip>
)
: (
<SuggestedAction
onClick={() => {
setEmbeddingModalOpen(true)
handleTrigger()
}}
disabled={!publishedAt}
icon={<CodeBrowser className="h-4 w-4" />}
>
{t('common.embedIntoSite', { ns: 'workflow' })}
</SuggestedAction>
)}
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
<SuggestedAction
className="flex-1"
onClick={() => {
if (publishedAt)
handleOpenInExplore()
}}
disabled={disabledFunctionButton}
icon={<span className="i-ri-planet-line h-4 w-4" />}
>
{t('common.openInExplore', { ns: 'workflow' })}
</SuggestedAction>
</Tooltip>
<Tooltip triggerClassName="flex" disabled={!!publishedAt && !missingStartNode} popupContent={!publishedAt ? t('notPublishedYet', { ns: 'app' }) : t('noUserInputNode', { ns: 'app' })} asChild={false}>
<SuggestedAction
className="flex-1"
disabled={!publishedAt || missingStartNode}
link="./develop"
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
>
{t('common.accessAPIReference', { ns: 'workflow' })}
</SuggestedAction>
</Tooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
<WorkflowToolConfigureButton
disabled={workflowToolDisabled}
published={!!toolPublished}
detailNeedUpdate={!!toolPublished && published}
workflowAppId={appDetail?.id}
icon={{
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
}}
name={appDetail?.name}
description={appDetail?.description}
inputs={inputs}
outputs={outputs}
handlePublish={handlePublish}
onRefreshData={onRefreshData}
disabledReason={workflowToolMessage}
/>
)}
</div>
)
}
</>
)}
<PublisherSummarySection
debugWithMultipleModel={debugWithMultipleModel}
draftUpdatedAt={draftUpdatedAt}
formatTimeFromNow={formatTimeFromNow}
handlePublish={handlePublish}
handleRestore={handleRestore}
isChatApp={isChatApp}
multipleModelConfigs={multipleModelConfigs}
publishDisabled={publishDisabled}
published={published}
publishedAt={publishedAt}
publishShortcut={PUBLISH_SHORTCUT}
startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle}
workflowTypeSwitchConfig={workflowTypeSwitchConfig}
workflowTypeSwitchDisabled={publishDisabled || published || isConvertingWorkflowType}
onWorkflowTypeSwitch={handleWorkflowTypeSwitch}
/>
{!isEvaluationWorkflowType && (
<>
<PublisherAccessSection
enabled={systemFeatures.webapp_auth.enabled}
isAppAccessSet={isAppAccessSet}
isLoading={Boolean(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))}
accessMode={appDetail?.access_mode}
onClick={() => setShowAppAccessControl(true)}
/>
<PublisherActionsSection
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
handleEmbed={() => {
setEmbeddingModalOpen(true)
handleTrigger()
}}
handleOpenInExplore={handleOpenInExplore}
handlePublish={handlePublish}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt}
toolPublished={toolPublished}
workflowToolAvailable={workflowToolAvailable}
workflowToolMessage={workflowToolMessage}
/>
</>
)}
</div>
</PortalToFollowElemContent>
<EmbeddedModal

View File

@@ -0,0 +1,412 @@
import type { CSSProperties, ReactNode } from 'react'
import type { ModelAndParameter } from '../configuration/debug/types'
import type { AppPublisherProps } from './index'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import type { PublishWorkflowParams, WorkflowTypeConversionTarget } from '@/types/workflow'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import Loading from '@/app/components/base/loading'
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/app/components/base/ui/tooltip'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { appDefaultIconBackground } from '@/config'
import { AppModeEnum } from '@/types/app'
import ShortcutsName from '../../workflow/shortcuts-name'
import PublishWithMultipleModel from './publish-with-multiple-model'
import SuggestedAction from './suggested-action'
import { ACCESS_MODE_MAP } from './utils'
type WorkflowTypeSwitchLabelKey = I18nKeysWithPrefix<'workflow', 'common.'>
type SummarySectionProps = Pick<AppPublisherProps, | 'debugWithMultipleModel'
| 'draftUpdatedAt'
| 'multipleModelConfigs'
| 'publishDisabled'
| 'publishedAt'
| 'startNodeLimitExceeded'> & {
formatTimeFromNow: (value: number) => string
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
handleRestore: () => Promise<void>
isChatApp: boolean
onWorkflowTypeSwitch: () => Promise<void>
published: boolean
publishShortcut: string[]
upgradeHighlightStyle: CSSProperties
workflowTypeSwitchConfig?: {
targetType: WorkflowTypeConversionTarget
publishLabelKey: WorkflowTypeSwitchLabelKey
switchLabelKey: WorkflowTypeSwitchLabelKey
tipKey: WorkflowTypeSwitchLabelKey
}
workflowTypeSwitchDisabled: boolean
}
type AccessSectionProps = {
enabled: boolean
isAppAccessSet: boolean
isLoading: boolean
accessMode?: keyof typeof ACCESS_MODE_MAP
onClick: () => void
}
type ActionsSectionProps = Pick<AppPublisherProps, | 'hasHumanInputNode'
| 'hasTriggerNode'
| 'inputs'
| 'missingStartNode'
| 'onRefreshData'
| 'toolPublished'
| 'outputs'
| 'publishedAt'
| 'workflowToolAvailable'> & {
appDetail: {
id?: string
icon?: string
icon_type?: string | null
icon_background?: string | null
description?: string
mode?: AppModeEnum
name?: string
} | null | undefined
appURL: string
disabledFunctionButton: boolean
disabledFunctionTooltip?: string
handleEmbed: () => void
handleOpenInExplore: () => void
handlePublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void>
published: boolean
workflowToolMessage?: string
}
export const AccessModeDisplay = ({ mode }: { mode?: keyof typeof ACCESS_MODE_MAP }) => {
const { t } = useTranslation()
if (!mode || !ACCESS_MODE_MAP[mode])
return null
const { icon, label } = ACCESS_MODE_MAP[mode]
return (
<>
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
<div className="grow truncate">
<span className="system-sm-medium text-text-secondary">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
</div>
</>
)
}
export const PublisherSummarySection = ({
debugWithMultipleModel = false,
draftUpdatedAt,
formatTimeFromNow,
handlePublish,
handleRestore,
isChatApp,
multipleModelConfigs = [],
onWorkflowTypeSwitch,
publishDisabled = false,
published,
publishedAt,
publishShortcut,
startNodeLimitExceeded = false,
upgradeHighlightStyle,
workflowTypeSwitchConfig,
workflowTypeSwitchDisabled,
}: SummarySectionProps) => {
const { t } = useTranslation()
return (
<div className="p-4 pt-3">
<div className="flex h-6 items-center system-xs-medium-uppercase text-text-tertiary">
{publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })}
</div>
{publishedAt
? (
<div className="flex items-center justify-between">
<div className="flex items-center system-sm-medium text-text-secondary">
{t('common.publishedAt', { ns: 'workflow' })}
{' '}
{formatTimeFromNow(publishedAt)}
</div>
{isChatApp && (
<Button
variant="secondary-accent"
size="small"
onClick={handleRestore}
disabled={published}
>
{t('common.restore', { ns: 'workflow' })}
</Button>
)}
</div>
)
: (
<div className="flex items-center system-sm-medium text-text-secondary">
{t('common.autoSaved', { ns: 'workflow' })}
{' '}
·
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
</div>
)}
{debugWithMultipleModel
? (
<PublishWithMultipleModel
multipleModelConfigs={multipleModelConfigs}
onSelect={item => handlePublish(item)}
/>
)
: (
<>
<Button
variant="primary"
className="mt-3 w-full"
onClick={() => handlePublish()}
disabled={publishDisabled || published}
>
{published
? t('common.published', { ns: 'workflow' })
: (
<div className="flex gap-1">
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
<ShortcutsName keys={publishShortcut} bgColor="white" />
</div>
)}
</Button>
{workflowTypeSwitchConfig && (
<button
type="button"
className="flex h-8 w-full items-center justify-center gap-0.5 rounded-lg px-3 py-2 system-sm-medium text-text-tertiary hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50"
onClick={() => void onWorkflowTypeSwitch()}
disabled={workflowTypeSwitchDisabled}
>
<span className="px-0.5">
{t(
publishedAt
? workflowTypeSwitchConfig.switchLabelKey
: workflowTypeSwitchConfig.publishLabelKey,
{ ns: 'workflow' },
)}
</span>
<Tooltip>
<TooltipTrigger
render={(
<span
className="flex h-4 w-4 items-center justify-center text-text-quaternary hover:text-text-tertiary"
aria-label={t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<span className="i-ri-question-line h-3.5 w-3.5" />
</span>
)}
/>
<TooltipContent
placement="top"
popupClassName="w-[180px]"
>
{t(workflowTypeSwitchConfig.tipKey, { ns: 'workflow' })}
</TooltipContent>
</Tooltip>
</button>
)}
{startNodeLimitExceeded && (
<div className="mt-3 flex flex-col items-stretch">
<p
className="text-sm leading-5 font-semibold text-transparent"
style={upgradeHighlightStyle}
>
<span className="block">{t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })}</span>
<span className="block">{t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })}</span>
</p>
<p className="mt-1 text-xs leading-4 text-text-secondary">
{t('publishLimit.startNodeDesc', { ns: 'workflow' })}
</p>
<UpgradeBtn
isShort
className="mt-[9px] mb-[12px] h-[32px] w-[93px] self-start"
/>
</div>
)}
</>
)}
</div>
)
}
export const PublisherAccessSection = ({
enabled,
isAppAccessSet,
isLoading,
accessMode,
onClick,
}: AccessSectionProps) => {
const { t } = useTranslation()
if (isLoading)
return <div className="py-2"><Loading /></div>
return (
<>
<Divider className="my-0" />
{enabled && (
<div className="p-4 pt-3">
<div className="flex h-6 items-center">
<p className="system-xs-medium text-text-tertiary">{t('publishApp.title', { ns: 'app' })}</p>
</div>
<div
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pr-2 pl-2.5 hover:bg-primary-50 hover:text-text-accent"
onClick={onClick}
>
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
<AccessModeDisplay mode={accessMode} />
</div>
{!isAppAccessSet && <p className="shrink-0 system-xs-regular text-text-tertiary">{t('publishApp.notSet', { ns: 'app' })}</p>}
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
</div>
</div>
{!isAppAccessSet && <p className="mt-1 system-xs-regular text-text-warning">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
</div>
)}
</>
)
}
const ActionTooltip = ({
disabled,
tooltip,
children,
}: {
disabled: boolean
tooltip?: ReactNode
children: ReactNode
}) => {
if (!disabled || !tooltip)
return <>{children}</>
return (
<Tooltip>
<TooltipTrigger render={<div className="flex">{children}</div>} />
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
export const PublisherActionsSection = ({
appDetail,
appURL,
disabledFunctionButton,
disabledFunctionTooltip,
handleEmbed,
handleOpenInExplore,
handlePublish,
hasHumanInputNode = false,
hasTriggerNode = false,
inputs,
missingStartNode = false,
onRefreshData,
outputs,
published,
publishedAt,
toolPublished,
workflowToolAvailable = true,
workflowToolMessage,
}: ActionsSectionProps) => {
const { t } = useTranslation()
if (hasTriggerNode)
return null
const workflowToolDisabled = !publishedAt || !workflowToolAvailable
return (
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
link={appURL}
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
>
{t('common.runApp', { ns: 'workflow' })}
</SuggestedAction>
</ActionTooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
? (
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
>
{t('common.batchRunApp', { ns: 'workflow' })}
</SuggestedAction>
</ActionTooltip>
)
: (
<SuggestedAction
onClick={handleEmbed}
disabled={!publishedAt}
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
>
{t('common.embedIntoSite', { ns: 'workflow' })}
</SuggestedAction>
)}
<ActionTooltip disabled={disabledFunctionButton} tooltip={disabledFunctionTooltip}>
<SuggestedAction
className="flex-1"
onClick={() => {
if (publishedAt)
handleOpenInExplore()
}}
disabled={disabledFunctionButton}
icon={<span className="i-ri-planet-line h-4 w-4" />}
>
{t('common.openInExplore', { ns: 'workflow' })}
</SuggestedAction>
</ActionTooltip>
<ActionTooltip
disabled={!publishedAt || missingStartNode}
tooltip={!publishedAt ? t('notPublishedYet', { ns: 'app' }) : t('noUserInputNode', { ns: 'app' })}
>
<SuggestedAction
className="flex-1"
disabled={!publishedAt || missingStartNode}
link="./develop"
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
>
{t('common.accessAPIReference', { ns: 'workflow' })}
</SuggestedAction>
</ActionTooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
<WorkflowToolConfigureButton
disabled={workflowToolDisabled}
published={!!toolPublished}
detailNeedUpdate={!!toolPublished && published}
workflowAppId={appDetail?.id ?? ''}
icon={{
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
}}
name={appDetail?.name ?? ''}
description={appDetail?.description ?? ''}
inputs={inputs}
outputs={outputs}
handlePublish={handlePublish}
onRefreshData={onRefreshData}
disabledReason={workflowToolMessage}
/>
)}
</div>
)
}

View File

@@ -2,7 +2,7 @@ import type { HTMLProps, PropsWithChildren } from 'react'
import { RiArrowRightUpLine } from '@remixicon/react'
import { cn } from '@/utils/classnames'
export type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement> & {
type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement> & {
icon?: React.ReactNode
link?: string
disabled?: boolean

View File

@@ -0,0 +1,84 @@
import type { TFunction } from 'i18next'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
type AccessSubjectsLike = {
groups?: unknown[]
members?: unknown[]
} | null | undefined
type AppDetailLike = {
access_mode?: AccessMode
mode?: AppModeEnum
}
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
export const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
[AccessMode.ORGANIZATION]: {
label: 'organization',
icon: 'i-ri-building-line',
},
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
label: 'specific',
icon: 'i-ri-lock-line',
},
[AccessMode.PUBLIC]: {
label: 'anyone',
icon: 'i-ri-global-line',
},
[AccessMode.EXTERNAL_MEMBERS]: {
label: 'external',
icon: 'i-ri-verified-badge-line',
},
}
export const getPublisherAppMode = (mode?: AppModeEnum) => {
if (mode !== AppModeEnum.COMPLETION && mode !== AppModeEnum.WORKFLOW)
return AppModeEnum.CHAT
return mode
}
export const getPublisherAppUrl = ({
appBaseUrl,
accessToken,
mode,
}: {
appBaseUrl: string
accessToken: string
mode?: AppModeEnum
}) => `${appBaseUrl}${basePath}/${getPublisherAppMode(mode)}/${accessToken}`
export const isPublisherAccessConfigured = (appDetail: AppDetailLike | null | undefined, appAccessSubjects: AccessSubjectsLike) => {
if (!appDetail || !appAccessSubjects)
return true
if (appDetail.access_mode !== AccessMode.SPECIFIC_GROUPS_MEMBERS)
return true
return Boolean(appAccessSubjects.groups?.length || appAccessSubjects.members?.length)
}
export const getDisabledFunctionTooltip = ({
t,
publishedAt,
missingStartNode,
noAccessPermission,
}: {
t: TFunction
publishedAt?: number
missingStartNode: boolean
noAccessPermission: boolean
}) => {
if (!publishedAt)
return t('notPublishedYet', { ns: 'app' })
if (missingStartNode)
return t('noUserInputNode', { ns: 'app' })
if (noAccessPermission)
return t('noAccessPermission', { ns: 'app' })
return undefined
}

View File

@@ -0,0 +1,283 @@
import type { ComponentProps } from 'react'
import type { ConfigurationViewModel } from '../hooks/use-configuration'
import type AppPublisher from '@/app/components/app/app-publisher/features-wrapper'
import type ConfigContext from '@/context/debug-configuration'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { AppModeEnum, ModelModeType } from '@/types/app'
import ConfigurationView from '../configuration-view'
vi.mock('@/app/components/app/app-publisher/features-wrapper', () => ({
default: () => <div data-testid="app-publisher" />,
}))
vi.mock('@/app/components/app/configuration/config', () => ({
default: () => <div data-testid="config-panel" />,
}))
vi.mock('@/app/components/app/configuration/debug', () => ({
default: () => <div data-testid="debug-panel" />,
}))
vi.mock('@/app/components/app/configuration/config/agent-setting-button', () => ({
default: () => <div data-testid="agent-setting-button" />,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
default: () => <div data-testid="model-parameter-modal" />,
}))
vi.mock('@/app/components/app/configuration/dataset-config/select-dataset', () => ({
default: () => <div data-testid="select-dataset" />,
}))
vi.mock('@/app/components/app/configuration/config-prompt/conversation-history/edit-modal', () => ({
default: () => <div data-testid="history-modal" />,
}))
vi.mock('@/app/components/base/features/new-feature-panel', () => ({
default: () => <div data-testid="feature-panel" />,
}))
vi.mock('@/app/components/workflow/plugin-dependency', () => ({
default: () => <div data-testid="plugin-dependency" />,
}))
const createContextValue = (): ComponentProps<typeof ConfigContext.Provider>['value'] => ({
appId: 'app-1',
isAPIKeySet: true,
isTrailFinished: false,
mode: AppModeEnum.CHAT,
modelModeType: ModelModeType.chat,
promptMode: 'simple' as never,
setPromptMode: vi.fn(),
isAdvancedMode: false,
isAgent: false,
isFunctionCall: false,
isOpenAI: false,
collectionList: [],
canReturnToSimpleMode: false,
setCanReturnToSimpleMode: vi.fn(),
chatPromptConfig: { prompt: [] } as never,
completionPromptConfig: {
prompt: { text: '' },
conversation_histories_role: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
} as never,
currentAdvancedPrompt: [],
setCurrentAdvancedPrompt: vi.fn(),
showHistoryModal: vi.fn(),
conversationHistoriesRole: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
setConversationHistoriesRole: vi.fn(),
hasSetBlockStatus: {
context: false,
history: true,
query: true,
},
conversationId: '',
setConversationId: vi.fn(),
introduction: '',
setIntroduction: vi.fn(),
suggestedQuestions: [],
setSuggestedQuestions: vi.fn(),
controlClearChatMessage: 0,
setControlClearChatMessage: vi.fn(),
prevPromptConfig: {
prompt_template: '',
prompt_variables: [],
},
setPrevPromptConfig: vi.fn(),
moreLikeThisConfig: { enabled: false },
setMoreLikeThisConfig: vi.fn(),
suggestedQuestionsAfterAnswerConfig: { enabled: false },
setSuggestedQuestionsAfterAnswerConfig: vi.fn(),
speechToTextConfig: { enabled: false },
setSpeechToTextConfig: vi.fn(),
textToSpeechConfig: { enabled: false, voice: '', language: '' },
setTextToSpeechConfig: vi.fn(),
citationConfig: { enabled: false },
setCitationConfig: vi.fn(),
annotationConfig: {
id: '',
enabled: false,
score_threshold: 0.5,
embedding_model: {
embedding_model_name: '',
embedding_provider_name: '',
},
},
setAnnotationConfig: vi.fn(),
moderationConfig: { enabled: false },
setModerationConfig: vi.fn(),
externalDataToolsConfig: [],
setExternalDataToolsConfig: vi.fn(),
formattingChanged: false,
setFormattingChanged: vi.fn(),
inputs: {},
setInputs: vi.fn(),
query: '',
setQuery: vi.fn(),
completionParams: {},
setCompletionParams: vi.fn(),
modelConfig: {
provider: 'openai',
model_id: 'gpt-4o',
mode: ModelModeType.chat,
configs: {
prompt_template: '',
prompt_variables: [],
},
chat_prompt_config: null,
completion_prompt_config: null,
opening_statement: '',
more_like_this: null,
suggested_questions: [],
suggested_questions_after_answer: null,
speech_to_text: null,
text_to_speech: null,
file_upload: null,
retriever_resource: null,
sensitive_word_avoidance: null,
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
dataSets: [],
agentConfig: {
enabled: false,
strategy: 'react',
max_iteration: 1,
tools: [],
},
} as never,
setModelConfig: vi.fn(),
dataSets: [],
setDataSets: vi.fn(),
showSelectDataSet: vi.fn(),
datasetConfigs: {
retrieval_model: 'multiple',
reranking_model: {
reranking_provider_name: '',
reranking_model_name: '',
},
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
datasets: { datasets: [] },
} as never,
datasetConfigsRef: { current: {} as never },
setDatasetConfigs: vi.fn(),
hasSetContextVar: false,
isShowVisionConfig: false,
visionConfig: {
enabled: false,
number_limits: 1,
detail: 'low',
transfer_methods: ['local_file'],
} as never,
setVisionConfig: vi.fn(),
isAllowVideoUpload: false,
isShowDocumentConfig: false,
isShowAudioConfig: false,
rerankSettingModalOpen: false,
setRerankSettingModalOpen: vi.fn(),
})
const createViewModel = (overrides: Partial<ConfigurationViewModel> = {}): ConfigurationViewModel => ({
appPublisherProps: {
publishDisabled: false,
publishedAt: 0,
debugWithMultipleModel: false,
multipleModelConfigs: [],
onPublish: vi.fn(),
publishedConfig: {
modelConfig: createContextValue().modelConfig,
completionParams: {},
},
resetAppConfig: vi.fn(),
} as ComponentProps<typeof AppPublisher>,
contextValue: createContextValue(),
featuresData: {
moreLikeThis: { enabled: false },
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
moderation: { enabled: false },
speech2text: { enabled: false },
text2speech: { enabled: false, voice: '', language: '' },
file: { enabled: false, image: { enabled: false, detail: 'high', number_limits: 3, transfer_methods: ['local_file'] } } as never,
suggested: { enabled: false },
citation: { enabled: false },
annotationReply: { enabled: false },
},
isAgent: false,
isAdvancedMode: false,
isMobile: false,
isShowDebugPanel: false,
isShowHistoryModal: false,
isShowSelectDataSet: false,
modelConfig: createContextValue().modelConfig,
multipleModelConfigs: [],
onAutoAddPromptVariable: vi.fn(),
onAgentSettingChange: vi.fn(),
onCloseFeaturePanel: vi.fn(),
onCloseHistoryModal: vi.fn(),
onCloseSelectDataSet: vi.fn(),
onCompletionParamsChange: vi.fn(),
onConfirmUseGPT4: vi.fn(),
onEnableMultipleModelDebug: vi.fn(),
onFeaturesChange: vi.fn(),
onHideDebugPanel: vi.fn(),
onModelChange: vi.fn(),
onMultipleModelConfigsChange: vi.fn(),
onOpenAccountSettings: vi.fn(),
onOpenDebugPanel: vi.fn(),
onSaveHistory: vi.fn(),
onSelectDataSets: vi.fn(),
promptVariables: [],
selectedIds: [],
showAppConfigureFeaturesModal: false,
showLoading: false,
showUseGPT4Confirm: false,
setShowUseGPT4Confirm: vi.fn(),
...overrides,
})
describe('ConfigurationView', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should render a loading state before configuration data is ready', () => {
render(<ConfigurationView {...createViewModel({ showLoading: true })} />)
expect(screen.getByRole('status', { name: 'appApi.loading' })).toBeInTheDocument()
expect(screen.queryByTestId('app-publisher')).not.toBeInTheDocument()
})
it('should open the mobile debug panel from the header button', () => {
const onOpenDebugPanel = vi.fn()
render(<ConfigurationView {...createViewModel({ isMobile: true, onOpenDebugPanel })} />)
fireEvent.click(screen.getByRole('button', { name: /appDebug.operation.debugConfig/i }))
expect(onOpenDebugPanel).toHaveBeenCalledTimes(1)
})
it('should close the GPT-4 confirmation dialog when cancel is clicked', () => {
const setShowUseGPT4Confirm = vi.fn()
render(<ConfigurationView {...createViewModel({ showUseGPT4Confirm: true, setShowUseGPT4Confirm })} />)
fireEvent.click(screen.getByRole('button', { name: /operation.cancel/i }))
expect(setShowUseGPT4Confirm).toHaveBeenCalledWith(false)
})
})

View File

@@ -0,0 +1,32 @@
import { render } from '@testing-library/react'
import * as React from 'react'
import { useConfiguration } from '../hooks/use-configuration'
import Configuration from '../index'
const mockView = vi.fn((_: unknown) => <div data-testid="configuration-view" />)
vi.mock('../configuration-view', () => ({
default: (props: unknown) => mockView(props),
}))
vi.mock('../hooks/use-configuration', () => ({
useConfiguration: vi.fn(),
}))
describe('Configuration entry', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should pass the hook view model into ConfigurationView', () => {
const viewModel = {
showLoading: true,
}
vi.mocked(useConfiguration).mockReturnValue(viewModel as never)
render(<Configuration />)
expect(useConfiguration).toHaveBeenCalledTimes(1)
expect(mockView).toHaveBeenCalledWith(viewModel)
})
})

View File

@@ -0,0 +1,226 @@
import type { ModelConfig } from '@/models/debug'
import { AppModeEnum, ModelModeType, Resolution, TransferMethod } from '@/types/app'
import { buildConfigurationFeaturesData, getConfigurationPublishingState, withCollectionIconBasePath } from '../utils'
const createModelConfig = (overrides: Partial<ModelConfig> = {}): ModelConfig => ({
provider: 'openai',
model_id: 'gpt-4o',
mode: ModelModeType.chat,
configs: {
prompt_template: 'Hello',
prompt_variables: [],
},
chat_prompt_config: {
prompt: [],
} as ModelConfig['chat_prompt_config'],
completion_prompt_config: {
prompt: { text: '' },
conversation_histories_role: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
} as ModelConfig['completion_prompt_config'],
opening_statement: '',
more_like_this: { enabled: false },
suggested_questions: [],
suggested_questions_after_answer: { enabled: false },
speech_to_text: { enabled: false },
text_to_speech: { enabled: false, voice: '', language: '' },
file_upload: null,
retriever_resource: { enabled: false },
sensitive_word_avoidance: { enabled: false },
annotation_reply: null,
external_data_tools: [],
system_parameters: {
audio_file_size_limit: 1,
file_size_limit: 1,
image_file_size_limit: 1,
video_file_size_limit: 1,
workflow_file_upload_limit: 1,
},
dataSets: [],
agentConfig: {
enabled: false,
strategy: 'react',
max_iteration: 1,
tools: [],
} as ModelConfig['agentConfig'],
...overrides,
})
describe('configuration utils', () => {
describe('withCollectionIconBasePath', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should prefix relative collection icons with the base path', () => {
const result = withCollectionIconBasePath([
{ id: 'tool-1', icon: '/icons/tool.svg' },
{ id: 'tool-2', icon: '/console/icons/prefixed.svg' },
] as never, '/console')
expect(result[0].icon).toBe('/console/icons/tool.svg')
expect(result[1].icon).toBe('/console/icons/prefixed.svg')
})
})
describe('buildConfigurationFeaturesData', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should derive feature toggles and upload fallbacks from model config', () => {
const result = buildConfigurationFeaturesData(createModelConfig({
opening_statement: 'Welcome',
suggested_questions: ['How are you?'],
file_upload: {
enabled: true,
image: {
enabled: true,
detail: Resolution.low,
number_limits: 2,
transfer_methods: [TransferMethod.local_file],
},
allowed_file_types: ['image'],
allowed_file_extensions: ['.png'],
allowed_file_upload_methods: [TransferMethod.local_file],
number_limits: 2,
},
}), undefined)
expect(result.opening).toEqual({
enabled: true,
opening_statement: 'Welcome',
suggested_questions: ['How are you?'],
})
expect(result.file).toBeDefined()
expect(result.file!.enabled).toBe(true)
expect(result.file!.image!.detail).toBe(Resolution.low)
expect(result.file!.allowed_file_extensions).toEqual(['.png'])
})
})
describe('getConfigurationPublishingState', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should block publish when advanced completion mode is missing required blocks', () => {
const result = getConfigurationPublishingState({
chatPromptConfig: {
prompt: [],
} as never,
completionPromptConfig: {
prompt: { text: 'Answer' },
conversation_histories_role: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
} as never,
hasSetBlockStatus: {
context: false,
history: false,
query: false,
},
hasSetContextVar: false,
hasSelectedDataSets: false,
isAdvancedMode: true,
mode: AppModeEnum.CHAT,
modelModeType: ModelModeType.completion,
promptTemplate: 'ignored',
})
expect(result.promptEmpty).toBe(false)
expect(result.cannotPublish).toBe(true)
})
it('should require a context variable only for completion apps with selected datasets', () => {
const result = getConfigurationPublishingState({
chatPromptConfig: {
prompt: [],
} as never,
completionPromptConfig: {
prompt: { text: 'Completion prompt' },
conversation_histories_role: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
} as never,
hasSetBlockStatus: {
context: false,
history: true,
query: true,
},
hasSetContextVar: false,
hasSelectedDataSets: true,
isAdvancedMode: false,
mode: AppModeEnum.COMPLETION,
modelModeType: ModelModeType.completion,
promptTemplate: 'Prompt',
})
expect(result.promptEmpty).toBe(false)
expect(result.cannotPublish).toBe(false)
expect(result.contextVarEmpty).toBe(true)
})
it('should treat advanced completion chat prompts as empty when every segment is blank', () => {
const result = getConfigurationPublishingState({
chatPromptConfig: {
prompt: [{ text: '' }, { text: '' }],
} as never,
completionPromptConfig: {
prompt: { text: 'ignored' },
conversation_histories_role: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
} as never,
hasSetBlockStatus: {
context: true,
history: true,
query: true,
},
hasSetContextVar: true,
hasSelectedDataSets: false,
isAdvancedMode: true,
mode: AppModeEnum.COMPLETION,
modelModeType: ModelModeType.chat,
promptTemplate: 'ignored',
})
expect(result.promptEmpty).toBe(true)
expect(result.cannotPublish).toBe(true)
})
it('should treat advanced completion text prompts as empty when the completion prompt is missing', () => {
const result = getConfigurationPublishingState({
chatPromptConfig: {
prompt: [{ text: 'ignored' }],
} as never,
completionPromptConfig: {
prompt: { text: '' },
conversation_histories_role: {
user_prefix: 'user',
assistant_prefix: 'assistant',
},
} as never,
hasSetBlockStatus: {
context: true,
history: true,
query: true,
},
hasSetContextVar: true,
hasSelectedDataSets: false,
isAdvancedMode: true,
mode: AppModeEnum.COMPLETION,
modelModeType: ModelModeType.completion,
promptTemplate: 'ignored',
})
expect(result.promptEmpty).toBe(true)
expect(result.cannotPublish).toBe(true)
})
})
})

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import FeaturePanel from './index'
import FeaturePanel from '../index'
describe('FeaturePanel', () => {
// Rendering behavior for standard layout.

View File

@@ -3,7 +3,7 @@ import type { FC, ReactNode } from 'react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
export type IFeaturePanelProps = {
type IFeaturePanelProps = {
className?: string
headerIcon?: ReactNode
title: ReactNode

View File

@@ -1,5 +1,5 @@
import { render, screen } from '@testing-library/react'
import GroupName from './index'
import GroupName from '../index'
describe('GroupName', () => {
beforeEach(() => {

View File

@@ -2,7 +2,7 @@
import type { FC } from 'react'
import * as React from 'react'
export type IGroupNameProps = {
type IGroupNameProps = {
name: string
}

View File

@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react'
import OperationBtn from './index'
import OperationBtn from '../index'
vi.mock('@remixicon/react', () => ({
RiAddLine: (props: { className?: string }) => (

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