Compare commits

...
Author SHA1 Message Date
-LAN-andGitHub 61a222834a Merge branch 'main' into verify-email-reset-flow 2026-03-26 16:43:59 +08:00
-LAN-andGitHub e8657cc3de chore: Support merge queue status checks in required CI workflows (#34133) 2026-03-26 16:42:27 +08:00
-LAN-andGitHub 33dd82a3dd Merge branch 'main' into verify-email-reset-flow 2026-03-26 16:15:55 +08:00
e08c06cbc3 fix: import path (#34124)
Co-authored-by: -LAN- <laipz8200@outlook.com>
2026-03-26 16:13:53 +08:00
Mahmoud HamdyGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
8ca54ddf94 refactor(web): convert 7 enums to as-const objects (batch 5) (#33960)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-26 15:50:54 +08:00
3e073404cc fix: the menu of multi nodes always display on left top corner (#34120)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-03-26 15:49:42 +08:00
0acabf5f73 chore(deps): update picomatch version in nodejs-client and web packages (#34123)
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-03-26 15:49:19 +08:00
Stephen ZhouandGitHub 38285aa1ac chore: enable no-barrel-files (#34121) 2026-03-26 15:11:25 +08:00
Achieve3318GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
5341cd015b fix: dataset query created_by empty UUID in iteration subgraph (#34004) (#34044)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-26 14:57:19 +08:00
-LAN- 1e0e4a8b43 Merge remote-tracking branch 'origin/main' into verify-email-reset-flow 2026-03-26 14:50:57 +08:00
Stephen ZhouandGitHub c32eebf57d refactor: use ungh for github api (#34108) 2026-03-26 14:37:17 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
554ba6b8f3 chore(deps): bump pypdf from 6.9.1 to 6.9.2 in /api (#34099)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-26 13:27:04 +09:00
RenzoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a69b8c1e96 refactor: select in service API dataset document and segment controllers (#34101)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-26 13:24:54 +09:00
-LAN- f9b2ff59c8 Merge remote-tracking branch 'origin/main' into verify-email-reset-flow 2026-03-19 23:41:39 +08:00
-LAN- 0da31b1a14 Merge remote-tracking branch 'origin/main' into verify-email-reset-flow 2026-03-18 14:33:34 +08:00
-LAN- 10d1904e59 Merge remote-tracking branch 'origin/main' into verify-email-reset-flow 2026-03-11 23:46:34 +08:00
-LAN- 095b436621 Fix transfer ownership modal timer cleanup 2026-03-02 22:51:28 +08:00
-LAN- baaf4e8041 Merge origin/main and resolve workflow_app_service conflict 2026-03-02 22:12:24 +08:00
-LAN- bc41371975 Fix tenant-scoped account filtering for workflow app logs 2026-03-02 22:10:12 +08:00
-LAN- 9c5c935ed5 derive change-email phase from token context 2026-03-01 19:56:09 +08:00
-LAN- 559f8263b7 fix(type): narrow change-email phase lookup 2026-03-01 19:56:09 +08:00
-LAN- 59c5638342 refactor(auth): model change-email phases with StrEnum 2026-03-01 19:56:09 +08:00
-LAN- 897ffb6b35 fix(auth): prevent phase spoofing in change-email flow 2026-03-01 19:56:09 +08:00
-LAN- d367a6b1e1 test(auth): cover missing change-email transition cases 2026-03-01 19:56:09 +08:00
-LAN- daa9d38788 fix(auth): Implemented a minimal, stateful fix that closes the bypass.
Signed-off-by: -LAN- <laipz8200@outlook.com>
2026-03-01 19:56:08 +08:00
68 changed files with 1757 additions and 900 deletions
+24 -10
View File
@@ -2,6 +2,9 @@ name: autofix.ci
on:
pull_request:
branches: ["main"]
merge_group:
branches: ["main"]
types: [checks_requested]
push:
branches: ["main"]
permissions:
@@ -12,9 +15,15 @@ jobs:
if: github.repository == 'langgenius/dify'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Complete merge group check
if: github.event_name == 'merge_group'
run: echo "autofix.ci updates pull request branches, not merge group refs."
- if: github.event_name != 'merge_group'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check Docker Compose inputs
if: github.event_name != 'merge_group'
id: docker-compose-changes
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with:
@@ -24,30 +33,34 @@ jobs:
docker/docker-compose-template.yaml
docker/docker-compose.yaml
- name: Check web inputs
if: github.event_name != 'merge_group'
id: web-changes
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with:
files: |
web/**
- name: Check api inputs
if: github.event_name != 'merge_group'
id: api-changes
uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with:
files: |
api/**
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- if: github.event_name != 'merge_group'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Generate Docker Compose
if: steps.docker-compose-changes.outputs.any_changed == 'true'
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
run: |
cd docker
./generate_docker_compose
- if: steps.api-changes.outputs.any_changed == 'true'
- if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
run: |
cd api
uv sync --dev
@@ -59,13 +72,13 @@ jobs:
uv run ruff format ..
- name: count migration progress
if: steps.api-changes.outputs.any_changed == 'true'
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
run: |
cd api
./cnt_base.sh
- name: ast-grep
if: steps.api-changes.outputs.any_changed == 'true'
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
run: |
# ast-grep exits 1 if no matches are found; allow idempotent runs.
uvx --from ast-grep-cli ast-grep --pattern 'db.session.query($WHATEVER).filter($HERE)' --rewrite 'db.session.query($WHATEVER).where($HERE)' -l py --update-all || true
@@ -95,13 +108,14 @@ jobs:
find . -name "*.py.bak" -type f -delete
- name: Setup web environment
if: steps.web-changes.outputs.any_changed == 'true'
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-web
- name: ESLint autofix
if: steps.web-changes.outputs.any_changed == 'true'
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
run: |
cd web
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3
- if: github.event_name != 'merge_group'
uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3
+3
View File
@@ -3,6 +3,9 @@ name: Main CI Pipeline
on:
pull_request:
branches: ["main"]
merge_group:
branches: ["main"]
types: [checks_requested]
push:
branches: ["main"]
@@ -7,6 +7,9 @@ on:
- edited
- reopened
- synchronize
merge_group:
branches: ["main"]
types: [checks_requested]
jobs:
lint:
@@ -15,7 +18,11 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- name: Complete merge group check
if: github.event_name == 'merge_group'
run: echo "Semantic PR title validation is handled on pull requests."
- name: Check title
if: github.event_name == 'pull_request'
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+33 -7
View File
@@ -134,7 +134,6 @@ class EducationAutocompleteQuery(BaseModel):
class ChangeEmailSendPayload(BaseModel):
email: EmailStr
language: str | None = None
phase: str | None = None
token: str | None = None
@@ -548,13 +547,17 @@ class ChangeEmailSendEmailApi(Resource):
account = None
user_email = None
email_for_sending = args.email.lower()
if args.phase is not None and args.phase == "new_email":
if args.token is None:
raise InvalidTokenError()
send_phase = AccountService.CHANGE_EMAIL_PHASE_OLD
if args.token is not None:
send_phase = AccountService.CHANGE_EMAIL_PHASE_NEW
reset_data = AccountService.get_change_email_data(args.token)
if reset_data is None:
raise InvalidTokenError()
reset_token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
if reset_token_phase != AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED:
raise InvalidTokenError()
user_email = reset_data.get("email", "")
if user_email.lower() != current_user.email.lower():
@@ -574,7 +577,7 @@ class ChangeEmailSendEmailApi(Resource):
email=email_for_sending,
old_email=user_email,
language=language,
phase=args.phase,
phase=send_phase,
)
return {"result": "success", "data": token}
@@ -609,12 +612,26 @@ class ChangeEmailCheckApi(Resource):
AccountService.add_change_email_error_rate_limit(user_email)
raise EmailCodeError()
phase_transitions: dict[str, str] = {
AccountService.CHANGE_EMAIL_PHASE_OLD: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
AccountService.CHANGE_EMAIL_PHASE_NEW: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
}
token_phase = token_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
if not isinstance(token_phase, str):
raise InvalidTokenError()
refreshed_phase = phase_transitions.get(token_phase)
if refreshed_phase is None:
raise InvalidTokenError()
# Verified, revoke the first token
AccountService.revoke_change_email_token(args.token)
# Refresh token data by generating a new token
_, new_token = AccountService.generate_change_email_token(
user_email, code=args.code, old_email=token_data.get("old_email"), additional_data={}
user_email,
code=args.code,
old_email=token_data.get("old_email"),
additional_data={AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: refreshed_phase},
)
AccountService.reset_change_email_error_rate_limit(user_email)
@@ -644,13 +661,22 @@ class ChangeEmailResetApi(Resource):
if not reset_data:
raise InvalidTokenError()
AccountService.revoke_change_email_token(args.token)
token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
if token_phase != AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED:
raise InvalidTokenError()
token_email = reset_data.get("email")
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
if normalized_token_email != normalized_new_email:
raise InvalidTokenError()
old_email = reset_data.get("old_email", "")
current_user, _ = current_account_with_tenant()
if current_user.email.lower() != old_email.lower():
raise AccountNotFound()
AccountService.revoke_change_email_token(args.token)
updated_account = AccountService.update_account_email(current_user, email=normalized_new_email)
AccountService.send_change_email_completed_notify_email(
+35 -18
View File
@@ -6,7 +6,7 @@ from uuid import UUID
from flask import request, send_file
from flask_restx import marshal
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import desc, select
from sqlalchemy import desc, func, select
from werkzeug.exceptions import Forbidden, NotFound
import services
@@ -155,7 +155,9 @@ class DocumentAddByTextApi(DatasetApiResource):
dataset_id = str(dataset_id)
tenant_id = str(tenant_id)
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise ValueError("Dataset does not exist.")
@@ -238,7 +240,9 @@ class DocumentUpdateByTextApi(DatasetApiResource):
def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
"""Update document by text."""
payload = DocumentTextUpdate.model_validate(service_api_ns.payload or {})
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == str(dataset_id)).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == str(dataset_id)).limit(1)
)
args = payload.model_dump(exclude_none=True)
if not dataset:
raise ValueError("Dataset does not exist.")
@@ -315,7 +319,9 @@ class DocumentAddByFileApi(DatasetApiResource):
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id):
"""Create document by upload file."""
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise ValueError("Dataset does not exist.")
@@ -425,7 +431,9 @@ class DocumentUpdateByFileApi(DatasetApiResource):
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id, document_id):
"""Update document by upload file."""
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise ValueError("Dataset does not exist.")
@@ -515,7 +523,9 @@ class DocumentListApi(DatasetApiResource):
dataset_id = str(dataset_id)
tenant_id = str(tenant_id)
query_params = DocumentListQuery.model_validate(request.args.to_dict())
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise NotFound("Dataset not found.")
@@ -609,7 +619,9 @@ class DocumentIndexingStatusApi(DatasetApiResource):
batch = str(batch)
tenant_id = str(tenant_id)
# get dataset
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise NotFound("Dataset not found.")
# get documents
@@ -619,20 +631,23 @@ class DocumentIndexingStatusApi(DatasetApiResource):
documents_status = []
for document in documents:
completed_segments = (
db.session.query(DocumentSegment)
.where(
DocumentSegment.completed_at.isnot(None),
DocumentSegment.document_id == str(document.id),
DocumentSegment.status != SegmentStatus.RE_SEGMENT,
db.session.scalar(
select(func.count(DocumentSegment.id)).where(
DocumentSegment.completed_at.isnot(None),
DocumentSegment.document_id == str(document.id),
DocumentSegment.status != SegmentStatus.RE_SEGMENT,
)
)
.count()
or 0
)
total_segments = (
db.session.query(DocumentSegment)
.where(
DocumentSegment.document_id == str(document.id), DocumentSegment.status != SegmentStatus.RE_SEGMENT
db.session.scalar(
select(func.count(DocumentSegment.id)).where(
DocumentSegment.document_id == str(document.id),
DocumentSegment.status != SegmentStatus.RE_SEGMENT,
)
)
.count()
or 0
)
# Create a dictionary with document attributes and additional fields
document_dict = {
@@ -822,7 +837,9 @@ class DocumentApi(DatasetApiResource):
tenant_id = str(tenant_id)
# get dataset info
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise ValueError("Dataset does not exist.")
+28 -9
View File
@@ -3,6 +3,7 @@ from typing import Any
from flask import request
from flask_restx import marshal
from pydantic import BaseModel, Field
from sqlalchemy import select
from werkzeug.exceptions import NotFound
from configs import dify_config
@@ -92,7 +93,9 @@ class SegmentApi(DatasetApiResource):
_, current_tenant_id = current_account_with_tenant()
"""Create single segment."""
# check dataset
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise NotFound("Dataset not found.")
# check document
@@ -150,7 +153,9 @@ class SegmentApi(DatasetApiResource):
# check dataset
page = request.args.get("page", default=1, type=int)
limit = request.args.get("limit", default=20, type=int)
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise NotFound("Dataset not found.")
# check document
@@ -220,7 +225,9 @@ class DatasetSegmentApi(DatasetApiResource):
def delete(self, tenant_id: str, dataset_id: str, document_id: str, segment_id: str):
_, current_tenant_id = current_account_with_tenant()
# check dataset
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise NotFound("Dataset not found.")
# check user's model setting
@@ -254,7 +261,9 @@ class DatasetSegmentApi(DatasetApiResource):
def post(self, tenant_id: str, dataset_id: str, document_id: str, segment_id: str):
_, current_tenant_id = current_account_with_tenant()
# check dataset
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise NotFound("Dataset not found.")
# check user's model setting
@@ -301,7 +310,9 @@ class DatasetSegmentApi(DatasetApiResource):
def get(self, tenant_id: str, dataset_id: str, document_id: str, segment_id: str):
_, current_tenant_id = current_account_with_tenant()
# check dataset
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise NotFound("Dataset not found.")
# check user's model setting
@@ -344,7 +355,9 @@ class ChildChunkApi(DatasetApiResource):
_, current_tenant_id = current_account_with_tenant()
"""Create child chunk."""
# check dataset
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise NotFound("Dataset not found.")
@@ -402,7 +415,9 @@ class ChildChunkApi(DatasetApiResource):
_, current_tenant_id = current_account_with_tenant()
"""Get child chunks."""
# check dataset
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise NotFound("Dataset not found.")
@@ -468,7 +483,9 @@ class DatasetChildChunkApi(DatasetApiResource):
_, current_tenant_id = current_account_with_tenant()
"""Delete child chunk."""
# check dataset
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise NotFound("Dataset not found.")
@@ -527,7 +544,9 @@ class DatasetChildChunkApi(DatasetApiResource):
_, current_tenant_id = current_account_with_tenant()
"""Update child chunk."""
# check dataset
dataset = db.session.query(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).first()
dataset = db.session.scalar(
select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
)
if not dataset:
raise NotFound("Dataset not found.")
@@ -137,6 +137,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
workflow=self._workflow,
single_iteration_run=self.application_generate_entity.single_iteration_run,
single_loop_run=self.application_generate_entity.single_loop_run,
user_id=self.application_generate_entity.user_id,
)
else:
inputs = self.application_generate_entity.inputs
@@ -106,6 +106,7 @@ class PipelineRunner(WorkflowBasedAppRunner):
workflow=workflow,
single_iteration_run=self.application_generate_entity.single_iteration_run,
single_loop_run=self.application_generate_entity.single_loop_run,
user_id=self.application_generate_entity.user_id,
)
else:
inputs = self.application_generate_entity.inputs
+1
View File
@@ -92,6 +92,7 @@ class WorkflowAppRunner(WorkflowBasedAppRunner):
workflow=self._workflow,
single_iteration_run=self.application_generate_entity.single_iteration_run,
single_loop_run=self.application_generate_entity.single_loop_run,
user_id=self.application_generate_entity.user_id,
)
else:
inputs = self.application_generate_entity.inputs
+7 -1
View File
@@ -164,6 +164,8 @@ class WorkflowBasedAppRunner:
workflow: Workflow,
single_iteration_run: Any | None = None,
single_loop_run: Any | None = None,
*,
user_id: str,
) -> tuple[Graph, VariablePool, GraphRuntimeState]:
"""
Prepare graph, variable pool, and runtime state for single node execution
@@ -200,6 +202,7 @@ class WorkflowBasedAppRunner:
graph_runtime_state=graph_runtime_state,
node_type_filter_key="iteration_id",
node_type_label="iteration",
user_id=user_id,
)
elif single_loop_run:
graph, variable_pool = self._get_graph_and_variable_pool_for_single_node_run(
@@ -209,6 +212,7 @@ class WorkflowBasedAppRunner:
graph_runtime_state=graph_runtime_state,
node_type_filter_key="loop_id",
node_type_label="loop",
user_id=user_id,
)
else:
raise ValueError("Neither single_iteration_run nor single_loop_run is specified")
@@ -225,6 +229,8 @@ class WorkflowBasedAppRunner:
graph_runtime_state: GraphRuntimeState,
node_type_filter_key: str, # 'iteration_id' or 'loop_id'
node_type_label: str = "node", # 'iteration' or 'loop' for error messages
*,
user_id: str = "",
) -> tuple[Graph, VariablePool]:
"""
Get graph and variable pool for single node execution (iteration or loop).
@@ -290,7 +296,7 @@ class WorkflowBasedAppRunner:
run_context=build_dify_run_context(
tenant_id=workflow.tenant_id,
app_id=self._app_id,
user_id="",
user_id=user_id,
user_from=UserFrom.ACCOUNT,
invoke_from=InvokeFrom.DEBUGGER,
),
+10 -4
View File
@@ -71,6 +71,7 @@ from graphon.model_runtime.entities.llm_entities import LLMMode, LLMResult, LLMU
from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageRole, PromptMessageTool
from graphon.model_runtime.entities.model_entities import ModelFeature, ModelType
from graphon.model_runtime.model_providers.__base.large_language_model import LargeLanguageModel
from libs.helper import parse_uuid_str_or_none
from libs.json_in_md_parser import parse_and_check_json_markdown
from models import UploadFile
from models.dataset import (
@@ -1024,8 +1025,13 @@ class DatasetRetrieval:
"""
if not query and not attachment_ids:
return
created_by_role = self._resolve_creator_user_role(user_from)
if created_by_role is None:
created_by = parse_uuid_str_or_none(user_id)
if created_by is None:
logger.debug(
"Skipping dataset query log: empty created_by user_id (user_from=%s, app_id=%s)",
user_from,
app_id,
)
return
dataset_queries = []
for dataset_id in dataset_ids:
@@ -1041,8 +1047,8 @@ class DatasetRetrieval:
content=json.dumps(contents),
source=DatasetQuerySource.APP,
source_app_id=app_id,
created_by_role=created_by_role,
created_by=user_id,
created_by_role=CreatorUserRole(user_from),
created_by=created_by,
)
dataset_queries.append(dataset_query)
if dataset_queries:
+12
View File
@@ -174,6 +174,18 @@ def normalize_uuid(value: str | UUID) -> str:
raise ValueError("must be a valid UUID") from exc
def parse_uuid_str_or_none(value: str | None) -> str | None:
"""
Return None for missing/empty UUID-like values.
Keep non-empty values unchanged to avoid changing behavior in paths that
currently pass placeholder IDs in tests/mocks.
"""
if value is None or not str(value).strip():
return None
return str(value)
UUIDStrOrEmpty = Annotated[str, AfterValidator(normalize_uuid)]
+22 -1
View File
@@ -4,6 +4,7 @@ import logging
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from enum import StrEnum
from hashlib import sha256
from typing import Any, cast
@@ -90,12 +91,25 @@ class TokenPair(BaseModel):
csrf_token: str
class ChangeEmailPhase(StrEnum):
OLD = "old_email"
OLD_VERIFIED = "old_email_verified"
NEW = "new_email"
NEW_VERIFIED = "new_email_verified"
REFRESH_TOKEN_PREFIX = "refresh_token:"
ACCOUNT_REFRESH_TOKEN_PREFIX = "account_refresh_token:"
REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS)
class AccountService:
CHANGE_EMAIL_TOKEN_PHASE_KEY = "email_change_phase"
CHANGE_EMAIL_PHASE_OLD = ChangeEmailPhase.OLD
CHANGE_EMAIL_PHASE_OLD_VERIFIED = ChangeEmailPhase.OLD_VERIFIED
CHANGE_EMAIL_PHASE_NEW = ChangeEmailPhase.NEW
CHANGE_EMAIL_PHASE_NEW_VERIFIED = ChangeEmailPhase.NEW_VERIFIED
reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1)
email_code_login_rate_limiter = RateLimiter(
@@ -552,13 +566,20 @@ class AccountService:
raise ValueError("Email must be provided.")
if not phase:
raise ValueError("phase must be provided.")
if phase not in (cls.CHANGE_EMAIL_PHASE_OLD, cls.CHANGE_EMAIL_PHASE_NEW):
raise ValueError("phase must be one of old_email or new_email.")
if cls.change_email_rate_limiter.is_rate_limited(account_email):
from controllers.console.auth.error import EmailChangeRateLimitExceededError
raise EmailChangeRateLimitExceededError(int(cls.change_email_rate_limiter.time_window / 60))
code, token = cls.generate_change_email_token(account_email, account, old_email=old_email)
code, token = cls.generate_change_email_token(
account_email,
account,
old_email=old_email,
additional_data={cls.CHANGE_EMAIL_TOKEN_PHASE_KEY: phase},
)
send_change_mail_task.delay(
language=language,
@@ -954,6 +954,16 @@ class TestWorkflowAppService:
assert result_with_new_email["total"] == 3
assert all(log.created_by_role == CreatorUserRole.ACCOUNT for log in result_with_new_email["data"])
# Create another account in a different tenant using the original email.
# Querying by the old email should still fail for this app's tenant.
cross_tenant_account = AccountService.create_account(
email=original_email,
name=fake.name(),
interface_language="en-US",
password=fake.password(length=12),
)
TenantService.create_owner_tenant_if_not_exist(cross_tenant_account, name=fake.company())
# Old email unbound, is unexpected input, should raise ValueError
with pytest.raises(ValueError) as exc_info:
service.get_paginate_workflow_app_logs(
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
import pytest
from flask import Flask, g
from controllers.console.auth.error import InvalidTokenError
from controllers.console.workspace.account import (
AccountDeleteUpdateFeedbackApi,
ChangeEmailCheckApi,
@@ -52,7 +53,7 @@ class TestChangeEmailSend:
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_normalize_new_email_phase(
def test_should_infer_new_email_phase_from_token(
self,
mock_features,
mock_csrf,
@@ -68,13 +69,16 @@ class TestChangeEmailSend:
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_account = _build_account("current@example.com", "acc1")
mock_current_account.return_value = (mock_account, None)
mock_get_change_data.return_value = {"email": "current@example.com"}
mock_get_change_data.return_value = {
"email": "current@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
}
mock_send_email.return_value = "token-abc"
with app.test_request_context(
"/account/change-email",
method="POST",
json={"email": "New@Example.com", "language": "en-US", "phase": "new_email", "token": "token-123"},
json={"email": "New@Example.com", "language": "en-US", "token": "token-123"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
response = ChangeEmailSendEmailApi().post()
@@ -91,6 +95,107 @@ class TestChangeEmailSend:
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
mock_csrf.assert_called_once()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.db")
@patch("controllers.console.workspace.account.Session")
@patch("controllers.console.workspace.account.AccountService.get_account_by_email_with_case_fallback")
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_ignore_client_phase_and_use_old_phase_when_token_missing(
self,
mock_features,
mock_csrf,
mock_extract_ip,
mock_is_ip_limit,
mock_send_email,
mock_get_account_by_email,
mock_session_cls,
mock_account_db,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_current_account.return_value = (_build_account("current@example.com", "current"), None)
existing_account = _build_account("old@example.com", "acc-old")
mock_get_account_by_email.return_value = existing_account
mock_send_email.return_value = "token-legacy"
mock_session = MagicMock()
mock_session_cm = MagicMock()
mock_session_cm.__enter__.return_value = mock_session
mock_session_cm.__exit__.return_value = None
mock_session_cls.return_value = mock_session_cm
mock_account_db.engine = MagicMock()
with app.test_request_context(
"/account/change-email",
method="POST",
json={"email": "old@example.com", "language": "en-US", "phase": "new_email"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
response = ChangeEmailSendEmailApi().post()
assert response == {"result": "success", "data": "token-legacy"}
mock_get_account_by_email.assert_called_once_with("old@example.com", session=mock_session)
mock_send_email.assert_called_once_with(
account=existing_account,
email="old@example.com",
old_email="old@example.com",
language="en-US",
phase=AccountService.CHANGE_EMAIL_PHASE_OLD,
)
mock_extract_ip.assert_called_once()
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
mock_csrf.assert_called_once()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_reject_unverified_old_email_token_for_new_email_phase(
self,
mock_features,
mock_csrf,
mock_extract_ip,
mock_is_ip_limit,
mock_send_email,
mock_get_change_data,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_account = _build_account("current@example.com", "acc1")
mock_current_account.return_value = (mock_account, None)
mock_get_change_data.return_value = {
"email": "current@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
}
with app.test_request_context(
"/account/change-email",
method="POST",
json={"email": "New@Example.com", "language": "en-US", "token": "token-123"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
with pytest.raises(InvalidTokenError):
ChangeEmailSendEmailApi().post()
mock_send_email.assert_not_called()
mock_extract_ip.assert_called_once()
mock_is_ip_limit.assert_called_once_with("127.0.0.1")
mock_csrf.assert_called_once()
class TestChangeEmailValidity:
@patch("controllers.console.wraps.db")
@@ -122,7 +227,12 @@ class TestChangeEmailValidity:
mock_account = _build_account("user@example.com", "acc2")
mock_current_account.return_value = (mock_account, None)
mock_is_rate_limit.return_value = False
mock_get_data.return_value = {"email": "user@example.com", "code": "1234", "old_email": "old@example.com"}
mock_get_data.return_value = {
"email": "user@example.com",
"code": "1234",
"old_email": "old@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
}
mock_generate_token.return_value = (None, "new-token")
with app.test_request_context(
@@ -138,11 +248,76 @@ class TestChangeEmailValidity:
mock_add_rate.assert_not_called()
mock_revoke_token.assert_called_once_with("token-123")
mock_generate_token.assert_called_once_with(
"user@example.com", code="1234", old_email="old@example.com", additional_data={}
"user@example.com",
code="1234",
old_email="old@example.com",
additional_data={
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED
},
)
mock_reset_rate.assert_called_once_with("user@example.com")
mock_csrf.assert_called_once()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.reset_change_email_error_rate_limit")
@patch("controllers.console.workspace.account.AccountService.generate_change_email_token")
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.add_change_email_error_rate_limit")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_refresh_new_email_phase_to_verified(
self,
mock_features,
mock_csrf,
mock_is_rate_limit,
mock_get_data,
mock_add_rate,
mock_revoke_token,
mock_generate_token,
mock_reset_rate,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_account = _build_account("old@example.com", "acc2")
mock_current_account.return_value = (mock_account, None)
mock_is_rate_limit.return_value = False
mock_get_data.return_value = {
"email": "new@example.com",
"code": "5678",
"old_email": "old@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW,
}
mock_generate_token.return_value = (None, "new-phase-token")
with app.test_request_context(
"/account/change-email/validity",
method="POST",
json={"email": "New@Example.com", "code": "5678", "token": "token-456"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
response = ChangeEmailCheckApi().post()
assert response == {"is_valid": True, "email": "new@example.com", "token": "new-phase-token"}
mock_is_rate_limit.assert_called_once_with("new@example.com")
mock_add_rate.assert_not_called()
mock_revoke_token.assert_called_once_with("token-456")
mock_generate_token.assert_called_once_with(
"new@example.com",
code="5678",
old_email="old@example.com",
additional_data={
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED
},
)
mock_reset_rate.assert_called_once_with("new@example.com")
mock_csrf.assert_called_once()
class TestChangeEmailReset:
@patch("controllers.console.wraps.db")
@@ -175,7 +350,11 @@ class TestChangeEmailReset:
mock_current_account.return_value = (current_user, None)
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = {"old_email": "OLD@example.com"}
mock_get_data.return_value = {
"old_email": "OLD@example.com",
"email": "new@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
}
mock_account_after_update = _build_account("new@example.com", "acc3-updated")
mock_update_account.return_value = mock_account_after_update
@@ -194,6 +373,106 @@ class TestChangeEmailReset:
mock_send_notify.assert_called_once_with(email="new@example.com")
mock_csrf.assert_called_once()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
@patch("controllers.console.workspace.account.AccountService.update_account_email")
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_reject_old_phase_token_for_reset(
self,
mock_features,
mock_csrf,
mock_is_freeze,
mock_check_unique,
mock_get_data,
mock_revoke_token,
mock_update_account,
mock_send_notify,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
current_user = _build_account("old@example.com", "acc3")
mock_current_account.return_value = (current_user, None)
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = {
"old_email": "OLD@example.com",
"email": "old@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
}
with app.test_request_context(
"/account/change-email/reset",
method="POST",
json={"new_email": "new@example.com", "token": "token-123"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
with pytest.raises(InvalidTokenError):
ChangeEmailResetApi().post()
mock_revoke_token.assert_not_called()
mock_update_account.assert_not_called()
mock_send_notify.assert_not_called()
mock_csrf.assert_called_once()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
@patch("controllers.console.workspace.account.AccountService.update_account_email")
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_reject_mismatched_new_email_for_verified_token(
self,
mock_features,
mock_csrf,
mock_is_freeze,
mock_check_unique,
mock_get_data,
mock_revoke_token,
mock_update_account,
mock_send_notify,
mock_current_account,
mock_db,
app,
):
_mock_wraps_db(mock_db)
mock_features.return_value = SimpleNamespace(enable_change_email=True)
current_user = _build_account("old@example.com", "acc3")
mock_current_account.return_value = (current_user, None)
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = {
"old_email": "OLD@example.com",
"email": "another@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
}
with app.test_request_context(
"/account/change-email/reset",
method="POST",
json={"new_email": "new@example.com", "token": "token-789"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
with pytest.raises(InvalidTokenError):
ChangeEmailResetApi().post()
mock_revoke_token.assert_not_called()
mock_update_account.assert_not_called()
mock_send_notify.assert_not_called()
mock_csrf.assert_called_once()
class TestAccountDeletionFeedback:
@patch("controllers.console.wraps.db")
@@ -788,7 +788,7 @@ class TestSegmentApiGet:
"""Test successful segment list retrieval."""
# Arrange
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock(doc_form=IndexStructureType.PARAGRAPH_INDEX)
mock_seg_svc.get_segments.return_value = ([mock_segment], 1)
mock_marshal.return_value = [{"id": mock_segment.id}]
@@ -813,7 +813,7 @@ class TestSegmentApiGet:
"""Test 404 when dataset not found."""
# Arrange
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with app.test_request_context(
@@ -833,7 +833,7 @@ class TestSegmentApiGet:
"""Test 404 when document not found."""
# Arrange
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = None
# Act & Assert
@@ -899,7 +899,7 @@ class TestSegmentApiPost:
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_dataset.indexing_technique = "economy"
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc = Mock()
mock_doc.indexing_status = "completed"
@@ -950,7 +950,7 @@ class TestSegmentApiPost:
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_dataset.indexing_technique = "economy"
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc = Mock()
mock_doc.indexing_status = "completed"
@@ -992,7 +992,7 @@ class TestSegmentApiPost:
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc = Mock()
mock_doc.indexing_status = "indexing" # Not completed
@@ -1043,7 +1043,7 @@ class TestDatasetSegmentApiDelete:
"""Test successful segment deletion."""
# Arrange
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc = Mock()
@@ -1087,7 +1087,7 @@ class TestDatasetSegmentApiDelete:
"""Test 404 when segment not found."""
# Arrange
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc = Mock()
mock_doc.indexing_status = "completed"
@@ -1129,7 +1129,7 @@ class TestDatasetSegmentApiDelete:
"""Test 404 when dataset not found for delete."""
# Arrange
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with app.test_request_context(
@@ -1163,7 +1163,7 @@ class TestDatasetSegmentApiDelete:
"""Test 404 when document not found for delete."""
# Arrange
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc_svc.get_document.return_value = None
@@ -1233,7 +1233,7 @@ class TestDatasetSegmentApiUpdate:
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_dataset.indexing_technique = "economy"
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = mock_segment
@@ -1280,7 +1280,7 @@ class TestDatasetSegmentApiUpdate:
"""Test 404 when dataset not found for update."""
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/doc-id/segments/seg-id",
@@ -1321,7 +1321,7 @@ class TestDatasetSegmentApiUpdate:
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_dataset.indexing_technique = "economy"
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = None
@@ -1370,7 +1370,7 @@ class TestDatasetSegmentApiGetSingle:
):
"""Test successful single segment retrieval."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc = Mock(doc_form=IndexStructureType.PARAGRAPH_INDEX)
mock_doc_svc.get_document.return_value = mock_doc
@@ -1405,7 +1405,7 @@ class TestDatasetSegmentApiGetSingle:
):
"""Test 404 when dataset not found."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/doc-id/segments/seg-id",
@@ -1436,7 +1436,7 @@ class TestDatasetSegmentApiGetSingle:
):
"""Test 404 when document not found."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc_svc.get_document.return_value = None
@@ -1471,7 +1471,7 @@ class TestDatasetSegmentApiGetSingle:
):
"""Test 404 when segment not found."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_dataset_svc.check_dataset_model_setting.return_value = None
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = None
@@ -1515,7 +1515,7 @@ class TestChildChunkApiGet:
):
"""Test successful child chunk list retrieval."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = Mock()
@@ -1554,7 +1554,7 @@ class TestChildChunkApiGet:
):
"""Test 404 when dataset not found."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/doc-id/segments/seg-id/child_chunks",
@@ -1583,7 +1583,7 @@ class TestChildChunkApiGet:
):
"""Test 404 when document not found."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = None
with app.test_request_context(
@@ -1615,7 +1615,7 @@ class TestChildChunkApiGet:
):
"""Test 404 when segment not found."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = None
@@ -1676,7 +1676,7 @@ class TestChildChunkApiPost:
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_dataset.indexing_technique = "economy"
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = Mock()
mock_child = Mock()
@@ -1717,7 +1717,7 @@ class TestChildChunkApiPost:
"""Test 404 when dataset not found."""
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
with app.test_request_context(
f"/datasets/{mock_dataset.id}/documents/doc-id/segments/seg-id/child_chunks",
@@ -1755,7 +1755,7 @@ class TestChildChunkApiPost:
"""Test 404 when segment not found."""
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
mock_seg_svc.get_segment_by_id.return_value = None
@@ -1808,7 +1808,7 @@ class TestDatasetChildChunkApiDelete:
):
"""Test successful child chunk deletion."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc = Mock()
mock_doc_svc.get_document.return_value = mock_doc
@@ -1858,7 +1858,7 @@ class TestDatasetChildChunkApiDelete:
):
"""Test 404 when child chunk not found."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
segment_id = str(uuid.uuid4())
@@ -1899,7 +1899,7 @@ class TestDatasetChildChunkApiDelete:
):
"""Test 404 when segment does not belong to the document."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
segment_id = str(uuid.uuid4())
@@ -1939,7 +1939,7 @@ class TestDatasetChildChunkApiDelete:
):
"""Test 404 when child chunk does not belong to the segment."""
mock_account_fn.return_value = (Mock(), mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = Mock()
segment_id = str(uuid.uuid4())
@@ -717,7 +717,7 @@ class TestDocumentApiDelete:
dataset_id = str(uuid.uuid4())
mock_dataset = Mock()
mock_dataset.id = dataset_id
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = mock_document
mock_doc_svc.check_archived.return_value = False
@@ -746,7 +746,7 @@ class TestDocumentApiDelete:
document_id = str(uuid.uuid4())
mock_dataset = Mock()
mock_dataset.id = dataset_id
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = None
@@ -767,7 +767,7 @@ class TestDocumentApiDelete:
dataset_id = str(uuid.uuid4())
mock_dataset = Mock()
mock_dataset.id = dataset_id
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_document.return_value = mock_document
mock_doc_svc.check_archived.return_value = True
@@ -788,7 +788,7 @@ class TestDocumentApiDelete:
# Arrange
dataset_id = str(uuid.uuid4())
document_id = str(uuid.uuid4())
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with app.test_request_context(
@@ -809,7 +809,7 @@ class TestDocumentListApi:
def test_list_documents_success(self, mock_db, mock_doc_svc, mock_marshal, app, mock_tenant, mock_dataset):
"""Test successful document list retrieval."""
# Arrange
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_pagination = Mock()
mock_pagination.items = [Mock(), Mock()]
@@ -838,7 +838,7 @@ class TestDocumentListApi:
def test_list_documents_dataset_not_found(self, mock_db, app, mock_tenant, mock_dataset):
"""Test 404 when dataset not found."""
# Arrange
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with app.test_request_context(
@@ -860,8 +860,6 @@ class TestDocumentIndexingStatusApi:
"""Test successful indexing status retrieval."""
# Arrange
batch_id = "batch_123"
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_doc = Mock()
mock_doc.id = str(uuid.uuid4())
mock_doc.is_paused = False
@@ -877,8 +875,8 @@ class TestDocumentIndexingStatusApi:
mock_doc_svc.get_batch_documents.return_value = [mock_doc]
# Mock segment count queries
mock_db.session.query.return_value.where.return_value.where.return_value.count.return_value = 5
# scalar() called 3 times: dataset lookup, completed_segments count, total_segments count
mock_db.session.scalar.side_effect = [mock_dataset, 5, 5]
mock_marshal.return_value = {"id": mock_doc.id, "indexing_status": "completed"}
# Act
@@ -898,7 +896,7 @@ class TestDocumentIndexingStatusApi:
"""Test 404 when dataset not found."""
# Arrange
batch_id = "batch_123"
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with app.test_request_context(
@@ -915,7 +913,7 @@ class TestDocumentIndexingStatusApi:
"""Test 404 when no documents found for batch."""
# Arrange
batch_id = "batch_empty"
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_doc_svc.get_batch_documents.return_value = []
# Act & Assert
@@ -986,7 +984,7 @@ class TestDocumentAddByTextApi:
# Arrange — neutralise billing decorators
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_dataset.indexing_technique = "economy"
mock_current_user.id = str(uuid.uuid4())
@@ -1035,7 +1033,7 @@ class TestDocumentAddByTextApi:
# Arrange — neutralise billing decorators
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
# Act & Assert
with app.test_request_context(
@@ -1064,7 +1062,7 @@ class TestDocumentAddByTextApi:
self._setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_dataset.indexing_technique = None
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
# Act & Assert
with app.test_request_context(
@@ -1150,7 +1148,7 @@ class TestDocumentUpdateByTextApiPost:
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_dataset.indexing_technique = "economy"
mock_dataset.latest_process_rule = Mock()
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_current_user.id = "user-1"
mock_upload = Mock()
@@ -1193,7 +1191,7 @@ class TestDocumentUpdateByTextApiPost:
):
"""Test ValueError when dataset not found."""
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
doc_id = str(uuid.uuid4())
with app.test_request_context(
@@ -1232,7 +1230,7 @@ class TestDocumentAddByFileApiPost:
):
"""Test ValueError when dataset not found."""
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
from io import BytesIO
@@ -1263,7 +1261,7 @@ class TestDocumentAddByFileApiPost:
"""Test ValueError when dataset is external."""
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_dataset.provider = "external"
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
from io import BytesIO
@@ -1298,7 +1296,7 @@ class TestDocumentAddByFileApiPost:
mock_dataset.provider = "vendor"
mock_dataset.indexing_technique = "economy"
mock_dataset.chunk_structure = None
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
with app.test_request_context(
f"/datasets/{mock_dataset.id}/document/create_by_file",
@@ -1328,7 +1326,7 @@ class TestDocumentAddByFileApiPost:
mock_dataset.provider = "vendor"
mock_dataset.indexing_technique = None
mock_dataset.chunk_structure = None
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
from io import BytesIO
@@ -1366,7 +1364,7 @@ class TestDocumentUpdateByFileApiPost:
):
"""Test ValueError when dataset not found."""
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_db.session.query.return_value.where.return_value.first.return_value = None
mock_db.session.scalar.return_value = None
from io import BytesIO
@@ -1402,7 +1400,7 @@ class TestDocumentUpdateByFileApiPost:
"""Test ValueError when dataset is external."""
_setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
mock_dataset.provider = "external"
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
from io import BytesIO
@@ -1450,7 +1448,7 @@ class TestDocumentUpdateByFileApiPost:
mock_dataset.chunk_structure = None
mock_dataset.latest_process_rule = Mock()
mock_dataset.created_by_account = Mock()
mock_db.session.query.return_value.where.return_value.first.return_value = mock_dataset
mock_db.session.scalar.return_value = mock_dataset
mock_current_user.id = "user-1"
mock_upload = Mock()
@@ -88,7 +88,7 @@ class TestWorkflowBasedAppRunner:
workflow = SimpleNamespace(environment_variables=[], graph_dict={})
with pytest.raises(ValueError, match="Neither single_iteration_run nor single_loop_run"):
runner._prepare_single_node_execution(workflow, None, None)
runner._prepare_single_node_execution(workflow, None, None, user_id="00000000-0000-0000-0000-000000000001")
def test_get_graph_and_variable_pool_for_single_node_run(self, monkeypatch):
runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app")
@@ -136,6 +136,7 @@ class TestWorkflowBasedAppRunner:
graph_runtime_state=graph_runtime_state,
node_type_filter_key="iteration_id",
node_type_label="iteration",
user_id="00000000-0000-0000-0000-000000000001",
)
assert graph is not None
@@ -100,6 +100,7 @@ def test_run_uses_single_node_execution_branch(
workflow=workflow,
single_iteration_run=single_iteration_run,
single_loop_run=single_loop_run,
user_id="user",
)
init_graph.assert_not_called()
@@ -158,6 +159,7 @@ def test_single_node_run_validates_target_node_config(monkeypatch) -> None:
graph_runtime_state=graph_runtime_state,
node_type_filter_key="loop_id",
node_type_label="loop",
user_id="00000000-0000-0000-0000-000000000001",
)
assert seen_configs == [workflow.graph_dict["nodes"][0]]
@@ -16,8 +16,8 @@ from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
from core.rag.index_processor.constant.built_in_field import BuiltInField
from core.rag.index_processor.constant.index_type import IndexStructureType
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from dify_graph.model_runtime.entities.model_entities import ModelFeature, ModelType
from enums.cloud_plan import CloudPlan
from graphon.model_runtime.entities.model_entities import ModelFeature, ModelType
from models import Account, TenantAccountRole
from models.dataset import (
ChildChunk,
@@ -190,7 +190,7 @@ class TestDatasetServiceValidation:
with patch("services.dataset_service.ModelManager") as model_manager_cls:
DatasetService.check_dataset_model_setting(dataset)
model_manager_cls.return_value.get_model_instance.assert_called_once_with(
model_manager_cls.for_tenant.return_value.get_model_instance.assert_called_once_with(
tenant_id=dataset.tenant_id,
provider=dataset.embedding_model_provider,
model_type=ModelType.TEXT_EMBEDDING,
@@ -201,7 +201,7 @@ class TestDatasetServiceValidation:
dataset = DatasetServiceUnitDataFactory.create_dataset_mock(indexing_technique="high_quality")
with patch("services.dataset_service.ModelManager") as model_manager_cls:
model_manager_cls.return_value.get_model_instance.side_effect = LLMBadRequestError()
model_manager_cls.for_tenant.return_value.get_model_instance.side_effect = LLMBadRequestError()
with pytest.raises(ValueError, match="No Embedding Model available"):
DatasetService.check_dataset_model_setting(dataset)
@@ -210,14 +210,18 @@ class TestDatasetServiceValidation:
dataset = DatasetServiceUnitDataFactory.create_dataset_mock(indexing_technique="high_quality")
with patch("services.dataset_service.ModelManager") as model_manager_cls:
model_manager_cls.return_value.get_model_instance.side_effect = ProviderTokenNotInitError("token missing")
model_manager_cls.for_tenant.return_value.get_model_instance.side_effect = ProviderTokenNotInitError(
"token missing"
)
with pytest.raises(ValueError, match="token missing"):
with pytest.raises(ValueError, match="The dataset is unavailable, due to: token missing"):
DatasetService.check_dataset_model_setting(dataset)
def test_check_embedding_model_setting_wraps_provider_token_error_description(self):
with patch("services.dataset_service.ModelManager") as model_manager_cls:
model_manager_cls.return_value.get_model_instance.side_effect = ProviderTokenNotInitError("provider setup")
model_manager_cls.for_tenant.return_value.get_model_instance.side_effect = ProviderTokenNotInitError(
"provider setup"
)
with pytest.raises(ValueError, match="provider setup"):
DatasetService.check_embedding_model_setting("tenant-1", "provider", "embedding-model")
@@ -226,7 +230,7 @@ class TestDatasetServiceValidation:
with patch("services.dataset_service.ModelManager") as model_manager_cls:
DatasetService.check_reranking_model_setting("tenant-1", "provider", "reranker")
model_manager_cls.return_value.get_model_instance.assert_called_once_with(
model_manager_cls.for_tenant.return_value.get_model_instance.assert_called_once_with(
tenant_id="tenant-1",
provider="provider",
model_type=ModelType.RERANK,
@@ -235,7 +239,7 @@ class TestDatasetServiceValidation:
def test_check_reranking_model_setting_wraps_bad_request(self):
with patch("services.dataset_service.ModelManager") as model_manager_cls:
model_manager_cls.return_value.get_model_instance.side_effect = LLMBadRequestError()
model_manager_cls.for_tenant.return_value.get_model_instance.side_effect = LLMBadRequestError()
with pytest.raises(ValueError, match="No Rerank Model available"):
DatasetService.check_reranking_model_setting("tenant-1", "provider", "reranker")
@@ -251,7 +255,7 @@ class TestDatasetServiceValidation:
)
with patch("services.dataset_service.ModelManager") as model_manager_cls:
model_manager_cls.return_value.get_model_instance.return_value = model_instance
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = model_instance
result = DatasetService.check_is_multimodal_model("tenant-1", "provider", "embedding-model")
@@ -268,7 +272,7 @@ class TestDatasetServiceValidation:
)
with patch("services.dataset_service.ModelManager") as model_manager_cls:
model_manager_cls.return_value.get_model_instance.return_value = model_instance
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = model_instance
result = DatasetService.check_is_multimodal_model("tenant-1", "provider", "embedding-model")
@@ -284,14 +288,14 @@ class TestDatasetServiceValidation:
)
with patch("services.dataset_service.ModelManager") as model_manager_cls:
model_manager_cls.return_value.get_model_instance.return_value = model_instance
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = model_instance
with pytest.raises(ValueError, match="Model schema not found"):
DatasetService.check_is_multimodal_model("tenant-1", "provider", "embedding-model")
def test_check_is_multimodal_model_wraps_bad_request_error(self):
with patch("services.dataset_service.ModelManager") as model_manager_cls:
model_manager_cls.return_value.get_model_instance.side_effect = LLMBadRequestError()
model_manager_cls.for_tenant.return_value.get_model_instance.side_effect = LLMBadRequestError()
with pytest.raises(ValueError, match="No Model available"):
DatasetService.check_is_multimodal_model("tenant-1", "provider", "embedding-model")
@@ -323,7 +327,7 @@ class TestDatasetServiceCreationAndUpdate:
patch.object(DatasetService, "check_embedding_model_setting") as check_embedding,
):
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
model_manager_cls.return_value.get_default_model_instance.return_value = default_embedding_model
model_manager_cls.for_tenant.return_value.get_default_model_instance.return_value = default_embedding_model
dataset = DatasetService.create_empty_dataset(
tenant_id="tenant-1",
@@ -337,7 +341,7 @@ class TestDatasetServiceCreationAndUpdate:
assert dataset.embedding_model == "default-embedding"
assert dataset.permission == DatasetPermissionEnum.ONLY_ME
assert dataset.provider == "vendor"
model_manager_cls.return_value.get_default_model_instance.assert_called_once_with(
model_manager_cls.for_tenant.return_value.get_default_model_instance.assert_called_once_with(
tenant_id="tenant-1",
model_type=ModelType.TEXT_EMBEDDING,
)
@@ -365,7 +369,7 @@ class TestDatasetServiceCreationAndUpdate:
patch.object(DatasetService, "check_reranking_model_setting") as check_reranking,
):
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
model_manager_cls.return_value.get_model_instance.return_value = embedding_model
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = embedding_model
dataset = DatasetService.create_empty_dataset(
tenant_id="tenant-1",
@@ -804,7 +808,7 @@ class TestDatasetServiceCreationAndUpdate:
return_value=SimpleNamespace(id="binding-1"),
),
):
model_manager_cls.return_value.get_model_instance.return_value = embedding_model
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = embedding_model
DatasetService._configure_embedding_model_for_high_quality(
{"embedding_model_provider": "provider", "embedding_model": "embedding-model"},
@@ -836,7 +840,7 @@ class TestDatasetServiceCreationAndUpdate:
patch("services.dataset_service.current_user", current_user),
patch("services.dataset_service.ModelManager") as model_manager_cls,
):
model_manager_cls.return_value.get_model_instance.side_effect = error
model_manager_cls.for_tenant.return_value.get_model_instance.side_effect = error
with pytest.raises(ValueError, match=message):
DatasetService._configure_embedding_model_for_high_quality(
@@ -967,7 +971,7 @@ class TestDatasetServiceCreationAndUpdate:
return_value=SimpleNamespace(id="binding-2"),
),
):
model_manager_cls.return_value.get_model_instance.return_value = SimpleNamespace(
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = SimpleNamespace(
provider="provider-two",
model_name="embedding-model-two",
)
@@ -1002,7 +1006,9 @@ class TestDatasetServiceCreationAndUpdate:
patch("services.dataset_service.current_user", current_user),
patch("services.dataset_service.ModelManager") as model_manager_cls,
):
model_manager_cls.return_value.get_model_instance.side_effect = ProviderTokenNotInitError("token missing")
model_manager_cls.for_tenant.return_value.get_model_instance.side_effect = ProviderTokenNotInitError(
"token missing"
)
DatasetService._apply_new_embedding_settings(
dataset,
@@ -1067,7 +1073,7 @@ class TestDatasetServiceRagPipelineSettings:
return_value=SimpleNamespace(id="binding-1"),
),
):
model_manager_cls.return_value.get_model_instance.return_value = embedding_model
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = embedding_model
DatasetService.update_rag_pipeline_dataset_settings(session, dataset, knowledge_configuration)
@@ -1161,7 +1167,7 @@ class TestDatasetServiceRagPipelineSettings:
),
patch("services.dataset_service.deal_dataset_index_update_task") as update_task,
):
model_manager_cls.return_value.get_model_instance.return_value = embedding_model
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = embedding_model
DatasetService.update_rag_pipeline_dataset_settings(
session,
@@ -1204,7 +1210,7 @@ class TestDatasetServiceRagPipelineSettings:
),
patch("services.dataset_service.deal_dataset_index_update_task") as update_task,
):
model_manager_cls.return_value.get_model_instance.return_value = SimpleNamespace(
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = SimpleNamespace(
provider="provider-two",
model_name="embedding-model-two",
)
@@ -1243,7 +1249,9 @@ class TestDatasetServiceRagPipelineSettings:
patch("services.dataset_service.ModelManager") as model_manager_cls,
patch("services.dataset_service.deal_dataset_index_update_task") as update_task,
):
model_manager_cls.return_value.get_model_instance.side_effect = ProviderTokenNotInitError("token missing")
model_manager_cls.for_tenant.return_value.get_model_instance.side_effect = ProviderTokenNotInitError(
"token missing"
)
DatasetService.update_rag_pipeline_dataset_settings(
session,
@@ -1828,7 +1828,7 @@ class TestDocumentServiceSaveDocumentAdditionalBranches:
) as get_binding,
patch.object(DocumentService, "update_document_with_dataset_id", return_value=updated_document),
):
model_manager_cls.return_value.get_default_model_instance.return_value = SimpleNamespace(
model_manager_cls.for_tenant.return_value.get_default_model_instance.return_value = SimpleNamespace(
model_name="default-embedding",
provider="default-provider",
)
@@ -1880,7 +1880,7 @@ class TestDocumentServiceSaveDocumentAdditionalBranches:
):
DocumentService.save_document_with_dataset_id(dataset, knowledge_config, account_context)
model_manager_cls.return_value.get_default_model_instance.assert_not_called()
model_manager_cls.for_tenant.return_value.get_default_model_instance.assert_not_called()
get_binding.assert_called_once_with("explicit-provider", "explicit-model")
assert dataset.embedding_model == "explicit-model"
assert dataset.embedding_model_provider == "explicit-provider"
@@ -9,6 +9,7 @@ from .dataset_service_test_helpers import (
DocumentSegment,
IndexStructureType,
MagicMock,
ModelType,
SegmentService,
SegmentUpdateArgs,
SimpleNamespace,
@@ -459,7 +460,7 @@ class TestSegmentServiceMutations:
patch("services.dataset_service.naive_utc_now", return_value="now"),
):
mock_redis.lock.return_value = _make_lock_context()
model_manager_cls.return_value.get_model_instance.return_value = embedding_model
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = embedding_model
mock_db.session.query.return_value.where.return_value.scalar.return_value = 1
vector_service.create_segments_vector.side_effect = RuntimeError("vector failed")
@@ -571,7 +572,7 @@ class TestSegmentServiceMutations:
patch("services.summary_index_service.SummaryIndexService.update_summary_for_segment") as update_summary,
):
mock_redis.get.return_value = None
model_manager_cls.return_value.get_model_instance.return_value = embedding_model_instance
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = embedding_model_instance
processing_rule_query = MagicMock()
processing_rule_query.where.return_value.first.return_value = processing_rule
@@ -618,7 +619,7 @@ class TestSegmentServiceMutations:
) as generate_summary,
):
mock_redis.get.return_value = None
model_manager_cls.return_value.get_model_instance.return_value = embedding_model
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = embedding_model
summary_query = MagicMock()
summary_query.where.return_value.first.return_value = existing_summary
@@ -661,7 +662,7 @@ class TestSegmentServiceMutations:
patch("services.summary_index_service.SummaryIndexService.update_summary_for_segment") as update_summary,
):
mock_redis.get.return_value = None
model_manager_cls.return_value.get_model_instance.return_value = embedding_model
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = embedding_model
summary_query = MagicMock()
summary_query.where.return_value.first.return_value = existing_summary
@@ -900,7 +901,7 @@ class TestSegmentServiceAdditionalRegenerationBranches:
patch("services.dataset_service.naive_utc_now", return_value="now"),
):
mock_redis.get.return_value = None
model_manager_cls.return_value.get_model_instance.return_value = embedding_model
model_manager_cls.for_tenant.return_value.get_model_instance.return_value = embedding_model
summary_query = MagicMock()
summary_query.where.return_value.first.return_value = None
refreshed_query = MagicMock()
@@ -947,7 +948,7 @@ class TestSegmentServiceAdditionalRegenerationBranches:
patch("services.summary_index_service.SummaryIndexService.update_summary_for_segment") as update_summary,
):
mock_redis.get.return_value = None
model_manager_cls.return_value.get_default_model_instance.return_value = embedding_model_instance
model_manager_cls.for_tenant.return_value.get_default_model_instance.return_value = embedding_model_instance
update_summary.side_effect = RuntimeError("summary failed")
processing_rule_query = MagicMock()
@@ -966,9 +967,9 @@ class TestSegmentServiceAdditionalRegenerationBranches:
)
assert result is refreshed_segment
model_manager_cls.return_value.get_default_model_instance.assert_called_once_with(
model_manager_cls.for_tenant.return_value.get_default_model_instance.assert_called_once_with(
tenant_id="tenant-1",
model_type="text-embedding",
model_type=ModelType.TEXT_EMBEDDING,
)
vector_service.generate_child_chunks.assert_called_once_with(
segment,
Generated
+3 -3
View File
@@ -5340,11 +5340,11 @@ wheels = [
[[package]]
name = "pypdf"
version = "6.9.1"
version = "6.9.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/fb/dc2e8cb006e80b0020ed20d8649106fe4274e82d8e756ad3e24ade19c0df/pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d", size = 5311551, upload-time = "2026-03-17T10:46:07.876Z" }
sdist = { url = "https://files.pythonhosted.org/packages/31/83/691bdb309306232362503083cb15777491045dd54f45393a317dc7d8082f/pypdf-6.9.2.tar.gz", hash = "sha256:7f850faf2b0d4ab936582c05da32c52214c2b089d61a316627b5bfb5b0dab46c", size = 5311837, upload-time = "2026-03-23T14:53:27.983Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/f4/75543fa802b86e72f87e9395440fe1a89a6d149887e3e55745715c3352ac/pypdf-6.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f", size = 333661, upload-time = "2026-03-17T10:46:06.286Z" },
{ url = "https://files.pythonhosted.org/packages/a5/7e/c85f41243086a8fe5d1baeba527cb26a1918158a565932b41e0f7c0b32e9/pypdf-6.9.2-py3-none-any.whl", hash = "sha256:662cf29bcb419a36a1365232449624ab40b7c2d0cfc28e54f42eeecd1fd7e844", size = 333744, upload-time = "2026-03-23T14:53:26.573Z" },
]
[[package]]
+2 -1
View File
@@ -70,7 +70,8 @@
"pnpm": {
"overrides": {
"flatted@<=3.4.1": "3.4.2",
"rollup@>=4.0.0,<4.59.0": "4.59.0"
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
"rollup@>=4.0.0 <4.59.0": "4.59.0"
}
}
}
+13 -12
View File
@@ -6,7 +6,8 @@ settings:
overrides:
flatted@<=3.4.1: 3.4.2
rollup@>=4.0.0,<4.59.0: 4.59.0
picomatch@>=4.0.0 <4.0.4: 4.0.4
rollup@>=4.0.0 <4.59.0: 4.59.0
importers:
@@ -735,7 +736,7 @@ packages:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
peerDependencies:
picomatch: ^3 || ^4
picomatch: 4.0.4
peerDependenciesMeta:
picomatch:
optional: true
@@ -963,8 +964,8 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
picomatch@4.0.3:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
picomatch@4.0.4:
resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
engines: {node: '>=12'}
pirates@4.0.7:
@@ -1829,9 +1830,9 @@ snapshots:
fast-levenshtein@2.0.6: {}
fdir@6.5.0(picomatch@4.0.3):
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.3
picomatch: 4.0.4
file-entry-cache@8.0.0:
dependencies:
@@ -2038,7 +2039,7 @@ snapshots:
picocolors@1.1.1: {}
picomatch@4.0.3: {}
picomatch@4.0.4: {}
pirates@4.0.7: {}
@@ -2149,8 +2150,8 @@ snapshots:
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyrainbow@3.0.3: {}
@@ -2207,8 +2208,8 @@ snapshots:
vite@7.3.1(@types/node@25.4.0):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
postcss: 8.5.8
rollup: 4.59.0
tinyglobby: 0.2.15
@@ -2230,7 +2231,7 @@ snapshots:
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.3
picomatch: 4.0.4
std-env: 3.10.0
tinybench: 2.9.0
tinyexec: 1.0.2
-2
View File
@@ -51,8 +51,6 @@ NEXT_PUBLIC_ALLOW_EMBED=
# Allow rendering unsafe URLs which have "data:" scheme.
NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME=false
# Github Access Token, used for invoking Github API
NEXT_PUBLIC_GITHUB_ACCESS_TOKEN=
# The maximum number of top-k value for RAG.
NEXT_PUBLIC_TOP_K_MAX_VALUE=10
@@ -5,11 +5,9 @@
* upload handling, and task status polling. Verifies the complete plugin
* installation pipeline from source discovery to completion.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/config', () => ({
GITHUB_ACCESS_TOKEN: '',
}))
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { checkForUpdates, fetchReleases, handleUpload } from '@/app/components/plugins/install-plugin/hooks'
const mockToastNotify = vi.fn()
vi.mock('@/app/components/base/ui/toast', () => ({
@@ -30,10 +28,6 @@ vi.mock('@/service/plugins', () => ({
checkTaskStatus: vi.fn(),
}))
const { useGitHubReleases, useGitHubUpload } = await import(
'@/app/components/plugins/install-plugin/hooks',
)
describe('Plugin Installation Flow Integration', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -44,22 +38,22 @@ describe('Plugin Installation Flow Integration', () => {
it('fetches releases, checks for updates, and uploads the new version', async () => {
const mockReleases = [
{
tag_name: 'v2.0.0',
assets: [{ browser_download_url: 'https://github.com/test/v2.difypkg', name: 'plugin-v2.difypkg' }],
tag: 'v2.0.0',
assets: [{ downloadUrl: 'https://github.com/test/v2.difypkg' }],
},
{
tag_name: 'v1.5.0',
assets: [{ browser_download_url: 'https://github.com/test/v1.5.difypkg', name: 'plugin-v1.5.difypkg' }],
tag: 'v1.5.0',
assets: [{ downloadUrl: 'https://github.com/test/v1.5.difypkg' }],
},
{
tag_name: 'v1.0.0',
assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }],
tag: 'v1.0.0',
assets: [{ downloadUrl: 'https://github.com/test/v1.difypkg' }],
},
]
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockReleases),
json: () => Promise.resolve({ releases: mockReleases }),
})
mockUploadGitHub.mockResolvedValue({
@@ -67,8 +61,6 @@ describe('Plugin Installation Flow Integration', () => {
unique_identifier: 'test-plugin:2.0.0',
})
const { fetchReleases, checkForUpdates } = useGitHubReleases()
const releases = await fetchReleases('test-org', 'test-repo')
expect(releases).toHaveLength(3)
expect(releases[0].tag_name).toBe('v2.0.0')
@@ -77,7 +69,6 @@ describe('Plugin Installation Flow Integration', () => {
expect(needUpdate).toBe(true)
expect(toastProps.message).toContain('v2.0.0')
const { handleUpload } = useGitHubUpload()
const onSuccess = vi.fn()
const result = await handleUpload(
'https://github.com/test-org/test-repo',
@@ -104,18 +95,16 @@ describe('Plugin Installation Flow Integration', () => {
it('handles no new version available', async () => {
const mockReleases = [
{
tag_name: 'v1.0.0',
assets: [{ browser_download_url: 'https://github.com/test/v1.difypkg', name: 'plugin-v1.difypkg' }],
tag: 'v1.0.0',
assets: [{ downloadUrl: 'https://github.com/test/v1.difypkg' }],
},
]
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockReleases),
json: () => Promise.resolve({ releases: mockReleases }),
})
const { fetchReleases, checkForUpdates } = useGitHubReleases()
const releases = await fetchReleases('test-org', 'test-repo')
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
@@ -127,11 +116,9 @@ describe('Plugin Installation Flow Integration', () => {
it('handles empty releases', async () => {
;(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: () => Promise.resolve([]),
json: () => Promise.resolve({ releases: [] }),
})
const { fetchReleases, checkForUpdates } = useGitHubReleases()
const releases = await fetchReleases('test-org', 'test-repo')
expect(releases).toHaveLength(0)
@@ -147,7 +134,6 @@ describe('Plugin Installation Flow Integration', () => {
status: 404,
})
const { fetchReleases } = useGitHubReleases()
const releases = await fetchReleases('nonexistent-org', 'nonexistent-repo')
expect(releases).toEqual([])
@@ -159,7 +145,6 @@ describe('Plugin Installation Flow Integration', () => {
it('handles upload failure gracefully', async () => {
mockUploadGitHub.mockRejectedValue(new Error('Upload failed'))
const { handleUpload } = useGitHubUpload()
const onSuccess = vi.fn()
await expect(
@@ -58,11 +58,10 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
}, 1000)
}
const sendEmail = async (email: string, isOrigin: boolean, token?: string) => {
const sendEmail = async (email: string, token?: string) => {
try {
const res = await sendVerifyCode({
email,
phase: isOrigin ? 'old_email' : 'new_email',
token,
})
startCount()
@@ -106,7 +105,6 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
const sendCodeToOriginEmail = async () => {
await sendEmail(
email,
true,
)
setStep(STEP.verifyOrigin)
}
@@ -162,7 +160,6 @@ const EmailChangeModal = ({ onClose, email, show }: Props) => {
}
await sendEmail(
mail,
false,
stepToken,
)
setStep(STEP.verifyNew)
@@ -1,11 +1,8 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen, waitFor } from '@testing-library/react'
import nock from 'nock'
import * as React from 'react'
import GithubStar from '../index'
const GITHUB_HOST = 'https://api.github.com'
const GITHUB_PATH = '/repos/langgenius/dify'
const GITHUB_STAR_URL = 'https://ungh.cc/repos/langgenius/dify'
const renderWithQueryClient = () => {
const queryClient = new QueryClient({
@@ -18,40 +15,66 @@ const renderWithQueryClient = () => {
)
}
const mockGithubStar = (status: number, body: Record<string, unknown>, delayMs = 0) => {
return nock(GITHUB_HOST).get(GITHUB_PATH).delay(delayMs).reply(status, body)
const createJsonResponse = (body: Record<string, unknown>, status = 200) => {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
const createDeferred = <T,>() => {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
describe('GithubStar', () => {
beforeEach(() => {
nock.cleanAll()
vi.restoreAllMocks()
vi.clearAllMocks()
})
// Shows fetched star count when request succeeds
// Covers the fetched star count shown after a successful request.
it('should render fetched star count', async () => {
mockGithubStar(200, { stargazers_count: 123456 })
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
createJsonResponse({ repo: { stars: 123456 } }),
)
renderWithQueryClient()
expect(await screen.findByText('123,456')).toBeInTheDocument()
expect(fetchSpy).toHaveBeenCalledWith(GITHUB_STAR_URL)
})
// Falls back to default star count when request fails
// Covers the fallback star count shown when the request fails.
it('should render default star count on error', async () => {
mockGithubStar(500, {})
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
createJsonResponse({}, 500),
)
renderWithQueryClient()
expect(await screen.findByText('110,918')).toBeInTheDocument()
})
// Renders loader while fetching data
// Covers the loading indicator while the fetch promise is still pending.
it('should show loader while fetching', async () => {
mockGithubStar(200, { stargazers_count: 222222 }, 50)
const deferred = createDeferred<Response>()
vi.spyOn(globalThis, 'fetch').mockReturnValueOnce(deferred.promise)
const { container } = renderWithQueryClient()
expect(container.querySelector('.animate-spin')).toBeInTheDocument()
await waitFor(() => expect(screen.getByText('222,222')).toBeInTheDocument())
deferred.resolve(createJsonResponse({ repo: { stars: 222222 } }))
await waitFor(() => {
expect(screen.getByText('222,222')).toBeInTheDocument()
})
})
})
+13 -11
View File
@@ -1,16 +1,19 @@
'use client'
import type { FC } from 'react'
import type { GithubRepo } from '@/models/common'
import { RiLoader2Line } from '@remixicon/react'
import { useQuery } from '@tanstack/react-query'
import { IS_DEV } from '@/config'
const defaultData = {
stargazers_count: 110918,
type GithubStarResponse = {
repo: {
stars: number
}
}
const defaultData: GithubStarResponse = {
repo: { stars: 110918 },
}
const getStar = async () => {
const res = await fetch('https://api.github.com/repos/langgenius/dify')
const res = await fetch('https://ungh.cc/repos/langgenius/dify')
if (!res.ok)
throw new Error('Failed to fetch github star')
@@ -19,21 +22,20 @@ const getStar = async () => {
}
const GithubStar: FC<{ className: string }> = (props) => {
const { isFetching, isError, data } = useQuery<GithubRepo>({
const { isFetching, isError, data } = useQuery<GithubStarResponse>({
queryKey: ['github-star'],
queryFn: getStar,
enabled: !IS_DEV,
retry: false,
placeholderData: defaultData,
})
if (isFetching)
return <RiLoader2Line className="size-3 shrink-0 animate-spin text-text-tertiary" />
return <span className="i-ri-loader-2-line size-3 shrink-0 animate-spin text-text-tertiary" />
if (isError)
return <span {...props}>{defaultData.stargazers_count.toLocaleString()}</span>
return <span {...props}>{defaultData.repo.stars.toLocaleString()}</span>
return <span {...props}>{data?.stargazers_count.toLocaleString()}</span>
return <span {...props}>{data?.repo.stars.toLocaleString()}</span>
}
export default GithubStar
@@ -1,6 +1,5 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useGitHubReleases, useGitHubUpload } from '../hooks'
import { checkForUpdates, fetchReleases, handleUpload } from '../hooks'
const mockNotify = vi.fn()
vi.mock('@/app/components/base/ui/toast', () => ({
@@ -15,10 +14,6 @@ vi.mock('@/app/components/base/ui/toast', () => ({
}),
}))
vi.mock('@/config', () => ({
GITHUB_ACCESS_TOKEN: '',
}))
const mockUploadGitHub = vi.fn()
vi.mock('@/service/plugins', () => ({
uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args),
@@ -37,17 +32,17 @@ describe('install-plugin/hooks', () => {
it('fetches releases from GitHub API and formats them', async () => {
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve([
{
tag_name: 'v1.0.0',
assets: [{ browser_download_url: 'https://example.com/v1.zip', name: 'plugin.zip' }],
body: 'Release notes',
},
]),
json: () => Promise.resolve({
releases: [
{
tag: 'v1.0.0',
assets: [{ downloadUrl: 'https://example.com/plugin.zip' }],
},
],
}),
})
const { result } = renderHook(() => useGitHubReleases())
const releases = await result.current.fetchReleases('owner', 'repo')
const releases = await fetchReleases('owner', 'repo')
expect(releases).toHaveLength(1)
expect(releases[0].tag_name).toBe('v1.0.0')
@@ -60,8 +55,7 @@ describe('install-plugin/hooks', () => {
ok: false,
})
const { result } = renderHook(() => useGitHubReleases())
const releases = await result.current.fetchReleases('owner', 'repo')
const releases = await fetchReleases('owner', 'repo')
expect(releases).toEqual([])
expect(mockNotify).toHaveBeenCalledWith('Failed to fetch repository releases')
@@ -70,29 +64,26 @@ describe('install-plugin/hooks', () => {
describe('checkForUpdates', () => {
it('detects newer version available', () => {
const { result } = renderHook(() => useGitHubReleases())
const releases = [
{ tag_name: 'v1.0.0', assets: [] },
{ tag_name: 'v2.0.0', assets: [] },
]
const { needUpdate, toastProps } = result.current.checkForUpdates(releases, 'v1.0.0')
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
expect(needUpdate).toBe(true)
expect(toastProps.message).toContain('v2.0.0')
})
it('returns no update when current is latest', () => {
const { result } = renderHook(() => useGitHubReleases())
const releases = [
{ tag_name: 'v1.0.0', assets: [] },
]
const { needUpdate, toastProps } = result.current.checkForUpdates(releases, 'v1.0.0')
const { needUpdate, toastProps } = checkForUpdates(releases, 'v1.0.0')
expect(needUpdate).toBe(false)
expect(toastProps.type).toBe('info')
})
it('returns error for empty releases', () => {
const { result } = renderHook(() => useGitHubReleases())
const { needUpdate, toastProps } = result.current.checkForUpdates([], 'v1.0.0')
const { needUpdate, toastProps } = checkForUpdates([], 'v1.0.0')
expect(needUpdate).toBe(false)
expect(toastProps.type).toBe('error')
expect(toastProps.message).toContain('empty')
@@ -109,8 +100,7 @@ describe('install-plugin/hooks', () => {
})
const onSuccess = vi.fn()
const { result } = renderHook(() => useGitHubUpload())
const pkg = await result.current.handleUpload(
const pkg = await handleUpload(
'https://github.com/owner/repo',
'v1.0.0',
'plugin.difypkg',
@@ -132,9 +122,8 @@ describe('install-plugin/hooks', () => {
it('shows toast on upload error', async () => {
mockUploadGitHub.mockRejectedValue(new Error('Upload failed'))
const { result } = renderHook(() => useGitHubUpload())
await expect(
result.current.handleUpload('url', 'v1', 'pkg'),
handleUpload('url', 'v1', 'pkg'),
).rejects.toThrow('Upload failed')
expect(mockNotify).toHaveBeenCalledWith('Error uploading package')
})
@@ -1,101 +1,87 @@
import type { GitHubRepoReleaseResponse } from '../types'
import { toast } from '@/app/components/base/ui/toast'
import { GITHUB_ACCESS_TOKEN } from '@/config'
import { uploadGitHub } from '@/service/plugins'
import { compareVersion, getLatestVersion } from '@/utils/semver'
const normalizeAssetName = (downloadUrl: string) => {
const parts = downloadUrl.split('/')
return parts[parts.length - 1]
}
const formatReleases = (releases: any) => {
return releases.map((release: any) => ({
tag_name: release.tag_name,
tag_name: release.tag,
assets: release.assets.map((asset: any) => ({
browser_download_url: asset.browser_download_url,
name: asset.name,
browser_download_url: asset.downloadUrl,
name: normalizeAssetName(asset.downloadUrl),
})),
}))
}
export const useGitHubReleases = () => {
const fetchReleases = async (owner: string, repo: string) => {
try {
if (!GITHUB_ACCESS_TOKEN) {
// Fetch releases without authentication from client
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/releases`)
if (!res.ok)
throw new Error('Failed to fetch repository releases')
const data = await res.json()
return formatReleases(data)
}
else {
// Fetch releases with authentication from server
const res = await fetch(`/repos/${owner}/${repo}/releases`)
const bodyJson = await res.json()
if (bodyJson.status !== 200)
throw new Error(bodyJson.data.message)
return formatReleases(bodyJson.data)
}
}
catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
else {
toast.error('Failed to fetch repository releases')
}
return []
}
export const fetchReleases = async (owner: string, repo: string) => {
try {
// Fetch releases without authentication from client
const res = await fetch(`https://ungh.cc/repos/${owner}/${repo}/releases`)
if (!res.ok)
throw new Error('Failed to fetch repository releases')
const data = await res.json()
return formatReleases(data.releases)
}
catch (error) {
if (error instanceof Error) {
toast.error(error.message)
}
else {
toast.error('Failed to fetch repository releases')
}
return []
}
}
const checkForUpdates = (fetchedReleases: GitHubRepoReleaseResponse[], currentVersion: string) => {
let needUpdate = false
const toastProps: { type?: 'success' | 'error' | 'info' | 'warning', message: string } = {
type: 'info',
message: 'No new version available',
}
if (fetchedReleases.length === 0) {
toastProps.type = 'error'
toastProps.message = 'Input releases is empty'
return { needUpdate, toastProps }
}
const versions = fetchedReleases.map(release => release.tag_name)
const latestVersion = getLatestVersion(versions)
try {
needUpdate = compareVersion(latestVersion, currentVersion) === 1
if (needUpdate)
toastProps.message = `New version available: ${latestVersion}`
}
catch {
needUpdate = false
toastProps.type = 'error'
toastProps.message = 'Fail to compare versions, please check the version format'
}
export const checkForUpdates = (fetchedReleases: GitHubRepoReleaseResponse[], currentVersion: string) => {
let needUpdate = false
const toastProps: { type?: 'success' | 'error' | 'info' | 'warning', message: string } = {
type: 'info',
message: 'No new version available',
}
if (fetchedReleases.length === 0) {
toastProps.type = 'error'
toastProps.message = 'Input releases is empty'
return { needUpdate, toastProps }
}
return { fetchReleases, checkForUpdates }
const versions = fetchedReleases.map(release => release.tag_name)
const latestVersion = getLatestVersion(versions)
try {
needUpdate = compareVersion(latestVersion, currentVersion) === 1
if (needUpdate)
toastProps.message = `New version available: ${latestVersion}`
}
catch {
needUpdate = false
toastProps.type = 'error'
toastProps.message = 'Fail to compare versions, please check the version format'
}
return { needUpdate, toastProps }
}
export const useGitHubUpload = () => {
const handleUpload = async (
repoUrl: string,
selectedVersion: string,
selectedPackage: string,
onSuccess?: (GitHubPackage: { manifest: any, unique_identifier: string }) => void,
) => {
try {
const response = await uploadGitHub(repoUrl, selectedVersion, selectedPackage)
const GitHubPackage = {
manifest: response.manifest,
unique_identifier: response.unique_identifier,
}
if (onSuccess)
onSuccess(GitHubPackage)
return GitHubPackage
}
catch (error) {
toast.error('Error uploading package')
throw error
export const handleUpload = async (
repoUrl: string,
selectedVersion: string,
selectedPackage: string,
onSuccess?: (GitHubPackage: { manifest: any, unique_identifier: string }) => void,
) => {
try {
const response = await uploadGitHub(repoUrl, selectedVersion, selectedPackage)
const GitHubPackage = {
manifest: response.manifest,
unique_identifier: response.unique_identifier,
}
if (onSuccess)
onSuccess(GitHubPackage)
return GitHubPackage
}
catch (error) {
toast.error('Error uploading package')
throw error
}
return { handleUpload }
}
@@ -74,10 +74,16 @@ vi.mock('@/app/components/plugins/install-plugin/base/use-get-icon', () => ({
default: () => ({ getIconUrl: mockGetIconUrl }),
}))
const mockFetchReleases = vi.fn()
vi.mock('../../hooks', () => ({
useGitHubReleases: () => ({ fetchReleases: mockFetchReleases }),
const { mockFetchReleases } = vi.hoisted(() => ({
mockFetchReleases: vi.fn(),
}))
vi.mock('../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../hooks')>()
return {
...actual,
fetchReleases: mockFetchReleases,
}
})
const mockRefreshPluginList = vi.fn()
vi.mock('../../hooks/use-refresh-plugin-list', () => ({
@@ -12,7 +12,7 @@ import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-ico
import { cn } from '@/utils/classnames'
import { InstallStepFromGitHub } from '../../types'
import Installed from '../base/installed'
import { useGitHubReleases } from '../hooks'
import { fetchReleases } from '../hooks'
import useHideLogic from '../hooks/use-hide-logic'
import useRefreshPluginList from '../hooks/use-refresh-plugin-list'
import { convertRepoToUrl, parseGitHubUrl } from '../utils'
@@ -31,7 +31,6 @@ type InstallFromGitHubProps = {
const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({ updatePayload, onClose, onSuccess }) => {
const { t } = useTranslation()
const { getIconUrl } = useGetIcon()
const { fetchReleases } = useGitHubReleases()
const { refreshPluginList } = useRefreshPluginList()
const {
@@ -5,11 +5,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PluginCategoryEnum } from '../../../../types'
import SelectPackage from '../selectPackage'
// Mock the useGitHubUpload hook
const mockHandleUpload = vi.fn()
vi.mock('../../../hooks', () => ({
useGitHubUpload: () => ({ handleUpload: mockHandleUpload }),
// Mock upload helper from hooks module
const { mockHandleUpload } = vi.hoisted(() => ({
mockHandleUpload: vi.fn(),
}))
vi.mock('../../../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../hooks')>()
return {
...actual,
handleUpload: mockHandleUpload,
}
})
// Factory functions
const createMockManifest = (): PluginDeclaration => ({
@@ -6,7 +6,7 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { PortalSelect } from '@/app/components/base/select'
import { useGitHubUpload } from '../../hooks'
import { handleUpload } from '../../hooks'
const i18nPrefix = 'installFromGitHub'
@@ -43,7 +43,6 @@ const SelectPackage: React.FC<SelectPackageProps> = ({
const { t } = useTranslation()
const isEdit = Boolean(updatePayload)
const [isUploading, setIsUploading] = React.useState(false)
const { handleUpload } = useGitHubUpload()
const handleUploadPackage = async () => {
if (isUploading)
@@ -103,12 +103,14 @@ vi.mock('@/service/use-tools', () => ({
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
}))
vi.mock('../../install-plugin/hooks', () => ({
useGitHubReleases: () => ({
vi.mock('../../install-plugin/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../install-plugin/hooks')>()
return {
...actual,
checkForUpdates: mockCheckForUpdates,
fetchReleases: mockFetchReleases,
}),
}))
}
})
// Auto upgrade settings mock
let mockAutoUpgradeInfo: {
@@ -72,12 +72,14 @@ vi.mock('@/service/use-tools', () => ({
useInvalidateAllToolProviders: () => mockInvalidateAllToolProviders,
}))
vi.mock('../../../../install-plugin/hooks', () => ({
useGitHubReleases: () => ({
vi.mock('../../../../install-plugin/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../../install-plugin/hooks')>()
return {
...actual,
checkForUpdates: mockCheckForUpdates,
fetchReleases: mockFetchReleases,
}),
}))
}
})
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => ({
id: 'test-id',
@@ -11,7 +11,7 @@ import { useProviderContext } from '@/context/provider-context'
import { uninstallPlugin } from '@/service/plugins'
import { useInvalidateCheckInstalled } from '@/service/use-plugins'
import { useInvalidateAllToolProviders } from '@/service/use-tools'
import { useGitHubReleases } from '../../../install-plugin/hooks'
import { checkForUpdates, fetchReleases } from '../../../install-plugin/hooks'
import { PluginCategoryEnum, PluginSource } from '../../../types'
type UsePluginOperationsParams = {
@@ -39,7 +39,6 @@ export const usePluginOperations = ({
onUpdate,
}: UsePluginOperationsParams): UsePluginOperationsReturn => {
const { t } = useTranslation()
const { checkForUpdates, fetchReleases } = useGitHubReleases()
const { setShowUpdatePluginModal } = useModalContext()
const { refreshModelProviders } = useProviderContext()
const invalidateCheckInstalled = useInvalidateCheckInstalled()
@@ -46,13 +46,15 @@ vi.mock('@/service/plugins', () => ({
uninstallPlugin: (id: string) => mockUninstallPlugin(id),
}))
// Mock GitHub releases hook
vi.mock('../../install-plugin/hooks', () => ({
useGitHubReleases: () => ({
// Mock GitHub release helpers
vi.mock('../../install-plugin/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../install-plugin/hooks')>()
return {
...actual,
fetchReleases: mockFetchReleases,
checkForUpdates: mockCheckForUpdates,
}),
}))
}
})
// Mock modal context
vi.mock('@/context/modal-context', () => ({
@@ -14,7 +14,7 @@ import { useInvalidateInstalledPluginList } from '@/service/use-plugins'
import ActionButton from '../../base/action-button'
import Confirm from '../../base/confirm'
import Tooltip from '../../base/tooltip'
import { useGitHubReleases } from '../install-plugin/hooks'
import { checkForUpdates, fetchReleases } from '../install-plugin/hooks'
import PluginInfo from '../plugin-page/plugin-info'
import { PluginSource } from '../types'
@@ -54,7 +54,6 @@ const Action: FC<Props> = ({
setTrue: showDeleting,
setFalse: hideDeleting,
}] = useBoolean(false)
const { checkForUpdates, fetchReleases } = useGitHubReleases()
const { setShowUpdatePluginModal } = useModalContext()
const invalidateInstalledPluginList = useInvalidateInstalledPluginList()
@@ -26,7 +26,8 @@ export type QAChunks = {
export type ChunkInfo = GeneralChunks | ParentChildChunks | QAChunks
export enum QAItemType {
Question = 'question',
Answer = 'answer',
}
export const QAItemType = {
Question: 'question',
Answer: 'answer',
} as const
export type QAItemType = typeof QAItemType[keyof typeof QAItemType]
@@ -1,9 +1,10 @@
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
export enum TestRunStep {
dataSource = 'dataSource',
documentProcessing = 'documentProcessing',
}
export const TestRunStep = {
dataSource: 'dataSource',
documentProcessing: 'documentProcessing',
} as const
export type TestRunStep = typeof TestRunStep[keyof typeof TestRunStep]
export type DataSourceOption = {
label: string
@@ -81,7 +81,7 @@ describe('SelectionContextmenu', () => {
expect(screen.queryByText('operator.vertical')).not.toBeInTheDocument()
})
it('should keep the menu inside the workflow container bounds', () => {
it('should render menu items when selectionMenu is present', async () => {
const nodes = [
createNode({ id: 'n1', selected: true, width: 80, height: 40 }),
createNode({ id: 'n2', selected: true, position: { x: 140, y: 0 }, width: 80, height: 40 }),
@@ -89,11 +89,12 @@ describe('SelectionContextmenu', () => {
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 780, top: 590 } })
store.setState({ selectionMenu: { clientX: 780, clientY: 590 } })
})
const menu = screen.getByTestId('selection-contextmenu')
expect(menu).toHaveStyle({ left: '540px', top: '210px' })
await waitFor(() => {
expect(screen.getByTestId('selection-contextmenu-item-left')).toBeInTheDocument()
})
})
it('should close itself when only one node is selected', async () => {
@@ -104,7 +105,7 @@ describe('SelectionContextmenu', () => {
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 120, top: 120 } })
store.setState({ selectionMenu: { clientX: 120, clientY: 120 } })
})
await waitFor(() => {
@@ -129,7 +130,7 @@ describe('SelectionContextmenu', () => {
})
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
store.setState({ selectionMenu: { clientX: 100, clientY: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
@@ -162,7 +163,7 @@ describe('SelectionContextmenu', () => {
})
act(() => {
store.setState({ selectionMenu: { left: 160, top: 120 } })
store.setState({ selectionMenu: { clientX: 160, clientY: 120 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-distributeHorizontal'))
@@ -201,7 +202,7 @@ describe('SelectionContextmenu', () => {
})
act(() => {
store.setState({ selectionMenu: { left: 180, top: 120 } })
store.setState({ selectionMenu: { clientX: 180, clientY: 120 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
@@ -220,7 +221,7 @@ describe('SelectionContextmenu', () => {
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
store.setState({ selectionMenu: { clientX: 100, clientY: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
@@ -238,7 +239,7 @@ describe('SelectionContextmenu', () => {
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
store.setState({ selectionMenu: { clientX: 100, clientY: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
@@ -263,7 +264,7 @@ describe('SelectionContextmenu', () => {
const { store } = renderSelectionMenu({ nodes })
act(() => {
store.setState({ selectionMenu: { left: 100, top: 100 } })
store.setState({ selectionMenu: { clientX: 100, clientY: 100 } })
})
fireEvent.click(screen.getByTestId('selection-contextmenu-item-left'))
@@ -29,7 +29,7 @@ describe('usePanelInteractions', () => {
const { result, store } = renderWorkflowHook(() => usePanelInteractions(), {
initialStoreState: {
nodeMenu: { top: 20, left: 40, nodeId: 'n1' },
selectionMenu: { top: 30, left: 50 },
selectionMenu: { clientX: 30, clientY: 50 },
edgeMenu: { clientX: 320, clientY: 180, edgeId: 'e1' },
},
})
@@ -200,8 +200,8 @@ describe('useSelectionInteractions', () => {
})
expect(store.getState().selectionMenu).toEqual({
top: 150,
left: 200,
clientX: 300,
clientY: 200,
})
expect(store.getState().nodeMenu).toBeUndefined()
expect(store.getState().panelMenu).toBeUndefined()
@@ -210,7 +210,7 @@ describe('useSelectionInteractions', () => {
it('handleSelectionContextmenuCancel should clear selectionMenu', () => {
const { result, store } = renderSelectionInteractions({
selectionMenu: { top: 50, left: 60 },
selectionMenu: { clientX: 50, clientY: 60 },
})
act(() => {
@@ -137,15 +137,13 @@ export const useSelectionInteractions = () => {
return
e.preventDefault()
const container = document.querySelector('#workflow-container')
const { x, y } = container!.getBoundingClientRect()
workflowStore.setState({
nodeMenu: undefined,
panelMenu: undefined,
edgeMenu: undefined,
selectionMenu: {
top: e.clientY - y,
left: e.clientX - x,
clientX: e.clientX,
clientY: e.clientY,
},
})
}, [workflowStore])
@@ -1,13 +1,14 @@
import type { CommonNodeType } from '../types'
export enum NoteTheme {
blue = 'blue',
cyan = 'cyan',
green = 'green',
yellow = 'yellow',
pink = 'pink',
violet = 'violet',
}
export const NoteTheme = {
blue: 'blue',
cyan: 'cyan',
green: 'green',
yellow: 'yellow',
pink: 'pink',
violet: 'violet',
} as const
export type NoteTheme = typeof NoteTheme[keyof typeof NoteTheme]
export type NoteNodeType = CommonNodeType & {
text: string
@@ -1,13 +1,4 @@
import type { ComponentType } from 'react'
import type { Node } from './types'
import {
RiAlignBottom,
RiAlignCenter,
RiAlignJustify,
RiAlignLeft,
RiAlignRight,
RiAlignTop,
} from '@remixicon/react'
import { produce } from 'immer'
import {
memo,
@@ -24,7 +15,6 @@ import {
ContextMenuGroupLabel,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from '@/app/components/base/ui/context-menu'
import { useNodesReadOnly, useNodesSyncDraft } from './hooks'
import { useSelectionInteractions } from './hooks/use-selection-interactions'
@@ -44,13 +34,6 @@ const AlignType = {
type AlignTypeValue = (typeof AlignType)[keyof typeof AlignType]
type SelectionMenuPosition = {
left: number
top: number
}
type ContainerRect = Pick<DOMRect, 'width' | 'height'>
type AlignBounds = {
minX: number
maxX: number
@@ -60,7 +43,7 @@ type AlignBounds = {
type MenuItem = {
alignType: AlignTypeValue
icon: ComponentType<{ className?: string }>
icon: string
iconClassName?: string
translationKey: string
}
@@ -70,53 +53,27 @@ type MenuSection = {
items: MenuItem[]
}
const MENU_WIDTH = 240
const MENU_HEIGHT = 380
const menuSections: MenuSection[] = [
{
titleKey: 'operator.vertical',
items: [
{ alignType: AlignType.Top, icon: RiAlignTop, translationKey: 'operator.alignTop' },
{ alignType: AlignType.Middle, icon: RiAlignCenter, iconClassName: 'rotate-90', translationKey: 'operator.alignMiddle' },
{ alignType: AlignType.Bottom, icon: RiAlignBottom, translationKey: 'operator.alignBottom' },
{ alignType: AlignType.DistributeVertical, icon: RiAlignJustify, iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' },
{ alignType: AlignType.Top, icon: 'i-ri-align-top', translationKey: 'operator.alignTop' },
{ alignType: AlignType.Middle, icon: 'i-ri-align-center', iconClassName: 'rotate-90', translationKey: 'operator.alignMiddle' },
{ alignType: AlignType.Bottom, icon: 'i-ri-align-bottom', translationKey: 'operator.alignBottom' },
{ alignType: AlignType.DistributeVertical, icon: 'i-ri-align-justify', iconClassName: 'rotate-90', translationKey: 'operator.distributeVertical' },
],
},
{
titleKey: 'operator.horizontal',
items: [
{ alignType: AlignType.Left, icon: RiAlignLeft, translationKey: 'operator.alignLeft' },
{ alignType: AlignType.Center, icon: RiAlignCenter, translationKey: 'operator.alignCenter' },
{ alignType: AlignType.Right, icon: RiAlignRight, translationKey: 'operator.alignRight' },
{ alignType: AlignType.DistributeHorizontal, icon: RiAlignJustify, translationKey: 'operator.distributeHorizontal' },
{ alignType: AlignType.Left, icon: 'i-ri-align-left', translationKey: 'operator.alignLeft' },
{ alignType: AlignType.Center, icon: 'i-ri-align-center', translationKey: 'operator.alignCenter' },
{ alignType: AlignType.Right, icon: 'i-ri-align-right', translationKey: 'operator.alignRight' },
{ alignType: AlignType.DistributeHorizontal, icon: 'i-ri-align-justify', translationKey: 'operator.distributeHorizontal' },
],
},
]
const getMenuPosition = (
selectionMenu: SelectionMenuPosition | undefined,
containerRect?: ContainerRect | null,
) => {
if (!selectionMenu)
return { left: 0, top: 0 }
let { left, top } = selectionMenu
if (containerRect) {
if (left + MENU_WIDTH > containerRect.width)
left = left - MENU_WIDTH
if (top + MENU_HEIGHT > containerRect.height)
top = top - MENU_HEIGHT
left = Math.max(0, left)
top = Math.max(0, top)
}
return { left, top }
}
const getAlignableNodes = (nodes: Node[], selectedNodes: Node[]) => {
const selectedNodeIds = new Set(selectedNodes.map(node => node.id))
const childNodeIds = new Set<string>()
@@ -275,9 +232,18 @@ const SelectionContextmenu = () => {
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const { saveStateToHistory } = useWorkflowHistory()
const menuPosition = useMemo(() => {
const container = document.querySelector('#workflow-container')
return getMenuPosition(selectionMenu, container?.getBoundingClientRect())
const anchor = useMemo(() => {
if (!selectionMenu)
return undefined
return {
getBoundingClientRect: () => DOMRect.fromRect({
width: 0,
height: 0,
x: selectionMenu.clientX,
y: selectionMenu.clientY,
}),
}
}, [selectionMenu])
useEffect(() => {
@@ -352,49 +318,39 @@ const SelectionContextmenu = () => {
return null
return (
<div
className="absolute z-[9]"
data-testid="selection-contextmenu"
style={{
left: menuPosition.left,
top: menuPosition.top,
<ContextMenu
open
onOpenChange={(open) => {
if (!open)
handleSelectionContextmenuCancel()
}}
>
<ContextMenu
open
onOpenChange={(open) => {
if (!open)
handleSelectionContextmenuCancel()
}}
<ContextMenuContent
popupClassName="w-[240px]"
positionerProps={anchor ? { anchor } : undefined}
>
<ContextMenuTrigger>
<span aria-hidden className="block size-px opacity-0" />
</ContextMenuTrigger>
<ContextMenuContent popupClassName="w-[240px]">
{menuSections.map((section, sectionIndex) => (
<ContextMenuGroup key={section.titleKey}>
{sectionIndex > 0 && <ContextMenuSeparator />}
<ContextMenuGroupLabel>
{t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })}
</ContextMenuGroupLabel>
{section.items.map((item) => {
const Icon = item.icon
return (
<ContextMenuItem
key={item.alignType}
data-testid={`selection-contextmenu-item-${item.alignType}`}
onClick={() => handleAlignNodes(item.alignType)}
>
<Icon className={`h-4 w-4 ${item.iconClassName ?? ''}`.trim()} />
{t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })}
</ContextMenuItem>
)
})}
</ContextMenuGroup>
))}
</ContextMenuContent>
</ContextMenu>
</div>
{menuSections.map((section, sectionIndex) => (
<ContextMenuGroup key={section.titleKey}>
{sectionIndex > 0 && <ContextMenuSeparator />}
<ContextMenuGroupLabel>
{t(section.titleKey, { defaultValue: section.titleKey, ns: 'workflow' })}
</ContextMenuGroupLabel>
{section.items.map((item) => {
return (
<ContextMenuItem
key={item.alignType}
data-testid={`selection-contextmenu-item-${item.alignType}`}
onClick={() => handleAlignNodes(item.alignType)}
>
<span aria-hidden className={`${item.icon} h-4 w-4 ${item.iconClassName ?? ''}`.trim()} />
{t(item.translationKey, { defaultValue: item.translationKey, ns: 'workflow' })}
</ContextMenuItem>
)
})}
</ContextMenuGroup>
))}
</ContextMenuContent>
</ContextMenu>
)
}
@@ -96,7 +96,7 @@ describe('createWorkflowStore', () => {
['showInputsPanel', 'setShowInputsPanel', true],
['showDebugAndPreviewPanel', 'setShowDebugAndPreviewPanel', true],
['panelMenu', 'setPanelMenu', { top: 10, left: 20 }],
['selectionMenu', 'setSelectionMenu', { top: 50, left: 60 }],
['selectionMenu', 'setSelectionMenu', { clientX: 50, clientY: 60 }],
['edgeMenu', 'setEdgeMenu', { clientX: 320, clientY: 180, edgeId: 'e1' }],
['showVariableInspectPanel', 'setShowVariableInspectPanel', true],
['initShowLastRunTab', 'setInitShowLastRunTab', true],
@@ -16,8 +16,8 @@ export type PanelSliceShape = {
}
setPanelMenu: (panelMenu: PanelSliceShape['panelMenu']) => void
selectionMenu?: {
top: number
left: number
clientX: number
clientY: number
}
setSelectionMenu: (selectionMenu: PanelSliceShape['selectionMenu']) => void
edgeMenu?: {
@@ -174,7 +174,7 @@ const Right = ({
{currentNodeVar?.var && (
<>
{
[VarInInspectType.environment, VarInInspectType.conversation, VarInInspectType.system].includes(currentNodeVar.nodeType as VarInInspectType) && (
([VarInInspectType.environment, VarInInspectType.conversation, VarInInspectType.system] as VarInInspectType[]).includes(currentNodeVar.nodeType as VarInInspectType) && (
<VariableIconWithColor
variableCategory={currentNodeVar.nodeType as VarInInspectType}
className="size-4"
@@ -2,12 +2,14 @@ export const EVENT_WORKFLOW_STOP = 'WORKFLOW_STOP'
export const CHUNK_SCHEMA_TYPES = ['general_structure', 'parent_child_structure', 'qa_structure']
export enum ViewMode {
Code = 'code',
Preview = 'preview',
}
export const ViewMode = {
Code: 'code',
Preview: 'preview',
} as const
export type ViewMode = typeof ViewMode[keyof typeof ViewMode]
export enum PreviewType {
Markdown = 'markdown',
Chunks = 'chunks',
}
export const PreviewType = {
Markdown: 'markdown',
Chunks: 'chunks',
} as const
export type PreviewType = typeof PreviewType[keyof typeof PreviewType]
@@ -1,37 +0,0 @@
import type { NextRequest } from '@/next/server'
import { Octokit } from '@octokit/core'
import { RequestError } from '@octokit/request-error'
import { GITHUB_ACCESS_TOKEN } from '@/config'
import { NextResponse } from '@/next/server'
type Params = {
owner: string
repo: string
}
const octokit = new Octokit({
auth: GITHUB_ACCESS_TOKEN,
})
export async function GET(
request: NextRequest,
{ params }: { params: Promise<Params> },
) {
const { owner, repo } = (await params)
try {
const releasesRes = await octokit.request('GET /repos/{owner}/{repo}/releases', {
owner,
repo,
headers: {
'X-GitHub-Api-Version': '2022-11-28',
},
})
return NextResponse.json(releasesRes)
}
catch (error) {
if (error instanceof RequestError)
return NextResponse.json(error.response)
else
throw error
}
}
-3
View File
@@ -292,9 +292,6 @@ export const resetHITLInputReg = () => HITL_INPUT_REG.lastIndex = 0
export const DISABLE_UPLOAD_IMAGE_AS_ICON = env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON
export const GITHUB_ACCESS_TOKEN
= env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN
export const SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS = '.difypkg,.difybndl'
export const FULL_DOC_PREVIEW_LENGTH = 50
-10
View File
@@ -283,16 +283,6 @@ Reserve snapshots for static, deterministic fragments (icons, badges, layout chr
**Note**: Dify is a desktop application. **No need for** responsive/mobile testing.
### 12. Mock API
Use Nock to mock API calls. Example:
```ts
const mockGithubStar = (status: number, body: Record<string, unknown>, delayMs = 0) => {
return nock(GITHUB_HOST).get(GITHUB_PATH).delay(delayMs).reply(status, body)
}
```
## Code Style
### Example Structure
-5
View File
@@ -66,10 +66,6 @@ const clientSchema = {
NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL: coercedBoolean.default(true),
NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER: coercedBoolean.default(true),
NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL: coercedBoolean.default(false),
/**
* Github Access Token, used for invoking Github API
*/
NEXT_PUBLIC_GITHUB_ACCESS_TOKEN: z.string().optional(),
/**
* The maximum number of tokens for segmentation
*/
@@ -171,7 +167,6 @@ export const env = createEnv({
NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL : getRuntimeEnvFromBody('enableWebsiteFirecrawl'),
NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER : getRuntimeEnvFromBody('enableWebsiteJinareader'),
NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL : getRuntimeEnvFromBody('enableWebsiteWatercrawl'),
NEXT_PUBLIC_GITHUB_ACCESS_TOKEN: isServer ? process.env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN : getRuntimeEnvFromBody('githubAccessToken'),
NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: isServer ? process.env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH : getRuntimeEnvFromBody('indexingMaxSegmentationTokensLength'),
NEXT_PUBLIC_IS_MARKETPLACE: isServer ? process.env.NEXT_PUBLIC_IS_MARKETPLACE : getRuntimeEnvFromBody('isMarketplace'),
NEXT_PUBLIC_LOOP_NODE_MAX_COUNT: isServer ? process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT : getRuntimeEnvFromBody('loopNodeMaxCount'),
File diff suppressed because it is too large Load Diff
+19 -3
View File
@@ -7,6 +7,7 @@ import md from 'eslint-markdown'
import tailwindcss from 'eslint-plugin-better-tailwindcss'
import hyoban from 'eslint-plugin-hyoban'
import markdownPreferences from 'eslint-plugin-markdown-preferences'
import noBarrelFiles from 'eslint-plugin-no-barrel-files'
import { reactRefresh } from 'eslint-plugin-react-refresh'
import sonar from 'eslint-plugin-sonarjs'
import storybook from 'eslint-plugin-storybook'
@@ -30,12 +31,17 @@ const plugins = pluginReact.configs.all.plugins
export default antfu(
{
react: false,
nextjs: true,
nextjs: {
overrides: {
'next/no-img-element': 'off',
},
},
ignores: ['public', 'types/doc-paths.ts', 'eslint-suppressions.json'],
typescript: {
overrides: {
'ts/consistent-type-definitions': ['error', 'type'],
'ts/no-explicit-any': 'error',
'ts/no-redeclare': 'off',
},
erasableOnly: true,
},
@@ -66,12 +72,23 @@ export default antfu(
...pluginReact.configs['recommended-typescript'].rules,
'react/prefer-namespace-import': 'error',
'react/set-state-in-effect': 'error',
'react/no-unnecessary-use-prefix': 'error',
},
},
{
files: [...GLOB_TESTS, GLOB_MARKDOWN_CODE, 'vitest.setup.ts', 'test/i18n-mock.ts'],
rules: {
'react/component-hook-factories': 'off',
'react/no-unnecessary-use-prefix': 'off',
},
},
{
plugins: {
'no-barrel-files': noBarrelFiles,
},
ignores: ['next/**'],
rules: {
'no-barrel-files/no-barrel-files': 'error',
},
},
reactRefresh.configs.next(),
@@ -98,7 +115,6 @@ export default antfu(
{
rules: {
'node/prefer-global/process': 'off',
'next/no-img-element': 'off',
},
},
{
@@ -160,7 +176,7 @@ export default antfu(
},
},
{
files: ['**/package.json'],
files: ['package.json'],
rules: {
'hyoban/no-dependency-version-prefix': 'error',
},
-4
View File
@@ -220,10 +220,6 @@ export type DataSources = {
sources: DataSourceItem[]
}
export type GithubRepo = {
stargazers_count: number
}
export type PluginProvider = {
tool_name: string
is_enabled: boolean
-2
View File
@@ -1,2 +0,0 @@
export { NextResponse } from 'next/server'
export type { NextRequest } from 'next/server'
+5 -3
View File
@@ -74,8 +74,6 @@
"@lexical/text": "0.42.0",
"@lexical/utils": "0.42.0",
"@monaco-editor/react": "4.7.0",
"@octokit/core": "7.0.6",
"@octokit/request-error": "7.1.0",
"@orpc/client": "1.13.9",
"@orpc/contract": "1.13.9",
"@orpc/openapi-client": "1.13.9",
@@ -217,6 +215,7 @@
"eslint-plugin-better-tailwindcss": "4.3.2",
"eslint-plugin-hyoban": "0.14.1",
"eslint-plugin-markdown-preferences": "0.40.3",
"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",
@@ -228,7 +227,6 @@
"jsdom-testing-mocks": "1.16.0",
"knip": "6.0.2",
"lint-staged": "16.4.0",
"nock": "14.0.11",
"postcss": "8.5.8",
"postcss-js": "5.1.0",
"react-server-dom-webpack": "19.2.4",
@@ -278,6 +276,8 @@
"object.values": "npm:@nolyfill/object.values@^1.0.44",
"pbkdf2": "~3.1.5",
"pbkdf2@<3.1.3": "3.1.3",
"picomatch@<2.3.2": "2.3.2",
"picomatch@>=4.0.0 <4.0.4": "4.0.4",
"prismjs": "~1.30",
"prismjs@<1.30.0": "1.30.0",
"rollup@>=4.0.0 <4.59.0": "4.59.0",
@@ -285,6 +285,7 @@
"safe-regex-test": "npm:@nolyfill/safe-regex-test@^1.0.44",
"safer-buffer": "npm:@nolyfill/safer-buffer@^1.0.44",
"side-channel": "npm:@nolyfill/side-channel@^1.0.44",
"smol-toml@<1.6.1": "1.6.1",
"solid-js": "1.9.11",
"string-width": "~8.2.0",
"string.prototype.includes": "npm:@nolyfill/string.prototype.includes@^1.0.44",
@@ -298,6 +299,7 @@
"vite": "npm:@voidzero-dev/vite-plus-core@0.1.13",
"vitest": "npm:@voidzero-dev/vite-plus-test@0.1.13",
"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"
},
"ignoredBuiltDependencies": [
+142 -292
View File
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -1,9 +1,11 @@
import type { NextRequest } from '@/next/server'
// eslint-disable-next-line no-restricted-imports
import type { NextRequest } from 'next/server'
import { Buffer } from 'node:buffer'
// eslint-disable-next-line no-restricted-imports
import { NextResponse } from 'next/server'
import { env } from '@/env'
import { NextResponse } from '@/next/server'
const NECESSARY_DOMAIN = '*.sentry.io http://localhost:* http://127.0.0.1:* https://analytics.google.com googletagmanager.com *.googletagmanager.com https://www.google-analytics.com https://api.github.com https://api2.amplitude.com *.amplitude.com'
const NECESSARY_DOMAIN = '*.sentry.io http://localhost:* http://127.0.0.1:* https://analytics.google.com googletagmanager.com *.googletagmanager.com https://www.google-analytics.com https://ungh.cc https://api2.amplitude.com *.amplitude.com'
const wrapResponseWithXFrameOptions = (response: NextResponse, pathname: string) => {
// prevent clickjacking: https://owasp.org/www-community/attacks/Clickjacking
+1 -1
View File
@@ -372,7 +372,7 @@ export const submitDeleteAccountFeedback = (body: { feedback: string, email: str
export const getDocDownloadUrl = (doc_name: string): Promise<{ url: string }> =>
get<{ url: string }>('/compliance/download', { params: { doc_name } }, { silent: true })
export const sendVerifyCode = (body: { email: string, phase: string, token?: string }): Promise<CommonResponse & { data: string }> =>
export const sendVerifyCode = (body: { email: string, token?: string }): Promise<CommonResponse & { data: string }> =>
post<CommonResponse & { data: string }>('/account/change-email', { body })
export const verifyEmail = (body: { email: string, code: string, token: string }): Promise<CommonResponse & { is_valid: boolean, email: string, token: string }> =>
+10 -9
View File
@@ -2,12 +2,13 @@
* Model provider quota types - shared type definitions for API responses
* These represent the provider identifiers that support paid/trial quotas
*/
export enum ModelProviderQuotaGetPaid {
ANTHROPIC = 'langgenius/anthropic/anthropic',
OPENAI = 'langgenius/openai/openai',
// AZURE_OPENAI = 'langgenius/azure_openai/azure_openai',
GEMINI = 'langgenius/gemini/google',
X = 'langgenius/x/x',
DEEPSEEK = 'langgenius/deepseek/deepseek',
TONGYI = 'langgenius/tongyi/tongyi',
}
export const ModelProviderQuotaGetPaid = {
ANTHROPIC: 'langgenius/anthropic/anthropic',
OPENAI: 'langgenius/openai/openai',
// AZURE_OPENAI: 'langgenius/azure_openai/azure_openai',
GEMINI: 'langgenius/gemini/google',
X: 'langgenius/x/x',
DEEPSEEK: 'langgenius/deepseek/deepseek',
TONGYI: 'langgenius/tongyi/tongyi',
} as const
export type ModelProviderQuotaGetPaid = typeof ModelProviderQuotaGetPaid[keyof typeof ModelProviderQuotaGetPaid]
+7 -6
View File
@@ -455,12 +455,13 @@ export type PanelProps = {
export type NodeRunResult = NodeTracing
// Var Inspect
export enum VarInInspectType {
conversation = 'conversation',
environment = 'env',
node = 'node',
system = 'sys',
}
export const VarInInspectType = {
conversation: 'conversation',
environment: 'env',
node: 'node',
system: 'sys',
} as const
export type VarInInspectType = typeof VarInInspectType[keyof typeof VarInInspectType]
export type FullContent = {
size_bytes: number